diff options
Diffstat (limited to 'internal/tui/app.go')
-rw-r--r-- | internal/tui/app.go | 538 |
1 files changed, 538 insertions, 0 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..98bad4f --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,538 @@ +package tui + +import ( + "context" + "database/sql" + "fmt" + "time" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" + "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()) + + 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{} + })) + + case updateDataCmd: + cmds = append(cmds, m.updateDataCmd()) + } + + 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() + } + + return nil +} + +// 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 + } +} + +// Projects pane action handlers +func (m *AppModel) handleProjectsNext() { + m.clientsProjectsModel = m.clientsProjectsModel.NextSelection() +} + +func (m *AppModel) handleProjectsPrev() { + m.clientsProjectsModel = m.clientsProjectsModel.PrevSelection() +} + +func (m *AppModel) handleProjectsEnter() tea.Cmd { + // TODO: Punch in to selected client/project + return nil +} + +func (m *AppModel) handleProjectsNewProject() tea.Cmd { + // TODO: Open new project modal + return nil +} + +func (m *AppModel) handleProjectsNewClient() tea.Cmd { + // TODO: Open new client modal + return nil +} + +// History pane action handlers +func (m *AppModel) handleHistoryNext() { + m.historyBoxModel = m.historyBoxModel.NextSelection() +} + +func (m *AppModel) handleHistoryPrev() { + m.historyBoxModel = m.historyBoxModel.PrevSelection() +} + +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 + } +} + +func (m *AppModel) handleHistoryEdit() tea.Cmd { + // TODO: Open edit modal for selected entry + return nil +} + +func (m *AppModel) handleHistoryDelete() tea.Cmd { + // TODO: Delete selected entry + return nil +} + +func (m *AppModel) handleHistoryResume() tea.Cmd { + // TODO: Resume selected entry (punch in with same details) + return nil +} + +func (m *AppModel) handleHistoryBack() { + // Switch back to summary view + m.historyBoxModel = m.historyBoxModel.GoBack() +} + +// 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), + } +} + +// 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 "left": + if m.textInputModel.cursorPos > 0 { + m.textInputModel.cursorPos-- + } + case "right": + if m.textInputModel.cursorPos < len(m.textInputModel.value) { + m.textInputModel.cursorPos++ + } + 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++ + } + } + + 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{} + } +} + +// View renders the app +func (m AppModel) View() string { + if m.width == 0 || m.height == 0 { + return "Loading..." + } + + // Calculate dimensions + topBarHeight := 1 + bottomBarHeight := 1 + contentHeight := m.height - topBarHeight - bottomBarHeight + + vertBoxOverhead := 6 // 2 border, 4 padding + horizBoxOverhead := 4 // 2 border, 2 padding + + // Timer box is in 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 + + // History box takes the right side + historyBoxWidth := (m.width - (m.width / 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) + + // 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) + + // 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) + + // 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) +} + + +// 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 +} + +// 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 + entries []queries.TimeEntry +} + +// 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{}, + } + } + + return dataUpdatedMsg{ + timerInfo: timerInfo, + stats: stats, + clients: clients, + projects: projects, + entries: entries, + } + } +} + +// Run starts the TUI application +func Run(ctx context.Context, q *queries.Queries) error { + app := NewApp(ctx, q) + p := tea.NewProgram(app, tea.WithAltScreen()) + _, err := p.Run() + return err +} |