Add basic terminal user interface
This commit is contained in:
parent
bf4e19378d
commit
5956331a28
9 changed files with 878 additions and 0 deletions
16
go.mod
16
go.mod
|
|
@ -1,3 +1,19 @@
|
|||
module suruatoel.xyz/timefiles
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.9.0
|
||||
github.com/rivo/tview v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
)
|
||||
|
|
|
|||
51
go.sum
Normal file
51
go.sum
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.9.0 h1:N6t+eqK7/xwtRPwxzs1PXeRWnm0H9l02CrgJ7DLn1ys=
|
||||
github.com/gdamore/tcell/v2 v2.9.0/go.mod h1:8/ZoqM9rxzYphT9tH/9LnunhV9oPBqwS8WHGYm5nrmo=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
29
main.go
Normal file
29
main.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"suruatoel.xyz/timefiles/lib"
|
||||
"suruatoel.xyz/timefiles/tui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
slog.Info(fmt.Sprintf("Set data directory to: %s", os.Args[1]))
|
||||
lib.SetDataDir(os.Args[1])
|
||||
}
|
||||
|
||||
tui, appErr := tui.NewTUI()
|
||||
if appErr != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to initialize application: %s", appErr))
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
runErr := tui.Run()
|
||||
if runErr != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to run application: %s", runErr))
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
275
tui/day.go
Normal file
275
tui/day.go
Normal 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
78
tui/mainscreen.go
Normal 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
115
tui/modal.go
Normal 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
37
tui/statusbar.go
Normal 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
163
tui/tabs.go
Normal 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
114
tui/tui.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue