diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-28 10:47:28 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-07 09:24:18 +0300 |
| commit | 4ddd1f139d4e7934c127f86c2dd2a142d2a44118 (patch) | |
| tree | b83a76adeb465f182941193d2de2395bbd186b1e /internal | |
| parent | a37e561acddaaa63ca574788a0826755a2a7a446 (diff) | |
ui: add ultra mode task actions (vq)
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/ui/handlers.go | 29 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 2 | ||||
| -rw-r--r-- | internal/ui/table.go | 7 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 283 | ||||
| -rw-r--r-- | internal/ui/ultra.go | 370 |
5 files changed, 652 insertions, 39 deletions
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index d61e166..2d3208a 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -346,6 +346,11 @@ func (m *Model) handleAddTaskMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.tbl.SetCursor(row) m.tbl.SetColumnCursor(7) // Description column m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + if m.showUltra { + m.ultraFocusedID = newID + m.selectTaskByID(newID) + m.ultraFocusedID = 0 + } return m, m.startBlink(newID, false) } return m, nil @@ -466,7 +471,11 @@ func (m *Model) handleHelpSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // handleBlinkingState handles input when a task is blinking func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { if _, ok := msg.(tea.KeyPressMsg); ok { - // Only allow navigation while blinking + if m.showUltra { + return m.handleUltraBlinkingState(msg.(tea.KeyPressMsg)) + } + + // Only allow navigation while blinking. prevRow := m.tbl.Cursor() prevCol := m.tbl.ColumnCursor() var cmd tea.Cmd @@ -479,6 +488,24 @@ func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleUltraBlinkingState(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "j", "down": + m.ultraMoveCursor(1) + case "k", "up": + m.ultraMoveCursor(-1) + case "pgdn", "pgdown", "space": + m.ultraMoveCursor(m.ultraVisibleCount()) + case "pgup", "b": + m.ultraMoveCursor(-m.ultraVisibleCount()) + case "g", "home": + m.ultraGoHome() + case "G", "end": + m.ultraGoEnd() + } + return m, nil +} + // handleEditingModes checks if we're in any editing mode and handles it func (m *Model) handleEditingModes(msg tea.KeyPressMsg) (handled bool, model tea.Model, cmd tea.Cmd) { switch { diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index 613efd5..13333aa 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -111,6 +111,7 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "i": return m.handleEnterOrEdit() case "u": + m.ultraClearFocusedID() m.showUltra = true m.ultraCursor = m.tbl.Cursor() m.ultraOffset = 0 @@ -146,6 +147,7 @@ func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) { func (m *Model) handleQuitOrEscape() (tea.Model, tea.Cmd) { if m.showUltra { + m.ultraClearFocusedID() m.showUltra = false m.ultraSearchRegex = nil m.ultraFiltered = nil diff --git a/internal/ui/table.go b/internal/ui/table.go index 48ed9d5..42487c1 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -87,6 +87,7 @@ type ultraState struct { ultraSearchInput textinput.Model ultraSearchRegex *regexp.Regexp ultraFiltered []int + ultraFocusedID int } // editState holds inline field-editing state for the task table. @@ -387,6 +388,7 @@ func (m *Model) reload() error { // Always show only pending tasks by default. filters := append([]string(nil), m.filters...) filters = append(filters, "status:pending") + ultraFilterIDs := m.ultraFilteredTaskIDs() tasks, err := task.Export(filters...) if err != nil { return err @@ -432,6 +434,7 @@ func (m *Model) reload() error { if len(m.searchMatches) > 0 { m.searchIndex = 0 } + m.rebuildUltraFiltered(ultraFilterIDs) if m.tbl.Columns() == nil { m.tbl, m.tblStyles = m.newTable(rows) @@ -439,6 +442,7 @@ func (m *Model) reload() error { m.tbl.SetRows(rows) m.applyColumns() } + m.reconcileUltraSelection() m.updateSelectionHighlight(-1, m.tbl.Cursor(), 0, m.tbl.ColumnCursor()) return nil } @@ -536,6 +540,9 @@ func (m *Model) handleEditDone(msg editDoneMsg) (tea.Model, tea.Cmd) { if msg.err != nil { m.showError(fmt.Errorf("editor: %w", msg.err)) } + if m.showUltra { + m.ultraFocusedID = m.editID + } m.reload() cmd := m.startBlink(m.editID, false) m.editID = 0 diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index b3e40dd..bb9d0e1 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "regexp" "strings" "testing" @@ -623,6 +624,35 @@ func setupUltraTaskSet(t *testing.T, tmp string) string { return taskPath } +func setupUltraReloadTaskSet(t *testing.T, tmp string) (string, string) { + taskPath := filepath.Join(tmp, "task") + phaseFile := filepath.Join(tmp, "phase") + if err := os.WriteFile(phaseFile, []byte("1"), 0o644); err != nil { + t.Fatal(err) + } + + script := fmt.Sprintf("#!/bin/sh\n"+ + "phase=$(cat %q)\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " if [ \"$phase\" = \"1\" ]; then\n"+ + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"one\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":0}'\n"+ + " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"two\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"M\",\"urgency\":0}'\n"+ + " echo '{\"id\":3,\"uuid\":\"3\",\"description\":\"three\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":0}'\n"+ + " else\n"+ + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"one\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"H\",\"urgency\":0}'\n"+ + " echo '{\"id\":2,\"uuid\":\"2\",\"description\":\"two\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"L\",\"urgency\":0}'\n"+ + " echo '{\"id\":3,\"uuid\":\"3\",\"description\":\"three\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"M\",\"urgency\":0}'\n"+ + " fi\n"+ + " exit 0\n"+ + "fi\n", phaseFile) + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + return taskPath, phaseFile +} + func setupBasicTask(t *testing.T, tmp string) string { taskPath := filepath.Join(tmp, "task") script := "#!/bin/sh\n" + @@ -685,6 +715,7 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) { t.Fatalf("New: %v", err) } + m.ultraFocusedID = 42 mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) if cmd != nil { t.Fatalf("u unexpectedly returned a command") @@ -693,10 +724,14 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) { if !m.showUltra { t.Fatalf("u did not enter ultra mode") } + if m.ultraFocusedID != 0 { + t.Fatalf("u did not clear ultraFocusedID, got %d", m.ultraFocusedID) + } m.ultraSearchRegex = regexp.MustCompile("alpha") m.ultraFiltered = []int{0, 1} m.ultraSearchInput.SetValue("ultra needle") + m.ultraFocusedID = 17 mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'q', Text: "q"}) if cmd != nil { @@ -715,12 +750,16 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) { if got := m.ultraSearchInput.Value(); got != "" { t.Fatalf("q did not clear ultraSearchInput, got %q", got) } + if m.ultraFocusedID != 0 { + t.Fatalf("q did not clear ultraFocusedID, got %d", m.ultraFocusedID) + } mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) if cmd != nil { t.Fatalf("u unexpectedly returned a command on re-entry") } m = *mv.(*Model) + m.ultraFocusedID = 23 m.ultraSearchRegex = regexp.MustCompile("beta") m.ultraFiltered = []int{2} m.ultraSearchInput.SetValue("second needle") @@ -742,6 +781,78 @@ func TestUltraExitHotkeysClearUltraState(t *testing.T) { if got := m.ultraSearchInput.Value(); got != "" { t.Fatalf("esc did not clear ultraSearchInput, got %q", got) } + if m.ultraFocusedID != 0 { + t.Fatalf("esc did not clear ultraFocusedID, got %d", m.ultraFocusedID) + } +} + +func TestUltraFocusedIDLifecycleAcrossNormalEditEntryAndReload(t *testing.T) { + tmp := t.TempDir() + taskPath := setupBasicTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, cmd := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if cmd != nil { + t.Fatalf("resize unexpectedly returned a command") + } + m = *mv.(*Model) + + m.blinkEnabled = false + m.editID = 1 + + mv, cmd = (&m).Update(editDoneMsg{}) + if cmd != nil { + t.Fatalf("editDone unexpectedly returned a command") + } + m = *mv.(*Model) + if m.ultraFocusedID != 0 { + t.Fatalf("normal edit completion left ultraFocusedID=%d, want 0", m.ultraFocusedID) + } + + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + if cmd != nil { + t.Fatalf("j unexpectedly returned a command") + } + m = *mv.(*Model) + if got := m.tbl.Cursor(); got != 1 { + t.Fatalf("cursor after j = %d, want 1", got) + } + + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if cmd != nil { + t.Fatalf("u unexpectedly returned a command") + } + m = *mv.(*Model) + if !m.showUltra { + t.Fatalf("u did not enter ultra mode") + } + if m.ultraFocusedID != 0 { + t.Fatalf("u left ultraFocusedID=%d, want 0", m.ultraFocusedID) + } + if got := m.ultraCursor; got != 1 { + t.Fatalf("ultra cursor after entry = %d, want 1", got) + } + + if err := m.reload(); err != nil { + t.Fatalf("reload: %v", err) + } + if m.ultraFocusedID != 0 { + t.Fatalf("reload left ultraFocusedID=%d, want 0", m.ultraFocusedID) + } + if got := m.ultraCursor; got != 1 { + t.Fatalf("ultra cursor after reload = %d, want 1", got) + } + if got := m.ultraTaskList()[m.ultraCursor].ID; got != 2 { + t.Fatalf("reload snapped to task %d, want 2", got) + } + if got := m.tbl.Cursor(); got != 1 { + t.Fatalf("table cursor after reload = %d, want 1", got) + } } func TestUltraEntryResizeAndNavigationBindings(t *testing.T) { @@ -849,6 +960,178 @@ func TestUltraEntryResizeAndNavigationBindings(t *testing.T) { } } +func TestUltraBlinkUsesVisibleSelectionAndRendersBlink(t *testing.T) { + tmp := t.TempDir() + taskPath := setupUltraTaskSet(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + step := func(msg tea.KeyPressMsg) { + t.Helper() + mv, cmd := (&m).Update(msg) + if cmd != nil { + t.Fatalf("%q unexpectedly returned a command", msg.String()) + } + m = *mv.(*Model) + } + + mv, cmd := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if cmd != nil { + t.Fatalf("resize unexpectedly returned a command") + } + m = *mv.(*Model) + + step(tea.KeyPressMsg{Code: 'j', Text: "j"}) + step(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if !m.showUltra { + t.Fatalf("u did not enter ultra mode") + } + + m.blinkID = m.tasks[m.ultraCursor].ID + m.blinkOn = false + baseView := m.renderUltraModus() + m.blinkOn = true + blinkView := m.renderUltraModus() + if baseView == blinkView { + t.Fatalf("ultra view did not change when blink state toggled") + } + + beforeTable := m.tbl.Cursor() + beforeUltra := m.ultraCursor + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + if cmd != nil { + t.Fatalf("blink navigation unexpectedly returned a command") + } + m = *mv.(*Model) + if m.tbl.Cursor() != beforeTable { + t.Fatalf("blink navigation moved hidden table cursor: got %d want %d", m.tbl.Cursor(), beforeTable) + } + if m.ultraCursor != beforeUltra+1 { + t.Fatalf("blink navigation did not move visible ultra cursor: got %d want %d", m.ultraCursor, beforeUltra+1) + } +} + +func TestUltraPriorityOpUsesUltraSelection(t *testing.T) { + tmp := t.TempDir() + taskPath := setupUltraTaskSet(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + m.showUltra = true + m.tbl.SetCursor(0) + m.ultraCursor = 1 + + hiddenID := m.tasks[m.tbl.Cursor()].ID + selectedID := m.ultraTaskList()[m.ultraCursor].ID + if hiddenID == selectedID { + t.Fatalf("test setup failed: hidden and ultra selections match") + } + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + if cmd != nil { + t.Fatalf("ultra priority hotkey unexpectedly returned a command") + } + m = *mv.(*Model) + + if m.priorityID != selectedID { + t.Fatalf("priority editor targeted task %d, want ultra-selected task %d", m.priorityID, selectedID) + } + if m.priorityID == hiddenID { + t.Fatalf("priority editor followed hidden table cursor %d instead of ultra selection %d", hiddenID, selectedID) + } + if !m.prioritySelecting { + t.Fatalf("priority editor was not activated") + } +} + +func TestUltraReloadPreservesFilteredSelection(t *testing.T) { + tmp := t.TempDir() + taskPath, phaseFile := setupUltraReloadTaskSet(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + if got := m.tasks; len(got) != 3 || got[0].ID != 3 || got[1].ID != 2 || got[2].ID != 1 { + t.Fatalf("unexpected initial sort order: %+v", got) + } + + m.showUltra = true + m.ultraFiltered = []int{0, 2} + m.ultraCursor = 1 + m.ultraOffset = 0 + if got := m.ultraTaskList(); len(got) != 2 || got[0].ID != 3 || got[1].ID != 1 { + t.Fatalf("unexpected initial ultra filter list: %+v", got) + } + + if err := os.WriteFile(phaseFile, []byte("2"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.reload(); err != nil { + t.Fatalf("reload: %v", err) + } + + if got := m.tasks; len(got) != 3 || got[0].ID != 1 || got[1].ID != 3 || got[2].ID != 2 { + t.Fatalf("unexpected reloaded sort order: %+v", got) + } + if !reflect.DeepEqual(m.ultraFiltered, []int{1, 0}) { + t.Fatalf("ultraFiltered was not rebuilt from task IDs: %#v", m.ultraFiltered) + } + if got := m.ultraTaskList(); len(got) != 2 || got[0].ID != 3 || got[1].ID != 1 { + t.Fatalf("ultra task list changed selection order: %+v", got) + } + if got := m.ultraTaskList()[m.ultraCursor].ID; got != 1 { + t.Fatalf("ultra cursor drifted after reload: got task %d want 1", got) + } +} + +func TestUltraInlineOverlayRenders(t *testing.T) { + tmp := t.TempDir() + taskPath := setupBasicTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, cmd := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if cmd != nil { + t.Fatalf("resize unexpectedly returned a command") + } + m = *mv.(*Model) + + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + if cmd != nil { + t.Fatalf("u unexpectedly returned a command") + } + m = *mv.(*Model) + + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + if cmd != nil { + t.Fatalf("f unexpectedly returned a command") + } + m = *mv.(*Model) + if !m.filterEditing { + t.Fatalf("f did not activate filter editing in ultra mode") + } + + view := m.View().Content + if !strings.Contains(view, "filter:") { + t.Fatalf("ultra view did not render filter overlay: %q", view) + } +} + // TestExpandedCellViewNoDoubleRender is a regression test for a bug where // expandedCellView() was appended to the layout unconditionally AND again // inside the cellExpanded guard, producing a duplicate line when expanded. diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go index 8e7ad2a..74d6c8c 100644 --- a/internal/ui/ultra.go +++ b/internal/ui/ultra.go @@ -18,7 +18,7 @@ func (m *Model) renderUltraModus() string { top := m.ultraStatusLine(m.ultraModeStatus(tasks), width) bottom := m.ultraStatusLine(m.ultraCursorStatus(tasks), width) - overlay, overlayHeight := m.ultraSearchOverlay() + overlay, overlayHeight := m.ultraOverlay() var lines []string lines = append(lines, top) @@ -87,7 +87,7 @@ func (m *Model) ultraVisibleCount() int { width := m.ultraRenderWidth() top := m.ultraStatusLine(m.ultraModeStatus(tasks), width) bottom := m.ultraStatusLine(m.ultraCursorStatus(tasks), width) - _, overlayHeight := m.ultraSearchOverlay() + _, overlayHeight := m.ultraOverlay() budget := m.ultraCardBudget(top, bottom, overlayHeight) if budget <= 0 { @@ -140,6 +140,146 @@ func (m *Model) ultraTaskList() []task.Task { return tasks } +func (m *Model) ultraFilteredTaskIDs() []int { + if m.ultraFiltered == nil { + return nil + } + + ids := make([]int, 0, len(m.ultraFiltered)) + for _, idx := range m.ultraFiltered { + if idx < 0 || idx >= len(m.tasks) { + continue + } + ids = append(ids, m.tasks[idx].ID) + } + return ids +} + +func (m *Model) rebuildUltraFiltered(ids []int) { + if m.ultraFiltered == nil { + return + } + + indexes := make([]int, 0, len(ids)) + for _, id := range ids { + if idx := m.taskIndexByID(id); idx >= 0 { + indexes = append(indexes, idx) + } + } + m.ultraFiltered = indexes +} + +func (m *Model) getUltraSelectedTaskID() (int, error) { + tasks := m.ultraTaskList() + if len(tasks) == 0 { + return 0, fmt.Errorf("no ultra tasks available") + } + if m.ultraCursor < 0 || m.ultraCursor >= len(tasks) { + return 0, fmt.Errorf("ultra cursor %d out of range", m.ultraCursor) + } + return tasks[m.ultraCursor].ID, nil +} + +func (m *Model) ultraTaskIndexByID(id int) int { + for i, t := range m.ultraTaskList() { + if t.ID == id { + return i + } + } + return -1 +} + +func (m *Model) taskIndexByID(id int) int { + for i, t := range m.tasks { + if t.ID == id { + return i + } + } + return -1 +} + +func (m *Model) selectTaskByID(id int) bool { + row := m.taskIndexByID(id) + if row < 0 { + return false + } + + prevRow := m.tbl.Cursor() + prevCol := m.tbl.ColumnCursor() + m.tbl.SetCursor(row) + + if m.showUltra { + if ultraRow := m.ultraTaskIndexByID(id); ultraRow >= 0 { + m.ultraCursor = ultraRow + } + m.ultraEnsureVisible() + } + + if prevRow != m.tbl.Cursor() || prevCol != m.tbl.ColumnCursor() { + m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) + } + return true +} + +func (m *Model) reconcileUltraSelection() { + if !m.showUltra { + return + } + + if m.ultraFocusedID > 0 { + _ = m.selectTaskByID(m.ultraFocusedID) + m.ultraFocusedID = 0 + m.ultraEnsureVisible() + return + } + + m.ultraEnsureVisible() +} + +func (m *Model) ultraOverlay() (string, int) { + overlay, overlayHeight := m.ultraSearchOverlay() + + inputOverlay := m.ultraInputOverlay() + if inputOverlay != "" { + if overlay != "" { + overlay += "\n" + inputOverlay + overlayHeight += lipgloss.Height(inputOverlay) + } else { + overlay = inputOverlay + overlayHeight = lipgloss.Height(inputOverlay) + } + } + + return overlay, overlayHeight +} + +func (m *Model) ultraInputOverlay() string { + switch { + case m.annotating: + return m.annotateInput.View() + case m.dueEditing: + return m.dueView(true) + case m.prioritySelecting: + return m.priorityView(true) + case m.descEditing: + return m.descInput.View() + case m.tagsEditing: + return m.tagsInput.View() + case m.recurEditing: + return m.recurInput.View() + case m.projEditing: + return m.projInput.View() + case m.filterEditing: + return m.filterInput.View() + case m.addingTask: + return m.addInput.View() + case m.searching: + return m.searchInput.View() + default: + return "" + } +} + // ultraVisibleCursor treats ultraCursor as a cursor within the visible ultra task list. func (m *Model) ultraVisibleCursor(tasks []task.Task) int { if len(tasks) == 0 { @@ -193,7 +333,13 @@ func (m *Model) renderUltraCard(t task.Task, width int, selected bool, re *regex if card == "" { return "" } - return ultraCardStyle(m.theme, width, selected).Render(card) + blink := m.blinkID != 0 && m.blinkOn && t.ID == m.blinkID + if blink { + lines := strings.SplitN(card, "\n", 2) + lines[0] = "! " + lines[0] + card = strings.Join(lines, "\n") + } + return ultraCardStyle(m.theme, width, selected, blink).Render(card) } // renderUltraHeader renders the task's primary state line. @@ -328,10 +474,13 @@ func (m *Model) ultraKeyValue(re *regexp.Regexp, label, value string) string { return labelStyle.Render(label+":") + " " + m.ultraStyledText(re, valueStyle, value) } -func ultraCardStyle(theme Theme, width int, selected bool) lipgloss.Style { +func ultraCardStyle(theme Theme, width int, selected, blink bool) lipgloss.Style { style := lipgloss.NewStyle().Width(width) if selected { - return style.Foreground(lipgloss.Color(theme.SelectedFG)).Background(lipgloss.Color(theme.SelectedBG)) + style = style.Foreground(lipgloss.Color(theme.SelectedFG)).Background(lipgloss.Color(theme.SelectedBG)) + } + if blink { + style = style.Bold(true).Reverse(true) } return style } @@ -457,50 +606,195 @@ func (m *Model) ultraEnsureVisible() { // handleUltraMode handles keyboard input in ultra mode. func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - tasks := m.ultraTaskList() - last := len(tasks) - 1 - switch msg.String() { case "q", "esc": return m.handleQuitOrEscape() case "j", "down": - if last >= 0 { - m.ultraCursor++ - if m.ultraCursor > last { - m.ultraCursor = last - } - } - m.ultraEnsureVisible() + m.ultraMoveCursor(1) case "k", "up": - if last >= 0 { - m.ultraCursor-- - if m.ultraCursor < 0 { - m.ultraCursor = 0 - } - } - m.ultraEnsureVisible() + m.ultraMoveCursor(-1) case "pgdn", "pgdown", "space": - m.ultraCursor += m.ultraVisibleCount() - if last >= 0 && m.ultraCursor > last { - m.ultraCursor = last - } - m.ultraEnsureVisible() + m.ultraMoveCursor(m.ultraVisibleCount()) case "pgup", "b": - m.ultraCursor -= m.ultraVisibleCount() + m.ultraMoveCursor(-m.ultraVisibleCount()) + case "g", "home": + m.ultraGoHome() + case "G", "end": + m.ultraGoEnd() + case "enter", "e", "E": + return m.handleUltraEditTask() + case "s": + return m.handleUltraToggleStart() + case "d": + return m.handleUltraMarkDone() + case "p": + return m.handleUltraSetPriority() + case "w": + return m.handleUltraSetDueDate() + case "W": + return m.handleUltraRemoveDueDate() + case "t": + return m.handleUltraEditTags() + case "a": + return m.handleUltraAnnotate(false) + case "A": + return m.handleUltraAnnotate(true) + case "J": + return m.handleUltraEditProject() + case "R": + return m.handleUltraSetRecurrence() + case "f": + return m.handleFilter() + case "+": + m.ultraClearFocusedID() + return m.handleAddTask() + case "U": + return m.handleUndo() + case "c": + return m.handleRandomTheme() + case "C": + return m.handleResetTheme() + case "x": + return m.handleToggleDisco() + case "B": + return m.handleToggleBlink() + } + return m, nil +} + +func (m *Model) ultraMoveCursor(delta int) { + m.ultraFocusedID = 0 + + tasks := m.ultraTaskList() + last := len(tasks) - 1 + if last >= 0 { + m.ultraCursor += delta if m.ultraCursor < 0 { m.ultraCursor = 0 } - m.ultraEnsureVisible() - case "g", "home": - m.ultraCursor = 0 - m.ultraOffset = 0 - case "G", "end": - if last >= 0 { + if m.ultraCursor > last { m.ultraCursor = last - } else { - m.ultraCursor = 0 } - m.ultraEnsureVisible() } - return m, nil + + m.ultraEnsureVisible() +} + +func (m *Model) ultraGoHome() { + m.ultraFocusedID = 0 + m.ultraCursor = 0 + m.ultraOffset = 0 +} + +func (m *Model) ultraGoEnd() { + m.ultraFocusedID = 0 + + tasks := m.ultraTaskList() + if last := len(tasks) - 1; last >= 0 { + m.ultraCursor = last + } else { + m.ultraCursor = 0 + } + + m.ultraEnsureVisible() +} + +func (m *Model) ultraClearFocusedID() { + m.ultraFocusedID = 0 +} + +func (m *Model) ultraPrepareSelectedTask() (int, bool) { + id, err := m.getUltraSelectedTaskID() + if err != nil { + return 0, false + } + if !m.selectTaskByID(id) { + return 0, false + } + + m.ultraFocusedID = id + return id, true +} + +func (m *Model) handleUltraEditTask() (tea.Model, tea.Cmd) { + id, ok := m.ultraPrepareSelectedTask() + if !ok { + return m, nil + } + + m.editID = id + return m, editCmd(id) +} + +func (m *Model) handleUltraToggleStart() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleToggleStart() +} + +func (m *Model) handleUltraMarkDone() (tea.Model, tea.Cmd) { + id, ok := m.ultraPrepareSelectedTask() + if !ok { + return m, nil + } + + return m, m.startBlink(id, true) +} + +func (m *Model) handleUltraSetPriority() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleSetPriority() +} + +func (m *Model) handleUltraSetDueDate() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleSetDueDate() +} + +func (m *Model) handleUltraRemoveDueDate() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleRemoveDueDate() +} + +func (m *Model) handleUltraEditTags() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleEditTags() +} + +func (m *Model) handleUltraAnnotate(replace bool) (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleAnnotate(replace) +} + +func (m *Model) handleUltraEditProject() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleEditProject() +} + +func (m *Model) handleUltraSetRecurrence() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleSetRecurrence() } |
