Add basic terminal user interface

This commit is contained in:
Olli 2025-10-16 07:59:16 +02:00
commit 5956331a28
9 changed files with 878 additions and 0 deletions

275
tui/day.go Normal file
View file

@ -0,0 +1,275 @@
package tui
import (
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/lib"
)
const (
PageNameTimer = "set"
)
type DayUI struct {
*tview.Pages
app *TUI
summaryList *tview.Table
totalsList *tview.Table
trackButtons *tview.Flex
startButton *tview.Button
stopButton *tview.Button
setButton *tview.Button
timerModal *ProActModal
currentTimer *lib.Time
summaryCount int
}
func NewDayUI(app *TUI) *DayUI {
ui := DayUI{
Pages: tview.NewPages(),
app: app,
}
main := tview.NewFlex().SetDirection(tview.FlexRow)
ui.AddPage("main", main, true, true)
summary := tview.NewFlex().SetDirection(tview.FlexColumn)
main.AddItem(summary, 0, 1, false)
ui.summaryList = tview.NewTable()
ui.summaryList.SetBorder(true).SetTitle("List")
ui.summaryList.SetSelectable(true, false)
summary.AddItem(ui.summaryList, 0, 1, false)
ui.totalsList = tview.NewTable()
ui.totalsList.SetBorder(true).SetTitle(" Totals ")
summary.AddItem(ui.totalsList, 0, 1, false)
track := tview.NewFlex().SetDirection(tview.FlexRow)
track.SetBorder(true).SetTitle(" Track ")
main.AddItem(track, 3, 0, true)
ui.trackButtons = tview.NewFlex().SetDirection(tview.FlexColumn)
track.AddItem(ui.trackButtons, 1, 0, true)
ui.startButton = tview.NewButton("start (n)")
ui.trackButtons.AddItem(ui.startButton, 0, 1, false)
ui.trackButtons.AddItem(nil, 1, 0, false)
ui.stopButton = tview.NewButton("stop (r)")
ui.trackButtons.AddItem(ui.stopButton, 0, 1, false)
ui.trackButtons.AddItem(nil, 1, 0, false)
ui.setButton = tview.NewButton("set (s)")
ui.trackButtons.AddItem(ui.setButton, 0, 1, false)
ui.timerModal = NewProActModal(app, "Timer")
ui.AddPage(PageNameTimer, ui.timerModal, true, false)
ui.assignHandlers()
return &ui
}
func (ui *DayUI) SetTimer(timer *lib.Time) {
if ui.currentTimer != nil {
ui.summaryList.RemoveRow(ui.summaryCount)
}
ui.currentTimer = timer
ui.setInputStates()
if timer == nil {
ui.app.Focus(ui.startButton)
ui.timerModal.SetValues(nil, nil)
} else {
ui.timerModal.SetValues(ui.resolveTimerValues(timer.ProjectID, timer.ActivityID))
if timer.ProjectID == "" || timer.ActivityID == "" {
ui.openTimerModal()
} else {
ui.app.Focus(ui.stopButton)
}
ui.includeTimerInSummary()
}
}
func (ui *DayUI) SetSummary(summary []*lib.Time) {
ui.summaryCount = len(summary)
ui.summaryList.Clear()
for i, entry := range summary {
ui.setSummaryLine(entry, i)
}
ui.includeTimerInSummary()
}
func (ui *DayUI) SetTotals(totalTime lib.TotalTime) {
ui.totalsList.Clear()
for i, entry := range totalTime.Totals {
projectLabel := entry.Project
var projectAttrs tcell.AttrMask
activityLabel := entry.Activity
var activityAttrs tcell.AttrMask
if entry.Project == "" {
projectLabel = "not set"
projectAttrs = tcell.AttrItalic
}
if entry.Activity == "" {
activityLabel = "not set"
activityAttrs = tcell.AttrItalic
}
ui.totalsList.SetCell(i, 0, tview.NewTableCell(projectLabel).SetExpansion(1).SetAttributes(projectAttrs))
ui.totalsList.SetCell(i, 1, tview.NewTableCell(activityLabel).SetExpansion(1))
ui.totalsList.SetCell(i, 2, tview.NewTableCell(entry.Duration.Round(time.Second).String()).SetExpansion(0).SetAttributes(activityAttrs))
}
ui.totalsList.SetCell(len(totalTime.Totals), 0, tview.NewTableCell("Total").SetExpansion(1).SetAttributes(tcell.AttrBold))
ui.totalsList.SetCell(len(totalTime.Totals), 2, tview.NewTableCell(totalTime.TotalSum.Round(time.Second).String()).SetExpansion(0).SetAttributes(tcell.AttrBold))
}
func (ui *DayUI) setSummaryLine(entry *lib.Time, line int) {
projectAttrs := tcell.AttrItalic
activityAttrs := tcell.AttrItalic
projectLabel := "not set"
activityLabel := "not set"
project, activity := ui.resolveTimerValues(entry.ProjectID, entry.ActivityID)
if project != nil {
projectLabel = project.Title
projectAttrs = tcell.AttrNone
if activity != nil {
activityLabel = activity.Title
activityAttrs = tcell.AttrNone
}
}
ui.summaryList.SetCell(line, 0, tview.NewTableCell(projectLabel).SetExpansion(1).SetAttributes(projectAttrs))
ui.summaryList.SetCell(line, 1, tview.NewTableCell(activityLabel).SetExpansion(1).SetAttributes(activityAttrs))
if !entry.Start.IsZero() {
ui.summaryList.SetCell(line, 2, tview.NewTableCell(formatTime(entry.Start)).SetExpansion(0))
}
if !entry.End.IsZero() {
ui.summaryList.SetCell(line, 3, tview.NewTableCell(formatTime(entry.End)).SetExpansion(0))
}
}
func (ui *DayUI) includeTimerInSummary() {
if ui.currentTimer == nil {
return
}
ui.setSummaryLine(ui.currentTimer, ui.summaryCount)
}
func (ui *DayUI) assignHandlers() {
ui.startButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTAB:
ui.app.Focus(ui.stopButton)
return nil
}
return event
})
ui.startButton.SetSelectedFunc(func() {
ui.start()
})
ui.stopButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTAB:
ui.app.Focus(ui.setButton)
return nil
}
return event
})
ui.stopButton.SetSelectedFunc(func() {
ui.stop()
})
ui.setButton.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTAB:
ui.app.Focus(ui.summaryList)
return nil
}
return event
})
ui.setButton.SetSelectedFunc(func() {
ui.openTimerModal()
})
ui.timerModal.SetCloseFunc(func(project string, activity string, success bool) {
ui.HidePage(PageNameTimer)
if success {
ui.setDetails(project, activity)
ui.app.Focus(ui.stopButton)
} else {
ui.app.Focus(ui.setButton)
}
})
ui.trackButtons.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'n':
ui.start()
return nil
case 'r':
ui.stop()
return nil
case 's':
ui.openTimerModal()
return nil
}
return event
})
}
func (ui *DayUI) setInputStates() {
ui.stopButton.SetDisabled(ui.currentTimer == nil)
ui.setButton.SetDisabled(ui.currentTimer == nil)
}
func (ui *DayUI) start() {
ui.app.timeApp.StartTimer()
}
func (ui *DayUI) stop() {
if ui.currentTimer != nil {
ui.app.timeApp.StopTimer()
}
}
func (ui *DayUI) openTimerModal() {
if ui.currentTimer != nil {
ui.ShowPage(PageNameTimer)
ui.app.Focus(ui.timerModal)
}
}
func (ui *DayUI) setDetails(project, activity string) {
if ui.currentTimer == nil {
return
}
ui.app.timeApp.UpdateTimer(project, activity)
}
func (ui *DayUI) resolveTimerValues(projectID, activityID string) (*lib.Project, *lib.Activity) {
project := ui.app.timeApp.GetProject(projectID)
if project != nil {
activity := ui.app.timeApp.GetActivity(project, activityID)
return project, activity
}
return project, nil
}
func formatTime(t time.Time) string {
return t.Format("15:04:05")
}

78
tui/mainscreen.go Normal file
View file

@ -0,0 +1,78 @@
package tui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/lib"
)
// MainScreen represents the main screen of the user interface.
type MainScreen struct {
app *TUI
main *tview.Flex
tabs *Tabs
timesPage *DayUI
statusBar *StatusBar
help *tview.TextView
}
// NewMainScreen creates a new main screen of the user interface.
func NewMainScreen(app *TUI) (*MainScreen, error) {
ui := MainScreen{
app: app,
}
ui.tabs = NewTabs(true)
ui.timesPage = NewDayUI(app)
ui.tabs.AddTab("times", "Times", ui.timesPage, true)
ui.statusBar = NewStatusBar()
ui.main = tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(ui.tabs, 0, 1, false).
AddItem(ui.statusBar, 1, 1, false)
ui.tabs.Select("times")
app.uiApp.SetRoot(ui.main, true).EnableMouse(true).SetFocus(ui.timesPage)
ui.addShortcuts()
return &ui, nil
}
// SetStatus displays a message in the status bar.
func (ui *MainScreen) SetStatus(message string) {
ui.statusBar.SetStatus(message)
}
// SetTimer displays the current timer on the times page.
func (ui *MainScreen) SetTimer(timer *lib.Time) {
ui.timesPage.SetTimer(timer)
}
// SetSummary displays the time summary on the times page.
func (ui *MainScreen) SetSummary(summary []*lib.Time) {
ui.timesPage.SetSummary(summary)
}
// SetTotals displays the time totals on the times page.
func (ui *MainScreen) SetTotals(totalTime lib.TotalTime) {
ui.timesPage.SetTotals(totalTime)
}
// addShortcuts registers keyboard shortcuts for the main screen.
func (ui *MainScreen) addShortcuts() {
ui.main.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyCtrlN:
ui.app.Focus(ui.tabs.Next())
return nil
case tcell.KeyCtrlP:
ui.app.Focus(ui.tabs.Prev())
return nil
}
return event
})
}

115
tui/modal.go Normal file
View file

@ -0,0 +1,115 @@
package tui
import (
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/lib"
)
type ProActModal struct {
*tview.Flex
app *TUI
projectInput *tview.InputField
activityInput *tview.InputField
form *tview.Form
closeFunc func(project string, activity string, success bool)
}
func NewProActModal(app *TUI, title string) *ProActModal {
modal := ProActModal{
Flex: tview.NewFlex(),
app: app,
}
modal.projectInput = tview.NewInputField().SetLabel("Project:")
modal.activityInput = tview.NewInputField().SetLabel("Activity: ")
modal.form = tview.NewForm().
AddFormItem(modal.projectInput).
AddFormItem(modal.activityInput).
AddButton("set", func() {
if modal.closeFunc != nil {
modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), true)
}
}).
AddButton("cancel", func() {
if modal.closeFunc != nil {
modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), false)
}
}).
SetCancelFunc(func() {
if modal.closeFunc != nil {
modal.closeFunc(modal.projectInput.GetText(), modal.activityInput.GetText(), false)
}
})
modal.form.SetTitle(title).SetBorder(true).SetBorderPadding(0, 0, 0, 0)
modal.form.
SetButtonsAlign(tview.AlignCenter).
SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
SetButtonTextColor(tview.Styles.PrimaryTextColor).
SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
inner := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(modal.form, 0, 1, true)
width, height := 60, 7
modal.AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(inner, height, 1, true).
AddItem(nil, 0, 1, false), width, 1, true).
AddItem(nil, 0, 1, false)
modal.assignHandlers()
return &modal
}
func (modal *ProActModal) Focus(delegate func(p tview.Primitive)) {
modal.form.SetFocus(0)
delegate(modal.form)
}
func (modal *ProActModal) SetCloseFunc(closeFunc func(project string, activity string, success bool)) *ProActModal {
modal.closeFunc = closeFunc
return modal
}
func (modal *ProActModal) SetValues(project *lib.Project, activity *lib.Activity) {
projectTitle := ""
if project != nil {
projectTitle = project.Title
}
modal.form.GetFormItem(0).(*tview.InputField).SetText(projectTitle)
activityTitle := ""
if activity != nil {
activityTitle = activity.Title
}
modal.form.GetFormItem(1).(*tview.InputField).SetText(activityTitle)
}
func (modal *ProActModal) assignHandlers() {
modal.projectInput.SetAutocompleteFunc(func(currentText string) (entries []string) {
if len(currentText) == 0 {
return
}
for _, project := range modal.app.timeApp.SearchProjects(currentText) {
entries = append(entries, project.Title)
}
return
})
modal.activityInput.SetAutocompleteFunc(func(currentText string) (entries []string) {
project := modal.projectInput.GetText()
if project == "" || currentText == "" {
return
}
for _, activity := range modal.app.timeApp.SearchActivities(project, currentText) {
entries = append(entries, activity.Title)
}
return
})
}

37
tui/statusbar.go Normal file
View file

@ -0,0 +1,37 @@
package tui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// StatusBar represents a status bar of the user interface.
//
// status is a text view showing a status message.
type StatusBar struct {
*tview.Flex
status *tview.TextView
}
// NewStatusBar creates a new one-line status bar of the user interface.
func NewStatusBar() *StatusBar {
ui := StatusBar{
Flex: tview.NewFlex(),
}
statusLabel := tview.NewTextView().SetText("Status:")
statusLabel.SetTextColor(tcell.NewRGBColor(180, 180, 180))
ui.AddItem(statusLabel, 8, 0, false)
ui.status = tview.NewTextView()
ui.status.SetText("idle")
ui.status.SetTextColor(tcell.NewRGBColor(180, 180, 180))
ui.AddItem(ui.status, 0, 1, false)
return &ui
}
// SetStatus displays a status message.
func (ui *StatusBar) SetStatus(text string) {
ui.status.SetText(text)
}

163
tui/tabs.go Normal file
View file

@ -0,0 +1,163 @@
package tui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type TabPrimitive interface {
tview.Primitive
ForceRedraw()
}
type Tabs struct {
*tview.Flex
names []string
items map[string]tview.Primitive
selector *tview.TextView
pages *tview.Pages
fallover bool
unlock bool
}
func NewTabs(selectorAtTop bool) *Tabs {
widget := &Tabs{
Flex: tview.NewFlex(),
names: []string{},
items: make(map[string]tview.Primitive),
fallover: true,
}
widget.SetDirection(tview.FlexRow)
// Selector
widget.selector = tview.NewTextView().
SetDynamicColors(true).
SetRegions(true).
SetWrap(false).
SetTextAlign(tview.AlignLeft)
widget.selector.SetHighlightedFunc(
func(added, removed, remaining []string) {
if len(added) == 0 {
return
}
widget.pages.SwitchToPage(added[0])
},
)
if selectorAtTop {
widget.AddItem(widget.selector, 1, 0, false)
}
// Pages
widget.pages = tview.NewPages()
widget.AddItem(widget.pages, 0, 1, false)
if !selectorAtTop {
widget.AddItem(widget.selector, 1, 0, false)
}
return widget
}
func (widget *Tabs) AllowFallover(allow bool) {
widget.fallover = allow
}
func (widget *Tabs) AddText(text string) {
fmt.Fprint(widget.selector, text)
}
func (widget *Tabs) AddTab(name string, label string, item tview.Primitive, visible bool) {
widget.names = append(widget.names, name)
widget.items[name] = item
fmt.Fprintf(widget.selector, `["%s"][darkcyan]%s[white][""] `, name, label)
widget.pages.AddPage(name, item, true, false)
if visible {
widget.Select(name)
}
}
func (widget *Tabs) Select(name string) {
widget.selector.Highlight(name)
}
func (widget *Tabs) Next() tview.Primitive {
widget.unlock = true
if len(widget.names) == 0 {
return nil
}
index := -1
highlights := widget.selector.GetHighlights()
if len(highlights) > 0 {
currentName := highlights[0]
for currentIndex, name := range widget.names {
if name == currentName {
index = currentIndex
break
}
}
}
index++
if index >= len(widget.names) {
if widget.fallover {
index = 0
} else {
index = len(widget.names) - 1
}
}
widget.selector.Highlight(widget.names[index])
return widget.items[widget.names[index]]
}
func (widget *Tabs) Prev() tview.Primitive {
widget.unlock = true
if len(widget.names) == 0 {
return nil
}
index := len(widget.names)
highlights := widget.selector.GetHighlights()
if len(highlights) > 0 {
currentName := highlights[0]
for currentIndex, name := range widget.names {
if name == currentName {
index = currentIndex
break
}
}
}
index--
if index < 0 {
if widget.fallover {
index = len(widget.names) - 1
} else {
index = 0
}
}
widget.selector.Highlight(widget.names[index])
return widget.items[widget.names[index]]
}
func (widget *Tabs) Focus(delegate func(p tview.Primitive)) {
delegate(widget.pages)
}
func (widget *Tabs) Draw(screen tcell.Screen) {
if widget.unlock {
widgetX, widgetY, widgetWidth, widgetHeight := widget.GetInnerRect()
screen.LockRegion(widgetX, widgetY, widgetWidth, widgetHeight, false)
widget.unlock = false
}
widget.Flex.Draw(screen)
}

114
tui/tui.go Normal file
View file

@ -0,0 +1,114 @@
// Package tui implements a terminal user interface.
package tui
import (
"fmt"
"github.com/rivo/tview"
"suruatoel.xyz/timefiles/application"
"suruatoel.xyz/timefiles/lib"
)
// TUI represents the terminal user interface.
//
// timeApp is the application to manage the time files.
//
// uiApp is the tview application.
//
// uiScreen is the main screen of the terminal application.
type TUI struct {
timeApp *application.Application
uiApp *tview.Application
uiScreen *MainScreen
}
// NewTUI creates a new terminal user interface.
func NewTUI() (*TUI, error) {
tui := TUI{}
tui.timeApp = application.NewApplication(&tui)
tui.uiApp = tview.NewApplication()
ui, uiErr := NewMainScreen(&tui)
if uiErr != nil {
return nil, uiErr
}
tui.uiScreen = ui
return &tui, nil
}
// Run loads the initial application data and runs the terminal user interface. It will wait for the UI to finish and
// for all asynchronous jobs to be completed before returning.
func (ui *TUI) Run() error {
ui.timeApp.Load()
ui.timeApp.GetTimer()
ui.timeApp.GetTimeSummary()
ui.timeApp.GetTimeTotals()
err := ui.uiApp.Run()
ui.timeApp.Wait()
return err
}
// Focus delegates the focus request of the UI to TUI.uiApp.
func (ui *TUI) Focus(p tview.Primitive) {
ui.uiApp.SetFocus(p)
}
// SetTimerStart sets the status for updating the timer.
func (ui *TUI) SetTimerStart() {
ui.uiApp.QueueUpdateDraw(func() {
ui.uiScreen.SetStatus("Updating timer")
})
}
// SetTimer updates the UI with the timer.
func (ui *TUI) SetTimer(timer *lib.Time, err error) {
ui.uiApp.QueueUpdateDraw(func() {
if err == nil {
ui.uiScreen.SetStatus("Timer updated")
} else {
ui.uiScreen.SetStatus(fmt.Sprintf("Failed to update timer: %s", err))
}
ui.uiScreen.SetTimer(timer)
})
}
// SetSummaryStart sets the status for gathering the time summary.
func (ui *TUI) SetSummaryStart() {
ui.uiApp.QueueUpdateDraw(func() {
ui.uiScreen.SetStatus("Getting summary")
})
}
// SetSummary updates the UI with the time summary.
func (ui *TUI) SetSummary(summary []*lib.Time, err error) {
ui.uiApp.QueueUpdateDraw(func() {
if err == nil {
ui.uiScreen.SetStatus("Summary calculated")
} else {
ui.uiScreen.SetStatus(fmt.Sprintf("Failed get summary: %s", err))
}
ui.uiScreen.SetSummary(summary)
})
}
// SetTotalsStart sets the status for calculating the time totals.
func (ui *TUI) SetTotalsStart() {
ui.uiApp.QueueUpdateDraw(func() {
ui.uiScreen.SetStatus("Calculated totals")
})
}
// SetTotals updates the UI with the time totals.
func (ui *TUI) SetTotals(totalTime lib.TotalTime, err error) {
ui.uiApp.QueueUpdateDraw(func() {
if err == nil {
ui.uiScreen.SetStatus("Totals calculated")
} else {
ui.uiScreen.SetStatus(fmt.Sprintf("Failed get totals: %s", err))
}
ui.uiScreen.SetTotals(totalTime)
})
}