diff --git a/.imgs/priority_prompt.png b/.imgs/priority_prompt.png new file mode 100644 index 0000000..f62c690 Binary files /dev/null and b/.imgs/priority_prompt.png differ diff --git a/README.md b/README.md index f5d4f47..f863ecf 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ org # Opens ./todo.org by default | `p` | Set priority | | `e` | Set effort | | `r` | Toggle reorder mode | -| `shift+↑/↓` | Move item up/down (in reorder mode) | +| `shift+↑/↓` | Move item up/down | | `ctrl+s` | Save | | `?` | Toggle help | | `q` or `ctrl+c` | Quit | @@ -89,6 +89,7 @@ Changes are automatically saved when you quit the application. ### Prompts ![capture](./.imgs/capture_prompt.png) ![delete](./.imgs/delete_prompt.png) +![priority](./.imgs/priority_prompt.png) ## File Format diff --git a/internal/ui/app.go b/internal/ui/app.go index b5968d7..a9a3b8d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -22,24 +22,26 @@ const ( modeSetDeadline modeSetPriority modeSetEffort + modeHelp ) type uiModel struct { - orgFile *model.OrgFile - cursor int - scrollOffset int // Track the scroll position - mode viewMode - help help.Model - keys keyMap - width int - height int - statusMsg string - statusExpiry time.Time - editingItem *model.Item - textarea textarea.Model - textinput textinput.Model - itemToDelete *model.Item - reorderMode bool + orgFile *model.OrgFile + cursor int + scrollOffset int // Track the scroll position + helpScroll int // Track scroll position in help mode + mode viewMode + help help.Model + keys keyMap + width int + height int + statusMsg string + statusExpiry time.Time + editingItem *model.Item + textarea textarea.Model + textinput textinput.Model + itemToDelete *model.Item + reorderMode bool } func initialModel(orgFile *model.OrgFile) uiModel { diff --git a/internal/ui/modes.go b/internal/ui/modes.go index 97df9b4..cd5b518 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -30,6 +30,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSetPriority(msg) case modeSetEffort: return m.updateSetEffort(msg) + case modeHelp: + return m.updateHelp(msg) } switch msg := msg.(type) { @@ -48,7 +50,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, m.keys.Help): - m.help.ShowAll = !m.help.ShowAll + m.mode = modeHelp + m.helpScroll = 0 // Reset scroll when entering help return m, nil case key.Matches(msg, m.keys.Up): @@ -733,3 +736,41 @@ func (m *uiModel) swapItems(item1, item2 *model.Item) { } swapInList(m.orgFile.Items) } + +func (m uiModel) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "?", "esc", "q": + m.mode = modeList + m.helpScroll = 0 // Reset scroll when exiting + return m, nil + case "up", "k": + if m.helpScroll > 0 { + m.helpScroll-- + } + return m, nil + case "down", "j": + m.helpScroll++ + // The view will handle clamping to max scroll + return m, nil + case "pageup": + m.helpScroll -= 10 + if m.helpScroll < 0 { + m.helpScroll = 0 + } + return m, nil + case "pagedown": + m.helpScroll += 10 + return m, nil + case "home", "g": + m.helpScroll = 0 + return m, nil + } + } + return m, nil +} diff --git a/internal/ui/views.go b/internal/ui/views.go index 34ec0ec..042b8d2 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -85,6 +85,8 @@ func (m uiModel) View() string { return m.viewSetPriority() case modeSetEffort: return m.viewSetEffort() + case modeHelp: + return m.viewHelp() } // Build footer (status + help) @@ -421,6 +423,123 @@ func (m uiModel) viewSetEffort() string { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) } +func (m uiModel) viewHelp() string { + // Build the full help content first + var lines []string + + // Title + lines = append(lines, titleStyle.Render("Keybindings Help")) + lines = append(lines, "") + + // Group bindings by category + navigationBindings := []key.Binding{m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right} + itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState} + taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete} + timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetEffort} + organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder} + viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Save, m.keys.Help, m.keys.Quit} + + // Helper function to render a binding + renderBinding := func(b key.Binding) string { + keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true) + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + help := b.Help() + return fmt.Sprintf(" %s %s", keyStyle.Render(help.Key), descStyle.Render(help.Desc)) + } + + // Render categories + categoryStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + + lines = append(lines, categoryStyle.Render("Navigation")) + for _, binding := range navigationBindings { + lines = append(lines, renderBinding(binding)) + } + lines = append(lines, "") + + lines = append(lines, categoryStyle.Render("Item Actions")) + for _, binding := range itemBindings { + lines = append(lines, renderBinding(binding)) + } + lines = append(lines, "") + + lines = append(lines, categoryStyle.Render("Task Management")) + for _, binding := range taskBindings { + lines = append(lines, renderBinding(binding)) + } + lines = append(lines, "") + + lines = append(lines, categoryStyle.Render("Time Tracking")) + for _, binding := range timeBindings { + lines = append(lines, renderBinding(binding)) + } + lines = append(lines, "") + + lines = append(lines, categoryStyle.Render("Organization")) + for _, binding := range organizationBindings { + lines = append(lines, renderBinding(binding)) + } + lines = append(lines, "") + + lines = append(lines, categoryStyle.Render("View & System")) + for _, binding := range viewBindings { + lines = append(lines, renderBinding(binding)) + } + lines = append(lines, "") + + // Calculate visible area + footerLines := 2 // Footer text + availableHeight := m.height - footerLines + if availableHeight < 5 { + availableHeight = 5 + } + + totalLines := len(lines) + + // Determine which lines to show based on scroll offset + startLine := m.helpScroll + endLine := startLine + availableHeight + if endLine > totalLines { + endLine = totalLines + } + if startLine >= totalLines { + startLine = totalLines - 1 + if startLine < 0 { + startLine = 0 + } + } + + // Build visible content + var content strings.Builder + for i := startLine; i < endLine && i < len(lines); i++ { + content.WriteString(lines[i]) + content.WriteString("\n") + } + + // Add scroll indicators and footer + var footer strings.Builder + if startLine > 0 || endLine < totalLines { + scrollInfo := fmt.Sprintf("(Scroll: %d-%d of %d lines)", startLine+1, endLine, totalLines) + footer.WriteString(statusStyle.Render(scrollInfo)) + footer.WriteString(" ") + } + footer.WriteString(statusStyle.Render("↑/↓ scroll • ? or ESC to close")) + + // Combine content and footer + var result strings.Builder + result.WriteString(content.String()) + + // Add padding if needed + currentHeight := lipgloss.Height(content.String()) + paddingNeeded := availableHeight - currentHeight + if paddingNeeded > 0 { + result.WriteString(strings.Repeat("\n", paddingNeeded)) + } + + result.WriteString(footer.String()) + + return result.String() +} + func (m uiModel) viewEditMode() string { var b strings.Builder