summaryrefslogtreecommitdiff
path: root/internal/tui/tui.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-04 10:50:07 +0200
committerPaul Buetow <paul@buetow.org>2026-03-04 10:50:07 +0200
commit97aa8a6f666f5f40859c8a9aa4948bde435cf18f (patch)
tree0cb5928cd6a1220607dbf64e234a2522acac2848 /internal/tui/tui.go
parentc25c9002f3214e07b041aefa26d5d13c26757839 (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.go191
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, ", ")
+}