diff options
| author | T <t@tjp.lol> | 2026-02-19 19:42:22 -0700 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-02-19 19:42:57 -0700 |
| commit | 2ad5e85c729b4a32c4c49584e076ac9f6ce6fa60 (patch) | |
| tree | c6aa5db061504406f40d904399af4fb01857a0a9 | |
| parent | 7ba68d333bc20b5795ccfd3870546a05eee60470 (diff) | |
re-skin
| -rw-r--r-- | .opencode/skill/tui-iteration/SKILL.md | 287 | ||||
| -rw-r--r-- | AGENTS.md (renamed from CLAUDE.md) | 0 | ||||
| -rw-r--r-- | internal/tui/app.go | 60 | ||||
| -rw-r--r-- | internal/tui/history_box.go | 81 | ||||
| -rw-r--r-- | internal/tui/modal.go | 6 | ||||
| -rw-r--r-- | internal/tui/projects_box.go | 66 | ||||
| -rw-r--r-- | internal/tui/shared.go | 92 | ||||
| -rw-r--r-- | internal/tui/timer_box.go | 67 |
8 files changed, 489 insertions, 170 deletions
diff --git a/.opencode/skill/tui-iteration/SKILL.md b/.opencode/skill/tui-iteration/SKILL.md new file mode 100644 index 0000000..d28f0a7 --- /dev/null +++ b/.opencode/skill/tui-iteration/SKILL.md @@ -0,0 +1,287 @@ +--- +name: tui-iteration +description: Iteratively develop and refine Terminal User Interfaces (TUIs) by using tmux to spawn, observe, and interact with a running TUI process. Use this skill when asked to build, debug, or visually refine a TUI application — any time you need to "see" what the terminal renders and react to it, the way Playwright lets a browser agent iterate on web UI. +--- + +This skill lets you act as a TUI design agent: launch a TUI in a controlled tmux pane, capture its rendered output, send keystrokes to interact with it, observe the result, and iterate — all from the CLI. The tmux pane is your "browser tab"; `capture-pane` is your "screenshot". + +--- + +## Core Concept + +TUIs render to a virtual terminal buffer. `tmux` holds that buffer in memory at all times, even when the pane is not visible. You can: + +1. **Spawn** a tmux session/pane and run the TUI inside it. +2. **Capture** the current rendered state (text + ANSI color codes) as a snapshot. +3. **Send** keystrokes or input to drive the TUI. +4. **Repeat** — capture → evaluate → send → capture — until the UI looks right. + +This is the TUI equivalent of Playwright's `page.screenshot()` + `page.click()` loop. + +--- + +## Setup + +### Create a dedicated session + +```bash +tmux new-session -d -s tui-dev -x 220 -y 50 +``` + +- `-d` — detached (runs in background, no need to attach) +- `-s tui-dev` — session name; use this name in all subsequent commands +- `-x 220 -y 50` — terminal dimensions (columns × rows); set to match your target environment + +### Run the TUI in that session + +```bash +tmux send-keys -t tui-dev 'python my_tui.py' Enter +# or +tmux send-keys -t tui-dev 'cargo run' Enter +# or any other start command +``` + +Give the process a moment to render before capturing: + +```bash +sleep 0.5 +``` + +--- + +## Capturing State ("Taking a Screenshot") + +### Plain text capture + +```bash +tmux capture-pane -t tui-dev -p +``` + +Prints the current pane content to stdout as plain text. Good for reading layout, text content, and cursor position. + +### Capture with ANSI color codes preserved + +```bash +tmux capture-pane -t tui-dev -p -e +``` + +The `-e` flag includes escape sequences for colors and styling. Pipe to a file if you want to save a snapshot: + +```bash +tmux capture-pane -t tui-dev -p -e > snapshot.txt +``` + +Render that snapshot back to your terminal to visually inspect it: + +```bash +cat snapshot.txt +``` + +### Capture a scrollback window (not just the visible area) + +```bash +tmux capture-pane -t tui-dev -p -S -100 +``` + +`-S -100` captures 100 lines above the visible area. Useful if the TUI printed startup logs. + +### Save to a named tmux buffer (instead of stdout) + +```bash +tmux capture-pane -t tui-dev -b my-snapshot +tmux show-buffer -b my-snapshot +``` + +--- + +## Sending Input ("Clicking / Typing") + +### Type text + +```bash +tmux send-keys -t tui-dev 'hello world' '' +# Note: omitting Enter means no newline — useful for text fields +``` + +### Press Enter + +```bash +tmux send-keys -t tui-dev '' Enter +``` + +### Arrow keys and navigation + +```bash +tmux send-keys -t tui-dev '' Up +tmux send-keys -t tui-dev '' Down +tmux send-keys -t tui-dev '' Left +tmux send-keys -t tui-dev '' Right +``` + +### Special keys + +```bash +tmux send-keys -t tui-dev '' Tab +tmux send-keys -t tui-dev '' Escape +tmux send-keys -t tui-dev '' BSpace # Backspace +tmux send-keys -t tui-dev '' DC # Delete +tmux send-keys -t tui-dev '' Home +tmux send-keys -t tui-dev '' End +tmux send-keys -t tui-dev '' PPage # Page Up +tmux send-keys -t tui-dev '' NPage # Page Down +tmux send-keys -t tui-dev '' F1 # Function keys F1–F12 +``` + +### Control sequences + +```bash +tmux send-keys -t tui-dev 'q' '' # Just the letter q (quit in many TUIs) +tmux send-keys -t tui-dev '' C-c # Ctrl+C +tmux send-keys -t tui-dev '' C-d # Ctrl+D (EOF) +tmux send-keys -t tui-dev '' C-z # Ctrl+Z (suspend) +``` + +### Hex / raw escape sequences (advanced) + +```bash +# Send Escape then [ then A (same as Up arrow in many terminals) +tmux send-keys -t tui-dev $'\x1b[A' '' +``` + +--- + +## Targeting Specific Panes + +If your session has multiple windows or panes: + +```bash +# target syntax: session:window.pane +tmux send-keys -t tui-dev:0.0 '' Enter # session tui-dev, window 0, pane 0 +tmux capture-pane -t tui-dev:1.0 -p # window 1, pane 0 +``` + +List all panes to find the right target: + +```bash +tmux list-panes -a +``` + +--- + +## Resize the Terminal (Test Responsive Layouts) + +```bash +tmux resize-window -t tui-dev -x 80 -y 24 # classic 80×24 +tmux resize-window -t tui-dev -x 120 -y 40 # wider +``` + +After resizing, most TUI frameworks emit a `SIGWINCH` and redraw automatically. Capture again after ~200ms. + +--- + +## The Iteration Loop + +This is the core workflow. Repeat until satisfied: + +``` +1. send-keys → trigger an action or type input +2. sleep 0.2 → give the TUI time to redraw +3. capture-pane -p -e → observe the new state +4. evaluate → does it look right? any errors visible? +5. go to 1 +``` + +Example shell loop for automated checking: + +```bash +# Press Down 5 times and snapshot each state +for i in $(seq 1 5); do + tmux send-keys -t tui-dev '' Down + sleep 0.15 + echo "--- Step $i ---" + tmux capture-pane -t tui-dev -p +done +``` + +--- + +## Reading the Captured Output + +When you receive the text from `capture-pane -p`, look for: + +- **Borders and box-drawing characters** (`─`, `│`, `╭`, `╰`, `┌`, `└`, etc.) — indicate panels, modals, popups +- **Highlighted / reversed rows** — often indicated by `[7m` in ANSI output (reverse video = selected item) +- **Color shifts** — `[32m` green, `[31m` red, `[33m` yellow — often encode status +- **Cursor position** — `tmux display-message -t tui-dev -p '#{cursor_x},#{cursor_y}'` +- **Blank regions** — unexpected whitespace may mean a rendering bug or layout overflow + +To strip ANSI codes for pure text analysis: + +```bash +tmux capture-pane -t tui-dev -p -e | sed 's/\x1b\[[0-9;]*m//g' +``` + +--- + +## Useful Diagnostic Commands + +```bash +# What is currently running in the pane? +tmux display-message -t tui-dev -p '#{pane_current_command}' + +# Get pane dimensions +tmux display-message -t tui-dev -p '#{pane_width}x#{pane_height}' + +# Get cursor position +tmux display-message -t tui-dev -p 'cursor: #{cursor_x},#{cursor_y}' + +# Is the pane still alive? +tmux list-panes -t tui-dev -F '#{pane_id} #{pane_dead} #{pane_current_command}' + +# Watch the pane live (attach briefly) +tmux attach -t tui-dev +# Detach again with: Ctrl+B then D +``` + +--- + +## Teardown + +Kill the TUI process without destroying the session: + +```bash +tmux send-keys -t tui-dev '' C-c +``` + +Kill the whole session when done: + +```bash +tmux kill-session -t tui-dev +``` + +--- + +## Workflow Summary + +| Goal | Command | +|------|---------| +| Create session | `tmux new-session -d -s tui-dev -x 220 -y 50` | +| Start TUI | `tmux send-keys -t tui-dev 'CMD' Enter` | +| Snapshot (plain) | `tmux capture-pane -t tui-dev -p` | +| Snapshot (with color) | `tmux capture-pane -t tui-dev -p -e` | +| Send keystroke | `tmux send-keys -t tui-dev '' Down` | +| Type text | `tmux send-keys -t tui-dev 'text' ''` | +| Resize terminal | `tmux resize-window -t tui-dev -x W -y H` | +| Cursor position | `tmux display-message -t tui-dev -p '#{cursor_x},#{cursor_y}'` | +| Kill process | `tmux send-keys -t tui-dev '' C-c` | +| Kill session | `tmux kill-session -t tui-dev` | + +--- + +## Tips + +- **Always `sleep` after `send-keys`** before capturing. TUIs need a render cycle. 100–300ms is usually enough; animations may need longer. +- **Prefer `-e` captures when debugging visual issues** — color is often load-bearing information in TUIs (errors in red, selection in reverse video, etc.). +- **Use a wide session** (`-x 220`) by default. Many TUI layout bugs only appear at narrow widths; you can always resize down to test. +- **Scrollback is truncated** — `capture-pane` only sees the visible buffer plus scrollback history. If the TUI clears the screen on launch, earlier output is gone. +- **Mouse events** can be sent via `tmux send-keys` using raw escape sequences, but most TUI iteration tasks don't need them — keyboard navigation is sufficient. diff --git a/internal/tui/app.go b/internal/tui/app.go index 38457ff..9850595 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -459,43 +459,67 @@ func (m AppModel) View() string { return "Loading..." } + // Constrain content width for large displays + contentWidth := m.width + if contentWidth > maxContentWidth { + contentWidth = maxContentWidth + } + topBarHeight := 1 bottomBarHeight := 2 contentHeight := m.height - topBarHeight - bottomBarHeight - // Timer box top-left - timerBoxWidth := (m.width / 3) - timerBoxHeight := (contentHeight / 2) - if timerBoxWidth < 30 { - timerBoxWidth = 30 + // Sidebar: fixed comfortable width, capped to avoid dominating + sidebarWidth := 38 + if sidebarWidth > contentWidth/3 { + sidebarWidth = contentWidth / 3 + } + if sidebarWidth < 30 { + sidebarWidth = 30 + } + + // Timer box is compact - just needs enough for the timer display + timerBoxHeight := contentHeight * 2 / 5 + if timerBoxHeight < 8 { + timerBoxHeight = 8 + } + if timerBoxHeight > 14 { + timerBoxHeight = 14 } - // Projects box bottom-left - projectsBoxWidth := timerBoxWidth + // Projects box gets the rest of the left column projectsBoxHeight := contentHeight - timerBoxHeight // History box right side full height - historyBoxWidth := m.width - projectsBoxWidth + historyBoxWidth := contentWidth - sidebarWidth historyBoxHeight := contentHeight - activeDur := m.timerBox.activeTime() - stats := m.timeStats - stats.TodayTotal += activeDur - stats.WeekTotal += activeDur + topBar := RenderTopBar(m, contentWidth) - topBar := RenderTopBar(m) - - timerBox := m.timerBox.View(timerBoxWidth, timerBoxHeight, m.selectedBox == TimerBox) - projectsBox := m.projectsBox.View(projectsBoxWidth, projectsBoxHeight, m.selectedBox == ProjectsBox) + timerBox := m.timerBox.View(sidebarWidth, timerBoxHeight, m.selectedBox == TimerBox) + projectsBox := m.projectsBox.View(sidebarWidth, projectsBoxHeight, m.selectedBox == ProjectsBox) historyBox := m.historyBox.View(historyBoxWidth, historyBoxHeight, m.selectedBox == HistoryBox, m.timerBox, m.projectsBox.clients, m.projectsBox.projects) leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, projectsBox) mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox) keyBindings := activeBindings(m.selectedBox, m.historyBox.viewLevel, m.modalBox) - bottomBar := RenderBottomBar(m, keyBindings, m.err) + bottomBar := RenderBottomBar(m, keyBindings, m.err, contentWidth) + + fullView := topBar + "\n" + mainContent + "\n" + bottomBar + + // Apply modal overlay (uses contentWidth for centering within the content area) + fullView = m.modalBox.RenderCenteredOver(fullView, m, contentWidth) + + // Center the content if terminal is wider than max + if m.width > contentWidth { + fullView = lipgloss.NewStyle(). + Width(m.width). + Align(lipgloss.Center). + Render(fullView) + } - return m.modalBox.RenderCenteredOver(topBar+"\n"+mainContent+"\n"+bottomBar, m) + return fullView } // dataUpdatedMsg is sent when data is updated from the database diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go index 17958c3..9e01f2f 100644 --- a/internal/tui/history_box.go +++ b/internal/tui/history_box.go @@ -184,7 +184,7 @@ func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBox var content string if len(m.entries) == 0 { - content = "📝 Recent History\n\n" + content = titleStyle.Render("History") + "\n\n" content += inactiveTimerStyle.Render("No recent entries\n\nStart tracking time to\nsee your history here.") } else { switch m.viewLevel { @@ -224,23 +224,18 @@ func (m HistoryBoxModel) selectionHeight() int { } func (m HistoryBoxModel) summarySelectionHeight() int { - height := 1 // "Recent History" title line - - if len(m.summaryItems) > 0 { - height += 3 // 2 newlines + filter info line - } + height := 1 // title line (History + filter info on same line) var date *time.Time for i, item := range m.summaryItems { if date == nil || !date.Equal(item.Date) { date = &item.Date - height += 4 // 2 newlines, the date, 1 more newline + height += 2 // blank line + date header } - height += 1 // newline before the selectable line + height += 1 // the entry line if i == m.summarySelection { return height } - height += 1 // the selectable line that's not selected } return 0 } @@ -259,25 +254,26 @@ func (m HistoryBoxModel) detailsSelectionHeight() int { var ( titleStyle = lipgloss.NewStyle().Bold(true) - dateStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")) + dateStyle = lipgloss.NewStyle().Bold(true).Foreground(colorDate) summaryItemStyle = lipgloss.NewStyle() - selectedItemStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + selectedItemStyle = lipgloss.NewStyle().Background(colorSelected).Foreground(colorSelectedFg) + durationStyle = lipgloss.NewStyle().Foreground(colorDimmed) entryStyle = lipgloss.NewStyle() - selectedEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) - activeEntryStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196")) - selectedActiveEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230")) - descriptionStyle = lipgloss.NewStyle() - activeDescriptionStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) - filterInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")) + selectedEntryStyle = lipgloss.NewStyle().Background(colorSelected).Foreground(colorSelectedFg) + activeEntryStyle = lipgloss.NewStyle().Bold(true).Foreground(colorTimerText) + selectedActiveEntryStyle = lipgloss.NewStyle().Background(colorWarning).Foreground(colorSelectedFg) + descriptionStyle = lipgloss.NewStyle().Foreground(colorDimmed) + activeDescriptionStyle = lipgloss.NewStyle().Background(colorSelected).Foreground(colorSelectedFg) + filterInfoStyle = lipgloss.NewStyle().Foreground(colorDimmed) ) // renderSummaryView renders the summary view (level 1) with date headers and client/project summaries func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel, clients []queries.Client, projects map[int64][]queries.Project) string { - content := titleStyle.Render("📝 Recent History") + content := titleStyle.Render("History") if len(m.summaryItems) > 0 { filterInfo := m.formatFilterInfo(clients, projects, timer) - content += "\n\n" + filterInfoStyle.Render(filterInfo) + content += " " + filterInfoStyle.Render(filterInfo) } var activeKey HistorySummaryKey @@ -292,19 +288,15 @@ func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel, clients []querie } if len(m.summaryItems) == 0 { - return "\n\nNo recent entries found." + return content + "\n\n" + filterInfoStyle.Render("No entries found.") } var date *time.Time for i, item := range m.summaryItems { if date == nil || !date.Equal(item.Date) { date = &item.Date - content += fmt.Sprintf("\n\n%s\n", dateStyle.Render(date.Format("Mon 01/02"))) - } - - style := summaryItemStyle - if m.summarySelection == i { - style = selectedItemStyle + content += "\n" + content += fmt.Sprintf("\n%s", dateStyle.Render(date.Format("Mon Jan 02"))) } dur := item.TotalDuration @@ -312,8 +304,16 @@ func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel, clients []querie dur += timer.currentTime.Sub(timer.timerInfo.StartTime) } - line := fmt.Sprintf(" %s (%s)", m.formatSummaryTitle(item), FormatDuration(dur)) - content += fmt.Sprintf("\n%s", style.Render(line)) + title := m.formatSummaryTitle(item) + durStr := FormatDuration(dur) + + if m.summarySelection == i { + line := fmt.Sprintf(" %s %s", title, durStr) + content += "\n" + selectedItemStyle.Render(line) + } else { + line := fmt.Sprintf(" %s ", title) + content += "\n" + summaryItemStyle.Render(line) + durationStyle.Render(durStr) + } } return content @@ -338,12 +338,13 @@ func (m HistoryBoxModel) selectedEntries() []queries.TimeEntry { func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string { summary := m.summaryItems[m.summarySelection] clientProject := m.formatSummaryTitle(summary) - date := summary.Date.Format("Mon 01/02") - content := titleStyle.Render(fmt.Sprintf("📝 %s on %s", clientProject, date)) + "\n\n" + date := summary.Date.Format("Mon Jan 02") + content := titleStyle.Render(clientProject) + + filterInfoStyle.Render(" "+date) + "\n\n" entries := m.selectedEntries() if len(entries) == 0 { - return "No entries found for this selection." + return content + filterInfoStyle.Render("No entries found.") } for i, entry := range entries { @@ -363,7 +364,7 @@ func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string { timeRange = fmt.Sprintf("%s - now", startTime) } - entryLine := fmt.Sprintf("%s (%s)", timeRange, FormatDuration(duration)) + durStr := FormatDuration(duration) var style lipgloss.Style if m.detailSelection == i { @@ -380,14 +381,20 @@ func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string { } } - content += style.Render(entryLine) - - descStyle := descriptionStyle if m.detailSelection == i { - descStyle = activeDescriptionStyle + entryLine := fmt.Sprintf(" %s %s", timeRange, durStr) + content += style.Render(entryLine) + } else { + content += style.Render(fmt.Sprintf(" %s ", timeRange)) + content += durationStyle.Render(durStr) } + if entry.Description.Valid { - content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String)) + descStyle := descriptionStyle + if m.detailSelection == i { + descStyle = activeDescriptionStyle + } + content += descStyle.Render(" " + entry.Description.String) } content += "\n" diff --git a/internal/tui/modal.go b/internal/tui/modal.go index b614d04..0e6c517 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -78,7 +78,7 @@ func (m *ModalBoxModel) HandleKeyPress(msg tea.KeyMsg) tea.Cmd { return cmd } -func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) string { +func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel, contentWidth int) string { if !m.Active { return mainContent } @@ -86,12 +86,12 @@ func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) stri modalContent := m.Render() base := lipgloss.NewLayer(mainContent) - overlayStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")).Padding(2, 4) + overlayStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colorAccent).Padding(2, 4) overlay := lipgloss.NewLayer(overlayStyle.Render(modalContent)) overlayWidth := overlay.GetWidth() overlayHeight := overlay.GetHeight() - overlayLeft := (app.width - overlayWidth) / 2 + overlayLeft := (contentWidth - overlayWidth) / 2 overlayTop := (app.height - overlayHeight) / 2 canvas := lipgloss.NewCanvas( diff --git a/internal/tui/projects_box.go b/internal/tui/projects_box.go index cd50d5e..d909e20 100644 --- a/internal/tui/projects_box.go +++ b/internal/tui/projects_box.go @@ -74,7 +74,7 @@ func (m ClientsProjectsModel) View(width, height int, isSelected bool) string { style = selectedBoxStyle } - title := titleStyle.Render("👥 Clients & Projects") + title := titleStyle.Render("Clients & Projects") return style.Width(width).Height(height).Render( fmt.Sprintf("%s\n\n%s", title, content), @@ -86,57 +86,51 @@ func (m ClientsProjectsModel) renderClientsAndProjects() string { var content string visibleClients := m.visibleClients() + rateStyle := lipgloss.NewStyle().Foreground(colorDimmed) + treeStyle := lipgloss.NewStyle().Foreground(colorSubtle) + for i, client := range visibleClients { if i > 0 { content += "\n" } - // Build client name and rate - clientText := client.Name - if client.BillableRate.Valid { - rateInDollars := float64(client.BillableRate.Int64) / 100.0 - clientText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) - } - // Style for client text clientStyle := lipgloss.NewStyle().Bold(true) if m.selectedClient == i && m.selectedProject == nil { - clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + clientStyle = clientStyle.Background(colorSelected).Foreground(colorSelectedFg) } else if client.Archived != 0 { - // Gray out archived clients - clientStyle = clientStyle.Foreground(lipgloss.Color("246")) + clientStyle = clientStyle.Foreground(colorDimmed) } - content += "• " + clientStyle.Render(clientText) + "\n" + clientText := client.Name + content += clientStyle.Render(clientText) + if client.BillableRate.Valid && !(m.selectedClient == i && m.selectedProject == nil) { + rateInDollars := float64(client.BillableRate.Int64) / 100.0 + content += rateStyle.Render(fmt.Sprintf(" $%g", rateInDollars)) + } + content += "\n" visibleProjects := m.visibleProjects(client.ID) - if len(visibleProjects) == 0 { - content += " └── (no projects)\n" - } else { - for j, project := range visibleProjects { - prefix := "├──" - if j == len(visibleProjects)-1 { - prefix = "└──" - } - - // Build project name and rate - projectText := project.Name - if project.BillableRate.Valid { - rateInDollars := float64(project.BillableRate.Int64) / 100.0 - projectText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) - } + for j, project := range visibleProjects { + prefix := "├ " + if j == len(visibleProjects)-1 { + prefix = "└ " + } - // Style for project text - projectStyle := lipgloss.NewStyle() - if m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j { - projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) - } else if project.Archived != 0 { - // Gray out archived projects - projectStyle = projectStyle.Foreground(lipgloss.Color("246")) - } + // Style for project text + projectStyle := lipgloss.NewStyle() + if m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j { + projectStyle = projectStyle.Background(colorSelected).Foreground(colorSelectedFg) + } else if project.Archived != 0 { + projectStyle = projectStyle.Foreground(colorDimmed) + } - content += fmt.Sprintf(" %s ", prefix) + projectStyle.Render(projectText) + "\n" + content += treeStyle.Render(" "+prefix) + projectStyle.Render(project.Name) + if project.BillableRate.Valid && !(m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j) { + rateInDollars := float64(project.BillableRate.Int64) / 100.0 + content += rateStyle.Render(fmt.Sprintf(" $%g", rateInDollars)) } + content += "\n" } } diff --git a/internal/tui/shared.go b/internal/tui/shared.go index 7271293..9025fb8 100644 --- a/internal/tui/shared.go +++ b/internal/tui/shared.go @@ -13,34 +13,57 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) +// Maximum content width for large displays - prevents over-stretching +const maxContentWidth = 180 + var ( + // Color palette + colorAccent = lipgloss.Color("4") // Blue accent + colorAccentBright = lipgloss.Color("12") // Bright blue + colorTimerActive = lipgloss.Color("2") // Green for active timer + colorTimerText = lipgloss.Color("10") // Bright green + colorDimmed = lipgloss.Color("242") // Dimmed text + colorSubtle = lipgloss.Color("238") // Very subtle borders/separators + colorFg = lipgloss.Color("253") // Main foreground + colorBg = lipgloss.Color("235") // Slightly lighter than terminal default + colorBarBg = lipgloss.Color("236") // Bar background + colorSelected = lipgloss.Color("4") // Selection background + colorSelectedFg = lipgloss.Color("15") // White text on selection + colorDate = lipgloss.Color("6") // Cyan for dates + colorWarning = lipgloss.Color("1") // Red for warnings/errors + // Styles for the TUI - topBarInactiveStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("21")). - Foreground(lipgloss.Color("230")). - Padding(0, 1) + topBarStyle = lipgloss.NewStyle(). + Background(colorBarBg). + Foreground(colorFg). + Padding(0, 1) bottomBarStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("238")). - Foreground(lipgloss.Color("252")) + Background(colorBarBg). + Foreground(colorDimmed) // Box styles selectedBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). + BorderForeground(colorAccent). Padding(1, 2) unselectedBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("238")). + BorderForeground(colorSubtle). Padding(1, 2) activeTimerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). + Foreground(colorTimerText). Bold(true) + activeBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorTimerActive). + Padding(1, 2) + inactiveTimerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("246")) + Foreground(colorDimmed) ) // FormatDuration formats a duration in a human-readable way @@ -66,7 +89,7 @@ func getContractorInfo(ctx context.Context, q *queries.Queries) (ContractorInfo, } return ContractorInfo{ - name: c.Name, + name: c.Name, label: c.Label, email: c.Email, }, nil @@ -133,8 +156,8 @@ func getMostRecentTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, } // RenderTopBar renders the top bar with view name and time stats -func RenderTopBar(m AppModel) string { - left := fmt.Sprintf("👊 Punchcard ♦️ - %s", m.selectedBox.String()) +func RenderTopBar(m AppModel, contentWidth int) string { + leftText := fmt.Sprintf("Punchcard / %s", m.selectedBox.String()) today := m.timeStats.TodayTotal week := m.timeStats.WeekTotal @@ -145,35 +168,35 @@ func RenderTopBar(m AppModel) string { week += activeTime } - right := fmt.Sprintf("Today: %s | Week: %s", FormatDuration(today), FormatDuration(week)) + rightText := fmt.Sprintf("today %s week %s", FormatDuration(today), FormatDuration(week)) - // Use lipgloss to create left and right aligned content leftStyle := lipgloss.NewStyle().Align(lipgloss.Left) rightStyle := lipgloss.NewStyle().Align(lipgloss.Right) - // Calculate available width for content (minus padding) - contentWidth := m.width - 2 // Account for horizontal padding + // Account for the 2 chars of padding in topBarStyle + innerWidth := contentWidth - 2 - // Create a layout with left and right content content := lipgloss.JoinHorizontal( lipgloss.Top, - leftStyle.Width(contentWidth/2).Render(left), - rightStyle.Width(contentWidth/2).Render(right), + leftStyle.Width(innerWidth/2).Render(leftText), + rightStyle.Width(innerWidth-innerWidth/2).Render(rightText), ) - return topBarInactiveStyle.Width(m.width).Render(content) + return topBarStyle.Width(contentWidth).Render(content) } // RenderBottomBar renders the bottom bar with key bindings -func RenderBottomBar(m AppModel, bindings []KeyBinding, err error) string { +func RenderBottomBar(m AppModel, bindings []KeyBinding, err error, contentWidth int) string { var content string - // Style for keys (inherits background from bottomBarStyle) - keyStyle := bottomBarStyle.Bold(true) - // Style for descriptions (inherits background from bottomBarStyle) - descStyle := bottomBarStyle - // Style for separators (inherits background from bottomBarStyle) - sepStyle := bottomBarStyle + keyStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("240")). + Foreground(lipgloss.Color("15")). + Padding(0, 1) + descStyle := lipgloss.NewStyle(). + Background(colorBarBg). + Foreground(colorDimmed) + sepStyle := lipgloss.NewStyle().Background(colorBarBg) for i, binding := range bindings { if binding.Hide { @@ -184,20 +207,19 @@ func RenderBottomBar(m AppModel, bindings []KeyBinding, err error) string { continue } if i > 0 { - content += sepStyle.Render(" ") + content += sepStyle.Render(" ") } - content += keyStyle.Render(fmt.Sprintf("[%s]", binding.Key)) - content += descStyle.Render(fmt.Sprintf(" %s", desc)) + content += keyStyle.Render(binding.Key) + content += descStyle.Render(" " + desc) } if err != nil { - // Style for errors (inherits background, adds red foreground) - errorStyle := bottomBarStyle.Bold(true).Foreground(lipgloss.Color("196")) + errStyle := lipgloss.NewStyle().Background(colorBarBg).Bold(true).Foreground(colorWarning) content += sepStyle.Render(" ") - content += errorStyle.Render(err.Error()) + content += errStyle.Render(err.Error()) } - return bottomBarStyle.Width(m.width).Align(lipgloss.Left).Render(content) + return bottomBarStyle.Width(contentWidth).Align(lipgloss.Left).Render(content) } // GetAppData fetches all data needed for the TUI diff --git a/internal/tui/timer_box.go b/internal/tui/timer_box.go index 891004a..2b0c34a 100644 --- a/internal/tui/timer_box.go +++ b/internal/tui/timer_box.go @@ -1,10 +1,11 @@ package tui import ( - "fmt" "time" "git.tjp.lol/punchcard/internal/queries" + + "github.com/charmbracelet/lipgloss/v2" ) // TimerInfo holds information about the current or most recent timer state @@ -66,9 +67,13 @@ func (m TimerBoxModel) View(width, height int, isSelected bool) string { } // Apply box styling - style := unselectedBoxStyle + var style lipgloss.Style if isSelected { style = selectedBoxStyle + } else if m.timerInfo.IsActive { + style = activeBoxStyle + } else { + style = unselectedBoxStyle } return style.Width(width).Height(height).Render(content) @@ -76,67 +81,47 @@ func (m TimerBoxModel) View(width, height int, isSelected bool) string { // renderActiveTimer renders the active timer display func (m TimerBoxModel) renderActiveTimer() string { - content := titleStyle.Render("⏰ Active Timer") + "\n\n" + statusStyle := lipgloss.NewStyle().Foreground(colorTimerActive).Bold(true) + content := statusStyle.Render("TRACKING") + "\n\n" - // Timer duration - timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.currentTime.Sub(m.timerInfo.StartTime))) - content += activeTimerStyle.Render(timerLine) + "\n\n" + // Timer duration - big and bold + dur := FormatDuration(m.currentTime.Sub(m.timerInfo.StartTime)) + content += activeTimerStyle.Render(dur) + "\n\n" // Project/Client info + dimLabel := lipgloss.NewStyle().Foreground(colorDimmed) if m.timerInfo.ProjectName != "" { - projectLine := fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName) - content += projectLine + "\n" + content += dimLabel.Render(m.timerInfo.ClientName+" /") + "\n" + content += m.timerInfo.ProjectName + "\n" } else { - clientLine := fmt.Sprintf("Client: %s", m.timerInfo.ClientName) - content += clientLine + "\n" + content += m.timerInfo.ClientName + "\n" } - // Start time (convert from UTC to local) + // Start time localStartTime := m.timerInfo.StartTime.Local() - startLine := fmt.Sprintf("Started: %s", localStartTime.Format("3:04 PM")) - content += startLine + "\n" - - // Description if available - if m.timerInfo.Description != nil { - content += "\n" - descLine := fmt.Sprintf("Description: %s", *m.timerInfo.Description) - content += descLine + "\n" - } - - // Billable rate if available - if m.timerInfo.BillableRate != nil { - rateLine := fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate) - content += rateLine + "\n" - } + content += dimLabel.Render("since " + localStartTime.Format("3:04 PM")) return content } // renderInactiveTimer renders the inactive timer display func (m TimerBoxModel) renderInactiveTimer() string { - content := titleStyle.Render("⌛ Last Timer (Inactive)") + "\n\n" + statusStyle := lipgloss.NewStyle().Foreground(colorDimmed) + content := statusStyle.Render("IDLE") + "\n\n" if m.timerInfo.EntryID == 0 { - content += inactiveTimerStyle.Render("No time entries yet.\nSelect a client or project and\npunch in to start tracking time.") + content += inactiveTimerStyle.Render("No entries yet.\nPunch in to start.") return content } - timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration)) - content += inactiveTimerStyle.Render(timerLine) + "\n\n" + content += inactiveTimerStyle.Render(FormatDuration(m.timerInfo.Duration)) + "\n\n" + dimLabel := lipgloss.NewStyle().Foreground(colorDimmed) if m.timerInfo.ProjectName != "" { - content += inactiveTimerStyle.Render(fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName)) + "\n" + content += dimLabel.Render(m.timerInfo.ClientName+" /") + "\n" + content += inactiveTimerStyle.Render(m.timerInfo.ProjectName) } else { - content += inactiveTimerStyle.Render(fmt.Sprintf("Client: %s", m.timerInfo.ClientName)) + "\n" - } - - content += inactiveTimerStyle.Render(fmt.Sprintf("Started: %s", m.timerInfo.StartTime.Local().Format("2006/01/02 3:04 PM"))) + "\n" - - if m.timerInfo.Description != nil { - content += "\n" + inactiveTimerStyle.Render(fmt.Sprintf("Description: %s", *m.timerInfo.Description)) + "\n" - } - if m.timerInfo.BillableRate != nil { - content += inactiveTimerStyle.Render(fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate)) + "\n" + content += inactiveTimerStyle.Render(m.timerInfo.ClientName) } return content |
