summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:55:33 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:55:33 +0200
commit6437f0a5f6a4a114540092efe50f62270070afb0 (patch)
tree3b610d656f77b2cbeb1a46608304a424926180ec
parent4aa4fe858f64da07046f00ccb926bb72d6b68070 (diff)
Task 352: add TUI root scaffold
-rw-r--r--internal/tui/styles.go41
-rw-r--r--internal/tui/tui.go154
-rw-r--r--internal/tui/tui_test.go79
3 files changed, 274 insertions, 0 deletions
diff --git a/internal/tui/styles.go b/internal/tui/styles.go
new file mode 100644
index 0000000..247411c
--- /dev/null
+++ b/internal/tui/styles.go
@@ -0,0 +1,41 @@
+package tui
+
+import "github.com/charmbracelet/lipgloss"
+
+// Styles groups visual styles for the root TUI scaffold.
+type Styles struct {
+ App lipgloss.Style
+ Header lipgloss.Style
+ Tab lipgloss.Style
+ ActiveTab lipgloss.Style
+ Body lipgloss.Style
+ Help lipgloss.Style
+ Hint lipgloss.Style
+}
+
+// DefaultStyles returns the default style set.
+func DefaultStyles() Styles {
+ return Styles{
+ App: lipgloss.NewStyle().
+ Padding(1, 2),
+ Header: lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#8A8A8A")).
+ Bold(true),
+ Tab: lipgloss.NewStyle().
+ Padding(0, 1).
+ Foreground(lipgloss.Color("#B5B5B5")),
+ ActiveTab: lipgloss.NewStyle().
+ Padding(0, 1).
+ Foreground(lipgloss.Color("#101010")).
+ Background(lipgloss.Color("#8BD3DD")).
+ Bold(true),
+ Body: lipgloss.NewStyle().
+ PaddingTop(1),
+ Help: lipgloss.NewStyle().
+ PaddingTop(1).
+ Foreground(lipgloss.Color("#A0D568")),
+ Hint: lipgloss.NewStyle().
+ PaddingTop(1).
+ Foreground(lipgloss.Color("#777777")),
+ }
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
new file mode 100644
index 0000000..51445f2
--- /dev/null
+++ b/internal/tui/tui.go
@@ -0,0 +1,154 @@
+package tui
+
+import (
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type tab int
+
+const (
+ tabEntries tab = iota
+ tabReport
+ tabTimer
+ tabCount
+)
+
+var tabLabels = []string{"Entries", "Report", "Timer"}
+
+// Model is the root TUI scaffold model.
+type Model struct {
+ activeTab tab
+ width int
+ height int
+
+ showHelp bool
+ pendingG bool
+ pendingZ bool
+
+ styles Styles
+}
+
+// NewModel creates a new root TUI model.
+func NewModel() Model {
+ return Model{
+ activeTab: tabEntries,
+ styles: DefaultStyles(),
+ }
+}
+
+// Init implements tea.Model.
+func (m Model) Init() tea.Cmd {
+ return nil
+}
+
+// Update implements tea.Model.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ return m, nil
+
+ case tea.KeyMsg:
+ key := msg.String()
+
+ if m.pendingZ {
+ m.pendingZ = false
+ if key == "Q" {
+ return m, tea.Quit
+ }
+ }
+
+ if m.pendingG {
+ m.pendingG = false
+ switch key {
+ case "t":
+ m.nextTab()
+ return m, nil
+ case "T":
+ m.prevTab()
+ return m, nil
+ }
+ }
+
+ switch key {
+ case "tab":
+ m.nextTab()
+ case "1":
+ m.activeTab = tabEntries
+ case "2":
+ m.activeTab = tabReport
+ case "3":
+ m.activeTab = tabTimer
+ case "?":
+ m.showHelp = !m.showHelp
+ case "g":
+ m.pendingG = true
+ case "Z":
+ m.pendingZ = true
+ case "q", "ctrl+c":
+ return m, tea.Quit
+ }
+ }
+
+ return m, nil
+}
+
+// View implements tea.Model.
+func (m Model) View() string {
+ header := m.renderTabs()
+ body := m.styles.Body.Render(m.renderBody())
+
+ 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")
+ }
+
+ content := lipgloss.JoinVertical(lipgloss.Left, header, body, help)
+ rendered := m.styles.App.Render(content)
+
+ if m.width > 0 && m.height > 0 {
+ return lipgloss.Place(m.width, m.height, lipgloss.Left, lipgloss.Top, rendered)
+ }
+ return rendered
+}
+
+func (m *Model) nextTab() {
+ m.activeTab = (m.activeTab + 1) % tabCount
+}
+
+func (m *Model) prevTab() {
+ if m.activeTab == 0 {
+ m.activeTab = tabCount - 1
+ return
+ }
+ m.activeTab--
+}
+
+func (m Model) renderTabs() string {
+ parts := make([]string, 0, len(tabLabels))
+ for idx, label := range tabLabels {
+ if tab(idx) == m.activeTab {
+ parts = append(parts, m.styles.ActiveTab.Render(label))
+ continue
+ }
+ parts = append(parts, m.styles.Tab.Render(label))
+ }
+ return m.styles.Header.Render(strings.Join(parts, " "))
+}
+
+func (m Model) renderBody() string {
+ switch m.activeTab {
+ case tabEntries:
+ return "Entries screen scaffold.\nList/search/edit wiring lands in next tasks."
+ case tabReport:
+ return "Report screen scaffold.\nWeekly report table wiring lands in next tasks."
+ case tabTimer:
+ return "Timer screen scaffold.\nLive timer integration lands in next tasks."
+ default:
+ return ""
+ }
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
new file mode 100644
index 0000000..0b42e1f
--- /dev/null
+++ b/internal/tui/tui_test.go
@@ -0,0 +1,79 @@
+package tui
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestTabNavigation(t *testing.T) {
+ model := NewModel()
+
+ modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab})
+ model = modelAny.(Model)
+ if model.activeTab != tabReport {
+ t.Fatalf("active tab after Tab = %v, want %v", model.activeTab, tabReport)
+ }
+
+ modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
+ model = modelAny.(Model)
+ modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'T'}})
+ model = modelAny.(Model)
+ if model.activeTab != tabEntries {
+ t.Fatalf("active tab after gT = %v, want %v", model.activeTab, tabEntries)
+ }
+
+ modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}})
+ model = modelAny.(Model)
+ if model.activeTab != tabTimer {
+ t.Fatalf("active tab after key 3 = %v, want %v", model.activeTab, tabTimer)
+ }
+}
+
+func TestHelpToggle(t *testing.T) {
+ model := NewModel()
+
+ modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
+ model = modelAny.(Model)
+ if !model.showHelp {
+ t.Fatal("showHelp = false, want true after ?")
+ }
+
+ modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
+ model = modelAny.(Model)
+ if model.showHelp {
+ t.Fatal("showHelp = true, want false after second ?")
+ }
+}
+
+func TestQuitKeys(t *testing.T) {
+ model := NewModel()
+
+ modelAny, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+ model = modelAny.(Model)
+ if cmd == nil {
+ t.Fatal("quit cmd is nil for q")
+ }
+ if _, ok := cmd().(tea.QuitMsg); !ok {
+ t.Fatal("q key did not return tea.Quit command")
+ }
+
+ modelAny, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Z'}})
+ model = modelAny.(Model)
+ modelAny, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Q'}})
+ model = modelAny.(Model)
+ if cmd == nil {
+ t.Fatal("quit cmd is nil for ZQ")
+ }
+ if _, ok := cmd().(tea.QuitMsg); !ok {
+ t.Fatal("ZQ did not return tea.Quit command")
+ }
+}
+
+func TestViewContainsTabLabels(t *testing.T) {
+ model := NewModel()
+ view := model.View()
+ if view == "" {
+ t.Fatal("View() returned empty output")
+ }
+}