diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 22:55:33 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 22:55:33 +0200 |
| commit | 6437f0a5f6a4a114540092efe50f62270070afb0 (patch) | |
| tree | 3b610d656f77b2cbeb1a46608304a424926180ec | |
| parent | 4aa4fe858f64da07046f00ccb926bb72d6b68070 (diff) | |
Task 352: add TUI root scaffold
| -rw-r--r-- | internal/tui/styles.go | 41 | ||||
| -rw-r--r-- | internal/tui/tui.go | 154 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 79 |
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") + } +} |
