diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-04 10:50:07 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-04 10:50:07 +0200 |
| commit | 97aa8a6f666f5f40859c8a9aa4948bde435cf18f (patch) | |
| tree | 0cb5928cd6a1220607dbf64e234a2522acac2848 /internal/tui/tui.go | |
| parent | c25c9002f3214e07b041aefa26d5d13c26757839 (diff) | |
Rename project to timesamurai and release v0.5.0v0.5.0
Diffstat (limited to 'internal/tui/tui.go')
| -rw-r--r-- | internal/tui/tui.go | 191 |
1 files changed, 176 insertions, 15 deletions
diff --git a/internal/tui/tui.go b/internal/tui/tui.go index eaa1dd4..926c331 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,11 +1,13 @@ package tui import ( + "fmt" "strings" "time" - "codeberg.org/snonux/timr/internal/config" - "codeberg.org/snonux/timr/internal/worktime" + timesamurai "codeberg.org/snonux/timesamurai/internal" + "codeberg.org/snonux/timesamurai/internal/config" + "codeberg.org/snonux/timesamurai/internal/worktime" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -35,11 +37,14 @@ type Model struct { width int height int - showHelp bool - pendingG bool - pendingZ bool + showHelp bool + confirmQuit bool + pendingG bool + pendingZ bool styles Styles + theme Theme + disco bool entries EntriesModel report ReportModel @@ -59,9 +64,17 @@ func NewModel() *Model { // NewModelWithConfig creates a data-backed root model from config. func NewModelWithConfig(cfg config.Config) (*Model, error) { + return NewModelWithConfigAndDisco(cfg, false) +} + +// NewModelWithConfigAndDisco creates a data-backed root model and optionally enables disco mode. +func NewModelWithConfigAndDisco(cfg config.Config, disco bool) (*Model, error) { + theme := DefaultTheme() model := &Model{ activeTab: tabEntries, - styles: DefaultStyles(), + styles: StylesFromTheme(theme), + theme: theme, + disco: disco, entries: NewEntriesModel(nil), report: NewReportModel(nil), } @@ -70,14 +83,21 @@ func NewModelWithConfig(cfg config.Config) (*Model, error) { if err != nil { model.entriesErr = err.Error() } else { + host, hostErr := cfg.EffectiveHostname() + if hostErr != nil { + host = strings.TrimSpace(cfg.Hostname) + } model.entries.SetEntries(entries) - model.entries.SetPersistence(cfg.WorktimeDBDir) + model.entries.SetPersistence(cfg.WorktimeDBDir, host) weeks, reportErr := worktime.BuildReport(entries, cfg) if reportErr != nil { model.reportErr = reportErr.Error() } else { model.report.SetWeeks(weeks) } + if warning := reportOpenSessionWarning(entries); warning != "" { + model.report.SetWarning(warning) + } } timerModel, timerErr := NewTimerModel("doom", cfg) @@ -87,6 +107,10 @@ func NewModelWithConfig(cfg config.Config) (*Model, error) { model.timer = timerModel } + if model.disco { + model.randomizeTheme() + } + return model, nil } @@ -114,10 +138,31 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: key := msg.String() + if m.confirmQuit { + switch key { + case "s": + if err := m.entries.savePendingChanges(); err != nil { + m.entries.setStatusError("Save failed: " + err.Error()) + m.confirmQuit = false + return m, nil + } + m.confirmQuit = false + return m, tea.Quit + case "d", "n": + m.confirmQuit = false + return m, tea.Quit + case "esc": + m.confirmQuit = false + return m, nil + default: + return m, nil + } + } + if m.pendingZ { m.pendingZ = false if key == "Q" { - return m, tea.Quit + return m.requestQuit() } } @@ -140,9 +185,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.switchTab(tabReport) case "3": return m, m.switchTab(tabTimer) - case "?": + case "?", "H": m.showHelp = !m.showHelp return m, nil + case "esc": + if m.showHelp { + m.showHelp = false + return m, nil + } + case "c": + m.randomizeTheme() + return m, nil + case "C": + m.resetTheme() + return m, nil + case "x": + m.disco = !m.disco + if m.disco { + m.randomizeTheme() + } + return m, nil case "g": m.pendingG = true return m, nil @@ -150,7 +212,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingZ = true return m, nil case "q", "ctrl+c": - return m, tea.Quit + return m.requestQuit() } } @@ -161,13 +223,39 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) View() string { header := m.renderTabs() body := m.renderBody() + status := m.renderStatusLine() + + if m.confirmQuit { + body = m.styles.Help.Render(strings.Join([]string{ + "Unsaved entry changes detected.", + "", + "Save before quitting?", + "", + "s : save and quit", + "d : discard changes and quit", + "Esc : cancel", + }, "\n")) + } - help := m.styles.Hint.Render("Press ? for help") - if m.showHelp { - help = m.styles.Help.Render("Tab/gt/gT/1/2/3 switch tabs, ? toggles help, q or ZQ quits") + if !m.confirmQuit && m.showHelp { + body = m.styles.Help.Render(strings.Join([]string{ + "Global keys", + "", + "Tab / gt / gT / 1 / 2 / 3 : switch tabs", + "? / H : toggle help", + "c / C : random/reset theme", + "x : toggle disco mode", + "q / ZQ : quit", + "", + "Entries", + "", + "j/k rows, h/l columns, Enter edit selected cell", + "/ search, f category filter, e/v quick edit, s save, dd delete entry", + "D day-off datepicker (8h off entry)", + }, "\n")) } - content := lipgloss.JoinVertical(lipgloss.Left, header, body, help) + content := lipgloss.JoinVertical(lipgloss.Left, header, body, status) rendered := m.styles.App.Render(content) if m.width > 0 && m.height > 0 { @@ -190,6 +278,7 @@ func (m *Model) prevTab() tea.Cmd { func (m *Model) renderTabs() string { parts := make([]string, 0, len(tabLabels)) + parts = append(parts, lipgloss.NewStyle().Bold(true).Render("timesamurai "+timesamurai.Version)) for idx, label := range tabLabels { if tab(idx) == m.activeTab { parts = append(parts, m.styles.ActiveTab.Render(label)) @@ -197,7 +286,14 @@ func (m *Model) renderTabs() string { } parts = append(parts, m.styles.Tab.Render(label)) } - return m.styles.Header.Render(strings.Join(parts, " ")) + if m.disco { + parts = append(parts, m.styles.ActiveTab.Render("DISCO")) + } + if m.entries.hasUnsavedChanges() { + parts = append(parts, m.styles.ActiveTab.Render("UNSAVED")) + } + line := strings.Join(parts, " ") + return m.styles.Header.Width(m.statusWidth()).Render(line) } func (m *Model) renderBody() string { @@ -261,8 +357,12 @@ func (m *Model) bodySize() (width int, height int) { func (m *Model) updateActiveTab(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.activeTab { case tabEntries: + beforeMutations := m.entries.mutationCount updated, cmd := m.entries.Update(msg) m.entries = updated + if m.disco && m.entries.mutationCount != beforeMutations { + m.randomizeTheme() + } return m, cmd case tabReport: updated, cmd := m.report.Update(msg) @@ -290,3 +390,64 @@ func (m *Model) startRootTimerTicker() tea.Cmd { m.timerTickScheduled = true return rootTimerTick() } + +func (m *Model) randomizeTheme() { + m.theme = RandomTheme() + m.styles = StylesFromTheme(m.theme) +} + +func (m *Model) resetTheme() { + m.theme = DefaultTheme() + m.styles = StylesFromTheme(m.theme) +} + +func (m *Model) renderStatusLine() string { + status := fmt.Sprintf( + "Entries timeline table | unsaved:%t | disco:%t | H help | c/C theme | x disco | q quit", + m.entries.hasUnsavedChanges(), + m.disco, + ) + if m.showHelp { + status = "Help mode active (press H or ? to close)" + } + if m.confirmQuit { + status = "Unsaved changes: s save+quit, d discard+quit, Esc cancel" + } + + return m.styles.Status.Width(m.statusWidth()).Render(status) +} + +func (m *Model) statusWidth() int { + if m.width <= 0 { + return 80 + } + if m.width <= 2 { + return m.width + } + return m.width - 2 +} + +func (m *Model) requestQuit() (tea.Model, tea.Cmd) { + if m.entries.hasUnsavedChanges() { + m.confirmQuit = true + return m, nil + } + return m, tea.Quit +} + +func reportOpenSessionWarning(entries []worktime.Entry) string { + openSessions := worktime.OpenSessions(entries) + if len(openSessions) == 0 { + return "" + } + + items := make([]string, 0, len(openSessions)) + for _, session := range openSessions { + items = append(items, fmt.Sprintf( + "%s (since %s)", + session.Category, + time.Unix(session.Login.Epoch, 0).Format("2006-01-02 15:04"), + )) + } + return "currently logged in: " + strings.Join(items, ", ") +} |
