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 }