diff options
author | T <t@tjp.lol> | 2025-08-05 12:36:30 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-06 12:13:11 -0600 |
commit | 65e2ed65775d64afbc6065a3b4ac1069020093ca (patch) | |
tree | f94fabfed5be2d2622429ebc7c8af1bf51085824 /internal/tui/app.go | |
parent | 665bd389a0a1c8adadcaa1122e846cc81f5ead31 (diff) |
most features in TUI working, remaining unimplemented keybinds need a modal view
Diffstat (limited to 'internal/tui/app.go')
-rw-r--r-- | internal/tui/app.go | 599 |
1 files changed, 155 insertions, 444 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go index 98bad4f..2ba2a9b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,8 +2,6 @@ package tui import ( "context" - "database/sql" - "fmt" "time" "punchcard/internal/queries" @@ -12,341 +10,160 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) -// NewApp creates a new TUI application -func NewApp(ctx context.Context, q *queries.Queries) *AppModel { - return &AppModel{ - ctx: ctx, - queries: q, - selectedBox: TimerBox, - timerBoxModel: NewTimerBoxModel(), - clientsProjectsModel: NewClientsProjectsModel(), - historyBoxModel: NewHistoryBoxModel(), - } -} - -// Init initializes the app -func (m AppModel) Init() tea.Cmd { - return tea.Batch( - m.updateDataCmd(), - m.tickCmd(), - ) -} - -// Update handles messages for the app -func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - - case tea.KeyMsg: - if m.showModal { - // Handle modal input - cmd := m.handleModalInput(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - } else { - // Handle normal input - action := HandleKeyPress(msg, m.selectedBox, m.historyBoxModel.viewLevel, m.timerBoxModel.timerInfo.IsActive) - cmd := m.handleAction(action) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - case TickMsg: - // Update timer duration if active using cached start time - if m.runningTimerStart != nil { - m.timerBoxModel.timerInfo.IsActive = true - m.timerBoxModel.timerInfo.Duration = time.Since(*m.runningTimerStart) - m.timerBoxModel.timerInfo.StartTime = *m.runningTimerStart - // Keep history model in sync - m.historyBoxModel.runningTimerStart = m.runningTimerStart - } else { - m.timerBoxModel.timerInfo.IsActive = false - m.timerBoxModel.timerInfo.Duration = 0 - // Keep history model in sync - m.historyBoxModel.runningTimerStart = nil - } - cmds = append(cmds, m.tickCmd()) +// BoxType represents the different boxes that can be selected +type BoxType int - case dataUpdatedMsg: - // Update all models with fresh data - m.timerBoxModel = m.timerBoxModel.UpdateTimerInfo(msg.timerInfo) - m.clientsProjectsModel = m.clientsProjectsModel.UpdateData(msg.clients, msg.projects) - m.historyBoxModel = m.historyBoxModel.UpdateData(msg.entries, msg.clients, msg.projects) - // Update running timer data in history model too - if msg.timerInfo.IsActive { - m.historyBoxModel.runningTimerStart = &msg.timerInfo.StartTime - } else { - m.historyBoxModel.runningTimerStart = nil - } - // Cache stats and running timer start time - m.stats = msg.stats - if msg.timerInfo.IsActive { - m.runningTimerStart = &msg.timerInfo.StartTime - } else { - m.runningTimerStart = nil - } - - // Schedule next data update in 30 seconds - cmds = append(cmds, tea.Tick(30*time.Second, func(t time.Time) tea.Msg { - return updateDataCmd{} - })) +const ( + TimerBox BoxType = iota + ProjectsBox + HistoryBox +) - case updateDataCmd: - cmds = append(cmds, m.updateDataCmd()) +func (b BoxType) String() string { + switch b { + case TimerBox: + return "Timer" + case ProjectsBox: + return "Clients & Projects" + case HistoryBox: + return "History" + default: + return "Unknown" } - - return m, tea.Batch(cmds...) } -// handleAction processes the action returned by key handling -func (m *AppModel) handleAction(action KeyAction) tea.Cmd { - switch action { - // Global actions - case ActionNextPane: - m.selectedBox = (m.selectedBox + 1) % 3 - case ActionPrevPane: - m.selectedBox = (m.selectedBox + 2) % 3 // +2 is like -1 in mod 3 - case ActionPunchToggle: - return m.handlePunchToggle() - case ActionSearch: - return m.handleSearch() - case ActionRefresh: - return m.handleRefresh() - case ActionQuit: - return tea.Quit - - // Timer pane actions - case ActionTimerEnter: - return m.handleTimerEnter() - case ActionTimerDescribe: - m.handleTimerDescribe() - - // Projects pane actions - case ActionProjectsNext: - m.handleProjectsNext() - case ActionProjectsPrev: - m.handleProjectsPrev() - case ActionProjectsEnter: - return m.handleProjectsEnter() - case ActionProjectsNewProject: - return m.handleProjectsNewProject() - case ActionProjectsNewClient: - return m.handleProjectsNewClient() - - // History pane actions - case ActionHistoryNext: - m.handleHistoryNext() - case ActionHistoryPrev: - m.handleHistoryPrev() - case ActionHistoryEnter: - return m.handleHistoryEnter() - case ActionHistoryEdit: - return m.handleHistoryEdit() - case ActionHistoryDelete: - return m.handleHistoryDelete() - case ActionHistoryResume: - return m.handleHistoryResume() - case ActionHistoryBack: - m.handleHistoryBack() +func (b BoxType) Next() BoxType { + switch b { + case TimerBox: + return ProjectsBox + case ProjectsBox: + return HistoryBox + case HistoryBox: + return TimerBox } - - return nil + return 0 } -// Global action handlers -func (m *AppModel) handlePunchToggle() tea.Cmd { - // TODO: Implement punch in/out toggle - return nil -} - -func (m *AppModel) handleSearch() tea.Cmd { - // TODO: Implement search modal - return nil -} - -func (m *AppModel) handleRefresh() tea.Cmd { - // Immediately refresh data from database - return m.updateDataCmd() -} - -// Timer pane action handlers -func (m *AppModel) handleTimerEnter() tea.Cmd { - if m.timerBoxModel.timerInfo.IsActive { - // TODO: Implement punch out - return nil - } else { - // TODO: Implement resume recent (punch back in to most recent project) - return nil +func (b BoxType) Prev() BoxType { + switch b { + case TimerBox: + return HistoryBox + case HistoryBox: + return ProjectsBox + case ProjectsBox: + return TimerBox } + return 0 } -// Projects pane action handlers -func (m *AppModel) handleProjectsNext() { - m.clientsProjectsModel = m.clientsProjectsModel.NextSelection() -} +// AppModel is the main model for the TUI application +type AppModel struct { + ctx context.Context + queries *queries.Queries -func (m *AppModel) handleProjectsPrev() { - m.clientsProjectsModel = m.clientsProjectsModel.PrevSelection() -} + selectedBox BoxType + timerBox TimerBoxModel + projectsBox ClientsProjectsModel + historyBox HistoryBoxModel -func (m *AppModel) handleProjectsEnter() tea.Cmd { - // TODO: Punch in to selected client/project - return nil -} + width int + height int -func (m *AppModel) handleProjectsNewProject() tea.Cmd { - // TODO: Open new project modal - return nil + timeStats TimeStats + err error } -func (m *AppModel) handleProjectsNewClient() tea.Cmd { - // TODO: Open new client modal - return nil +// TimeStats holds time statistics for display (excluding the currently running timer, if any) +type TimeStats struct { + TodayTotal time.Duration + WeekTotal time.Duration } -// History pane action handlers -func (m *AppModel) handleHistoryNext() { - m.historyBoxModel = m.historyBoxModel.NextSelection() +// NewApp creates a new TUI application +func NewApp(ctx context.Context, q *queries.Queries) *AppModel { + return &AppModel{ + ctx: ctx, + queries: q, + selectedBox: TimerBox, + timerBox: NewTimerBoxModel(), + projectsBox: NewClientsProjectsModel(), + historyBox: NewHistoryBoxModel(), + timeStats: TimeStats{}, + } } -func (m *AppModel) handleHistoryPrev() { - m.historyBoxModel = m.historyBoxModel.PrevSelection() +// Init initializes the app +func (m AppModel) Init() tea.Cmd { + return tea.Batch( + m.refreshCmd, + doTick(), + ) } -func (m *AppModel) handleHistoryEnter() tea.Cmd { - if m.historyBoxModel.viewLevel == HistoryLevelSummary { - // Drill down to details view - m.historyBoxModel = m.historyBoxModel.DrillDown() - return nil - } else { - // TODO: Resume selected entry (punch in with same details) - return nil - } -} +// TickMsg is sent every second to update timers +type TickMsg time.Time -func (m *AppModel) handleHistoryEdit() tea.Cmd { - // TODO: Open edit modal for selected entry - return nil +// doTick returns a command that sends a tick message in a second +func doTick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return TickMsg(t) + }) } -func (m *AppModel) handleHistoryDelete() tea.Cmd { - // TODO: Delete selected entry - return nil -} +// Update handles messages for the app +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd -func (m *AppModel) handleHistoryResume() tea.Cmd { - // TODO: Resume selected entry (punch in with same details) - return nil -} + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height -func (m *AppModel) handleHistoryBack() { - // Switch back to summary view - m.historyBoxModel = m.historyBoxModel.GoBack() -} + case tea.KeyMsg: + cmds = append(cmds, HandleKeyPress(msg, m)) -// Timer action handlers -func (m *AppModel) handleTimerDescribe() { - // Show modal for timer description - m.showModal = true - m.modalType = ModalDescribeTimer - - // Get current description if any - currentDesc := "" - if m.timerBoxModel.timerInfo.Description != "" { - currentDesc = m.timerBoxModel.timerInfo.Description - } - - m.textInputModel = TextInputModel{ - prompt: "Enter timer description:", - value: currentDesc, - placeholder: "Working on...", - cursorPos: len(currentDesc), - } -} + case TickMsg: + m.timerBox.currentTime = time.Time(msg) + cmds = append(cmds, doTick()) -// Modal handling methods -func (m *AppModel) handleModalInput(msg tea.KeyMsg) tea.Cmd { - key := msg.String() - - switch key { - case "enter": - return m.submitModal() - case "escape", "ctrl+c": - m.closeModal() - return nil - case "backspace": - if m.textInputModel.cursorPos > 0 { - // Remove character before cursor - value := m.textInputModel.value - m.textInputModel.value = value[:m.textInputModel.cursorPos-1] + value[m.textInputModel.cursorPos:] - m.textInputModel.cursorPos-- + case dataUpdatedMsg: + m.timerBox.timerInfo = msg.timerInfo + m.timerBox.timerInfo.setNames(msg.clients, msg.projects) + m.timeStats = msg.stats + m.projectsBox.clients = msg.clients + m.projectsBox.projects = msg.projects + m.historyBox.entries = nil + m.historyBox.regenerateSummaries(msg.clients, msg.projects, msg.entries, m.timerBox.timerInfo) + m.err = msg.err + + case navigationMsg: + if msg.Forward { + m.selectedBox = m.selectedBox.Next() + } else { + m.selectedBox = m.selectedBox.Prev() } - case "left": - if m.textInputModel.cursorPos > 0 { - m.textInputModel.cursorPos-- + + case selectionMsg: + switch m.selectedBox { + case ProjectsBox: + m.projectsBox.changeSelection(msg.Forward) + case HistoryBox: + m.historyBox.changeSelection(msg.Forward) } - case "right": - if m.textInputModel.cursorPos < len(m.textInputModel.value) { - m.textInputModel.cursorPos++ + + case drillDownMsg: + if m.selectedBox == HistoryBox { + m.historyBox.drillDown() } - case "home", "ctrl+a": - m.textInputModel.cursorPos = 0 - case "end", "ctrl+e": - m.textInputModel.cursorPos = len(m.textInputModel.value) - default: - // Handle character input - if len(key) == 1 && key[0] >= 32 && key[0] <= 126 { - // Insert character at cursor position - value := m.textInputModel.value - m.textInputModel.value = value[:m.textInputModel.cursorPos] + key + value[m.textInputModel.cursorPos:] - m.textInputModel.cursorPos++ + + case drillUpMsg: + if m.selectedBox == HistoryBox { + m.historyBox.drillUp() } - } - - return nil -} -func (m *AppModel) submitModal() tea.Cmd { - var cmd tea.Cmd - - switch m.modalType { - case ModalDescribeTimer: - // Update the active timer description - cmd = m.updateTimerDescription(m.textInputModel.value) } - - m.closeModal() - return cmd -} - -func (m *AppModel) closeModal() { - m.showModal = false - m.textInputModel = TextInputModel{} -} -func (m *AppModel) updateTimerDescription(description string) tea.Cmd { - return func() tea.Msg { - // Update the active timer's description in the database - var desc sql.NullString - if description != "" { - desc = sql.NullString{String: description, Valid: true} - } - - err := m.queries.UpdateActiveTimerDescription(m.ctx, desc) - if err != nil { - // Handle error silently for now - return nil - } - - // Trigger a data refresh to update the UI - return updateDataCmd{} - } + return m, tea.Batch(cmds...) } // View renders the app @@ -355,177 +172,71 @@ func (m AppModel) View() string { return "Loading..." } - // Calculate dimensions topBarHeight := 1 bottomBarHeight := 1 contentHeight := m.height - topBarHeight - bottomBarHeight - vertBoxOverhead := 6 // 2 border, 4 padding + vertBoxOverhead := 6 // 2 border, 4 padding horizBoxOverhead := 4 // 2 border, 2 padding - // Timer box is in top-left + // Timer box top-left timerBoxWidth := (m.width / 3) - horizBoxOverhead timerBoxHeight := (contentHeight / 2) - vertBoxOverhead - // Clients/Projects box is in bottom-left - clientsProjectsBoxWidth := (m.width / 3) - horizBoxOverhead - clientsProjectsBoxHeight := (contentHeight - timerBoxHeight) - vertBoxOverhead + // Projects box bottom-left + projectsBoxWidth := (m.width / 3) - horizBoxOverhead + projectsBoxHeight := (contentHeight / 2) - vertBoxOverhead - // History box takes the right side - historyBoxWidth := (m.width - (m.width / 3)) - horizBoxOverhead + // History box right side full height + historyBoxWidth := (m.width * 2 / 3) - horizBoxOverhead historyBoxHeight := contentHeight - vertBoxOverhead - // Render top bar with current box info and time stats - viewName := fmt.Sprintf("Selected: %s", m.selectedBox.String()) - // Use cached stats, but add running timer duration to both today's and week's totals if active - currentStats := m.stats - if m.runningTimerStart != nil { - runningDuration := time.Since(*m.runningTimerStart) - currentStats.TodayTotal += runningDuration - currentStats.WeekTotal += runningDuration - } - topBar := RenderTopBar(viewName, currentStats, m.width) + activeDur := m.timerBox.activeTime() + stats := m.timeStats + stats.TodayTotal += activeDur + stats.WeekTotal += activeDur - // Render boxes - timerBox := m.timerBoxModel.View(timerBoxWidth, timerBoxHeight, m.selectedBox == TimerBox) - clientsProjectsBox := m.clientsProjectsModel.View(clientsProjectsBoxWidth, clientsProjectsBoxHeight, m.selectedBox == ClientsProjectsBox) - historyBox := m.historyBoxModel.View(historyBoxWidth, historyBoxHeight, m.selectedBox == HistoryBox) + topBar := RenderTopBar(m) - // Layout: Timer box above Clients/Projects box on the left, History box on the right - leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, clientsProjectsBox) - mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox) + timerBox := m.timerBox.View(timerBoxWidth, timerBoxHeight, m.selectedBox == TimerBox) + projectsBox := m.projectsBox.View(projectsBoxWidth, projectsBoxHeight, m.selectedBox == ProjectsBox) + historyBox := m.historyBox.View(historyBoxWidth, historyBoxHeight, m.selectedBox == HistoryBox, m.timerBox) - // Render bottom bar - keyBindings := GetContextualKeyBindings(m.selectedBox, m.historyBoxModel.viewLevel, m.timerBoxModel.timerInfo.IsActive) - bottomBar := RenderBottomBar(keyBindings, m.width) - - // Combine everything - finalView := topBar + "\n" + mainContent + "\n" + bottomBar - - // Overlay modal if one is active using lipgloss v2 Layers - if m.showModal { - modal := m.renderModal() - - // Create layers for base content and modal - baseLayer := lipgloss.NewLayer(finalView) - modalLayer := lipgloss.NewLayer(modal). - X((m.width - 60) / 2). // Center horizontally - Y((m.height - 8) / 2). // Center vertically - Z(1) // Put modal on top - - // Use lipgloss v2 Canvas to overlay the modal - canvas := lipgloss.NewCanvas(baseLayer, modalLayer) - finalView = canvas.Render() - } - - return finalView -} - -// renderModal renders the modal content with proper styling -func (m AppModel) renderModal() string { - // Modal dimensions - modalWidth := 60 - - // Create modal content based on type - var modalContent string - switch m.modalType { - case ModalDescribeTimer: - modalContent = m.renderDescribeTimerModal(modalWidth-8) // Account for border and padding - } - - // Create modal box with border using lipgloss v2 - modalStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Background(lipgloss.Color("235")). - Padding(1, 2). - Width(modalWidth-4) - - return modalStyle.Render(modalContent) -} + leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, projectsBox) + mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox) + keyBindings := activeBindings(m.selectedBox, m.historyBox.viewLevel) + bottomBar := RenderBottomBar(m, keyBindings, m.err) -// renderDescribeTimerModal renders the timer description modal -func (m AppModel) renderDescribeTimerModal(width int) string { - prompt := m.textInputModel.prompt - value := m.textInputModel.value - placeholder := m.textInputModel.placeholder - cursorPos := m.textInputModel.cursorPos - - // Show placeholder if value is empty - displayValue := value - if displayValue == "" { - displayValue = placeholder - // Style placeholder differently - displayValue = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(displayValue) - } else { - // Insert cursor - if cursorPos >= 0 && cursorPos <= len(value) { - if cursorPos == len(value) { - displayValue = value + "│" - } else { - displayValue = value[:cursorPos] + "│" + value[cursorPos:] - } - } - } - - // Input field styling - inputStyle := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("6")). - Padding(0, 1). - Width(width - 2) - - inputField := inputStyle.Render(displayValue) - - instructions := lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")). - Render("Press Enter to save, Escape to cancel") - - return prompt + "\n\n" + inputField + "\n\n" + instructions + return topBar + "\n" + mainContent + "\n" + bottomBar } -// tickCmd returns a command that sends a tick message every second -func (m AppModel) tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return TickMsg(t) - }) -} - -// updateDataCmd triggers a data update -type updateDataCmd struct{} - // dataUpdatedMsg is sent when data is updated from the database type dataUpdatedMsg struct { timerInfo TimerInfo stats TimeStats clients []queries.Client - projects []queries.ListAllProjectsRow + projects map[int64][]queries.Project entries []queries.TimeEntry + err error } -// updateDataCmd returns a command to update data -func (m AppModel) updateDataCmd() tea.Cmd { - return func() tea.Msg { - timerInfo, stats, clients, projects, entries, err := GetAppData(m.ctx, m.queries) - if err != nil { - // Handle error silently for now - return empty data - return dataUpdatedMsg{ - timerInfo: TimerInfo{}, - stats: TimeStats{}, - clients: []queries.Client{}, - projects: []queries.ListAllProjectsRow{}, - entries: []queries.TimeEntry{}, - } - } +// refreshCmd is a command to update all app data +func (m AppModel) refreshCmd() tea.Msg { + timerInfo, stats, clients, projects, entries, err := getAppData(m.ctx, m.queries) + if err != nil { + msg := dataUpdatedMsg{} + msg.err = err + return msg + } - return dataUpdatedMsg{ - timerInfo: timerInfo, - stats: stats, - clients: clients, - projects: projects, - entries: entries, - } + return dataUpdatedMsg{ + timerInfo: timerInfo, + stats: stats, + clients: clients, + projects: projects, + entries: entries, + err: nil, } } |