From bea0218cf0a23772c86fa389d09d10147e9f30ad Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Thu, 29 Oct 2026 14:30:49 -0400 Subject: [PATCH 01/65] Feat(tui): settings page groups rows under User and Vault by prefix Co-Authored-By: Claude Opus 5.5 --- internal/tui/settings_controls.go | 61 +++++++++++++++++++++++++++-- internal/tui/settings_tasks_test.go | 20 ++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index 68a00fb..6dcb6ad 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -17,6 +17,7 @@ type settingRow struct { } type settingsView struct { scroll, rows int + dscroll int lines []string filter string list listCursor @@ -78,15 +79,69 @@ func (v *settingsView) render(a *App, w, h int, focused bool) string { v.list.ensureVisible(v.rows) v.scroll = v.list.scroll lines := []string{padRight(truncateCells(" Enter edit · / filter · v version · :config external editor", w), w), padRight(" "+v.filter, w)} - for i := v.scroll; i < len(v.lines) && len(lines) < h; i++ { - line := padRight(" "+truncateCells(v.lines[i], max(1, w-2)), w) - if focused && i == v.list.cursor { + disp, cur := v.display(w) + body := max(1, h-2) + if cur >= 0 { + start := cur + for n := 0; n < 2 && start > 0 && disp[start-1].idx < 0; n++ { + start-- + } + if start < v.dscroll { + v.dscroll = start + } else if cur >= v.dscroll+body { + v.dscroll = cur - body + 1 + } + } + v.dscroll = max(0, min(v.dscroll, len(disp)-1)) + for i := v.dscroll; i < len(disp) && len(lines) < h; i++ { + line := padRight(disp[i].text, w) + switch { + case disp[i].idx < 0: + line = a.theme.Heading.Render(line) + case focused && disp[i].idx == v.list.cursor: line = a.theme.SelectedFocus.Render(line) } lines = append(lines, line) } return fitBlock(strings.Join(lines, "\n"), w, h) } + +type settingDisplay struct { + text string + idx int // index into settings; -1 for a heading +} + +// display lays the settings out under two top-level sections, User and Vault, +// each split into subsections named by the config prefix (vim, editor, +// dailyNotes, ...). Rows keep their settings order, so the cursor index maps +// straight onto v.settings. It returns the cell index of the cursor row. +func (v *settingsView) display(w int) ([]settingDisplay, int) { + out := []settingDisplay{} + cur := -1 + scope, sub := "", "" + for i, s := range v.settings { + name := "User" + if s.vault { + name = "Vault" + } + if name != scope { + if scope != "" { + out = append(out, settingDisplay{"", -1}) + } + scope, sub = name, "" + out = append(out, settingDisplay{truncateCells(" "+strings.ToUpper(name), w), -1}) + } + if s.section != sub { + sub = s.section + out = append(out, settingDisplay{truncateCells(" "+sub, w), -1}) + } + if i == v.list.cursor { + cur = len(out) + } + out = append(out, settingDisplay{truncateCells(" "+s.key+" = "+fmt.Sprint(s.value), w), i}) + } + return out, cur +} func (v *settingsView) handleKey(a *App, k vim.Key) bool { if k.IsRune('v') { _ = a.showVersion(vim.ExCommand{Name: "version"}) diff --git a/internal/tui/settings_tasks_test.go b/internal/tui/settings_tasks_test.go index 12173a5..03e35a4 100644 --- a/internal/tui/settings_tasks_test.go +++ b/internal/tui/settings_tasks_test.go @@ -2,6 +2,7 @@ package tui import ( "reflect" + "strings" "testing" ) @@ -27,3 +28,22 @@ func TestSetTasksFolderList(t *testing.T) { t.Fatalf("emptying both lists should drop tasks, got %#v", raw) } } + +func TestSettingsDisplayGroupsUserAndVaultByPrefix(t *testing.T) { + v := &settingsView{settings: []settingRow{ + {"vim", "a", 1, false}, {"vim", "b", 2, false}, {"editor", "c", 3, false}, + {"dailyNotes", "enabled", true, true}, {"tasks", "includedFolders", "x", true}, + }} + disp, cur := v.display(40) + var got []string + for _, d := range disp { + got = append(got, strings.TrimSpace(d.text)) + } + want := []string{"USER", "vim", "a = 1", "b = 2", "editor", "c = 3", "", "VAULT", "dailyNotes", "enabled = true", "tasks", "includedFolders = x"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("layout = %q", got) + } + if cur != 2 || disp[cur].idx != 0 { + t.Fatalf("cursor row = %d", cur) + } +} From 6500bb1e26c598b8b1790df21c15df4c1a70739e Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 16:39:31 -0400 Subject: [PATCH 02/65] Feat(tui): place calendar tasks by due and scheduled date The Tasks calendar is getting month, week and day views, and each needs to know which open tasks fall on which day. Until now only the due date counted, through the grouping the side calendar and Home share. bucketCalendarEntries places a task on its due day and, when it has a different scheduled date, on that day too, flagged as scheduled; a task whose two dates match shows once. Due entries come before scheduled ones and each group keeps the scan order. Done, cancelled and forwarded tasks are left out. Tasks with no date are returned for a separate "No date" list, ordered by priority and then alphabetically. The calendar uses its own grouping so the side calendar panel and BucketTasksByDueDate stay exactly as they are; new tests pin the panel's rendering and click mapping and the existing grouping, so a later change to the Tasks calendar cannot alter them by accident. Co-Authored-By: Claude Opus 5.5 --- internal/tui/calendar_entries.go | 67 ++++++ internal/tui/calendar_entries_test.go | 199 ++++++++++++++++++ internal/tui/sidepanel_calendar_guard_test.go | 108 ++++++++++ internal/vault/tasks_bucket_guard_test.go | 31 +++ 4 files changed, 405 insertions(+) create mode 100644 internal/tui/calendar_entries.go create mode 100644 internal/tui/calendar_entries_test.go create mode 100644 internal/tui/sidepanel_calendar_guard_test.go create mode 100644 internal/vault/tasks_bucket_guard_test.go diff --git a/internal/tui/calendar_entries.go b/internal/tui/calendar_entries.go new file mode 100644 index 0000000..73c033b --- /dev/null +++ b/internal/tui/calendar_entries.go @@ -0,0 +1,67 @@ +package tui + +import ( + "sort" + "strings" + + "github.com/ZenNotes/tui/internal/vault" +) + +// calEntry is one task placed on a calendar day. scheduled is true when the +// task's scheduled date placed it rather than its due date. +type calEntry struct { + task vault.Task + scheduled bool +} + +// calPriorityRank orders the strip: high, medium, low, then none. +var calPriorityRank = map[string]int{"high": 0, "med": 1, "low": 2} + +func calRank(priority string) int { + if r, ok := calPriorityRank[priority]; ok { + return r + } + return len(calPriorityRank) +} + +// bucketCalendarEntries places open tasks on the days of their due and +// scheduled dates. A task whose two dates are the same day appears once, as a +// due entry. On each day due entries come before scheduled ones, and each +// group keeps the input order. Tasks with neither date are returned as +// undated, ordered by priority and then by lower-cased text, ties keeping +// the input order. Closed, cancelled and forwarded tasks are left out. +func bucketCalendarEntries(tasks []vault.Task) (map[string][]calEntry, []vault.Task) { + due := map[string][]calEntry{} + sched := map[string][]calEntry{} + var undated []vault.Task + for _, t := range tasks { + if !vault.IsTaskOpen(t) || t.Forwarded { + continue + } + if t.Due == "" && t.Scheduled == "" { + undated = append(undated, t) + continue + } + if t.Due != "" { + due[t.Due] = append(due[t.Due], calEntry{task: t}) + } + if t.Scheduled != "" && t.Scheduled != t.Due { + sched[t.Scheduled] = append(sched[t.Scheduled], calEntry{task: t, scheduled: true}) + } + } + byDay := make(map[string][]calEntry, len(due)+len(sched)) + for day, entries := range due { + byDay[day] = append(byDay[day], entries...) + } + for day, entries := range sched { + byDay[day] = append(byDay[day], entries...) + } + sort.SliceStable(undated, func(i, j int) bool { + ri, rj := calRank(undated[i].Priority), calRank(undated[j].Priority) + if ri != rj { + return ri < rj + } + return strings.ToLower(undated[i].Content) < strings.ToLower(undated[j].Content) + }) + return byDay, undated +} diff --git a/internal/tui/calendar_entries_test.go b/internal/tui/calendar_entries_test.go new file mode 100644 index 0000000..ca4dbd8 --- /dev/null +++ b/internal/tui/calendar_entries_test.go @@ -0,0 +1,199 @@ +package tui + +import ( + "reflect" + "testing" + + "github.com/ZenNotes/tui/internal/vault" +) + +// entryIDs lists the task ids of a day's entries in order. +func entryIDs(entries []calEntry) []string { + out := []string{} + for _, e := range entries { + out = append(out, e.task.ID) + } + return out +} + +func taskIDs(tasks []vault.Task) []string { + out := []string{} + for _, t := range tasks { + out = append(out, t.ID) + } + return out +} + +func TestCalendarEntriesDueOnly(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Due: "2026-11-18"}} + byDay, undated := bucketCalendarEntries(tasks) + got := byDay["2026-11-18"] + if len(got) != 1 || got[0].task.ID != "a" || got[0].scheduled { + t.Errorf("entries on 2026-11-18 = %+v, want one due entry for a", got) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } + if len(byDay) != 1 { + t.Errorf("days with entries = %d, want 1", len(byDay)) + } +} + +func TestCalendarEntriesInferredDailyDue(t *testing.T) { + raw := []vault.Task{{ID: "daily#0", SourcePath: "inbox/2026-11-18.md", Content: "from the daily note"}} + tasks := vault.InferDailyTaskDueDates(raw, map[string]string{"inbox/2026-11-18.md": "2026-11-18"}) + byDay, undated := bucketCalendarEntries(tasks) + got := byDay["2026-11-18"] + if len(got) != 1 || got[0].task.ID != "daily#0" || got[0].scheduled { + t.Errorf("entries on 2026-11-18 = %+v, want one due entry for daily#0", got) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } +} + +func TestCalendarEntriesDueAndScheduledOnTwoDays(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Due: "2026-11-18", Scheduled: "2026-11-16"}} + byDay, undated := bucketCalendarEntries(tasks) + due := byDay["2026-11-18"] + if len(due) != 1 || due[0].task.ID != "a" || due[0].scheduled { + t.Errorf("entries on 2026-11-18 = %+v, want one due entry", due) + } + sched := byDay["2026-11-16"] + if len(sched) != 1 || sched[0].task.ID != "a" || !sched[0].scheduled { + t.Errorf("entries on 2026-11-16 = %+v, want one scheduled entry", sched) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } +} + +func TestCalendarEntriesSameDayShowsOnce(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Due: "2026-11-18", Scheduled: "2026-11-18"}} + byDay, _ := bucketCalendarEntries(tasks) + got := byDay["2026-11-18"] + if len(got) != 1 || got[0].task.ID != "a" { + t.Errorf("entries on 2026-11-18 = %+v, want exactly one for a", got) + } +} + +func TestCalendarEntriesScheduledOnly(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Scheduled: "2026-11-16"}} + byDay, undated := bucketCalendarEntries(tasks) + got := byDay["2026-11-16"] + if len(got) != 1 || got[0].task.ID != "a" || !got[0].scheduled { + t.Errorf("entries on 2026-11-16 = %+v, want one scheduled entry", got) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none: a scheduled task is placed", taskIDs(undated)) + } +} + +func TestCalendarEntriesHideClosedTasks(t *testing.T) { + tasks := []vault.Task{ + {ID: "checked", Checked: true, Due: "2026-11-18"}, + {ID: "cancelled", Cancelled: true, Due: "2026-11-18"}, + {ID: "forwarded", Forwarded: true, Due: "2026-11-18"}, + {ID: "closed-sched", Checked: true, Scheduled: "2026-11-16"}, + {ID: "closed-undated", Cancelled: true}, + } + byDay, undated := bucketCalendarEntries(tasks) + if len(byDay) != 0 { + t.Errorf("days with entries = %v, want none", byDay) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } +} + +func TestCalendarEntriesUndatedGoToStrip(t *testing.T) { + tasks := []vault.Task{ + {ID: "u", Content: "u"}, + {ID: "d", Content: "d", Due: "2026-11-18"}, + } + byDay, undated := bucketCalendarEntries(tasks) + if got := taskIDs(undated); !reflect.DeepEqual(got, []string{"u"}) { + t.Errorf("undated = %v, want [u]", got) + } + for day, entries := range byDay { + for _, e := range entries { + if e.task.ID == "u" { + t.Errorf("undated task listed on %s", day) + } + } + } +} + +func TestCalendarEntriesDueBeforeScheduled(t *testing.T) { + // Scan order interleaves scheduled and due entries for one day. + tasks := []vault.Task{ + {ID: "s1", Scheduled: "2026-11-18"}, + {ID: "d1", Due: "2026-11-18"}, + {ID: "s2", Scheduled: "2026-11-18"}, + {ID: "d2", Due: "2026-11-18"}, + } + byDay, _ := bucketCalendarEntries(tasks) + if got, want := entryIDs(byDay["2026-11-18"]), []string{"d1", "d2", "s1", "s2"}; !reflect.DeepEqual(got, want) { + t.Errorf("order on 2026-11-18 = %v, want %v", got, want) + } +} + +func TestCalendarEntriesKeepScanOrderWithinGroup(t *testing.T) { + // Ids and text are deliberately not in alphabetical order. + tasks := []vault.Task{ + {ID: "z", Content: "zulu", Due: "2026-11-18"}, + {ID: "m", Content: "mike", Due: "2026-11-18"}, + {ID: "a", Content: "alpha", Due: "2026-11-18"}, + {ID: "y", Content: "yankee", Scheduled: "2026-11-18"}, + {ID: "b", Content: "bravo", Scheduled: "2026-11-18"}, + } + byDay, _ := bucketCalendarEntries(tasks) + if got, want := entryIDs(byDay["2026-11-18"]), []string{"z", "m", "a", "y", "b"}; !reflect.DeepEqual(got, want) { + t.Errorf("order on 2026-11-18 = %v, want %v", got, want) + } +} + +func TestCalendarStripOrderPriorityThenText(t *testing.T) { + tasks := []vault.Task{ + {ID: "beta", Content: "beta"}, + {ID: "Alpha", Content: "Alpha", Priority: "low"}, + {ID: "gamma", Content: "gamma", Priority: "high"}, + {ID: "delta", Content: "delta", Priority: "high"}, + } + _, undated := bucketCalendarEntries(tasks) + if got, want := taskIDs(undated), []string{"delta", "gamma", "Alpha", "beta"}; !reflect.DeepEqual(got, want) { + t.Errorf("strip order = %v, want %v", got, want) + } +} + +func TestCalendarStripOrderMedBetweenHighAndLow(t *testing.T) { + tasks := []vault.Task{ + {ID: "low", Content: "a", Priority: "low"}, + {ID: "none", Content: "a"}, + {ID: "med", Content: "z", Priority: "med"}, + {ID: "high", Content: "z", Priority: "high"}, + } + _, undated := bucketCalendarEntries(tasks) + if got, want := taskIDs(undated), []string{"high", "med", "low", "none"}; !reflect.DeepEqual(got, want) { + t.Errorf("strip order = %v, want %v", got, want) + } +} + +func TestCalendarStripOrderTiesKeepScanOrder(t *testing.T) { + tasks := []vault.Task{ + {ID: "first", Content: "same", Priority: "med"}, + {ID: "second", Content: "SAME", Priority: "med"}, + {ID: "third", Content: "same", Priority: "med"}, + } + _, undated := bucketCalendarEntries(tasks) + if got, want := taskIDs(undated), []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) { + t.Errorf("strip order = %v, want %v", got, want) + } +} + +func TestCalendarEntriesEmptyInput(t *testing.T) { + byDay, undated := bucketCalendarEntries(nil) + if len(byDay) != 0 || len(undated) != 0 { + t.Errorf("empty input gave days=%v undated=%v", byDay, taskIDs(undated)) + } +} diff --git a/internal/tui/sidepanel_calendar_guard_test.go b/internal/tui/sidepanel_calendar_guard_test.go new file mode 100644 index 0000000..b9f9700 --- /dev/null +++ b/internal/tui/sidepanel_calendar_guard_test.go @@ -0,0 +1,108 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vault" +) + +func guardCalendarApp(t *testing.T) *App { + t.Helper() + a, _ := newFeatureTestApp(t) + a.idx.tasks = []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "pay rent", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "call mum", Due: "2026-11-18", Scheduled: "2026-11-16"}, + {ID: "n#2", SourcePath: "n.md", Content: "scheduled only", Scheduled: "2026-11-20"}, + {ID: "n#3", SourcePath: "n.md", Content: "no date"}, + {ID: "n#4", SourcePath: "n.md", Content: "later", Due: "2026-11-25"}, + } + a.calendarOpen = true + a.calendar.month = time.Date(2026, 11, 1, 0, 0, 0, 0, time.Local) + a.calendar.day = time.Date(2026, 11, 18, 0, 0, 0, 0, time.Local) + a.calendar.hasDaily = nil + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = false + return a +} + +// The side calendar panel keeps its compact grid, its digit markers and its +// day list whatever the Tasks tab does with its own calendar. +func TestSideCalendarPanelRenderUnchanged(t *testing.T) { + a := guardCalendarApp(t) + got := stripAnsi(a.calendar.render(a, 40, 16)) + var trimmed []string + for _, l := range strings.Split(got, "\n") { + trimmed = append(trimmed, strings.TrimRight(l, " ")) + } + want := strings.Join([]string{ + " Calendar", + " November 2026", + " Mo Tu We Th Fr Sa Su", + " 26 27 28 29 30 31 1", + " 2 3 4 5 6 7 8", + " 9 10 11 12 13 14 15", + " 16 17 18 2 19 20 21 22", + " 23 24 25 1 26 27 28 29", + " 30 1 2 3 4 5 6", + "", + " No daily note. Enter creates it.", + " ☐ pay rent", + " ☐ call mum", + "", + "", + "", + }, "\n") + if strings.Join(trimmed, "\n") != want { + t.Errorf("side calendar render changed:\n%s", strings.Join(trimmed, "\n")) + } +} + +// dayAtCell maps clicks on the side panel's grid to days, with and without +// the week-number column. +func TestSideCalendarPanelClickUnchanged(t *testing.T) { + for _, weekNumbers := range []bool{false, true} { + a := guardCalendarApp(t) + a.prefs.CalendarShowWeekNumbers = weekNumbers + a.calendar.render(a, 40, 16) + first, weeks := a.calendar.calFirst, a.calendar.calWeeks + if first.Format("2006-01-02") != "2026-10-26" || weeks != 6 { + t.Fatalf("grid = %s x %d weeks, want 2026-10-26 x 6", first.Format("2006-01-02"), weeks) + } + offset := 0 + if weekNumbers { + offset = 4 + } + type cell struct { + name string + x, y int + want string + ok bool + } + cases := []cell{ + {"first cell", offset, 2, "2026-10-26", true}, + {"second column", offset + 5, 2, "2026-10-27", true}, + {"last column", offset + 30, 2, "2026-11-01", true}, + {"third row", offset + 10, 4, "2026-11-11", true}, + {"last row", offset + 15, 7, "2026-12-03", true}, + {"title row", offset, 0, "", false}, + {"header row", offset, 1, "", false}, + {"below grid", offset, 8, "", false}, + {"right of grid", offset + 35, 2, "", false}, + } + if weekNumbers { + cases = append(cases, cell{"week number column", 2, 2, "", false}) + } + for _, tc := range cases { + day, ok := dayAtCell(first, weeks, tc.x, tc.y, weekNumbers) + got := "" + if ok { + got = day.Format("2006-01-02") + } + if ok != tc.ok || got != tc.want { + t.Errorf("weekNumbers=%v %s: dayAtCell(%d,%d) = %q,%v want %q,%v", weekNumbers, tc.name, tc.x, tc.y, got, ok, tc.want, tc.ok) + } + } + } +} diff --git a/internal/vault/tasks_bucket_guard_test.go b/internal/vault/tasks_bucket_guard_test.go new file mode 100644 index 0000000..b16ae0f --- /dev/null +++ b/internal/vault/tasks_bucket_guard_test.go @@ -0,0 +1,31 @@ +package vault + +import ( + "reflect" + "testing" +) + +// BucketTasksByDueDate feeds Home and the side calendar panel; its result +// must not change when other surfaces learn about other dates. +func TestBucketTasksByDueDateUnchanged(t *testing.T) { + tasks := []Task{ + {ID: "due-a", Due: "2026-11-18"}, + {ID: "due-b", Due: "2026-11-18", Scheduled: "2026-11-16"}, + {ID: "sched-only", Scheduled: "2026-11-16"}, + {ID: "undated"}, + {ID: "checked", Checked: true, Due: "2026-11-18"}, + {ID: "cancelled", Cancelled: true}, + {ID: "forwarded-dated", Forwarded: true, Due: "2026-11-18"}, + {ID: "forwarded-undated", Forwarded: true}, + {ID: "due-c", Due: "2026-11-19"}, + } + got := BucketTasksByDueDate(tasks) + want := map[string][]Task{ + "2026-11-18": {tasks[0], tasks[1], tasks[6]}, + "2026-11-19": {tasks[8]}, + "unscheduled": {tasks[2], tasks[3]}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("BucketTasksByDueDate = %+v, want %+v", got, want) + } +} From 0381c692aef02c1297643ba7d4ef8928ae4ee866 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 17:03:22 -0400 Subject: [PATCH 03/65] Fix(tui): a new note opens in the editor whatever the default pane mode createNote opened the note with the default pane mode (or the mode remembered for that path) and then entered insert mode. With a preview or split default, you were typing into a buffer the screen didn't show. The new note's tab is now set to edit mode directly, which leaves the default pane mode and the remembered per-note modes unchanged. Co-Authored-By: Claude Opus 5.5 --- internal/tui/newnote_mode_test.go | 22 ++++++++++++++++++++++ internal/tui/noteops.go | 6 ++++++ 2 files changed, 28 insertions(+) create mode 100644 internal/tui/newnote_mode_test.go diff --git a/internal/tui/newnote_mode_test.go b/internal/tui/newnote_mode_test.go new file mode 100644 index 0000000..0d6aceb --- /dev/null +++ b/internal/tui/newnote_mode_test.go @@ -0,0 +1,22 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/vault" +) + +// A freshly created note is entered in insert mode, so it must show the +// editor even when the default pane mode is preview. +func TestCreateNoteOpensInEditMode(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.DefaultPaneMode = string(modePreview) + a.createNote(vault.FolderInbox, "Fresh", "", nil, true) + tb := a.activeTab() + if tb == nil { + t.Fatal("no active tab") + } + if tb.mode != modeEdit { + t.Fatalf("new note opened in mode %q, want edit", tb.mode) + } +} diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index b013b50..6b8bbfc 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -29,6 +29,12 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.addNoteToIndex(meta) if open { a.openNote(meta.Path, true) + // The new note is entered for typing, so show the editor whatever + // the default pane mode is. + if t := a.activeTab(); t != nil && t.view == nil && t.path == meta.Path { + t.mode = modeEdit + a.layout() + } a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) if a.prefs.VimMode { From 9f47fd81a4eca139c604944a1c446bd50320f65f Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 18:09:23 -0400 Subject: [PATCH 04/65] Feat(tui): the Tasks calendar opens on a full-page month grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar mode drew a 35-column mini-grid with the selected day's tasks listed underneath, so a month's work could only be read one day at a time. It now fills the page: weekday names across the top, a box per day with its date in the top-left corner and its tasks inside, cut to the box's width (wide characters included) and closed with "+N more" when they do not fit. The selected day's tasks sit in a panel beside the grid and tasks without a date in a strip below it; an entry placed by its scheduled date carries a "»" marker. Week numbers and the week start follow the existing calendar preferences, and the task filter applies as before. Below 72x18 cells the body says "Terminal too small" instead of drawing a grid too cramped to read. The calendar moves into its own component that computes the layout once per frame; drawing, mouse hit-testing and the selection position all read that layout, so they cannot disagree about where a day is. The side calendar panel keeps its own renderer and is unchanged. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_mouse.go | 30 +- internal/tui/views_tasks.go | 91 ++--- internal/tui/views_tasks_calendar.go | 389 ++++++++++++++++++ internal/tui/views_tasks_calendar_test.go | 455 ++++++++++++++++++++++ 4 files changed, 873 insertions(+), 92 deletions(-) create mode 100644 internal/tui/views_tasks_calendar.go create mode 100644 internal/tui/views_tasks_calendar_test.go diff --git a/internal/tui/views_mouse.go b/internal/tui/views_mouse.go index c48a085..d313344 100644 --- a/internal/tui/views_mouse.go +++ b/internal/tui/views_mouse.go @@ -41,29 +41,7 @@ func (v *tasksView) click(a *App, p *pane, x, y int, m tea.MouseMsg, double bool a.openTaskSource(t) } case "calendar": - gridX := x - 1 - gridY := y - 1 - if day, ok := dayAtCell(v.calFirst, v.calWeeks, gridX, gridY, a.prefs.CalendarShowWeekNumbers); ok { - v.selectDay(a, day) - v.dayFocus = false - if double { - v.calendarKey(a, vim.KeyEnter) - } - return - } - listTop := 1 + 2 + v.calWeeks + 1 - idx := y - listTop - if idx >= 0 && idx < len(v.dayTasks) { - v.dayFocus = true - v.dayCursor = idx - t := v.dayTasks[idx] - switch { - case right: - a.taskMenu(t) - case double: - a.openTaskSource(t) - } - } + v.calc().click(a, v, x, y-1, right, double) default: idx := v.list.scroll + y - 1 if idx < 0 || idx >= len(v.rows) || v.rows[idx].task == nil { @@ -170,10 +148,8 @@ func (v *tasksView) selectionPos(a *App, p *pane) (int, int, bool) { } return a.contentRect(p).x + v.col*(colW+1) + 2, top + 1 + 2 + (v.card-start)*3 + 1, true case "calendar": - if v.dayFocus { - return a.contentRect(p).x + 4, top + 1 + 2 + v.calWeeks + 1 + v.dayCursor + 1, true - } - return a.contentRect(p).x + 8, top + 1 + 2 + 1, true + cx, cy := v.calc().selectionCell(v.day) + return a.contentRect(p).x + cx, top + 1 + cy, true } return a.contentRect(p).x + 4, top + 1 + (v.list.cursor - v.list.scroll) + 1, true } diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index b5fd016..92e5623 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -28,17 +28,12 @@ type tasksView struct { col int card int - month time.Time - day time.Time - dayTasks []vault.Task - dayCursor int - dayFocus bool - byDay map[string][]vault.Task - pageRows int + month time.Time + day time.Time + cal *taskCalendar + pageRows int boardStarts []int - calFirst time.Time - calWeeks int } type taskRow struct { @@ -69,6 +64,14 @@ func newTasksView(a *App) *tasksView { return v } +// calc is the calendar state, made on first use. +func (v *tasksView) calc() *taskCalendar { + if v.cal == nil { + v.cal = newTaskCalendar() + } + return v.cal +} + func (v *tasksView) title() string { return "Tasks" } func (v *tasksView) hint(a *App) string { return a.keysHint(v.hintPairs(a)) } @@ -120,11 +123,7 @@ func (v *tasksView) refresh(a *App) { v.list.cursor++ } v.buildColumns(a) - v.byDay = vault.BucketTasksByDueDate(v.tasks) - v.dayTasks = v.byDay[v.day.Format("2006-01-02")] - if v.dayCursor >= len(v.dayTasks) { - v.dayCursor = max(0, len(v.dayTasks)-1) - } + v.calc().rebuild(v.tasks, v.day) } // filterTasks applies the cross-view filter: free words match content and @@ -499,7 +498,7 @@ func (v *tasksView) render(a *App, w, h int, focused bool) string { case "kanban": body = v.renderBoard(a, w, h-1, focused) case "calendar": - body = v.renderCalendar(a, w, h-1, focused) + body = v.calc().render(a, v.month, v.day, w, h-1, focused) default: body = v.renderList(a, w, h-1, focused) } @@ -662,39 +661,6 @@ func (v *tasksView) renderBoard(a *App, w, h int, focused bool) string { return lipgloss.JoinHorizontal(lipgloss.Top, blocks...) } -func (v *tasksView) renderCalendar(a *App, w, h int, focused bool) string { - th := a.theme - weekStartMonday := a.prefs.CalendarWeekStart != "sunday" - grid, first, weeks := renderMonthGrid(a, v.month, v.day, weekStartMonday, a.prefs.CalendarShowWeekNumbers, func(day time.Time) (string, lipgloss.Style) { - tasks := v.byDay[day.Format("2006-01-02")] - open := 0 - for _, t := range tasks { - if !t.Checked && !t.Cancelled && !t.Forwarded { - open++ - } - } - if open == 0 { - return "", th.Base - } - return fmt.Sprint(open), th.Marker - }, focused && !v.dayFocus) - v.calFirst, v.calWeeks = first, weeks - gridLines := strings.Split(grid, "\n") - lines := []string{} - for _, gl := range gridLines { - lines = append(lines, padRight(" "+gl, w)) - } - lines = append(lines, padRight(th.Bold.Render(" "+v.day.Format("Monday, January 2")+fmt.Sprintf(" (%d tasks)", len(v.dayTasks))), w)) - listH := h - len(lines) - if len(v.dayTasks) == 0 { - lines = append(lines, padRight(" "+th.Muted.Render("No tasks due this day. n adds one to the daily note."), w)) - } - for i := 0; i < len(v.dayTasks) && i < listH; i++ { - lines = append(lines, v.taskLine(a, v.dayTasks[i], w, focused && v.dayFocus && i == v.dayCursor)) - } - return fitBlock(strings.Join(lines, "\n"), w, h) -} - // renderMonthGrid draws a month with a marker per day. func renderMonthGrid(a *App, month, selected time.Time, mondayFirst, weekNumbers bool, mark func(day time.Time) (string, lipgloss.Style), focused bool) (string, time.Time, int) { th := a.theme @@ -847,11 +813,7 @@ func (v *tasksView) selectedTask() *vault.Task { } return nil case "calendar": - if v.dayFocus && v.dayCursor < len(v.dayTasks) { - t := v.dayTasks[v.dayCursor] - return &t - } - return nil + return v.calc().selected(v.day) } if v.list.cursor < len(v.rows) { return v.rows[v.list.cursor].task @@ -1080,17 +1042,17 @@ func (v *tasksView) moveCard(a *App, t vault.Task, target int) { func (v *tasksView) calendarKey(a *App, k vim.Key) bool { t := v.selectedTask() - if v.dayFocus { + if v.calc().focus != calFocusGrid { switch { case k.Is("esc") || k.IsRune('h') || k.Is("left"): - v.dayFocus = false + v.calc().focus = calFocusGrid case k.IsRune('j') || k.Is("down"): - if v.dayCursor < len(v.dayTasks)-1 { - v.dayCursor++ + if v.calc().cursor < len(v.calc().dayEntries(v.day))-1 { + v.calc().cursor++ } case k.IsRune('k') || k.Is("up"): - if v.dayCursor > 0 { - v.dayCursor-- + if v.calc().cursor > 0 { + v.calc().cursor-- } case k.Is("enter") || k.IsRune('o'): if t != nil { @@ -1128,13 +1090,13 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { v.month = v.month.AddDate(0, 1, 0) v.selectDay(a, v.day.AddDate(0, 1, 0)) case k.IsRune('t') || k.IsRune('T'): - now := time.Now() + now := calendarNow() v.month = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) v.selectDay(a, now) case k.Is("enter"): - if len(v.dayTasks) > 0 { - v.dayFocus = true - v.dayCursor = 0 + if len(v.calc().dayEntries(v.day)) > 0 { + v.calc().focus = calFocusItems + v.calc().cursor = 0 } else { a.openPeriodicOn(periodic.Daily, v.day) } @@ -1153,8 +1115,7 @@ func (v *tasksView) selectDay(a *App, day time.Time) { if v.day.Month() != v.month.Month() || v.day.Year() != v.month.Year() { v.month = time.Date(v.day.Year(), v.day.Month(), 1, 0, 0, 0, 0, time.Local) } - v.dayTasks = v.byDay[v.day.Format("2006-01-02")] - v.dayCursor = 0 + v.calc().cursor = 0 } func (v *tasksView) hintTargets(a *App, p *pane) []hintTarget { diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go new file mode 100644 index 0000000..a8ad6d2 --- /dev/null +++ b/internal/tui/views_tasks_calendar.go @@ -0,0 +1,389 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +// calendarNow is the clock the calendar reads for "today", so tests can fix it. +var calendarNow = time.Now + +// calSub is the calendar's layout: a month of day boxes, or a week or a day +// of columns. +type calSub int + +const ( + calMonth calSub = iota + calWeek + calDay +) + +// calFocus is the part of the calendar that takes the keys. +type calFocus int + +const ( + calFocusGrid calFocus = iota + calFocusItems + calFocusStrip +) + +// calMin holds the smallest body (width, height) each layout renders in. +// Below it the body only says the terminal is too small. +var calMin = map[calSub][2]int{ + calMonth: {72, 18}, + calWeek: {64, 12}, + calDay: {40, 10}, +} + +// calScheduledMark prefixes an entry placed by its scheduled date. It must +// stay one cell wide. +const calScheduledMark = "»" + +// calRect is a region of the calendar body, in body cells. +type calRect struct{ x, y, w, h int } + +func (r calRect) contains(x, y int) bool { + return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h +} + +// calDayBox is where one day sits in the grid. +type calDayBox struct { + day time.Time + rect calRect +} + +// calLayout is every region of the calendar body for one size. Rendering, +// clicks and menu placement all read the last one, so the geometry is +// written once. +type calLayout struct { + w, h int + tooSmall bool + items calRect + strip calRect + boxes []calDayBox + itemStart int + stripRows int +} + +// taskCalendar is the Tasks tab's calendar: day entries, the items beside +// the grid, and the strip of tasks without a date. +type taskCalendar struct { + sub calSub + focus calFocus + cursor int + strip int + + byDay map[string][]calEntry + undated []vault.Task + layout calLayout +} + +func newTaskCalendar() *taskCalendar { return &taskCalendar{} } + +// rebuild places the (already filtered) tasks and keeps the cursors inside +// the new lists. +func (c *taskCalendar) rebuild(tasks []vault.Task, day time.Time) { + c.byDay, c.undated = bucketCalendarEntries(tasks) + entries := c.dayEntries(day) + if c.cursor >= len(entries) { + c.cursor = max(0, len(entries)-1) + } + if c.focus == calFocusItems && len(entries) == 0 { + c.focus = calFocusGrid + } +} + +func (c *taskCalendar) dayEntries(day time.Time) []calEntry { + return c.byDay[day.Format("2006-01-02")] +} + +// selected is the task the item panel or the strip has selected, if any. +func (c *taskCalendar) selected(day time.Time) *vault.Task { + switch c.focus { + case calFocusItems: + entries := c.dayEntries(day) + if c.cursor < len(entries) { + t := entries[c.cursor].task + return &t + } + case calFocusStrip: + if c.strip < len(c.undated) { + t := c.undated[c.strip] + return &t + } + } + return nil +} + +// monthLayout computes the regions of the month view for a body of w x h. +func (c *taskCalendar) monthLayout(a *App, month time.Time, w, h int) calLayout { + lay := calLayout{w: w, h: h} + if m := calMin[calMonth]; w < m[0] || h < m[1] { + lay.tooSmall = true + return lay + } + panelW := clampInt(w*30/100, 26, 44) + gw := w - panelW + wnW := 0 + if a.prefs.CalendarShowWeekNumbers { + wnW = 3 + } + colW := (gw - wnW) / 7 + stripRows := clampInt(h/8, 2, 6) + stripH := 1 + stripRows + gridH := h - 2 - stripH + first, weeks := monthGridStart(month, a.prefs.CalendarWeekStart != "sunday") + rowH := gridH / weeks + lay.items = calRect{x: gw, y: 0, w: panelW, h: h - stripH} + lay.strip = calRect{x: 0, y: h - stripH, w: w, h: stripH} + lay.stripRows = stripRows + day := first + for r := 0; r < weeks; r++ { + for col := 0; col < 7; col++ { + lay.boxes = append(lay.boxes, calDayBox{day: day, rect: calRect{x: wnW + col*colW, y: 2 + r*rowH, w: colW, h: rowH}}) + day = day.AddDate(0, 0, 1) + } + } + return lay +} + +// monthGridStart is the first day shown for a month and how many week rows +// cover it. +func monthGridStart(month time.Time, mondayFirst bool) (time.Time, int) { + first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local) + offset := int(first.Weekday()) + if mondayFirst { + offset = (offset + 6) % 7 + } + days := first.AddDate(0, 1, -1).Day() + return first.AddDate(0, 0, -offset), (offset + days + 6) / 7 +} + +// render draws the calendar into a w x h body. +func (c *taskCalendar) render(a *App, month, day time.Time, w, h int, focused bool) string { + lay := c.monthLayout(a, month, w, h) + c.layout = lay + th := a.theme + if lay.tooSmall { + m := calMin[calMonth] + lines := make([]string, max(h, 0)) + if len(lines) > 0 { + msg := fmt.Sprintf("Terminal too small: needs %d×%d", m[0], m[1]) + lines[len(lines)/2] = centerLine(th.Muted.Render(msg), w) + } + return fitBlock(strings.Join(lines, "\n"), w, h) + } + + weeks := len(lay.boxes) / 7 + gw := lay.items.x + wnW := lay.boxes[0].rect.x + colW := lay.boxes[0].rect.w + rowH := lay.boxes[0].rect.h + left := make([]string, lay.items.h) + left[0] = padRight(" "+th.Title.Render(month.Format("January 2006")), gw) + names := []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + if a.prefs.CalendarWeekStart == "sunday" { + names = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + } + head := strings.Repeat(" ", wnW) + for _, n := range names { + head += padRight(" "+th.Dim.Render(n), colW) + } + left[1] = padRight(head, gw) + today := calendarNow() + for r := 0; r < weeks; r++ { + for li := 0; li < rowH; li++ { + var sb strings.Builder + if wnW > 0 { + if li == 0 { + anchor := lay.boxes[r*7].day + if a.prefs.CalendarWeekStart == "sunday" { + anchor = anchor.AddDate(0, 0, 1) + } + _, wk := anchor.ISOWeek() + sb.WriteString(th.Muted.Render(fmt.Sprintf("%2d ", wk))) + } else { + sb.WriteString(strings.Repeat(" ", wnW)) + } + } + for col := 0; col < 7; col++ { + box := lay.boxes[r*7+col] + sb.WriteString(c.boxLine(a, box, li, sameDay(box.day, day), sameDay(box.day, today), box.day.Month() != month.Month(), focused)) + } + left[2+r*rowH+li] = padRight(sb.String(), gw) + } + } + for i := range left { + if left[i] == "" { + left[i] = strings.Repeat(" ", gw) + } + } + + panel := c.itemPanel(a, day, lay, focused) + rows := make([]string, 0, h) + for i := 0; i < lay.items.h; i++ { + rows = append(rows, left[i]+panel[i]) + } + rows = append(rows, c.stripLines(a, lay, w, focused)...) + return fitBlock(strings.Join(rows, "\n"), w, h) +} + +// boxLine is line li of one day box: the date number on top, then one line +// per entry, with "+N more" on the last line when they do not all fit. +func (c *taskCalendar) boxLine(a *App, box calDayBox, li int, selected, today, outside, focused bool) string { + th := a.theme + w := box.rect.w + text := "" + style := th.Base + if outside { + style = th.Muted + } + if li == 0 { + text = fmt.Sprintf(" %d", box.day.Day()) + if today && !selected { + style = th.Base.Foreground(th.Accent).Bold(true) + } + } else { + entries := c.dayEntries(box.day) + avail := box.rect.h - 1 + shown := len(entries) + if shown > avail { + shown = avail - 1 + } + switch { + case li-1 < shown: + e := entries[li-1] + lead := " " + if e.scheduled { + lead = calScheduledMark + } + text = lead + truncateCells(e.task.Content, w-1) + case li-1 == shown && shown < len(entries): + text = " " + fmt.Sprintf("+%d more", len(entries)-shown) + if !selected { + style = th.Dim + } + } + } + if selected { + if focused && c.focus == calFocusGrid { + style = th.SelectedFocus + } else { + style = th.Selected + } + } + return style.Render(padRight(text, w)) +} + +// itemPanel lists every entry of the selected day beside the grid. +func (c *taskCalendar) itemPanel(a *App, day time.Time, lay calLayout, focused bool) []string { + th := a.theme + pw := lay.items.w + sep := th.Dim.Render("│") + entries := c.dayEntries(day) + out := make([]string, lay.items.h) + out[0] = padRight(sep+" "+th.Bold.Render(day.Format("Monday, January 2"))+th.Muted.Render(fmt.Sprintf(" (%d)", len(entries))), pw) + rows := lay.items.h - 1 + start := 0 + if c.cursor >= rows { + start = c.cursor - rows + 1 + } + c.layout.itemStart = start + for i := 1; i < lay.items.h; i++ { + line := sep + idx := start + i - 1 + switch { + case idx < len(entries): + e := entries[idx] + lead := " " + if e.scheduled { + lead = calScheduledMark + } + body := " " + lead + " " + truncateCells(e.task.Content, pw-4) + if focused && c.focus == calFocusItems && idx == c.cursor { + body = th.SelectedFocus.Render(padRight(body, pw-1)) + } + line += body + case len(entries) == 0 && i == 1: + line += " " + th.Muted.Render(truncateCells("Nothing here. n adds a task.", pw-2)) + } + out[i] = padRight(line, pw) + } + return out +} + +// stripLines draws the strip of tasks without a date under the grid. +func (c *taskCalendar) stripLines(a *App, lay calLayout, w int, focused bool) []string { + th := a.theme + out := make([]string, lay.strip.h) + out[0] = padRight(" "+th.Bold.Render("No date")+th.Muted.Render(fmt.Sprintf(" (%d)", len(c.undated))), w) + rows := lay.stripRows + start := 0 + if c.focus == calFocusStrip && c.strip >= rows { + start = c.strip - rows + 1 + } + for i := 0; i < rows; i++ { + idx := start + i + line := "" + if idx < len(c.undated) { + line = " " + truncateCells(c.undated[idx].Content, w-2) + if focused && c.focus == calFocusStrip && idx == c.strip { + line = th.SelectedFocus.Render(padRight(line, w)) + } + } + out[1+i] = padRight(line, w) + } + return out +} + +// click handles a mouse press at body cell (x, y). It reports whether the + +func (c *taskCalendar) click(a *App, v *tasksView, x, y int, right, double bool) { + lay := c.layout + if lay.tooSmall { + return + } + for _, b := range lay.boxes { + if b.rect.contains(x, y) { + v.selectDay(a, b.day) + c.focus = calFocusGrid + if double { + v.calendarKey(a, vim.KeyEnter) + } + return + } + } + if lay.items.contains(x, y) && y > lay.items.y { + idx := lay.itemStart + y - lay.items.y - 1 + entries := c.dayEntries(v.day) + if idx < len(entries) { + c.focus = calFocusItems + c.cursor = idx + t := entries[idx].task + switch { + case right: + a.taskMenu(t) + case double: + a.openTaskSource(t) + } + } + } +} + +// selectionCell is the body cell menus open next to. +func (c *taskCalendar) selectionCell(day time.Time) (int, int) { + lay := c.layout + if c.focus == calFocusItems { + return lay.items.x + 3, lay.items.y + 1 + c.cursor - lay.itemStart + } + for _, b := range lay.boxes { + if sameDay(b.day, day) { + return b.rect.x + 1, b.rect.y + } + } + return 0, 0 +} diff --git a/internal/tui/views_tasks_calendar_test.go b/internal/tui/views_tasks_calendar_test.go new file mode 100644 index 0000000..c0d372d --- /dev/null +++ b/internal/tui/views_tasks_calendar_test.go @@ -0,0 +1,455 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +func calDate(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.Local) +} + +// calTestApp opens the Tasks tab in calendar mode on 2026-11-18 with the given +// tasks as the vault's scan result. +func calTestApp(t *testing.T, tasks []vault.Task) (*App, *tasksView) { + t.Helper() + a, _ := newFeatureTestApp(t) + a.idx.tasks = tasks + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = true + return calOpen(t, a) +} + +func calOpen(t *testing.T, a *App) (*App, *tasksView) { + t.Helper() + if err := a.openTasksMode("calendar"); err != nil { + t.Fatal(err) + } + tab := a.activeTab() + if tab == nil { + t.Fatal("no active tab after opening the Tasks tab") + } + tv, ok := tab.view.(*tasksView) + if !ok { + t.Fatalf("active tab is %T, want *tasksView", tab.view) + } + tv.refresh(a) + tv.selectDay(a, calDate(2026, time.November, 18)) + return a, tv +} + +// calScreen renders the calendar into a body of w x bodyH cells (the Tasks +// view adds its one-line toolbar on top) and returns the lines without styling. +func calScreen(a *App, tv *tasksView, w, bodyH int) []string { + return strings.Split(stripAnsi(tv.render(a, w, bodyH+1, true)), "\n") +} + +var calWeekdayNames = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} +var calSundayFirst = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + +// calWeekdayHeader finds the line carrying all seven weekday names in the +// given order and returns the column each name starts at. +func calWeekdayHeader(lines []string, order []string) []int { + for _, l := range lines { + cols := make([]int, len(order)) + ok := true + prev := -1 + for i, n := range order { + c := strings.Index(l, n) + if c <= prev { + ok = false + break + } + cols[i], prev = c, c + } + if ok { + return cols + } + } + return nil +} + +// requireFullMonthGrid fails unless the screen shows a month grid spread over +// a 120-wide page (day boxes), not the compact mini-grid with a day list. +func requireFullMonthGrid(t *testing.T, lines []string) { + t.Helper() + cols := calWeekdayHeader(lines, calWeekdayNames) + if cols == nil { + cols = calWeekdayHeader(lines, calSundayFirst) + } + if cols == nil { + t.Fatalf("no weekday header found:\n%s", calScreenText(lines)) + } + if last := cols[len(cols)-1]; last < 45 { + t.Fatalf("weekday header ends at column %d: the grid is not full page:\n%s", last, calScreenText(lines)) + } + joined := calScreenText(lines) + if strings.Contains(joined, "No tasks due this day") || strings.Contains(joined, "tasks)") { + t.Fatalf("the old mini-grid day list is still shown:\n%s", joined) + } +} + +// requireFullMonthGridAt is requireFullMonthGrid for other body widths. +func requireFullMonthGridAt(t *testing.T, lines []string, w int) { + t.Helper() + cols := calWeekdayHeader(lines, calWeekdayNames) + if cols == nil { + t.Fatalf("w=%d: no weekday header found:\n%s", w, calScreenText(lines)) + } + if cols[6] < w/3 { + t.Fatalf("w=%d: header ends at column %d: the grid is not full page:\n%s", w, cols[6], calScreenText(lines)) + } + joined := calScreenText(lines) + if strings.Contains(joined, "No tasks due this day") || strings.Contains(joined, "tasks)") { + t.Fatalf("w=%d: the old mini-grid day list is still shown:\n%s", w, joined) + } +} + +func calScreenText(lines []string) string { return strings.Join(lines, "\n") } + +func TestCalendarOpensOnMonthGrid(t *testing.T) { + a, tv := calTestApp(t, nil) + requireFullMonthGrid(t, calScreen(a, tv, 120, 40)) + + // Coming from the Tasks tab's kanban mode with v. + tv.setMode("kanban") + if !tv.handleKey(a, vim.Key{Rune: 'v'}) { + t.Fatal("v was not handled in kanban mode") + } + if tv.mode != "calendar" { + t.Fatalf("mode after v = %q, want calendar", tv.mode) + } + requireFullMonthGrid(t, calScreen(a, tv, 120, 40)) +} + +func TestMonthWeekdayHeaderFollowsWeekStart(t *testing.T) { + a, tv := calTestApp(t, nil) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + if calWeekdayHeader(lines, calWeekdayNames) == nil { + t.Errorf("Monday-first header not found:\n%s", calScreenText(lines)) + } + + a.prefs.CalendarWeekStart = "sunday" + lines = calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + if calWeekdayHeader(lines, calSundayFirst) == nil { + t.Errorf("Sunday-first header not found:\n%s", calScreenText(lines)) + } +} + +func TestMonthDayBoxesShowDateNumbers(t *testing.T) { + a, tv := calTestApp(t, nil) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + re := regexp.MustCompile(`(^|\s)(\d{1,2})(\s|$)`) + seen := map[string]bool{} + for _, l := range lines { + for _, m := range re.FindAllStringSubmatch(l, -1) { + seen[m[2]] = true + } + } + for d := 1; d <= 30; d++ { + if !seen[fmt.Sprint(d)] { + t.Errorf("date number %d not shown", d) + } + } +} + +func weekNumberShown(lines []string, n int) bool { + re := regexp.MustCompile(fmt.Sprintf(`(^|\s)%d(\s|$)`, n)) + for _, l := range lines { + if re.MatchString(l) { + return true + } + } + return false +} + +func TestMonthWeekNumbersFollowPreference(t *testing.T) { + // November 2026 spans ISO weeks 44 to 49. + a, tv := calTestApp(t, nil) + a.prefs.CalendarShowWeekNumbers = true + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + for n := 44; n <= 49; n++ { + if !weekNumberShown(lines, n) { + t.Errorf("week number %d missing with week numbers on:\n%s", n, calScreenText(lines)) + } + } + + a.prefs.CalendarShowWeekNumbers = false + lines = calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + for n := 44; n <= 49; n++ { + if weekNumberShown(lines, n) { + t.Errorf("week number %d shown with week numbers off", n) + } + } +} + +func TestMonthBoxListsEntriesCutToWidth(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "zebra alpha long text", Due: "2026-11-10"}, + {ID: "n#1", SourcePath: "n.md", Content: "zebra bravo long text", Due: "2026-11-10"}, + {ID: "n#2", SourcePath: "n.md", Content: "zebra charlie long text", Due: "2026-11-10"}, + }) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + if got := strings.Count(text, "zebra"); got != 3 { + t.Errorf("entries listed = %d, want one line per entry (3):\n%s", got, text) + } + if strings.Contains(text, "long text") { + t.Errorf("entry text is not cut to the box width:\n%s", text) + } +} + +func TestMonthBoxCutsWideCharactersToWidth(t *testing.T) { + wide := strings.Repeat("日本語", 12) + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: wide, Due: "2026-11-10"}, + }) + for _, w := range []int{72, 100, 120} { + lines := calScreen(a, tv, w, 40) + requireFullMonthGridAt(t, lines, w) + text := calScreenText(lines) + if !strings.Contains(text, "日") { + t.Errorf("w=%d: wide-character entry not shown:\n%s", w, text) + } + if strings.Contains(text, wide) { + t.Errorf("w=%d: wide-character entry not cut", w) + } + for i, l := range lines { + if lipgloss.Width(l) > w { + t.Errorf("w=%d line %d is %d cells wide: %q", w, i, lipgloss.Width(l), l) + } + } + } +} + +func TestMonthBoxOverflowShowsMoreCount(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 50; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("zq%02d", i), Due: "2026-11-10"}) + } + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + listed := len(regexp.MustCompile(`zq\d\d`).FindAllString(text, -1)) + if listed == 0 || listed >= 50 { + t.Fatalf("listed %d of 50 entries in a box, want some but not all:\n%s", listed, text) + } + more := regexp.MustCompile(`\+(\d+) more`).FindAllStringSubmatch(text, -1) + if len(more) != 1 { + t.Fatalf("found %d `+N more` lines, want 1:\n%s", len(more), text) + } + if want := fmt.Sprint(50 - listed); more[0][1] != want { + t.Errorf("+%s more, want +%s more for %d listed", more[0][1], want, listed) + } +} + +func TestMonthItemPanelListsSelectedDay(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 6; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("panel entry number %d in full", i), Due: "2026-11-18"}) + } + tasks = append(tasks, + vault.Task{ID: "n#6", SourcePath: "n.md", Kind: "file", Content: "panel scheduled entry in full", Scheduled: "2026-11-18"}, + vault.Task{ID: "n#7", SourcePath: "n.md", Content: "other day entry in full text", Due: "2026-11-19"}, + ) + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + for i := 0; i < 6; i++ { + if want := fmt.Sprintf("panel entry number %d in full", i); !strings.Contains(text, want) { + t.Errorf("item panel is missing %q:\n%s", want, text) + } + } + if !strings.Contains(text, "panel scheduled entry in full") { + t.Errorf("item panel is missing the scheduled entry:\n%s", text) + } + if strings.Contains(text, "other day entry in full text") { + t.Errorf("item panel lists another day's entry:\n%s", text) + } +} + +func TestScheduledEntryCarriesMarker(t *testing.T) { + const text = "twin task with a long tail" + tasks := []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Kind: "file", Content: text, Due: "2026-11-18", Scheduled: "2026-11-16"}, + } + around := func(a *App, tv *tasksView, day time.Time) string { + t.Helper() + tv.selectDay(a, day) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + for _, l := range lines { + if i := strings.Index(l, text); i >= 0 { + r := []rune(l[:i]) + if len(r) > 3 { + r = r[len(r)-3:] + } + return string(r) + "|" + l[i:i+len(text)] + } + } + t.Fatalf("%s: %q not found in the item panel:\n%s", day.Format("2006-01-02"), text, calScreenText(lines)) + return "" + } + a, tv := calTestApp(t, tasks) + due := around(a, tv, calDate(2026, time.November, 18)) + sched := around(a, tv, calDate(2026, time.November, 16)) + if due == sched { + t.Errorf("scheduled entry looks the same as the due entry: %q", due) + } +} + +func TestCalendarAppliesTaskFilter(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "alpha item in the panel", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "beta item in the panel", Due: "2026-11-18"}, + {ID: "n#2", SourcePath: "n.md", Content: "alpha loose end"}, + {ID: "n#3", SourcePath: "n.md", Content: "beta loose end"}, + }) + tv.filter = "alpha" + tv.refresh(a) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + for _, want := range []string{"alpha item in the panel", "alpha loose end"} { + if !strings.Contains(text, want) { + t.Errorf("%q missing under the filter:\n%s", want, text) + } + } + for _, bad := range []string{"beta item", "beta loose"} { + if strings.Contains(text, bad) { + t.Errorf("%q shown despite the filter:\n%s", bad, text) + } + } +} + +func TestMonthStripBelowGrid(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "bottom row task", Due: "2026-11-30"}, + {ID: "n#1", SourcePath: "n.md", Content: "loose strip task"}, + }) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + gridRow, stripRow := -1, -1 + for i, l := range lines { + if gridRow < 0 && strings.Contains(l, "bottom r") { + gridRow = i + } + if strings.Contains(l, "loose strip task") { + stripRow = i + } + } + if gridRow < 0 { + t.Fatalf("the task on the last grid row is not shown:\n%s", calScreenText(lines)) + } + if stripRow < 0 { + t.Fatalf("the undated task is not shown:\n%s", calScreenText(lines)) + } + if stripRow <= gridRow { + t.Errorf("strip row %d is not below the last grid row %d:\n%s", stripRow, gridRow, calScreenText(lines)) + } + if got := strings.Count(calScreenText(lines), "loose strip task"); got != 1 { + t.Errorf("undated task shown %d times, want once", got) + } +} + +func TestMonthTooSmallBelowMinimum(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "something", Due: "2026-11-18"}}) + for _, size := range [][2]int{{71, 18}, {72, 17}} { + text := calScreenText(calScreen(a, tv, size[0], size[1])) + if !strings.Contains(text, "Terminal too small") { + t.Errorf("%dx%d: no `Terminal too small`:\n%s", size[0], size[1], text) + } + for _, name := range []string{"Tu", "We", "Th", "Fr", "something"} { + if strings.Contains(text, name) { + t.Errorf("%dx%d: grid content %q shown while too small:\n%s", size[0], size[1], name, text) + } + } + } +} + +func TestMonthRendersAtExactMinimum(t *testing.T) { + a, tv := calTestApp(t, nil) + lines := calScreen(a, tv, 72, 18) + text := calScreenText(lines) + if strings.Contains(text, "Terminal too small") { + t.Fatalf("72x18 is the minimum and must render:\n%s", text) + } + requireFullMonthGridAt(t, lines, 72) + for i, l := range lines { + if lipgloss.Width(l) > 72 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } + if len(lines) != 19 { + t.Errorf("render is %d lines, want toolbar plus 18", len(lines)) + } +} + +func TestMonthRendersInVeryLargeBody(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 20; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("entry %d", i), Due: "2026-11-18"}) + } + tasks = append(tasks, vault.Task{ID: "n#u", SourcePath: "n.md", Content: "undated"}) + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, 300, 100) + requireFullMonthGridAt(t, lines, 300) + if len(lines) != 101 { + t.Errorf("render is %d lines, want toolbar plus 100", len(lines)) + } + for i, l := range lines { + if lipgloss.Width(l) > 300 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } +} + +func TestCalendarRenderLeavesNotesUnchanged(t *testing.T) { + a, root := newFeatureTestApp(t) + body := []byte("---\ntags: [task]\nscheduled: 2026-11-16\n---\n# Plan the move\n") + path := filepath.Join(root, "plan.md") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + a.idx.tasks = []vault.Task{{ID: "plan.md#task", SourcePath: "plan.md", Kind: "file", Content: "Plan the move", Scheduled: "2026-11-16"}} + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = true + a, tv := calOpen(t, a) + tv.selectDay(a, calDate(2026, time.November, 16)) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + if !strings.Contains(calScreenText(lines), "Plan the move") { + t.Fatalf("the scheduled task is not shown on its day:\n%s", calScreenText(lines)) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(body) { + t.Errorf("note changed by rendering:\n%s", got) + } +} + +func TestScheduledMarkIsOneCellWide(t *testing.T) { + if w := cellWidth(calScheduledMark); w != 1 { + t.Errorf("scheduled marker %q is %d cells wide, want 1", calScheduledMark, w) + } +} From d2642ba99e045cace7168fac40d83e2bfe4f9d69 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 18:21:58 -0400 Subject: [PATCH 05/65] Feat(tui): half-page keys page the Tasks calendar by month On the month grid, the half-page keys (Ctrl+D and Ctrl+U by default) now show the next and previous month, the same move as ] and [, and the selected day follows into the month shown. They go through the keymap, so a user who rebinds nav.halfPageDown or nav.halfPageUp gets the move on their own keys, with Vim mode on or off. The new tests also pin the month keys that already worked: hjkl and the arrows, Enter into the day's tasks and Esc or h back to the grid, the due-date keys, t for today and o for the daily note. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_tasks.go | 6 + .../tui/views_tasks_calendar_keys_test.go | 363 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 internal/tui/views_tasks_calendar_keys_test.go diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 92e5623..4c580a6 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -1083,6 +1083,12 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { v.selectDay(a, v.day.AddDate(0, 0, 7)) case k.IsRune('k') || k.Is("up"): v.selectDay(a, v.day.AddDate(0, 0, -7)) + case a.bound(k, "nav.halfPageUp"): + v.month = v.month.AddDate(0, -1, 0) + v.selectDay(a, v.day.AddDate(0, -1, 0)) + case a.bound(k, "nav.halfPageDown"): + v.month = v.month.AddDate(0, 1, 0) + v.selectDay(a, v.day.AddDate(0, 1, 0)) case k.IsRune('[') || k.IsRune('H'): v.month = v.month.AddDate(0, -1, 0) v.selectDay(a, v.day.AddDate(0, -1, 0)) diff --git a/internal/tui/views_tasks_calendar_keys_test.go b/internal/tui/views_tasks_calendar_keys_test.go new file mode 100644 index 0000000..28dae2d --- /dev/null +++ b/internal/tui/views_tasks_calendar_keys_test.go @@ -0,0 +1,363 @@ +package tui + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/keymaps" + "github.com/ZenNotes/tui/internal/periodic" + "github.com/ZenNotes/tui/internal/vim" +) + +const calWorkNote = "- [ ] alpha job due:2026-11-18\n- [ ] bravo job due:2026-11-18\n- [ ] charlie job due:2026-11-19\n" + +const calPlanNote = "---\ntags: [task]\ndue: 2026-11-18\nscheduled: 2026-11-16\n---\n# Plan the move\n" + +// calVaultApp opens the calendar on 2026-11-18 over real note files, so key +// presses that edit a task change a file that can be read back. +func calVaultApp(t *testing.T, files map[string]string) (*App, *tasksView, string) { + t.Helper() + a, root := newFeatureTestApp(t) + for name, body := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + m := a.loadIndexCmd()().(indexLoadedMsg) + if m.err != nil { + t.Fatal(m.err) + } + a.idx = m.idx + tm := a.loadTasksCmd()().(tasksLoadedMsg) + if tm.err != nil { + t.Fatal(tm.err) + } + a.idx.tasks = tm.tasks + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = true + a.prefs.VimMode = true + a, tv := calOpen(t, a) + return a, tv, root +} + +func calPress(a *App, tv *tasksView, keys ...vim.Key) { + for _, k := range keys { + tv.handleKey(a, k) + } +} + +func calRunes(s string) []vim.Key { + var out []vim.Key + for _, r := range s { + out = append(out, vim.R(r)) + } + return out +} + +func calReadFile(t *testing.T, root, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func calFixToday(t *testing.T, day time.Time) { + t.Helper() + prev := calendarNow + calendarNow = func() time.Time { return day } + t.Cleanup(func() { calendarNow = prev }) +} + +func calDayISO(tv *tasksView) string { + return tv.day.Format("2006-01-02") +} + +func calDailyPath(a *App, day time.Time) string { + _, _, _, rel := a.periodicLocation(periodic.Daily, day) + return rel +} + +func requireDay(t *testing.T, tv *tasksView, want string, context string) { + t.Helper() + if got := calDayISO(tv); got != want { + t.Errorf("%s: selected day = %s, want %s", context, got, want) + } +} + +func requireMonth(t *testing.T, tv *tasksView, year int, month time.Month, context string) { + t.Helper() + if tv.month.Year() != year || tv.month.Month() != month { + t.Errorf("%s: month shown = %s, want %d-%02d", context, tv.month.Format("2006-01"), year, int(month)) + } +} + +func TestMonthGridHJKLMoveByDayAndWeek(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + for _, c := range []struct { + key rune + want string + }{{'h', "2026-11-17"}, {'l', "2026-11-19"}, {'j', "2026-11-25"}, {'k', "2026-11-11"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.R(c.key)) { + t.Errorf("%c was not handled on the grid", c.key) + } + requireDay(t, tv, c.want, string(c.key)) + } +} + +func TestMonthGridArrowsWithoutVimMode(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + for _, c := range []struct { + key string + want string + }{{"left", "2026-11-17"}, {"right", "2026-11-19"}, {"down", "2026-11-25"}, {"up", "2026-11-11"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.Special(c.key)) { + t.Errorf("%s was not handled on the grid without Vim mode", c.key) + } + requireDay(t, tv, c.want, c.key) + } + // Printable letters keep their Vim-only gate. + for _, r := range "hjkl" { + tv.selectDay(a, calDate(2026, time.November, 18)) + if tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was handled on the grid without Vim mode", r) + } + requireDay(t, tv, "2026-11-18", string(r)+" without Vim mode") + } +} + +func TestMonthHalfPageKeysChangeMonth(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = vimOn + calPress(a, tv, vim.Ctrl('d')) + requireMonth(t, tv, 2026, time.December, "half-page down") + if tv.day.Month() != time.December { + t.Errorf("vim=%v: selected day %s is outside the month shown", vimOn, calDayISO(tv)) + } + calPress(a, tv, vim.Ctrl('u'), vim.Ctrl('u')) + requireMonth(t, tv, 2026, time.October, "half-page up twice") + if tv.day.Month() != time.October { + t.Errorf("vim=%v: selected day %s is outside the month shown", vimOn, calDayISO(tv)) + } + if text := calScreenText(calScreen(a, tv, 120, 40)); !strings.Contains(text, "October") { + t.Errorf("vim=%v: October is not shown after half-page up:\n%s", vimOn, text) + } + } +} + +func TestMonthHalfPageFollowsRebinding(t *testing.T) { + a, tv := calTestApp(t, nil) + a.keymap = keymaps.NewResolver(map[string]string{"nav.halfPageDown": "Ctrl+G"}) + calPress(a, tv, vim.Ctrl('d')) + requireMonth(t, tv, 2026, time.November, " after rebinding") + requireDay(t, tv, "2026-11-18", " after rebinding") + calPress(a, tv, vim.Ctrl('g')) + requireMonth(t, tv, 2026, time.December, "the rebound key") +} + +func TestMonthBracketAndShiftHLChangeMonth(t *testing.T) { + for _, c := range []struct { + key rune + year int + month time.Month + }{{']', 2026, time.December}, {'L', 2026, time.December}, {'[', 2026, time.October}, {'H', 2026, time.October}} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + if !tv.handleKey(a, vim.R(c.key)) { + t.Errorf("%c was not handled on the grid", c.key) + } + requireMonth(t, tv, c.year, c.month, string(c.key)) + } +} + +func TestMonthEnterFocusesItemPanel(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("focus after Enter on a day with entries = %v, want the item panel", tv.calc().focus) + } + got := tv.selectedTask() + if got == nil || got.Content != "alpha job" { + t.Errorf("selected entry after Enter = %+v, want the first entry (alpha job)", got) + } +} + +func TestMonthEnterOnEmptyDayOpensDailyNote(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + tv.selectDay(a, calDate(2026, time.November, 25)) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusGrid { + t.Errorf("focus after Enter on an empty day = %v, want the grid", tv.calc().focus) + } + want := calDailyPath(a, calDate(2026, time.November, 25)) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("active tab = %+v, want the daily note %s", tab, want) + } +} + +func TestCalendarTodayKeysJumpToToday(t *testing.T) { + calFixToday(t, calDate(2026, time.October, 29)) + for _, r := range "tT" { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled on the grid", r) + } + requireDay(t, tv, "2026-10-29", string(r)) + requireMonth(t, tv, 2026, time.October, string(r)) + } + // Without Vim mode the printable key keeps its gate. + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + if tv.handleKey(a, vim.R('t')) { + t.Error("t was handled without Vim mode") + } + requireDay(t, tv, "2026-11-18", "t without Vim mode") +} + +func TestCalendarOpenKeyOpensDailyNote(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.R('o')) + want := calDailyPath(a, calDate(2026, time.November, 18)) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("active tab = %+v, want the daily note %s", tab, want) + } +} + +func TestItemPanelEditKeysChangeDue(t *testing.T) { + for _, c := range []struct { + key rune + want string + }{{'>', "alpha job due:2026-11-19"}, {'<', "alpha job due:2026-11-17"}} { + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, vim.R(c.key)) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, c.want) { + t.Errorf("%c: note is\n%s\nwant it to contain %q", c.key, got, c.want) + } + } + + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, vim.R('d')) + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay after d = %T, want the due-date prompt", a.overlay) + } + if !strings.Contains(p.title, "Due") { + t.Errorf("prompt title = %q, want the due-date prompt", p.title) + } +} + +func TestItemPanelShiftOnScheduledEntryMovesDue(t *testing.T) { + a, tv, root := calVaultApp(t, map[string]string{"plan.md": calPlanNote}) + tv.selectDay(a, calDate(2026, time.November, 16)) + calPress(a, tv, vim.KeyEnter) + got := tv.selectedTask() + if got == nil || got.SourcePath != "plan.md" { + t.Fatalf("selected entry on the scheduled day = %+v, want the task file", got) + } + calPress(a, tv, vim.R('>')) + body := calReadFile(t, root, "plan.md") + if !regexp.MustCompile(`(?m)^due:\s*2026-11-19\s*$`).MatchString(body) { + t.Errorf("due did not move one day later:\n%s", body) + } + if !regexp.MustCompile(`(?m)^scheduled:\s*2026-11-16\s*$`).MatchString(body) { + t.Errorf("scheduled changed:\n%s", body) + } +} + +func TestItemPanelOpenToggleAndMenu(t *testing.T) { + for _, k := range []vim.Key{vim.KeyEnter, vim.R('o')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, k) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("%s: active tab = %+v, want work.md", k, tab) + } + } + + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, vim.R('x')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "- [x] alpha job") { + t.Errorf("x did not toggle the selected entry:\n%s", got) + } + + a, tv, _ = calVaultApp(t, map[string]string{"work.md": calWorkNote}) + keys := bindingKeys(a.keymap.Binding("nav.contextMenu")) + if len(keys) != 1 { + t.Fatalf("nav.contextMenu binding = %v, want one key", keys) + } + calPress(a, tv, vim.KeyEnter, keys[0]) + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("overlay after the context-menu key = %T, want the task menu", a.overlay) + } +} + +func TestItemPanelSelectedTaskFeedsActions(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter) + want := []string{"bravo job", "bravo job", "alpha job", "alpha job"} + moves := []vim.Key{vim.R('j'), vim.R('j'), vim.R('k'), vim.R('k')} + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Fatalf("selected after Enter = %+v, want alpha job", got) + } + // j stops at the last entry of the day; k stops at the first. + for i, k := range moves { + calPress(a, tv, k) + got := tv.selectedTask() + if got == nil || got.Content != want[i] { + t.Errorf("after move %d selected = %+v, want %s", i+1, got, want[i]) + } + } +} + +func TestItemPanelLeftAndEscReturnToGrid(t *testing.T) { + for _, vimOn := range []bool{true, false} { + for _, back := range []vim.Key{vim.KeyEsc, vim.Special("left"), vim.R('h')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + a.prefs.VimMode = vimOn + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("vim=%v: Enter did not focus the item panel", vimOn) + } + calPress(a, tv, back) + if tv.calc().focus != calFocusGrid { + t.Errorf("vim=%v: %s left focus on %v, want the grid", vimOn, back, tv.calc().focus) + } + requireDay(t, tv, "2026-11-18", "returning to the grid") + } + } +} + +func TestItemPanelArrowsWithoutVimMode(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + a.prefs.VimMode = false + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatal("Enter did not focus the item panel without Vim mode") + } + calPress(a, tv, vim.Special("down")) + if got := tv.selectedTask(); got == nil || got.Content != "bravo job" { + t.Errorf("after Down selected = %+v, want bravo job", got) + } + calPress(a, tv, vim.Special("up")) + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Errorf("after Up selected = %+v, want alpha job", got) + } + calPress(a, tv, vim.Special("left")) + if tv.calc().focus != calFocusGrid { + t.Errorf("Left left focus on %v, want the grid", tv.calc().focus) + } +} From 84b33792181efd21345d8b39da0e0ac0b6167000 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 18:32:38 -0400 Subject: [PATCH 06/65] Feat(tui): Esc switches the Tasks calendar between month, week and day The calendar showed only a month. Esc on the grid now steps through month, week and day and back, keeping the selected day, so the week shown is the one that contains it. Esc still works below a layout's minimum size, which is how to reach a smaller layout when the terminal is too small for the current one; nothing switches by itself. Week view shows seven columns in the configured week-start order and day view one full-width column, each with its tasks listed from the top, "+N more" when they overflow, and the "No date" strip pinned below. An hour list runs down the left, 00 to 23 or 12 AM to 11 PM following time_format. Tasks have no time of day yet, so the list only gives the day its shape and no task is placed in an hour row. Week numbers stay in the month view only. The layout chosen lasts for the session, across switching Tasks views and :tasks calendar, and every new session starts on the month. It is never written to the configuration, so tasks_view_mode, which the desktop app shares, still only says "calendar". Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_tasks.go | 2 + internal/tui/views_tasks_calendar.go | 151 ++++- .../tui/views_tasks_calendar_modes_test.go | 537 ++++++++++++++++++ 3 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 internal/tui/views_tasks_calendar_modes_test.go diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 4c580a6..4db3254 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -1075,6 +1075,8 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { return false } switch { + case k.Is("esc"): + v.calc().sub = (v.calc().sub + 1) % 3 case k.IsRune('h') || k.Is("left"): v.selectDay(a, v.day.AddDate(0, 0, -1)) case k.IsRune('l') || k.Is("right"): diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index a8ad6d2..9152f65 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -67,6 +67,8 @@ type calLayout struct { boxes []calDayBox itemStart int stripRows int + // gutter is the width of the hour labels left of the week and day columns. + gutter int } // taskCalendar is the Tasks tab's calendar: day entries, the items beside @@ -151,6 +153,143 @@ func (c *taskCalendar) monthLayout(a *App, month time.Time, w, h int) calLayout return lay } +// hourLabelWidth is the width of the widest hour label for the time format. +func hourLabelWidth(a *App) int { + if a.prefs.TimeFormat == "12h" { + return 5 + } + return 2 +} + +// hourLabel is the label of hour row h (0-23). +func hourLabel(a *App, h int) string { + if a.prefs.TimeFormat == "12h" { + n := h % 12 + if n == 0 { + n = 12 + } + suffix := "AM" + if h >= 12 { + suffix = "PM" + } + return fmt.Sprintf("%2d %s", n, suffix) + } + return fmt.Sprintf("%02d", h) +} + +// columnDays is the days a week or day layout shows as columns, in order. +func (c *taskCalendar) columnDays(a *App, day time.Time) []time.Time { + if c.sub == calDay { + return []time.Time{day} + } + offset := int(day.Weekday()) + if a.prefs.CalendarWeekStart != "sunday" { + offset = (offset + 6) % 7 + } + first := time.Date(day.Year(), day.Month(), day.Day()-offset, 0, 0, 0, 0, time.Local) + days := make([]time.Time, 7) + for i := range days { + days[i] = first.AddDate(0, 0, i) + } + return days +} + +// columnLayout computes the regions of the week or day view for a body of +// w x h. The hour list sits under the header rows with one column per day +// beside it, and the strip is pinned to the bottom of the body. +func (c *taskCalendar) columnLayout(a *App, day time.Time, w, h int) calLayout { + lay := calLayout{w: w, h: h} + if m := calMin[c.sub]; w < m[0] || h < m[1] { + lay.tooSmall = true + return lay + } + days := c.columnDays(a, day) + headRows := 1 + if c.sub == calWeek { + headRows = 2 + } + stripRows := clampInt(h/8, 2, 6) + stripH := 1 + stripRows + lay.stripRows = stripRows + lay.strip = calRect{x: 0, y: h - stripH, w: w, h: stripH} + lay.gutter = hourLabelWidth(a) + 2 + hourRows := clampInt(h-stripH-headRows, 0, 24) + colW := (w - lay.gutter) / len(days) + for i, d := range days { + lay.boxes = append(lay.boxes, calDayBox{day: d, rect: calRect{x: lay.gutter + i*colW, y: headRows, w: colW, h: hourRows}}) + } + return lay +} + +// renderColumns draws the week or day view: a header, the hour list with the +// day columns beside it, and the strip. Entries are listed from the top of +// their column, not placed by time of day. +func (c *taskCalendar) renderColumns(a *App, day time.Time, lay calLayout, w, h int, focused bool) string { + th := a.theme + rows := make([]string, 0, h) + blank := strings.Repeat(" ", lay.gutter) + first, last := lay.boxes[0].day, lay.boxes[len(lay.boxes)-1].day + if c.sub == calDay { + rows = append(rows, padRight(" "+th.Title.Render(day.Format("Monday, January 2, 2006")), w)) + } else { + title := first.Format("January 2") + " – " + last.Format("January 2, 2006") + rows = append(rows, padRight(" "+th.Title.Render(title), w)) + head := blank + for _, b := range lay.boxes { + cell := padRight(" "+b.day.Format("Mon")[:2]+b.day.Format(" 2"), b.rect.w) + if sameDay(b.day, day) { + cell = th.Selected.Render(cell) + } else { + cell = th.Dim.Render(cell) + } + head += cell + } + rows = append(rows, padRight(head, w)) + } + hourRows := lay.boxes[0].rect.h + for i := 0; i < hourRows; i++ { + var sb strings.Builder + sb.WriteString(th.Muted.Render(padRight(hourLabel(a, i), lay.gutter-2)) + th.Dim.Render(" │")) + for _, b := range lay.boxes { + sb.WriteString(c.columnLine(a, b, i, focused && sameDay(b.day, day))) + } + rows = append(rows, padRight(sb.String(), w)) + } + for len(rows) < lay.strip.y { + rows = append(rows, strings.Repeat(" ", w)) + } + rows = append(rows, c.stripLines(a, lay, w, focused)...) + return fitBlock(strings.Join(rows, "\n"), w, h) +} + +// columnLine is line li of one day column: entries from the top, with +// "+N more" on the last line when they do not all fit. +func (c *taskCalendar) columnLine(a *App, b calDayBox, li int, focusedDay bool) string { + th := a.theme + w := b.rect.w + entries := c.dayEntries(b.day) + shown := len(entries) + if shown > b.rect.h { + shown = b.rect.h - 1 + } + switch { + case li < shown: + e := entries[li] + lead := " " + if e.scheduled { + lead = calScheduledMark + } + text := padRight(lead+truncateCells(e.task.Content, w-2), w) + if focusedDay && c.focus == calFocusItems && li == c.cursor { + return th.SelectedFocus.Render(text) + } + return text + case li == shown && shown < len(entries): + return th.Dim.Render(padRight(fmt.Sprintf(" +%d more", len(entries)-shown), w)) + } + return strings.Repeat(" ", w) +} + // monthGridStart is the first day shown for a month and how many week rows // cover it. func monthGridStart(month time.Time, mondayFirst bool) (time.Time, int) { @@ -165,11 +304,16 @@ func monthGridStart(month time.Time, mondayFirst bool) (time.Time, int) { // render draws the calendar into a w x h body. func (c *taskCalendar) render(a *App, month, day time.Time, w, h int, focused bool) string { - lay := c.monthLayout(a, month, w, h) + var lay calLayout + if c.sub == calMonth { + lay = c.monthLayout(a, month, w, h) + } else { + lay = c.columnLayout(a, day, w, h) + } c.layout = lay th := a.theme if lay.tooSmall { - m := calMin[calMonth] + m := calMin[c.sub] lines := make([]string, max(h, 0)) if len(lines) > 0 { msg := fmt.Sprintf("Terminal too small: needs %d×%d", m[0], m[1]) @@ -178,6 +322,9 @@ func (c *taskCalendar) render(a *App, month, day time.Time, w, h int, focused bo return fitBlock(strings.Join(lines, "\n"), w, h) } + if c.sub != calMonth { + return c.renderColumns(a, day, lay, w, h, focused) + } weeks := len(lay.boxes) / 7 gw := lay.items.x wnW := lay.boxes[0].rect.x diff --git a/internal/tui/views_tasks_calendar_modes_test.go b/internal/tui/views_tasks_calendar_modes_test.go new file mode 100644 index 0000000..7df0f96 --- /dev/null +++ b/internal/tui/views_tasks_calendar_modes_test.go @@ -0,0 +1,537 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +var ( + calLabel24 = regexp.MustCompile(`^\s*(\d{2}(?::30)?)(?:\s|\x{2502}|\|$|$)`) + calLabel12 = regexp.MustCompile(`^\s*(\d{1,2}(?::30)? [AP]M)`) + calMoreRe = regexp.MustCompile(`\+(\d+) more`) + calZqRe = regexp.MustCompile(`zq\d\d`) +) + +func calEsc(a *App, tv *tasksView) { calPress(a, tv, vim.KeyEsc) } + +// calToSub presses Esc until the calendar shows the given layout. +func calToSub(t *testing.T, a *App, tv *tasksView, want calSub) { + t.Helper() + for i := 0; i < 3 && tv.calc().sub != want; i++ { + calEsc(a, tv) + } + if tv.calc().sub != want { + t.Fatalf("Esc did not reach layout %v (stuck on %v)", want, tv.calc().sub) + } +} + +func calHourLabels(lines []string, re *regexp.Regexp) []string { + var out []string + for _, l := range lines { + if m := re.FindStringSubmatch(l); m != nil { + out = append(out, m[1]) + } + } + return out +} + +func calHours24() []string { + var out []string + for h := 0; h < 24; h++ { + out = append(out, fmt.Sprintf("%02d", h)) + } + return out +} + +func calHours12() []string { + var out []string + for h := 0; h < 24; h++ { + n := h % 12 + if n == 0 { + n = 12 + } + suffix := "AM" + if h >= 12 { + suffix = "PM" + } + out = append(out, fmt.Sprintf("%d %s", n, suffix)) + } + return out +} + +func requireLabels(t *testing.T, lines []string, re *regexp.Regexp, want []string, context string) { + t.Helper() + got := calHourLabels(lines, re) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("%s: hour labels = %v, want %v\n%s", context, got, want, calScreenText(lines)) + } +} + +// calColumnOf is the cell column where text starts, or -1. +func calColumnOf(lines []string, text string) int { + for _, l := range lines { + if i := strings.Index(l, text); i >= 0 { + return len([]rune(l[:i])) + } + } + return -1 +} + +func calRowOf(lines []string, text string) int { + for i, l := range lines { + if strings.Contains(l, text) { + return i + } + } + return -1 +} + +func requireWeekHeader(t *testing.T, lines []string, order []string, context string) []int { + t.Helper() + cols := calWeekdayHeader(lines, order) + if cols == nil { + t.Fatalf("%s: no weekday header in %v order:\n%s", context, order, calScreenText(lines)) + } + return cols +} + +func requireTooSmall(t *testing.T, lines []string, want bool, context string) { + t.Helper() + got := strings.Contains(calScreenText(lines), "Terminal too small") + if got != want { + t.Errorf("%s: `Terminal too small` shown = %v, want %v:\n%s", context, got, want, calScreenText(lines)) + } +} + +func calManyTasks(day string, n int, prefix string) []vault.Task { + var tasks []vault.Task + for i := 0; i < n; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("%s%02d", prefix, i), Due: day}) + } + return tasks +} + +func TestEscCyclesSubModes(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = vimOn + if tv.calc().sub != calMonth { + t.Fatalf("vim=%v: calendar did not open on the month layout", vimOn) + } + for i, want := range []calSub{calWeek, calDay, calMonth} { + if !tv.handleKey(a, vim.KeyEsc) { + t.Errorf("vim=%v: Esc %d was not handled", vimOn, i+1) + } + if got := tv.calc().sub; got != want { + t.Errorf("vim=%v: after Esc %d layout = %v, want %v", vimOn, i+1, got, want) + } + if tv.mode != "calendar" { + t.Errorf("vim=%v: Esc %d left calendar mode: %q", vimOn, i+1, tv.mode) + } + } + } +} + +func TestEscToWeekKeepsSelectedDay(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "edge16 task", Due: "2026-11-16"}, + {ID: "n#1", SourcePath: "n.md", Content: "edge22 task", Due: "2026-11-22"}, + {ID: "n#2", SourcePath: "n.md", Content: "before15 task", Due: "2026-11-15"}, + {ID: "n#3", SourcePath: "n.md", Content: "after23 task", Due: "2026-11-23"}, + }) + calEsc(a, tv) + requireDay(t, tv, "2026-11-18", "week view") + if tv.calc().sub != calWeek { + t.Fatalf("Esc did not open week view") + } + lines := calScreen(a, tv, 120, 60) + requireWeekHeader(t, lines, calWeekdayNames, "week of 2026-11-18") + text := calScreenText(lines) + for _, want := range []string{"edge16 task", "edge22 task"} { + if !strings.Contains(text, want) { + t.Errorf("%q missing from the week containing 2026-11-18:\n%s", want, text) + } + } + for _, bad := range []string{"before15", "after23"} { + if strings.Contains(text, bad) { + t.Errorf("%q shown, but it is outside the week:\n%s", bad, text) + } + } + calEsc(a, tv) + requireDay(t, tv, "2026-11-18", "day view") +} + +func TestSubModeNeverWritesPreferences(t *testing.T) { + a, tv := calTestApp(t, nil) + dir := os.Getenv("ZENNOTES_CONFIG_DIR") + if dir == "" { + t.Fatal("test config dir is not set") + } + snapshot := func() map[string]string { + out := map[string]string{} + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + b, _ := os.ReadFile(p) + out[p] = string(b) + return nil + }) + return out + } + // The user's saved view mode; opening the tab through a command does not set it. + a.prefs.TasksViewMode = "calendar" + before := snapshot() + for _, want := range []calSub{calWeek, calDay, calMonth, calWeek} { + calEsc(a, tv) + if tv.calc().sub != want { + t.Fatalf("layout = %v, want %v", tv.calc().sub, want) + } + } + after := snapshot() + if len(after) != len(before) { + t.Errorf("config dir has %d files after cycling, had %d", len(after), len(before)) + } + for p, body := range after { + if old, ok := before[p]; !ok || old != body { + t.Errorf("config file %s was written while cycling", p) + } + } + if a.prefs.TasksViewMode != "calendar" || tv.mode != "calendar" { + t.Errorf("tasks_view_mode = %q, view mode = %q, want calendar for both", a.prefs.TasksViewMode, tv.mode) + } +} + +func TestNewSessionOpensMonth(t *testing.T) { + a, tv := calTestApp(t, nil) + calEsc(a, tv) + if tv.calc().sub != calWeek { + t.Fatal("Esc did not leave the month layout in the first session") + } + a2, tv2 := calTestApp(t, nil) + if tv2.calc().sub != calMonth { + t.Errorf("new session opened on layout %v, want month", tv2.calc().sub) + } + requireFullMonthGrid(t, calScreen(a2, tv2, 120, 40)) +} + +func TestSubModeSurvivesViewCycle(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + calEsc(a, tv) + if tv.calc().sub != calWeek { + t.Fatal("Esc did not open week view") + } + for i, want := range []string{"list", "kanban", "calendar"} { + if !tv.handleKey(a, vim.R('v')) { + t.Fatalf("v %d was not handled", i+1) + } + if tv.mode != want { + t.Fatalf("v %d gave mode %q, want %q", i+1, tv.mode, want) + } + } + if tv.calc().sub != calWeek { + t.Errorf("layout after the view cycle = %v, want week", tv.calc().sub) + } + requireWeekHeader(t, calScreen(a, tv, 120, 60), calWeekdayNames, "week after v cycle") +} + +func TestSubModeSurvivesTasksCalendarCommand(t *testing.T) { + a, tv := calTestApp(t, nil) + calToSub(t, a, tv, calDay) + tv.setMode("list") + if err := a.openTasksMode("calendar"); err != nil { + t.Fatal(err) + } + tab := a.activeTab() + if tab == nil { + t.Fatal("no active tab") + } + tv2, ok := tab.view.(*tasksView) + if !ok { + t.Fatalf("active tab is %T", tab.view) + } + if tv2.mode != "calendar" { + t.Fatalf("mode = %q, want calendar", tv2.mode) + } + if tv2.calc().sub != calDay { + t.Errorf("layout after :tasks calendar = %v, want day", tv2.calc().sub) + } + if cols := calWeekdayHeader(calScreen(a, tv2, 120, 60), calWeekdayNames); cols != nil { + t.Errorf("day view shows a seven-day header") + } +} + +func TestWeekDayViewsHideWeekNumbers(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.CalendarShowWeekNumbers = true + if !weekNumberShown(calScreen(a, tv, 120, 60), 47) { + t.Fatal("month view should show ISO week 47 with week numbers on") + } + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + lines := calScreen(a, tv, 120, 60) + requireTooSmall(t, lines, false, fmt.Sprint(sub)) + if weekNumberShown(lines, 47) { + t.Errorf("layout %v shows a week number:\n%s", sub, calScreenText(lines)) + } + if sub == calWeek { + requireWeekHeader(t, lines, calWeekdayNames, "week view") + } + } +} + +func TestHourlyLabels24h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourlyLabels12h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.TimeFormat = "12h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel12, calHours12(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestEntriesStayOutOfHourRows(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "call at 15:00 sharp", Due: "2026-11-18"}, + }) + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + lines := calScreen(a, tv, 120, 70) + text := calScreenText(lines) + if got := strings.Count(text, "call at 15:00"); got != 1 { + t.Fatalf("layout %v: entry shown %d times, want once in its column:\n%s", sub, got, text) + } + if len(calHourLabels(lines, calLabel24)) != 24 { + t.Fatalf("layout %v: hour list missing:\n%s", sub, text) + } + // Entries are listed from the top of the column, not by time of day: + // the entry cannot sit as far down as the 15 row. + entryRow := calRowOf(lines, "call at 15:00") + above := calHourLabels(lines[:entryRow+1], calLabel24) + if len(above) > 6 { + t.Errorf("layout %v: entry placed %d hour rows down the column:\n%s", sub, len(above), text) + } + } +} + +func TestWeekShowsSevenColumnsInWeekStartOrder(t *testing.T) { + var tasks []vault.Task + for d := 14; d <= 24; d++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", d), SourcePath: "n.md", Content: fmt.Sprintf("day%d", d), Due: fmt.Sprintf("2026-11-%02d", d)}) + } + for _, c := range []struct { + start string + order []string + in []int + outside []int + }{ + {"monday", calWeekdayNames, []int{16, 17, 18, 19, 20, 21, 22}, []int{14, 15, 23, 24}}, + {"sunday", calSundayFirst, []int{15, 16, 17, 18, 19, 20, 21}, []int{14, 22, 23, 24}}, + } { + a, tv := calTestApp(t, tasks) + a.prefs.CalendarWeekStart = c.start + calToSub(t, a, tv, calWeek) + lines := calScreen(a, tv, 120, 70) + cols := requireWeekHeader(t, lines, c.order, c.start) + if cols[0] < 2 { + t.Errorf("%s: first day column starts at %d, leaving no room for the hour list", c.start, cols[0]) + } + prev := -1 + for _, d := range c.in { + x := calColumnOf(lines, fmt.Sprintf("day%d", d)) + if x < 0 { + t.Errorf("%s: day%d missing from the week:\n%s", c.start, d, calScreenText(lines)) + continue + } + if x <= prev { + t.Errorf("%s: day%d at column %d is not right of the previous day (%d)", c.start, d, x, prev) + } + prev = x + } + for _, d := range c.outside { + if strings.Contains(calScreenText(lines), fmt.Sprintf("day%d", d)) { + t.Errorf("%s: day%d shown outside the week", c.start, d) + } + } + } +} + +func TestWeekColumnOverflowShowsMoreCount(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 50, "zq")) + calToSub(t, a, tv, calWeek) + lines := calScreen(a, tv, 120, 60) + requireWeekHeader(t, lines, calWeekdayNames, "week view") + requireOverflowCount(t, lines, 50) +} + +func TestDayColumnOverflowShowsMoreCount(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 50, "zq")) + calToSub(t, a, tv, calDay) + requireOverflowCount(t, calScreen(a, tv, 120, 60), 50) +} + +func requireOverflowCount(t *testing.T, lines []string, total int) { + t.Helper() + text := calScreenText(lines) + listed := len(calZqRe.FindAllString(text, -1)) + if listed == 0 || listed >= total { + t.Fatalf("listed %d of %d entries, want some but not all:\n%s", listed, total, text) + } + more := calMoreRe.FindAllStringSubmatch(text, -1) + if len(more) != 1 { + t.Fatalf("found %d `+N more` lines, want 1:\n%s", len(more), text) + } + if want := fmt.Sprint(total - listed); more[0][1] != want { + t.Errorf("+%s more, want +%s more for %d listed", more[0][1], want, listed) + } + lastEntry := -1 + for i, l := range lines { + if calZqRe.MatchString(l) { + lastEntry = i + } + } + if row := calRowOf(lines, "more"); row <= lastEntry { + t.Errorf("`+N more` on row %d is not after the last listed entry (row %d)", row, lastEntry) + } +} + +func TestDayShowsOneFullWidthColumn(t *testing.T) { + long := "fullwidth " + strings.Repeat("abcdefghi ", 6) + "end" + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: long, Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "nextday task", Due: "2026-11-19"}, + }) + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calDay) + lines := calScreen(a, tv, 120, 60) + requireTooSmall(t, lines, false, "day view") + text := calScreenText(lines) + if !strings.Contains(text, long) { + t.Errorf("a %d-cell entry is cut in a 120-wide day column:\n%s", len(long), text) + } + if strings.Contains(text, "nextday task") { + t.Errorf("day view shows another day's entry:\n%s", text) + } + if cols := calWeekdayHeader(lines, calWeekdayNames); cols != nil { + t.Errorf("day view shows seven day columns:\n%s", text) + } + if len(calHourLabels(lines, calLabel24)) != 24 { + t.Errorf("day view hour list missing:\n%s", text) + } +} + +func TestWeekAndDayStripBelowGrid(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "column entry", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "loose strip task"}, + }) + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + lines := calScreen(a, tv, 120, 70) + text := calScreenText(lines) + if got := strings.Count(text, "loose strip task"); got != 1 { + t.Fatalf("layout %v: undated task shown %d times, want once:\n%s", sub, got, text) + } + if calRowOf(lines, "column entry") < 0 { + t.Fatalf("layout %v: dated entry missing:\n%s", sub, text) + } + last := -1 + for i, l := range lines { + if m := calLabel24.FindStringSubmatch(l); m != nil && m[1] == "23" { + last = i + } + } + if last < 0 { + t.Fatalf("layout %v: no 23 hour row:\n%s", sub, text) + } + if row := calRowOf(lines, "loose strip task"); row <= last { + t.Errorf("layout %v: strip row %d is not below the last hour row %d", sub, row, last) + } + } +} + +func TestWeekTooSmallBelowMinimum(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "something", Due: "2026-11-18"}}) + calToSub(t, a, tv, calWeek) + for _, size := range [][2]int{{63, 12}, {64, 11}} { + lines := calScreen(a, tv, size[0], size[1]) + requireTooSmall(t, lines, true, fmt.Sprintf("%dx%d", size[0], size[1])) + if strings.Contains(calScreenText(lines), "something") { + t.Errorf("%dx%d: entry shown while too small", size[0], size[1]) + } + } + lines := calScreen(a, tv, 64, 12) + requireTooSmall(t, lines, false, "64x12") + requireWeekHeader(t, lines, calWeekdayNames, "64x12") + if len(lines) != 13 { + t.Errorf("render is %d lines, want toolbar plus 12", len(lines)) + } + for i, l := range lines { + if lipgloss.Width(l) > 64 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } +} + +func TestDayTooSmallBelowMinimum(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "dt1", Due: "2026-11-18"}}) + calToSub(t, a, tv, calDay) + for _, size := range [][2]int{{39, 10}, {40, 9}} { + lines := calScreen(a, tv, size[0], size[1]) + requireTooSmall(t, lines, true, fmt.Sprintf("%dx%d", size[0], size[1])) + if strings.Contains(calScreenText(lines), "dt1") { + t.Errorf("%dx%d: entry shown while too small", size[0], size[1]) + } + } + lines := calScreen(a, tv, 40, 10) + requireTooSmall(t, lines, false, "40x10") + if !strings.Contains(calScreenText(lines), "dt1") { + t.Errorf("40x10: day column does not show the entry:\n%s", calScreenText(lines)) + } + if len(lines) != 11 { + t.Errorf("render is %d lines, want toolbar plus 10", len(lines)) + } + for i, l := range lines { + if lipgloss.Width(l) > 40 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } +} + +func TestTooSmallMonthDoesNotSwitchModeButEscDoes(t *testing.T) { + a, tv := calTestApp(t, nil) + // A body that meets the week minimum but not the month one. + for i := 0; i < 2; i++ { + requireTooSmall(t, calScreen(a, tv, 64, 12), true, "month at 64x12") + if tv.calc().sub != calMonth { + t.Fatalf("render switched the layout to %v by itself", tv.calc().sub) + } + } + calEsc(a, tv) + if tv.calc().sub != calWeek { + t.Fatalf("Esc while too small left layout %v, want week", tv.calc().sub) + } + lines := calScreen(a, tv, 64, 12) + requireTooSmall(t, lines, false, "week at 64x12") + requireWeekHeader(t, lines, calWeekdayNames, "week at 64x12") +} From fd41049d797227df1be1305646b78100e64d61d4 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 18:45:56 -0400 Subject: [PATCH 07/65] Feat(tui): week and day calendar navigation and column focus In the week and day views the half-page keys moved a whole month, as on the month grid. They now move by the layout's own unit: a month in month view, a week in week view, a day in day view, and the selected day follows. Tab, and Enter on a day with tasks, move focus from the grid into the selected day's tasks, where j and k pick an entry and the usual task keys act on it: Enter or o opens it, x toggles it, < and > move its due date and the menu key opens its task menu. Esc, Left or h return to the grid. Tab skips a day with no tasks, and Enter on such a day still opens its daily note. Tab also works without Vim mode. h and l, the arrows across week edges, t, o and n already behaved the same in every layout; new tests pin that. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_tasks.go | 31 +- internal/tui/views_tasks_calendar.go | 31 ++ internal/tui/views_tasks_calendar_nav_test.go | 312 ++++++++++++++++++ 3 files changed, 365 insertions(+), 9 deletions(-) create mode 100644 internal/tui/views_tasks_calendar_nav_test.go diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 4db3254..7a77bb0 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -929,7 +929,7 @@ func (v *tasksView) taskEditKey(a *App, k vim.Key, t *vault.Task) bool { } func (v *tasksView) boardKey(a *App, k vim.Key) bool { - if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { + if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("tab") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { return false } t := v.selectedTask() @@ -1046,6 +1046,8 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { switch { case k.Is("esc") || k.IsRune('h') || k.Is("left"): v.calc().focus = calFocusGrid + case k.Is("tab"): + v.calc().cycleFocus(v.day) case k.IsRune('j') || k.Is("down"): if v.calc().cursor < len(v.calc().dayEntries(v.day))-1 { v.calc().cursor++ @@ -1086,11 +1088,9 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { case k.IsRune('k') || k.Is("up"): v.selectDay(a, v.day.AddDate(0, 0, -7)) case a.bound(k, "nav.halfPageUp"): - v.month = v.month.AddDate(0, -1, 0) - v.selectDay(a, v.day.AddDate(0, -1, 0)) + v.pageDay(a, -1) case a.bound(k, "nav.halfPageDown"): - v.month = v.month.AddDate(0, 1, 0) - v.selectDay(a, v.day.AddDate(0, 1, 0)) + v.pageDay(a, 1) case k.IsRune('[') || k.IsRune('H'): v.month = v.month.AddDate(0, -1, 0) v.selectDay(a, v.day.AddDate(0, -1, 0)) @@ -1101,11 +1101,10 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { now := calendarNow() v.month = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) v.selectDay(a, now) + case k.Is("tab"): + return v.calc().cycleFocus(v.day) case k.Is("enter"): - if len(v.calc().dayEntries(v.day)) > 0 { - v.calc().focus = calFocusItems - v.calc().cursor = 0 - } else { + if !v.calc().cycleFocus(v.day) { a.openPeriodicOn(periodic.Daily, v.day) } case k.IsRune('o'): @@ -1118,6 +1117,20 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { return true } +// pageDay moves the selected day by one page of the current layout: a month, +// a week or a day. dir is -1 or 1. +func (v *tasksView) pageDay(a *App, dir int) { + switch v.calc().sub { + case calWeek: + v.selectDay(a, v.day.AddDate(0, 0, 7*dir)) + case calDay: + v.selectDay(a, v.day.AddDate(0, 0, dir)) + default: + v.month = v.month.AddDate(0, dir, 0) + v.selectDay(a, v.day.AddDate(0, dir, 0)) + } +} + func (v *tasksView) selectDay(a *App, day time.Time) { v.day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.Local) if v.day.Month() != v.month.Month() || v.day.Year() != v.month.Year() { diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index 9152f65..0ecc303 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -103,6 +103,37 @@ func (c *taskCalendar) dayEntries(day time.Time) []calEntry { return c.byDay[day.Format("2006-01-02")] } +// focusOrder is the parts Tab visits, in order: the grid, then the selected +// day's items when it has any. +func (c *taskCalendar) focusOrder(day time.Time) []calFocus { + order := []calFocus{calFocusGrid} + if len(c.dayEntries(day)) > 0 { + order = append(order, calFocusItems) + } + return order +} + +// cycleFocus moves focus to the next part in focusOrder, wrapping around. It +// reports whether focus moved. +func (c *taskCalendar) cycleFocus(day time.Time) bool { + order := c.focusOrder(day) + if len(order) < 2 { + return false + } + next := order[0] + for i, f := range order { + if f == c.focus { + next = order[(i+1)%len(order)] + break + } + } + c.focus = next + if next == calFocusItems { + c.cursor = 0 + } + return true +} + // selected is the task the item panel or the strip has selected, if any. func (c *taskCalendar) selected(day time.Time) *vault.Task { switch c.focus { diff --git a/internal/tui/views_tasks_calendar_nav_test.go b/internal/tui/views_tasks_calendar_nav_test.go new file mode 100644 index 0000000..30b24e8 --- /dev/null +++ b/internal/tui/views_tasks_calendar_nav_test.go @@ -0,0 +1,312 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +// calNavTasks puts one task in each of several weeks so a test can tell +// which week or day a render shows by the task texts on screen. +func calNavTasks() []vault.Task { + return []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "wk09 task", Due: "2026-11-09"}, + {ID: "n#1", SourcePath: "n.md", Content: "wk15 task", Due: "2026-11-15"}, + {ID: "n#2", SourcePath: "n.md", Content: "wk16 task", Due: "2026-11-16"}, + {ID: "n#3", SourcePath: "n.md", Content: "wk22 task", Due: "2026-11-22"}, + {ID: "n#4", SourcePath: "n.md", Content: "wk23 task", Due: "2026-11-23"}, + {ID: "n#5", SourcePath: "n.md", Content: "wk25 task", Due: "2026-11-25"}, + {ID: "n#6", SourcePath: "n.md", Content: "wk11 task", Due: "2026-11-11"}, + {ID: "n#7", SourcePath: "n.md", Content: "today27 task", Due: "2026-10-27"}, + {ID: "n#8", SourcePath: "n.md", Content: "today29 task", Due: "2026-10-29"}, + {ID: "n#9", SourcePath: "n.md", Content: "wk19 task", Due: "2026-11-19"}, + {ID: "n#10", SourcePath: "n.md", Content: "wk17 task", Due: "2026-11-17"}, + } +} + +func calNavApp(t *testing.T, sub calSub, vimOn bool) (*App, *tasksView) { + t.Helper() + a, tv := calTestApp(t, calNavTasks()) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, sub) + return a, tv +} + +func requireShown(t *testing.T, a *App, tv *tasksView, shown, hidden []string, context string) { + t.Helper() + text := calScreenText(calScreen(a, tv, 140, 60)) + for _, s := range shown { + if !strings.Contains(text, s) { + t.Errorf("%s: %q is not shown:\n%s", context, s, text) + } + } + for _, s := range hidden { + if strings.Contains(text, s) { + t.Errorf("%s: %q is shown, but it is outside the view:\n%s", context, s, text) + } + } +} + +func TestWeekHLMoveWithinWeek(t *testing.T) { + a, tv := calNavApp(t, calWeek, true) + if !tv.handleKey(a, vim.R('l')) { + t.Error("l was not handled in week view") + } + requireDay(t, tv, "2026-11-19", "l") + if !tv.handleKey(a, vim.R('h')) || !tv.handleKey(a, vim.R('h')) { + t.Error("h was not handled in week view") + } + requireDay(t, tv, "2026-11-17", "h twice") + if tv.calc().sub != calWeek { + t.Errorf("layout after h/l = %v, want week", tv.calc().sub) + } +} + +func TestWeekLOnLastDayMovesToNextWeek(t *testing.T) { + a, tv := calNavApp(t, calWeek, true) + tv.selectDay(a, calDate(2026, time.November, 22)) + calPress(a, tv, vim.R('l')) + requireDay(t, tv, "2026-11-23", "l on the last day of the week") + requireShown(t, a, tv, []string{"wk23 task", "wk25 task"}, []string{"wk22 task", "wk16 task"}, "next week") +} + +func TestWeekHOnFirstDayMovesToPreviousWeek(t *testing.T) { + a, tv := calNavApp(t, calWeek, true) + tv.selectDay(a, calDate(2026, time.November, 16)) + calPress(a, tv, vim.R('h')) + requireDay(t, tv, "2026-11-15", "h on the first day of the week") + requireShown(t, a, tv, []string{"wk09 task", "wk15 task"}, []string{"wk16 task", "wk22 task"}, "previous week") +} + +func TestWeekHalfPageMovesByWeek(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calNavApp(t, calWeek, vimOn) + if !tv.handleKey(a, vim.Ctrl('d')) { + t.Errorf("vim=%v: half-page down was not handled in week view", vimOn) + } + requireDay(t, tv, "2026-11-25", "half-page down") + requireShown(t, a, tv, []string{"wk23 task", "wk25 task"}, []string{"wk16 task"}, "week after half-page down") + calPress(a, tv, vim.Ctrl('u'), vim.Ctrl('u')) + requireDay(t, tv, "2026-11-11", "half-page up twice") + requireShown(t, a, tv, []string{"wk09 task", "wk11 task"}, []string{"wk16 task", "wk25 task"}, "week after half-page up") + } +} + +func TestWeekTabFocusesColumnAndJKMove(t *testing.T) { + for _, k := range []vim.Key{vim.KeyTab, vim.KeyEnter} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, calWeek) + if !tv.handleKey(a, k) { + t.Errorf("%s was not handled on the week grid", k) + } + if tv.calc().focus != calFocusItems { + t.Fatalf("%s: focus = %v, want the day's column", k, tv.calc().focus) + } + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Errorf("%s: selected = %+v, want alpha job", k, got) + } + calPress(a, tv, vim.R('j')) + if got := tv.selectedTask(); got == nil || got.Content != "bravo job" { + t.Errorf("%s: after j selected = %+v, want bravo job", k, got) + } + calPress(a, tv, vim.R('k')) + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Errorf("%s: after k selected = %+v, want alpha job", k, got) + } + requireDay(t, tv, "2026-11-18", "moving through the column") + } +} + +func TestDayHalfPageMovesByDay(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calNavApp(t, calDay, vimOn) + if !tv.handleKey(a, vim.Ctrl('d')) { + t.Errorf("vim=%v: half-page down was not handled in day view", vimOn) + } + requireDay(t, tv, "2026-11-19", "half-page down") + requireShown(t, a, tv, []string{"wk19 task"}, []string{"wk17 task"}, "day after half-page down") + calPress(a, tv, vim.Ctrl('u'), vim.Ctrl('u')) + requireDay(t, tv, "2026-11-17", "half-page up twice") + requireShown(t, a, tv, []string{"wk17 task"}, []string{"wk19 task"}, "day after half-page up") + if tv.calc().sub != calDay { + t.Errorf("vim=%v: layout = %v, want day", vimOn, tv.calc().sub) + } + } +} + +func TestWeekArrowsWithoutVimMode(t *testing.T) { + a, tv := calNavApp(t, calWeek, false) + for _, c := range []struct{ key, want string }{{"right", "2026-11-19"}, {"left", "2026-11-17"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.Special(c.key)) { + t.Errorf("%s was not handled in week view without Vim mode", c.key) + } + requireDay(t, tv, c.want, c.key) + } + tv.selectDay(a, calDate(2026, time.November, 22)) + calPress(a, tv, vim.Special("right")) + requireDay(t, tv, "2026-11-23", "Right on the last day of the week") + requireShown(t, a, tv, []string{"wk23 task"}, []string{"wk22 task"}, "next week by Right") + tv.selectDay(a, calDate(2026, time.November, 16)) + calPress(a, tv, vim.Special("left")) + requireDay(t, tv, "2026-11-15", "Left on the first day of the week") + requireShown(t, a, tv, []string{"wk15 task"}, []string{"wk16 task"}, "previous week by Left") +} + +func TestDayArrowsWithoutVimMode(t *testing.T) { + a, tv := calNavApp(t, calDay, false) + for _, c := range []struct{ key, want string }{{"right", "2026-11-19"}, {"left", "2026-11-17"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.Special(c.key)) { + t.Errorf("%s was not handled in day view without Vim mode", c.key) + } + requireDay(t, tv, c.want, c.key) + } + requireShown(t, a, tv, []string{"wk17 task"}, []string{"wk19 task"}, "day by Left") +} + +func TestWeekPrintableGridKeysNeedVimMode(t *testing.T) { + a, tv := calNavApp(t, calWeek, false) + tabBefore := a.activeTab() + for _, r := range "z[]to" { + if tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was handled in week view without Vim mode", r) + } + requireDay(t, tv, "2026-11-18", string(r)+" without Vim mode") + if tv.calc().sub != calWeek || tv.calc().focus != calFocusGrid { + t.Errorf("%c without Vim mode changed layout/focus to %v/%v", r, tv.calc().sub, tv.calc().focus) + } + if a.activeTab() != tabBefore { + t.Errorf("%c without Vim mode changed the active tab", r) + } + if a.overlay != nil { + t.Errorf("%c without Vim mode opened an overlay", r) + } + } +} + +func TestNewTaskGoesToSelectedDayInEverySubMode(t *testing.T) { + for _, sub := range []calSub{calMonth, calWeek, calDay} { + a, tv, root := calVaultApp(t, nil) + a.idx.settings.DailyNotes.Enabled = true + calToSub(t, a, tv, sub) + calPress(a, tv, vim.R('n')) + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("layout %v: overlay after n = %T, want the new-task prompt", sub, a.overlay) + } + if !strings.Contains(p.title, "2026-11-18") { + t.Errorf("layout %v: prompt title = %q, want it to name 2026-11-18", sub, p.title) + } + p.onSubmit(a, "navtask text") + rel := calDailyPath(a, calDate(2026, time.November, 18)) + b, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatalf("layout %v: daily note %s not written: %v", sub, rel, err) + } + if !strings.Contains(string(b), "navtask text") { + t.Errorf("layout %v: daily note is\n%s\nwant it to hold the new task", sub, b) + } + } +} + +func TestWeekColumnFocusEditAndOpenKeys(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + for _, k := range []vim.Key{vim.KeyEnter, vim.R('o')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab, k) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("layout %v: %s opened %+v, want work.md", sub, k, tab) + } + } + + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab, vim.R('x')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "- [x] alpha job") { + t.Errorf("layout %v: x did not toggle the selected entry:\n%s", sub, got) + } + + a, tv, root = calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab, vim.R('>')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-11-19") { + t.Errorf("layout %v: > did not move due:\n%s", sub, got) + } + + a, tv, _ = calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + keys := bindingKeys(a.keymap.Binding("nav.contextMenu")) + if len(keys) != 1 { + t.Fatalf("nav.contextMenu binding = %v, want one key", keys) + } + calPress(a, tv, vim.KeyTab, keys[0]) + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("layout %v: overlay after the context-menu key = %T, want the task menu", sub, a.overlay) + } + } +} + +func TestWeekDayEscAndLeftReturnToGrid(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + for _, vimOn := range []bool{true, false} { + for _, back := range []vim.Key{vim.KeyEsc, vim.Special("left"), vim.R('h')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab) + if tv.calc().focus != calFocusItems { + t.Fatalf("layout %v vim=%v: Tab did not focus the column", sub, vimOn) + } + calPress(a, tv, back) + if tv.calc().focus != calFocusGrid { + t.Errorf("layout %v vim=%v: %s left focus on %v, want the grid", sub, vimOn, back, tv.calc().focus) + } + if tv.calc().sub != sub { + t.Errorf("layout %v vim=%v: %s changed the layout to %v", sub, vimOn, back, tv.calc().sub) + } + requireDay(t, tv, "2026-11-18", "returning to the grid") + } + } + } +} + +func TestTodayKeysInWeekAndDay(t *testing.T) { + calFixToday(t, calDate(2026, time.October, 29)) + for _, r := range "tT" { + a, tv := calNavApp(t, calWeek, true) + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled in week view", r) + } + requireDay(t, tv, "2026-10-29", "week "+string(r)) + requireShown(t, a, tv, []string{"today27 task", "today29 task"}, []string{"wk16 task", "wk22 task"}, "week of today") + if tv.calc().sub != calWeek { + t.Errorf("%c changed the layout to %v", r, tv.calc().sub) + } + + a, tv = calNavApp(t, calDay, true) + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled in day view", r) + } + requireDay(t, tv, "2026-10-29", "day "+string(r)) + requireShown(t, a, tv, []string{"today29 task"}, []string{"today27 task"}, "day of today") + } +} + +func TestWeekDayOpenKeyOpensDailyNote(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.R('o')) + want := calDailyPath(a, calDate(2026, time.November, 18)) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("layout %v: active tab = %+v, want the daily note %s", sub, tab, want) + } + } +} From f86670d62c7e1c0950646093dfb32f297d1f0acf Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:09:04 -0400 Subject: [PATCH 08/65] Feat(tui): half-hourly hour list and hour-list scrolling in week and day With Vim mode on, z switches the week and day hour list between hourly and half-hourly rows. The variant goes back to hourly each time the calendar is opened, and stays put while moving between days, weeks and the two column views. When the list does not fit the body, j/k, the arrow keys and the mouse wheel scroll it by one row, clamped at both ends. The position is kept as a time of day rather than a row index, so it survives a change of variant and moving to other days. On the grid in week and day view, j/k no longer move the selected day; h/l and the half-page keys do that. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_hints.go | 4 + internal/tui/views_tasks.go | 13 + internal/tui/views_tasks_calendar.go | 93 +++- .../tui/views_tasks_calendar_hours_test.go | 432 ++++++++++++++++++ 4 files changed, 533 insertions(+), 9 deletions(-) create mode 100644 internal/tui/views_tasks_calendar_hours_test.go diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index d483e54..0f0a700 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -46,6 +46,10 @@ func (v *tasksView) scrollBy(a *App, delta int) { v.card = max(0, min(max(0, len(v.columns[v.col].cards)-1), v.card+delta)) } case "calendar": + if c := v.calc(); c.sub != calMonth && c.visibleHourRows() > 0 && !c.hourListWhole() { + c.scrollHours(sign(delta)) + return + } v.selectDay(a, v.day.AddDate(0, 0, 7*sign(delta))) default: v.list.move(delta, len(v.rows)) diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 7a77bb0..4b1ce3d 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -82,6 +82,7 @@ func (v *tasksView) setMode(mode string) { v.mode = "kanban" case "calendar": v.mode = "calendar" + v.calc().half = false default: v.mode = "list" } @@ -758,6 +759,9 @@ func (v *tasksView) handleKey(a *App, k vim.Key) bool { default: v.mode = "list" } + if v.mode == "calendar" { + v.calc().half = false + } a.prefs.TasksViewMode = v.mode v.refresh(a) return true @@ -1042,6 +1046,10 @@ func (v *tasksView) moveCard(a *App, t vault.Task, target int) { func (v *tasksView) calendarKey(a *App, k vim.Key) bool { t := v.selectedTask() + if k.IsRune('z') && a.prefs.VimMode && v.calc().sub != calMonth { + v.calc().toggleHalf() + return true + } if v.calc().focus != calFocusGrid { switch { case k.Is("esc") || k.IsRune('h') || k.Is("left"): @@ -1076,9 +1084,14 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { return false } + hourList := v.calc().sub != calMonth switch { case k.Is("esc"): v.calc().sub = (v.calc().sub + 1) % 3 + case hourList && (k.IsRune('j') || k.Is("down")): + v.calc().scrollHours(1) + case hourList && (k.IsRune('k') || k.Is("up")): + v.calc().scrollHours(-1) case k.IsRune('h') || k.Is("left"): v.selectDay(a, v.day.AddDate(0, 0, -1)) case k.IsRune('l') || k.Is("right"): diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index 0ecc303..588e648 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -69,6 +69,9 @@ type calLayout struct { stripRows int // gutter is the width of the hour labels left of the week and day columns. gutter int + // hourStart is the index of the first hour row shown, and hourTotal the + // number of rows in the whole list. + hourStart, hourTotal int } // taskCalendar is the Tasks tab's calendar: day entries, the items beside @@ -78,6 +81,12 @@ type taskCalendar struct { focus calFocus cursor int strip int + // half selects half-hourly hour rows in place of hourly ones. + half bool + // scroll is the time of day, in minutes, of the first hour row shown. + // It is a time rather than a row index so it survives a change of + // variant. + scroll int byDay map[string][]calEntry undated []vault.Task @@ -184,16 +193,22 @@ func (c *taskCalendar) monthLayout(a *App, month time.Time, w, h int) calLayout return lay } -// hourLabelWidth is the width of the widest hour label for the time format. -func hourLabelWidth(a *App) int { +// hourLabelWidth is the width of the widest hour label for the time format +// and hour-list variant. +func hourLabelWidth(a *App, half bool) int { + w := 2 if a.prefs.TimeFormat == "12h" { - return 5 + w = 5 } - return 2 + if half { + w += 3 + } + return w } -// hourLabel is the label of hour row h (0-23). -func hourLabel(a *App, h int) string { +// hourLabel is the label of the row starting at the given minute of the day. +func hourLabel(a *App, minute int) string { + h, half := minute/60, minute%60 == 30 if a.prefs.TimeFormat == "12h" { n := h % 12 if n == 0 { @@ -203,11 +218,69 @@ func hourLabel(a *App, h int) string { if h >= 12 { suffix = "PM" } + if half { + return fmt.Sprintf("%2d:30 %s", n, suffix) + } return fmt.Sprintf("%2d %s", n, suffix) } + if half { + return fmt.Sprintf("%02d:30", h) + } return fmt.Sprintf("%02d", h) } +// hourStep is the minutes one hour row covers. +func (c *taskCalendar) hourStep() int { + if c.half { + return 30 + } + return 60 +} + +// hourRowCount is the number of rows in the whole hour list. +func (c *taskCalendar) hourRowCount() int { return 24 * 60 / c.hourStep() } + +// firstHourRow is the index of the first row shown when room rows fit. +func (c *taskCalendar) firstHourRow(room int) int { + return clampInt(c.scroll/c.hourStep(), 0, max(0, c.hourRowCount()-room)) +} + +// visibleHourRows is how many hour rows the last layout showed, or 0 when +// none was laid out. +func (c *taskCalendar) visibleHourRows() int { + if c.layout.tooSmall || len(c.layout.boxes) == 0 || c.sub == calMonth { + return 0 + } + return c.layout.boxes[0].rect.h +} + +// hourListWhole reports whether the last layout shows every hour row. +func (c *taskCalendar) hourListWhole() bool { + n := c.visibleHourRows() + return n > 0 && n >= c.hourRowCount() +} + +// scrollHours moves the hour list by delta rows, never past either end. +func (c *taskCalendar) scrollHours(delta int) { + step := c.hourStep() + room := c.visibleHourRows() + if room == 0 { + c.scroll = max(0, c.scroll+delta*step) + return + } + c.scroll = clampInt(c.firstHourRow(room)+delta, 0, max(0, c.hourRowCount()-room)) * step +} + +// toggleHalf switches between hourly and half-hourly rows. The list starts at +// the same time, or at the hour containing it, kept inside the new list. +func (c *taskCalendar) toggleHalf() { + c.half = !c.half + c.scroll = c.scroll / c.hourStep() * c.hourStep() + if room := c.visibleHourRows(); room > 0 { + c.scroll = c.firstHourRow(room) * c.hourStep() + } +} + // columnDays is the days a week or day layout shows as columns, in order. func (c *taskCalendar) columnDays(a *App, day time.Time) []time.Time { if c.sub == calDay { @@ -243,8 +316,10 @@ func (c *taskCalendar) columnLayout(a *App, day time.Time, w, h int) calLayout { stripH := 1 + stripRows lay.stripRows = stripRows lay.strip = calRect{x: 0, y: h - stripH, w: w, h: stripH} - lay.gutter = hourLabelWidth(a) + 2 - hourRows := clampInt(h-stripH-headRows, 0, 24) + lay.gutter = hourLabelWidth(a, c.half) + 2 + lay.hourTotal = c.hourRowCount() + hourRows := clampInt(h-stripH-headRows, 0, lay.hourTotal) + lay.hourStart = c.firstHourRow(hourRows) colW := (w - lay.gutter) / len(days) for i, d := range days { lay.boxes = append(lay.boxes, calDayBox{day: d, rect: calRect{x: lay.gutter + i*colW, y: headRows, w: colW, h: hourRows}}) @@ -280,7 +355,7 @@ func (c *taskCalendar) renderColumns(a *App, day time.Time, lay calLayout, w, h hourRows := lay.boxes[0].rect.h for i := 0; i < hourRows; i++ { var sb strings.Builder - sb.WriteString(th.Muted.Render(padRight(hourLabel(a, i), lay.gutter-2)) + th.Dim.Render(" │")) + sb.WriteString(th.Muted.Render(padRight(hourLabel(a, (lay.hourStart+i)*c.hourStep()), lay.gutter-2)) + th.Dim.Render(" │")) for _, b := range lay.boxes { sb.WriteString(c.columnLine(a, b, i, focused && sameDay(b.day, day))) } diff --git a/internal/tui/views_tasks_calendar_hours_test.go b/internal/tui/views_tasks_calendar_hours_test.go new file mode 100644 index 0000000..22fed9e --- /dev/null +++ b/internal/tui/views_tasks_calendar_hours_test.go @@ -0,0 +1,432 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/vim" +) + +// calHalfHours24 is the 48 half-hourly labels in the 24h format. +func calHalfHours24() []string { + var out []string + for h := 0; h < 24; h++ { + out = append(out, fmt.Sprintf("%02d", h), fmt.Sprintf("%02d:30", h)) + } + return out +} + +// calHalfHours12 is the 48 half-hourly labels in the 12h format. +func calHalfHours12() []string { + var out []string + for _, h := range calHours12() { + out = append(out, h, strings.Replace(h, " ", ":30 ", 1)) + } + return out +} + +func calLabels(a *App, tv *tasksView, w, bodyH int) []string { + return calHourLabels(calScreen(a, tv, w, bodyH), calLabel24) +} + +func requireVariant(t *testing.T, a *App, tv *tasksView, half bool, context string) { + t.Helper() + want := 24 + if half { + want = 48 + } + lines := calScreen(a, tv, 120, 110) + if got := calHourLabels(lines, calLabel24); len(got) != want { + t.Errorf("%s: %d hour rows shown, want %d (half-hourly=%v):\n%s", context, len(got), want, half, calScreenText(lines)) + } +} + +func TestMonthIgnoresZ(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 3, "zt")) + a.prefs.VimMode = true + before := calScreenText(calScreen(a, tv, 120, 40)) + calPress(a, tv, vim.R('z')) + if after := calScreenText(calScreen(a, tv, 120, 40)); after != before { + t.Errorf("z changed the month view:\nbefore:\n%s\nafter:\n%s", before, after) + } + if tv.calc().sub != calMonth { + t.Errorf("layout after z = %v, want month", tv.calc().sub) + } + calEsc(a, tv) + requireVariant(t, a, tv, false, "week after z in month view") +} + +func TestHourListStartsHourlyAtMidnight(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + got := calLabels(a, tv, 120, 70) + if strings.Join(got, ",") != strings.Join(calHours24(), ",") { + t.Errorf("layout %v: rows = %v, want 00..23", sub, got) + } + if small := calLabels(a, tv, 120, 20); len(small) == 0 || small[0] != "00" { + t.Errorf("layout %v: first visible row in a short body = %v, want 00", sub, small) + } + } +} + +func TestZTogglesVariantInWeek(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calWeek) + if !tv.handleKey(a, vim.R('z')) { + t.Error("z was not handled in week view") + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), "week after z") + calPress(a, tv, vim.R('z')) + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHours24(), "week after z twice") +} + +func TestZTogglesVariantInDay(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calDay) + if !tv.handleKey(a, vim.R('z')) { + t.Error("z was not handled in day view") + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), "day after z") + calPress(a, tv, vim.R('z')) + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHours24(), "day after z twice") +} + +func TestZWorksInItemFocusWithVimMode(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 2, "zi")) + a.prefs.VimMode = true + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("Enter left focus on %v, want the day's column", tv.calc().focus) + } + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, true, "z in item focus") +} + +func TestVariantResetsOnCalendarEntry(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, true, "before leaving") + for i := 0; i < 3; i++ { + calPress(a, tv, vim.R('v')) + } + if tv.mode != "calendar" { + t.Fatalf("mode after v round trip = %q, want calendar", tv.mode) + } + requireVariant(t, a, tv, false, "after the v round trip") + + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, true, "before :tasks calendar") + tv.setMode("list") + if err := a.openTasksMode("calendar"); err != nil { + t.Fatal(err) + } + tv2, ok := a.activeTab().view.(*tasksView) + if !ok { + t.Fatalf("active tab is %T", a.activeTab().view) + } + requireVariant(t, a, tv2, false, "after :tasks calendar") +} + +func TestVariantSurvivesMovesAndWeekDaySwitch(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.R('z')) + // Over the end of the week into the next one, then back. + calPress(a, tv, calRunes("lllllll")...) + requireDay(t, tv, "2026-11-25", "seven l presses") + requireVariant(t, a, tv, true, "after moving to another week") + calPress(a, tv, calRunes("hhhhhhh")...) + requireVariant(t, a, tv, true, "after moving back") + calEsc(a, tv) + if tv.calc().sub != calDay { + t.Fatalf("Esc gave layout %v, want day", tv.calc().sub) + } + requireVariant(t, a, tv, true, "after switching to day view") +} + +func TestHourlyShownWholeWhenTallEnough(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v", sub)) + calPress(a, tv, vim.R('j')) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v after j", sub)) + } +} + +func TestHalfHourlyShownWholeWhenTallEnough(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + if sub == calWeek { + calPress(a, tv, vim.R('z')) + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourListScrollsAtMinimumSize(t *testing.T) { + for _, c := range []struct { + sub calSub + w, h int + }{{calWeek, 64, 12}, {calDay, 40, 10}} { + for _, half := range []bool{false, true} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, c.sub) + if half { + calPress(a, tv, vim.R('z')) + } + lines := calScreen(a, tv, c.w, c.h) + ctx := fmt.Sprintf("layout %v half=%v %dx%d", c.sub, half, c.w, c.h) + requireTooSmall(t, lines, false, ctx) + got := calHourLabels(lines, calLabel24) + total := 24 + if half { + total = 48 + } + if len(got) == 0 || len(got) >= total { + t.Fatalf("%s: %d rows shown, want some but fewer than %d:\n%s", ctx, len(got), total, calScreenText(lines)) + } + if got[0] != "00" { + t.Errorf("%s: first row = %s, want 00", ctx, got[0]) + } + calPress(a, tv, vim.R('j')) + scrolled := calHourLabels(calScreen(a, tv, c.w, c.h), calLabel24) + if len(scrolled) == 0 || scrolled[0] == "00" { + t.Errorf("%s: j did not scroll the list: %v", ctx, scrolled) + } + } + } +} + +func TestHalfHourlyLabels24h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + if sub == calWeek { + calPress(a, tv, vim.R('z')) + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHalfHourlyLabels12h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "12h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + if sub == calWeek { + calPress(a, tv, vim.R('z')) + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel12, calHalfHours12(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourListJKScrollClamped(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, sub) + first := calLabels(a, tv, 120, 20) + n := len(first) + if n == 0 || n >= 24 || first[0] != "00" { + t.Fatalf("layout %v: unexpected short-body rows %v", sub, first) + } + calPress(a, tv, vim.R('k')) + if got := calLabels(a, tv, 120, 20); got[0] != "00" { + t.Errorf("layout %v: k before the first row moved the list to %s", sub, got[0]) + } + calPress(a, tv, vim.R('j')) + if got := calLabels(a, tv, 120, 20); got[0] != "01" { + t.Errorf("layout %v: j moved the list to %s, want 01", sub, got[0]) + } + requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v after j", sub)) + for i := 0; i < 60; i++ { + calPress(a, tv, vim.R('j')) + } + got := calLabels(a, tv, 120, 20) + if len(got) != n || got[len(got)-1] != "23" || got[0] != fmt.Sprintf("%02d", 24-n) { + t.Errorf("layout %v: after scrolling to the end rows = %v, want %d rows ending at 23", sub, got, n) + } + requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v after many j", sub)) + for i := 0; i < 60; i++ { + calPress(a, tv, vim.R('k')) + } + if got := calLabels(a, tv, 120, 20); got[0] != "00" { + t.Errorf("layout %v: after scrolling back first row = %s, want 00", sub, got[0]) + } + } +} + +func TestHourListArrowsScrollWithoutVimMode(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, sub) + if got := calLabels(a, tv, 120, 20); len(got) >= 24 || got[0] != "00" { + t.Fatalf("layout %v: unexpected rows %v", sub, got) + } + calPress(a, tv, vim.KeyDown) + if got := calLabels(a, tv, 120, 20); got[0] != "01" { + t.Errorf("layout %v: Down moved the list to %s, want 01", sub, got[0]) + } + calPress(a, tv, vim.KeyDown) + calPress(a, tv, vim.KeyUp) + if got := calLabels(a, tv, 120, 20); got[0] != "01" { + t.Errorf("layout %v: Down, Down, Up left the list at %s, want 01", sub, got[0]) + } + requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourListScrollPersists(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calWeek) + calLabels(a, tv, 120, 20) + calPress(a, tv, calRunes("jjjjjj")...) + requireFirst := func(context string) { + t.Helper() + got := calLabels(a, tv, 120, 20) + if len(got) == 0 || got[0] != "06" { + t.Errorf("%s: first row = %v, want 06", context, got) + } + } + requireFirst("after scrolling") + calPress(a, tv, calRunes("lllllll")...) + requireFirst("in the next week") + calPress(a, tv, calRunes("hhhhhhh")...) + requireFirst("back in the first week") + calEsc(a, tv) + requireFirst("in day view") + calPress(a, tv, calRunes("l")...) + requireFirst("on another day") + calEsc(a, tv) + if tv.calc().sub != calMonth { + t.Fatalf("layout = %v, want month", tv.calc().sub) + } + calEsc(a, tv) + requireFirst("in week view after the month view") + calEsc(a, tv) + requireFirst("in day view after the month view") +} + +func TestHourListScrollAfterZKeepsTime(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calWeek) + n := len(calLabels(a, tv, 120, 20)) + if n == 0 || n >= 24 { + t.Fatalf("unexpected short-body row count %d", n) + } + calPress(a, tv, calRunes("jjjjjj")...) + calPress(a, tv, vim.R('z')) + got := calLabels(a, tv, 120, 20) + if len(got) == 0 || got[0] != "06" { + t.Fatalf("hourly 06 became %v in the half-hourly variant, want first row 06", got) + } + calPress(a, tv, vim.R('j')) + if got := calLabels(a, tv, 120, 20); got[0] != "06:30" { + t.Fatalf("one j in the half-hourly variant gave %v, want first row 06:30", got) + } + calPress(a, tv, vim.R('z')) + if got := calLabels(a, tv, 120, 20); got[0] != "06" { + t.Errorf("06:30 became %v in the hourly variant, want first row 06", got) + } + + // At the end of the half-hourly list the same time is past the end of + // the hourly list, so the start is clamped to the last full page. + calPress(a, tv, vim.R('z')) + for i := 0; i < 120; i++ { + calPress(a, tv, vim.R('j')) + } + end := calLabels(a, tv, 120, 20) + if len(end) != n || end[len(end)-1] != "23:30" { + t.Fatalf("half-hourly end of list = %v, want %d rows ending at 23:30", end, n) + } + calPress(a, tv, vim.R('z')) + got = calLabels(a, tv, 120, 20) + if len(got) != n || got[len(got)-1] != "23" { + t.Errorf("hourly list after z at the end = %v, want %d rows ending at 23", got, n) + } +} + +func TestZWithoutVimModeInItemFocusDoesNothing(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 2, "zv")) + a.prefs.VimMode = false + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("Enter left focus on %v, want the day's column", tv.calc().focus) + } + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, false, "z in item focus without Vim mode") +} + +func TestZWithoutVimModeOnGridDoesNothing(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, false, fmt.Sprintf("z on the %v grid without Vim mode", sub)) + } +} + +func TestEditorFoldKeysUnaffected(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.VimMode = true + a.openNote("note.md", true) + buf := a.activeBuffer() + if buf == nil { + t.Fatal("no active note buffer") + } + buf.ed.ReplaceText("# A\na1\na2\n# B\nb1") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + press := func(s string) { + for _, r := range s { + a.handleKey(vim.R(r)) + } + } + press("zc") + if end, ok := buf.ed.IsFoldStart(0); !ok || end != 2 { + t.Errorf("zc: fold at line 0 = %d %v, want 0..2", end, ok) + } + press("zo") + if _, ok := buf.ed.IsFoldStart(0); ok { + t.Error("zo did not open the fold") + } + press("zM") + if len(buf.ed.Folds()) != 2 { + t.Errorf("zM: folds = %v, want both sections", buf.ed.Folds()) + } + press("zR") + if len(buf.ed.Folds()) != 0 { + t.Errorf("zR: folds = %v, want none", buf.ed.Folds()) + } +} From 47370e5a46bc893a4adc1a44a20020dc10bd1358 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:20:39 -0400 Subject: [PATCH 09/65] Feat(tui): scroll a busy day's column in week and day view When the whole hour list fits, j/k and the arrow keys on the grid scroll the selected day's column one entry at a time instead, so a day with more tasks than lines can be read without leaving the grid. A scrolled column counts the entries hidden above it with a "+N more" line at its top, as well as those still below. Picking another day returns the column to its top. With focus inside the column, the column follows the selection, so the selected task never sits off screen. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_tasks.go | 9 +- internal/tui/views_tasks_calendar.go | 97 ++++++- .../views_tasks_calendar_colscroll_test.go | 251 ++++++++++++++++++ 3 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 internal/tui/views_tasks_calendar_colscroll_test.go diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 4b1ce3d..e5566b7 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -1089,9 +1089,13 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { case k.Is("esc"): v.calc().sub = (v.calc().sub + 1) % 3 case hourList && (k.IsRune('j') || k.Is("down")): - v.calc().scrollHours(1) + if !v.calc().scrollColumn(v.day, 1) { + v.calc().scrollHours(1) + } case hourList && (k.IsRune('k') || k.Is("up")): - v.calc().scrollHours(-1) + if !v.calc().scrollColumn(v.day, -1) { + v.calc().scrollHours(-1) + } case k.IsRune('h') || k.Is("left"): v.selectDay(a, v.day.AddDate(0, 0, -1)) case k.IsRune('l') || k.Is("right"): @@ -1150,6 +1154,7 @@ func (v *tasksView) selectDay(a *App, day time.Time) { v.month = time.Date(v.day.Year(), v.day.Month(), 1, 0, 0, 0, 0, time.Local) } v.calc().cursor = 0 + v.calc().colScroll = 0 } func (v *tasksView) hintTargets(a *App, p *pane) []hintTarget { diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index 588e648..039e54a 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -87,6 +87,9 @@ type taskCalendar struct { // It is a time rather than a row index so it survives a change of // variant. scroll int + // colScroll is how many entries the selected day's column has scrolled + // off its top. It only applies while the hour list is shown whole. + colScroll int byDay map[string][]calEntry undated []vault.Task @@ -357,7 +360,11 @@ func (c *taskCalendar) renderColumns(a *App, day time.Time, lay calLayout, w, h var sb strings.Builder sb.WriteString(th.Muted.Render(padRight(hourLabel(a, (lay.hourStart+i)*c.hourStep()), lay.gutter-2)) + th.Dim.Render(" │")) for _, b := range lay.boxes { - sb.WriteString(c.columnLine(a, b, i, focused && sameDay(b.day, day))) + scroll := 0 + if sameDay(b.day, day) { + scroll = c.columnScroll(b) + } + sb.WriteString(c.columnLine(a, b, i, focused && sameDay(b.day, day), scroll)) } rows = append(rows, padRight(sb.String(), w)) } @@ -368,30 +375,98 @@ func (c *taskCalendar) renderColumns(a *App, day time.Time, lay calLayout, w, h return fitBlock(strings.Join(rows, "\n"), w, h) } -// columnLine is line li of one day column: entries from the top, with -// "+N more" on the last line when they do not all fit. -func (c *taskCalendar) columnLine(a *App, b calDayBox, li int, focusedDay bool) string { +// maxColumnScroll is the furthest a day column can scroll: the last entry +// then sits on the last line. +func (c *taskCalendar) maxColumnScroll(b calDayBox) int { + n := len(c.dayEntries(b.day)) + if n <= b.rect.h { + return 0 + } + return n - (b.rect.h - 1) +} + +// columnScroll is the scroll applied to box b, kept inside its range. A +// column only scrolls while the hour list is shown whole. +func (c *taskCalendar) columnScroll(b calDayBox) int { + items := c.focus == calFocusItems + if !c.hourListWhole() && !items { + return 0 + } + s := clampInt(c.colScroll, 0, c.maxColumnScroll(b)) + if items { + // Keep the selected entry on screen as the selection moves. + s = min(s, c.cursor) + for s < c.maxColumnScroll(b) && c.cursor >= s+c.columnShown(b, s) { + s++ + } + c.colScroll = s + } + return s +} + +// columnShown is how many entries box b lists when scrolled by s. +func (c *taskCalendar) columnShown(b calDayBox, s int) int { + room := b.rect.h - min(s, 1) + if rest := len(c.dayEntries(b.day)) - s; rest <= room { + return rest + } + return room - 1 +} + +// scrollColumn scrolls the selected day's column by delta entries, never past +// either end. It reports whether there is a column to scroll. +func (c *taskCalendar) scrollColumn(day time.Time, delta int) bool { + if c.sub == calMonth || !c.hourListWhole() { + return false + } + for _, b := range c.layout.boxes { + if sameDay(b.day, day) { + c.colScroll = clampInt(c.columnScroll(b)+delta, 0, c.maxColumnScroll(b)) + return true + } + } + return false +} + +// columnLine is line li of one day column: entries from the scroll offset, +// with "+N more" on the first line when some are scrolled off the top and on +// the last line when they do not all fit. +func (c *taskCalendar) columnLine(a *App, b calDayBox, li int, focusedDay bool, scroll int) string { th := a.theme w := b.rect.w entries := c.dayEntries(b.day) - shown := len(entries) - if shown > b.rect.h { - shown = b.rect.h - 1 + more := func(n int) string { + return th.Dim.Render(padRight(fmt.Sprintf(" +%d more", n), w)) + } + first := 0 + if scroll > 0 { + if li == 0 { + return more(scroll) + } + first = scroll + li-- + } + rest := entries[first:] + room := b.rect.h - min(scroll, 1) + shown := len(rest) + if shown > room { + shown = room - 1 } switch { case li < shown: - e := entries[li] + idx := first + li + e := entries[idx] lead := " " if e.scheduled { lead = calScheduledMark } text := padRight(lead+truncateCells(e.task.Content, w-2), w) - if focusedDay && c.focus == calFocusItems && li == c.cursor { + if focusedDay && c.focus == calFocusItems && idx == c.cursor { return th.SelectedFocus.Render(text) } return text - case li == shown && shown < len(entries): - return th.Dim.Render(padRight(fmt.Sprintf(" +%d more", len(entries)-shown), w)) + case li == shown && shown < len(rest): + return more(len(rest) - shown) } return strings.Repeat(" ", w) } diff --git a/internal/tui/views_tasks_calendar_colscroll_test.go b/internal/tui/views_tasks_calendar_colscroll_test.go new file mode 100644 index 0000000..434ab43 --- /dev/null +++ b/internal/tui/views_tasks_calendar_colscroll_test.go @@ -0,0 +1,251 @@ +package tui + +import ( + "fmt" + "slices" + "strconv" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/vim" +) + +// colView is what the busy day's column shows: the entries listed, in order, +// and the two `+N more` counts around them (-1 when a count is absent). +type colView struct { + entries []int + above int + below int +} + +const colScrollTotal = 50 + +// calColumnView reads the zq entries and the `+N more` lines of one render. +func calColumnView(t *testing.T, lines []string) colView { + t.Helper() + v := colView{above: -1, below: -1} + first, last := -1, -1 + for i, l := range lines { + for _, m := range calZqRe.FindAllString(l, -1) { + n, err := strconv.Atoi(strings.TrimPrefix(m, "zq")) + if err != nil { + t.Fatal(err) + } + v.entries = append(v.entries, n) + if first < 0 { + first = i + } + last = i + } + } + if first < 0 { + t.Fatalf("no entries listed:\n%s", calScreenText(lines)) + } + for i, l := range lines { + m := calMoreRe.FindStringSubmatch(l) + if m == nil { + continue + } + n, _ := strconv.Atoi(m[1]) + switch { + case i < first: + if v.above >= 0 { + t.Fatalf("two `+N more` lines above the entries:\n%s", calScreenText(lines)) + } + v.above = n + case i > last: + if v.below >= 0 { + t.Fatalf("two `+N more` lines below the entries:\n%s", calScreenText(lines)) + } + v.below = n + default: + t.Fatalf("`+N more` line between entries:\n%s", calScreenText(lines)) + } + } + return v +} + +func (v colView) String() string { + return fmt.Sprintf("above=%d entries=%v below=%d", v.above, v.entries, v.below) +} + +// calColumnApp opens a layout with one very busy day and an hour list that is +// shown whole. +func calColumnApp(t *testing.T, sub calSub, vimOn bool) (*App, *tasksView) { + t.Helper() + a, tv := calTestApp(t, calManyTasks("2026-11-18", colScrollTotal, "zq")) + a.prefs.VimMode = vimOn + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, sub) + if got := calLabels(a, tv, 120, 70); len(got) != 24 || got[0] != "00" { + t.Fatalf("layout %v: hour list not shown whole: %v", sub, got) + } + return a, tv +} + +func calColumnAfter(t *testing.T, a *App, tv *tasksView) colView { + t.Helper() + return calColumnView(t, calScreen(a, tv, 120, 70)) +} + +func TestColumnScrollWhenHourListWhole(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + before := calColumnAfter(t, a, tv) + if before.above != -1 || before.below <= 0 || before.entries[0] != 0 { + t.Fatalf("%s: unscrolled column = %v", ctx, before) + } + + calPress(a, tv, vim.R('j')) + got := calColumnAfter(t, a, tv) + if got.above != 1 || got.entries[0] != 1 { + t.Errorf("%s: after j column = %v, want one entry scrolled off the top", ctx, got) + } + if tv.calc().focus != calFocusGrid { + t.Errorf("%s: j moved focus to %v, want the grid", ctx, tv.calc().focus) + } + if sel := tv.selectedTask(); sel != nil { + t.Errorf("%s: j selected entry %q, want none", ctx, sel.Content) + } + requireDay(t, tv, "2026-11-18", ctx+" after j") + if labels := calLabels(a, tv, 120, 70); len(labels) != 24 || labels[0] != "00" { + t.Errorf("%s: j scrolled the hour list: %v", ctx, labels) + } + + calPress(a, tv, calRunes("jj")...) + if got := calColumnAfter(t, a, tv); got.above != 3 || got.entries[0] != 3 { + t.Errorf("%s: after three j column = %v, want three entries scrolled off", ctx, got) + } + + calPress(a, tv, calRunes("kkk")...) + got = calColumnAfter(t, a, tv) + if got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: after k back to the top column = %v", ctx, got) + } + calPress(a, tv, vim.R('k')) + if got := calColumnAfter(t, a, tv); got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: k at the top scrolled the column to %v", ctx, got) + } + } +} + +func TestScrolledColumnShowsMoreAboveAndBelow(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + base := calColumnAfter(t, a, tv) + // The unscrolled column spends one line on the bottom count, so its + // room is the entries listed plus that line. + room := len(base.entries) + 1 + if room < 5 { + t.Fatalf("%s: column room %d is too small for the check", ctx, room) + } + calPress(a, tv, calRunes("jjj")...) + got := calColumnAfter(t, a, tv) + // Scrolled by three: a top line, a bottom line, entries between. + wantEntries := room - 2 + if got.above != 3 { + t.Errorf("%s: top count = %d, want +3 more", ctx, got.above) + } + if len(got.entries) != wantEntries || got.entries[0] != 3 || got.entries[len(got.entries)-1] != 3+wantEntries-1 { + t.Errorf("%s: entries = %v, want %d entries from the 4th", ctx, got.entries, wantEntries) + } + if want := colScrollTotal - 3 - wantEntries; got.below != want { + t.Errorf("%s: bottom count = %d, want +%d more", ctx, got.below, want) + } + lines := calScreen(a, tv, 120, 70) + if top, firstEntry := calRowOf(lines, "+3 more"), calRowOf(lines, "zq03"); top < 0 || top != firstEntry-1 { + t.Errorf("%s: `+3 more` on row %d, want the line right above the first entry (row %d)", ctx, top, firstEntry) + } + } +} + +func TestColumnScrolledToEndShowsOnlyTopMore(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + room := len(calColumnAfter(t, a, tv).entries) + 1 + for i := 0; i < colScrollTotal+20; i++ { + calPress(a, tv, vim.R('j')) + } + got := calColumnAfter(t, a, tv) + wantEntries := room - 1 + if got.below != -1 { + t.Errorf("%s: scrolled to the end still shows a bottom count: %v", ctx, got) + } + if want := colScrollTotal - wantEntries; got.above != want { + t.Errorf("%s: top count = %d, want +%d more", ctx, got.above, want) + } + if len(got.entries) != wantEntries || got.entries[len(got.entries)-1] != colScrollTotal-1 { + t.Errorf("%s: entries = %v, want the last %d ending at the 50th", ctx, got.entries, wantEntries) + } + requireDay(t, tv, "2026-11-18", ctx) + } +} + +func TestUnscrolledColumnHasNoTopMore(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + got := calColumnAfter(t, a, tv) + if got.above != -1 { + t.Errorf("%s: unscrolled column has a top count: %v", ctx, got) + } + if got.below != colScrollTotal-len(got.entries) { + t.Errorf("%s: bottom count = %d, want +%d more", ctx, got.below, colScrollTotal-len(got.entries)) + } + // Scroll down and all the way back: the top line goes away again. + calPress(a, tv, calRunes("jjjj")...) + calPress(a, tv, calRunes("kkkkk")...) + if got := calColumnAfter(t, a, tv); got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: column scrolled back to the top = %v", ctx, got) + } + } +} + +func TestColumnScrollArrowsWithoutVimMode(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, false) + ctx := fmt.Sprintf("layout %v", sub) + calPress(a, tv, vim.KeyDown, vim.KeyDown) + got := calColumnAfter(t, a, tv) + if got.above != 2 || got.entries[0] != 2 { + t.Errorf("%s: Down twice gave %v, want two entries scrolled off", ctx, got) + } + calPress(a, tv, vim.KeyUp) + if got := calColumnAfter(t, a, tv); got.above != 1 || got.entries[0] != 1 { + t.Errorf("%s: Down twice then Up gave %v, want one entry scrolled off", ctx, got) + } + if tv.calc().focus != calFocusGrid || tv.selectedTask() != nil { + t.Errorf("%s: arrows changed focus or selected an entry", ctx) + } + requireDay(t, tv, "2026-11-18", ctx) + } +} + +func TestColumnFollowsSelectionInItemFocus(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + calPress(a, tv, vim.KeyTab) + if tv.calc().focus != calFocusItems { + t.Fatalf("%s: Tab did not focus the column", ctx) + } + for i := 0; i < 40; i++ { + calPress(a, tv, vim.R('j')) + sel := tv.selectedTask() + if sel == nil { + t.Fatalf("%s: no selection after %d j", ctx, i+1) + } + n, _ := strconv.Atoi(strings.TrimPrefix(sel.Content, "zq")) + if got := calColumnAfter(t, a, tv); !slices.Contains(got.entries, n) { + t.Fatalf("%s: selected zq%02d is off screen: %v", ctx, n, got) + } + } + calPress(a, tv, calRunes(strings.Repeat("k", 40))...) + if got := calColumnAfter(t, a, tv); got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: back on the first entry the column is still scrolled: %v", ctx, got) + } + } +} From 1622851013a41808bfba8478e3ed90ff0aa4c2d3 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:33:49 -0400 Subject: [PATCH 10/65] Feat(tui): Tab reaches the calendar's No-date strip Tab now cycles the calendar between the grid, the selected day's tasks and the strip of undated tasks below, skipping whichever of the two is empty. In the strip, j/k and the arrow keys move the selection and the usual task keys act on it, so undated tasks can be opened, toggled or given a date without leaving the calendar. When a task leaves the strip, for example because it got a due date, the selection stays at the same position, and focus goes back to the grid once the strip is empty. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_tasks.go | 16 +- internal/tui/views_tasks_calendar.go | 13 +- .../tui/views_tasks_calendar_strip_test.go | 307 ++++++++++++++++++ 3 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 internal/tui/views_tasks_calendar_strip_test.go diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index e5566b7..d0ea477 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -1057,12 +1057,20 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { case k.Is("tab"): v.calc().cycleFocus(v.day) case k.IsRune('j') || k.Is("down"): - if v.calc().cursor < len(v.calc().dayEntries(v.day))-1 { - v.calc().cursor++ + if c := v.calc(); c.focus == calFocusStrip { + if c.strip < len(c.undated)-1 { + c.strip++ + } + } else if c.cursor < len(c.dayEntries(v.day))-1 { + c.cursor++ } case k.IsRune('k') || k.Is("up"): - if v.calc().cursor > 0 { - v.calc().cursor-- + if c := v.calc(); c.focus == calFocusStrip { + if c.strip > 0 { + c.strip-- + } + } else if c.cursor > 0 { + c.cursor-- } case k.Is("enter") || k.IsRune('o'): if t != nil { diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index 039e54a..8d22f91 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -109,6 +109,12 @@ func (c *taskCalendar) rebuild(tasks []vault.Task, day time.Time) { if c.focus == calFocusItems && len(entries) == 0 { c.focus = calFocusGrid } + if c.strip >= len(c.undated) { + c.strip = max(0, len(c.undated)-1) + } + if c.focus == calFocusStrip && len(c.undated) == 0 { + c.focus = calFocusGrid + } } func (c *taskCalendar) dayEntries(day time.Time) []calEntry { @@ -116,12 +122,15 @@ func (c *taskCalendar) dayEntries(day time.Time) []calEntry { } // focusOrder is the parts Tab visits, in order: the grid, then the selected -// day's items when it has any. +// day's items when it has any, then the strip of undated tasks when it has any. func (c *taskCalendar) focusOrder(day time.Time) []calFocus { order := []calFocus{calFocusGrid} if len(c.dayEntries(day)) > 0 { order = append(order, calFocusItems) } + if len(c.undated) > 0 { + order = append(order, calFocusStrip) + } return order } @@ -142,6 +151,8 @@ func (c *taskCalendar) cycleFocus(day time.Time) bool { c.focus = next if next == calFocusItems { c.cursor = 0 + } else if next == calFocusStrip { + c.strip = 0 } return true } diff --git a/internal/tui/views_tasks_calendar_strip_test.go b/internal/tui/views_tasks_calendar_strip_test.go new file mode 100644 index 0000000..e747b00 --- /dev/null +++ b/internal/tui/views_tasks_calendar_strip_test.go @@ -0,0 +1,307 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +var calStripSubs = []struct { + name string + sub calSub +}{{"month", calMonth}, {"week", calWeek}, {"day", calDay}} + +const calStripNote = "- [ ] aaa strip\n- [ ] bbb strip\n- [ ] ccc strip\n" + +func calStripTask(id, content string) vault.Task { + return vault.Task{ID: id, SourcePath: "s.md", Content: content} +} + +func calDatedTask() vault.Task { + return vault.Task{ID: "d#0", SourcePath: "d.md", Content: "dated job", Due: "2026-11-18"} +} + +func requireFocus(t *testing.T, tv *tasksView, want calFocus, context string) { + t.Helper() + if got := tv.calc().focus; got != want { + t.Errorf("%s: focus = %v, want %v", context, got, want) + } +} + +func requireSelected(t *testing.T, tv *tasksView, want, context string) { + t.Helper() + got := tv.selectedTask() + if got == nil || got.Content != want { + t.Errorf("%s: selected = %+v, want %q", context, got, want) + } +} + +// calReload runs the reload every task change triggers: the tasks are scanned +// again and the open views rebuilt from them. +func calReload(t *testing.T, a *App) { + t.Helper() + msg, ok := a.loadTasksCmd()().(tasksLoadedMsg) + if !ok || msg.err != nil { + t.Fatalf("task reload failed: %+v", msg) + } + a.Update(msg) +} + +// calGiveDue sets a due date through the prompt that `d` opens. +func calGiveDue(t *testing.T, a *App, tv *tasksView, day string) { + t.Helper() + calPress(a, tv, vim.R('d')) + if _, ok := a.overlay.(*prompt); !ok { + t.Fatalf("overlay after d = %T, want the due-date prompt", a.overlay) + } + for _, r := range day { + a.overlay.handleKey(a, vim.R(r)) + } + a.overlay.handleKey(a, vim.KeyEnter) + calReload(t, a) +} + +func calManyUndated(n int) []vault.Task { + var out []vault.Task + for i := 0; i < n; i++ { + out = append(out, calStripTask(fmt.Sprintf("u#%d", i), fmt.Sprintf("loose item %02d", i))) + } + return out +} + +func TestTabCyclesGridItemsStrip(t *testing.T) { + for _, vimOn := range []bool{true, false} { + for _, s := range calStripSubs { + a, tv := calTestApp(t, []vault.Task{calDatedTask(), calStripTask("u#0", "loose end")}) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, s.sub) + ctx := fmt.Sprintf("%s vim=%v", s.name, vimOn) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusItems, ctx+" first Tab") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, ctx+" second Tab") + requireSelected(t, tv, "loose end", ctx+" second Tab") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, ctx+" third Tab") + requireDay(t, tv, "2026-11-18", ctx) + if tv.calc().sub != s.sub { + t.Errorf("%s: Tab changed the sub-mode to %v", ctx, tv.calc().sub) + } + } + } +} + +func TestTabSkipsEmptyStrip(t *testing.T) { + for _, s := range calStripSubs { + a, tv := calTestApp(t, []vault.Task{calDatedTask()}) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusItems, s.name+" first Tab") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, s.name+" Tab from the items skips the empty strip") + } +} + +func TestTabSkipsEmptyItemPanel(t *testing.T) { + for _, s := range calStripSubs { + a, tv := calTestApp(t, []vault.Task{calStripTask("u#0", "loose end")}) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, s.name+" Tab on an empty day skips the item panel") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, s.name+" Tab from the strip") + } +} + +func TestTabStaysOnGridWhenBothEmpty(t *testing.T) { + for _, s := range calStripSubs { + a, tv := calTestApp(t, nil) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, s.name+" Tab with nothing to focus") + requireDay(t, tv, "2026-11-18", s.name) + } +} + +func TestStripJKMovesAndScrolls(t *testing.T) { + a, tv := calTestApp(t, calManyUndated(3)) + a.prefs.VimMode = true + calPress(a, tv, vim.KeyTab) + requireSelected(t, tv, "loose item 00", "strip entered") + calPress(a, tv, vim.R('j'), vim.R('j'), vim.R('k')) + requireSelected(t, tv, "loose item 01", "j j k") + calPress(a, tv, vim.R('k'), vim.R('k')) + requireSelected(t, tv, "loose item 00", "k stops at the first") + calPress(a, tv, vim.R('j'), vim.R('j'), vim.R('j'), vim.R('j')) + requireSelected(t, tv, "loose item 02", "j stops at the last") + + for _, s := range calStripSubs { + a, tv := calTestApp(t, calManyUndated(30)) + a.prefs.VimMode = true + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + if text := calScreenText(calScreen(a, tv, 140, 40)); strings.Contains(text, "loose item 29") { + t.Fatalf("%s: the strip shows all 30 tasks, so it cannot scroll:\n%s", s.name, text) + } + for i := 0; i < 29; i++ { + calPress(a, tv, vim.R('j')) + } + requireSelected(t, tv, "loose item 29", s.name+" after 29 j") + if text := calScreenText(calScreen(a, tv, 140, 40)); !strings.Contains(text, "loose item 29") { + t.Errorf("%s: the strip did not scroll to the selected task:\n%s", s.name, text) + } + for i := 0; i < 29; i++ { + calPress(a, tv, vim.R('k')) + } + if text := calScreenText(calScreen(a, tv, 140, 40)); !strings.Contains(text, "loose item 00") { + t.Errorf("%s: the strip did not scroll back to the first task:\n%s", s.name, text) + } + } +} + +func TestStripArrowsWithoutVimMode(t *testing.T) { + a, tv := calTestApp(t, calManyUndated(3)) + a.prefs.VimMode = false + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "Tab without Vim mode") + calPress(a, tv, vim.Special("down"), vim.Special("down"), vim.Special("up")) + requireSelected(t, tv, "loose item 01", "Down Down Up") +} + +func TestStripKeysActOnSelectedTask(t *testing.T) { + stripApp := func(t *testing.T) (*App, *tasksView, string) { + a, tv, root := calVaultApp(t, map[string]string{"work.md": calStripNote}) + calPress(a, tv, vim.KeyTab, vim.R('j')) + requireFocus(t, tv, calFocusStrip, "setup") + requireSelected(t, tv, "bbb strip", "setup") + return a, tv, root + } + + for _, k := range []vim.Key{vim.KeyEnter, vim.R('o')} { + a, tv, _ := stripApp(t) + calPress(a, tv, k) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("%s: active tab = %+v, want work.md", k, tab) + } + } + + a, tv, root := stripApp(t) + calPress(a, tv, vim.R('x')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "- [x] bbb strip") || strings.Contains(got, "- [x] aaa") { + t.Errorf("x did not toggle the selected strip task:\n%s", got) + } + + a, tv, _ = stripApp(t) + keys := bindingKeys(a.keymap.Binding("nav.contextMenu")) + if len(keys) != 1 { + t.Fatalf("nav.contextMenu binding = %v, want one key", keys) + } + calPress(a, tv, keys[0]) + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("overlay after the context-menu key = %T, want the task menu", a.overlay) + } + + for _, r := range "pdesy" { + a, tv, _ := stripApp(t) + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled with strip focus", r) + } + switch r { + case 'p': + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("p: overlay = %T, want the priority menu", a.overlay) + } + case 'd', 'e', 's': + if _, ok := a.overlay.(*prompt); !ok { + t.Errorf("%c: overlay = %T, want a prompt", r, a.overlay) + } + } + requireFocus(t, tv, calFocusStrip, string(r)) + } + + for _, r := range "wc/" { + a, tv, root := stripApp(t) + calPress(a, tv, vim.R(r)) + got := calReadFile(t, root, "work.md") + if got == calStripNote { + t.Errorf("%c left the note unchanged", r) + } + want := strings.Split(calStripNote, "\n") + for i, l := range strings.Split(got, "\n") { + if i != 1 && (i >= len(want) || l != want[i]) { + t.Errorf("%c changed line %d (%q), which is not the selected task", r, i+1, l) + } + } + } + + for _, r := range "><" { + a, tv, root := stripApp(t) + calPress(a, tv, vim.R(r)) + got := calReadFile(t, root, "work.md") + if !strings.Contains(got, "bbb strip due:") || strings.Contains(got, "aaa strip due:") || strings.Contains(got, "ccc strip due:") { + t.Errorf("%c did not give the selected strip task a due date:\n%s", r, got) + } + } +} + +func TestStripSelectionMovesOnWhenTaskGetsDue(t *testing.T) { + a, tv, root := calVaultApp(t, map[string]string{"work.md": calStripNote}) + calPress(a, tv, vim.KeyTab, vim.R('j')) + requireSelected(t, tv, "bbb strip", "before") + calGiveDue(t, a, tv, "2026-11-18") + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "bbb strip due:2026-11-18") { + t.Fatalf("d did not set the due date:\n%s", got) + } + requireFocus(t, tv, calFocusStrip, "after the reload") + requireSelected(t, tv, "ccc strip", "after the second of three leaves") + if text := calScreenText(calScreen(a, tv, 140, 40)); !strings.Contains(text, "bbb strip") { + t.Errorf("the task is not on its day after the reload:\n%s", text) + } +} + +func TestStripLastTaskLeavingSelectsNewLast(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calStripNote}) + calPress(a, tv, vim.KeyTab, vim.R('j'), vim.R('j')) + requireSelected(t, tv, "ccc strip", "before") + calGiveDue(t, a, tv, "2026-11-18") + requireFocus(t, tv, calFocusStrip, "after the reload") + requireSelected(t, tv, "bbb strip", "after the last of three leaves") +} + +func TestStripEmptyingReturnsFocusToGrid(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": "- [ ] only strip\n"}) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "before") + requireSelected(t, tv, "only strip", "before") + calGiveDue(t, a, tv, "2026-11-18") + requireFocus(t, tv, calFocusGrid, "after the only strip task leaves") + if got := tv.selectedTask(); got != nil { + t.Errorf("selected = %+v on the grid, want none", got) + } + requireDay(t, tv, "2026-11-18", "after the reload") + if tv.calc().sub != calMonth { + t.Errorf("sub-mode = %v, want it unchanged", tv.calc().sub) + } +} + +func TestStripEscReturnsToGrid(t *testing.T) { + for _, vimOn := range []bool{true, false} { + for _, s := range calStripSubs { + a, tv := calTestApp(t, calManyUndated(2)) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "setup") + calPress(a, tv, vim.KeyEsc) + ctx := fmt.Sprintf("%s vim=%v", s.name, vimOn) + requireFocus(t, tv, calFocusGrid, ctx+" after Esc") + if tv.calc().sub != s.sub { + t.Errorf("%s: Esc changed the sub-mode to %v", ctx, tv.calc().sub) + } + requireDay(t, tv, "2026-11-18", ctx) + } + } +} From e234a94c7533bb902d2c39fc7ce1738932faaea3 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:44:26 -0400 Subject: [PATCH 11/65] Feat(tui): a terminal output wrapper that places and shapes the cursor zn draws its own cursor as a reverse-video cell because Bubble Tea v1 hides the terminal cursor and parks it at the bottom after every frame. A bar or underline cursor in insert and replace mode needs the real terminal cursor, so cursorOut sits between the program and the terminal: it appends the wanted position and shape (steady bar, CSI 6 SP q, or steady underline, CSI 4 SP q) to every frame, writes it on its own when only the cursor moved, since Bubble Tea skips unchanged frames, and resets the terminal to its default shape on request. A write that is a lone control sequence gets nothing appended, and while released (for suspend or an external program) the wrapper writes no cursor bytes at all. It keeps the method set of Bubble Tea's term.File so window-size and raw mode detection still work, and uses only charmbracelet/x/ansi, which is already a dependency. Nothing uses it yet; later steps wire it in. Tests, written red first: steady shapes, the reset sequence, frame and lone-sequence writes, set-on-change, release, concurrent writes under -race, and the forwarded file descriptor. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursorout.go | 134 +++++++++++++++++++++ internal/tui/cursorout_test.go | 211 +++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 internal/tui/cursorout.go create mode 100644 internal/tui/cursorout_test.go diff --git a/internal/tui/cursorout.go b/internal/tui/cursorout.go new file mode 100644 index 0000000..e444007 --- /dev/null +++ b/internal/tui/cursorout.go @@ -0,0 +1,134 @@ +package tui + +import ( + "io" + "sync" + + "github.com/charmbracelet/x/ansi" +) + +// cursorShape is the terminal cursor shape zn asks for. +type cursorShape int + +const ( + // cursorShapeBar is a steady vertical bar (DECSCUSR 6). + cursorShapeBar cursorShape = iota + // cursorShapeUnderline is a steady underline (DECSCUSR 4). + cursorShapeUnderline +) + +// cursorState is the cursor the terminal should show for one frame: hidden, +// or visible at a screen cell (zero-based) with a shape. +type cursorState struct { + visible bool + x, y int + shape cursorShape +} + +// cursorFile is the terminal handle the program reads from and writes to. It +// has the same method set as Bubble Tea's term.File, so window-size and +// raw-mode detection keep working through the wrapper. +type cursorFile interface { + io.ReadWriteCloser + Fd() uintptr +} + +// cursorOut wraps the terminal output. Bubble Tea parks the cursor at the +// bottom after each frame and skips frames that did not change, so the +// wrapper appends the wanted cursor to every frame and writes it on its own +// when only the cursor changed. One mutex serialises every write. +type cursorOut struct { + mu sync.Mutex + dst cursorFile + want cursorState + released bool +} + +func newCursorOut(dst cursorFile) *cursorOut { + return &cursorOut{dst: dst} +} + +// sequence returns the bytes that put the terminal's cursor into state c. +func (c cursorState) sequence() string { + if !c.visible { + return ansi.HideCursor + } + style := 6 + if c.shape == cursorShapeUnderline { + style = 4 + } + return ansi.CursorPosition(c.x+1, c.y+1) + ansi.SetCursorStyle(style) + ansi.ShowCursor +} + +// loneControlSequence reports whether p is exactly one CSI sequence, such as +// a lone cursor hide or show, rather than a rendered frame. +func loneControlSequence(p []byte) bool { + if len(p) < 3 || p[0] != 0x1b || p[1] != '[' { + return false + } + for i := 2; i < len(p); i++ { + if p[i] >= 0x40 && p[i] <= 0x7e { + return i == len(p)-1 + } + } + return false +} + +// Write forwards p and, when p is a frame, appends the wanted cursor in the +// same write so nothing can land between them. +func (w *cursorOut) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.released || loneControlSequence(p) { + return w.dst.Write(p) + } + out := make([]byte, 0, len(p)+32) + out = append(out, p...) + out = append(out, w.want.sequence()...) + n, err := w.dst.Write(out) + if n > len(p) { + n = len(p) + } + return n, err +} + +// Set records the wanted cursor and writes it at once if it changed. +func (w *cursorOut) Set(c cursorState) { + w.mu.Lock() + defer w.mu.Unlock() + if c == w.want { + return + } + w.want = c + if w.released { + return + } + _, _ = w.dst.Write([]byte(c.sequence())) +} + +// Reset puts the terminal back to its default cursor shape and shows it. +func (w *cursorOut) Reset() { + w.mu.Lock() + defer w.mu.Unlock() + _, _ = w.dst.Write([]byte(ansi.SetCursorStyle(0) + ansi.ShowCursor)) +} + +// Release stops the wrapper from adding cursor sequences, for suspend and +// external programs that own the terminal. +func (w *cursorOut) Release() { + w.mu.Lock() + defer w.mu.Unlock() + w.released = true +} + +// Hold resumes cursor control and writes the wanted cursor again. +func (w *cursorOut) Hold() { + w.mu.Lock() + defer w.mu.Unlock() + w.released = false + _, _ = w.dst.Write([]byte(w.want.sequence())) +} + +func (w *cursorOut) Read(p []byte) (int, error) { return w.dst.Read(p) } +func (w *cursorOut) Close() error { return w.dst.Close() } +func (w *cursorOut) Fd() uintptr { return w.dst.Fd() } diff --git a/internal/tui/cursorout_test.go b/internal/tui/cursorout_test.go new file mode 100644 index 0000000..1f120f4 --- /dev/null +++ b/internal/tui/cursorout_test.go @@ -0,0 +1,211 @@ +package tui + +import ( + "strings" + "sync" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// fakeTerm is an in-memory term.File that records every Write call. +type fakeTerm struct { + mu sync.Mutex + writes []string + fd uintptr +} + +func (f *fakeTerm) Write(p []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.writes = append(f.writes, string(p)) + return len(p), nil +} +func (f *fakeTerm) Read(p []byte) (int, error) { return 0, nil } +func (f *fakeTerm) Close() error { return nil } +func (f *fakeTerm) Fd() uintptr { return f.fd } + +func (f *fakeTerm) all() string { + f.mu.Lock() + defer f.mu.Unlock() + return strings.Join(f.writes, "") +} + +func (f *fakeTerm) calls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.writes...) +} + +func (f *fakeTerm) clear() { + f.mu.Lock() + defer f.mu.Unlock() + f.writes = nil +} + +const ( + steadyBar = "\x1b[6 q" + blinkingBar = "\x1b[5 q" + steadyUnderline = "\x1b[4 q" + blinkUnderline = "\x1b[3 q" + defaultShape = "\x1b[0 q" +) + +func TestCursorOutBarIsSteadyBar(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 3, y: 2, shape: cursorShapeBar}) + f.clear() + _, _ = w.Write([]byte("frame text")) + got := f.all() + if !strings.Contains(got, steadyBar) { + t.Fatalf("bar frame lacks CSI 6 SP q: %q", got) + } + if strings.Contains(got, blinkingBar) { + t.Fatalf("bar frame contains blinking CSI 5 SP q: %q", got) + } +} + +func TestCursorOutUnderlineIsSteadyUnderline(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 3, y: 2, shape: cursorShapeUnderline}) + f.clear() + _, _ = w.Write([]byte("frame text")) + got := f.all() + if !strings.Contains(got, steadyUnderline) { + t.Fatalf("underline frame lacks CSI 4 SP q: %q", got) + } + if strings.Contains(got, blinkUnderline) { + t.Fatalf("underline frame contains blinking CSI 3 SP q: %q", got) + } +} + +func TestCursorOutFrameWriteIsFollowedByPositionShapeShow(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 7, y: 4, shape: cursorShapeBar}) + f.clear() + _, _ = w.Write([]byte("frame text")) + want := "frame text" + ansi.CursorPosition(8, 5) + steadyBar + ansi.ShowCursor + if got := f.all(); got != want { + t.Fatalf("frame output = %q, want %q", got, want) + } +} + +func TestCursorOutHiddenAppendsHideCursor(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{}) + f.clear() + _, _ = w.Write([]byte("frame text")) + want := "frame text" + ansi.HideCursor + if got := f.all(); got != want { + t.Fatalf("frame output = %q, want %q", got, want) + } +} + +func TestCursorOutLoneControlSequenceGetsNothingAppended(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + f.clear() + _, _ = w.Write([]byte(ansi.HideCursor)) + if got := f.all(); got != ansi.HideCursor { + t.Fatalf("lone control sequence output = %q, want %q", got, ansi.HideCursor) + } +} + +func TestCursorOutSetChangedWritesAtOnce(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + f.clear() + w.Set(cursorState{visible: true, x: 5, y: 1, shape: cursorShapeBar}) + got := f.all() + if !strings.Contains(got, ansi.CursorPosition(6, 2)) || !strings.Contains(got, steadyBar) { + t.Fatalf("changed Set wrote %q, want position and shape", got) + } +} + +func TestCursorOutSetUnchangedWritesNothing(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + c := cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar} + w.Set(c) + f.clear() + w.Set(c) + if got := f.all(); got != "" { + t.Fatalf("unchanged Set wrote %q, want nothing", got) + } +} + +func TestCursorOutReleasedAppendsNothing(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + w.Release() + f.clear() + _, _ = w.Write([]byte("frame text")) + if got := f.all(); got != "frame text" { + t.Fatalf("released frame output = %q, want %q", got, "frame text") + } + w.Set(cursorState{visible: true, x: 9, y: 9, shape: cursorShapeBar}) + if got := f.all(); got != "frame text" { + t.Fatalf("released Set wrote %q", got) + } +} + +func TestCursorOutResetWritesDefaultShapeAndShowCursor(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + f.clear() + w.Reset() + want := defaultShape + ansi.ShowCursor + if got := f.all(); got != want { + t.Fatalf("Reset wrote %q, want %q", got, want) + } +} + +func TestCursorOutConcurrentWritesInterleaveOnlyAtSequenceBoundaries(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + _, _ = w.Write([]byte("frame text")) + } + }() + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + w.Set(cursorState{visible: true, x: i % 20, y: i % 5, shape: cursorShapeBar}) + } + }() + wg.Wait() + // Each recorded write is a whole unit: a frame followed by its cursor + // sequence, or a complete cursor sequence. Never two frames in one + // write, and a frame always starts a write. + for i, c := range f.calls() { + if strings.Count(c, "frame text") > 1 { + t.Fatalf("write %d holds two frames: %q", i, c) + } + if idx := strings.Index(c, "frame text"); idx > 0 { + t.Fatalf("write %d has a cursor sequence before the frame: %q", i, c) + } + if strings.HasSuffix(c, "\x1b") || strings.HasSuffix(c, "\x1b[") { + t.Fatalf("write %d ends inside an escape sequence: %q", i, c) + } + } +} + +func TestCursorOutFdIsForwarded(t *testing.T) { + f := &fakeTerm{fd: 42} + w := newCursorOut(f) + if got := w.Fd(); got != 42 { + t.Fatalf("Fd() = %d, want 42", got) + } +} From 041da143a91cfec77725a9128081afbf949fe079 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:47:02 -0400 Subject: [PATCH 12/65] Fix(tui): `[b` and `]b` switch tabs from every view and panel With Vim mode on, `[b` / `]b` (and `gt` / `gT`) switched tabs only while a note editor had focus, because the two-key sequences were matched only in the editor's key path. On Home, Tasks, Calendar, the database views and the side panels the first key went straight to the surface, so the sequence never completed. handleKey now matches the buffer and tab sequences before handing a key to any surface other than a note editor, honouring custom keymaps. When the second key does not complete a sequence, the held first key is replayed to the surface and the second follows, so `]` month navigation and `gg` keep working, one key later. Counts such as `3]b` stay editor-only. The held key lasts only until the next key: an overlay, chord, leader key, Ctrl+W prefix, mouse click, focus moving to a note editor or Vim mode turning off drops it without replaying it, and Esc cancels it. Tests, the cycling one red first: `]b` / `[b` from a view, a key that does not complete the sequence reaching the view, and a held key dropped by each of those paths. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 1 + internal/tui/input.go | 54 ++++++++++- internal/tui/mouse.go | 3 + internal/tui/tabsequence_test.go | 152 +++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 internal/tui/tabsequence_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..405f362 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -107,6 +107,7 @@ type App struct { overlay overlay leader *leaderState navKeys []vim.Key + tabSeq []vim.Key // first key of a buffer/tab sequence typed outside the editor paneKeys bool // tagMenu is the tag completion under the cursor; tagDismissed the token diff --git a/internal/tui/input.go b/internal/tui/input.go index b07d287..1d6f878 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -38,6 +38,8 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { } a.deferReads = true defer func() { a.deferReads = false }() + held := a.tabSeq + a.tabSeq = nil if a.message != "" && !(k.Is("esc") && a.overlay == nil) { a.message, a.messageErr = "", false } @@ -100,6 +102,15 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { return a.afterKey() } } + if a.tabSequenceKey(k, held) { + return a.afterKey() + } + a.surfaceKey(k) + return a.afterKey() +} + +// surfaceKey hands a key to the focused surface. +func (a *App) surfaceKey(k vim.Key) { switch a.focus { case focusSidebar: a.sidebar.handleKey(a, k) @@ -114,7 +125,48 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { default: a.paneKey(k) } - return a.afterKey() +} + +// tabSequenceKey runs the buffer and tab sequences (`[b`, `]b`, `gt`, `gT`) +// from every surface that is not a note editor; the editor handles them in +// editorKey. A started sequence that does not complete is replayed to the +// surface, so a lone `]` still reaches the view that binds it. +func (a *App) tabSequenceKey(k vim.Key, held []vim.Key) bool { + if !a.prefs.VimMode || len(a.navKeys) > 0 { + return false + } + if a.focus == focusPane { + if t := a.activeTab(); t != nil && t.view == nil && a.buffers[t.path] != nil && t.mode != modePreview { + return false + } + } + seqIDs := []string{"vim.bufferPrevious", "vim.bufferNext", "vim.tabPrevious", "vim.tabNext"} + if len(held) > 0 { + if k.Is("esc") { + return true + } + seq := append(append([]vim.Key{}, held...), k) + for _, id := range seqIDs { + if complete, _ := matchSequence(seq, bindingKeys(a.keymap.Binding(id))); complete { + a.runSequenceAction(id, 1, false) + return true + } + } + a.surfaceKey(seq[0]) + return false + } + for _, id := range seqIDs { + keys := bindingKeys(a.keymap.Binding(id)) + if len(keys) > 1 && sameKey(k, keys[0]) { + a.tabSeq = []vim.Key{k} + return true + } + if len(keys) == 1 && sameKey(k, keys[0]) { + a.runSequenceAction(id, 1, false) + return true + } + } + return false } // completionKey takes the keys insert-mode completion owns: the ones that diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 7fda616..72cdcb8 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -123,6 +123,9 @@ func (a *App) dividerAt(n *paneNode, x, y int) *paneNode { // handleMouse is the entry point for every mouse event. func (a *App) handleMouse(m tea.MouseMsg) { + if m.Action != tea.MouseActionMotion { + a.tabSeq = nil + } switch m.Action { case tea.MouseActionRelease: a.mouse.drag = "" diff --git a/internal/tui/tabsequence_test.go b/internal/tui/tabsequence_test.go new file mode 100644 index 0000000..7bd84cc --- /dev/null +++ b/internal/tui/tabsequence_test.go @@ -0,0 +1,152 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/keymaps" + "github.com/ZenNotes/tui/internal/vim" +) + +type probeView struct{ keys []vim.Key } + +func (v *probeView) title() string { return "probe" } +func (v *probeView) hint(a *App) string { return "" } +func (v *probeView) refresh(a *App) {} +func (v *probeView) render(a *App, w, h int, focused bool) string { return "" } +func (v *probeView) handleKey(a *App, k vim.Key) bool { + v.keys = append(v.keys, k) + return true +} + +func tabSequenceApp() (*App, *probeView) { + a := &App{keymap: keymaps.NewResolver(nil), theme: buildTheme(true), prefs: prefsView{}} + a.prefs.VimMode = true + pv := &probeView{} + p := &pane{tabs: []*tab{{path: "virtual:a", view: pv}, {path: "virtual:b", view: &probeView{}}}} + a.panes = &paneNode{leaf: p} + a.activePane = p + a.focus = focusPane + return a, pv +} + +func TestBufferSequencesCycleTabsFromAView(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]b") + if a.activePane.active != 1 { + t.Fatalf("]b from a view: active = %d", a.activePane.active) + } + typeKeys(a, "[b") + if a.activePane.active != 0 { + t.Fatalf("[b from a view: active = %d", a.activePane.active) + } + if len(pv.keys) != 0 { + t.Fatalf("a completed sequence leaked keys to the view: %v", pv.keys) + } +} + +func TestBracketNotFollowedByBReachesTheView(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]x") + if a.activePane.active != 0 || len(pv.keys) != 2 { + t.Fatalf("active=%d keys=%v", a.activePane.active, pv.keys) + } +} + +type closeOverlay struct{} + +func (closeOverlay) handleKey(a *App, k vim.Key) { a.overlay = nil } +func (closeOverlay) render(a *App, w, h int) string { return "" } + +func requireDropped(t *testing.T, a *App, pv *probeView) { + t.Helper() + if a.activePane.active != 0 { + t.Fatalf("a stale held key switched tabs: active = %d", a.activePane.active) + } + for _, k := range pv.keys { + if k.IsRune(']') { + t.Fatalf("a held key was replayed to the view: %v", pv.keys) + } + } +} + +func TestHeldKeyIsDroppedWhenAnOverlayTakesTheInput(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.overlay = closeOverlay{} + a.handleKey(vim.R('x')) + typeKeys(a, "b") + requireDropped(t, a, pv) + typeKeys(a, "]b") + if a.activePane.active != 1 { + t.Fatalf("a fresh sequence after the drop: active = %d", a.activePane.active) + } +} + +func TestHeldKeyIsDroppedByALeaderSequence(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleKey(vim.R(' ')) + a.leader = nil + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedByThePanePrefix(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleKey(vim.Ctrl('w')) + a.paneKeys = false + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedByAMouseClick(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleMouse(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedWhenFocusMovesToAnEditor(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.openNote("note.md", true) + note := a.activePane.active + pv := &probeView{} + a.activePane.tabs = append(a.activePane.tabs, &tab{path: "virtual:a", view: pv}, &tab{path: "virtual:b", view: &probeView{}}) + view := len(a.activePane.tabs) - 2 + a.activePane.active = view + a.focus = focusPane + typeKeys(a, "]") + a.activePane.active = note + a.handleKey(vim.KeyEsc) + a.activePane.active = view + typeKeys(a, "b") + if a.activePane.active != view || len(pv.keys) != 1 || !pv.keys[0].IsRune('b') { + t.Fatalf("active = %d keys = %v", a.activePane.active, pv.keys) + } +} + +func TestEscapeCancelsAHeldKeyWithoutReplay(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleKey(vim.KeyEsc) + if len(pv.keys) != 0 { + t.Fatalf("Esc replayed the held key: %v", pv.keys) + } + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedWhenVimModeTurnsOff(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.prefs.VimMode = false + a.handleKey(vim.R('x')) + a.prefs.VimMode = true + pv.keys = nil + typeKeys(a, "b") + requireDropped(t, a, pv) +} From 4bbbc0b88b2681e5790e736c039a516417ac153a Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 18:58:17 -0400 Subject: [PATCH 13/65] Feat(config): a terminal view-mode field and frontmatter rules table zn tui is gaining a way to open a note in edit, split or preview from its own frontmatter. This adds the configuration behind it, without changing what any note does yet. `[terminal] view_mode_field` names the frontmatter field that sets a note's view mode; it is empty by default, which keeps the feature off. `[terminal.view_mode_rules]` maps `"field=value"` to a mode, so a field such as `page-status = done` can choose preview. Both live under `[terminal]`, which the desktop app ignores, so a save from the desktop cannot drop them. Rule keys and values are trimmed, unquoted and lower-cased, and rules are ordered by their lower-cased `field=value`, which is the order the first match will be taken in. A rule with a bad mode, no `=` or an empty field name is kept in the file and reported with its reason but never applied, and an invalid rule never stops the config from loading. SetViewModeRule and RemoveViewModeRule rewrite only that table; removing works on the key exactly as written, so a malformed rule can still be deleted, and removing the last rule drops the table. Tests, written red first, cover the defaults, loading the sub-table, invalid rules and field values, normalising and ordering, and writing and removing rules while keeping the rest of the file. Co-Authored-By: Claude Opus 5.5 --- internal/config/prefs.go | 11 ++ internal/config/prefs_validate.go | 2 +- internal/config/prefs_values.go | 2 +- internal/config/prefs_write.go | 66 +++++++ internal/config/viewmode_prefs_test.go | 239 +++++++++++++++++++++++++ internal/config/viewmode_rules.go | 118 ++++++++++++ internal/config/viewmode_rules_test.go | 78 ++++++++ 7 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 internal/config/viewmode_prefs_test.go create mode 100644 internal/config/viewmode_rules.go create mode 100644 internal/config/viewmode_rules_test.go diff --git a/internal/config/prefs.go b/internal/config/prefs.go index cffb461..6dcb98e 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -80,6 +80,12 @@ type Prefs struct { // TerminalMath is typst or latex: the engine for display math. Anything // else loads as typst; zn doctor reports it. TerminalMath string + // TerminalViewModeField names the frontmatter field that picks a note's + // pane mode when it opens in a new tab. Empty turns the feature off. + TerminalViewModeField string + // ViewModeRules maps "=" to a pane mode, from + // [terminal.view_mode_rules]. Entries are raw; see ViewModeRuleEntries. + ViewModeRules map[string]string // RenderTimeout (seconds) bounds one external render; RenderConcurrency // caps how many run at once. Non-positive values load as the defaults. RenderTimeout int @@ -115,6 +121,7 @@ func DefaultPrefs() Prefs { PreviewStyle: "auto", TerminalMouse: true, TerminalMath: "typst", + ViewModeRules: map[string]string{}, RenderTimeout: 10, RenderConcurrency: 2, UnifiedSidebar: true, @@ -236,6 +243,10 @@ func applyPrefs(p *Prefs, doc map[string]any) { setString(terminal, "images", &p.TerminalImages) setString(terminal, "theme", &p.TerminalTheme) setString(terminal, "math", &p.TerminalMath) + setString(terminal, "view_mode_field", &p.TerminalViewModeField) + if rules, ok := terminal["view_mode_rules"].(map[string]any); ok { + p.ViewModeRules = mergeStringTable(map[string]string{}, rules) + } setInt(terminal, "render_timeout", &p.RenderTimeout) setInt(terminal, "render_concurrency", &p.RenderConcurrency) if strings.TrimSpace(p.PreviewStyle) == "" { diff --git a/internal/config/prefs_validate.go b/internal/config/prefs_validate.go index eaf7e6d..422466a 100644 --- a/internal/config/prefs_validate.go +++ b/internal/config/prefs_validate.go @@ -27,7 +27,7 @@ func ValidateConfig() error { // lenientKeys are checked only when strict (zn doctor): LoadPrefs falls back // to their defaults, so a bad value never stops zn from starting. var lenientKeys = map[string]map[string]bool{ - "terminal": {"math": true, "render_timeout": true, "render_concurrency": true}, + "terminal": {"math": true, "view_mode_field": true, "render_timeout": true, "render_concurrency": true}, } func validateKnownValues(doc map[string]any, strict bool) error { diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index a183052..0e7139a 100644 --- a/internal/config/prefs_values.go +++ b/internal/config/prefs_values.go @@ -13,7 +13,7 @@ func ValueTables(p Prefs) map[string]map[string]any { "editor": {"tab_size": p.EditorTabSize, "scroll_off": p.EditorScrollOff, "word_wrap": p.WordWrap, "default_view_mode": p.DefaultPaneMode, "line_number_mode": p.LineNumberMode, "completed_task_style": p.CompletedTaskStyle, "time_format": p.TimeFormat, "auto_pairs": p.AutoPairs, "auto_pair_quotes_in_prose": p.AutoPairQuotesInProse, "tabs_enabled": p.TabsEnabled, "wrap_tabs": p.WrapTabs, "keep_view_mode_across_notes": p.RetainViewMode, "markdown_snippets": p.MarkdownSnippets, "hide_builtin_templates": p.HideBuiltinTemplates, "text_replacements_enabled": p.TextReplacementsEnabled, "sync_title_heading_on_rename": p.SyncTitleHeadingOnRename}, "appearance": {"theme_family": p.ThemeFamily, "theme_mode": p.ThemeMode, "theme_id": p.ThemeID, "unified_sidebar": p.UnifiedSidebar}, "view": {"note_sort_order": p.NoteSortOrder, "group_by_kind": p.GroupByKind, "nested_tags": p.NestedTags, "quick_note_date_title": p.QuickNoteDateTitle, "quick_note_title_prefix": p.QuickNoteTitlePrefix, "calendar_week_start": p.CalendarWeekStart, "calendar_show_week_numbers": p.CalendarShowWeekNumbers, "tasks_view_mode": p.TasksViewMode, "show_archived_tasks": p.ShowArchivedTasks, "kanban_group_by": p.KanbanGroupBy, "kanban_folder_root": p.KanbanFolderRoot}, - "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme, "math": p.TerminalMath, "render_timeout": p.RenderTimeout, "render_concurrency": p.RenderConcurrency}, + "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme, "math": p.TerminalMath, "view_mode_field": p.TerminalViewModeField, "render_timeout": p.RenderTimeout, "render_concurrency": p.RenderConcurrency}, } } diff --git a/internal/config/prefs_write.go b/internal/config/prefs_write.go index 8793aec..37178b3 100644 --- a/internal/config/prefs_write.go +++ b/internal/config/prefs_write.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "github.com/BurntSushi/toml" @@ -33,6 +34,71 @@ func SetValue(section, key string, value any) error { }) } +// SetViewModeRule adds or changes one [terminal.view_mode_rules] entry, +// keeping every other key and table. Rules already in the file that are +// malformed never block the write. +func SetViewModeRule(key, mode string) error { + if err := ValidateViewModeRule(key, mode); err != nil { + return err + } + mode = strings.ToLower(strings.TrimSpace(mode)) + return UpdateConfig(func(doc map[string]any) error { + terminal, rules, err := viewModeTables(doc, true) + if err != nil { + return err + } + rules[key] = mode + terminal["view_mode_rules"] = rules + doc["terminal"] = terminal + return nil + }) +} + +// RemoveViewModeRule deletes the entry stored under the raw key exactly as +// written, so malformed keys can be removed too. Removing the last rule +// drops the empty table. +func RemoveViewModeRule(key string) error { + return UpdateConfig(func(doc map[string]any) error { + terminal, rules, err := viewModeTables(doc, false) + if err != nil || rules == nil { + return err + } + delete(rules, key) + if len(rules) == 0 { + delete(terminal, "view_mode_rules") + } + return nil + }) +} + +// viewModeTables returns [terminal] and its rules sub-table, creating them +// when create is set; otherwise missing tables come back nil. +func viewModeTables(doc map[string]any, create bool) (terminal, rules map[string]any, err error) { + raw, exists := doc["terminal"] + terminal, ok := raw.(map[string]any) + if exists && !ok { + return nil, nil, fmt.Errorf("terminal is not a config table") + } + if !exists { + if !create { + return nil, nil, nil + } + terminal = map[string]any{} + } + raw, exists = terminal["view_mode_rules"] + rules, ok = raw.(map[string]any) + if exists && !ok { + return nil, nil, fmt.Errorf("terminal.view_mode_rules is not a config table") + } + if !exists { + if !create { + return terminal, nil, nil + } + rules = map[string]any{} + } + return terminal, rules, nil +} + // UpdateConfig applies a targeted edit to the latest file. A parse failure or // a concurrent external edit leaves the original file untouched. func UpdateConfig(edit func(map[string]any) error) error { diff --git a/internal/config/viewmode_prefs_test.go b/internal/config/viewmode_prefs_test.go new file mode 100644 index 0000000..0ee7f95 --- /dev/null +++ b/internal/config/viewmode_prefs_test.go @@ -0,0 +1,239 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +func writeConfigFile(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func readConfigDoc(t *testing.T) map[string]any { + t.Helper() + var doc map[string]any + if _, err := toml.DecodeFile(ConfigTomlPath(), &doc); err != nil { + t.Fatal(err) + } + return doc +} + +func ruleTable(doc map[string]any) map[string]any { + terminal, _ := doc["terminal"].(map[string]any) + rules, _ := terminal["view_mode_rules"].(map[string]any) + return rules +} + +// A fresh config turns both the field and the rules off. +func TestViewModeDefaultsAreOff(t *testing.T) { + if p := DefaultPrefs(); p.TerminalViewModeField != "" || len(p.ViewModeRules) != 0 { + t.Fatalf("defaults: field %q rules %v", p.TerminalViewModeField, p.ViewModeRules) + } + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + p, _, err := LoadPrefs() + if err != nil || p.TerminalViewModeField != "" || len(p.ViewModeRules) != 0 { + t.Fatalf("no file: field %q rules %v err %v", p.TerminalViewModeField, p.ViewModeRules, err) + } + writeTerminalConfig(t, `math = "latex"`) + p, _, err = LoadPrefs() + if err != nil || p.TerminalViewModeField != "" || len(p.ViewModeRules) != 0 { + t.Fatalf("no keys: field %q rules %v err %v", p.TerminalViewModeField, p.ViewModeRules, err) + } + if strings.Contains(DefaultConfigTOML, "view_mode_field = \"view-mode\"") { + t.Fatal("the starter config must leave the field off") + } +} + +// [terminal] holds scalars plus the rules sub-table; both load. +func TestViewModeRulesLoadFromTerminalSubtable(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" +view_mode_field = "view-mode" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "split" +`) + p, _, err := LoadPrefs() + if err != nil { + t.Fatal(err) + } + if p.TerminalViewModeField != "view-mode" || p.TerminalMath != "latex" { + t.Fatalf("scalars: field %q math %q", p.TerminalViewModeField, p.TerminalMath) + } + want := map[string]string{"page-status=done": "preview", "kind=journal": "split"} + if !reflect.DeepEqual(p.ViewModeRules, want) { + t.Fatalf("rules: got %v want %v", p.ViewModeRules, want) + } +} + +// A hand-edited bad rule never stops the config from loading. +func TestInvalidViewModeRulesDoNotBlockLoad(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "reading" +"status" = "edit" +"=x" = "edit" +`) + p, _, err := LoadPrefs() + if err != nil { + t.Fatalf("LoadPrefs: %v", err) + } + if p.TerminalMath != "latex" || p.ViewModeRules["page-status=done"] != "preview" { + t.Fatalf("rest of the config lost: math %q rules %v", p.TerminalMath, p.ViewModeRules) + } + if err := ValidateConfig(); err != nil && strings.Contains(err.Error(), "view_mode_rules") { + t.Fatalf("a bad rule must not fail validation: %v", err) + } +} + +// A wrong-typed field leaves the field off and loading intact. +func TestBadViewModeFieldTypeDoesNotBlockLoad(t *testing.T) { + writeTerminalConfig(t, "view_mode_field = 3\nmath = \"latex\"") + p, _, err := LoadPrefs() + if err != nil { + t.Fatalf("LoadPrefs: %v", err) + } + if p.TerminalViewModeField != "" || p.TerminalMath != "latex" { + t.Fatalf("field %q math %q", p.TerminalViewModeField, p.TerminalMath) + } +} + +// Writing a rule keeps the rest of [terminal], the other tables and unknown data. +func TestSetViewModeRuleKeepsOtherTablesAndKeys(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" + +[editor] +word_wrap = false + +[unrecognized.nested] +list = [1, 2, 3] +`) + if err := SetViewModeRule("page-status=done", "preview"); err != nil { + t.Fatal(err) + } + if err := SetViewModeRule("kind=journal", "split"); err != nil { + t.Fatal(err) + } + doc := readConfigDoc(t) + rules := ruleTable(doc) + if rules["page-status=done"] != "preview" || rules["kind=journal"] != "split" || len(rules) != 2 { + t.Fatalf("rules: %v", rules) + } + if doc["terminal"].(map[string]any)["math"] != "latex" { + t.Fatalf("[terminal] math lost: %v", doc["terminal"]) + } + if doc["editor"].(map[string]any)["word_wrap"] != false || doc["unrecognized"] == nil { + t.Fatalf("other tables lost: %v", doc) + } + if err := SetViewModeRule("page-status=done", "edit"); err != nil { + t.Fatal(err) + } + if got := ruleTable(readConfigDoc(t))["page-status=done"]; got != "edit" { + t.Fatalf("changed rule: %v", got) + } + p, _, err := LoadPrefs() + if err != nil || p.ViewModeRules["page-status=done"] != "edit" { + t.Fatalf("reload: %v err %v", p.ViewModeRules, err) + } +} + +// Removing the last rule drops the empty table but keeps [terminal]. +func TestRemoveLastViewModeRuleDropsTable(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "split" +`) + if err := RemoveViewModeRule("page-status=done"); err != nil { + t.Fatal(err) + } + if rules := ruleTable(readConfigDoc(t)); len(rules) != 1 || rules["kind=journal"] != "split" { + t.Fatalf("after first removal: %v", rules) + } + if err := RemoveViewModeRule("kind=journal"); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(ConfigTomlPath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "view_mode_rules") { + t.Fatalf("empty rules table kept:\n%s", raw) + } + if readConfigDoc(t)["terminal"].(map[string]any)["math"] != "latex" { + t.Fatalf("[terminal] lost:\n%s", raw) + } +} + +// A malformed key is removed by its raw key exactly as written. +func TestRemoveMalformedViewModeRule(t *testing.T) { + writeConfigFile(t, `[terminal.view_mode_rules] +"status" = "edit" +"=x" = "edit" +"kind=journal" = "reading" +"page-status=done" = "preview" +`) + for _, key := range []string{"status", "=x", "kind=journal"} { + if err := RemoveViewModeRule(key); err != nil { + t.Fatalf("remove %q: %v", key, err) + } + } + rules := ruleTable(readConfigDoc(t)) + if len(rules) != 1 || rules["page-status=done"] != "preview" { + t.Fatalf("rules after removal: %v", rules) + } +} + +// A bad rule already in the file never blocks a later write. +func TestWritingRuleWithExistingInvalidRuleSucceeds(t *testing.T) { + writeConfigFile(t, `[terminal.view_mode_rules] +"status" = "edit" +"kind=journal" = "reading" +`) + if err := SetViewModeRule("page-status=done", "preview"); err != nil { + t.Fatalf("set with a bad rule present: %v", err) + } + rules := ruleTable(readConfigDoc(t)) + if rules["page-status=done"] != "preview" || rules["status"] != "edit" || rules["kind=journal"] != "reading" { + t.Fatalf("rules: %v", rules) + } + if err := SetValue("terminal", "math", "latex"); err != nil { + t.Fatalf("unrelated write with a bad rule present: %v", err) + } + if len(ruleTable(readConfigDoc(t))) != 3 { + t.Fatal("rules lost by an unrelated write") + } +} + +// The field is settable through the writer :settings and zn config use, and +// listed among the editable terminal settings. +func TestViewModeFieldRoundTrips(t *testing.T) { + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + if err := SetValue("terminal", "view_mode_field", "view-mode"); err != nil { + t.Fatal(err) + } + p, _, err := LoadPrefs() + if err != nil || p.TerminalViewModeField != "view-mode" { + t.Fatalf("field %q err %v", p.TerminalViewModeField, err) + } + if got := ValueTables(p)["terminal"]["view_mode_field"]; got != "view-mode" { + t.Fatalf("ValueTables: %v", got) + } +} diff --git a/internal/config/viewmode_rules.go b/internal/config/viewmode_rules.go new file mode 100644 index 0000000..5511ebc --- /dev/null +++ b/internal/config/viewmode_rules.go @@ -0,0 +1,118 @@ +package config + +import ( + "sort" + "strings" + + "github.com/ZenNotes/tui/internal/vault" +) + +// ViewModeRule is one valid [terminal.view_mode_rules] entry in normalised +// form: the frontmatter key and value are lower-cased and unquoted, and the +// mode is edit, split or preview. +type ViewModeRule struct { + Key string + Value string + Mode string +} + +// ViewModeRuleEntry is one entry as written in the file. Key and Mode are +// the raw strings, so a malformed entry can still be shown and removed. A +// valid entry also carries its normalised Rule. +type ViewModeRuleEntry struct { + Key string + Mode string + Valid bool + Reason string + Rule ViewModeRule +} + +// ViewModeModes are the pane modes a rule can name. +var ViewModeModes = []string{"edit", "split", "preview"} + +func normalizeViewModeMode(mode string) (string, bool) { + m := strings.ToLower(strings.TrimSpace(mode)) + for _, v := range ViewModeModes { + if m == v { + return m, true + } + } + return "", false +} + +func normalizeViewModePart(s string) string { + return strings.ToLower(strings.TrimSpace(vault.Unquote(s))) +} + +// ParseViewModeRule validates one "=" entry and its mode. The +// first "=" splits key from value. On failure it returns a short reason. +func ParseViewModeRule(key, mode string) (ViewModeRule, string) { + k, v, found := strings.Cut(key, "=") + if !found { + return ViewModeRule{}, `no "="` + } + k = normalizeViewModePart(k) + if k == "" { + return ViewModeRule{}, "empty key" + } + m, ok := normalizeViewModeMode(mode) + if !ok { + return ViewModeRule{}, "bad mode" + } + return ViewModeRule{Key: k, Value: normalizeViewModePart(v), Mode: m}, "" +} + +// ValidateViewModeRule is the check writers run before storing a rule: the +// key must hold "=", a non-empty field and a non-empty value, and the mode +// must be edit, split or preview. +func ValidateViewModeRule(key, mode string) error { + rule, reason := ParseViewModeRule(key, mode) + if reason == "" && rule.Value == "" { + reason = "empty value" + } + if reason != "" { + return &viewModeRuleError{reason} + } + return nil +} + +type viewModeRuleError struct{ reason string } + +func (e *viewModeRuleError) Error() string { return "invalid view-mode rule: " + e.reason } + +// ViewModeRuleEntries returns every entry, valid or not. Valid ones come +// first in match order, then the invalid ones sorted by raw key. +func ViewModeRuleEntries(raw map[string]string) []ViewModeRuleEntry { + var valid, invalid []ViewModeRuleEntry + for k, m := range raw { + rule, reason := ParseViewModeRule(k, m) + e := ViewModeRuleEntry{Key: k, Mode: m, Valid: reason == "", Reason: reason, Rule: rule} + if e.Valid { + valid = append(valid, e) + } else { + invalid = append(invalid, e) + } + } + sort.Slice(valid, func(i, j int) bool { + a, b := valid[i], valid[j] + ak, bk := a.Rule.Key+"="+a.Rule.Value, b.Rule.Key+"="+b.Rule.Value + if ak != bk { + return ak < bk + } + return a.Key < b.Key + }) + sort.Slice(invalid, func(i, j int) bool { return invalid[i].Key < invalid[j].Key }) + return append(valid, invalid...) +} + +// ValidViewModeRules returns the usable rules in match order: sorted by the +// lower-cased "key=value" in byte order. Invalid entries are left out. +func ValidViewModeRules(raw map[string]string) []ViewModeRule { + var out []ViewModeRule + for _, e := range ViewModeRuleEntries(raw) { + if e.Valid { + out = append(out, e.Rule) + } + } + return out +} diff --git a/internal/config/viewmode_rules_test.go b/internal/config/viewmode_rules_test.go new file mode 100644 index 0000000..0339681 --- /dev/null +++ b/internal/config/viewmode_rules_test.go @@ -0,0 +1,78 @@ +package config + +import ( + "reflect" + "testing" +) + +// ViewModeRule is a normalised valid rule, ValidViewModeRules gives the valid +// ones in match order, and ViewModeRuleEntries gives every entry with its raw +// key and mode, a Valid flag and a Reason for the invalid ones. + +func TestViewModeRulesParseNormaliseAndSort(t *testing.T) { + raw := map[string]string{ + "page-status=done": "preview", + "Kind=Journal": "split", + ` "Tags" = "Draft" `: "Edit", + "a=b": " PREVIEW ", + "page-status=in-done": "edit", + } + want := []ViewModeRule{ + {Key: "a", Value: "b", Mode: "preview"}, + {Key: "kind", Value: "journal", Mode: "split"}, + {Key: "page-status", Value: "done", Mode: "preview"}, + {Key: "page-status", Value: "in-done", Mode: "edit"}, + {Key: "tags", Value: "draft", Mode: "edit"}, + } + if got := ValidViewModeRules(raw); !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v\nwant %+v", got, want) + } +} + +func TestInvalidViewModeRulesAreIgnoredButReported(t *testing.T) { + raw := map[string]string{ + "page-status=done": "preview", + "kind=journal": "reading", + "status": "edit", + "=x": "edit", + } + valid := ValidViewModeRules(raw) + if len(valid) != 1 || valid[0].Key != "page-status" { + t.Fatalf("valid rules: %+v", valid) + } + entries := ViewModeRuleEntries(raw) + if len(entries) != len(raw) { + t.Fatalf("entries: %+v", entries) + } + reasons := map[string]string{} + for _, e := range entries { + if e.Valid != (e.Key == "page-status=done") { + t.Fatalf("entry %+v: wrong validity", e) + } + if !e.Valid { + if e.Reason == "" { + t.Fatalf("entry %+v: no reason", e) + } + reasons[e.Key] = e.Reason + } + } + if len(reasons) != 3 || reasons["kind=journal"] == reasons["status"] || reasons["status"] == reasons["=x"] { + t.Fatalf("reasons should tell the three failures apart: %v", reasons) + } +} + +// An empty value part is a rule like any other when read from the file. +func TestViewModeRuleWithEmptyValueIsValid(t *testing.T) { + valid := ValidViewModeRules(map[string]string{"status=": "edit"}) + if len(valid) != 1 || valid[0].Key != "status" || valid[0].Value != "" { + t.Fatalf("got %+v", valid) + } +} + +// The first "=" splits key from value. +func TestViewModeRuleSplitsOnFirstEquals(t *testing.T) { + valid := ValidViewModeRules(map[string]string{"a=b=c": "edit"}) + if len(valid) != 1 || valid[0].Key != "a" || valid[0].Value != "b=c" { + t.Fatalf("got %+v", valid) + } +} From cb78af24fff87805310afdff0a3cf82a6f3f4b13 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:01:46 -0400 Subject: [PATCH 14/65] Feat(tui): work out a note's view mode from its frontmatter frontmatterPaneMode reads a note's frontmatter and says which pane mode it asks for, for the coming change that opens notes in that mode. Nothing calls it yet. A valid value in the configured field (`view-mode: preview`) wins. The field name and its value compare without regard to case; a list, an empty value or any word other than edit, split or preview gives no mode, and the rules are tried instead. A rule matches when its field holds its value, or any element of a list field does, compared case-insensitively after unquoting; rules are tried in the order they arrive in, so the first match wins. A note without frontmatter, or a vault with no field and no rules, gets no mode. Tests cover the field, every way it can fall through, rule matching with case, quotes and lists, rule order, and a field that beats a matching rule. Co-Authored-By: Claude Opus 5.5 --- internal/tui/viewmode_frontmatter.go | 54 ++++++++++++++++++++++ internal/tui/viewmode_frontmatter_test.go | 56 +++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 internal/tui/viewmode_frontmatter.go create mode 100644 internal/tui/viewmode_frontmatter_test.go diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go new file mode 100644 index 0000000..e64b2bd --- /dev/null +++ b/internal/tui/viewmode_frontmatter.go @@ -0,0 +1,54 @@ +package tui + +import ( + "sort" + "strings" + + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vault" +) + +// frontmatterPaneMode picks the pane mode a note's frontmatter asks for. +// A valid value in the named field wins. Otherwise the first rule, in the +// order given, that matches a frontmatter key and value wins. A list field +// matches a rule when any element does. It reports false when nothing +// applies, including when the note has no frontmatter. +func frontmatterPaneMode(text, field string, rules []config.ViewModeRule) (paneMode, bool) { + field = strings.ToLower(strings.TrimSpace(field)) + if field == "" && len(rules) == 0 { + return "", false + } + block, _, ok := vault.Frontmatter(text) + if !ok { + return "", false + } + if field != "" { + scalars, _ := vault.ParseFrontmatterScalars(text) + keys := make([]string, 0, len(scalars)) + for k := range scalars { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if strings.ToLower(k) != field { + continue + } + switch v := strings.ToLower(strings.TrimSpace(scalars[k])); v { + case string(modeEdit), string(modeSplit), string(modePreview): + return paneMode(v), true + } + } + } + if len(rules) == 0 { + return "", false + } + fields := vault.ParseFrontmatterFields(block) + for _, r := range rules { + for _, el := range fields[r.Key] { + if strings.ToLower(strings.TrimSpace(vault.Unquote(el))) == r.Value { + return paneMode(r.Mode), true + } + } + } + return "", false +} diff --git a/internal/tui/viewmode_frontmatter_test.go b/internal/tui/viewmode_frontmatter_test.go new file mode 100644 index 0000000..e7e054b --- /dev/null +++ b/internal/tui/viewmode_frontmatter_test.go @@ -0,0 +1,56 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +func TestFrontmatterPaneMode(t *testing.T) { + rules := func(raw map[string]string) []config.ViewModeRule { return config.ValidViewModeRules(raw) } + fm := func(body string) string { return "---\n" + body + "\n---\n\nbody\n" } + done := rules(map[string]string{"page-status=done": "preview"}) + + tests := []struct { + name string + text string + field string + rules []config.ViewModeRule + want paneMode + ok bool + }{ + {"no frontmatter", "just text\n", "view-mode", done, "", false}, + {"unrelated keys", fm("title: x\ntags: [a]"), "view-mode", done, "", false}, + {"field edit", fm("view-mode: edit"), "view-mode", nil, modeEdit, true}, + {"field split", fm("view-mode: split"), "view-mode", nil, modeSplit, true}, + {"field preview", fm("view-mode: preview"), "view-mode", nil, modePreview, true}, + {"key case", fm("View-Mode: preview"), "view-mode", nil, modePreview, true}, + {"value case", fm("view-mode: Preview"), "view-mode", nil, modePreview, true}, + {"value spaces and quotes", fm(`view-mode: " preview "`), "view-mode", nil, modePreview, true}, + {"unknown word", fm("view-mode: reading"), "view-mode", nil, "", false}, + {"unknown word falls to rule", fm("view-mode: reading\npage-status: done"), "view-mode", done, modePreview, true}, + {"empty value", fm("view-mode:"), "view-mode", nil, "", false}, + {"inline list", fm("view-mode: [preview]"), "view-mode", nil, "", false}, + {"block list", fm("view-mode:\n - preview"), "view-mode", nil, "", false}, + {"list field falls to rule", fm("view-mode: [preview]\npage-status: done"), "view-mode", done, modePreview, true}, + {"empty field with rule", fm("view-mode: edit\npage-status: done"), "", done, modePreview, true}, + {"rule hit", fm("page-status: done"), "", done, modePreview, true}, + {"rule miss", fm("page-status: draft"), "", done, "", false}, + {"two rules one key", fm("page-status: draft"), "", rules(map[string]string{"page-status=done": "preview", "page-status=draft": "split"}), modeSplit, true}, + {"rule case and quotes", fm(`Page-Status: "DONE"`), "", rules(map[string]string{`"Page-Status"="Done"`: "preview"}), modePreview, true}, + {"rule normalised", fm(`Page-Status: "DONE"`), "", rules(map[string]string{` Page-Status = "Done" `: "Preview"}), modePreview, true}, + {"inline list element", fm("status: [draft, published]"), "", rules(map[string]string{"status=published": "preview"}), modePreview, true}, + {"block list element", fm("status:\n - draft\n - published"), "", rules(map[string]string{"status=published": "split"}), modeSplit, true}, + {"first sorted rule wins", fm("kind: journal\npage-status: done"), "", rules(map[string]string{"page-status=done": "preview", "kind=journal": "edit"}), modeEdit, true}, + {"invalid rule ignored", fm("page-status: done\nkind: journal"), "", rules(map[string]string{"kind=journal": "reading", "no-equals": "edit", "page-status=done": "split"}), modeSplit, true}, + {"field beats rule", fm("view-mode: edit\npage-status: done"), "view-mode", done, modeEdit, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := frontmatterPaneMode(tc.text, tc.field, tc.rules) + if got != tc.want || ok != tc.ok { + t.Fatalf("got (%q, %v), want (%q, %v)", got, ok, tc.want, tc.ok) + } + }) + } +} From c1de97e5443448e5af024009c490f26b82e2b063 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:15:13 -0400 Subject: [PATCH 15/65] Feat(tui): a note opens in the view mode its frontmatter asks for With `[terminal] view_mode_field` set or a valid rule in `[terminal.view_mode_rules]`, a note opened in a new tab now takes the pane mode its frontmatter asks for, both from openNote and from quiet opens such as the jump list and pickers. A note's body is often read in the background after its tab appears, so the tab opens in the mode it would have had anyway and is marked as waiting; once the text is loaded the frontmatter sets the mode, and the tab visibly switches on a slow read rather than holding up the open. A manual mode switch before the load clears the mark, so the user's choice is never overridden. The frontmatter mode beats the remembered per-note mode and the keep-across mode, but only writes the tab: neither of those is changed by it, so one finished note does not make every later note open in preview. A tab that is already open keeps its mode when the note is saved with new frontmatter; the new value applies the next time the note is opened. With the field empty and no valid rules, which is the default, no tab is marked and opening a note behaves exactly as before. Tests cover the default config, the field and a rule on open, both beating the remembered and keep-across modes, a deferred read, a manual switch before and after the load, saving and reopening, quiet opens, and that applying a mode leaves the remembered modes alone. Co-Authored-By: Claude Opus 5.5 --- internal/tui/input.go | 1 + internal/tui/noteops.go | 4 +- internal/tui/panes.go | 5 + internal/tui/viewmode_frontmatter.go | 48 +++++ internal/tui/viewmode_open_test.go | 252 +++++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 internal/tui/viewmode_open_test.go diff --git a/internal/tui/input.go b/internal/tui/input.go index b07d287..e92ae41 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -597,6 +597,7 @@ func (a *App) setPaneMode(mode paneMode) { return } t.mode = mode + t.frontmatterPending = false if a.prefs.RetainViewMode { a.prefs.DefaultPaneMode = string(mode) } diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index b013b50..4ba375e 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -918,8 +918,10 @@ func (a *App) openNoteQuiet(path string) { if mode == "" { mode = modeEdit } - pane.tabs = append(pane.tabs, &tab{path: path, mode: mode}) + t := &tab{path: path, mode: mode} + pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 + a.watchFrontmatterMode(t) a.touchRecent(path) a.markSessionDirty() } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index fbf071a..b321d0a 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -43,6 +43,10 @@ type tab struct { preview *previewState view view pinned bool + // frontmatterPending is set while a freshly opened tab still waits for + // its note to load, so the frontmatter can pick the pane mode. A manual + // mode switch clears it. + frontmatterPending bool } // pane is a leaf of the split tree holding tabs. @@ -239,6 +243,7 @@ func (a *App) openNote(path string, focus bool) { t := &tab{path: path, mode: mode} pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 + a.watchFrontmatterMode(t) } if cur := a.activeBuffer(); cur != nil && cur.path != path { a.pushJump(cur.path, cur.ed.Cursor()) diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go index e64b2bd..5502b41 100644 --- a/internal/tui/viewmode_frontmatter.go +++ b/internal/tui/viewmode_frontmatter.go @@ -52,3 +52,51 @@ func frontmatterPaneMode(text, field string, rules []config.ViewModeRule) (paneM } return "", false } + +// frontmatterModeOn reports whether the configuration asks notes to pick +// their pane mode from frontmatter. +func (a *App) frontmatterModeOn() bool { + return strings.TrimSpace(a.prefs.TerminalViewModeField) != "" || + len(config.ValidViewModeRules(a.prefs.ViewModeRules)) > 0 +} + +// watchFrontmatterMode marks a newly opened note tab so that its frontmatter +// sets the pane mode once the note text is available. It does nothing when +// the feature is off, so the default open path is unchanged. +func (a *App) watchFrontmatterMode(t *tab) { + if !a.frontmatterModeOn() || isVirtualPath(t.path) { + return + } + t.frontmatterPending = true + a.withLoadedBuffer(t.path, a.applyFrontmatterMode) +} + +// applyFrontmatterMode sets the pane mode of every still-pending tab on the +// loaded note. It writes the tab only: the remembered per-note modes and the +// keep-across default stay as the user left them. +func (a *App) applyFrontmatterMode(buf *noteBuffer) { + var pending []*tab + for _, p := range a.panes.leaves() { + for _, t := range p.tabs { + if t.path == buf.path && t.frontmatterPending { + t.frontmatterPending = false + pending = append(pending, t) + } + } + } + if len(pending) == 0 { + return + } + mode, ok := frontmatterPaneMode(buf.ed.Text(), a.prefs.TerminalViewModeField, config.ValidViewModeRules(a.prefs.ViewModeRules)) + if !ok { + return + } + for _, t := range pending { + t.mode = mode + if mode != modeEdit && t.preview == nil { + t.preview = &previewState{} + } + } + a.layout() + a.markSessionDirty() +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go new file mode 100644 index 0000000..aa4ac37 --- /dev/null +++ b/internal/tui/viewmode_open_test.go @@ -0,0 +1,252 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" +) + +const fmPreviewNote = "---\nview-mode: preview\npage-status: done\n---\n# Body\n" + +func writeVaultNote(t *testing.T, root, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// loadDeferred finishes a deferred read of an open note. +func loadDeferred(t *testing.T, a *App, path string) { + t.Helper() + buf := a.buffers[path] + if buf == nil { + t.Fatalf("no buffer for %s", path) + } + a.applyBufferRead(a.readBufferCmd(buf)().(bufferReadMsg)) +} + +func modeOf(t *testing.T, a *App, path string) paneMode { + t.Helper() + for _, p := range a.panes.leaves() { + if i := p.findTab(path); i >= 0 { + return p.tabs[i].mode + } + } + t.Fatalf("no tab for %s", path) + return "" +} + +func enableViewModeField(a *App) { a.prefs.TerminalViewModeField = "view-mode" } + +func TestDefaultConfigIgnoresViewModeFrontmatter(t *testing.T) { + for _, tc := range []struct { + name string + setup func(a *App) + want paneMode + }{ + {"default edit", func(a *App) { a.prefs.DefaultPaneMode = "edit" }, modeEdit}, + {"default split", func(a *App) { a.prefs.DefaultPaneMode = "split" }, modeSplit}, + {"remembered", func(a *App) { a.noteModes["n.md"] = modeSplit }, modeSplit}, + {"keep across", func(a *App) { a.prefs.RetainViewMode = true; a.prefs.DefaultPaneMode = "split" }, modeSplit}, + } { + t.Run(tc.name, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + tc.setup(a) + a.deferReads = true + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != tc.want { + t.Fatalf("before load: %q, want %q", got, tc.want) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != tc.want { + t.Fatalf("after load: %q, want %q", got, tc.want) + } + }) + } +} + +func TestFieldModeAppliesOnOpen(t *testing.T) { + for _, m := range []paneMode{modeEdit, modeSplit, modePreview} { + t.Run(string(m), func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: "+string(m)+"\n---\nbody\n") + a.prefs.DefaultPaneMode = "split" + if m == modeSplit { + a.prefs.DefaultPaneMode = "edit" + } + enableViewModeField(a) + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != m { + t.Fatalf("mode %q, want %q", got, m) + } + if m != modeEdit && a.activeTab().preview == nil { + t.Fatal("preview state not allocated") + } + }) + } +} + +func TestFrontmatterModeBeatsRememberedMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.noteModes["n.md"] = modeEdit + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q", got) + } +} + +func TestFrontmatterModeBeatsKeepAcrossMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.prefs.RetainViewMode = true + a.prefs.DefaultPaneMode = "edit" + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q", got) + } +} + +func TestRuleModeAppliesOnOpen(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + a.prefs.ViewModeRules = map[string]string{"page-status=done": "split"} + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q", got) + } +} + +func TestDeferredReadSwitchesModeAfterLoad(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("before load: %q", got) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("after load: %q", got) + } + if a.activeTab().preview == nil { + t.Fatal("preview state not allocated") + } + + t.Run("closed before load", func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + msg := a.readBufferCmd(a.buffers["n.md"])().(bufferReadMsg) + // A loading note cannot be closed through closeTab, so drop the tab + // and its buffer the way a closed pane would. + a.activePane.tabs = nil + a.closeBufferIfUnused("n.md") + a.applyBufferRead(msg) + for _, p := range a.panes.leaves() { + if len(p.tabs) != 0 { + t.Fatalf("stray tab: %d", len(p.tabs)) + } + } + }) +} + +func TestManualSwitchBeforeLoadWins(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q", got) + } +} + +func TestSavingNewFrontmatterKeepsOpenTabMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "# plain\n") + writeVaultNote(t, root, "other.md", "other\n") + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.openNote("n.md", true) + buf := a.buffers["n.md"] + buf.ed.SetText("---\npage-status: done\n---\n# plain\n") + if err := a.saveBuffer(buf); err != nil { + t.Fatal(err) + } + a.openNote("other.md", true) + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("mode %q", got) + } +} + +func TestReopenAfterCloseUsesNewFrontmatter(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "# plain\n") + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("first open %q", got) + } + a.closeTab(a.activePane, 0) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\n# plain\n") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("reopen %q", got) + } +} + +func TestQuietOpenAppliesFrontmatterMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNoteQuiet("n.md") + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("before load %q", got) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("after load %q", got) + } +} + +func TestManualSwitchAfterApplyIsRemembered(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.prefs.RetainViewMode = true + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + if a.noteModes["n.md"] != modeSplit || a.prefs.DefaultPaneMode != "split" { + t.Fatalf("noteModes %q, default %q", a.noteModes["n.md"], a.prefs.DefaultPaneMode) + } +} + +func TestAppliedModeLeavesKeepAcrossAndRememberedAlone(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.prefs.RetainViewMode = true + a.prefs.DefaultPaneMode = "edit" + a.deferReads = true + a.openNote("n.md", true) + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q", got) + } + if a.prefs.DefaultPaneMode != "edit" { + t.Fatalf("keep-across default changed to %q", a.prefs.DefaultPaneMode) + } + if _, ok := a.noteModes["n.md"]; ok { + t.Fatal("applied mode was remembered") + } +} From 1cb85fba5444b799b2232e985ba45cb036f02594 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:24:53 -0400 Subject: [PATCH 16/65] Feat(tui): split copies, restored tabs and references respect view modes A tab opened in the mode its frontmatter asks for has three neighbours that must not lose or override that choice. Splitting a tab before its note has loaded now gives the copy the same waiting state, so both tabs take the frontmatter mode once the text is in. A copy of a tab whose mode was switched by hand keeps that mode, as before. A tab restored from the last session keeps the mode it was saved with; the frontmatter does not change it. A note pinned as a reference with `:ref` always stays in preview, whatever its frontmatter asks for. Tests cover a restored tab, splits before and after the load, a split of a manually switched tab, and a reference whose frontmatter asks for edit or split. Co-Authored-By: Claude Opus 5.5 --- internal/tui/commands_parity.go | 1 + internal/tui/panes.go | 2 +- internal/tui/session.go | 2 + internal/tui/viewmode_open_test.go | 104 +++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index f041189..bd7b751 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -722,6 +722,7 @@ func (a *App) openReference(path string) { return } t.mode = modePreview + t.frontmatterPending = false if !t.pinned { a.togglePin(p, p.active) } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index b321d0a..41405fb 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -113,7 +113,7 @@ func (n *paneNode) split(target *pane, vertical bool) *pane { if n.leaf == target { newPane := &pane{} if t := target.activeTab(); t != nil { - copyTab := &tab{path: t.path, mode: t.mode, view: t.view} + copyTab := &tab{path: t.path, mode: t.mode, view: t.view, frontmatterPending: t.frontmatterPending} if t.preview != nil { pv := *t.preview copyTab.preview = &pv diff --git a/internal/tui/session.go b/internal/tui/session.go index e069930..6ebabb6 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -261,6 +261,8 @@ func (a *App) restoreSessionPane(s *sessionPane, depth int) *paneNode { tab.preview = &previewState{} } } + // The saved mode stands; the note's frontmatter must not override it. + tab.frontmatterPending = false } if idx := n.leaf.findTab(selected); idx >= 0 { n.leaf.active = idx diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index aa4ac37..30a2ba5 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -250,3 +250,107 @@ func TestAppliedModeLeavesKeepAcrossAndRememberedAlone(t *testing.T) { t.Fatal("applied mode was remembered") } } + +func tabsOf(a *App, path string) []*tab { + var out []*tab + for _, p := range a.panes.leaves() { + for _, t := range p.tabs { + if t.path == path { + out = append(out, t) + } + } + } + return out +} + +func modesOf(ts []*tab) []paneMode { + var out []paneMode + for _, t := range ts { + out = append(out, t.mode) + } + return out +} + +func TestRestoredSessionTabKeepsSavedMode(t *testing.T) { + root := newSessionParityVault(t, "n.md") + writeVaultNote(t, root, "n.md", fmPreviewNote) + a := newSessionParityApp(t, root) + a.openNote("n.md", true) + a.activeTab().mode = modeEdit + a.saveSession() + + restored := newSessionParityApp(t, root) + enableViewModeField(restored) + restored.deferReads = true + restored.restoreSession() + if got := modeOf(t, restored, "n.md"); got != modeEdit { + t.Fatalf("before load: %q, want edit", got) + } + loadDeferred(t, restored, "n.md") + if got := modeOf(t, restored, "n.md"); got != modeEdit { + t.Fatalf("after load: %q, want edit", got) + } +} + +func TestSplitCopyFollowsFrontmatterMode(t *testing.T) { + for _, preLoad := range []bool{false, true} { + name := "after load" + if preLoad { + name = "before load" + } + t.Run(name, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + if preLoad { + a.splitPane(true) + loadDeferred(t, a, "n.md") + } else { + loadDeferred(t, a, "n.md") + a.splitPane(true) + } + ts := tabsOf(a, "n.md") + if len(ts) != 2 || ts[0].mode != modePreview || ts[1].mode != modePreview { + t.Fatalf("tabs %d, modes %v", len(ts), modesOf(ts)) + } + }) + } +} + +func TestSplitCopyKeepsManualMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + a.setPaneMode(modeEdit) + a.splitPane(true) + loadDeferred(t, a, "n.md") + ts := tabsOf(a, "n.md") + if len(ts) != 2 || ts[0].mode != modeEdit || ts[1].mode != modeEdit { + t.Fatalf("tabs %d, modes %v", len(ts), modesOf(ts)) + } +} + +func TestReferencePaneStaysPreview(t *testing.T) { + for _, fm := range []string{"edit", "split"} { + t.Run(fm, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: "+fm+"\n---\nbody\n") + writeVaultNote(t, root, "other.md", "# other\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.deferReads = true + a.openNote("other.md", true) + loadDeferred(t, a, "other.md") + a.openReference("n.md") + loadDeferred(t, a, "n.md") + ts := tabsOf(a, "n.md") + if len(ts) != 1 || ts[0].mode != modePreview { + t.Fatalf("modes %v, want preview", modesOf(ts)) + } + }) + } +} From 5271c09400e1721647a9ee7fa7ac4695dd7527db Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:01:11 -0400 Subject: [PATCH 17/65] Feat(tui): a newly created note opens in the editor, whatever its frontmatter With a view-mode field or rules configured, a note created from a template that says `view-mode: preview`, or whose first fields match a preview rule, would otherwise open straight into preview. A note that has just been made is about to be written, so it should never start there. Every action that creates a note and opens it now marks the note first: a new note, a note from a template, a periodic note created on first open, a new task file, and today's daily note when a rollover creates it. The open that follows uses the mark up, starts in the editor and does not wait for the frontmatter. Notes created without being opened, such as a daily note made by adding a task to it or a saved capture, carry no mark; the first time they are opened they follow their frontmatter like any other note. With the feature off, a new note opens in the mode it does today. Tests cover each of the five creation paths, an existing periodic note, notes created without opening, the feature off, and reopening a new note after closing it. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 2 + internal/tui/commands_parity.go | 6 + internal/tui/noteops.go | 13 +- internal/tui/panes.go | 8 +- internal/tui/viewmode_frontmatter.go | 16 ++ internal/tui/viewmode_open_test.go | 215 +++++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 2 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..c7303f5 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -87,6 +87,7 @@ type App struct { buffers map[string]*noteBuffer registers vim.RegisterStore noteModes map[string]paneMode + newNotes map[string]bool panes *paneNode activePane *pane focus focusTarget @@ -216,6 +217,7 @@ func newApp(ctx context.Context, opts Options, prefs config.Prefs, systemDark bo mouseEnabled: prefs.TerminalMouse, buffers: map[string]*noteBuffer{}, noteModes: map[string]paneMode{}, + newNotes: map[string]bool{}, panes: newPaneTree(), sidebarOpen: true, sidebarWidth: 32, diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index bd7b751..7898ae7 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -529,6 +529,7 @@ func (a *App) rolloverNow() error { } today := time.Now() _, _, title, rel := a.periodicLocation(periodic.Daily, today) + created := false if _, ok := a.noteMeta(rel); !ok { body := a.periodicBody(periodic.Daily, title, today) meta, err := a.backend.CreateNote(a.ctx, vault.FolderInbox, title, "", &body) @@ -538,11 +539,15 @@ func (a *App) rolloverNow() error { a.ignoreChange(meta.Path) a.addNoteToIndex(meta) rel = meta.Path + created = true } n := a.rolloverInto(rel, today) if n == 0 { a.notify("Nothing to roll over") } + if created { + a.markNewNote(rel) + } a.openNote(rel, true) return nil } @@ -906,6 +911,7 @@ func (a *App) newTaskFile(target string) error { } a.ignoreChange(meta.Path) a.addNoteToIndex(meta) + a.markNewNote(meta.Path) a.openNote(meta.Path, true) a.afterTaskChange() }) diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index 4ba375e..0ab3847 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -25,6 +25,9 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.notifyError(err.Error()) return } + if open { + a.markNewNote(meta.Path) + } a.ignoreChange(meta.Path) a.addNoteToIndex(meta) if open { @@ -602,6 +605,7 @@ func (a *App) openPeriodicOn(kind periodic.Kind, date time.Time) { if kind == periodic.Daily { a.rolloverUnfinishedTasks(meta.Path, date) } + a.markNewNote(meta.Path) a.openNote(meta.Path, true) a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) }) a.refreshIndex() @@ -675,6 +679,7 @@ func (a *App) createFromTemplate(t templates.Template) { } a.ignoreChange(meta.Path) a.addNoteToIndex(meta) + a.markNewNote(meta.Path) a.openNote(meta.Path, true) if rendered.CursorOffset >= 0 { a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.SetCursor(posForOffset(buf.ed.Text(), rendered.CursorOffset)) }) @@ -906,6 +911,7 @@ func (a *App) openTextSearch(initial string) { func (a *App) openNoteQuiet(path string) { a.forgetEmbedTimeouts() pane := a.activePane + justCreated := a.consumeNewNote(path) if idx := pane.findTab(path); idx >= 0 { pane.active = idx return @@ -918,10 +924,15 @@ func (a *App) openNoteQuiet(path string) { if mode == "" { mode = modeEdit } + if justCreated && a.frontmatterModeOn() { + mode = modeEdit + } t := &tab{path: path, mode: mode} pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 - a.watchFrontmatterMode(t) + if !justCreated { + a.watchFrontmatterMode(t) + } a.touchRecent(path) a.markSessionDirty() } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index 41405fb..f5b45fa 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -226,6 +226,7 @@ func (a *App) openNote(path string, focus bool) { if pane == nil { return } + justCreated := a.consumeNewNote(path) if idx := pane.findTab(path); idx >= 0 { pane.active = idx } else { @@ -240,10 +241,15 @@ func (a *App) openNote(path string, focus bool) { if remembered, ok := a.noteModes[path]; ok && !a.prefs.KeepViewModeAcrossNotes() { mode = remembered } + if justCreated && a.frontmatterModeOn() { + mode = modeEdit + } t := &tab{path: path, mode: mode} pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 - a.watchFrontmatterMode(t) + if !justCreated { + a.watchFrontmatterMode(t) + } } if cur := a.activeBuffer(); cur != nil && cur.path != path { a.pushJump(cur.path, cur.ed.Cursor()) diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go index 5502b41..2867333 100644 --- a/internal/tui/viewmode_frontmatter.go +++ b/internal/tui/viewmode_frontmatter.go @@ -100,3 +100,19 @@ func (a *App) applyFrontmatterMode(buf *noteBuffer) { a.layout() a.markSessionDirty() } + +// markNewNote records that path was just created and is about to be opened, +// so the open starts in the editor instead of taking a mode from frontmatter. +func (a *App) markNewNote(path string) { + a.newNotes[path] = true +} + +// consumeNewNote reports whether path carried a new-note mark and clears it, +// so a later open of the same note is an ordinary one. +func (a *App) consumeNewNote(path string) bool { + if !a.newNotes[path] { + return false + } + delete(a.newNotes, path) + return true +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 30a2ba5..5275d69 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -1,9 +1,16 @@ package tui import ( + "context" "os" "path/filepath" + "strings" "testing" + "time" + + "github.com/ZenNotes/tui/internal/periodic" + "github.com/ZenNotes/tui/internal/templates" + "github.com/ZenNotes/tui/internal/vault" ) const fmPreviewNote = "---\nview-mode: preview\npage-status: done\n---\n# Body\n" @@ -354,3 +361,211 @@ func TestReferencePaneStaysPreview(t *testing.T) { }) } } + +const ( + fmPreviewBody = "---\nview-mode: preview\n---\n# New\n" + fmRuleBody = "---\npage-status: done\n---\n# New\n" +) + +// previewEverywhere makes every frontmatter and default route point at preview +// so only the new-note handling can keep a note in edit. +func previewEverywhere(a *App, defaultMode string) { + enableViewModeField(a) + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.prefs.DefaultPaneMode = defaultMode + a.deferReads = true +} + +func activeNotePath(t *testing.T, a *App) string { + t.Helper() + tb := a.activePane.activeTab() + if tb == nil { + t.Fatal("no active tab") + } + return tb.path +} + +// wantEditBeforeAndAfterLoad checks the active note's mode across the load. +func wantEditBeforeAndAfterLoad(t *testing.T, a *App) string { + t.Helper() + p := activeNotePath(t, a) + if got := modeOf(t, a, p); got != modeEdit { + t.Fatalf("before load: %q, want edit", got) + } + loadDeferred(t, a, p) + if got := modeOf(t, a, p); got != modeEdit { + t.Fatalf("after load: %q, want edit", got) + } + if len(a.newNotes) != 0 { + t.Fatalf("new-note mark left behind: %v", a.newNotes) + } + return p +} + +func TestNewNoteFromTemplateOpensInEdit(t *testing.T) { + for _, body := range []string{fmPreviewBody, fmRuleBody} { + for _, def := range []string{"preview", "split"} { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, def) + tpl := templates.Template{ID: "custom:x", Name: "X", Body: body} + a.createFromTemplate(tpl) + typeText(a, "From Template") + a.overlay.handleKey(a, enterKey()) + wantEditBeforeAndAfterLoad(t, a) + } + } +} + +func TestNewBlankNoteOpensInEditWithFeatureOn(t *testing.T) { + for _, def := range []string{"preview", "split"} { + for _, body := range []string{fmPreviewBody, fmRuleBody} { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, def) + b := body + a.createNote(vault.FolderInbox, "Blank", "", &b, true) + wantEditBeforeAndAfterLoad(t, a) + } + } +} + +func useDailyTemplate(t *testing.T, a *App, body string) { + t.Helper() + raw := templates.ComposeFile(templates.ComposeInput{Name: "Daily FM", Body: body}) + if _, err := a.backend.WriteTemplate(context.Background(), vault.WriteTemplateInput{Slug: "dailyfm", Raw: raw}); err != nil { + t.Fatal(err) + } + a.idx.settings.DailyNotes.Enabled = true + a.idx.settings.DailyNotes.TemplateID = "custom:dailyfm" +} + +func TestNewPeriodicNoteOpensInEdit(t *testing.T) { + for _, body := range []string{fmPreviewBody, fmRuleBody} { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, body) + previewEverywhere(a, "preview") + a.openPeriodicOn(periodic.Daily, time.Now()) + wantEditBeforeAndAfterLoad(t, a) + } +} + +func TestExistingPeriodicNoteFollowsFrontmatter(t *testing.T) { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, fmPreviewBody) + previewEverywhere(a, "edit") + a.openPeriodicOn(periodic.Daily, time.Now()) + p := activeNotePath(t, a) + loadDeferred(t, a, p) + a.closeTab(a.activePane, a.activePane.findTab(p)) + a.openPeriodicOn(periodic.Daily, time.Now()) + if got := modeOf(t, a, p); got != modeEdit { + t.Fatalf("before load: %q", got) + } + loadDeferred(t, a, p) + if got := modeOf(t, a, p); got != modePreview { + t.Fatalf("existing daily note: %q, want preview", got) + } +} + +func TestNewTaskFileOpensInEdit(t *testing.T) { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, "preview") + a.prefs.ViewModeRules = map[string]string{"status=open": "preview"} + if err := a.newTaskFile(""); err != nil { + t.Fatal(err) + } + typeText(a, "Write it") + a.overlay.handleKey(a, enterKey()) + wantEditBeforeAndAfterLoad(t, a) +} + +func TestRolloverCreatedDailyNoteOpensInEdit(t *testing.T) { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, fmPreviewBody) + previewEverywhere(a, "preview") + if err := a.rolloverNow(); err != nil { + t.Fatal(err) + } + wantEditBeforeAndAfterLoad(t, a) +} + +func TestNotesCreatedWithoutOpeningLeaveNoMark(t *testing.T) { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, fmRuleBody) + previewEverywhere(a, "edit") + before := map[string]bool{} + for _, n := range a.idx.notes { + before[n.Path] = true + } + + a.addTaskToDaily("one", time.Now()) + a.saveCapture("Captured\nbody") + if len(a.newNotes) != 0 { + t.Fatalf("mark set: %v", a.newNotes) + } + var created []string + for _, n := range a.idx.notes { + if !before[n.Path] { + created = append(created, n.Path) + } + } + if len(created) == 0 { + t.Fatal("no notes were created") + } + // A note created without opening follows its frontmatter on a later open. + daily := "" + for _, p := range created { + if strings.Contains(p, time.Now().Format("2006-01-02")) { + daily = p + } + } + if daily == "" { + t.Fatalf("daily note not found in %v", created) + } + a.openNote(daily, true) + loadDeferred(t, a, daily) + if got := modeOf(t, a, daily); got != modePreview { + t.Fatalf("later open: %q, want preview", got) + } + + // Without daily notes the task goes to an Inbox "Tasks" note. + a.idx.settings.DailyNotes.Enabled = false + a.addTaskToDaily("two", time.Now()) + if len(a.newNotes) != 0 { + t.Fatalf("mark set: %v", a.newNotes) + } +} + +// With the feature off, a new note's mode is whatever note creation already +// gives it; its frontmatter plays no part, and nothing is left waiting. +func TestNewNoteWithFeatureOffIgnoresFrontmatter(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.DefaultPaneMode = "split" + b := fmPreviewBody + a.createNote(vault.FolderInbox, "Plain", "", &b, true) + p := activeNotePath(t, a) + if got := modeOf(t, a, p); got == modePreview { + t.Fatalf("mode %q: frontmatter applied with the feature off", got) + } + for _, tb := range tabsOf(a, p) { + if tb.frontmatterPending { + t.Fatal("tab left waiting for frontmatter with the feature off") + } + } + if len(a.newNotes) != 0 { + t.Fatalf("mark left: %v", a.newNotes) + } +} + +func TestReopenedNewNoteFollowsFrontmatter(t *testing.T) { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, "edit") + b := fmPreviewBody + a.createNote(vault.FolderInbox, "Again", "", &b, true) + p := wantEditBeforeAndAfterLoad(t, a) + a.closeTab(a.activePane, a.activePane.findTab(p)) + a.openNote(p, true) + loadDeferred(t, a, p) + if got := modeOf(t, a, p); got != modePreview { + t.Fatalf("reopened: %q, want preview", got) + } +} From 71bd1867902e59981b4c292df353cece03219cde Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:03:53 -0400 Subject: [PATCH 18/65] Feat(cli): zn config lists, reads and sets terminal view-mode rules The view-mode rules live in `[terminal.view_mode_rules]`, a table inside `[terminal]` that `zn config` could not reach: it splits a key at the first dot and only knew the four top-level map tables. `zn config list` now shows every rule as `terminal.view_mode_rules.= = `, invalid ones too, so a hand-edited mistake is visible; `list --json` carries the same entries. `zn config get` reads a rule by that name, and `zn config set` adds a new rule or changes an existing one. Set checks the rule before writing: a bad mode, a key without `=`, an empty field or an empty value exits with an error and leaves config.toml as it was. The mode is stored lower-cased. `terminal.view_mode_field` reads and sets like any other terminal key. Removing a rule stays with the settings view and `zn config edit`, since `zn config` has no unset. Tests cover listing, reading, adding and changing a rule, every kind of bad input, and setting the field. Co-Authored-By: Claude Opus 5.5 --- internal/cli/commands_config.go | 22 +++++- internal/cli/config_viewmode_test.go | 110 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 internal/cli/config_viewmode_test.go diff --git a/internal/cli/commands_config.go b/internal/cli/commands_config.go index 190eb37..14533b1 100644 --- a/internal/cli/commands_config.go +++ b/internal/cli/commands_config.go @@ -61,6 +61,10 @@ func cmdConfig(ctx context.Context, args Args) error { return nil } +// viewModeRulePrefix names a frontmatter view-mode rule under terminal; the +// rest of the name is the rule's "=" as written in config.toml. +const viewModeRulePrefix = "view_mode_rules." + func preferenceValues(p config.Prefs) map[string]map[string]any { values := config.ValueTables(p) for section, entries := range map[string]map[string]string{"keymaps": p.KeymapOverrides, "saved_filters": p.SavedTaskFilters, "kanban_column_titles": p.KanbanColumnTitles, "tweaks": p.ThemeTweaks} { @@ -69,6 +73,9 @@ func preferenceValues(p config.Prefs) map[string]map[string]any { values[section][key] = value } } + for _, e := range config.ViewModeRuleEntries(p.ViewModeRules) { + values["terminal"][viewModeRulePrefix+e.Key] = e.Mode + } return values } @@ -109,7 +116,8 @@ func cmdConfigAction(ctx context.Context, args Args, action string) error { } value, exists := values[section][name] if !exists { - if action != "set" || (section != "keymaps" && section != "saved_filters" && section != "kanban_column_titles" && section != "tweaks") { + isRule := section == "terminal" && strings.HasPrefix(name, viewModeRulePrefix) + if action != "set" || (!isRule && section != "keymaps" && section != "saved_filters" && section != "kanban_column_titles" && section != "tweaks") { return fmt.Errorf("unknown preference %q; `zn config list` shows supported keys", key) } value = "" @@ -119,6 +127,18 @@ func cmdConfigAction(ctx context.Context, args Args, action string) error { return errors.New("usage: zn config set ") } raw := args.Positional(1) + if name, isRule := strings.CutPrefix(name, viewModeRulePrefix); isRule && section == "terminal" { + if err := config.SetViewModeRule(name, raw); err != nil { + return fmt.Errorf("invalid value for %s: %w", key, err) + } + mode := strings.ToLower(strings.TrimSpace(raw)) + if args.Bool("json") { + emitJSON(map[string]any{"ok": true, "key": key, "value": mode}) + } else { + emitOK(fmt.Sprintf("%s = %v", key, mode)) + } + return nil + } switch value.(type) { case bool: value, err = strconv.ParseBool(raw) diff --git a/internal/cli/config_viewmode_test.go b/internal/cli/config_viewmode_test.go new file mode 100644 index 0000000..86246ca --- /dev/null +++ b/internal/cli/config_viewmode_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "os" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +func runConfig(t *testing.T, args ...string) (string, int) { + t.Helper() + var code int + out := captureOutput(t, func() { code = Main(append([]string{"config"}, args...)) }) + return out, code +} + +func TestConfigListShowsViewModeRules(t *testing.T) { + isolatedCLI(t) + if _, code := runConfig(t, "set", "terminal.view_mode_rules.page-status=done", "preview"); code != 0 { + t.Fatalf("set exit %d", code) + } + out, code := runConfig(t, "list") + if code != 0 || !strings.Contains(out, "terminal.view_mode_rules.page-status=done = preview\n") { + t.Fatalf("list (exit %d): %s", code, out) + } + out, _ = runConfig(t, "list", "--json") + if !strings.Contains(out, `"view_mode_rules.page-status=done": "preview"`) { + t.Fatalf("list --json: %s", out) + } +} + +func TestConfigGetViewModeRule(t *testing.T) { + isolatedCLI(t) + runConfig(t, "set", "terminal.view_mode_rules.kind=journal", "split") + out, code := runConfig(t, "get", "terminal.view_mode_rules.kind=journal") + if code != 0 || strings.TrimSpace(out) != "split" { + t.Fatalf("get (exit %d): %q", code, out) + } + if _, code := runConfig(t, "get", "terminal.view_mode_rules.kind=other"); code == 0 { + t.Fatal("get of an unset rule succeeded") + } +} + +func TestConfigSetViewModeRuleAddsAndChanges(t *testing.T) { + isolatedCLI(t) + path := config.ConfigTomlPath() + if err := os.WriteFile(path, []byte("[terminal]\nmath = \"latex\"\n\n[editor]\nword_wrap = false\n\n[custom]\nkeep = \"me\"\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"preview", "Edit"} { + if o, code := runConfig(t, "set", "terminal.view_mode_rules.page-status=done", mode); code != 0 { + t.Fatalf("set %s: exit %d: %s", mode, code, o) + } + } + body, _ := os.ReadFile(path) + for _, want := range []string{`math = "latex"`, "[editor]", "word_wrap = false", "[custom]", `keep = "me"`, `"page-status=done" = "edit"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("missing %q in:\n%s", want, body) + } + } + if strings.Contains(string(body), "preview") { + t.Fatalf("old mode left behind:\n%s", body) + } + prefs, _, err := config.LoadPrefs() + if err != nil || len(prefs.ViewModeRules) != 1 { + t.Fatalf("reload: %+v %v", prefs.ViewModeRules, err) + } +} + +func TestConfigSetViewModeRuleRejectsBadMode(t *testing.T) { + isolatedCLI(t) + path := config.ConfigTomlPath() + original := "[terminal]\nmath = \"latex\"\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"set", "terminal.view_mode_rules.page-status=done", "reading"}, + {"set", "terminal.view_mode_rules.page-status", "preview"}, + {"set", "terminal.view_mode_rules.=done", "preview"}, + {"set", "terminal.view_mode_rules.status=", "preview"}, + } { + if _, code := runConfig(t, args...); code == 0 { + t.Fatalf("%v succeeded", args) + } + } + body, _ := os.ReadFile(path) + if string(body) != original { + t.Fatalf("config changed:\n%s", body) + } +} + +func TestConfigSetViewModeField(t *testing.T) { + isolatedCLI(t) + if _, code := runConfig(t, "set", "terminal.view_mode_field", "view-mode"); code != 0 { + t.Fatalf("exit %d", code) + } + prefs, _, err := config.LoadPrefs() + if err != nil || prefs.TerminalViewModeField != "view-mode" { + t.Fatalf("field: %q %v", prefs.TerminalViewModeField, err) + } + if _, code := runConfig(t, "set", "terminal.view_mode_field", ""); code != 0 { + t.Fatalf("clear exit %d", code) + } + prefs, _, err = config.LoadPrefs() + if err != nil || prefs.TerminalViewModeField != "" { + t.Fatalf("cleared field: %q %v", prefs.TerminalViewModeField, err) + } +} From 7428c7d723b88c70f0ca7f036eaefd09653058af Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:51:21 -0400 Subject: [PATCH 19/65] Feat(tui): view-mode settings and rules on the settings page `:settings` now shows and edits the frontmatter view-mode configuration, so it never needs config.toml opened by hand. `terminal.view_mode_field` appears with the other terminal settings and is edited like them; while it is empty its prompt suggests `view-mode` as an example rather than filling it in. Right after the terminal rows come the rules, as `terminal.view_mode_rules.= = `: the valid ones in the order they are matched, then the invalid ones, each ending in its reason, such as `(invalid: no "=")`. A rule shows its key as written in the file. Last comes an "(add rule)" row, which stays visible whatever the filter. Enter on the add row asks for the field, then the value, then offers edit, split and preview; nothing is written until a mode is picked, Esc at any step writes nothing, and an empty field, a field holding "=", an empty value or a rule that already exists in another case is refused. Enter on a rule offers the three modes and remove; `d` on a rule row also removes it, and the footer names `d` while one is selected. A rule with a bad mode can be fixed by picking a mode; one whose key is malformed offers only remove. Rules are changed and removed by their key as written, so hand-typed keys are found. After every change the preferences are reloaded and the rows refreshed. settingRow keeps its four fields: a rule row's key is the rule's key, and whether a row is a setting, a rule, an invalid rule or the add row is worked out from the row itself. Tests cover editing the field, rule order, invalid rules marked and ignored when a note opens, filtering, adding with every kind of bad input and cancel, changing, removing with the picker and with `d`, the footer hint, fixing a bad mode, and malformed rules. Co-Authored-By: Claude Opus 5.5 --- internal/tui/settings_controls.go | 170 +++++++- internal/tui/settings_viewmode_test.go | 547 +++++++++++++++++++++++++ internal/tui/views_hints.go | 6 +- 3 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 internal/tui/settings_viewmode_test.go diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index 45f6646..79126ef 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -15,6 +15,38 @@ type settingRow struct { value any vault bool } + +type ruleRowKind int + +const ( + rowSetting ruleRowKind = iota + rowRule + rowInvalidRule + rowAddRule +) + +const viewModeRulesSection = "terminal.view_mode_rules" + +// addRuleValue is the value of the row that adds a rule. Its own type keeps +// that row apart from every rule, whatever key a rule is written under. +type addRuleValue struct{} + +func (addRuleValue) String() string { return "Enter to add a rule" } + +// kind tells plain settings from the view-mode rule rows. A rule row's key +// is the rule's key exactly as written in config.toml. +func (r settingRow) kind() ruleRowKind { + switch { + case r.section != viewModeRulesSection: + return rowSetting + case r.value == addRuleValue{}: + return rowAddRule + case r.reason() != "": + return rowInvalidRule + } + return rowRule +} + type settingsView struct { scroll, rows int lines []string @@ -34,7 +66,10 @@ func (v *settingsView) refresh(a *App) { scope = "vault" } line := scope + " · " + s.section + "." + s.key + " = " + fmt.Sprint(s.value) - if strings.Contains(strings.ToLower(line), strings.ToLower(v.filter)) { + if s.kind() == rowInvalidRule { + line += " (invalid: " + s.reason() + ")" + } + if s.kind() == rowAddRule || strings.Contains(strings.ToLower(line), strings.ToLower(v.filter)) { v.settings = append(v.settings, s) v.lines = append(v.lines, line) } @@ -52,6 +87,9 @@ func (v *settingsView) refresh(a *App) { } add(settingRow{section, key, tables[section][key], false}) } + if section == "terminal" { + v.addRuleRows(a, add) + } } if a.idx != nil { for _, p := range []struct { @@ -72,6 +110,115 @@ func (v *settingsView) refresh(a *App) { } v.list.clamp(len(v.settings)) } + +// reason is the short explanation for an invalid rule row. +func (r settingRow) reason() string { + _, reason := config.ParseViewModeRule(r.key, fmt.Sprint(r.value)) + return reason +} + +// addRuleRows lists the view-mode rules after the terminal rows: the valid +// ones in match order, then the invalid ones, then the row that adds a rule. +// The add row stays under every filter. +func (v *settingsView) addRuleRows(a *App, add func(settingRow)) { + for _, e := range config.ViewModeRuleEntries(a.prefs.ViewModeRules) { + add(settingRow{viewModeRulesSection, e.Key, e.Mode, false}) + } + add(settingRow{viewModeRulesSection, "(add rule)", addRuleValue{}, false}) +} + +// addRule asks for the frontmatter field, then the value, then the mode, and +// writes the rule only after the mode is picked. Esc at any step writes +// nothing. +func (v *settingsView) addRule(a *App) { + a.promptFor("View-mode rule: frontmatter field", "", "For example page-status · Esc cancels", func(a *App, field string) { + field = strings.TrimSpace(field) + if field == "" { + a.notifyError("Enter a frontmatter field") + return + } + if strings.Contains(field, "=") { + a.notifyError("The field cannot contain \"=\"") + return + } + a.promptFor("View-mode rule: value of "+field, "", "For example done · a list field matches any element · Esc cancels", func(a *App, value string) { + value = strings.TrimSpace(value) + if value == "" { + a.notifyError("Enter a value to match") + return + } + key := field + "=" + value + rule, _ := config.ParseViewModeRule(key, "edit") + for _, e := range config.ViewModeRuleEntries(a.prefs.ViewModeRules) { + if e.Valid && e.Rule.Key == rule.Key && e.Rule.Value == rule.Value { + a.notifyError("A rule for " + rule.Key + "=" + rule.Value + " already exists") + return + } + } + items := []paletteItem{} + for _, m := range config.ViewModeModes { + items = append(items, paletteItem{label: m, id: m}) + } + a.overlay = &palette{title: "Pane mode for " + key, items: items, filtered: items, onSelect: func(a *App, it paletteItem) { + if err := config.SetViewModeRule(key, it.id); err != nil { + a.notifyError(err.Error()) + return + } + if err := a.reloadPreferences(); err != nil { + a.notifyError(err.Error()) + } + v.refresh(a) + }} + }) + }) +} + +// selectedRuleRow is the rule row under the cursor, valid or invalid. +func (v *settingsView) selectedRuleRow() (settingRow, bool) { + if v.list.cursor < 0 || v.list.cursor >= len(v.settings) { + return settingRow{}, false + } + row := v.settings[v.list.cursor] + return row, row.kind() == rowRule || row.kind() == rowInvalidRule +} + +// applyRuleChange reloads preferences after a rule was written or removed and +// refreshes the rows. +func (v *settingsView) applyRuleChange(a *App, err error) { + if err != nil { + a.notifyError(err.Error()) + return + } + if err := a.reloadPreferences(); err != nil { + a.notifyError(err.Error()) + } + v.refresh(a) +} + +// removeRule deletes a rule by its key exactly as written in config.toml. +func (v *settingsView) removeRule(a *App, row settingRow) { + v.applyRuleChange(a, config.RemoveViewModeRule(row.key)) +} + +// editRule opens the mode palette for a rule row. A rule whose key is +// malformed cannot take a mode, so it only offers remove. +func (v *settingsView) editRule(a *App, row settingRow) { + items := []paletteItem{} + if _, reason := config.ParseViewModeRule(row.key, "edit"); reason == "" { + for _, m := range config.ViewModeModes { + items = append(items, paletteItem{label: m, id: m}) + } + } + items = append(items, paletteItem{label: "remove", id: "remove"}) + a.overlay = &palette{title: "Pane mode for " + row.key, items: items, filtered: items, onSelect: func(a *App, it paletteItem) { + if it.id == "remove" { + v.removeRule(a, row) + return + } + v.applyRuleChange(a, config.SetViewModeRule(row.key, it.id)) + }} +} + func (v *settingsView) render(a *App, w, h int, focused bool) string { if v.lines == nil { v.refresh(a) @@ -94,12 +241,25 @@ func (v *settingsView) handleKey(a *App, k vim.Key) bool { _ = a.showVersion(vim.ExCommand{Name: "version"}) return true } + if k.IsRune('d') { + if row, ok := v.selectedRuleRow(); ok { + v.removeRule(a, row) + } + return true + } if a.listNav(&v.list, k, len(v.settings), v.rows) { return true } if k.Is("enter") { if v.list.cursor < len(v.settings) { - v.edit(a, v.settings[v.list.cursor]) + switch row := v.settings[v.list.cursor]; row.kind() { + case rowSetting: + v.edit(a, row) + case rowAddRule: + v.addRule(a) + case rowRule, rowInvalidRule: + v.editRule(a, row) + } } return true } @@ -175,7 +335,11 @@ func (v *settingsView) edit(a *App, row settingRow) { a.overlay = &palette{title: row.section + "." + row.key, items: items, filtered: items, onSelect: func(a *App, it paletteItem) { save(it.id) }} return } - a.promptFor(row.section+"."+row.key, fmt.Sprint(row.value), "Saved immediately · Esc cancels", func(a *App, text string) { + help := "Saved immediately · Esc cancels" + if row.section == "terminal" && row.key == "view_mode_field" && strings.TrimSpace(fmt.Sprint(row.value)) == "" { + help = "Frontmatter field that sets the pane mode, for example view-mode · " + help + } + a.promptFor(row.section+"."+row.key, fmt.Sprint(row.value), help, func(a *App, text string) { if _, ok := row.value.(int); ok { n, err := strconv.Atoi(strings.TrimSpace(text)) if err != nil || n < 0 || n > 10000 { diff --git a/internal/tui/settings_viewmode_test.go b/internal/tui/settings_viewmode_test.go new file mode 100644 index 0000000..56562e9 --- /dev/null +++ b/internal/tui/settings_viewmode_test.go @@ -0,0 +1,547 @@ +package tui + +import ( + "os" + "slices" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vim" +) + +func settingsLineIndex(t *testing.T, v *settingsView, line string) int { + t.Helper() + i := slices.Index(v.lines, line) + if i < 0 { + t.Fatalf("no settings row %q in:\n%s", line, strings.Join(v.lines, "\n")) + } + return i +} + +func writeRules(t *testing.T, a *App, rules map[string]string) { + t.Helper() + for k, m := range rules { + if err := config.UpdateConfig(func(doc map[string]any) error { + terminal, _ := doc["terminal"].(map[string]any) + if terminal == nil { + terminal = map[string]any{} + doc["terminal"] = terminal + } + table, _ := terminal["view_mode_rules"].(map[string]any) + if table == nil { + table = map[string]any{} + terminal["view_mode_rules"] = table + } + table[k] = m + return nil + }); err != nil { + t.Fatal(err) + } + } + if err := a.reloadPreferences(); err != nil { + t.Fatal(err) + } +} + +func TestSettingsEditsViewModeField(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + v := &settingsView{} + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, "user · terminal.view_mode_field = ") + if !v.handleKey(a, vim.Key{Name: "enter"}) { + t.Fatal("enter was not handled") + } + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay = %T, want a prompt", a.overlay) + } + if !strings.Contains(p.help, "view-mode") { + t.Fatalf("prompt help %q does not suggest view-mode", p.help) + } + p.onSubmit(a, "view-mode") + data, err := os.ReadFile(config.ConfigTomlPath()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `view_mode_field = "view-mode"`) { + t.Fatalf("config.toml:\n%s", data) + } + if a.prefs.TerminalViewModeField != "view-mode" { + t.Fatalf("reloaded field = %q", a.prefs.TerminalViewModeField) + } + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("note opened in %q, want preview", got) + } + + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, "user · terminal.view_mode_field = view-mode") + v.handleKey(a, vim.Key{Name: "enter"}) + a.overlay.(*prompt).onSubmit(a, "") + if a.prefs.TerminalViewModeField != "" { + t.Fatalf("field after clearing = %q", a.prefs.TerminalViewModeField) + } + data, _ = os.ReadFile(config.ConfigTomlPath()) + if !strings.Contains(string(data), `view_mode_field = ""`) { + t.Fatalf("config.toml after clearing:\n%s", data) + } +} + +func TestSettingsListsViewModeRulesInOrder(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "Kind=Journal": "edit", "other=x": "split"}) + v := &settingsView{} + v.refresh(a) + var got []string + for _, l := range v.lines { + if strings.Contains(l, "terminal.view_mode_rules.") { + got = append(got, l) + } + } + want := []string{ + "user · terminal.view_mode_rules.Kind=Journal = edit", + "user · terminal.view_mode_rules.other=x = split", + "user · terminal.view_mode_rules.page-status=done = preview", + "user · terminal.view_mode_rules.(add rule) = Enter to add a rule", + } + if !slices.Equal(got, want) { + t.Fatalf("rule rows:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + // The rules follow the terminal rows and come before the vault rows. + last := slices.Index(v.lines, want[3]) + for i, l := range v.lines { + if strings.HasPrefix(l, "vault · ") && i < last { + t.Fatalf("vault row %q sits before the rule rows", l) + } + } + for _, i := range []int{0, 1, 2, 3} { + if i < 3 && v.settings[slices.Index(v.lines, want[i])].kind() != rowRule { + t.Fatalf("row %q is not a rule row", want[i]) + } + } +} + +func TestSettingsListsInvalidRulesMarked(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nkind: journal\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "sideways", "status": "preview", "=x": "edit"}) + v := &settingsView{} + v.refresh(a) + var got []string + for _, l := range v.lines { + if strings.Contains(l, "terminal.view_mode_rules.") { + got = append(got, l) + } + } + want := []string{ + "user · terminal.view_mode_rules.page-status=done = preview", + `user · terminal.view_mode_rules.=x = edit (invalid: empty key)`, + "user · terminal.view_mode_rules.kind=journal = sideways (invalid: bad mode)", + `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, + "user · terminal.view_mode_rules.(add rule) = Enter to add a rule", + } + if !slices.Equal(got, want) { + t.Fatalf("rule rows:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + for _, l := range want[1:4] { + if v.settings[slices.Index(v.lines, l)].kind() != rowInvalidRule { + t.Fatalf("row %q is not marked invalid", l) + } + } + // A note that only an invalid rule matches opens as with the feature off. + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("note opened in %q, want edit", got) + } +} + +func TestSettingsFilterMatchesRulesAndKeepsAddRow(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "split"}) + add := "user · terminal.view_mode_rules.(add rule) = Enter to add a rule" + for _, tc := range []struct { + filter string + want []string + }{ + {"page-status", []string{"user · terminal.view_mode_rules.page-status=done = preview"}}, + {"journal", []string{"user · terminal.view_mode_rules.kind=journal = split"}}, + {"= split", []string{"user · terminal.view_mode_rules.kind=journal = split"}}, + {"no-such-text-anywhere", nil}, + } { + v := &settingsView{filter: tc.filter} + v.refresh(a) + want := append(slices.Clone(tc.want), add) + if !slices.Equal(v.lines, want) { + t.Fatalf("filter %q gave:\n%s\nwant:\n%s", tc.filter, strings.Join(v.lines, "\n"), strings.Join(want, "\n")) + } + } +} + +const addRuleRow = "user · terminal.view_mode_rules.(add rule) = Enter to add a rule" + +func openAddRule(t *testing.T, a *App) *settingsView { + t.Helper() + v := &settingsView{} + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, addRuleRow) + v.handleKey(a, vim.Key{Name: "enter"}) + if _, ok := a.overlay.(*prompt); !ok { + t.Fatalf("overlay = %T, want the field prompt", a.overlay) + } + return v +} + +func submitPrompt(t *testing.T, a *App, text string) { + t.Helper() + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay = %T, want a prompt", a.overlay) + } + a.overlay = nil + p.onSubmit(a, text) +} + +func TestSettingsAddRuleWritesAfterModePick(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\nbody\n") + if err := config.SetValue("terminal", "math", "latex"); err != nil { + t.Fatal(err) + } + if err := config.SetValue("editor", "tab_size", 3); err != nil { + t.Fatal(err) + } + if err := a.reloadPreferences(); err != nil { + t.Fatal(err) + } + v := openAddRule(t, a) + submitPrompt(t, a, " page-status ") + submitPrompt(t, a, " done ") + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want the mode palette", a.overlay) + } + before, _ := os.ReadFile(config.ConfigTomlPath()) + if strings.Contains(string(before), "view_mode_rules") { + t.Fatalf("rule written before the mode was picked:\n%s", before) + } + for _, it := range pal.items { + if it.id == "preview" { + pal.onSelect(a, it) + } + } + data, _ := os.ReadFile(config.ConfigTomlPath()) + for _, want := range []string{"math", "tab_size", "view_mode_rules", `"page-status=done" = "preview"`} { + if !strings.Contains(string(data), want) { + t.Fatalf("config.toml lacks %q:\n%s", want, data) + } + } + if a.prefs.ViewModeRules["page-status=done"] != "preview" { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + settingsLineIndex(t, v, "user · terminal.view_mode_rules.page-status=done = preview") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("note opened in %q, want preview", got) + } +} + +func TestSettingsAddRuleOffersOnlyThreeModes(t *testing.T) { + a, _ := newFeatureTestApp(t) + openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want the mode palette", a.overlay) + } + var got []string + for _, it := range pal.items { + got = append(got, it.id) + } + if !slices.Equal(got, []string{"edit", "split", "preview"}) { + t.Fatalf("palette items = %v", got) + } +} + +func TestSettingsAddRuleCancelWritesNothing(t *testing.T) { + a, _ := newFeatureTestApp(t) + if err := config.SetValue("terminal", "math", "latex"); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(config.ConfigTomlPath()) + esc := func() { + t.Helper() + a.overlay.handleKey(a, vim.KeyEsc) + if a.overlay != nil { + t.Fatalf("overlay %T survived esc", a.overlay) + } + } + // Esc at the field prompt, at the value prompt and at the mode palette. + openAddRule(t, a) + esc() + openAddRule(t, a) + submitPrompt(t, a, "page-status") + esc() + openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + esc() + after, _ := os.ReadFile(config.ConfigTomlPath()) + if string(before) != string(after) { + t.Fatalf("config.toml changed:\n%s\nwas:\n%s", after, before) + } +} + +func TestSettingsAddRuleRejectsBadInput(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview"}) + before, _ := os.ReadFile(config.ConfigTomlPath()) + for _, tc := range []struct{ field, value, want string }{ + {"", "", "field"}, + {" ", "", "field"}, + {"a=b", "", "="}, + {"page-status", "", "value"}, + {"page-status", "done", "already exists"}, + {"Page-Status", " DONE ", "already exists"}, + } { + a.message, a.messageErr = "", false + openAddRule(t, a) + submitPrompt(t, a, tc.field) + if tc.value != "" || tc.field == "page-status" || tc.field == "Page-Status" { + submitPrompt(t, a, tc.value) + } + if !a.messageErr || !strings.Contains(a.message, tc.want) { + t.Fatalf("%q/%q: message = %q (error %v), want error containing %q", tc.field, tc.value, a.message, a.messageErr, tc.want) + } + if a.overlay != nil { + t.Fatalf("%q/%q: overlay %T left open", tc.field, tc.value, a.overlay) + } + after, _ := os.ReadFile(config.ConfigTomlPath()) + if string(before) != string(after) { + t.Fatalf("%q/%q changed config.toml:\n%s", tc.field, tc.value, after) + } + } +} + +const ruleDoneRow = "user · terminal.view_mode_rules.page-status=done = preview" + +func selectRule(t *testing.T, a *App, line string) *settingsView { + t.Helper() + v := &settingsView{} + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, line) + return v +} + +func pickPalette(t *testing.T, a *App, id string) { + t.Helper() + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want a palette", a.overlay) + } + for _, it := range pal.items { + if it.id == id { + a.overlay = nil + pal.onSelect(a, it) + return + } + } + t.Fatalf("palette has no %q entry", id) +} + +func paletteIDs(t *testing.T, a *App) []string { + t.Helper() + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want a palette", a.overlay) + } + var ids []string + for _, it := range pal.items { + ids = append(ids, it.id) + } + return ids +} + +func configText(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(config.ConfigTomlPath()) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestSettingsChangeRuleMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\nbody\n") + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "split"}) + v := selectRule(t, a, ruleDoneRow) + v.handleKey(a, vim.Key{Name: "enter"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"edit", "split", "preview", "remove"}) { + t.Fatalf("palette items = %v", got) + } + pickPalette(t, a, "edit") + if a.prefs.ViewModeRules["page-status=done"] != "edit" || a.prefs.ViewModeRules["kind=journal"] != "split" { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + settingsLineIndex(t, v, "user · terminal.view_mode_rules.page-status=done = edit") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("note opened in %q, want edit", got) + } +} + +func TestSettingsRemoveRuleFromPicker(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "split"}) + v := selectRule(t, a, ruleDoneRow) + v.handleKey(a, vim.Key{Name: "enter"}) + pickPalette(t, a, "remove") + if _, ok := a.prefs.ViewModeRules["page-status=done"]; ok || len(a.prefs.ViewModeRules) != 1 { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + if slices.Contains(v.lines, ruleDoneRow) { + t.Fatal("removed rule still listed") + } + if !strings.Contains(configText(t), `"kind=journal" = "split"`) { + t.Fatalf("other rule lost:\n%s", configText(t)) + } +} + +func TestSettingsRemoveRuleWithKey(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview"}) + v := selectRule(t, a, ruleDoneRow) + if !v.handleKey(a, vim.Key{Rune: 'd'}) { + t.Fatal("d was not handled") + } + if len(a.prefs.ViewModeRules) != 0 { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + if slices.Contains(v.lines, ruleDoneRow) { + t.Fatal("removed rule still listed") + } + if strings.Contains(configText(t), "view_mode_rules") { + t.Fatalf("empty rules table left in config.toml:\n%s", configText(t)) + } +} + +func TestSettingsRemoveKeyIgnoredOffRuleRows(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview"}) + before := configText(t) + v := &settingsView{} + v.refresh(a) + rows := slices.Clone(v.lines) + for i, l := range rows { + if strings.Contains(l, "terminal.view_mode_rules.page-status") { + continue + } + v.list.cursor = i + if !v.handleKey(a, vim.Key{Rune: 'd'}) { + t.Fatalf("d on %q was not consumed", l) + } + if a.overlay != nil { + t.Fatalf("d on %q opened %T", l, a.overlay) + } + } + if configText(t) != before { + t.Fatalf("config.toml changed:\n%s", configText(t)) + } + if !slices.Equal(v.lines, rows) { + t.Fatal("rows changed") + } +} + +func TestSettingsHintNamesRemoveOnRuleRow(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "sideways"}) + v := &settingsView{} + v.refresh(a) + for i, l := range v.lines { + v.list.cursor = i + got := false + for _, h := range v.hintPairs(a) { + if h.id == "key:d" && h.label == "remove rule" { + got = true + } + } + want := strings.Contains(l, "terminal.view_mode_rules.") && !strings.Contains(l, "(add rule)") + if got != want { + t.Fatalf("hint on %q: remove rule present = %v, want %v", l, got, want) + } + } +} + +func TestSettingsRemoveInvalidRule(t *testing.T) { + rows := map[string]string{ + "kind=journal": "user · terminal.view_mode_rules.kind=journal = sideways (invalid: bad mode)", + "status": `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, + } + for key, row := range rows { + for _, how := range []string{"d", "picker"} { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "sideways", "status": "preview"}) + if err := config.SetValue("terminal", "math", "latex"); err != nil { + t.Fatal(err) + } + v := selectRule(t, a, row) + if how == "d" { + v.handleKey(a, vim.Key{Rune: 'd'}) + } else { + v.handleKey(a, vim.Key{Name: "enter"}) + pickPalette(t, a, "remove") + } + if _, ok := a.prefs.ViewModeRules[key]; ok { + t.Fatalf("%s/%s: entry still in prefs", key, how) + } + if slices.Contains(v.lines, row) { + t.Fatalf("%s/%s: row still listed", key, how) + } + text := configText(t) + if !strings.Contains(text, `"page-status=done" = "preview"`) || !strings.Contains(text, "math") { + t.Fatalf("%s/%s: other settings lost:\n%s", key, how, text) + } + if len(a.prefs.ViewModeRules) != 2 { + t.Fatalf("%s/%s: prefs rules = %v", key, how, a.prefs.ViewModeRules) + } + } + } +} + +func TestSettingsFixInvalidRuleMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nkind: journal\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + writeRules(t, a, map[string]string{"kind=journal": "sideways"}) + v := selectRule(t, a, "user · terminal.view_mode_rules.kind=journal = sideways (invalid: bad mode)") + v.handleKey(a, vim.Key{Name: "enter"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"edit", "split", "preview", "remove"}) { + t.Fatalf("palette items = %v", got) + } + pickPalette(t, a, "preview") + settingsLineIndex(t, v, "user · terminal.view_mode_rules.kind=journal = preview") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("note opened in %q, want preview", got) + } +} + +func TestSettingsMalformedRuleOffersOnlyRemove(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"status": "preview", "=x": "edit"}) + for _, line := range []string{ + `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, + "user · terminal.view_mode_rules.=x = edit (invalid: empty key)", + } { + v := selectRule(t, a, line) + v.handleKey(a, vim.Key{Name: "enter"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"remove"}) { + t.Fatalf("%q palette items = %v", line, got) + } + a.overlay = nil + } +} diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index d483e54..831e743 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -34,7 +34,11 @@ func (v *helpView) hintPairs(a *App) []hintPair { } func (v *settingsView) hintPairs(a *App) []hintPair { - return []hintPair{{"nav.moveDown|nav.moveUp", "select"}, {"key:enter", "edit"}, {"key:/", "filter"}, {"key:v", "version details"}, {"key::config", "config file"}} + pairs := []hintPair{{"nav.moveDown|nav.moveUp", "select"}, {"key:enter", "edit"}} + if _, ok := v.selectedRuleRow(); ok { + pairs = append(pairs, hintPair{"key:d", "remove rule"}) + } + return append(pairs, hintPair{"key:/", "filter"}, hintPair{"key:v", "version details"}, hintPair{"key::config", "config file"}) } // Mouse wheel scrolling for the list views. From 2e2da5fa24219e04541844ded1a93d008426d954 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:19:10 -0400 Subject: [PATCH 20/65] Fix(config): a view-mode rule with an empty value is invalid A hand-written rule such as `"status=" = "edit"` was read as valid and listed in `:settings` as an ordinary rule, yet choosing a new mode for it there failed, because writing a rule already refused an empty value. The rule looked usable but could only be removed. An empty value, including an empty quoted one, now makes a rule invalid when it is read, the same as a missing `=` or an empty field. Such a rule never matches a note; `:settings` and `zn config list` show it with the reason "empty value", and its picker offers only remove. The check that runs before a rule is written is now the same parse, so reading and writing agree on what a valid rule is. Co-Authored-By: Claude Opus 5.5 --- internal/config/viewmode_rules.go | 19 +++++++++---------- internal/config/viewmode_rules_test.go | 20 +++++++++++++++----- internal/tui/settings_viewmode_test.go | 3 ++- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/internal/config/viewmode_rules.go b/internal/config/viewmode_rules.go index 5511ebc..b22b9d4 100644 --- a/internal/config/viewmode_rules.go +++ b/internal/config/viewmode_rules.go @@ -45,7 +45,8 @@ func normalizeViewModePart(s string) string { } // ParseViewModeRule validates one "=" entry and its mode. The -// first "=" splits key from value. On failure it returns a short reason. +// first "=" splits key from value; both must be non-empty, and the mode +// must be edit, split or preview. On failure it returns a short reason. func ParseViewModeRule(key, mode string) (ViewModeRule, string) { k, v, found := strings.Cut(key, "=") if !found { @@ -55,22 +56,20 @@ func ParseViewModeRule(key, mode string) (ViewModeRule, string) { if k == "" { return ViewModeRule{}, "empty key" } + v = normalizeViewModePart(v) + if v == "" { + return ViewModeRule{}, "empty value" + } m, ok := normalizeViewModeMode(mode) if !ok { return ViewModeRule{}, "bad mode" } - return ViewModeRule{Key: k, Value: normalizeViewModePart(v), Mode: m}, "" + return ViewModeRule{Key: k, Value: v, Mode: m}, "" } -// ValidateViewModeRule is the check writers run before storing a rule: the -// key must hold "=", a non-empty field and a non-empty value, and the mode -// must be edit, split or preview. +// ValidateViewModeRule is the check writers run before storing a rule. func ValidateViewModeRule(key, mode string) error { - rule, reason := ParseViewModeRule(key, mode) - if reason == "" && rule.Value == "" { - reason = "empty value" - } - if reason != "" { + if _, reason := ParseViewModeRule(key, mode); reason != "" { return &viewModeRuleError{reason} } return nil diff --git a/internal/config/viewmode_rules_test.go b/internal/config/viewmode_rules_test.go index 0339681..13cbda3 100644 --- a/internal/config/viewmode_rules_test.go +++ b/internal/config/viewmode_rules_test.go @@ -61,11 +61,21 @@ func TestInvalidViewModeRulesAreIgnoredButReported(t *testing.T) { } } -// An empty value part is a rule like any other when read from the file. -func TestViewModeRuleWithEmptyValueIsValid(t *testing.T) { - valid := ValidViewModeRules(map[string]string{"status=": "edit"}) - if len(valid) != 1 || valid[0].Key != "status" || valid[0].Value != "" { - t.Fatalf("got %+v", valid) +// A rule with nothing after "=" is invalid when read from the file: it is +// reported with its reason and never matches. +func TestViewModeRuleWithEmptyValueIsInvalid(t *testing.T) { + raw := map[string]string{"status=": "edit", `status=""`: "preview"} + if valid := ValidViewModeRules(raw); len(valid) != 0 { + t.Fatalf("empty-value rules must not be valid, got %+v", valid) + } + entries := ViewModeRuleEntries(raw) + if len(entries) != 2 { + t.Fatalf("want both entries reported, got %+v", entries) + } + for _, e := range entries { + if e.Valid || e.Reason != "empty value" { + t.Fatalf("entry %q: want invalid with reason %q, got %+v", e.Key, "empty value", e) + } } } diff --git a/internal/tui/settings_viewmode_test.go b/internal/tui/settings_viewmode_test.go index 56562e9..265858b 100644 --- a/internal/tui/settings_viewmode_test.go +++ b/internal/tui/settings_viewmode_test.go @@ -532,10 +532,11 @@ func TestSettingsFixInvalidRuleMode(t *testing.T) { func TestSettingsMalformedRuleOffersOnlyRemove(t *testing.T) { a, _ := newFeatureTestApp(t) - writeRules(t, a, map[string]string{"status": "preview", "=x": "edit"}) + writeRules(t, a, map[string]string{"status": "preview", "=x": "edit", "kind=": "split"}) for _, line := range []string{ `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, "user · terminal.view_mode_rules.=x = edit (invalid: empty key)", + "user · terminal.view_mode_rules.kind= = split (invalid: empty value)", } { v := selectRule(t, a, line) v.handleKey(a, vim.Key{Name: "enter"}) From 6e05399df908f9ee1b96b4ea5b6ce4d5a4c7f6fa Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:21:07 -0400 Subject: [PATCH 21/65] Docs: README and zn tui help describe frontmatter view modes The README gains a section on opening notes in a pane mode from their frontmatter: `[terminal] view_mode_field`, off by default with `view-mode` suggested, the `[terminal.view_mode_rules]` format with a `page-status=done` example, what makes a rule invalid and how to remove one, the order in which the field, the rules, the remembered and keep-across modes, restored tabs, new notes and `:ref` win, and how to edit both in `:settings` or with `zn config`. It says only zn tui reads them. The settings table lists both under `[terminal]`. zn tui's help adds a row for the field and the rules next to the pane mode keys, and says a manual mode switch always wins over frontmatter. Co-Authored-By: Claude Opus 5.5 --- README.md | 59 +++++++++++++++++++++++++++++++++++++++- internal/tui/helpdata.go | 3 +- internal/tui/tui_test.go | 11 ++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b5a6785..8e49f03 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,63 @@ files by absolute path, so under `latex` a note can show in the preview the contents of any file you can read. Leave latex off for vaults you do not trust. +### Open notes in a pane mode from their frontmatter + +By default a note opens in the mode `editor.default_view_mode` gives (or the +mode you last chose, with `keep_view_mode_across_notes`). Two `[terminal]` +settings, both off by default, let a note's own frontmatter pick the mode when +it opens in a new tab: + +- **`view_mode_field`** names a frontmatter field. It is empty by default; + `view-mode` is a good name. A note with `view-mode: preview` (or `edit`, or + `split`) opens in that mode. The key and value are not case sensitive. A + list, an empty value or any other word is ignored. +- **`[terminal.view_mode_rules]`** maps a frontmatter `key=value` pair to a mode. + A list field matches when any element equals the value. + +```toml +[terminal] +view_mode_field = "view-mode" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "edit" +``` + +Rules are written `"=" = ""`, where the mode is `edit`, +`split` or `preview`. Keys and values are trimmed, unquoted and compared in +lower case. A rule is invalid, and ignored, when it has no `=`, an empty key, +an empty value or a mode other than those three. Invalid rules never stop zn +from starting; `:settings` lists them marked with their reason (`no "="`, +`empty key`, `empty value` or `bad mode`), and `d` or the mode picker's +`remove` deletes them. + +Precedence: + +- A valid `view_mode_field` value beats the rules. Among the rules, the first + one in sorted order of the lower-cased `key=value` wins, so + `kind=journal` is tried before `page-status=done`. +- Frontmatter beats the remembered mode and `keep_view_mode_across_notes`. + Applying it never changes either of them; a mode you switch to yourself + still does. +- A tab restored from the saved session keeps its saved mode. +- A note you have just created opens in the editor, whatever its frontmatter + says. Reopening it later follows the frontmatter. +- The pane opened by `:ref` stays in preview. +- Switching to a tab that is already open never changes its mode, and saving + new frontmatter does not either. + +Edit them in `:settings` (the `terminal.view_mode_field` row, and the +`terminal.view_mode_rules` rows with their add row) or with `zn config`: + +```bash +zn config set terminal.view_mode_field view-mode +zn config set terminal.view_mode_rules.page-status=done preview +``` + +Only `zn tui` reads these settings. zn never writes them into the vault, and +the desktop app ignores them. + ### Vaults and servers inside the app `:vault` (or `Space v`) lists every saved vault and server, with entries to @@ -810,7 +867,7 @@ layout choices do not change these terminal layouts. | `[view]` | `tasks_view_mode`, `kanban_group_by`, `kanban_statuses`, `note_sort_order` | | `[appearance]` | `theme_family`, `theme_mode`, `theme_id` | | `[tweaks]` | `accent = "#ff3b30"`: the desktop's Quick tweaks colors | -| `[terminal]` | `mouse`, `preview_style`, `images`, `theme`, `math`, `render_timeout`, `render_concurrency` (terminal only) | +| `[terminal]` | `mouse`, `preview_style`, `images`, `theme`, `math`, `render_timeout`, `render_concurrency`, `view_mode_field` and `[terminal.view_mode_rules]` (terminal only) | | `[keymaps]` | `"action.id" = "keys"`, or `""` to unbind; `:keymaps` lists the ids | | `[kanban_column_titles]` | `review = "In Review"` | | `[saved_filters]` | `Important = "!high"` | diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index dcb1498..24bac3f 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -31,6 +31,7 @@ func helpLines(a *App, filter string) []string { "Alt+.\tZen mode (editor only)", "Alt+1 … Alt+9\tJump to tab N", "Alt+E / Alt+S / Alt+P\tEditor, split (editor + preview) or preview mode for the tab", + "[terminal] view_mode_field\tOff by default. A frontmatter field (view-mode is a good name) whose value edit, split or preview sets the mode of a note opened in a new tab; [terminal.view_mode_rules] \"key=value\" = \"mode\" does the same for any field (the field wins). A new note opens in edit; a restored tab and :ref keep theirs", "Alt+Z\tToggle word wrap", "Alt+Left / Alt+Right\tBack and forward through the jump list", "Ctrl+P\tSearch notes (Enter opens; with no match Enter creates the note; Ctrl+D trashes the selected one)", @@ -119,7 +120,7 @@ func helpLines(a *App, filter string) []string { ":template :insert \tTemplates", ":newtask :filter \tTasks", ":outline :connections :calendar :closepanel :sidebar :zen\tPanels", - ":editmode :splitmode :previewmode\tTab view mode", + ":editmode :splitmode :previewmode\tTab view mode (a manual switch always wins over the note's frontmatter view mode)", ":fold :unfold :foldall :unfoldall :format :wrap :nu :theme :vim\tEditor state", ":db :dbnew \tDatabases", ":bind :unbind :keymaps\tPersistent keymaps; select an action in :keymaps to change its binding", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 1793b08..a40a3e2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -252,6 +252,17 @@ func TestHelpMentionsLatexSettings(t *testing.T) { } } +func TestHelpMentionsFrontmatterViewMode(t *testing.T) { + a := &App{} + a.keymap = newTestResolver() + help := strings.Join(helpLines(a, ""), "\n") + for _, want := range []string{"view_mode_field", "[terminal.view_mode_rules]", "frontmatter"} { + if !contains(help, want) { + t.Errorf("help does not mention %q", want) + } + } +} + // The help row keyed `[terminal] math` names the tools latex needs. func TestHelpMathEntryNamesTools(t *testing.T) { a := &App{} From 21ea0592e69bf22b7ba6d53804d29350e016b5d4 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:26:53 -0400 Subject: [PATCH 22/65] Test(tui): frontmatter view modes write nothing to the vault Two end-to-end tests guard the feature as a whole. One opens notes in frontmatter modes, lets a slow read land, switches, splits and closes tabs, and adds and removes a rule from `:settings`, then checks that every file in the vault, `.zennotes` included, is byte-for-byte as it was: the configuration lives only in zn's own config.toml. The other adds a rule in `:settings`, opens a matching note in that mode, changes the rule's mode, and reopens the note in the new one. Co-Authored-By: Claude Opus 5.5 --- internal/tui/settings_viewmode_test.go | 24 +++++++++ internal/tui/viewmode_open_test.go | 67 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/internal/tui/settings_viewmode_test.go b/internal/tui/settings_viewmode_test.go index 265858b..a424420 100644 --- a/internal/tui/settings_viewmode_test.go +++ b/internal/tui/settings_viewmode_test.go @@ -546,3 +546,27 @@ func TestSettingsMalformedRuleOffersOnlyRemove(t *testing.T) { a.overlay = nil } } + +func TestSettingsRuleThenOpenFollowsIt(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + + v := openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + pickPalette(t, a, "preview") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("first open in %q, want preview", got) + } + + v.list.cursor = settingsLineIndex(t, v, ruleDoneRow) + v.handleKey(a, vim.Key{Name: "enter"}) + pickPalette(t, a, "split") + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("reopen in %q, want split", got) + } +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 5275d69..77c0ea3 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -11,6 +11,7 @@ import ( "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" ) const fmPreviewNote = "---\nview-mode: preview\npage-status: done\n---\n# Body\n" @@ -569,3 +570,69 @@ func TestReopenedNewNoteFollowsFrontmatter(t *testing.T) { t.Fatalf("reopened: %q, want preview", got) } } + +// vaultSnapshot maps every file under root, hidden directories included, to its bytes. +func vaultSnapshot(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + rel, _ := filepath.Rel(root, p) + out[rel] = string(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestViewModeFeatureWritesNoVaultFiles(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + writeVaultNote(t, root, "m.md", "---\nkind: journal\n---\nbody\n") + writeVaultNote(t, root, "plain.md", "# plain\n") + enableViewModeField(a) + a.prefs.ViewModeRules = map[string]string{"kind=journal": "split"} + before := vaultSnapshot(t, root) + + a.deferReads = true + for _, name := range []string{"n.md", "m.md", "plain.md"} { + a.openNote(name, true) + loadDeferred(t, a, name) + } + a.setPaneMode(modeSplit) + a.splitPane(true) + a.setPaneMode(modeEdit) + a.openNote("n.md", true) + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + + v := openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + pickPalette(t, a, "preview") + v.list.cursor = settingsLineIndex(t, v, ruleDoneRow) + v.handleKey(a, vim.Key{Rune: 'd'}) + if _, ok := a.prefs.ViewModeRules["page-status=done"]; ok { + t.Fatalf("rules after add and remove = %v", a.prefs.ViewModeRules) + } + + after := vaultSnapshot(t, root) + if len(after) != len(before) { + t.Fatalf("vault files %d, want %d", len(after), len(before)) + } + for name, body := range before { + if after[name] != body { + t.Fatalf("%s changed:\n%s\nwas:\n%s", name, after[name], body) + } + } + if _, ok := after[filepath.Join(".zennotes", "vault.json")]; ok { + t.Fatal(".zennotes/vault.json appeared") + } +} From 0b106bb15db88d3a4019af23da39b2ad80249124 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:38:43 -0400 Subject: [PATCH 23/65] Test(tui): notes no rule or field matches open as with the feature off Three cases were covered only below the open path or not at all. A note whose frontmatter matches nothing (a bad field value, a list in the field, or no matching rule) now has a test that it opens exactly as it would with the feature off, against a preview default, a remembered mode and the keep-across mode, before and after a slow read. Invalid rules are shown to be ignored when a note opens while a valid rule beside them still applies, and an empty field is shown to leave the rules working after a slow read. A last test keeps the README's description of the settings from going missing. Co-Authored-By: Claude Opus 5.5 --- internal/tui/viewmode_gaps_test.go | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 internal/tui/viewmode_gaps_test.go diff --git a/internal/tui/viewmode_gaps_test.go b/internal/tui/viewmode_gaps_test.go new file mode 100644 index 0000000..97b2cf8 --- /dev/null +++ b/internal/tui/viewmode_gaps_test.go @@ -0,0 +1,103 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// With the feature on, a note whose frontmatter gives no mode opens exactly as +// it would with the feature off, before and after its body loads. +func TestNoMatchingFrontmatterOpensAsFeatureOff(t *testing.T) { + notes := map[string]string{ + "none.md": "# plain\n", + "unrelated.md": "---\ntitle: x\ntags: [a]\n---\nbody\n", + "miss.md": "---\npage-status: in-progress\n---\nbody\n", + "badfield.md": "---\nview-mode: reading\n---\nbody\n", + "listfield.md": "---\nview-mode: [preview]\n---\nbody\n", + } + for name, body := range notes { + for _, tc := range []struct { + label string + setup func(a *App) + want paneMode + }{ + {"default preview", func(a *App) { a.prefs.DefaultPaneMode = "preview" }, modePreview}, + {"remembered split", func(a *App) { a.noteModes[name] = modeSplit }, modeSplit}, + {"keep across split", func(a *App) { a.prefs.RetainViewMode = true; a.prefs.DefaultPaneMode = "split" }, modeSplit}, + } { + t.Run(name+"/"+tc.label, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, name, body) + enableViewModeField(a) + a.prefs.ViewModeRules = map[string]string{"page-status=done": "edit"} + tc.setup(a) + a.deferReads = true + a.openNote(name, true) + if got := modeOf(t, a, name); got != tc.want { + t.Fatalf("before load: %q, want %q", got, tc.want) + } + loadDeferred(t, a, name) + if got := modeOf(t, a, name); got != tc.want { + t.Fatalf("after load: %q, want %q", got, tc.want) + } + }) + } + } +} + +// An invalid rule never applies, and the valid rules still do. +func TestInvalidRuleIgnoredOnOpenValidRuleApplies(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nkind: journal\npage-status: done\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + a.prefs.ViewModeRules = map[string]string{ + "kind=journal": "reading", + "no-equals": "preview", + "=x": "preview", + "page-status=done": "split", + } + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q, want split", got) + } +} + +// An empty field turns off only the field: a rule still applies after a +// deferred read. +func TestEmptyFieldKeepsRulesOnDeferredOpen(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: edit\npage-status: done\n---\nbody\n") + a.prefs.TerminalViewModeField = "" + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.deferReads = true + a.openNote("n.md", true) + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q, want preview", got) + } +} + +// The README documents the settings a reader looks up for per-note view modes. +func TestReadmeDocumentsFrontmatterViewMode(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "README.md")) + if err != nil { + t.Fatal(err) + } + readme := string(raw) + for _, want := range []string{ + "terminal.view_mode_field", + "view-mode", + "[terminal.view_mode_rules]", + `"page-status=done" = "preview"`, + "keep_view_mode_across_notes", + } { + if !strings.Contains(readme, want) { + t.Errorf("README does not mention %q", want) + } + } + if !strings.Contains(readme, "Only `zn tui` reads these settings") { + t.Error("README does not say that only zn tui reads these settings") + } +} From ce02956108435107bc268a0c39b40373e080c0c4 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:48:05 -0400 Subject: [PATCH 24/65] Fix(tui): a sidebar peek stays in preview and a reopened tab follows frontmatter Two ways of opening a note set the tab's mode themselves right after opening it, and with frontmatter view modes on, a slow read of the note could then land and replace that mode. Peeking a note from the sidebar asks for preview, as `:ref` does; the frontmatter could then switch the peek to edit or split. Both now set their mode through one helper that also stops the frontmatter from taking it back. Reopening a closed tab put back the mode the tab was closed in, and the frontmatter replaced it only when the read happened to be slow. A reopened note is now treated like any note opened in a new tab: its frontmatter mode wins whether the read is quick or slow, and the closed tab's mode stands only when the frontmatter asks for none. Tests cover a peek of a note whose frontmatter asks for edit or split, and reopening a closed tab with a quick and a slow read, with and without a frontmatter mode. Co-Authored-By: Claude Opus 5.5 --- internal/tui/commands_parity.go | 3 +- internal/tui/panes.go | 6 +++ internal/tui/sidebar.go | 5 +- internal/tui/viewmode_frontmatter.go | 11 ++++ internal/tui/viewmode_open_test.go | 80 ++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index 7898ae7..d66c845 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -726,8 +726,7 @@ func (a *App) openReference(path string) { if t == nil { return } - t.mode = modePreview - t.frontmatterPending = false + forceTabMode(t, modePreview) if !t.pinned { a.togglePin(p, p.active) } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index f5b45fa..e866ddf 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -333,9 +333,15 @@ func (a *App) reopenClosedTab() { a.openVirtualByPath(last.path) return } + reused := a.activePane != nil && a.activePane.findTab(last.path) >= 0 a.openNote(last.path, true) if t := a.activePane.activeTab(); t != nil { t.mode = last.mode + // A reopened note follows its frontmatter like any note opened in a + // new tab; the closed tab's mode stands only when it asks for none. + if !reused { + a.watchFrontmatterMode(t) + } } } diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 141e91b..17f1de0 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -584,10 +584,7 @@ func (s *sidebarState) handleKey(a *App, k vim.Key) { if r := s.row(); r != nil && r.kind == "note" { a.openNote(r.path, false) if t := a.activeTab(); t != nil { - t.mode = modePreview - if t.preview == nil { - t.preview = &previewState{} - } + forceTabMode(t, modePreview) } } default: diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go index 2867333..8848165 100644 --- a/internal/tui/viewmode_frontmatter.go +++ b/internal/tui/viewmode_frontmatter.go @@ -101,6 +101,17 @@ func (a *App) applyFrontmatterMode(buf *noteBuffer) { a.markSessionDirty() } +// forceTabMode sets a tab's mode for an explicit request, such as a +// reference or a peek. The note's frontmatter, still to be read, will not +// override it. +func forceTabMode(t *tab, mode paneMode) { + t.mode = mode + t.frontmatterPending = false + if mode != modeEdit && t.preview == nil { + t.preview = &previewState{} + } +} + // markNewNote records that path was just created and is about to be opened, // so the open starts in the editor instead of taking a mode from frontmatter. func (a *App) markNewNote(path string) { diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 77c0ea3..840e7a1 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -636,3 +636,83 @@ func TestViewModeFeatureWritesNoVaultFiles(t *testing.T) { t.Fatal(".zennotes/vault.json appeared") } } + +// Peeking a note from the sidebar asks for preview; a slow read of the note's +// frontmatter must not take that back. +func TestSidebarPeekStaysPreview(t *testing.T) { + for _, fm := range []string{"edit", "split"} { + t.Run(fm, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: "+fm+"\n---\nbody\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + a.sidebar.rebuild(a) + enableViewModeField(a) + found := false + for i, row := range a.sidebar.rows { + if row.path == "n.md" { + a.sidebar.cursor = i + found = true + break + } + } + if !found { + t.Fatal("fixture note is absent from the sidebar") + } + a.focus = focusSidebar + a.deferReads = true + a.sidebar.handleKey(a, vim.Key{Rune: 'p'}) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode before load %q, want preview", got) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode after load %q, want preview", got) + } + }) + } +} + +// Reopening a closed tab is an ordinary open in a new tab: the frontmatter +// mode wins over the mode the tab was closed in, however the read lands. +func TestReopenedClosedTabFollowsFrontmatter(t *testing.T) { + for _, deferred := range []bool{false, true} { + t.Run(map[bool]string{false: "sync", true: "deferred"}[deferred], func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: preview\n---\nbody\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + if len(tabsOf(a, "n.md")) != 0 { + t.Fatal("tab did not close") + } + a.deferReads = deferred + a.reopenClosedTab() + if deferred { + loadDeferred(t, a, "n.md") + } + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q, want preview from frontmatter", got) + } + }) + } +} + +// A reopened note whose frontmatter asks for nothing keeps the mode its tab +// was closed in. +func TestReopenedClosedTabWithoutFrontmatterKeepsMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "# plain\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + a.deferReads = true + a.reopenClosedTab() + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q, want split", got) + } +} From 8afc2adc7c99927b24b278f68ecb4f24be487700 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:51:30 -0400 Subject: [PATCH 25/65] Fix(config): rules differing only in case, or with a non-string mode Two hand-edited or command-line cases slipped past the rule handling. `zn config set terminal.view_mode_rules.Page-Status=done split` added a second entry next to an existing `page-status=done`, although both are the same rule: the new one then won the tie, and `:settings` listed two rows for one rule. Writing a rule now looks for an entry with the same field and value, compared the way rules are matched, in any case or quoting and whether or not its mode is valid, and changes that entry under the key it already has. A rule such as `"a=b" = 1` or `= true` was dropped while loading, so it was ignored but also missing from `:settings` and `zn config list`, where invalid rules are shown with a reason and can be removed. Such a mode is now kept as text, so the rule shows up as invalid with "bad mode" and can be removed like any other. Co-Authored-By: Claude Opus 5.5 --- internal/cli/config_viewmode_case_test.go | 29 ++++++++++++ internal/config/prefs.go | 12 ++++- internal/config/prefs_write.go | 21 ++++++++- internal/config/viewmode_edgecases_test.go | 54 ++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 internal/cli/config_viewmode_case_test.go create mode 100644 internal/config/viewmode_edgecases_test.go diff --git a/internal/cli/config_viewmode_case_test.go b/internal/cli/config_viewmode_case_test.go new file mode 100644 index 0000000..b0cfbfe --- /dev/null +++ b/internal/cli/config_viewmode_case_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "maps" + "os" + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +// `zn config set` on a rule that differs from an existing one only in case +// changes the existing rule instead of adding a duplicate. +func TestConfigSetViewModeRuleCaseVariantChangesExisting(t *testing.T) { + isolatedCLI(t) + path := config.ConfigTomlPath() + if err := os.WriteFile(path, []byte("[terminal.view_mode_rules]\n\"page-status=done\" = \"preview\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if o, code := runConfig(t, "set", "terminal.view_mode_rules.Page-Status=done", "split"); code != 0 { + t.Fatalf("set: exit %d: %s", code, o) + } + prefs, _, err := config.LoadPrefs() + if err != nil { + t.Fatal(err) + } + if want := map[string]string{"page-status=done": "split"}; !maps.Equal(prefs.ViewModeRules, want) { + t.Fatalf("rules %v, want %v", prefs.ViewModeRules, want) + } +} diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 6dcb98e..83f72e9 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "strings" @@ -245,7 +246,16 @@ func applyPrefs(p *Prefs, doc map[string]any) { setString(terminal, "math", &p.TerminalMath) setString(terminal, "view_mode_field", &p.TerminalViewModeField) if rules, ok := terminal["view_mode_rules"].(map[string]any); ok { - p.ViewModeRules = mergeStringTable(map[string]string{}, rules) + p.ViewModeRules = map[string]string{} + for k, v := range rules { + // A mode that is not a string is kept as text, so the rule is + // listed as invalid and can be removed instead of vanishing. + s, isString := v.(string) + if !isString { + s = fmt.Sprint(v) + } + p.ViewModeRules[k] = s + } } setInt(terminal, "render_timeout", &p.RenderTimeout) setInt(terminal, "render_concurrency", &p.RenderConcurrency) diff --git a/internal/config/prefs_write.go b/internal/config/prefs_write.go index 37178b3..f22d403 100644 --- a/internal/config/prefs_write.go +++ b/internal/config/prefs_write.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" @@ -35,18 +36,34 @@ func SetValue(section, key string, value any) error { } // SetViewModeRule adds or changes one [terminal.view_mode_rules] entry, -// keeping every other key and table. Rules already in the file that are -// malformed never block the write. +// keeping every other key and table. An entry already in the file for the +// same field and value, written in any case or quoting and valid or not, +// is changed in place instead of gaining a duplicate. Rules already in the +// file that are malformed never block the write. func SetViewModeRule(key, mode string) error { if err := ValidateViewModeRule(key, mode); err != nil { return err } + want, _ := ParseViewModeRule(key, mode) mode = strings.ToLower(strings.TrimSpace(mode)) return UpdateConfig(func(doc map[string]any) error { terminal, rules, err := viewModeTables(doc, true) if err != nil { return err } + if _, exact := rules[key]; !exact { + existing := make([]string, 0, len(rules)) + for k := range rules { + existing = append(existing, k) + } + sort.Strings(existing) + for _, k := range existing { + if r, reason := ParseViewModeRule(k, "edit"); reason == "" && r.Key == want.Key && r.Value == want.Value { + key = k + break + } + } + } rules[key] = mode terminal["view_mode_rules"] = rules doc["terminal"] = terminal diff --git a/internal/config/viewmode_edgecases_test.go b/internal/config/viewmode_edgecases_test.go new file mode 100644 index 0000000..7bf8362 --- /dev/null +++ b/internal/config/viewmode_edgecases_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "reflect" + "testing" +) + +// A hand-edited rule whose mode is not a string is kept, as an invalid rule +// that can be seen and removed, instead of disappearing on load. +func TestNonStringViewModeRuleIsKeptInvalid(t *testing.T) { + writeConfigFile(t, "[terminal.view_mode_rules]\n\"a=b\" = 1\n\"c=d\" = true\n\"e=f\" = \"edit\"\n") + p, _, err := LoadPrefs() + if err != nil { + t.Fatal(err) + } + if len(p.ViewModeRules) != 3 { + t.Fatalf("rules %v, want all three entries", p.ViewModeRules) + } + for _, e := range ViewModeRuleEntries(p.ViewModeRules) { + switch e.Key { + case "a=b", "c=d": + if e.Valid || e.Reason != "bad mode" { + t.Fatalf("%q: want invalid with bad mode, got %+v", e.Key, e) + } + case "e=f": + if !e.Valid { + t.Fatalf("%q: want valid, got %+v", e.Key, e) + } + } + } + if err := RemoveViewModeRule("a=b"); err != nil { + t.Fatal(err) + } + rules := ruleTable(readConfigDoc(t)) + if _, ok := rules["a=b"]; ok || len(rules) != 2 { + t.Fatalf("after remove: %v", rules) + } +} + +// Setting a rule that differs from an existing entry only in case or quoting +// changes that entry instead of adding a second one, even when it is invalid. +func TestSetViewModeRuleChangesCaseVariantInPlace(t *testing.T) { + writeConfigFile(t, "[terminal.view_mode_rules]\n\"page-status=done\" = \"preview\"\n\"Kind=Journal\" = \"sideways\"\n") + if err := SetViewModeRule("Page-Status=DONE", "split"); err != nil { + t.Fatal(err) + } + if err := SetViewModeRule(`kind="journal"`, "edit"); err != nil { + t.Fatal(err) + } + want := map[string]any{"page-status=done": "split", "Kind=Journal": "edit"} + if got := ruleTable(readConfigDoc(t)); !reflect.DeepEqual(got, want) { + t.Fatalf("rules %v, want %v", got, want) + } +} From abe78991669b6b0899f37fc20d81968649b91eb5 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 06:53:39 -0500 Subject: [PATCH 26/65] Fix(tui): reopening a closed tab whose note is gone leaves the active tab alone When the note behind a closed tab had been deleted, reopening it opened nothing, but the closed tab's mode was still written to whichever tab was active, and with frontmatter view modes on that tab was then given its own frontmatter mode, replacing one the user had picked. The reopened mode is now applied only to the reopened note's tab. The README's list of what wins now says that a reopened tab follows its frontmatter, and that a sidebar peek stays in preview like `:ref`. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- internal/tui/panes.go | 2 +- internal/tui/viewmode_open_test.go | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e49f03..890ece3 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,9 @@ Precedence: - A tab restored from the saved session keeps its saved mode. - A note you have just created opens in the editor, whatever its frontmatter says. Reopening it later follows the frontmatter. -- The pane opened by `:ref` stays in preview. +- Reopening a closed tab follows the frontmatter too; the mode the tab was + closed in comes back only when the frontmatter asks for none. +- The pane opened by `:ref`, and a peek from the sidebar, stay in preview. - Switching to a tab that is already open never changes its mode, and saving new frontmatter does not either. diff --git a/internal/tui/panes.go b/internal/tui/panes.go index e866ddf..441f3f9 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -335,7 +335,7 @@ func (a *App) reopenClosedTab() { } reused := a.activePane != nil && a.activePane.findTab(last.path) >= 0 a.openNote(last.path, true) - if t := a.activePane.activeTab(); t != nil { + if t := a.activePane.activeTab(); t != nil && t.path == last.path { t.mode = last.mode // A reopened note follows its frontmatter like any note opened in a // new tab; the closed tab's mode stands only when it asks for none. diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 840e7a1..acebed7 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -716,3 +716,24 @@ func TestReopenedClosedTabWithoutFrontmatterKeepsMode(t *testing.T) { t.Fatalf("mode %q, want split", got) } } + +// Reopening a closed tab whose note has since gone leaves the active tab's +// mode alone. +func TestReopenedDeletedNoteLeavesActiveTabAlone(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "gone.md", "---\nview-mode: preview\n---\nbody\n") + writeVaultNote(t, root, "stay.md", "---\nview-mode: preview\n---\nbody\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.openNote("gone.md", true) + a.closeTab(a.activePane, a.activePane.findTab("gone.md")) + a.openNote("stay.md", true) + a.setPaneMode(modeSplit) + if err := os.Remove(filepath.Join(root, "gone.md")); err != nil { + t.Fatal(err) + } + a.reopenClosedTab() + if got := modeOf(t, a, "stay.md"); got != modeSplit { + t.Fatalf("active tab mode %q, want split", got) + } +} From 3fe9eae1c5821d668fff3ee24d10f5aed78c1dcc Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:37:26 -0500 Subject: [PATCH 27/65] Feat(vim): the editor reports when r waits for its character After `r`, with or without a count or register, the editor stays in normal mode until the replacement character arrives, so a host cannot tell from the mode that a character is pending. ReplacePending answers that: it is true only in normal mode while the pending keys are an optional register and count followed by `r`, and false once the character or Esc arrives, in visual mode and in insert mode. The TUI uses it to show an underline cursor while it waits. Tests: `r`, `3r` and `"ar` are pending; `rx` replaces and clears it; `r` clears it with the text unchanged; `vr`, insert mode, a typed `r` in insert mode and another pending operator are not. Co-Authored-By: Claude Opus 5.5 --- internal/vim/editor.go | 10 ++++++++++ internal/vim/editor_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/vim/editor.go b/internal/vim/editor.go index ca9a6ee..c414d08 100644 --- a/internal/vim/editor.go +++ b/internal/vim/editor.go @@ -1703,3 +1703,13 @@ func (e *Editor) PendingCount() (int, bool) { } return n, true } + +// ReplacePending reports whether a normal-mode `r` (with an optional +// register and count before it) is waiting for the character to put in. +func (e *Editor) ReplacePending() bool { + if e.mode != ModeNormal || len(e.pending) == 0 { + return false + } + _, _, _, rest, status := splitPrefix(e.pending) + return status == parseComplete && len(rest) == 1 && rest[0].IsRune('r') +} diff --git a/internal/vim/editor_test.go b/internal/vim/editor_test.go index 4cc5b4e..ce77f66 100644 --- a/internal/vim/editor_test.go +++ b/internal/vim/editor_test.go @@ -493,3 +493,39 @@ func TestReplaceAtCursorJoinsTheInsert(t *testing.T) { t.Fatalf("after undo: %q", got) } } + +func TestReplacePending(t *testing.T) { + cases := []struct { + name string + text string + keys string + pending bool + wantText string + }{ + {"r waits", "abc", "r", true, "abc"}, + {"count then r waits", "abcd", "3r", true, "abcd"}, + {"register then r waits", "abc", "\"ar", true, "abc"}, + {"r then character", "abc", "rx", false, "xbc"}, + {"r then esc", "abc", "r", false, "abc"}, + {"visual r", "abc", "vr", false, "abc"}, + {"insert mode", "abc", "i", false, "abc"}, + {"typed r in insert mode", "abc", "ir", false, "rabc"}, + {"other pending key", "abc", "d", false, "abc"}, + {"nothing pending", "abc", "", false, "abc"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + opts := DefaultOptions() + opts.TextReplacementsEnabled = false + e := New(c.text, opts, Hooks{}) + e.SetViewport(10) + e.Feed(c.keys) + if got := e.ReplacePending(); got != c.pending { + t.Fatalf("ReplacePending: got %v want %v", got, c.pending) + } + if got := e.Text(); got != c.wantText { + t.Fatalf("text: got %q want %q", got, c.wantText) + } + }) + } +} From 7f3e26948e1a5f87ba458088d0bd412070699cd1 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:53 -0500 Subject: [PATCH 28/65] Feat(tui): a bar or underline cursor for the note being edited While a note is edited, the reverse-video cell that stands in for the cursor hides the character under it and gives no sign of the mode. The TUI now works out, for every frame, the cursor the terminal should show: a steady bar in insert mode or with vim mode off, a steady underline in replace mode, and nothing in normal, visual and command-line mode or while the note is loading, where the drawn block stays exactly as it was. The cell is found the way the tag menu finds its anchor: the pane origin, the gutter, then the width of the text before the cursor on its wrapped row, so tabs and wide runes land on the right cell. While the terminal cursor owns the insertion point the editor stops drawing the block there and past the end of the line. Nothing reaches the terminal yet; a later commit connects the frame's cursor to the output wrapper. Tests: the bar on a plain, wrapped, CJK, tab and end-of-line position with no reverse cell drawn; the underline in replace mode; the bar with vim mode off; the block in normal and visual mode and after Esc; and the block while the note loads, with vim mode on and off. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 4 + internal/tui/cursor.go | 74 +++++++++++ internal/tui/cursor_test.go | 255 ++++++++++++++++++++++++++++++++++++ internal/tui/editorview.go | 7 +- 4 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 internal/tui/cursor.go create mode 100644 internal/tui/cursor_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..d9ae958 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -147,6 +147,9 @@ type App struct { // missing-TeX notice, so only a note's first LaTeX block carries it. latexNoticeShown bool + // cursor is where the terminal cursor belongs in the last frame drawn. + cursor cursorState + systemDark bool exHistory []string mouse mouseState @@ -714,6 +717,7 @@ func (a *App) View() string { if len(a.toasts) > 0 { screen = a.renderToasts(screen) } + a.cursor = a.computeCursor() return a.theme.paint(screen) } diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go new file mode 100644 index 0000000..662ee31 --- /dev/null +++ b/internal/tui/cursor.go @@ -0,0 +1,74 @@ +package tui + +import "github.com/ZenNotes/tui/internal/vim" + +// bufferCursorShape is the terminal cursor shape the buffer wants while its +// text is being edited. ok is false when the buffer keeps the drawn block +// instead: in normal and visual mode, in the command line, and while the +// note is still loading. +func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { + if buf == nil || buf.loading { + return 0, false + } + if !a.prefs.VimMode { + return cursorShapeBar, true + } + switch buf.ed.Mode() { + case vim.ModeInsert: + return cursorShapeBar, true + case vim.ModeReplace: + return cursorShapeUnderline, true + } + return 0, false +} + +// terminalCursorOwns reports whether the terminal's own cursor stands in for +// the drawn block in buf. Only the active tab's buffer is placed that way; +// other editors, such as quick capture's, keep drawing their block. +func (a *App) terminalCursorOwns(buf *noteBuffer) bool { + if buf == nil || buf != a.activeBuffer() { + return false + } + _, ok := a.bufferCursorShape(buf) + return ok +} + +// insertionCell is the screen cell where the next typed character in buf +// lands: the pane's content origin, the gutter, and the width of the text +// before the cursor on its wrapped row. +func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { + cur := buf.ed.Cursor() + line := buf.ed.LineRunes(cur.Line) + rc := a.contentRect(a.activePane) + for _, row := range buf.rows { + if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd { + continue + } + if cur.Col == row.segEnd && row.segEnd < len(line) { + continue // the cursor starts the next wrapped row + } + return rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col])), rc.y + row.y, true + } + return 0, 0, false +} + +// computeCursor picks the cursor for the frame just drawn. +func (a *App) computeCursor() cursorState { + if a.overlay != nil || a.focus != focusPane { + return cursorState{} + } + t := a.activeTab() + if t == nil || t.view != nil || t.mode == modePreview { + return cursorState{} + } + buf := a.activeBuffer() + shape, ok := a.bufferCursorShape(buf) + if !ok { + return cursorState{} + } + x, y, ok := a.insertionCell(buf) + if !ok { + return cursorState{} + } + return cursorState{visible: true, x: x, y: y, shape: shape} +} diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go new file mode 100644 index 0000000..fdeed53 --- /dev/null +++ b/internal/tui/cursor_test.go @@ -0,0 +1,255 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/cellbuf" + "github.com/muesli/termenv" + + "github.com/ZenNotes/tui/internal/vim" +) + +// cursorApp opens one note holding text in a single pane, with true colour so +// the drawn block cursor can be told apart from other cells. +func cursorApp(t *testing.T, text string, width int) (*App, *noteBuffer) { + t.Helper() + profile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) + a := newSessionParityApp(t, newSessionParityVault(t, "Alpha.md")) + a.openNote("Alpha.md", true) + a.sidebarOpen = false + a.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + buf := a.activeBuffer() + if buf == nil { + t.Fatal("no active buffer") + } + buf.ed.SetText(text) + buf.ed.Feed("i") + return a, buf +} + +type screenCell struct{ x, y int } + +// reverseCells lists the cells the view draws in reverse video. +func reverseCells(view string) []screenCell { + var out []screenCell + for y, row := range strings.Split(view, "\n") { + var style cellbuf.Style + parser := ansi.NewParser() + state, x := byte(0), 0 + for len(row) > 0 { + seq, width, n, next := ansi.DecodeSequence(row, state, parser) + if ansi.HasCsiPrefix(seq) && parser.Command() == 'm' { + cellbuf.ReadStyle(parser.Params(), &style) + } + if width > 0 && style.Attrs&cellbuf.ReverseAttr != 0 { + out = append(out, screenCell{x, y}) + } + x += width + row, state = row[n:], next + } + } + return out +} + +// cellText is the text the view draws in the cell at (x, y). +func cellText(view string, x, y int) string { + rows := strings.Split(ansi.Strip(view), "\n") + return ansi.Cut(rows[y], x, x+1) +} + +// cellText2 is the text of the n cells starting at (x, y), for wide runes. +func cellText2(view string, x, y, n int) string { + rows := strings.Split(ansi.Strip(view), "\n") + return ansi.Cut(rows[y], x, x+n) +} + +func TestInsertCursorBarInInsertMode(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "o" { + t.Fatalf("bar sits on %q, want the cell of %q", got, "o") + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn in insert mode: %v", cells) + } +} + +func TestInsertCursorWrappedLine(t *testing.T) { + text := strings.Repeat("abcdefghij ", 12) + "\n" + a, buf := cursorApp(t, text, 40) + a.View() + if len(buf.rows) < 3 { + t.Fatalf("line did not wrap: %d rows", len(buf.rows)) + } + second := buf.rows[1] + col := second.segStart + 3 + buf.ed.SetCursor(vim.Pos{Line: 0, Col: col}) + view := a.View() + rc := a.contentRect(a.activePane) + if a.cursor.y != rc.y+second.y { + t.Fatalf("bar row = %d, want the second wrapped row %d", a.cursor.y, rc.y+second.y) + } + if want := string([]rune(text)[col]); cellText(view, a.cursor.x, a.cursor.y) != want { + t.Fatalf("bar sits on %q, want %q", cellText(view, a.cursor.x, a.cursor.y), want) + } +} + +func TestInsertCursorWideRune(t *testing.T) { + a, buf := cursorApp(t, "ab日本cd\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 3}) + view := a.View() + if got := cellText2(view, a.cursor.x, a.cursor.y, 2); got != "本" { + t.Fatalf("bar sits on %q, want the first cell of 本", got) + } + if prev := cellText2(view, a.cursor.x-2, a.cursor.y, 2); prev != "日" { + t.Fatalf("two cells left of the bar is %q, want 日", prev) + } +} + +func TestInsertCursorTab(t *testing.T) { + a, buf := cursorApp(t, "a\tb\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 1}) + a.View() + start := a.cursor.x + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + view := a.View() + if a.cursor.x-start != 4 { + t.Fatalf("tab is %d cells wide, want 4", a.cursor.x-start) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "b" { + t.Fatalf("bar after the tab sits on %q, want b", got) + } + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + a.View() + if start-a.cursor.x != 1 { + t.Fatalf("a is %d cells wide, want 1", start-a.cursor.x) + } +} + +func TestInsertCursorPastEndOfLine(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + a.View() + last := a.cursor + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 5}) + view := a.View() + if !a.cursor.visible || a.cursor.x != last.x+1 || a.cursor.y != last.y { + t.Fatalf("cursor = %+v, want the cell after the last character %+v", a.cursor, last) + } + if got := cellText(view, last.x, last.y); got != "o" { + t.Fatalf("last character cell shows %q", got) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn past the end of the line: %v", cells) + } +} + +func TestInsertCursorReplaceModeIsUnderline(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("R") + if buf.ed.Mode() != vim.ModeReplace { + t.Fatalf("mode = %v, want replace", buf.ed.Mode()) + } + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeUnderline { + t.Fatalf("cursor = %+v, want a visible underline", a.cursor) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn in replace mode: %v", cells) + } +} + +func TestInsertCursorBarWithVimModeOff(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = false + buf.ed.SetOptions(a.editorOptions()) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn with vim mode off: %v", cells) + } +} + +// Normal and visual mode keep the drawn block and show no terminal cursor. +func TestInsertCursorNormalAndVisualKeepTheBlock(t *testing.T) { + for _, keys := range []string{"", "v", "V", ""} { + t.Run("keys "+keys, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(keys) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + cells := reverseCells(view) + found := false + for _, c := range cells { + if cellText(view, c.x, c.y) == "o" { + found = true + } + } + if !found { + t.Fatalf("no reverse-video cell on the cursor character; reverse cells: %v", cells) + } + if keys == "" && len(cells) != 1 { + t.Fatalf("normal mode draws %d reverse cells, want 1", len(cells)) + } + }) + } +} + +func TestInsertCursorEscRestoresTheBlock(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + a.View() + if !a.cursor.visible { + t.Fatal("no bar in insert mode") + } + buf.ed.Feed("") + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v after Esc, want hidden", a.cursor) + } + if len(reverseCells(view)) != 1 { + t.Fatalf("block cursor not drawn after Esc: %v", reverseCells(view)) + } +} + +func TestInsertCursorWaitsForTheNoteToLoad(t *testing.T) { + for _, vimMode := range []bool{true, false} { + a, buf := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = vimMode + buf.ed.SetOptions(a.editorOptions()) + buf.loading = true + view := a.View() + if a.cursor.visible { + t.Fatalf("vim=%v: cursor = %+v while loading, want hidden", vimMode, a.cursor) + } + if len(reverseCells(view)) != 1 { + t.Fatalf("vim=%v: block not drawn while loading: %v", vimMode, reverseCells(view)) + } + buf.loading = false + view = a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("vim=%v: cursor = %+v once loaded, want a bar", vimMode, a.cursor) + } + if len(reverseCells(view)) != 0 { + t.Fatalf("vim=%v: block still drawn once loaded", vimMode) + } + } +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index e143cb1..897dafe 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -289,6 +289,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { lines := ed.Lines() blocks := computeBlockState(lines) cur := ed.Cursor() + drawBlock := focused && !a.terminalCursorOwns(buf) vStart, vEnd, vMode, inVisual := ed.VisualRange() blockLeft, blockRight, blockToEnd := 0, 0, false if inVisual && vMode == vim.ModeVisualBlock { @@ -382,7 +383,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { key.search = true } } - if focused && line == cur.Line && col == cur.Col { + if drawBlock && line == cur.Line && col == cur.Col { key.cursor = true } // Merge runs of identical style. @@ -403,7 +404,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { k2.search = true } } - if focused && line == cur.Line && j == cur.Col { + if drawBlock && line == cur.Line && j == cur.Col { k2.cursor = true } if k2 != key { @@ -422,7 +423,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { cells += cellWidth(string(run)) col = j } - if focused && line == cur.Line && cur.Col >= seg.end && si == len(segs)-1 { + if drawBlock && line == cur.Line && cur.Col >= seg.end && si == len(segs)-1 { b.WriteString(a.styleFor(styleKey{cursor: true}).Render(" ")) cells++ } From 481b47bc663ab3eeab41ee649248a86846d68105 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:54 -0500 Subject: [PATCH 29/65] Feat(tui): the cursor finds its cell in every pane layout The cursor follows typing, arrow keys, backspace and mouse clicks, and lands on the character under it in a single pane, either split, with the sidebar or a side panel open, with tabs hidden, in zen mode and in split note mode, and again after a resize rewraps the line. A buffer's wrapped rows are shared by every pane that draws it, and the last pane drawn wins. With one note open in two panes of different widths, a focused pane drawn first would have placed the cursor using the other pane's wrapping, on a blank cell. The buffer now keeps a copy of the rows taken when the focused pane draws it, and the cursor reads that copy. Tests: the cursor through editing and mouse moves, across the layouts above, in the second pane of each split, after a resize, and with the same wrapping note in two unequal panes with either pane focused. The normal and visual mode block is compared byte for byte with the cursor style, so a change to how it looks fails. Co-Authored-By: Claude Opus 5.5 --- internal/tui/buffers.go | 3 + internal/tui/cursor.go | 5 +- internal/tui/cursor_test.go | 154 ++++++++++++++++++++++++++++++++++++ internal/tui/editorview.go | 3 + 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index cbad444..df4db83 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -30,6 +30,9 @@ type noteBuffer struct { afterLoad []func(*noteBuffer) loadErr error rows []rowInfo + // focusRows is the wrapping of the pane that last drew this buffer with + // focus; rows belongs to whichever pane drew it last. + focusRows []rowInfo words int wordsVersion int diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 662ee31..7c3b735 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -35,12 +35,13 @@ func (a *App) terminalCursorOwns(buf *noteBuffer) bool { // insertionCell is the screen cell where the next typed character in buf // lands: the pane's content origin, the gutter, and the width of the text -// before the cursor on its wrapped row. +// before the cursor on its wrapped row, all taken from the focused pane's own +// layout. func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { cur := buf.ed.Cursor() line := buf.ed.LineRunes(cur.Line) rc := a.contentRect(a.activePane) - for _, row := range buf.rows { + for _, row := range buf.focusRows { if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd { continue } diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go index fdeed53..0ee12f9 100644 --- a/internal/tui/cursor_test.go +++ b/internal/tui/cursor_test.go @@ -206,6 +206,12 @@ func TestInsertCursorNormalAndVisualKeepTheBlock(t *testing.T) { if !found { t.Fatalf("no reverse-video cell on the cursor character; reverse cells: %v", cells) } + // The block is the character rendered with the cursor style (plus + // the selection background inside a visual range), unchanged. + want := a.styleFor(styleKey{cursor: true, visual: keys != ""}).Render("o") + if !strings.Contains(view, want) { + t.Fatalf("block cell is not drawn as %q", want) + } if keys == "" && len(cells) != 1 { t.Fatalf("normal mode draws %d reverse cells, want 1", len(cells)) } @@ -253,3 +259,151 @@ func TestInsertCursorWaitsForTheNoteToLoad(t *testing.T) { } } } + +// assertBarOnChar fails unless the bar is visible and sits on the cell where +// the view draws the character at the buffer's cursor. +func assertBarOnChar(t *testing.T, name string, a *App, buf *noteBuffer, view string) { + t.Helper() + cur := buf.ed.Cursor() + want := string(buf.ed.LineRunes(cur.Line)[cur.Col]) + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("%s: cursor = %+v, want a visible bar", name, a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != want { + t.Fatalf("%s: bar at (%d,%d) sits on %q, want %q", name, a.cursor.x, a.cursor.y, got, want) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("%s: reverse-video cells drawn: %v", name, cells) + } +} + +func TestInsertCursorLayoutFollowsEditing(t *testing.T) { + a, buf := cursorApp(t, "first line\nsecond line\nthird line\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + a.View() + start := a.cursor + + a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("xyz")}) + view := a.View() + if a.cursor.x != start.x+3 || a.cursor.y != start.y { + t.Fatalf("after typing the bar is %+v, want 3 cells right of %+v", a.cursor, start) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "f" { + t.Fatalf("after typing the bar sits on %q, want f", got) + } + + a.Update(tea.KeyMsg{Type: tea.KeyDown}) + a.View() + if a.cursor.y != start.y+1 { + t.Fatalf("after Down the bar row is %d, want %d", a.cursor.y, start.y+1) + } + a.Update(tea.KeyMsg{Type: tea.KeyRight}) + view = a.View() + assertBarOnChar(t, "after arrows", a, buf, view) + + a.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + view = a.View() + assertBarOnChar(t, "after a deletion", a, buf, view) + + target := screenCell{x: start.x + 5, y: start.y + 2} + a.Update(tea.MouseMsg{X: target.x, Y: target.y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + view = a.View() + if a.cursor.x != target.x || a.cursor.y != target.y { + t.Fatalf("after a click the bar is %+v, want %+v", a.cursor, target) + } + assertBarOnChar(t, "after a click", a, buf, view) +} + +func TestInsertCursorLayoutAcrossLayouts(t *testing.T) { + cases := []struct { + name string + setup func(a *App) + }{ + {"single pane", func(a *App) {}}, + {"vertical split", func(a *App) { a.splitPane(true) }}, + {"horizontal split", func(a *App) { a.splitPane(false) }}, + {"sidebar open", func(a *App) { a.sidebarOpen = true }}, + {"side panel open", func(a *App) { a.outlineOpen = true }}, + {"tabs hidden", func(a *App) { a.prefs.TabsOff = true }}, + {"zen mode", func(a *App) { a.zen = true }}, + {"split note mode", func(a *App) { a.setPaneMode(modeSplit) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, "alpha beta\nline with Qcursor here\nomega\n", 100) + c.setup(a) + a.layout() + buf.ed.SetCursor(vim.Pos{Line: 1, Col: 10}) + assertBarOnChar(t, c.name, a, buf, a.View()) + }) + } +} + +func TestInsertCursorLayoutSecondPaneFocused(t *testing.T) { + for _, vertical := range []bool{true, false} { + a, buf := cursorApp(t, "alpha beta\nline with Qcursor here\nomega\n", 100) + first := a.activePane + a.splitPane(vertical) + if a.activePane == first { + t.Fatalf("vertical=%v: split did not move focus", vertical) + } + a.openNote("Alpha.md", true) + a.layout() + buf = a.activeBuffer() + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 1, Col: 10}) + assertBarOnChar(t, "second pane", a, buf, a.View()) + if rc := a.contentRect(a.activePane); a.cursor.x < rc.x || a.cursor.y < rc.y { + t.Fatalf("vertical=%v: bar %+v outside the focused pane %+v", vertical, a.cursor, rc) + } + } +} + +func TestInsertCursorLayoutFollowsResize(t *testing.T) { + text := strings.Repeat("abcdefghi ", 10) + "\n" + a, buf := cursorApp(t, text, 120) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 95}) + view := a.View() + assertBarOnChar(t, "wide", a, buf, view) + wide := a.cursor + + a.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + view = a.View() + if a.cursor == wide { + t.Fatalf("bar stayed at %+v after the line rewrapped", wide) + } + if a.cursor.y <= wide.y { + t.Fatalf("bar row %d, want below %d once the line rewraps", a.cursor.y, wide.y) + } + assertBarOnChar(t, "narrow", a, buf, view) +} + +// With the same note open in two panes of different sizes, the bar belongs to +// the focused pane's own wrapping whichever pane is painted first. +func TestInsertCursorLayoutFirstPaneFocusedUnequalPanes(t *testing.T) { + text := strings.Repeat("abcdefghi ", 20) + "\n" + for _, vertical := range []bool{true, false} { + for _, focusFirst := range []bool{true, false} { + a, _ := cursorApp(t, text, 100) + first := a.activePane + a.splitPane(vertical) + if a.activePane == first { + t.Fatalf("vertical=%v: split did not move focus", vertical) + } + a.openNote("Alpha.md", true) + a.resizePane(0.3) + a.layout() + if focusFirst { + a.activePane = first + } + buf := a.activeBuffer() + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 85}) + name := "first pane" + if !focusFirst { + name = "second pane" + } + assertBarOnChar(t, name, a, buf, a.View()) + } + } +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index 897dafe..be6d3d1 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -444,6 +444,9 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { for len(out) < h { out = append(out, padRight(th.Muted.Render("~"), w)) } + if focused { + buf.focusRows = append(buf.focusRows[:0], buf.rows...) + } return strings.Join(out, "\n") } From 49237b9d5b20044be8813c7301c96cbc6d99db81 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:55 -0500 Subject: [PATCH 30/65] Feat(tui): an underline while r waits for its character nvim shows an underline while `r` waits for the replacement character, which tells the user the next key replaces rather than moves. zn now does the same: while the editor reports a pending `r`, the terminal cursor is an underline on the character to be replaced, and once the character or Esc arrives the drawn block comes back. In visual mode `r` keeps the block. Tests: `r` and `3r` show the underline on the cursor character with no reverse cell; `rx`, `r` and `2rx` end with one block and no terminal cursor; `v` then `r` keeps the block. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursor.go | 5 ++- internal/tui/cursor_replace_test.go | 70 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 internal/tui/cursor_replace_test.go diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 7c3b735..5f8373f 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -3,7 +3,7 @@ package tui import "github.com/ZenNotes/tui/internal/vim" // bufferCursorShape is the terminal cursor shape the buffer wants while its -// text is being edited. ok is false when the buffer keeps the drawn block +// text is being edited, or a normal-mode r waits for its character. ok is false when the buffer keeps the drawn block // instead: in normal and visual mode, in the command line, and while the // note is still loading. func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { @@ -19,6 +19,9 @@ func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { case vim.ModeReplace: return cursorShapeUnderline, true } + if buf.ed.ReplacePending() { + return cursorShapeUnderline, true + } return 0, false } diff --git a/internal/tui/cursor_replace_test.go b/internal/tui/cursor_replace_test.go new file mode 100644 index 0000000..0df9433 --- /dev/null +++ b/internal/tui/cursor_replace_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/vim" +) + +// While a normal-mode r waits for its character the cursor is an underline at +// the cursor cell and no block is drawn. +func TestReplaceCharCursorUnderlineWhileWaiting(t *testing.T) { + for _, keys := range []string{"r", "3r"} { + t.Run("keys "+keys, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(keys) + if buf.ed.Mode() != vim.ModeNormal { + t.Fatalf("mode = %v, want normal", buf.ed.Mode()) + } + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeUnderline { + t.Fatalf("cursor = %+v, want a visible underline", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "o" { + t.Fatalf("underline on %q, want the cursor character", got) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn while r waits: %v", cells) + } + }) + } +} + +// The character, or Esc, ends the wait: the cursor hides and the block returns. +func TestReplaceCharCursorEndsWithCharOrEsc(t *testing.T) { + for _, tc := range []struct{ name, keys string }{ + {"character", "rx"}, + {"escape", "r"}, + {"counted character", "2rx"}, + } { + t.Run(tc.name, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(tc.keys) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if cells := reverseCells(view); len(cells) != 1 { + t.Fatalf("block cells = %v, want exactly one", cells) + } + }) + } +} + +// r typed in visual mode keeps the block, as every visual-mode key does. +func TestReplaceCharCursorVisualKeepsTheBlock(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("v") + buf.ed.Feed("r") + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("no block drawn in visual mode") + } +} From 0c97bab2a1c624e7049b81c82bd2fc5f43b28b3e Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:56 -0500 Subject: [PATCH 31/65] Feat(tui): a bar on the command line The `:`, `/` and `?` lines drew the same reverse-video cell as the editor. They now show the bar after the typed text, following Left and Ctrl+B (start of line), over wide runes too, and the wildmenu after the text does not move it. When the line closes with Enter or Esc, the buffer's own cursor rules apply again. Tests: the bar after empty and typed text for each of `:`, `/` and `?` with no reverse cell on the line; after Left once, twice over a wide rune, and after Ctrl+B; and the buffer's block after Esc and Enter. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 8 ++- internal/tui/cursor.go | 3 ++ internal/tui/cursor_cmdline_test.go | 79 +++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 internal/tui/cursor_cmdline_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index d9ae958..af72989 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -149,6 +149,10 @@ type App struct { // cursor is where the terminal cursor belongs in the last frame drawn. cursor cursorState + // cmdlineX is the column of the next typed character on the command + // line, set by renderCommandLine while a command line is open. + cmdlineX int + cmdlineOpen bool systemDark bool exHistory []string @@ -1014,6 +1018,7 @@ func (a *App) vaultLabel() string { func (a *App) renderCommandLine() string { t := a.theme + a.cmdlineOpen = false if buf := a.activeBuffer(); buf != nil && a.focus == focusPane { if kind, text, cursor, active := buf.ed.Cmdline(); active { runes := []rune(text) @@ -1022,7 +1027,8 @@ func (a *App) renderCommandLine() string { if cursor < len(runes) { after = string(runes[cursor:]) } - line := string(kind) + before + t.Cursor.Render(firstCellOr(after, " ")) + restAfterFirst(after) + a.cmdlineX, a.cmdlineOpen = cellWidth(string(kind)+before), true + line := string(kind) + before + after if comps, idx := buf.ed.Completions(); len(comps) > 0 { line += " " + a.renderWildmenu(comps, idx) } diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 5f8373f..59a7687 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -61,6 +61,9 @@ func (a *App) computeCursor() cursorState { if a.overlay != nil || a.focus != focusPane { return cursorState{} } + if a.cmdlineOpen { + return cursorState{visible: true, x: a.cmdlineX, y: a.height - 1, shape: cursorShapeBar} + } t := a.activeTab() if t == nil || t.view != nil || t.mode == modePreview { return cursorState{} diff --git a/internal/tui/cursor_cmdline_test.go b/internal/tui/cursor_cmdline_test.go new file mode 100644 index 0000000..6aa983c --- /dev/null +++ b/internal/tui/cursor_cmdline_test.go @@ -0,0 +1,79 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// lastRow is the screen row of the command line. +func lastRow(a *App) int { return a.height - 1 } + +func TestCmdlineCursorAfterText(t *testing.T) { + for _, kind := range []string{":", "/", "?"} { + for _, text := range []string{"", "wq"} { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("" + kind + text) + view := a.View() + want := 1 + len(text) + if !a.cursor.visible || a.cursor.shape != cursorShapeBar || + a.cursor.x != want || a.cursor.y != lastRow(a) { + t.Fatalf("%q %q: cursor = %+v, want a bar at (%d,%d)", kind, text, a.cursor, want, lastRow(a)) + } + for _, c := range reverseCells(view) { + if c.y == lastRow(a) { + t.Fatalf("%q %q: reverse cell in the command line at %v", kind, text, c) + } + } + if got := ansi.Strip(strings.Split(view, "\n")[lastRow(a)]); !strings.HasPrefix(got, kind+text) { + t.Fatalf("command line = %q", got) + } + } + } +} + +func TestCmdlineCursorMovedLeft(t *testing.T) { + cases := []struct { + name string + keys string + x int + on string + }{ + {"left once", "", 1 + 2 + 2, "c"}, + {"left twice, wide rune", "", 1 + 2, "日"}, + {"ctrl+b to the start", "", 1, "a"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed(":ab日c" + c.keys) + view := a.View() + if !a.cursor.visible || a.cursor.x != c.x || a.cursor.y != lastRow(a) { + t.Fatalf("cursor = %+v, want x %d on the last row", a.cursor, c.x) + } + if got := cellText2(view, a.cursor.x, a.cursor.y, cellWidth(c.on)); got != c.on { + t.Fatalf("bar on %q, want %q", got, c.on) + } + for _, cell := range reverseCells(view) { + if cell.y == lastRow(a) { + t.Fatalf("reverse cell in the command line at %v", cell) + } + } + }) + } +} + +func TestCmdlineCursorClosed(t *testing.T) { + for _, key := range []string{"", ""} { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed(":" + key) + view := a.View() + if a.cursor.visible { + t.Fatalf("%s: cursor = %+v, want hidden", key, a.cursor) + } + if cells := reverseCells(view); len(cells) == 0 { + t.Fatalf("%s: no block cursor drawn in the buffer", key) + } + } +} From 7d9cc36577c8a45b75573e18664ae621b2ba55cf Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:57 -0500 Subject: [PATCH 32/65] Fix(tui): keep the terminal cursor inside the editor and on screen The drawn block used to be clipped where it did not fit, so nobody saw it. The terminal cursor is not clipped: with word wrap off, a long line put the bar on the pane frame, a neighbouring split, the side panel or the preview half in split note mode, and a full wrapped row put the end-of-line bar one cell past the text. A command line longer than the terminal sent the cursor off screen, where the terminal pins it to the last column over other text. The focused pane now records the editor's drawn width next to its rows, and the cursor is clamped to the last cell of that width rather than hidden, so the bar at the end of a full row stays visible. The command line's cursor is clamped to the last column. Tests, written red first: word wrap off in a single pane, a vertical split, split note mode and with the side panel open; a full row with wrap on in a single pane and in split note mode; and a command line wider than the terminal. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 2 +- internal/tui/buffers.go | 2 + internal/tui/cursor.go | 13 ++++-- internal/tui/cursor_cmdline_test.go | 9 ++++ internal/tui/cursor_test.go | 64 +++++++++++++++++++++++++++++ internal/tui/editorview.go | 1 + 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index af72989..96ed938 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1027,7 +1027,7 @@ func (a *App) renderCommandLine() string { if cursor < len(runes) { after = string(runes[cursor:]) } - a.cmdlineX, a.cmdlineOpen = cellWidth(string(kind)+before), true + a.cmdlineX, a.cmdlineOpen = min(cellWidth(string(kind)+before), max(0, a.width-1)), true line := string(kind) + before + after if comps, idx := buf.ed.Completions(); len(comps) > 0 { line += " " + a.renderWildmenu(comps, idx) diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index df4db83..7c7a55d 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -33,6 +33,8 @@ type noteBuffer struct { // focusRows is the wrapping of the pane that last drew this buffer with // focus; rows belongs to whichever pane drew it last. focusRows []rowInfo + // focusW is the drawn width of that editor, gutter included. + focusW int words int wordsVersion int diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 59a7687..6130de9 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -3,9 +3,10 @@ package tui import "github.com/ZenNotes/tui/internal/vim" // bufferCursorShape is the terminal cursor shape the buffer wants while its -// text is being edited, or a normal-mode r waits for its character. ok is false when the buffer keeps the drawn block -// instead: in normal and visual mode, in the command line, and while the -// note is still loading. +// text is being edited, or a normal-mode r waits for its character. ok is false +// when the buffer keeps the drawn block instead: in vim normal and visual mode, +// and while the note is still loading. It does not know about the command +// line; computeCursor handles that. func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { if buf == nil || buf.loading { return 0, false @@ -51,7 +52,11 @@ func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { if cur.Col == row.segEnd && row.segEnd < len(line) { continue // the cursor starts the next wrapped row } - return rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col])), rc.y + row.y, true + x = rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col])) + if buf.focusW > 0 { + x = min(x, rc.x+buf.focusW-1) // keep the bar inside the editor + } + return x, rc.y + row.y, true } return 0, 0, false } diff --git a/internal/tui/cursor_cmdline_test.go b/internal/tui/cursor_cmdline_test.go index 6aa983c..afd8c0b 100644 --- a/internal/tui/cursor_cmdline_test.go +++ b/internal/tui/cursor_cmdline_test.go @@ -77,3 +77,12 @@ func TestCmdlineCursorClosed(t *testing.T) { } } } + +func TestCmdlineCursorWiderThanTerminal(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 40) + buf.ed.Feed(":" + strings.Repeat("a", 100)) + a.View() + if a.cursor.visible && (a.cursor.x < 0 || a.cursor.x >= a.width) { + t.Fatalf("cursor = %+v, want a column inside the %d-wide terminal", a.cursor, a.width) + } +} diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go index 0ee12f9..9d27388 100644 --- a/internal/tui/cursor_test.go +++ b/internal/tui/cursor_test.go @@ -407,3 +407,67 @@ func TestInsertCursorLayoutFirstPaneFocusedUnequalPanes(t *testing.T) { } } } + +// The bar never leaves the editor's drawn text area: with word wrap off, a +// long line runs past the pane, and with it on, an exactly full row puts the +// end-of-line cell one past the text. +func TestInsertCursorLayoutStaysInsideEditor(t *testing.T) { + cases := []struct { + name string + wrap bool + setup func(a *App) + col int + }{ + {"wrap off, single pane", false, func(a *App) {}, 150}, + {"wrap off, vertical split", false, func(a *App) { a.splitPane(true) }, 150}, + {"wrap off, split note mode", false, func(a *App) { a.setPaneMode(modeSplit) }, 150}, + {"wrap off, side panel", false, func(a *App) { a.outlineOpen = true }, 150}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, strings.Repeat("x", 200)+"\n", 100) + a.prefs.WordWrap = c.wrap + c.setup(a) + a.layout() + buf.ed.SetCursor(vim.Pos{Line: 0, Col: c.col}) + a.View() + assertCursorInEditor(t, a) + }) + } + t.Run("wrap on, exact width row", func(t *testing.T) { + for _, split := range []bool{false, true} { + a, buf := cursorApp(t, "x\n", 100) + a.prefs.WordWrap = true + if split { + a.setPaneMode(modeSplit) + } + a.layout() + a.View() + rc := a.contentRect(a.activePane) + w := rc.w + if split { + w /= 2 + } + textW := w - a.gutterWidth(buf) + buf.ed.SetText(strings.Repeat("x", textW) + "\n") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: textW}) + a.View() + assertCursorInEditor(t, a) + } + }) +} + +func assertCursorInEditor(t *testing.T, a *App) { + t.Helper() + if !a.cursor.visible { + return // hidden is acceptable + } + rc := a.contentRect(a.activePane) + w := rc.w + if t2 := a.activeTab(); t2 != nil && t2.mode == modeSplit { + w /= 2 + } + if a.cursor.x < rc.x || a.cursor.x >= rc.x+w { + t.Fatalf("bar x %d outside the editor columns [%d,%d)", a.cursor.x, rc.x, rc.x+w) + } +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index be6d3d1..f5a8bba 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -446,6 +446,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { } if focused { buf.focusRows = append(buf.focusRows[:0], buf.rows...) + buf.focusW = w } return strings.Join(out, "\n") } From 986d91b276eabe23b60642c5ad435fba4ff6a236 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:59 -0500 Subject: [PATCH 33/65] Feat(tui): a bar in text fields and quick capture Anywhere text is typed outside a note, the field drew its own reverse-video cell. The palette, the pickers, prompts including the masked password prompt, the ex prompt, database cell and date editing, and the task due and field prompts now report their cursor cell, and the terminal shows the bar there: after the typed text, after the last dot in a masked prompt, at the start of an empty field with a placeholder, and clamped inside the box for long input. A field opened over a note being edited takes the bar; closing it hands the cursor back to the note. Quick capture is a small vim editor, so it follows the note rules: bar in insert mode or with vim mode off, underline in replace mode, block in normal mode. Its title can wrap, so its height is measured. Tests: each field above, a placeholder in an empty palette and picker, a long input, a prompt over a note in insert mode, Esc back to a note in normal mode, and quick capture in insert mode and with vim mode off. Co-Authored-By: Claude Opus 5.5 --- internal/tui/buffers.go | 3 + internal/tui/cursor.go | 25 +++-- internal/tui/cursor_field_test.go | 168 ++++++++++++++++++++++++++++++ internal/tui/editorview.go | 15 ++- internal/tui/overlays.go | 76 +++++++++++--- internal/tui/pick_overlay.go | 9 +- 6 files changed, 269 insertions(+), 27 deletions(-) create mode 100644 internal/tui/cursor_field_test.go diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index 7c7a55d..1bf3985 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -35,6 +35,9 @@ type noteBuffer struct { focusRows []rowInfo // focusW is the drawn width of that editor, gutter included. focusW int + // standalone marks an editor drawn outside the tabs, such as quick + // capture's, whose cursor its host places. + standalone bool words int wordsVersion int diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 6130de9..508e084 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -28,9 +28,9 @@ func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { // terminalCursorOwns reports whether the terminal's own cursor stands in for // the drawn block in buf. Only the active tab's buffer is placed that way; -// other editors, such as quick capture's, keep drawing their block. +// a standalone editor, such as quick capture's, is placed by its host. func (a *App) terminalCursorOwns(buf *noteBuffer) bool { - if buf == nil || buf != a.activeBuffer() { + if buf == nil || (!buf.standalone && buf != a.activeBuffer()) { return false } _, ok := a.bufferCursorShape(buf) @@ -40,11 +40,10 @@ func (a *App) terminalCursorOwns(buf *noteBuffer) bool { // insertionCell is the screen cell where the next typed character in buf // lands: the pane's content origin, the gutter, and the width of the text // before the cursor on its wrapped row, all taken from the focused pane's own -// layout. -func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { +// layout. rc is the origin of the area buf was drawn in. +func (a *App) insertionCell(buf *noteBuffer, rc rect) (x, y int, ok bool) { cur := buf.ed.Cursor() line := buf.ed.LineRunes(cur.Line) - rc := a.contentRect(a.activePane) for _, row := range buf.focusRows { if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd { continue @@ -63,7 +62,19 @@ func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { // computeCursor picks the cursor for the frame just drawn. func (a *App) computeCursor() cursorState { - if a.overlay != nil || a.focus != focusPane { + if a.overlay != nil { + f, ok := a.overlay.(cursorField) + if !ok { + return cursorState{} + } + c := f.fieldCursor() + if c.visible { + c.x += a.overlayRect.x + c.y += a.overlayRect.y + } + return c + } + if a.focus != focusPane { return cursorState{} } if a.cmdlineOpen { @@ -78,7 +89,7 @@ func (a *App) computeCursor() cursorState { if !ok { return cursorState{} } - x, y, ok := a.insertionCell(buf) + x, y, ok := a.insertionCell(buf, a.contentRect(a.activePane)) if !ok { return cursorState{} } diff --git a/internal/tui/cursor_field_test.go b/internal/tui/cursor_field_test.go new file mode 100644 index 0000000..65654a9 --- /dev/null +++ b/internal/tui/cursor_field_test.go @@ -0,0 +1,168 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +// assertFieldBar checks the cursor is a visible bar on the cell where the +// view draws want, and that no reverse-video cell sits on its row. +func assertFieldBar(t *testing.T, a *App, view, want string) { + t.Helper() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != want { + t.Fatalf("bar sits on %q, want the cell of %q", got, want) + } + for _, c := range reverseCells(view) { + if c.y == a.cursor.y { + t.Fatalf("reverse-video cell drawn in the field at %v", c) + } + } +} + +func TestTextFieldCursorPalette(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openNoteSearch("abc") + view := a.View() + assertFieldBar(t, a, view, " ") + if cellText(view, a.cursor.x-1, a.cursor.y) != "c" { + t.Fatal("bar is not right after the typed text") + } + a.overlay.handleKey(a, vim.Key{Name: "left"}) + assertFieldBar(t, a, a.View(), "c") +} + +func TestTextFieldCursorPlaceholder(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openNoteSearch("") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x+1, a.cursor.y); got != "T" { + t.Fatalf("placeholder starts at %q, want T", got) + } + a.overlay = newPickOverlay("Tags", "Filter options", []pickItem{{id: "a", label: "a"}}, nil) + view = a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x+1, a.cursor.y); got != "F" { + t.Fatalf("picker placeholder starts at %q, want F", got) + } +} + +func TestTextFieldCursorSecretPrompt(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.promptSecret("Token", "", func(*App, string) {}) + typeText(a, "abc") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "•" { + t.Fatalf("cell before the bar is %q, want the third dot", got) + } +} + +func TestTextFieldCursorClampedToFieldWidth(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 40) + a.promptFor("Name", "", "", func(*App, string) {}) + typeText(a, "this text is a lot longer than the prompt is wide") + a.View() + box := a.overlayRect + if !a.cursor.visible || a.cursor.x >= box.x+box.w || a.cursor.x <= box.x { + t.Fatalf("cursor = %+v escapes the overlay %+v", a.cursor, box) + } +} + +func TestTextFieldCursorPromptBeatsInsertionPoint(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + a.View() + insertion := a.cursor + a.promptFor("Name", "ab", "", func(*App, string) {}) + view := a.View() + assertFieldBar(t, a, view, " ") + if a.cursor == insertion { + t.Fatalf("bar stayed at the insertion point %+v", insertion) + } +} + +func TestTextFieldCursorEscapeBackToNormalBuffer(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("") + a.promptFor("Name", "ab", "", func(*App, string) {}) + a.View() + if !a.cursor.visible { + t.Fatal("no bar in the prompt") + } + a.overlay.handleKey(a, vim.Key{Name: "esc"}) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("the buffer's block is not drawn") + } +} + +func TestTextFieldCursorCellAndTaskPrompts(t *testing.T) { + prompts := map[string]func(a *App){ + "date": func(a *App) { a.datePrompt("Due", "2026-11-01", func(*App, string) {}) }, + "number": func(a *App) { a.numberPrompt("Count", "42", func(*App, string) {}) }, + "text": func(a *App) { a.textPrompt("Notes", "abc", "[[ links a note", func(*App, string) {}) }, + "due": func(a *App) { a.taskDuePrompt(vault.Task{Due: "2026-11-01"}) }, + "field": func(a *App) { a.taskFieldPrompt(vault.Task{}) }, + } + for name, open := range prompts { + t.Run(name, func(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + open(a) + p := a.overlay.(*prompt) + view := a.View() + assertFieldBar(t, a, view, " ") + if p.input.cursor > 0 { + want := string(p.input.runes[p.input.cursor-1]) + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != want { + t.Fatalf("cell before the bar is %q, want %q", got, want) + } + } + }) + } +} + +func TestTextFieldCursorExPrompt(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.overlay = &exPrompt{input: newTextInput("set")} + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "t" { + t.Fatalf("cell before the bar is %q, want t", got) + } +} + +func TestTextFieldCursorQuickCapture(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openQuickCapture() + typeText(a, "ab") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "b" { + t.Fatalf("cell before the bar is %q, want b", got) + } + a.overlay.handleKey(a, vim.Key{Name: "esc"}) + view = a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v in normal mode, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("capture's block is not drawn in normal mode") + } +} + +func TestTextFieldCursorQuickCaptureVimOff(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = false + a.openQuickCapture() + typeText(a, "ab") + assertFieldBar(t, a, a.View(), " ") +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index f5a8bba..fd92798 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -513,13 +513,22 @@ func inVisualRange(line, col int, start, end vim.Pos, mode vim.Mode, bl, br int, } // renderPlainEditor draws a small editor (quick capture) without gutter. -func renderPlainEditor(a *App, ed *vim.Editor, w, h int, focused bool) string { - tmp := ¬eBuffer{ed: ed} +// It also returns the cursor it wants, as a cell inside the drawn block. +func renderPlainEditor(a *App, ed *vim.Editor, w, h int, focused bool) (string, cursorState) { + tmp := ¬eBuffer{ed: ed, standalone: true} saved := a.prefs.LineNumberMode a.prefs.LineNumberMode = "off" out := a.renderEditor(tmp, w, h, focused) a.prefs.LineNumberMode = saved - return out + shape, ok := a.bufferCursorShape(tmp) + if !ok { + return out, cursorState{} + } + x, y, ok := a.insertionCell(tmp, rect{}) + if !ok { + return out, cursorState{} + } + return out, cursorState{visible: true, x: x, y: y, shape: shape} } // editorHintTargets labels every link on the visible rows of a buffer. diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index a5f61fe..47be95b 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -95,14 +95,37 @@ func (t *textInput) handle(k vim.Key) bool { return true } -func (t *textInput) render(th Theme, width int) string { - before := string(t.runes[:t.cursor]) - after := "" - if t.cursor < len(t.runes) { - after = string(t.runes[t.cursor:]) - } - line := before + th.Cursor.Render(firstCellOr(after, " ")) + restAfterFirst(after) - return padRight(line, width) +// render draws the text padded to width. The cursor is not drawn: the +// terminal's own cursor is placed at cell instead. +func (t *textInput) render(width int) string { + return padRight(string(t.runes), width) +} + +// cell is the column, within a field width cells wide, of the next typed +// character; a cursor past the field's right edge stays on its last cell. +func (t *textInput) cell(width int) int { + return min(cellWidth(string(t.runes[:t.cursor])), max(width-1, 0)) +} + +// cursorField is an overlay with its own text field. fieldCursor is the +// cursor the field wants, with its cell relative to the overlay box and +// taken from the last render; it is hidden when the field has none. +type cursorField interface { + fieldCursor() cursorState +} + +// overlayInset is the offset of an overlay's content inside its box: the +// border plus the padding. +func (a *App) overlayInset() (x, y int) { + s := a.theme.Overlay + return s.GetBorderLeftSize() + s.GetPaddingLeft(), s.GetBorderTopSize() + s.GetPaddingTop() +} + +// fieldAt is the bar for a field whose first cell is at column col of the +// given content row of an overlay. +func (a *App) fieldAt(col, row int) cursorState { + ix, iy := a.overlayInset() + return cursorState{visible: true, x: ix + col, y: iy + row, shape: cursorShapeBar} } // --- palette --- @@ -122,6 +145,7 @@ type palette struct { title string placeholder string input textInput + cur cursorState items []paletteItem filtered []paletteItem cursor int @@ -249,6 +273,8 @@ func (p *palette) currentID() string { return p.filtered[p.cursor].id } +func (p *palette) fieldCursor() cursorState { return p.cur } + func (p *palette) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(40, w*2/3)) @@ -262,9 +288,11 @@ func (p *palette) render(a *App, w, h int) string { } lines := []string{th.OverlayTitle.Render(p.title)} prompt := "› " - lines = append(lines, prompt+p.input.render(th, inner-lipgloss.Width(prompt))) + fieldW := inner - lipgloss.Width(prompt) + lines = append(lines, prompt+p.input.render(fieldW)) + p.cur = a.fieldAt(lipgloss.Width(prompt)+p.input.cell(fieldW), 1) if p.input.String() == "" && p.placeholder != "" { - lines[len(lines)-1] = prompt + th.Cursor.Render(" ") + th.Muted.Render(padRight(p.placeholder, inner-3)) + lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } lines = append(lines, th.Muted.Render(strings.Repeat("─", inner))) if len(p.filtered) == 0 { @@ -330,8 +358,11 @@ type prompt struct { onLink func(a *App, p *prompt) // masked hides what is typed: tokens and other secrets. masked bool + cur cursorState } +func (p *prompt) fieldCursor() cursorState { return p.cur } + func (a *App) promptFor(title, initial, help string, onSubmit func(a *App, text string)) { a.overlay = &prompt{title: title, input: newTextInput(initial), help: help, onSubmit: onSubmit} } @@ -361,10 +392,12 @@ func (p *prompt) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(40, w/2)) inner := width - 2 - field := p.input.render(th, inner) + field := p.input.render(inner) + p.cur = a.fieldAt(p.input.cell(inner), 1) if p.masked { dots := strings.Repeat("•", len(p.input.runes)) - field = padRight(dots+th.Cursor.Render(" "), inner) + field = padRight(dots+" ", inner) + p.cur = a.fieldAt(min(len(p.input.runes), max(inner-1, 0)), 1) } lines := []string{th.OverlayTitle.Render(p.title), field} if p.help != "" { @@ -505,9 +538,12 @@ func (m *menu) render(a *App, w, h int) string { // --- quick capture --- type captureOverlay struct { - ed *vim.Editor + ed *vim.Editor + cur cursorState } +func (c *captureOverlay) fieldCursor() cursorState { return c.cur } + func (a *App) openQuickCapture() { opts := a.editorOptions() ed := vim.New("", opts, vim.Hooks{}) @@ -546,7 +582,13 @@ func (c *captureOverlay) render(a *App, w, h int) string { lines := []string{th.OverlayTitle.Render("Quick capture") + th.Muted.Render(" first line becomes the title · Ctrl+S save · Esc cancel")} c.ed.SetViewport(8) c.ed.EnsureCursorVisible() - body := renderPlainEditor(a, c.ed, inner, 8, true) + body, edCur := renderPlainEditor(a, c.ed, inner, 8, true) + c.cur = cursorState{} + if edCur.visible { + ix, iy := a.overlayInset() + titleRows := lipgloss.Height(lipgloss.NewStyle().Width(inner).Render(lines[0])) + c.cur = cursorState{visible: true, x: ix + edCur.x, y: iy + titleRows + edCur.y, shape: edCur.shape} + } lines = append(lines, strings.Split(body, "\n")...) mode := c.ed.Mode().Label() if !a.prefs.VimMode { @@ -767,8 +809,11 @@ type exPrompt struct { compBase string histIdx int saved string + cur cursorState } +func (p *exPrompt) fieldCursor() cursorState { return p.cur } + func (p *exPrompt) resetCompletion() { p.completions = nil p.compIdx = -1 @@ -866,7 +911,8 @@ func (p *exPrompt) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(50, w*2/3)) inner := width - 2 - lines := []string{th.OverlayTitle.Render(":") + " " + p.input.render(th, inner-2)} + p.cur = a.fieldAt(2+p.input.cell(inner-2), 0) + lines := []string{th.OverlayTitle.Render(":") + " " + p.input.render(inner-2)} if len(p.completions) > 1 { parts := []string{} shown := 0 diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index e30a188..0e36333 100644 --- a/internal/tui/pick_overlay.go +++ b/internal/tui/pick_overlay.go @@ -24,6 +24,7 @@ type pickOverlay struct { title string placeholder string input textInput + cur cursorState items []pickItem source func(a *App, query string) []pickItem checked map[string]bool @@ -177,6 +178,8 @@ func (p *pickOverlay) handleKey(a *App, k vim.Key) { } } +func (p *pickOverlay) fieldCursor() cursorState { return p.cur } + func (p *pickOverlay) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(44, w*2/3)) @@ -190,9 +193,11 @@ func (p *pickOverlay) render(a *App, w, h int) string { } lines := []string{th.OverlayTitle.Render(p.title)} prompt := "› " - lines = append(lines, prompt+p.input.render(th, inner-cellWidth(prompt))) + fieldW := inner - cellWidth(prompt) + lines = append(lines, prompt+p.input.render(fieldW)) + p.cur = a.fieldAt(cellWidth(prompt)+p.input.cell(fieldW), 1) if p.input.String() == "" && p.placeholder != "" { - lines[len(lines)-1] = prompt + th.Cursor.Render(" ") + th.Muted.Render(padRight(p.placeholder, inner-3)) + lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } lines = append(lines, th.Muted.Render(strings.Repeat("─", inner))) if len(p.filtered) == 0 { From 85815a9f5dbf61e35373fa00b96bc73a084ee865 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:00 -0500 Subject: [PATCH 34/65] Feat(tui): menus, toasts and sidebar focus hide the cursor A bar blinking through a menu or a toast looks like the menu takes text. While a context menu, a centred menu, the hints overlay, the which-key popup or a toast is showing, the terminal cursor is hidden, and it comes back on the same cell when it closes or expires. A pop-up with its own text field still shows the bar in that field, and the tag menu keeps the bar at the insertion point, since the user is still typing. With focus on the sidebar or a side panel the cursor is hidden until focus returns to the pane. Tests, with vim mode on and off: each covering element hides the cursor and restores it on the same cell; the tag menu keeps it; the sidebar and outline panel hide it and focusing the pane brings it back; a prompt opened from the sidebar shows the bar in the prompt. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursor.go | 6 + internal/tui/cursor_covered_test.go | 169 ++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 internal/tui/cursor_covered_test.go diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 508e084..b00b12c 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -62,6 +62,12 @@ func (a *App) insertionCell(buf *noteBuffer, rc rect) (x, y int, ok bool) { // computeCursor picks the cursor for the frame just drawn. func (a *App) computeCursor() cursorState { + if len(a.toasts) > 0 { + return cursorState{} // a toast covers the corner it sits in + } + if a.leader != nil && a.leader.showHints { + return cursorState{} // the which-key popup covers the pane + } if a.overlay != nil { f, ok := a.overlay.(cursorField) if !ok { diff --git a/internal/tui/cursor_covered_test.go b/internal/tui/cursor_covered_test.go new file mode 100644 index 0000000..48cff2c --- /dev/null +++ b/internal/tui/cursor_covered_test.go @@ -0,0 +1,169 @@ +package tui + +import ( + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vim" +) + +// coveredModes runs fn once with vim mode on and once with it off. +func coveredModes(t *testing.T, fn func(t *testing.T, vimMode bool)) { + t.Helper() + for _, vimMode := range []bool{true, false} { + name := "vim" + if !vimMode { + name = "plain" + } + t.Run(name, func(t *testing.T) { fn(t, vimMode) }) + } +} + +// coveredApp is cursorApp in the requested editing style, with the bar +// confirmed at the insertion point before anything covers it. +func coveredApp(t *testing.T, vimMode bool) (*App, cursorState) { + t.Helper() + a, buf := cursorApp(t, "hello world\n", 80) + a.prefs.VimMode = vimMode + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 3}) + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v before covering, want a visible bar", a.cursor) + } + return a, a.cursor +} + +func assertCursorHidden(t *testing.T, a *App, what string) { + t.Helper() + a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v under %s, want hidden", a.cursor, what) + } +} + +func assertCursorAt(t *testing.T, a *App, want cursorState, what string) { + t.Helper() + a.View() + if a.cursor != want { + t.Fatalf("cursor = %+v %s, want %+v", a.cursor, what, want) + } +} + +func TestCursorCoveredContextMenu(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.noteContextMenu("Alpha.md") + assertCursorHidden(t, a, "a context menu") + a.overlay = nil + assertCursorAt(t, a, want, "after the menu closed") + }) +} + +func TestCursorCoveredCentredMenu(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.overlay = &menu{title: "Centre", items: []menuItem{{key: "a", label: "A", run: func(*App) {}}}} + assertCursorHidden(t, a, "a centred menu") + a.overlay = nil + assertCursorAt(t, a, want, "after the menu closed") + }) +} + +func TestCursorCoveredHintsOverlay(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.overlay = &hintOverlay{targets: []hintTarget{{label: "a", x: 1, y: 1, length: 2, run: func(*App) {}}}} + assertCursorHidden(t, a, "the hints overlay") + a.overlay = nil + assertCursorAt(t, a, want, "after hints closed") + }) +} + +func TestCursorCoveredWhichKey(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.startLeader() + a.leader.showHints = true + assertCursorHidden(t, a, "the which-key popup") + a.leader = nil + assertCursorAt(t, a, want, "after the popup closed") + }) +} + +func TestCursorCoveredToast(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.notify("saved") + if len(a.toasts) == 0 { + t.Fatal("no toast") + } + assertCursorHidden(t, a, "a toast") + for i := range a.toasts { + a.toasts[i].until = time.Now().Add(-time.Second) + } + a.expireToasts() + assertCursorAt(t, a, want, "after the toast expired") + }) +} + +func TestCursorCoveredTagMenuKeepsBar(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + prefs := vimPrefs() + prefs.VimMode = vimMode + a, buf := newTagTestApp(t, prefs, "\n") + a.sidebarOpen = false + a.layout() + if vimMode { + feed(a, vim.R('i')) + } + typeKeys(a, "see #pro") + if a.liveTagMenu() == nil { + t.Fatal("the tag menu did not open") + } + a.View() + x, y, ok := a.insertionCell(buf, a.contentRect(a.activePane)) + if !ok { + t.Fatal("no insertion cell") + } + want := cursorState{visible: true, x: x, y: y, shape: cursorShapeBar} + if a.cursor != want { + t.Fatalf("cursor = %+v with the tag menu open, want %+v", a.cursor, want) + } + }) +} + +func TestCursorCoveredSidePanelFocus(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, _ := coveredApp(t, vimMode) + a.sidebarOpen = true + a.layout() + a.View() + want := a.cursor + if !want.visible { + t.Fatalf("cursor = %+v with the sidebar open, want a bar", want) + } + a.focus = focusSidebar + assertCursorHidden(t, a, "the sidebar's focus") + a.focus = focusPane + assertCursorAt(t, a, want, "back in the pane") + + a.toggleSidePanel(&a.outlineOpen, focusOutline) + a.focus = focusPane + a.View() + want = a.cursor + a.focus = focusOutline + assertCursorHidden(t, a, "a side panel's focus") + a.focus = focusPane + assertCursorAt(t, a, want, "back in the pane") + }) +} + +func TestCursorCoveredPromptFromSidebar(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, _ := coveredApp(t, vimMode) + a.focus = focusSidebar + a.promptFor("Name", "ab", "", func(*App, string) {}) + view := a.View() + assertFieldBar(t, a, view, " ") + }) +} From b437c106a8b03c5b8e0ac4f38909765172459c2e Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:01 -0500 Subject: [PATCH 35/65] Feat(tui): zn shows the terminal cursor and resets its shape on exit The program now writes through the cursor output wrapper, and every frame hands it the cursor worked out for that frame, so the bar and underline appear in the terminal. zn cannot read the shape the terminal used before it started, so on the way out it resets the terminal to its default shape: after a normal quit, after an error and after a panic Bubble Tea recovers. zn has no recover of its own, so the reset runs after the program returns, on every path. There is no setting; a default configuration gets the bar. Tests: the output after `:q`, after the context is cancelled and after a panic ends with the default-shape reset following the last bar, and an empty config.toml gives the bar in insert mode. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 20 +++- internal/tui/cursor_restore_test.go | 141 ++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 internal/tui/cursor_restore_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 96ed938..9a45321 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "errors" "fmt" + "io" "os" "os/exec" "runtime" @@ -149,6 +150,9 @@ type App struct { // cursor is where the terminal cursor belongs in the last frame drawn. cursor cursorState + // cursorOut shapes the terminal cursor; nil when the app runs without a + // terminal, as in tests. + cursorOut *cursorOut // cmdlineX is the column of the next typed character on the command // line, set by renderCommandLine while a command line is open. cmdlineX int @@ -196,9 +200,20 @@ func Run(ctx context.Context, opts Options) error { if prefs.TerminalMouse { teaOpts = append(teaOpts, tea.WithMouseCellMotion()) } + return runProgram(app, os.Stdout, os.Stdin, teaOpts...) +} + +// runProgram runs app on out and in with the cursor wrapper between Bubble Tea +// and the terminal. Bubble Tea recovers panics and p.Run returns an error, so +// the cursor is restored on every path after p.Run returns. +func runProgram(app *App, out cursorFile, in io.Reader, teaOpts ...tea.ProgramOption) error { + wrapper := newCursorOut(out) + app.cursorOut = wrapper + defer wrapper.Reset() + teaOpts = append([]tea.ProgramOption{tea.WithOutput(wrapper), tea.WithInput(in)}, teaOpts...) p := tea.NewProgram(app, teaOpts...) app.program = p - _, err = p.Run() + _, err := p.Run() saveErr := app.shutdown() if err == nil { err = saveErr @@ -722,6 +737,9 @@ func (a *App) View() string { screen = a.renderToasts(screen) } a.cursor = a.computeCursor() + if a.cursorOut != nil { + a.cursorOut.Set(a.cursor) + } return a.theme.paint(screen) } diff --git a/internal/tui/cursor_restore_test.go b/internal/tui/cursor_restore_test.go new file mode 100644 index 0000000..a383f66 --- /dev/null +++ b/internal/tui/cursor_restore_test.go @@ -0,0 +1,141 @@ +package tui + +import ( + "context" + "io" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vim" +) + +// restoreRun runs app through runProgram on a fake terminal and a pipe for +// input. feed receives the pipe's writer once the first frame is out. +type restoreRun struct { + term *fakeTerm + in *io.PipeWriter + done chan error +} + +func startRestoreRun(t *testing.T, app *App, opts ...tea.ProgramOption) *restoreRun { + t.Helper() + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close() }) + r := &restoreRun{term: &fakeTerm{}, in: pw, done: make(chan error, 1)} + opts = append([]tea.ProgramOption{tea.WithoutSignals()}, opts...) + go func() { r.done <- runProgram(app, r.term, pr, opts...) }() + waitFor(t, func() bool { return strings.Contains(r.term.all(), "\x1b[") && app.program != nil }) + return r +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("timed out waiting") + } + time.Sleep(10 * time.Millisecond) + } +} + +func (r *restoreRun) wait(t *testing.T) error { + t.Helper() + select { + case err := <-r.done: + return err + case <-time.After(10 * time.Second): + t.Fatal("program did not exit") + return nil + } +} + +func tail(s string) string { + if len(s) > 80 { + return s[len(s)-80:] + } + return s +} + +// assertRestored checks the output ends with the default shape and a shown +// cursor, after every earlier shape. +func assertRestored(t *testing.T, out string) { + t.Helper() + reset := strings.LastIndex(out, defaultShape) + if reset < 0 { + t.Fatalf("no CSI 0 SP q in output (tail %q)", tail(out)) + } + for _, shape := range []string{steadyBar, steadyUnderline} { + if i := strings.LastIndex(out, shape); i > reset { + t.Fatalf("shape %q written after the reset", shape) + } + } + if !strings.Contains(out[reset:], "\x1b[?25h") { + t.Fatalf("cursor not shown after the reset (tail %q)", tail(out)) + } +} + +func TestCursorRestoreQuit(t *testing.T) { + a, _ := cursorApp(t, "hello", 80) + r := startRestoreRun(t, a) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) + for _, k := range []tea.KeyMsg{ + {Type: tea.KeyEsc}, + {Type: tea.KeyRunes, Runes: []rune(":")}, + {Type: tea.KeyRunes, Runes: []rune("q")}, + {Type: tea.KeyEnter}, + } { + a.program.Send(k) + } + if err := r.wait(t); err != nil { + t.Fatal(err) + } + assertRestored(t, r.term.all()) +} + +func TestCursorRestoreError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + a, _ := cursorApp(t, "hello", 80) + a.ctx = ctx + r := startRestoreRun(t, a, tea.WithContext(ctx)) + cancel() + if err := r.wait(t); err == nil { + t.Fatal("expected an error from the cancelled run") + } + assertRestored(t, r.term.all()) +} + +type panicOverlay struct{} + +func (panicOverlay) handleKey(*App, vim.Key) { panic("boom") } +func (panicOverlay) render(*App, int, int) string { return "" } + +func TestCursorRestorePanic(t *testing.T) { + a, _ := cursorApp(t, "hello", 80) + a.overlay = panicOverlay{} + r := startRestoreRun(t, a) + a.program.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if err := r.wait(t); err == nil { + t.Fatal("expected an error from the recovered panic") + } + assertRestored(t, r.term.all()) +} + +func TestCursorRestoreDefaultPrefsGiveBar(t *testing.T) { + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + prefs, _, err := config.LoadPrefs() + if err != nil { + t.Fatal(err) + } + a, buf := cursorApp(t, "hello", 40) + a.rawPref, a.prefs = prefs, prefsView{Prefs: prefs, TabsOff: !prefs.TabsEnabled} + buf.ed.Feed("i") + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("insert mode with default prefs: cursor = %+v", a.cursor) + } +} From 9077380ad87d0f18c6d90d5dfacbc68b3c751174 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:02 -0500 Subject: [PATCH 36/65] Feat(tui): reset the cursor shape around suspend and external programs A shell or editor started from zn should see the terminal's own cursor, not zn's bar. Before Ctrl+Z, `:suspend`, the external editor and opening the config file, zn resets the cursor to the terminal default and stops writing cursor sequences. On resume, or when the external program returns, it takes the cursor back and writes the current one, which the next frame corrects if the mode changed. Tests: Ctrl+Z and `:suspend` write the reset and release the wrapper; resuming writes the bar at the insertion point; the external editor and config actions write the reset; returning in insert mode shows the bar and in normal mode hides it. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 5 ++ internal/tui/commands.go | 1 + internal/tui/cursor.go | 17 ++++ internal/tui/cursor_suspend_test.go | 135 ++++++++++++++++++++++++++++ internal/tui/editorext.go | 1 + internal/tui/input.go | 1 + internal/tui/settings_live.go | 1 + 7 files changed, 161 insertions(+) create mode 100644 internal/tui/cursor_suspend_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 9a45321..0c3bc21 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -336,7 +336,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return a, tea.Batch(configPollCmd(), a.flush()) + case tea.ResumeMsg: + a.resumeCursor() + return a, a.flush() case configEditedMsg: + a.resumeCursor() if m.err != nil { a.notifyError(m.err.Error()) } else if err := a.reloadPreferences(); err != nil { @@ -494,6 +498,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.invalidatePreviews() return a, a.flush() case editorDoneMsg: + a.resumeCursor() a.finishExternalEdit(m) return a, a.flush() case leaderHintMsg: diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 870cd38..947293d 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -635,6 +635,7 @@ func (a *App) commandTable() []command { return nil }}, {names: []string{"suspend", "stop"}, title: "Suspend to the shell (fg to return)", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + a.suspendCursor() a.queue(tea.Suspend) return nil }}, diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index b00b12c..5951e98 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -101,3 +101,20 @@ func (a *App) computeCursor() cursorState { } return cursorState{visible: true, x: x, y: y, shape: shape} } + +// suspendCursor hands the terminal to something else (the shell or an +// external program): the cursor goes back to the terminal's default shape +// and the wrapper stops adding sequences until resumeCursor. +func (a *App) suspendCursor() { + if a.cursorOut != nil { + a.cursorOut.Reset() + a.cursorOut.Release() + } +} + +// resumeCursor takes the terminal back and writes the cursor again. +func (a *App) resumeCursor() { + if a.cursorOut != nil { + a.cursorOut.Hold() + } +} diff --git a/internal/tui/cursor_suspend_test.go b/internal/tui/cursor_suspend_test.go new file mode 100644 index 0000000..ecc211b --- /dev/null +++ b/internal/tui/cursor_suspend_test.go @@ -0,0 +1,135 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/vim" +) + +// suspendApp is an insert-mode app whose wrapper sits over a fake terminal, +// with the bar already written at the insertion point. +func suspendApp(t *testing.T) (*App, *noteBuffer, *fakeTerm) { + t.Helper() + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 3}) + term := &fakeTerm{} + a.cursorOut = newCursorOut(term) + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + term.clear() + return a, buf, term +} + +func cursorReleased(a *App) bool { + a.cursorOut.mu.Lock() + defer a.cursorOut.mu.Unlock() + return a.cursorOut.released +} + +func assertReleasedWithReset(t *testing.T, a *App, term *fakeTerm, what string) { + t.Helper() + if !strings.Contains(term.all(), defaultShape) { + t.Fatalf("%s: wrote %q, want %q", what, term.all(), defaultShape) + } + if !cursorReleased(a) { + t.Fatalf("%s: the wrapper is not released", what) + } +} + +func TestCursorSuspendCtrlZ(t *testing.T) { + a, _, term := suspendApp(t) + a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) + assertReleasedWithReset(t, a, term, "Ctrl+Z") +} + +func TestCursorSuspendExCommand(t *testing.T) { + a, buf, term := suspendApp(t) + if _, err := a.runEx(buf, vim.ExCommand{Name: "suspend"}); err != nil { + t.Fatal(err) + } + assertReleasedWithReset(t, a, term, ":suspend") +} + +func TestCursorSuspendResumeWritesBar(t *testing.T) { + a, _, term := suspendApp(t) + a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) + term.clear() + a.Update(tea.ResumeMsg{}) + want := cursorState{visible: true, x: a.cursor.x, y: a.cursor.y, shape: cursorShapeBar}.sequence() + if !strings.HasSuffix(term.all(), want) { + t.Fatalf("after resume wrote %q, want it to end with %q", term.all(), want) + } + if cursorReleased(a) { + t.Fatal("the wrapper is still released after resume") + } +} + +func TestCursorSuspendExternalEditor(t *testing.T) { + t.Setenv("EDITOR", "true") + t.Setenv("VISUAL", "") + a, _, term := suspendApp(t) + a.editActiveInEditor() + assertReleasedWithReset(t, a, term, "the external editor") +} + +func TestCursorSuspendOpenConfig(t *testing.T) { + t.Setenv("EDITOR", "true") + t.Setenv("VISUAL", "") + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + a, _, term := suspendApp(t) + if err := a.editConfig(); err != nil { + t.Fatal(err) + } + assertReleasedWithReset(t, a, term, "open config") +} + +func returnMsgs() map[string]tea.Msg { + return map[string]tea.Msg{ + "editor": editorDoneMsg{path: "Alpha.md"}, + "config": configEditedMsg{}, + } +} + +func TestCursorSuspendReturnInInsertShowsBar(t *testing.T) { + for name, msg := range returnMsgs() { + t.Run(name, func(t *testing.T) { + a, _, term := suspendApp(t) + a.suspendCursor() + term.clear() + a.Update(msg) + a.toasts = nil + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if want := a.cursor.sequence(); !strings.HasSuffix(term.all(), want) { + t.Fatalf("wrote %q, want it to end with %q", term.all(), want) + } + }) + } +} + +func TestCursorSuspendReturnInNormalHidesCursor(t *testing.T) { + for name, msg := range returnMsgs() { + t.Run(name, func(t *testing.T) { + a, buf, term := suspendApp(t) + a.suspendCursor() + buf.ed.Feed("") + term.clear() + a.Update(msg) + a.toasts = nil + a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v in normal mode, want hidden", a.cursor) + } + if !strings.HasSuffix(term.all(), "\x1b[?25l") { + t.Fatalf("wrote %q, want it to end with the hide sequence", term.all()) + } + }) + } +} diff --git a/internal/tui/editorext.go b/internal/tui/editorext.go index 40f440f..b751d0a 100644 --- a/internal/tui/editorext.go +++ b/internal/tui/editorext.go @@ -56,6 +56,7 @@ func (a *App) editActiveInEditor() { return } path := buf.path + a.suspendCursor() a.queue(tea.ExecProcess(cmd, func(err error) tea.Msg { return editorDoneMsg{path: path, tmp: tmp, err: err} })) diff --git a/internal/tui/input.go b/internal/tui/input.go index b07d287..21507c3 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -264,6 +264,7 @@ func (a *App) chordKey(k vim.Key, editing bool) bool { if editing && !a.prefs.VimMode { return false } + a.suspendCursor() a.queue(tea.Suspend) case a.tabSelectChord(k): case editing && a.prefs.VimMode: diff --git a/internal/tui/settings_live.go b/internal/tui/settings_live.go index 31a1857..e96a2f7 100644 --- a/internal/tui/settings_live.go +++ b/internal/tui/settings_live.go @@ -113,6 +113,7 @@ func (a *App) editConfig() error { if err != nil { return err } + a.suspendCursor() a.queue(tea.ExecProcess(cmd, func(err error) tea.Msg { return configEditedMsg{err} })) return nil } From 87a78386e3a0426d00f989b804b262a34d005aa6 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:03 -0500 Subject: [PATCH 37/65] Fix(tui): the field cursor sits on the field under a wrapping title The prompt, the palette and the picker assumed their title fits on one row and put the field's cursor on the second content row. A title wider than the box wraps, as the sidebar's "New note in " does for a deep folder on an 80-column terminal, and the bar then sat on the title's second line, one row above the typed text. They now measure the rendered title height, as quick capture already did. The restore tests now show the bar before the panic or the cancelled context, so they prove the reset follows a shown shape, and the default-config test builds the app the way zn does, from an empty config.toml. Tests, written red first: a prompt, palette and picker with a title wider than the field put the bar right after the last typed rune. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursor_field_test.go | 26 +++++++++++++++++++ internal/tui/cursor_restore_test.go | 40 ++++++++++++++++++++++++----- internal/tui/overlays.go | 11 +++++--- internal/tui/pick_overlay.go | 2 +- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/internal/tui/cursor_field_test.go b/internal/tui/cursor_field_test.go index 65654a9..1e4a6f6 100644 --- a/internal/tui/cursor_field_test.go +++ b/internal/tui/cursor_field_test.go @@ -63,6 +63,32 @@ func TestTextFieldCursorSecretPrompt(t *testing.T) { } } +const wrappingTitle = "New note in Projects/Work/Clients/Acme Corporation/Meetings" + +func TestTextFieldCursorWrappedTitle(t *testing.T) { + open := map[string]func(a *App){ + "prompt": func(a *App) { a.promptFor(wrappingTitle, "", "", func(*App, string) {}) }, + "palette": func(a *App) { + a.overlay = &palette{title: wrappingTitle, input: newTextInput("")} + }, + "picker": func(a *App) { + a.overlay = newPickOverlay(wrappingTitle, "", []pickItem{{id: "a", label: "a"}}, nil) + }, + } + for name, fn := range open { + t.Run(name, func(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + fn(a) + typeText(a, "ab") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "b" { + t.Fatalf("cell before the bar is %q, want the last typed rune", got) + } + }) + } +} + func TestTextFieldCursorClampedToFieldWidth(t *testing.T) { a, _ := cursorApp(t, "hello\n", 40) a.promptFor("Name", "", "", func(*App, string) {}) diff --git a/internal/tui/cursor_restore_test.go b/internal/tui/cursor_restore_test.go index a383f66..8e9d698 100644 --- a/internal/tui/cursor_restore_test.go +++ b/internal/tui/cursor_restore_test.go @@ -3,12 +3,15 @@ package tui import ( "context" "io" + "os" + "path/filepath" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/vim" ) @@ -102,6 +105,7 @@ func TestCursorRestoreError(t *testing.T) { a, _ := cursorApp(t, "hello", 80) a.ctx = ctx r := startRestoreRun(t, a, tea.WithContext(ctx)) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) cancel() if err := r.wait(t); err == nil { t.Fatal("expected an error from the cancelled run") @@ -114,10 +118,20 @@ type panicOverlay struct{} func (panicOverlay) handleKey(*App, vim.Key) { panic("boom") } func (panicOverlay) render(*App, int, int) string { return "" } +// inLoop is an exec command whose body runs on the program's event loop, so a +// test can change the app without racing the loop. +type inLoop func() + +func (f inLoop) Run() error { f(); return nil } +func (inLoop) SetStdin(io.Reader) {} +func (inLoop) SetStdout(io.Writer) {} +func (inLoop) SetStderr(io.Writer) {} + func TestCursorRestorePanic(t *testing.T) { a, _ := cursorApp(t, "hello", 80) - a.overlay = panicOverlay{} r := startRestoreRun(t, a) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) + a.program.Send(tea.Exec(inLoop(func() { a.overlay = panicOverlay{} }), nil)()) a.program.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) if err := r.wait(t); err == nil { t.Fatal("expected an error from the recovered panic") @@ -125,17 +139,29 @@ func TestCursorRestorePanic(t *testing.T) { assertRestored(t, r.term.all()) } -func TestCursorRestoreDefaultPrefsGiveBar(t *testing.T) { - t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) +func TestCursorDefaultConfigGivesBar(t *testing.T) { + root := newSessionParityVault(t, "Alpha.md") + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.toml"), nil, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("ZENNOTES_CONFIG_DIR", dir) prefs, _, err := config.LoadPrefs() if err != nil { t.Fatal(err) } - a, buf := cursorApp(t, "hello", 40) - a.rawPref, a.prefs = prefs, prefsView{Prefs: prefs, TabsOff: !prefs.TabsEnabled} - buf.ed.Feed("i") + target := backend.Target{Kind: backend.KindLocal, Root: root} + b, err := backend.New(target, backend.Options{}) + if err != nil { + t.Fatal(err) + } + a := newApp(context.Background(), Options{Backend: b, Target: target}, prefs, true) + a.openNote("Alpha.md", true) + a.sidebarOpen = false + a.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + a.activeBuffer().ed.Feed("i") a.View() if !a.cursor.visible || a.cursor.shape != cursorShapeBar { - t.Fatalf("insert mode with default prefs: cursor = %+v", a.cursor) + t.Fatalf("insert mode with an empty config: cursor = %+v", a.cursor) } } diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index 47be95b..4023c6c 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -121,6 +121,11 @@ func (a *App) overlayInset() (x, y int) { return s.GetBorderLeftSize() + s.GetPaddingLeft(), s.GetBorderTopSize() + s.GetPaddingTop() } +// titleRows is how many rows an overlay title takes once it wraps at inner cells. +func titleRows(title string, inner int) int { + return lipgloss.Height(lipgloss.NewStyle().Width(inner).Render(title)) +} + // fieldAt is the bar for a field whose first cell is at column col of the // given content row of an overlay. func (a *App) fieldAt(col, row int) cursorState { @@ -290,7 +295,7 @@ func (p *palette) render(a *App, w, h int) string { prompt := "› " fieldW := inner - lipgloss.Width(prompt) lines = append(lines, prompt+p.input.render(fieldW)) - p.cur = a.fieldAt(lipgloss.Width(prompt)+p.input.cell(fieldW), 1) + p.cur = a.fieldAt(lipgloss.Width(prompt)+p.input.cell(fieldW), titleRows(p.title, inner)) if p.input.String() == "" && p.placeholder != "" { lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } @@ -393,11 +398,11 @@ func (p *prompt) render(a *App, w, h int) string { width := min(w-4, max(40, w/2)) inner := width - 2 field := p.input.render(inner) - p.cur = a.fieldAt(p.input.cell(inner), 1) + p.cur = a.fieldAt(p.input.cell(inner), titleRows(p.title, inner)) if p.masked { dots := strings.Repeat("•", len(p.input.runes)) field = padRight(dots+" ", inner) - p.cur = a.fieldAt(min(len(p.input.runes), max(inner-1, 0)), 1) + p.cur = a.fieldAt(min(len(p.input.runes), max(inner-1, 0)), titleRows(p.title, inner)) } lines := []string{th.OverlayTitle.Render(p.title), field} if p.help != "" { diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index 0e36333..16291a9 100644 --- a/internal/tui/pick_overlay.go +++ b/internal/tui/pick_overlay.go @@ -195,7 +195,7 @@ func (p *pickOverlay) render(a *App, w, h int) string { prompt := "› " fieldW := inner - cellWidth(prompt) lines = append(lines, prompt+p.input.render(fieldW)) - p.cur = a.fieldAt(cellWidth(prompt)+p.input.cell(fieldW), 1) + p.cur = a.fieldAt(cellWidth(prompt)+p.input.cell(fieldW), titleRows(p.title, inner)) if p.input.String() == "" && p.placeholder != "" { lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } From 5a224ff9e5ab3ec51e75efde4686f1727e198dfb Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:52:12 -0500 Subject: [PATCH 38/65] Feat(tui): T opens a due-date menu from the calendar's task lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With focus on the selected day's tasks or the No-date strip, T now opens a menu for the selected task's due date: Today, Tomorrow, Next week, the day selected in the calendar, Clear due date, or Other…, which opens the usual due-date prompt. Arrow keys and Enter pick a choice; Esc, q and Ctrl+C close the menu and leave the task as it was. The menu works with Vim mode on or off. On the grid, T still jumps to today. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_tasks.go | 21 ++ .../tui/views_tasks_calendar_duemenu_test.go | 188 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 internal/tui/views_tasks_calendar_duemenu_test.go diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index d0ea477..4603c34 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -1084,6 +1084,10 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { if t != nil { a.taskMenu(*t) } + case k.IsRune('T'): + if t != nil { + v.calendarDueMenu(a, *t) + } default: return v.taskEditKey(a, k, t) } @@ -1142,6 +1146,23 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { return true } +// calendarDueMenu offers the due dates the calendar can set at once. The +// choices have no shortcut keys, so letters never act on the task by accident. +func (v *tasksView) calendarDueMenu(a *App, t vault.Task) { + now := calendarNow() + set := func(d time.Time) func(a *App) { + return func(a *App) { a.setTaskDue(t, vault.TodayISO(d)) } + } + a.showMenu("Due date", []menuItem{ + {label: "Today (" + vault.TodayISO(now) + ")", run: set(now)}, + {label: "Tomorrow (" + vault.TodayISO(now.AddDate(0, 0, 1)) + ")", run: set(now.AddDate(0, 0, 1))}, + {label: "Next week (" + vault.TodayISO(now.AddDate(0, 0, 7)) + ")", run: set(now.AddDate(0, 0, 7))}, + {label: "Selected day (" + vault.TodayISO(v.day) + ")", run: set(v.day)}, + {label: "Clear due date", run: func(a *App) { a.setTaskDue(t, "") }}, + {label: "Other…", run: func(a *App) { a.taskDuePrompt(t) }}, + }) +} + // pageDay moves the selected day by one page of the current layout: a month, // a week or a day. dir is -1 or 1. func (v *tasksView) pageDay(a *App, dir int) { diff --git a/internal/tui/views_tasks_calendar_duemenu_test.go b/internal/tui/views_tasks_calendar_duemenu_test.go new file mode 100644 index 0000000..6cd1a4e --- /dev/null +++ b/internal/tui/views_tasks_calendar_duemenu_test.go @@ -0,0 +1,188 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vim" +) + +const calLooseNote = "- [ ] loose end\n" + +// calDueMenu focuses the item panel on 2026-11-18 (today fixed to 2026-10-29) +// and presses T. +func calDueMenu(t *testing.T, files map[string]string, vimOn bool) (*App, *tasksView, string, *menu) { + t.Helper() + calFixToday(t, calDate(2026, time.October, 29)) + a, tv, root := calVaultApp(t, files) + a.prefs.VimMode = vimOn + calPress(a, tv, vim.KeyEnter) + calPress(a, tv, vim.R('T')) + m, ok := a.overlay.(*menu) + if !ok { + t.Fatalf("overlay after T on an item = %T, want the due-date menu", a.overlay) + } + return a, tv, root, m +} + +func calMenuIndex(t *testing.T, m *menu, word string) int { + t.Helper() + for i, it := range m.items { + if strings.Contains(strings.ToLower(it.label), word) { + return i + } + } + t.Fatalf("menu has no item containing %q: %+v", word, m.items) + return -1 +} + +// calPick moves the cursor to the item and presses Enter. +func calPick(t *testing.T, a *App, m *menu, word string) { + t.Helper() + want := calMenuIndex(t, m, word) + for i := 0; i < len(m.items) && m.cursor != want; i++ { + a.overlay.handleKey(a, vim.KeyDown) + } + if m.cursor != want { + t.Fatalf("cursor stuck at %d, want %d", m.cursor, want) + } + a.overlay.handleKey(a, vim.KeyEnter) +} + +func TestDueMenuOpensFromItemPanel(t *testing.T) { // AC88 + _, tv, _, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + requireDay(t, tv, "2026-11-18", "after T") + if tv.calc().sub != calMonth { + t.Errorf("sub-mode = %v, want month", tv.calc().sub) + } + requireFocus(t, tv, calFocusItems, "after T") +} + +func TestDueMenuListsChoicesInOrder(t *testing.T) { // AC89 + _, _, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + if len(m.items) != 6 { + t.Fatalf("menu has %d items, want 6: %+v", len(m.items), m.items) + } + for i, w := range []string{"today", "tomorrow", "next week", "2026-11-18", "clear", "other"} { + if !strings.Contains(strings.ToLower(m.items[i].label), w) { + t.Errorf("item %d = %q, want it to mention %q", i, m.items[i].label, w) + } + } +} + +func TestDueMenuArrowsAndEnterRunChoice(t *testing.T) { // AC90 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + a.overlay.handleKey(a, vim.KeyDown) + if m.cursor != 1 { + t.Fatalf("cursor after Down = %d, want 1", m.cursor) + } + a.overlay.handleKey(a, vim.KeyEnter) + calReload(t, a) + if a.overlay != nil { + t.Errorf("overlay after Enter = %T, want none", a.overlay) + } + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-10-30") { + t.Errorf("Down then Enter did not pick tomorrow:\n%s", got) + } +} + +func TestDueMenuLettersRunNothing(t *testing.T) { // AC91 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + for _, it := range m.items { + if it.key != "" { + t.Errorf("item %q has shortcut key %q, want none", it.label, it.key) + } + } + for _, r := range "tTnwscodxa" { + a.overlay.handleKey(a, vim.R(r)) + } + if got := calReadFile(t, root, "work.md"); got != calWorkNote { + t.Errorf("letters changed the note:\n%s", got) + } +} + +func TestDueMenuRelativeChoicesSetDue(t *testing.T) { // AC92 + for _, c := range []struct{ word, due string }{ + {"today", "2026-10-29"}, {"tomorrow", "2026-10-30"}, {"next week", "2026-11-05"}, + } { + a, _, root, m := calDueMenu(t, map[string]string{"plan.md": calPlanNote}, true) + calPick(t, a, m, c.word) + body := calReadFile(t, root, "plan.md") + if !strings.Contains(body, "due: "+c.due) && !strings.Contains(body, "due:"+c.due) { + t.Errorf("%s: due is not %s:\n%s", c.word, c.due, body) + } + if !strings.Contains(body, "scheduled: 2026-11-16") { + t.Errorf("%s: scheduled changed:\n%s", c.word, body) + } + } +} + +func TestDueMenuSelectedDaySetsDue(t *testing.T) { // AC93 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + calPick(t, a, m, "2026-11-18") + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-11-18") { + t.Errorf("due is not the selected day:\n%s", got) + } +} + +func TestDueMenuClearRemovesDue(t *testing.T) { // AC94 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + calPick(t, a, m, "clear") + got := calReadFile(t, root, "work.md") + if strings.Contains(got, "alpha job due:") || !strings.Contains(got, "alpha job") { + t.Errorf("alpha job should lose its due date and stay:\n%s", got) + } + if !strings.Contains(got, "bravo job due:2026-11-18") { + t.Errorf("another task changed:\n%s", got) + } +} + +func TestDueMenuOtherOpensDuePrompt(t *testing.T) { // AC95 + a, _, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + calPick(t, a, m, "other") + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay = %T, want the due-date prompt", a.overlay) + } + if !strings.Contains(p.title, "Due") { + t.Errorf("prompt title = %q, want the due-date prompt", p.title) + } +} + +func TestDueMenuDismissLeavesTaskUnchanged(t *testing.T) { // AC96 + for _, k := range []vim.Key{vim.KeyEsc, vim.R('q'), vim.Ctrl('c')} { + a, _, root, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + a.overlay.handleKey(a, k) + if a.overlay != nil { + t.Errorf("%v: overlay = %T, want none", k, a.overlay) + } + if got := calReadFile(t, root, "work.md"); got != calWorkNote { + t.Errorf("%v: note changed:\n%s", k, got) + } + } +} + +func TestDueMenuOpensFromStrip(t *testing.T) { // AC97 + calFixToday(t, calDate(2026, time.October, 29)) + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote, "loose.md": calLooseNote}) + calPress(a, tv, vim.KeyTab, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "second Tab") + calPress(a, tv, vim.R('T')) + m, ok := a.overlay.(*menu) + if !ok { + t.Fatalf("overlay after T on the strip = %T, want the due-date menu", a.overlay) + } + calPick(t, a, m, "tomorrow") + if got := calReadFile(t, root, "loose.md"); !strings.Contains(got, "loose end due:2026-10-30") { + t.Errorf("strip task did not get the due date:\n%s", got) + } +} + +func TestDueMenuWithoutVimMode(t *testing.T) { // AC112 + _, tv, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, false) + if len(m.items) != 6 { + t.Errorf("menu has %d items, want 6", len(m.items)) + } + requireDay(t, tv, "2026-11-18", "after T") +} From 0c118b4d1409cb574050356da0cd62aad12f3533 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 14:30:24 -0500 Subject: [PATCH 39/65] Fix(tui): right-click lands on the clicked cell in framed panes Right-click measured its position from the pane's outer edge instead of its content, so in a framed pane every view saw the click two columns to the right and one row lower than where it happened, and context menus opened for the wrong row or not at all. It now measures from the content area like a left click does, and a right-click on the frame itself does nothing. Co-Authored-By: Claude Opus 5.5 --- internal/tui/mouse.go | 7 +++++- internal/tui/mouse_right_test.go | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 internal/tui/mouse_right_test.go diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 7fda616..5c10ddf 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -505,8 +505,13 @@ func (a *App) mouseRight(m tea.MouseMsg) { return } if t.view != nil { + inner := a.contentRect(hit.pane) + x, y := m.X-inner.x, m.Y-inner.y + if x < 0 || y < 0 || x >= inner.w || y >= inner.h { + return + } if c, ok := t.view.(clicker); ok { - c.click(a, hit.pane, m.X-hit.pane.rect.x, m.Y-hit.pane.rect.y-a.tabBarHeight(), m, false) + c.click(a, hit.pane, x, y, m, false) } return } diff --git a/internal/tui/mouse_right_test.go b/internal/tui/mouse_right_test.go new file mode 100644 index 0000000..43f3baf --- /dev/null +++ b/internal/tui/mouse_right_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// A right-click measures from the pane's content rectangle, like a left-click, +// so a framed pane's border and padding do not shift it. +func TestRightClickMeasuresFromContentRect(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, calMonth) + cr := a.contentRect(a.activePane) + lines := strings.Split(stripAnsi(tv.render(a, cr.w, cr.h, true)), "\n") + r := tv.calc().layout.items + var x, y int + found := false + for ly := r.y; ly < r.y+r.h && ly+1 < len(lines); ly++ { + if i := strings.Index(lines[ly+1], "bravo job"); i >= 0 { + x, y, found = lipgloss.Width(lines[ly+1][:i]), ly+1, true + break + } + } + if !found { + t.Fatalf("bravo job is not listed in the item panel:\n%s", strings.Join(lines, "\n")) + } + a.handleMouse(tea.MouseMsg{X: cr.x + x, Y: cr.y + y, Button: tea.MouseButtonRight, Action: tea.MouseActionPress}) + m, ok := a.overlay.(*menu) + if !ok { + t.Fatalf("overlay = %T, want the task menu", a.overlay) + } + if !strings.Contains(m.title, "bravo job") { + t.Fatalf("menu title = %q, want the bravo job task", m.title) + } +} From a39a2ccb447a1b718f1ac3b1d68ed6ce2b69a05d Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 14:35:39 -0500 Subject: [PATCH 40/65] Feat(tui): mouse on calendar entries and the No-date strip In week and day view, clicking a task in a day's column now selects it and moves focus into the column, a double-click opens its note, and a right-click opens its menu, as the month view's task list already did. Clicking, double-clicking or right-clicking a task in the No-date strip does the same there, in every view. Menus opened from the keyboard now appear next to the selected task in the strip and in week and day columns, instead of at a fixed spot. Co-Authored-By: Claude Opus 5.5 --- internal/tui/views_tasks_calendar.go | 86 ++++- .../tui/views_tasks_calendar_mouse_test.go | 349 ++++++++++++++++++ 2 files changed, 419 insertions(+), 16 deletions(-) create mode 100644 internal/tui/views_tasks_calendar_mouse_test.go diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index 8d22f91..dd4e23a 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -67,6 +67,8 @@ type calLayout struct { boxes []calDayBox itemStart int stripRows int + // stripStart is the index of the first strip task shown. + stripStart int // gutter is the width of the hour labels left of the week and day columns. gutter int // hourStart is the index of the first hour row shown, and hourTotal the @@ -665,6 +667,7 @@ func (c *taskCalendar) stripLines(a *App, lay calLayout, w int, focused bool) [] if c.focus == calFocusStrip && c.strip >= rows { start = c.strip - rows + 1 } + c.layout.stripStart = start for i := 0; i < rows; i++ { idx := start + i line := "" @@ -679,22 +682,68 @@ func (c *taskCalendar) stripLines(a *App, lay calLayout, w int, focused bool) [] return out } -// click handles a mouse press at body cell (x, y). It reports whether the +// columnEntryAt is the index of the entry shown on line li of box b when its +// column is scrolled by s, or false for the "+N more" lines and empty ones. +func (c *taskCalendar) columnEntryAt(b calDayBox, s, li int) (int, bool) { + li -= min(s, 1) + if li < 0 || li >= c.columnShown(b, s) { + return 0, false + } + return s + li, true +} + +// pressTask acts on the task a press landed on: a right-click opens its menu +// and a double-click its source note. +func (c *taskCalendar) pressTask(a *App, t vault.Task, right, double bool) { + switch { + case right: + a.taskMenu(t) + case double: + a.openTaskSource(t) + } +} +// click handles a mouse press at body cell (x, y) by hit-testing the last +// layout. func (c *taskCalendar) click(a *App, v *tasksView, x, y int, right, double bool) { lay := c.layout if lay.tooSmall { return } + if lay.strip.contains(x, y) { + idx := lay.stripStart + y - lay.strip.y - 1 + if y > lay.strip.y && idx < len(c.undated) { + c.focus = calFocusStrip + c.strip = idx + c.pressTask(a, c.undated[idx], right, double) + } + return + } for _, b := range lay.boxes { - if b.rect.contains(x, y) { - v.selectDay(a, b.day) - c.focus = calFocusGrid - if double { - v.calendarKey(a, vim.KeyEnter) + if !b.rect.contains(x, y) { + continue + } + if c.sub != calMonth { + s := 0 + if sameDay(b.day, v.day) { + s = c.columnScroll(b) + } + if idx, ok := c.columnEntryAt(b, s, y-b.rect.y); ok { + if !sameDay(b.day, v.day) { + v.selectDay(a, b.day) + } + c.focus = calFocusItems + c.cursor = idx + c.pressTask(a, c.dayEntries(b.day)[idx].task, right, double) + return } - return } + v.selectDay(a, b.day) + c.focus = calFocusGrid + if double { + v.calendarKey(a, vim.KeyEnter) + } + return } if lay.items.contains(x, y) && y > lay.items.y { idx := lay.itemStart + y - lay.items.y - 1 @@ -702,13 +751,7 @@ func (c *taskCalendar) click(a *App, v *tasksView, x, y int, right, double bool) if idx < len(entries) { c.focus = calFocusItems c.cursor = idx - t := entries[idx].task - switch { - case right: - a.taskMenu(t) - case double: - a.openTaskSource(t) - } + c.pressTask(a, entries[idx].task, right, double) } } } @@ -716,8 +759,19 @@ func (c *taskCalendar) click(a *App, v *tasksView, x, y int, right, double bool) // selectionCell is the body cell menus open next to. func (c *taskCalendar) selectionCell(day time.Time) (int, int) { lay := c.layout - if c.focus == calFocusItems { - return lay.items.x + 3, lay.items.y + 1 + c.cursor - lay.itemStart + switch c.focus { + case calFocusStrip: + return lay.strip.x + 2, lay.strip.y + 1 + c.strip - lay.stripStart + case calFocusItems: + if c.sub == calMonth { + return lay.items.x + 3, lay.items.y + 1 + c.cursor - lay.itemStart + } + for _, b := range lay.boxes { + if sameDay(b.day, day) { + s := c.columnScroll(b) + return b.rect.x + 2, b.rect.y + c.cursor - s + min(s, 1) + } + } } for _, b := range lay.boxes { if sameDay(b.day, day) { diff --git a/internal/tui/views_tasks_calendar_mouse_test.go b/internal/tui/views_tasks_calendar_mouse_test.go new file mode 100644 index 0000000..8c65b34 --- /dev/null +++ b/internal/tui/views_tasks_calendar_mouse_test.go @@ -0,0 +1,349 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/ZenNotes/tui/internal/vim" +) + +// calMouseFiles gives the calendar dated entries on 2026-11-18 and 19 and +// three undated tasks for the No-date strip. +func calMouseFiles() map[string]string { + return map[string]string{"work.md": calWorkNote, "strip.md": calStripNote} +} + +// calMouseSubs are the layouts a click has to work in, each with a day that +// holds no entries and is on screen next to 2026-11-21. +var calMouseSubs = []struct { + name string + sub calSub + target time.Time +}{ + {"month", calMonth, calDate(2026, time.November, 25)}, + {"week", calWeek, calDate(2026, time.November, 20)}, + {"day", calDay, calDate(2026, time.November, 21)}, +} + +// calPaneBody renders the Tasks view at the size its pane gives it, as the +// frame does, and returns the lines (line 0 is the view's own header). +func calPaneBody(a *App, tv *tasksView) []string { + cr := a.contentRect(a.activePane) + return strings.Split(stripAnsi(tv.render(a, cr.w, cr.h, true)), "\n") +} + +// calCellOf is the cell column where text starts on a line. +func calCellOf(line, text string) int { + i := strings.Index(line, text) + if i < 0 { + return -1 + } + return lipgloss.Width(line[:i]) +} + +// calFindIn looks for text on the rendered lines inside a layout rectangle +// (body cells, so line 0 of the view is the header above it) and returns the +// view-relative cell. +func calFindIn(lines []string, r calRect, text string) (int, int, bool) { + for y := r.y; y < r.y+r.h && y+1 < len(lines); y++ { + line := lines[y+1] + if x := calCellOf(line, text); x >= r.x && x < r.x+r.w { + return x, y + 1, true + } + } + return 0, 0, false +} + +func calBoxFor(t *testing.T, tv *tasksView, day time.Time) calRect { + t.Helper() + for _, b := range tv.calc().layout.boxes { + if b.day.Format("2006-01-02") == day.Format("2006-01-02") { + return b.rect + } + } + t.Fatalf("layout has no box for %s", day.Format("2006-01-02")) + return calRect{} +} + +// calMouse sends one mouse press at a view-relative cell through the app's +// real mouse entry point. +func calMouse(a *App, button tea.MouseButton, x, y int) { + cr := a.contentRect(a.activePane) + a.handleMouse(tea.MouseMsg{X: cr.x + x, Y: cr.y + y, Button: button, Action: tea.MouseActionPress}) +} + +// calDouble clicks the same cell twice, quickly. +func calDouble(a *App, x, y int) { + calMouse(a, tea.MouseButtonLeft, x, y) + calMouse(a, tea.MouseButtonLeft, x, y) +} + +// calEmptyDayCell is a view-relative cell inside a day's box or column, low +// enough to be clear of its entries. +func calEmptyDayCell(r calRect) (int, int) { + return r.x + r.w/2, 1 + r.y + r.h - 1 +} + +func requireMenuTitled(t *testing.T, a *App, want, context string) { + t.Helper() + m, ok := a.overlay.(*menu) + if !ok { + t.Errorf("%s: overlay = %T, want the task menu", context, a.overlay) + return + } + if !strings.Contains(m.title, want) { + t.Errorf("%s: menu title = %q, want the task %q", context, m.title, want) + } +} + +func TestClickDaySelectsWithGridFocus(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + tv.selectDay(a, calDate(2026, time.November, 21)) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, s.name+" set-up") + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, s.target)) + calMouse(a, tea.MouseButtonLeft, x, y) + requireDay(t, tv, s.target.Format("2006-01-02"), s.name+" click") + requireFocus(t, tv, calFocusGrid, s.name+" click") + if tv.calc().sub != s.sub { + t.Errorf("%s: a click changed the layout to %v", s.name, tv.calc().sub) + } + } +} + +func TestClickDayFromItemsFocus(t *testing.T) { + for _, s := range calMouseSubs[:2] { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyEnter) + requireFocus(t, tv, calFocusItems, s.name+" set-up") + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, s.target)) + calMouse(a, tea.MouseButtonLeft, x, y) + requireDay(t, tv, s.target.Format("2006-01-02"), s.name+" click") + requireFocus(t, tv, calFocusGrid, s.name+" click") + } +} + +func TestDoubleClickDayActsAsEnter(t *testing.T) { + for _, s := range calMouseSubs { + // An empty day opens its daily note. There is no No-date strip here, + // because Enter on a grid with a strip moves focus to it first. + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, s.sub) + tv.selectDay(a, calDate(2026, time.November, 21)) + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, s.target)) + calDouble(a, x, y) + want := calDailyPath(a, s.target) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("%s: double-click on an empty day: active tab = %+v, want the daily note %s", s.name, tab, want) + } + } + for _, s := range calMouseSubs[:2] { + // A day with entries focuses them. + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + tv.selectDay(a, calDate(2026, time.November, 21)) + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, calDate(2026, time.November, 18))) + calDouble(a, x, y) + requireDay(t, tv, "2026-11-18", s.name+" double-click") + requireFocus(t, tv, calFocusItems, s.name+" double-click on a day with entries") + requireSelected(t, tv, "alpha job", s.name+" double-click") + } +} + +// calEntryRegion is the rectangle the 2026-11-18 entries are listed in: the +// item panel in month view, the day's column in week and day view. +func calEntryRegion(t *testing.T, tv *tasksView, sub calSub) calRect { + t.Helper() + if sub == calMonth { + return tv.calc().layout.items + } + return calBoxFor(t, tv, calDate(2026, time.November, 18)) +} + +// calFindEntry renders the calendar and finds the bravo entry on screen. +func calFindEntry(t *testing.T, a *App, tv *tasksView, name string, sub calSub) (int, int) { + t.Helper() + lines := calPaneBody(a, tv) + x, y, ok := calFindIn(lines, calEntryRegion(t, tv, sub), "bravo job") + if !ok { + t.Fatalf("%s: bravo job is not shown where the entries are listed:\n%s", name, calScreenText(lines)) + } + return x, y +} + +func TestClickEntryFocusesItems(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + x, y := calFindEntry(t, a, tv, s.name, s.sub) + calMouse(a, tea.MouseButtonLeft, x, y) + requireFocus(t, tv, calFocusItems, s.name+" click on an entry") + requireSelected(t, tv, "bravo job", s.name+" click on an entry") + requireDay(t, tv, "2026-11-18", s.name+" click on an entry") + } +} + +func TestRightClickEntryOpensTaskMenu(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + x, y := calFindEntry(t, a, tv, s.name, s.sub) + calMouse(a, tea.MouseButtonRight, x, y) + requireMenuTitled(t, a, "bravo job", s.name+" right-click on an entry") + } +} + +func TestDoubleClickEntryOpensSource(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + x, y := calFindEntry(t, a, tv, s.name, s.sub) + calDouble(a, x, y) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("%s: double-click on an entry: active tab = %+v, want work.md", s.name, tab) + } + } +} + +func TestClickStripTaskFocusesStrip(t *testing.T) { + for _, s := range calMouseSubs { + find := func() (*App, *tasksView, int, int) { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + lines := calPaneBody(a, tv) + x, y, ok := calFindIn(lines, tv.calc().layout.strip, "bbb strip") + if !ok { + t.Fatalf("%s: bbb strip is not shown in the No-date strip:\n%s", s.name, calScreenText(lines)) + } + return a, tv, x, y + } + + a, tv, x, y := find() + calMouse(a, tea.MouseButtonLeft, x, y) + requireFocus(t, tv, calFocusStrip, s.name+" click on a strip task") + requireSelected(t, tv, "bbb strip", s.name+" click on a strip task") + + a, _, x, y = find() + calMouse(a, tea.MouseButtonRight, x, y) + requireMenuTitled(t, a, "bbb strip", s.name+" right-click on a strip task") + + a, _, x, y = find() + calDouble(a, x, y) + if tab := a.activeTab(); tab == nil || tab.path != "strip.md" { + t.Errorf("%s: double-click on a strip task: active tab = %+v, want strip.md", s.name, tab) + } + } +} + +func TestWheelScrollsHourListOneRow(t *testing.T) { + for _, c := range []struct { + sub calSub + w, h int + }{{calWeek, 64, 12}, {calDay, 40, 10}} { + for _, half := range []bool{false, true} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, c.sub) + if half { + calPress(a, tv, vim.R('z')) + } + ctx := fmt.Sprintf("layout %v half=%v", c.sub, half) + first := func() string { + got := calHourLabels(calScreen(a, tv, c.w, c.h), calLabel24) + if len(got) == 0 { + t.Fatalf("%s: no hour rows shown", ctx) + } + return got[0] + } + day := calDayISO(tv) + if got := first(); got != "00" { + t.Fatalf("%s: first row = %s before the wheel, want 00", ctx, got) + } + next := "01" + if half { + next = "00:30" + } + // The app sends the wheel as three steps; the list moves one row. + tv.scrollBy(a, 3) + if got := first(); got != next { + t.Errorf("%s: first row after one wheel step down = %s, want %s", ctx, got, next) + } + tv.scrollBy(a, -3) + if got := first(); got != "00" { + t.Errorf("%s: first row after one step down and one up = %s, want 00", ctx, got) + } + tv.scrollBy(a, -3) + if got := first(); got != "00" { + t.Errorf("%s: wheel up at the top scrolled to %s, want 00", ctx, got) + } + requireDay(t, tv, day, ctx+" wheel") + } + } +} + +func TestSelectionPosFollowsFocus(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + p := a.activePane + cr := a.contentRect(p) + at := func(context string) (int, int, []string) { + t.Helper() + lines := calPaneBody(a, tv) + x, y, ok := tv.selectionPos(a, p) + if !ok { + t.Fatalf("%s %s: selectionPos reported no position", s.name, context) + } + return x - cr.x, y - cr.y, lines + } + + // Grid focus: inside the selected day's box or column. + x, y, _ := at("grid focus") + if box := calBoxFor(t, tv, calDate(2026, time.November, 18)); !box.contains(x, y-1) { + t.Errorf("%s: grid focus position (%d,%d) is outside the selected day's box %+v", s.name, x, y-1, box) + } + tv.selectDay(a, s.target) + x, y, _ = at("moved selection") + if box := calBoxFor(t, tv, s.target); !box.contains(x, y-1) { + t.Errorf("%s: position (%d,%d) did not follow the selection to %s's box %+v", s.name, x, y-1, s.target.Format("2006-01-02"), box) + } + tv.selectDay(a, calDate(2026, time.November, 18)) + + // Items focus: on the selected entry's line. + calPress(a, tv, vim.KeyEnter, vim.R('j')) + requireFocus(t, tv, calFocusItems, s.name+" set-up") + requireSelected(t, tv, "bravo job", s.name+" set-up") + _, y, lines := at("items focus") + if y < 0 || y >= len(lines) || !strings.Contains(lines[y], "bravo job") { + t.Errorf("%s: items focus position row %d is not the selected entry's line", s.name, y) + } + calPress(a, tv, vim.R('k')) + _, y2, lines := at("items focus moved") + if y2 == y || y2 < 0 || y2 >= len(lines) || !strings.Contains(lines[y2], "alpha job") { + t.Errorf("%s: position did not follow the selection to alpha job: row %d then %d", s.name, y, y2) + } + + // Strip focus: on the selected strip task's line. + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, s.name+" set-up") + calPress(a, tv, vim.R('j')) + requireSelected(t, tv, "bbb strip", s.name+" set-up") + _, y, lines = at("strip focus") + if y < 0 || y >= len(lines) || !strings.Contains(lines[y], "bbb strip") { + t.Errorf("%s: strip focus position row %d is not the selected strip task's line", s.name, y) + } + } +} From d3401ca3c5df359bb887c6c44212f1c2e7efccf0 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 13:49:13 -0600 Subject: [PATCH 41/65] Fix(tui): every new note opens in the editor, not only a blank one #26 shows the editor for a note made through createNote (`:new`, the new-note prompt, a quick note, Enter on no match in Search notes, the sidebar's new note, following a link to a missing note) when the default view mode is preview or split. A daily, weekly or monthly note created on first open, a note from a template, a `:taskfile` task and the daily note `:rollover` creates make their note without createNote, so they still opened in preview, with the template's cursor placed in a buffer nobody could see. They now call the same showNewNoteInEditor right after opening the note, as the desktop's rememberEditModeForCreatedNote covers its new, daily, template and task notes (ZenNotes/zennotes da328744). Only the new note's tab changes: the default view mode and the remembered per-note modes stay as they were, and a note that is already there, rollover into an existing daily note included, still opens in the default. A database record page keeps the default too, as on the desktop. Tests: every creation path under a preview and a split default, with keep_view_mode_across_notes on, opens in edit and leaves the default and the remembered modes alone; the new periodic, template, task-file and rollover cases fail without this change. An existing daily note, rollover into it and an existing note open in preview. Checked by hand in zn tui with default_view_mode = "preview": a new daily note and a note from the Meeting Notes template open in the editor, Welcome opens in preview. --- internal/tui/commands_parity.go | 6 ++ internal/tui/newnote_mode_test.go | 102 ++++++++++++++++++++++++++++++ internal/tui/noteops.go | 20 ++++-- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index f041189..56d10cb 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -529,6 +529,7 @@ func (a *App) rolloverNow() error { } today := time.Now() _, _, title, rel := a.periodicLocation(periodic.Daily, today) + created := false if _, ok := a.noteMeta(rel); !ok { body := a.periodicBody(periodic.Daily, title, today) meta, err := a.backend.CreateNote(a.ctx, vault.FolderInbox, title, "", &body) @@ -538,12 +539,16 @@ func (a *App) rolloverNow() error { a.ignoreChange(meta.Path) a.addNoteToIndex(meta) rel = meta.Path + created = true } n := a.rolloverInto(rel, today) if n == 0 { a.notify("Nothing to roll over") } a.openNote(rel, true) + if created { + a.showNewNoteInEditor(rel) + } return nil } @@ -906,6 +911,7 @@ func (a *App) newTaskFile(target string) error { a.ignoreChange(meta.Path) a.addNoteToIndex(meta) a.openNote(meta.Path, true) + a.showNewNoteInEditor(meta.Path) a.afterTaskChange() }) return nil diff --git a/internal/tui/newnote_mode_test.go b/internal/tui/newnote_mode_test.go index 0d6aceb..d9303e6 100644 --- a/internal/tui/newnote_mode_test.go +++ b/internal/tui/newnote_mode_test.go @@ -2,8 +2,12 @@ package tui import ( "testing" + "time" + "github.com/ZenNotes/tui/internal/periodic" + "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" ) // A freshly created note is entered in insert mode, so it must show the @@ -20,3 +24,101 @@ func TestCreateNoteOpensInEditMode(t *testing.T) { t.Fatalf("new note opened in mode %q, want edit", tb.mode) } } + +// Every way of creating a note opens it in the editor under a preview or +// split default, as the desktop does, and leaves the default view mode and +// the remembered per-note modes alone. +func TestEveryNewNoteOpensInEditMode(t *testing.T) { + paths := map[string]func(t *testing.T, a *App){ + ":new": func(t *testing.T, a *App) { + if _, err := a.runEx(nil, vim.ExCommand{Name: "new", Args: "Fresh"}); err != nil { + t.Fatal(err) + } + }, + "quick note": func(t *testing.T, a *App) { + a.newQuickNote() + }, + "search palette": func(t *testing.T, a *App) { + a.openNoteSearch("Brand new idea") + a.overlay.handleKey(a, enterKey()) + }, + "link to a missing note": func(t *testing.T, a *App) { + a.followLink(linkTarget{kind: "wikilink", target: "Not yet written"}) + a.overlay.handleKey(a, enterKey()) + }, + "daily note": func(t *testing.T, a *App) { + a.openPeriodicOn(periodic.Daily, time.Now()) + }, + "weekly note": func(t *testing.T, a *App) { + a.idx.settings.WeeklyNotes.Enabled = true + a.openPeriodicOn(periodic.Weekly, time.Now()) + }, + "template": func(t *testing.T, a *App) { + a.createFromTemplate(templates.Template{ID: "custom:x", Name: "X", Body: "# {{title}}\n\n"}) + typeText(a, "From template") + a.overlay.handleKey(a, enterKey()) + }, + "task file": func(t *testing.T, a *App) { + if err := a.newTaskFile(""); err != nil { + t.Fatal(err) + } + typeText(a, "Write it") + a.overlay.handleKey(a, enterKey()) + }, + "rollover": func(t *testing.T, a *App) { + a.idx.settings.DailyNotes.Enabled = true + if err := a.rolloverNow(); err != nil { + t.Fatal(err) + } + }, + } + for name, create := range paths { + for _, def := range []paneMode{modePreview, modeSplit} { + t.Run(name+"/"+string(def), func(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.DefaultPaneMode = string(def) + a.prefs.RetainViewMode = true + create(t, a) + tb := a.activeTab() + if tb == nil || tb.path == "" || isVirtualPath(tb.path) { + t.Fatalf("no note tab after creating, got %+v", tb) + } + if tb.mode != modeEdit { + t.Fatalf("new note %s opened in mode %q, want edit", tb.path, tb.mode) + } + if a.prefs.DefaultPaneMode != string(def) { + t.Fatalf("default view mode changed to %q", a.prefs.DefaultPaneMode) + } + if len(a.noteModes) != 0 { + t.Fatalf("remembered modes changed: %v", a.noteModes) + } + }) + } + } +} + +// A note that is already there opens in the default view mode, even through +// the commands that would have created it. +func TestExistingNoteKeepsDefaultMode(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.idx.settings.DailyNotes.Enabled = true + a.openPeriodicOn(periodic.Daily, time.Now()) + daily := a.activeTab().path + a.closeTab(a.activePane, a.activePane.findTab(daily)) + a.prefs.DefaultPaneMode = string(modePreview) + for name, open := range map[string]func(){ + "daily note": func() { a.openPeriodicOn(periodic.Daily, time.Now()) }, + "rollover": func() { _ = a.rolloverNow() }, + "note": func() { a.openNote("note.md", true) }, + } { + open() + tb := a.activeTab() + if tb == nil { + t.Fatalf("%s: no active tab", name) + } + if tb.mode != modePreview { + t.Fatalf("%s: %s opened in mode %q, want preview", name, tb.path, tb.mode) + } + a.closeTab(a.activePane, a.activePane.active) + } +} diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index 6b8bbfc..d176e12 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -29,12 +29,7 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.addNoteToIndex(meta) if open { a.openNote(meta.Path, true) - // The new note is entered for typing, so show the editor whatever - // the default pane mode is. - if t := a.activeTab(); t != nil && t.view == nil && t.path == meta.Path { - t.mode = modeEdit - a.layout() - } + a.showNewNoteInEditor(meta.Path) a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) if a.prefs.VimMode { @@ -45,6 +40,17 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.refreshIndex() } +// showNewNoteInEditor shows a note just created and opened in the editor, +// whatever the default view mode: a new note is there to be written in, as +// on the desktop. Only its tab changes, so the default view mode and the +// remembered per-note modes stay as they were. +func (a *App) showNewNoteInEditor(path string) { + if t := a.activeTab(); t != nil && t.view == nil && t.path == path { + t.mode = modeEdit + a.layout() + } +} + // addNoteToIndex makes a fresh note visible before the next full reload. func (a *App) addNoteToIndex(meta vault.NoteMeta) { if a.idx == nil { @@ -609,6 +615,7 @@ func (a *App) openPeriodicOn(kind periodic.Kind, date time.Time) { a.rolloverUnfinishedTasks(meta.Path, date) } a.openNote(meta.Path, true) + a.showNewNoteInEditor(meta.Path) a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) }) a.refreshIndex() } @@ -682,6 +689,7 @@ func (a *App) createFromTemplate(t templates.Template) { a.ignoreChange(meta.Path) a.addNoteToIndex(meta) a.openNote(meta.Path, true) + a.showNewNoteInEditor(meta.Path) if rendered.CursorOffset >= 0 { a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.SetCursor(posForOffset(buf.ed.Text(), rendered.CursorOffset)) }) } From 730f394630f9d787c69a54c5ded31713a2556085 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 13:49:14 -0600 Subject: [PATCH 42/65] Fix(tui): the calendars keep `[` and `]`, and the Kanban board keeps `g` `[b`, `]b`, `gt` and `gT` now run from every view and panel, so the first key of each waits for the next one to show whether a sequence follows. Where a surface binds that key on its own, the wait delayed the key's own action by one press: `]` and `[` in the Tasks calendar and the calendar panel changed the month only when another key followed (and `]]` jumped two months at once), `g` on the Kanban board changed the grouping late, and `g` in a database's raw view went to the top late. A surface that binds one of those keys on its own now keeps it: the calendars keep `[` and `]` while a day has focus, the Kanban board keeps `g`, and a database's raw view keeps `g`. There, the sequence that starts with the kept key is not a tab switch (`]b` and `[b` in the calendars, `gt` and `gT` on the board); the other pair still is, and every other view and panel switches tabs with all four. The desktop app draws the same line: its calendar panel keeps its own keys (ZenNotes/zennotes#285) and the Atlas map keeps `[` and `]` (ZenNotes/zennotes#670). Tests, written red first: `]` and `[` change the month at once in the Tasks calendar and the calendar panel, and `g` regroups the Kanban board at once while `]b` still switches tabs from it. --- internal/tui/input.go | 24 ++++++++++++++++++++ internal/tui/panels.go | 4 ++++ internal/tui/tabsequence_test.go | 38 ++++++++++++++++++++++++++++++++ internal/tui/views_database.go | 4 ++++ internal/tui/views_tasks.go | 10 +++++++++ 5 files changed, 80 insertions(+) diff --git a/internal/tui/input.go b/internal/tui/input.go index 1d6f878..484ae08 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -155,6 +155,9 @@ func (a *App) tabSequenceKey(k vim.Key, held []vim.Key) bool { a.surfaceKey(seq[0]) return false } + if a.surfaceOwnsKey(k) { + return false + } for _, id := range seqIDs { keys := bindingKeys(a.keymap.Binding(id)) if len(keys) > 1 && sameKey(k, keys[0]) { @@ -169,6 +172,27 @@ func (a *App) tabSequenceKey(k vim.Key, held []vim.Key) bool { return false } +// keyOwner is a surface that binds a key on its own, such as the calendars' +// `[` and `]` for the month or the Kanban board's `g` for its grouping. A +// buffer or tab sequence does not hold that key back, so it acts at once. +type keyOwner interface { + ownsKey(a *App, k vim.Key) bool +} + +func (a *App) surfaceOwnsKey(k vim.Key) bool { + var s any + switch a.focus { + case focusCalendar: + s = a.calendar + case focusPane: + if t := a.activeTab(); t != nil && t.view != nil { + s = t.view + } + } + o, ok := s.(keyOwner) + return ok && o.ownsKey(a, k) +} + // completionKey takes the keys insert-mode completion owns: the ones that // drive an open tag menu, and Vim's Ctrl-X prefix, under which Ctrl-O (omni) // and Ctrl-] (tags) open that menu on demand. Ctrl-Space also completes diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 65c048c..96fc8ae 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -329,6 +329,10 @@ func (p *calendarPanel) render(a *App, w, h int) string { return fitBlock(strings.Join(lines, "\n"), w, h) } +func (p *calendarPanel) ownsKey(a *App, k vim.Key) bool { + return k.IsRune('[') || k.IsRune(']') +} + func (p *calendarPanel) handleKey(a *App, k vim.Key) { if k.Is("esc") { a.focus = focusPane diff --git a/internal/tui/tabsequence_test.go b/internal/tui/tabsequence_test.go index 7bd84cc..afd9e5e 100644 --- a/internal/tui/tabsequence_test.go +++ b/internal/tui/tabsequence_test.go @@ -150,3 +150,41 @@ func TestHeldKeyIsDroppedWhenVimModeTurnsOff(t *testing.T) { typeKeys(a, "b") requireDropped(t, a, pv) } + +func TestCalendarBracketsChangeTheMonthAtOnce(t *testing.T) { + a, _ := newFeatureTestApp(t) + if err := a.openTasksMode("calendar"); err != nil { + t.Fatal(err) + } + tv := a.activeTab().view.(*tasksView) + month := tv.month + typeKeys(a, "]") + if want := month.AddDate(0, 1, 0); !tv.month.Equal(want) { + t.Fatalf("Tasks calendar ]: month = %v, want %v", tv.month, want) + } + a.toggleSidePanel(&a.calendarOpen, focusCalendar) + month = a.calendar.month + typeKeys(a, "[") + if want := month.AddDate(0, -1, 0); !a.calendar.month.Equal(want) { + t.Fatalf("calendar panel [: month = %v, want %v", a.calendar.month, want) + } +} + +func TestKanbanGChangesTheGroupingAtOnce(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.openNote("note.md", true) + if err := a.openTasksMode("kanban"); err != nil { + t.Fatal(err) + } + board := a.activePane.active + tv := a.activeTab().view.(*tasksView) + before := tv.groupBy + typeKeys(a, "g") + if tv.groupBy == before { + t.Fatalf("g on the board: still grouped by %q", tv.groupBy) + } + typeKeys(a, "]b") + if a.activePane.active == board { + t.Fatal("]b from the board did not switch tabs") + } +} diff --git a/internal/tui/views_database.go b/internal/tui/views_database.go index c2b811e..0ae58e6 100644 --- a/internal/tui/views_database.go +++ b/internal/tui/views_database.go @@ -638,6 +638,10 @@ func (v *databaseView) renderRaw(a *App, w, h int) []string { // --- keys --- +func (v *databaseView) ownsKey(a *App, k vim.Key) bool { + return v.doc != nil && v.raw && k.IsRune('g') +} + func (v *databaseView) handleKey(a *App, k vim.Key) bool { if v.doc == nil { return false diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index b5fd016..0f5c523 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -766,6 +766,16 @@ func sameDay(a, b time.Time) bool { // --- keys --- +func (v *tasksView) ownsKey(a *App, k vim.Key) bool { + switch v.mode { + case "kanban": + return k.IsRune('g') + case "calendar": + return !v.dayFocus && (k.IsRune('[') || k.IsRune(']')) + } + return false +} + func (v *tasksView) handleKey(a *App, k vim.Key) bool { if k.IsRune('S') { a.savedTaskFilters() From 97a823c631c2fca1124300d52440d6c26e76c686 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 13:49:14 -0600 Subject: [PATCH 43/65] Fix(tui): the status bar shows a held `[`, `]` or `g` Outside the editor, the first key of `[b`, `]b`, `gt` or `gT` now waits for the next one, but nothing on screen said so: a `]` typed in Tasks or the sidebar looked lost until another key arrived. The editor and the lists already show a pending key after the mode in the status bar; a held key now shows there too. Test, written red first: the status bar shows `]` while it is held. --- internal/tui/app.go | 2 ++ internal/tui/tabsequence_test.go | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/internal/tui/app.go b/internal/tui/app.go index 405f362..ac6fdf2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -906,6 +906,8 @@ func (a *App) renderStatusBar() string { suffix = "SPC " + a.leader.pendingLabel() } else if len(a.navKeys) > 0 { suffix = vim.KeysString(a.navKeys) + } else if len(a.tabSeq) > 0 { + suffix = vim.KeysString(a.tabSeq) } else if a.paneKeys { suffix = "^W" } diff --git a/internal/tui/tabsequence_test.go b/internal/tui/tabsequence_test.go index afd9e5e..3e76af4 100644 --- a/internal/tui/tabsequence_test.go +++ b/internal/tui/tabsequence_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -188,3 +189,14 @@ func TestKanbanGChangesTheGroupingAtOnce(t *testing.T) { t.Fatal("]b from the board did not switch tabs") } } + +func TestStatusBarShowsAHeldKey(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.activePane.tabs = append(a.activePane.tabs, &tab{path: "virtual:a", view: &probeView{}}) + a.activePane.active = len(a.activePane.tabs) - 1 + a.focus = focusPane + typeKeys(a, "]") + if !strings.Contains(a.renderStatusBar(), "]") { + t.Fatalf("the status bar does not show the held key: %q", a.renderStatusBar()) + } +} From 65d44f3b7dbc022341196bb68a7e74a981bf32c9 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 13:49:14 -0600 Subject: [PATCH 44/65] Test(tui): `]b` and `gt` stay off with Vim mode off With Vim mode off, a letter or bracket sequence is never a command. The existing tests drop a held key when Vim mode turns off; this one types `]b` and `gt` in a view with Vim mode off and checks that the tab stays and the view gets all four keys. --- internal/tui/tabsequence_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/tui/tabsequence_test.go b/internal/tui/tabsequence_test.go index 3e76af4..56863d1 100644 --- a/internal/tui/tabsequence_test.go +++ b/internal/tui/tabsequence_test.go @@ -200,3 +200,12 @@ func TestStatusBarShowsAHeldKey(t *testing.T) { t.Fatalf("the status bar does not show the held key: %q", a.renderStatusBar()) } } + +func TestTabSequencesNeedVimMode(t *testing.T) { + a, pv := tabSequenceApp() + a.prefs.VimMode = false + typeKeys(a, "]bgt") + if a.activePane.active != 0 || len(pv.keys) != 4 { + t.Fatalf("with Vim off: active = %d keys = %v", a.activePane.active, pv.keys) + } +} From fecb57b3f8d7f43fd868d58a3b3d2513be235095 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 13:49:14 -0600 Subject: [PATCH 45/65] Fix(tui): every new note opens in the editor, with frontmatter view modes off too A new note is for typing: Ctrl+N, Search notes' create, a template, a periodic note made on first open, a new task file and rollover all put the cursor in it. The desktop app opens such a note in Edit whatever the Default view mode says (rememberEditModeForCreatedNote). The view-mode change marked every one of those notes as new, but opened it in the editor only while a view-mode field or rule was set. With both off, a default of preview or split still showed the new note read-only, so the same Ctrl+N behaved differently depending on an unrelated setting. A note marked as new now always opens in the editor. The mark is still cleared by that first open, the tab's mode is the only thing it sets (the remembered per-note modes and keep_view_mode_across_notes stay as they were), and a later open of the note follows the usual rules. --- internal/tui/noteops.go | 2 +- internal/tui/panes.go | 2 +- internal/tui/viewmode_frontmatter.go | 3 ++- internal/tui/viewmode_open_test.go | 39 ++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index 0ab3847..4d12459 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -924,7 +924,7 @@ func (a *App) openNoteQuiet(path string) { if mode == "" { mode = modeEdit } - if justCreated && a.frontmatterModeOn() { + if justCreated { mode = modeEdit } t := &tab{path: path, mode: mode} diff --git a/internal/tui/panes.go b/internal/tui/panes.go index 441f3f9..211ae2f 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -241,7 +241,7 @@ func (a *App) openNote(path string, focus bool) { if remembered, ok := a.noteModes[path]; ok && !a.prefs.KeepViewModeAcrossNotes() { mode = remembered } - if justCreated && a.frontmatterModeOn() { + if justCreated { mode = modeEdit } t := &tab{path: path, mode: mode} diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go index 8848165..8b8f8ee 100644 --- a/internal/tui/viewmode_frontmatter.go +++ b/internal/tui/viewmode_frontmatter.go @@ -113,7 +113,8 @@ func forceTabMode(t *tab, mode paneMode) { } // markNewNote records that path was just created and is about to be opened, -// so the open starts in the editor instead of taking a mode from frontmatter. +// so the open starts in the editor instead of taking a mode from the default +// view mode, a remembered mode or frontmatter, as the desktop app does. func (a *App) markNewNote(path string) { a.newNotes[path] = true } diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index acebed7..8c0e959 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -571,6 +571,45 @@ func TestReopenedNewNoteFollowsFrontmatter(t *testing.T) { } } +// A new note is for typing, so it opens in the editor with the frontmatter +// feature off too, whatever the default view mode. +func TestNewNoteOpensInEditWithFeatureOff(t *testing.T) { + for _, tc := range []struct { + name string + create func(t *testing.T, a *App) + }{ + {"blank", func(t *testing.T, a *App) { + a.createNote(vault.FolderInbox, "Blank", "", nil, true) + }}, + {"template", func(t *testing.T, a *App) { + a.createFromTemplate(templates.Template{ID: "custom:x", Name: "X", Body: "# New\n"}) + typeText(a, "From Template") + a.overlay.handleKey(a, enterKey()) + }}, + {"periodic", func(t *testing.T, a *App) { + useDailyTemplate(t, a, "# Daily\n") + a.openPeriodicOn(periodic.Daily, time.Now()) + }}, + {"task file", func(t *testing.T, a *App) { + if err := a.newTaskFile(""); err != nil { + t.Fatal(err) + } + typeText(a, "Write it") + a.overlay.handleKey(a, enterKey()) + }}, + } { + for _, def := range []string{"preview", "split"} { + t.Run(tc.name+" "+def, func(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.DefaultPaneMode = def + a.deferReads = true + tc.create(t, a) + wantEditBeforeAndAfterLoad(t, a) + }) + } + } +} + // vaultSnapshot maps every file under root, hidden directories included, to its bytes. func vaultSnapshot(t *testing.T, root string) map[string]string { t.Helper() From 28a91f146cea3b193b958b2d940f32b46036894f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Sun, 1 Nov 2026 13:49:14 -0600 Subject: [PATCH 46/65] Docs: zn help and the README say the tab keys work from any view The README's key table and the manual now say `gt`, `gT`, `]b` and `[b` switch tabs from any view or panel, and that the calendars keep `[` `]` for the month and the Kanban board keeps `g` for its grouping. --- README.md | 2 +- internal/tui/helpdata.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b5a6785..359d5db 100644 --- a/README.md +++ b/README.md @@ -517,7 +517,7 @@ keeps them open until you choose an action, press Space again, or cancel. | `Ctrl+B` | Toggle the sidebar | | `Ctrl+W` then `v` `s` `h` `j` `k` `l` `q` `o` `=` | Split, move between and close panes | | `Ctrl+O` `Ctrl+I` | Jump back and forward across notes | -| `gt` `gT` `]b` `[b` | Next and previous tab | +| `gt` `gT` `]b` `[b` | Next and previous tab, from any view or panel (the calendars keep `[` `]` for the month, the Kanban board keeps `g` for its grouping) | | `:` | Ex commands; `Space ;` or `Ctrl+G` opens the command palette | | `?` | The keys of the current view; `:help` is the full manual | | `F2` | Context actions, including when Vim mode is off | diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index dcb1498..16d14b5 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -106,7 +106,7 @@ func helpLines(a *App, filter string) []string { ":q :bd\tClose the current tab; closing the last tab quits", ":w :wa :wq :x\tSave, save all, or save and close the current tab", ":e :new :vs :sp :only\tOpen, create, split", - ":bn :bp ]b [b gt gT\tNext and previous tab", + ":bn :bp ]b [b gt gT\tNext and previous tab, from any view or panel; the calendars keep [ ] for the month and the Kanban board keeps g for its grouping", ":pin :tabmove -1|+1|first|last\tPin or unpin the tab (pinned tabs sit first and survive :tabonly); reorder tabs", ":link, or [[ in insert mode\tPick a note and insert a wikilink to it", "# and a letter in insert mode\tOffers the vault's tags under the cursor, most used first, as the desktop app does; inside a frontmatter tags: value it completes bare tags. Ctrl+N / Ctrl+P or ↑↓ move, Enter / Tab / Ctrl+Y accept, Ctrl+E dismisses, typing narrows", From 2dbedd74bf32c02211fb322b6856529cb9531ffd Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:49:18 -0600 Subject: [PATCH 47/65] Fix(tui): the external editor writes to the terminal, not to a pipe Bubble Tea gives a program it runs the program's own output as stdout. With the cursor wrapper in place, that output is no longer os.Stdout but a writer that is not a file, so os/exec connected the child's stdout to a pipe. `Space l e`, `:editor` and opening config.toml then broke: Vim printed "Warning: Output is not to a terminal" and Neovim drew a blank screen, while the same keys on main opened the note normally. execOnTerminal now sets the command's stdout to the terminal file the wrapper writes to (leaving a stdout the command already has alone), resets the cursor shape, and queues the hand-off. The external editor and the config action both go through it. Test: the command's stdout is the wrapper's terminal, the cursor is reset and released, and a stdout set beforehand is kept. Checked by hand in tmux with Vim and Neovim as $EDITOR, and with a probe script that reports `[ -t 1 ]` (now a tty, as on main). --- internal/tui/cursor.go | 21 ++++++++++++++++++++- internal/tui/cursor_suspend_test.go | 20 ++++++++++++++++++++ internal/tui/cursorout.go | 4 ++++ internal/tui/editorext.go | 5 ++--- internal/tui/settings_live.go | 3 +-- 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 5951e98..61efa8f 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -1,6 +1,12 @@ package tui -import "github.com/ZenNotes/tui/internal/vim" +import ( + "os/exec" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/vim" +) // bufferCursorShape is the terminal cursor shape the buffer wants while its // text is being edited, or a normal-mode r waits for its character. ok is false @@ -118,3 +124,16 @@ func (a *App) resumeCursor() { a.cursorOut.Hold() } } + +// execOnTerminal hands the terminal to cmd until it exits. Bubble Tea gives +// the program it runs the program's own output, which is the cursor wrapper, +// and a writer that is not a file reaches the child as a pipe: Vim then warns +// that its output is not a terminal and Neovim draws nothing. So cmd writes +// to the terminal itself. +func (a *App) execOnTerminal(cmd *exec.Cmd, done tea.ExecCallback) { + if cmd.Stdout == nil && a.cursorOut != nil { + cmd.Stdout = a.cursorOut.terminal() + } + a.suspendCursor() + a.queue(tea.ExecProcess(cmd, done)) +} diff --git a/internal/tui/cursor_suspend_test.go b/internal/tui/cursor_suspend_test.go index ecc211b..58cd042 100644 --- a/internal/tui/cursor_suspend_test.go +++ b/internal/tui/cursor_suspend_test.go @@ -1,6 +1,8 @@ package tui import ( + "io" + "os/exec" "strings" "testing" @@ -88,6 +90,24 @@ func TestCursorSuspendOpenConfig(t *testing.T) { assertReleasedWithReset(t, a, term, "open config") } +func TestCursorExternalProgramWritesToTheTerminal(t *testing.T) { + a, _, term := suspendApp(t) + cmd := exec.Command("true") + a.execOnTerminal(cmd, nil) + if cmd.Stdout != io.Writer(term) { + t.Fatalf("the program writes to %T, want the terminal itself", cmd.Stdout) + } + assertReleasedWithReset(t, a, term, "an external program") + + own := &strings.Builder{} + cmd = exec.Command("true") + cmd.Stdout = own + a.execOnTerminal(cmd, nil) + if cmd.Stdout != io.Writer(own) { + t.Fatal("a stdout the command already had was replaced") + } +} + func returnMsgs() map[string]tea.Msg { return map[string]tea.Msg{ "editor": editorDoneMsg{path: "Alpha.md"}, diff --git a/internal/tui/cursorout.go b/internal/tui/cursorout.go index e444007..7bf2604 100644 --- a/internal/tui/cursorout.go +++ b/internal/tui/cursorout.go @@ -129,6 +129,10 @@ func (w *cursorOut) Hold() { _, _ = w.dst.Write([]byte(w.want.sequence())) } +// terminal is the file the wrapper writes to, for a program that takes the +// terminal over and has to see it as one. +func (w *cursorOut) terminal() cursorFile { return w.dst } + func (w *cursorOut) Read(p []byte) (int, error) { return w.dst.Read(p) } func (w *cursorOut) Close() error { return w.dst.Close() } func (w *cursorOut) Fd() uintptr { return w.dst.Fd() } diff --git a/internal/tui/editorext.go b/internal/tui/editorext.go index b751d0a..bd49162 100644 --- a/internal/tui/editorext.go +++ b/internal/tui/editorext.go @@ -56,10 +56,9 @@ func (a *App) editActiveInEditor() { return } path := buf.path - a.suspendCursor() - a.queue(tea.ExecProcess(cmd, func(err error) tea.Msg { + a.execOnTerminal(cmd, func(err error) tea.Msg { return editorDoneMsg{path: path, tmp: tmp, err: err} - })) + }) } // finishExternalEdit reloads the note after the editor closed. diff --git a/internal/tui/settings_live.go b/internal/tui/settings_live.go index e96a2f7..bdb243e 100644 --- a/internal/tui/settings_live.go +++ b/internal/tui/settings_live.go @@ -113,8 +113,7 @@ func (a *App) editConfig() error { if err != nil { return err } - a.suspendCursor() - a.queue(tea.ExecProcess(cmd, func(err error) tea.Msg { return configEditedMsg{err} })) + a.execOnTerminal(cmd, func(err error) tea.Msg { return configEditedMsg{err} }) return nil } func (a *App) saveKeymap(id, binding string) error { From 015a97e2a072106b41003c94ce425d7a5bc4a6c5 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:49:18 -0600 Subject: [PATCH 48/65] Fix(tui): zn tui reads the terminal when stdin is redirected runProgram passed os.Stdin to Bubble Tea as a custom input. Bubble Tea only falls back to opening the terminal when it picks the input itself, so `zn tui < /dev/null` (or a launcher that gives zn a pipe) put no terminal in raw mode: keys were echoed by the tty and never reached zn, and it could not be quit from the keyboard. main opened /dev/tty there. Run now passes no input and runProgram adds WithInput only when a caller gives one, as the tests do with their pipe. Checked by hand in tmux: `zn tui < /dev/null` opens a note from `:e` again. --- internal/tui/app.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 0c3bc21..bcc38f8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -200,17 +200,21 @@ func Run(ctx context.Context, opts Options) error { if prefs.TerminalMouse { teaOpts = append(teaOpts, tea.WithMouseCellMotion()) } - return runProgram(app, os.Stdout, os.Stdin, teaOpts...) + return runProgram(app, os.Stdout, nil, teaOpts...) } // runProgram runs app on out and in with the cursor wrapper between Bubble Tea // and the terminal. Bubble Tea recovers panics and p.Run returns an error, so -// the cursor is restored on every path after p.Run returns. +// the cursor is restored on every path after p.Run returns. A nil in keeps +// Bubble Tea's own input, which opens the terminal when stdin is redirected. func runProgram(app *App, out cursorFile, in io.Reader, teaOpts ...tea.ProgramOption) error { wrapper := newCursorOut(out) app.cursorOut = wrapper defer wrapper.Reset() - teaOpts = append([]tea.ProgramOption{tea.WithOutput(wrapper), tea.WithInput(in)}, teaOpts...) + teaOpts = append([]tea.ProgramOption{tea.WithOutput(wrapper)}, teaOpts...) + if in != nil { + teaOpts = append(teaOpts, tea.WithInput(in)) + } p := tea.NewProgram(app, teaOpts...) app.program = p _, err := p.Run() From e728db0cb43534e3ebc2252fc6bf173c81ba8a76 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:49:18 -0600 Subject: [PATCH 49/65] Fix(tui): Ctrl+Z on Windows leaves the cursor alone Ctrl+Z and `:suspend` reset the cursor shape and released the wrapper before queueing tea.Suspend, and a ResumeMsg took it back. Bubble Tea cannot suspend on Windows: Suspend does nothing there and no ResumeMsg follows, so the wrapper stayed released for the rest of the session. The terminal cursor was left at the bottom in its default shape, and in insert mode, where the editor no longer draws its block, nothing marked the insertion point. suspend now hands the cursor over only where Bubble Tea can suspend. Both the chord and the ex command go through it. Tests: the existing suspend tests pin suspend support on, so they run on the Windows CI too; with it off, Ctrl+Z and `:suspend` keep the wrapper held, write no reset, and the bar stays. --- internal/tui/commands.go | 3 +-- internal/tui/cursor.go | 13 ++++++++++++ internal/tui/cursor_suspend_test.go | 32 +++++++++++++++++++++++++++++ internal/tui/input.go | 3 +-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 947293d..dbdcbd6 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -635,8 +635,7 @@ func (a *App) commandTable() []command { return nil }}, {names: []string{"suspend", "stop"}, title: "Suspend to the shell (fg to return)", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { - a.suspendCursor() - a.queue(tea.Suspend) + a.suspend() return nil }}, {names: []string{"mouse"}, title: "Toggle mouse support", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 61efa8f..9f4a74d 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -2,6 +2,7 @@ package tui import ( "os/exec" + "runtime" tea "github.com/charmbracelet/bubbletea" @@ -125,6 +126,18 @@ func (a *App) resumeCursor() { } } +// canSuspend is false where Bubble Tea cannot suspend: on Windows Suspend +// does nothing and no ResumeMsg follows, so the cursor must stay zn's. +var canSuspend = runtime.GOOS != "windows" + +// suspend hands the terminal to the shell until `fg`. +func (a *App) suspend() { + if canSuspend { + a.suspendCursor() + } + a.queue(tea.Suspend) +} + // execOnTerminal hands the terminal to cmd until it exits. Bubble Tea gives // the program it runs the program's own output, which is the cursor wrapper, // and a writer that is not a file reaches the child as a pipe: Vim then warns diff --git a/internal/tui/cursor_suspend_test.go b/internal/tui/cursor_suspend_test.go index 58cd042..49f3cd5 100644 --- a/internal/tui/cursor_suspend_test.go +++ b/internal/tui/cursor_suspend_test.go @@ -43,13 +43,23 @@ func assertReleasedWithReset(t *testing.T, a *App, term *fakeTerm, what string) } } +// allowSuspend sets whether the platform can suspend for one test. +func allowSuspend(t *testing.T, ok bool) { + t.Helper() + saved := canSuspend + canSuspend = ok + t.Cleanup(func() { canSuspend = saved }) +} + func TestCursorSuspendCtrlZ(t *testing.T) { + allowSuspend(t, true) a, _, term := suspendApp(t) a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) assertReleasedWithReset(t, a, term, "Ctrl+Z") } func TestCursorSuspendExCommand(t *testing.T) { + allowSuspend(t, true) a, buf, term := suspendApp(t) if _, err := a.runEx(buf, vim.ExCommand{Name: "suspend"}); err != nil { t.Fatal(err) @@ -57,7 +67,29 @@ func TestCursorSuspendExCommand(t *testing.T) { assertReleasedWithReset(t, a, term, ":suspend") } +// Bubble Tea cannot suspend on Windows and sends no ResumeMsg, so Ctrl+Z and +// :suspend there must leave the cursor as it is. +func TestCursorSuspendUnsupportedKeepsCursor(t *testing.T) { + allowSuspend(t, false) + a, buf, term := suspendApp(t) + a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) + if _, err := a.runEx(buf, vim.ExCommand{Name: "suspend"}); err != nil { + t.Fatal(err) + } + if cursorReleased(a) { + t.Fatal("the wrapper was released although nothing will resume it") + } + if strings.Contains(term.all(), defaultShape) { + t.Fatalf("wrote %q, want no reset", term.all()) + } + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want the bar still", a.cursor) + } +} + func TestCursorSuspendResumeWritesBar(t *testing.T) { + allowSuspend(t, true) a, _, term := suspendApp(t) a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) term.clear() diff --git a/internal/tui/input.go b/internal/tui/input.go index 21507c3..dd888a8 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -264,8 +264,7 @@ func (a *App) chordKey(k vim.Key, editing bool) bool { if editing && !a.prefs.VimMode { return false } - a.suspendCursor() - a.queue(tea.Suspend) + a.suspend() case a.tabSelectChord(k): case editing && a.prefs.VimMode: // Insert mode keeps Ctrl+P/N (completion) and the rest for Vim. From a56296c847ef80a838d57aef7846a7d4b1ede5c9 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:49:18 -0600 Subject: [PATCH 50/65] Fix(tui): a toast hides the cursor only where it is drawn over it Any toast hid the terminal cursor for its four seconds. In insert mode, and always with Vim mode off, the editor no longer draws its block there, so the insertion point vanished while the user kept typing: copying a link from the palette, "Reloaded <note>" after the external editor, or any error. A toast takes no keys, so typing goes on behind it. renderToasts now records the cells each toast is drawn over, from its left edge to the end of its rows, since the rest of those rows is cut off too. The cursor is hidden only when it falls inside one of them, and comes back on the same cell when the toast expires. Tests, with Vim mode on and off: a toast in its corner keeps the bar at the insertion point; a toast wide enough to cover the insertion point hides it, and its expiry restores it. Checked by hand in tmux with Vim mode off: after "Copy note link" the bar stays on the typing row. --- internal/tui/app.go | 13 ++++++--- internal/tui/cursor.go | 18 ++++++++++--- internal/tui/cursor_covered_test.go | 41 +++++++++++++++++++++++++---- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index bcc38f8..3cae7ba 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -121,9 +121,11 @@ type App struct { recent []string closedTabs []closedTab - message string - messageErr bool - toasts []toast + message string + messageErr bool + toasts []toast + // toastRects are the cells the last frame's toasts were drawn over. + toastRects []rect nextToastID int watcher *fswatch.Watcher @@ -1111,6 +1113,7 @@ func (a *App) renderWildmenu(comps []string, idx int) string { func (a *App) renderToasts(screen string) string { lines := strings.Split(screen, "\n") row := 1 + a.toastRects = a.toastRects[:0] for _, t := range a.toasts { style := a.theme.Overlay if t.isErr { @@ -1120,13 +1123,17 @@ func (a *App) renderToasts(screen string) string { blockLines := strings.Split(block, "\n") bw := lipgloss.Width(block) left := max(0, a.width-bw-2) + drawn := 0 for i, bl := range blockLines { if row+i >= len(lines)-2 { break } orig := lines[row+i] lines[row+i] = padRight(truncateAnsi(orig, left), left) + bl + drawn++ } + // The rest of each row past the toast is cut off too. + a.toastRects = append(a.toastRects, rect{x: left, y: row, w: max(0, a.width-left), h: drawn}) row += len(blockLines) } return strings.Join(lines, "\n") diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 9f4a74d..ebfaa61 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -67,11 +67,23 @@ func (a *App) insertionCell(buf *noteBuffer, rc rect) (x, y int, ok bool) { return 0, 0, false } -// computeCursor picks the cursor for the frame just drawn. +// computeCursor picks the cursor for the frame just drawn. A toast hides it +// only when it is drawn over the cursor's cell: toasts take no keys, and the +// user keeps typing while one shows. func (a *App) computeCursor() cursorState { - if len(a.toasts) > 0 { - return cursorState{} // a toast covers the corner it sits in + c := a.uncoveredCursor() + if c.visible && len(a.toasts) > 0 { + for _, r := range a.toastRects { + if c.x >= r.x && c.x < r.x+r.w && c.y >= r.y && c.y < r.y+r.h { + return cursorState{} + } + } } + return c +} + +// uncoveredCursor is the cursor for the frame, toasts aside. +func (a *App) uncoveredCursor() cursorState { if a.leader != nil && a.leader.showHints { return cursorState{} // the which-key popup covers the pane } diff --git a/internal/tui/cursor_covered_test.go b/internal/tui/cursor_covered_test.go index 48cff2c..0a08a1b 100644 --- a/internal/tui/cursor_covered_test.go +++ b/internal/tui/cursor_covered_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "time" @@ -90,18 +91,48 @@ func TestCursorCoveredWhichKey(t *testing.T) { }) } -func TestCursorCoveredToast(t *testing.T) { +// toastOver reports whether a toast in the last frame covers cell c. +func toastOver(a *App, c cursorState) bool { + for _, r := range a.toastRects { + if c.x >= r.x && c.x < r.x+r.w && c.y >= r.y && c.y < r.y+r.h { + return true + } + } + return false +} + +func expireToasts(a *App) { + for i := range a.toasts { + a.toasts[i].until = time.Now().Add(-time.Second) + } + a.expireToasts() +} + +// A toast in its corner takes no keys, so typing elsewhere keeps the bar. +func TestCursorToastElsewhereKeepsBar(t *testing.T) { coveredModes(t, func(t *testing.T, vimMode bool) { a, want := coveredApp(t, vimMode) a.notify("saved") if len(a.toasts) == 0 { t.Fatal("no toast") } - assertCursorHidden(t, a, "a toast") - for i := range a.toasts { - a.toasts[i].until = time.Now().Add(-time.Second) + assertCursorAt(t, a, want, "with a toast in the corner") + if toastOver(a, want) { + t.Fatalf("the toast %+v covers the cursor %+v; the test needs it elsewhere", a.toastRects, want) + } + }) +} + +func TestCursorCoveredToast(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.notify(strings.Repeat("saved ", 20)) + a.View() + if !toastOver(a, want) { + t.Fatalf("the toast %+v does not cover the cursor %+v", a.toastRects, want) } - a.expireToasts() + assertCursorHidden(t, a, "a toast over its cell") + expireToasts(a) assertCursorAt(t, a, want, "after the toast expired") }) } From 038ea1fae824ddf71b391cf0de291cc38818c8d8 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:49:18 -0600 Subject: [PATCH 51/65] Refactor(tui): drop the drawn-cursor helpers nothing calls The command line and text fields drew their cursor with firstCellOr and restAfterFirst. They place the terminal's cursor now, so nothing calls either helper. --- internal/tui/app.go | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 3cae7ba..fa73d53 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1079,21 +1079,6 @@ func (a *App) renderCommandLine() string { return padRight(t.Muted.Render(a.hintLine()), a.width) } -func firstCellOr(s, fallback string) string { - if s == "" { - return fallback - } - return string([]rune(s)[0]) -} - -func restAfterFirst(s string) string { - r := []rune(s) - if len(r) <= 1 { - return "" - } - return string(r[1:]) -} - func (a *App) renderWildmenu(comps []string, idx int) string { parts := []string{} for i, c := range comps { From e6bce16a1328da92ae69bfe7cf6fbd469afc4630 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:49:18 -0600 Subject: [PATCH 52/65] Docs: the README says how the cursor looks while you type The editor section says when zn shows a bar, an underline or the block, that the terminal's default cursor comes back on quit, suspend and the external editor, and what a terminal without cursor shapes shows. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index b5a6785..40c77e8 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,13 @@ Vim mode follows `[vim] enabled` in `config.toml`. With it off, the editor is a plain editor. Use arrows and Enter in lists, F2 for context actions, and Ctrl+G for the command palette. Ctrl+Z undoes plain-editor changes. +While you type, zn shows the terminal's own cursor: a bar in insert mode, +with Vim mode off and in text fields, and an underline in replace mode and +while `r` waits for its character. Normal and visual mode keep the block. +zn puts the terminal's default cursor back when it quits, suspends or hands +the note to `$EDITOR`. A terminal that cannot change the cursor's shape +shows its usual cursor there instead. + `Ctrl+Space` in insert mode (or `:complete`) completes tags, frontmatter tag values, callouts and code-fence languages. With `markdown_snippets` enabled, Space after an opening Markdown delimiter adds its closer; From defbdd1975ae1ada9fa0d7a9ff2268770ad125d3 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:49:21 -0600 Subject: [PATCH 53/65] Docs: README says the desktop app drops the [terminal] table when it saves The view-mode section said the desktop app ignores view_mode_field and [terminal.view_mode_rules]. It does more than ignore them: the desktop rebuilds config.toml from the settings it knows, at launch and on every settings change, and the rebuilt file has no [terminal] table at all (serializeConfig in apps/desktop/src/main/app-config.ts). So rules set in zn tui, like mouse, preview_style or math, are gone once the desktop app opens. The Configuration section now says so once for every [terminal] setting, and the view-mode section points there instead of promising the settings are left alone. The real fix belongs in the desktop app, which should keep the [terminal] table as it found it. --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 890ece3..64eb696 100644 --- a/README.md +++ b/README.md @@ -773,8 +773,10 @@ zn config set terminal.view_mode_field view-mode zn config set terminal.view_mode_rules.page-status=done preview ``` -Only `zn tui` reads these settings. zn never writes them into the vault, and -the desktop app ignores them. +Only `zn tui` reads these settings, and zn never writes them into the vault. +The desktop app does not use them, and like every `[terminal]` setting they +do not survive the desktop app saving `config.toml` (see +[Configuration](#configuration)). ### Vaults and servers inside the app @@ -856,6 +858,10 @@ it immediately. External edits are otherwise picked up within a second. Updates preserve unknown keys/tables and existing symlinks; TOML comments and formatting are normalized when the file is written. Invalid supported values produce an error while the running preferences remain active. +The desktop app does not keep the `[terminal]` table: it rewrites +`config.toml` at launch and whenever you change a setting there, and the +rewritten file leaves `[terminal]` out. If you use both apps, keep a copy of +your `[terminal]` settings to put back. Periodic note paths and named date tokens follow each kind's configured locale, including legacy patterns. The TUI keeps a horizontal tab strip From a5063d6180007ea790e95c62a92b9ad76f67bc9b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:50:46 -0600 Subject: [PATCH 54/65] Fix(tui): grouped settings scroll back on a taller window and keep each row's whole text #22 lays the settings page out under User and Vault headings and gave the view a second scroll offset, counted in display lines. Two things went wrong with it: - The offset was only capped at the page's last line, so after G in a short window, growing the window left the page scrolled down: the User and vim headings stayed off the top with blank lines below. It now stops where the last line sits at the bottom, so a taller window or a shorter filtered list shows the top again. - A row rebuilt its text from key and value, so anything refresh adds to a row's line was lost. A row now shows its filter line less the scope and prefix its headings already name; the "(invalid: ...)" note #28 adds to a bad view-mode rule shows that way. The view keeps one scroll field: v.scroll is the display offset now, and the setting-index scroll that no longer drove anything is gone from render and from the mouse wheel. Tests: the layout test keeps a row's whole line; every row a real refresh lists sits under the group it saves to; moving onto the first Vault row from above and from below keeps its headings on screen; and a tall window shows the page from the top. The cap and the heading walk-back each fail their test when reverted. --- internal/tui/settings_controls.go | 27 ++++---- internal/tui/settings_tasks_test.go | 98 ++++++++++++++++++++++++++++- internal/tui/views_hints.go | 5 +- 3 files changed, 111 insertions(+), 19 deletions(-) diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index 6dcb6ad..fdf65e0 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -17,7 +17,6 @@ type settingRow struct { } type settingsView struct { scroll, rows int - dscroll int lines []string filter string list listCursor @@ -76,24 +75,25 @@ func (v *settingsView) render(a *App, w, h int, focused bool) string { v.refresh(a) } v.rows = max(1, h-2) - v.list.ensureVisible(v.rows) - v.scroll = v.list.scroll lines := []string{padRight(truncateCells(" Enter edit · / filter · v version · :config external editor", w), w), padRight(" "+v.filter, w)} disp, cur := v.display(w) - body := max(1, h-2) + // v.scroll counts display lines, headings included. Moving up onto the + // first row of a group scrolls its headings (at most the scope and the + // prefix) back into view with it, and the page never scrolls past its + // last line, so a taller window or a shorter list shows the top again. if cur >= 0 { start := cur for n := 0; n < 2 && start > 0 && disp[start-1].idx < 0; n++ { start-- } - if start < v.dscroll { - v.dscroll = start - } else if cur >= v.dscroll+body { - v.dscroll = cur - body + 1 + if start < v.scroll { + v.scroll = start + } else if cur >= v.scroll+v.rows { + v.scroll = cur - v.rows + 1 } } - v.dscroll = max(0, min(v.dscroll, len(disp)-1)) - for i := v.dscroll; i < len(disp) && len(lines) < h; i++ { + v.scroll = max(0, min(v.scroll, len(disp)-v.rows)) + for i := v.scroll; i < len(disp) && len(lines) < h; i++ { line := padRight(disp[i].text, w) switch { case disp[i].idx < 0: @@ -114,7 +114,9 @@ type settingDisplay struct { // display lays the settings out under two top-level sections, User and Vault, // each split into subsections named by the config prefix (vim, editor, // dailyNotes, ...). Rows keep their settings order, so the cursor index maps -// straight onto v.settings. It returns the cell index of the cursor row. +// straight onto v.settings. A row shows its filter line less the scope and +// prefix its headings already name. It returns the display line of the cursor +// row, or -1. func (v *settingsView) display(w int) ([]settingDisplay, int) { out := []settingDisplay{} cur := -1 @@ -138,7 +140,8 @@ func (v *settingsView) display(w int) ([]settingDisplay, int) { if i == v.list.cursor { cur = len(out) } - out = append(out, settingDisplay{truncateCells(" "+s.key+" = "+fmt.Sprint(s.value), w), i}) + text := strings.TrimPrefix(v.lines[i], strings.ToLower(name)+" · "+s.section+".") + out = append(out, settingDisplay{truncateCells(" "+text, w), i}) } return out, cur } diff --git a/internal/tui/settings_tasks_test.go b/internal/tui/settings_tasks_test.go index 03e35a4..813a5a8 100644 --- a/internal/tui/settings_tasks_test.go +++ b/internal/tui/settings_tasks_test.go @@ -1,7 +1,9 @@ package tui import ( + "fmt" "reflect" + "slices" "strings" "testing" ) @@ -30,16 +32,25 @@ func TestSetTasksFolderList(t *testing.T) { } func TestSettingsDisplayGroupsUserAndVaultByPrefix(t *testing.T) { - v := &settingsView{settings: []settingRow{ + rows := []settingRow{ {"vim", "a", 1, false}, {"vim", "b", 2, false}, {"editor", "c", 3, false}, {"dailyNotes", "enabled", true, true}, {"tasks", "includedFolders", "x", true}, - }} + } + v := &settingsView{settings: rows} + for _, s := range rows { + scope := "user" + if s.vault { + scope = "vault" + } + v.lines = append(v.lines, scope+" · "+s.section+"."+s.key+" = "+fmt.Sprint(s.value)) + } + v.lines[1] += " (note)" disp, cur := v.display(40) var got []string for _, d := range disp { got = append(got, strings.TrimSpace(d.text)) } - want := []string{"USER", "vim", "a = 1", "b = 2", "editor", "c = 3", "", "VAULT", "dailyNotes", "enabled = true", "tasks", "includedFolders = x"} + want := []string{"USER", "vim", "a = 1", "b = 2 (note)", "editor", "c = 3", "", "VAULT", "dailyNotes", "enabled = true", "tasks", "includedFolders = x"} if strings.Join(got, "|") != strings.Join(want, "|") { t.Fatalf("layout = %q", got) } @@ -47,3 +58,84 @@ func TestSettingsDisplayGroupsUserAndVaultByPrefix(t *testing.T) { t.Fatalf("cursor row = %d", cur) } } + +// Every row the settings page lists sits under the group it is saved in: +// config.toml rows under User, vault.json rows under Vault, each group once. +func TestSettingsRowsSitUnderTheScopeTheySaveTo(t *testing.T) { + a, _ := newFeatureTestApp(t) + v := &settingsView{} + v.refresh(a) + disp, _ := v.display(80) + scopes, scope := []string{}, "" + vaultRows := 0 + for _, d := range disp { + if t := strings.TrimSpace(d.text); d.idx < 0 && (t == "USER" || t == "VAULT") { + scope = t + scopes = append(scopes, scope) + continue + } + if d.idx < 0 { + continue + } + s := v.settings[d.idx] + if s.vault != (scope == "VAULT") { + t.Fatalf("%s.%s sits under %q", s.section, s.key, scope) + } + if s.vault { + vaultRows++ + } + } + if strings.Join(scopes, ",") != "USER,VAULT" || vaultRows == 0 { + t.Fatalf("groups = %v with %d vault rows", scopes, vaultRows) + } +} + +// Moving onto the first row of a group brings that group's headings on +// screen with it, from above and from below. +func TestSettingsScrollKeepsGroupHeadingsInView(t *testing.T) { + a, _ := newFeatureTestApp(t) + v := &settingsView{} + v.refresh(a) + firstVault := slices.IndexFunc(v.settings, func(s settingRow) bool { return s.vault }) + if firstVault < 1 { + t.Fatalf("no vault rows after user rows: %d", firstVault) + } + screen := func() []string { return strings.Split(stripAnsi(v.render(a, 70, 12, true)), "\n") } + expectAbove := func(when string, lines []string, want ...string) { + t.Helper() + for i, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "enabled = ") && i >= len(want) { + got := []string{} + for _, g := range lines[i-len(want) : i] { + got = append(got, strings.Trim(strings.TrimSpace(g), "─ ")) + } + if strings.Join(got, "|") == strings.Join(want, "|") { + return + } + } + } + t.Fatalf("%s: no %q above the dailyNotes row:\n%s", when, want, strings.Join(lines, "\n")) + } + + if got := strings.Trim(strings.TrimSpace(screen()[2]), "─ "); got != "USER" { + t.Fatalf("top heading = %q", got) + } + v.list.cursor = firstVault - 1 + screen() + v.list.move(1, len(v.settings)) + expectAbove("down", screen(), "VAULT", "dailyNotes") + + v.list.cursor = len(v.settings) - 1 + screen() + for v.list.cursor > firstVault { + v.list.move(-1, len(v.settings)) + screen() + } + expectAbove("up", screen(), "VAULT", "dailyNotes") + + // A window tall enough for the whole page shows it from the top again. + tall := strings.Split(stripAnsi(v.render(a, 70, 200, true)), "\n") + if got := strings.Trim(strings.TrimSpace(tall[2]), "─ "); got != "USER" { + t.Fatalf("after growing, top line = %q", got) + } +} diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index d483e54..7c0e8da 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -67,10 +67,7 @@ func (v *noteListView) scrollBy(a *App, delta int) { v.list.move(delta, len(v.no func (v *helpView) scrollBy(a *App, delta int) { v.scroll = max(0, min(max(0, len(v.lines)-v.rows), v.scroll+delta)) } -func (v *settingsView) scrollBy(a *App, delta int) { - v.list.move(delta, len(v.settings)) - v.scroll = v.list.scroll -} +func (v *settingsView) scrollBy(a *App, delta int) { v.list.move(delta, len(v.settings)) } func sign(n int) int { if n < 0 { From b7bda036a665d9521b2999e2a4d3d2f78ebb49f9 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:51:38 -0600 Subject: [PATCH 55/65] Feat(tui): settings group headings name the file each group saves to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The User and Vault headings now draw the rule the Tasks list and the manual use for their sections ("─ User · config.toml ───" and "─ Vault · .zennotes/vault.json ───"), so the page says where an edit lands. That file is what separates the two groups: the desktop's Settings files rows by topic (Task folders sit under Editing), while this page lists config keys and groups them by where they are saved. Prefix headings (vim, editor, dailyNotes, ...) become bold body text under the accent rule, and the blank line before Vault goes, since the rule already separates the groups. --- internal/tui/settings_controls.go | 45 ++++++++++++++++------------- internal/tui/settings_tasks_test.go | 20 ++++++------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index fdf65e0..abaea6d 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -75,6 +75,7 @@ func (v *settingsView) render(a *App, w, h int, focused bool) string { v.refresh(a) } v.rows = max(1, h-2) + th := a.theme lines := []string{padRight(truncateCells(" Enter edit · / filter · v version · :config external editor", w), w), padRight(" "+v.filter, w)} disp, cur := v.display(w) // v.scroll counts display lines, headings included. Moving up onto the @@ -94,29 +95,36 @@ func (v *settingsView) render(a *App, w, h int, focused bool) string { } v.scroll = max(0, min(v.scroll, len(disp)-v.rows)) for i := v.scroll; i < len(disp) && len(lines) < h; i++ { - line := padRight(disp[i].text, w) + d := disp[i] switch { - case disp[i].idx < 0: - line = a.theme.Heading.Render(line) - case focused && disp[i].idx == v.list.cursor: - line = a.theme.SelectedFocus.Render(line) + case d.scope != "": + lines = append(lines, padRight(" "+sectionHeader(th, d.scope, settingScopeFiles[d.scope], w-2), w)) + case d.idx < 0: + lines = append(lines, th.Bold.Render(padRight(d.text, w))) + case focused && d.idx == v.list.cursor: + lines = append(lines, th.SelectedFocus.Render(padRight(d.text, w))) + default: + lines = append(lines, padRight(d.text, w)) } - lines = append(lines, line) } return fitBlock(strings.Join(lines, "\n"), w, h) } +// settingScopeFiles names where each scope's settings are saved. +var settingScopeFiles = map[string]string{"User": "config.toml", "Vault": ".zennotes/vault.json"} + type settingDisplay struct { - text string - idx int // index into settings; -1 for a heading + text string + idx int // index into settings; -1 for a heading + scope string // set on the heading that opens the User or Vault group } -// display lays the settings out under two top-level sections, User and Vault, -// each split into subsections named by the config prefix (vim, editor, -// dailyNotes, ...). Rows keep their settings order, so the cursor index maps -// straight onto v.settings. A row shows its filter line less the scope and -// prefix its headings already name. It returns the display line of the cursor -// row, or -1. +// display lays the settings out under two groups, User (config.toml) and +// Vault (.zennotes/vault.json), each split into subsections named by the +// config prefix (vim, editor, dailyNotes, ...). Rows keep their settings +// order, so the cursor index maps straight onto v.settings. A row shows its +// filter line less the scope and prefix its headings already name. It +// returns the display line of the cursor row, or -1. func (v *settingsView) display(w int) ([]settingDisplay, int) { out := []settingDisplay{} cur := -1 @@ -127,21 +135,18 @@ func (v *settingsView) display(w int) ([]settingDisplay, int) { name = "Vault" } if name != scope { - if scope != "" { - out = append(out, settingDisplay{"", -1}) - } scope, sub = name, "" - out = append(out, settingDisplay{truncateCells(" "+strings.ToUpper(name), w), -1}) + out = append(out, settingDisplay{idx: -1, scope: name}) } if s.section != sub { sub = s.section - out = append(out, settingDisplay{truncateCells(" "+sub, w), -1}) + out = append(out, settingDisplay{text: truncateCells(" "+sub, w), idx: -1}) } if i == v.list.cursor { cur = len(out) } text := strings.TrimPrefix(v.lines[i], strings.ToLower(name)+" · "+s.section+".") - out = append(out, settingDisplay{truncateCells(" "+text, w), i}) + out = append(out, settingDisplay{text: truncateCells(" "+text, w), idx: i}) } return out, cur } diff --git a/internal/tui/settings_tasks_test.go b/internal/tui/settings_tasks_test.go index 813a5a8..532f76b 100644 --- a/internal/tui/settings_tasks_test.go +++ b/internal/tui/settings_tasks_test.go @@ -48,9 +48,9 @@ func TestSettingsDisplayGroupsUserAndVaultByPrefix(t *testing.T) { disp, cur := v.display(40) var got []string for _, d := range disp { - got = append(got, strings.TrimSpace(d.text)) + got = append(got, d.scope+strings.TrimSpace(d.text)) } - want := []string{"USER", "vim", "a = 1", "b = 2 (note)", "editor", "c = 3", "", "VAULT", "dailyNotes", "enabled = true", "tasks", "includedFolders = x"} + want := []string{"User", "vim", "a = 1", "b = 2 (note)", "editor", "c = 3", "Vault", "dailyNotes", "enabled = true", "tasks", "includedFolders = x"} if strings.Join(got, "|") != strings.Join(want, "|") { t.Fatalf("layout = %q", got) } @@ -69,8 +69,8 @@ func TestSettingsRowsSitUnderTheScopeTheySaveTo(t *testing.T) { scopes, scope := []string{}, "" vaultRows := 0 for _, d := range disp { - if t := strings.TrimSpace(d.text); d.idx < 0 && (t == "USER" || t == "VAULT") { - scope = t + if d.scope != "" { + scope = d.scope scopes = append(scopes, scope) continue } @@ -78,14 +78,14 @@ func TestSettingsRowsSitUnderTheScopeTheySaveTo(t *testing.T) { continue } s := v.settings[d.idx] - if s.vault != (scope == "VAULT") { + if s.vault != (scope == "Vault") { t.Fatalf("%s.%s sits under %q", s.section, s.key, scope) } if s.vault { vaultRows++ } } - if strings.Join(scopes, ",") != "USER,VAULT" || vaultRows == 0 { + if strings.Join(scopes, ",") != "User,Vault" || vaultRows == 0 { t.Fatalf("groups = %v with %d vault rows", scopes, vaultRows) } } @@ -117,13 +117,13 @@ func TestSettingsScrollKeepsGroupHeadingsInView(t *testing.T) { t.Fatalf("%s: no %q above the dailyNotes row:\n%s", when, want, strings.Join(lines, "\n")) } - if got := strings.Trim(strings.TrimSpace(screen()[2]), "─ "); got != "USER" { + if got := strings.Trim(strings.TrimSpace(screen()[2]), "─ "); got != "User · config.toml" { t.Fatalf("top heading = %q", got) } v.list.cursor = firstVault - 1 screen() v.list.move(1, len(v.settings)) - expectAbove("down", screen(), "VAULT", "dailyNotes") + expectAbove("down", screen(), "Vault · .zennotes/vault.json", "dailyNotes") v.list.cursor = len(v.settings) - 1 screen() @@ -131,11 +131,11 @@ func TestSettingsScrollKeepsGroupHeadingsInView(t *testing.T) { v.list.move(-1, len(v.settings)) screen() } - expectAbove("up", screen(), "VAULT", "dailyNotes") + expectAbove("up", screen(), "Vault · .zennotes/vault.json", "dailyNotes") // A window tall enough for the whole page shows it from the top again. tall := strings.Split(stripAnsi(v.render(a, 70, 200, true)), "\n") - if got := strings.Trim(strings.TrimSpace(tall[2]), "─ "); got != "USER" { + if got := strings.Trim(strings.TrimSpace(tall[2]), "─ "); got != "User · config.toml" { t.Fatalf("after growing, top line = %q", got) } } From 632d06055a6c5d959b5ce1033f250a95a97b5a18 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:51:45 -0600 Subject: [PATCH 56/65] Docs: the README says where each settings group saves The settings paragraph named only periodic-note and task-exclusion controls as vault settings. Task folders and the template folder save to the vault too, and the page now groups its rows by that, so the paragraph says so. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6821531..bf59255 100644 --- a/README.md +++ b/README.md @@ -779,8 +779,9 @@ creates it with comments when it is missing. On Windows, it normally lives under `%APPDATA%\zennotes`. `XDG_CONFIG_HOME` and `ZENNOTES_CONFIG_DIR` can override the portable location. -`:settings` is searchable and editable. Supported user preferences persist -to `config.toml`; periodic-note and task-exclusion controls update the vault. +`:settings` is searchable and editable, its rows grouped by where they are +saved: User rows persist to `config.toml`, and Vault rows (periodic notes, +Task folders, the template folder) to the vault's `.zennotes/vault.json`. `:config` opens the file in an external editor, and `:reloadconfig` reloads it immediately. External edits are otherwise picked up within a second. `:bind` / `:unbind` persist, and `:keymaps` can edit bindings interactively. From f0d6fd1904cd08fc413f5012f033d6eb506fe5db Mon Sep 17 00:00:00 2001 From: Spirit of Light <lev.v.tanin@gmail.com> Date: Sun, 1 Nov 2026 14:51:54 -0500 Subject: [PATCH 57/65] Fix(tui): calendar overflow count no longer cut mid-word A day with more tasks than its box or column can show ends with a "+N more" line, but in narrow boxes it was cut to the width, so it read "+4 mor" or "+2 mo". When "+N more" does not fit, the line now reads just "+N". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/tui/views_tasks_calendar.go | 14 ++++- .../tui/views_tasks_calendar_moreline_test.go | 59 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 internal/tui/views_tasks_calendar_moreline_test.go diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index dd4e23a..6e5a048 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -441,6 +441,16 @@ func (c *taskCalendar) scrollColumn(day time.Time, delta int) bool { return false } +// calMoreLabel is the overflow line for n hidden entries in a w-wide cell: +// " +N more" when it fits, else " +N", else " +N" cut to the width. +func calMoreLabel(n, w int) string { + long := fmt.Sprintf(" +%d more", n) + if cellWidth(long) <= w { + return long + } + return truncateCells(fmt.Sprintf(" +%d", n), w) +} + // columnLine is line li of one day column: entries from the scroll offset, // with "+N more" on the first line when some are scrolled off the top and on // the last line when they do not all fit. @@ -449,7 +459,7 @@ func (c *taskCalendar) columnLine(a *App, b calDayBox, li int, focusedDay bool, w := b.rect.w entries := c.dayEntries(b.day) more := func(n int) string { - return th.Dim.Render(padRight(fmt.Sprintf(" +%d more", n), w)) + return th.Dim.Render(padRight(calMoreLabel(n, w), w)) } first := 0 if scroll > 0 { @@ -604,7 +614,7 @@ func (c *taskCalendar) boxLine(a *App, box calDayBox, li int, selected, today, o } text = lead + truncateCells(e.task.Content, w-1) case li-1 == shown && shown < len(entries): - text = " " + fmt.Sprintf("+%d more", len(entries)-shown) + text = calMoreLabel(len(entries)-shown, w) if !selected { style = th.Dim } diff --git a/internal/tui/views_tasks_calendar_moreline_test.go b/internal/tui/views_tasks_calendar_moreline_test.go new file mode 100644 index 0000000..dbf4e20 --- /dev/null +++ b/internal/tui/views_tasks_calendar_moreline_test.go @@ -0,0 +1,59 @@ +package tui + +import ( + "fmt" + "regexp" + "testing" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/charmbracelet/lipgloss" +) + +func TestCalMoreLabelFallsBackWithoutCuttingWords(t *testing.T) { + cases := []struct { + n, w int + want string + }{ + {4, 20, " +4 more"}, + {4, 8, " +4 more"}, + {4, 7, " +4"}, + {4, 3, " +4"}, + {12, 3, " +…"}, + {4, 0, ""}, + } + for _, c := range cases { + got := calMoreLabel(c.n, c.w) + if got != c.want { + t.Errorf("calMoreLabel(%d, %d) = %q, want %q", c.n, c.w, got, c.want) + } + if lipgloss.Width(got) > c.w { + t.Errorf("calMoreLabel(%d, %d) = %q is wider than %d", c.n, c.w, got, c.w) + } + } +} + +// A month box too narrow for "+N more" must read "+N", never a cut word. +func TestMonthOverflowLineNeverCutMidWord(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 50; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("zq%02d", i), Due: "2026-11-10"}) + } + cut := regexp.MustCompile(`\+\d+ mo?r?(\s|$)`) + short := regexp.MustCompile(`\+\d+(\s|$)`) + for _, sz := range [][2]int{{120, 40}, {72, 19}} { + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, sz[0], sz[1]) + text := calScreenText(lines) + for _, l := range lines { + if lipgloss.Width(l) > sz[0] { + t.Errorf("%dx%d: line wider than the body: %q", sz[0], sz[1], l) + } + if m := cut.FindString(l); m != "" { + t.Errorf("%dx%d: overflow line cut mid-word: %q\n%s", sz[0], sz[1], m, text) + } + } + if !short.MatchString(text) { + t.Errorf("%dx%d: no overflow count shown:\n%s", sz[0], sz[1], text) + } + } +} From e80c3fc92ab9ebde1a933f72778177c91848d60a Mon Sep 17 00:00:00 2001 From: Spirit of Light <lev.v.tanin@gmail.com> Date: Sun, 1 Nov 2026 14:56:07 -0500 Subject: [PATCH 58/65] Docs: document the calendar's views and keys The README's Views section now describes the calendar's month, week and day views, the hourly and half-hourly hour list, moving focus between the grid, the day's tasks and the No-date strip, and the due-date menu. It also notes that, unlike the desktop calendar, the terminal calendar places tasks on their scheduled date too. The key hints at the bottom of the calendar list the new keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 13 +++++++++++-- internal/tui/views_hints.go | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6821531..cf27f7a 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,20 @@ reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. `g` regroups by status, priority, due date, folder or `field:<key>`. `:boardoptions` (also F2 → Board options) sets the folder root, column labels, custom statuses and card order. Folder boards keep cross-folder moves - disabled. On the calendar Enter lists a day's tasks and `o` opens its - daily note. Filters match the desktop's substring search over task text, + disabled. Filters match the desktop's substring search over task text, note titles, `!priority`, `#tags` and `@fields`. `:filters` / `:savefilter` manage shared named queries. Prefix terminal operators with `where:`, for example `where: is:open due:today -meeting`. + The calendar has month, week and day views; `Esc` cycles them, and a + start in `calendar` mode always opens the month grid. `h`/`j`/`k`/`l` + move the day, `[`/`]` the month, `t` goes to today, `<C-D>`/`<C-U>` page + by half a view, and `o` opens the day's daily note. Week and day view + show an hour list: hourly by default, half-hourly after `z` (Vim mode), + scrolling with `j`/`k` when it does not fit. `Enter` and `Tab` move focus + between the day grid, the selected day's items and the No-date strip, and + `T` on a focused item or strip task opens a due-date menu. Unlike the + desktop calendar, the terminal calendar also places a task on its + scheduled date (marked) as well as its due date. - **Tags**: `Tab` multi-selects, `a` toggles any/all, `r`/`x` rename or remove a tag everywhere. - **Databases**: every `.base` folder and every loose `.csv` file, with diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index 0f0a700..48b3423 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -9,7 +9,7 @@ func (v *tasksView) hintPairs(a *App) []hintPair { case "kanban": return append([]hintPair{{"key:h/l", "column"}, {"key:j/k", "card"}, {"key:H/L", "move card"}, {"nav.toggleTask", "toggle"}, {"key:Enter", "open"}, {"key:g", "group by"}, {"key:m", "menu"}}, common...) case "calendar": - return append([]hintPair{{"key:h/j/k/l", "day"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}}, common...) + return append([]hintPair{{"key:h/j/k/l", "day"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}, {"key:z", "hour step"}, {"key:T", "due menu"}}, common...) } return append([]hintPair{{"nav.moveDown|nav.moveUp", "move"}, {"nav.toggleTask", "toggle"}, {"nav.openResult", "open"}, {"key:p", "priority"}, {"key:d", "due"}, {"key:w", "waiting"}, {"key:c", "cancel"}, {"key:/", "in progress"}, {"key:e", "edit"}, {"key:s", "field"}, {"key:</>", "shift due"}, {"tasks.moveTaskUp|tasks.moveTaskDown", "reorder"}, {"nav.contextMenu", "menu"}}, common...) } From 020afdc867f0b0ab87215c617e884ba6c903e64e Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 13:56:13 -0600 Subject: [PATCH 59/65] Refactor(tui): a new note reaches the editor one way, not two #26 and #28 fixed the same thing from two sides. #26's showNewNoteInEditor set a new note's tab to edit right after it opened, and its follow-up called it from every creation path. #28 marks a note as new before the open (markNewNote), and since its follow-up a marked note always opens in the editor. Merged together, every creation path called both, one after the other, to the same end. The mark stays, since it acts inside the open itself, before a default, remembered or frontmatter mode is applied, and showNewNoteInEditor goes. Every creation path #26's follow-up covers already marks its note, and its tests (a new periodic, template, task-file and rollover note under a preview and a split default) pass on the mark alone, as do #28's. --- internal/tui/commands_parity.go | 2 -- internal/tui/noteops.go | 14 -------------- 2 files changed, 16 deletions(-) diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index 14de94c..4670ffc 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -550,7 +550,6 @@ func (a *App) rolloverNow() error { } a.openNote(rel, true) if created { - a.showNewNoteInEditor(rel) } return nil } @@ -915,7 +914,6 @@ func (a *App) newTaskFile(target string) error { a.addNoteToIndex(meta) a.markNewNote(meta.Path) a.openNote(meta.Path, true) - a.showNewNoteInEditor(meta.Path) a.afterTaskChange() }) return nil diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index 746ba9e..4d12459 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -32,7 +32,6 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.addNoteToIndex(meta) if open { a.openNote(meta.Path, true) - a.showNewNoteInEditor(meta.Path) a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) if a.prefs.VimMode { @@ -43,17 +42,6 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.refreshIndex() } -// showNewNoteInEditor shows a note just created and opened in the editor, -// whatever the default view mode: a new note is there to be written in, as -// on the desktop. Only its tab changes, so the default view mode and the -// remembered per-note modes stay as they were. -func (a *App) showNewNoteInEditor(path string) { - if t := a.activeTab(); t != nil && t.view == nil && t.path == path { - t.mode = modeEdit - a.layout() - } -} - // addNoteToIndex makes a fresh note visible before the next full reload. func (a *App) addNoteToIndex(meta vault.NoteMeta) { if a.idx == nil { @@ -619,7 +607,6 @@ func (a *App) openPeriodicOn(kind periodic.Kind, date time.Time) { } a.markNewNote(meta.Path) a.openNote(meta.Path, true) - a.showNewNoteInEditor(meta.Path) a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) }) a.refreshIndex() } @@ -694,7 +681,6 @@ func (a *App) createFromTemplate(t templates.Template) { a.addNoteToIndex(meta) a.markNewNote(meta.Path) a.openNote(meta.Path, true) - a.showNewNoteInEditor(meta.Path) if rendered.CursorOffset >= 0 { a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.SetCursor(posForOffset(buf.ed.Text(), rendered.CursorOffset)) }) } From 32a7f85b967eef18fd3fa3dd60ec491cb814a70d Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 14:46:22 -0600 Subject: [PATCH 60/65] Fix(tui): a focused calendar task takes no letters with Vim off The Tasks list takes no printable key with Vim off, and the calendar's grid follows the same rule, but a calendar task with focus (in the selected day's items or the No-date strip) still took every letter: `T` opened the due-date menu, `c` cancelled the task, `d`, `e`, `w`, `s`, `/` and `<`/`>` edited it, and `h` and `o` moved focus or opened the note. One of the PR's tests asserted that `T` opens the menu with Vim off. A focused task now passes letters on with Vim off, so the arrow keys, Enter, Tab and Esc do the work there, as the PR describes. In Vim mode `:` on a focused task opens the ex line, as it does on the grid; before, it did nothing there. Tests, red on the PR head: with Vim off, `T`, `c`, `d`, `w`, `/` and `e` on a focused task open nothing and leave the note unchanged, `h` leaves focus on the task, and `:` on a strip task opens the ex line. --- internal/tui/views_tasks.go | 6 ++++++ .../tui/views_tasks_calendar_duemenu_test.go | 16 ++++++++++++---- internal/tui/views_tasks_calendar_keys_test.go | 7 +++++++ internal/tui/views_tasks_calendar_nav_test.go | 7 +++++++ internal/tui/views_tasks_calendar_strip_test.go | 11 +++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 4603c34..0915226 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -1051,7 +1051,13 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { return true } if v.calc().focus != calFocusGrid { + // With Vim off a focused task, like the list, takes no letters. + if !a.listKeyAllowed(k) { + return false + } switch { + case k.IsRune(':'): + a.openLocalEx() case k.Is("esc") || k.IsRune('h') || k.Is("left"): v.calc().focus = calFocusGrid case k.Is("tab"): diff --git a/internal/tui/views_tasks_calendar_duemenu_test.go b/internal/tui/views_tasks_calendar_duemenu_test.go index 6cd1a4e..c5e1a88 100644 --- a/internal/tui/views_tasks_calendar_duemenu_test.go +++ b/internal/tui/views_tasks_calendar_duemenu_test.go @@ -180,9 +180,17 @@ func TestDueMenuOpensFromStrip(t *testing.T) { // AC97 } func TestDueMenuWithoutVimMode(t *testing.T) { // AC112 - _, tv, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, false) - if len(m.items) != 6 { - t.Errorf("menu has %d items, want 6", len(m.items)) + calFixToday(t, calDate(2026, time.October, 29)) + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + a.prefs.VimMode = false + calPress(a, tv, vim.KeyEnter) + requireFocus(t, tv, calFocusItems, "after Enter") + calPress(a, tv, calRunes("Tcdw/e")...) + if a.overlay != nil { + t.Errorf("letters on a focused task opened %T with Vim off", a.overlay) } - requireDay(t, tv, "2026-11-18", "after T") + if got := calReadFile(t, root, "work.md"); got != calWorkNote { + t.Errorf("letters on a focused task changed the note with Vim off:\n%s", got) + } + requireFocus(t, tv, calFocusItems, "after the letters") } diff --git a/internal/tui/views_tasks_calendar_keys_test.go b/internal/tui/views_tasks_calendar_keys_test.go index 28dae2d..f029301 100644 --- a/internal/tui/views_tasks_calendar_keys_test.go +++ b/internal/tui/views_tasks_calendar_keys_test.go @@ -333,6 +333,13 @@ func TestItemPanelLeftAndEscReturnToGrid(t *testing.T) { t.Fatalf("vim=%v: Enter did not focus the item panel", vimOn) } calPress(a, tv, back) + if !vimOn && back.IsRune('h') { + // With Vim off h is a letter, and letters do nothing. + if tv.calc().focus != calFocusItems { + t.Errorf("h with Vim off moved focus to %v", tv.calc().focus) + } + continue + } if tv.calc().focus != calFocusGrid { t.Errorf("vim=%v: %s left focus on %v, want the grid", vimOn, back, tv.calc().focus) } diff --git a/internal/tui/views_tasks_calendar_nav_test.go b/internal/tui/views_tasks_calendar_nav_test.go index 30b24e8..ec32b18 100644 --- a/internal/tui/views_tasks_calendar_nav_test.go +++ b/internal/tui/views_tasks_calendar_nav_test.go @@ -265,6 +265,13 @@ func TestWeekDayEscAndLeftReturnToGrid(t *testing.T) { t.Fatalf("layout %v vim=%v: Tab did not focus the column", sub, vimOn) } calPress(a, tv, back) + if !vimOn && back.IsRune('h') { + // With Vim off h is a letter, and letters do nothing. + if tv.calc().focus != calFocusItems { + t.Errorf("layout %v: h with Vim off moved focus to %v", sub, tv.calc().focus) + } + continue + } if tv.calc().focus != calFocusGrid { t.Errorf("layout %v vim=%v: %s left focus on %v, want the grid", sub, vimOn, back, tv.calc().focus) } diff --git a/internal/tui/views_tasks_calendar_strip_test.go b/internal/tui/views_tasks_calendar_strip_test.go index e747b00..69037f5 100644 --- a/internal/tui/views_tasks_calendar_strip_test.go +++ b/internal/tui/views_tasks_calendar_strip_test.go @@ -305,3 +305,14 @@ func TestStripEscReturnsToGrid(t *testing.T) { } } } + +func TestStripColonOpensExLine(t *testing.T) { + a, tv := calTestApp(t, calManyUndated(2)) + a.prefs.VimMode = true + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "setup") + calPress(a, tv, vim.R(':')) + if _, ok := a.overlay.(*exPrompt); !ok { + t.Errorf(": on a strip task opened %T, want the ex line", a.overlay) + } +} From 557e86a2e38ca9bfcbf869c5e3b03ff9c5289ca6 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 14:46:22 -0600 Subject: [PATCH 61/65] Fix(tui): T moves a calendar task to today, and @ opens the due-date menu The desktop calendar binds `T` on a focused task to "move it to today" and `@` to "pick any due date", and its demo vault teaches `T` that way. The PR bound `T` to its new due-date menu (today, tomorrow, next week, the selected day, clear, other), so the same key did two different things in the two apps' calendars. `T` on a focused item or strip task now sets its due date to today, and the PR's menu moves to `@`, the desktop's key for choosing a date. Both stay Vim keys. On the grid `t` and `T` still go to today, as before. Tests: the due-menu tests press `@`; a new test checks that `T` moves a focused item and a strip task to today without opening a menu. --- README.md | 5 +-- internal/tui/views_tasks.go | 5 +++ .../tui/views_tasks_calendar_duemenu_test.go | 35 +++++++++++++++---- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cf27f7a..db46029 100644 --- a/README.md +++ b/README.md @@ -590,8 +590,9 @@ reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. by half a view, and `o` opens the day's daily note. Week and day view show an hour list: hourly by default, half-hourly after `z` (Vim mode), scrolling with `j`/`k` when it does not fit. `Enter` and `Tab` move focus - between the day grid, the selected day's items and the No-date strip, and - `T` on a focused item or strip task opens a due-date menu. Unlike the + between the day grid, the selected day's items and the No-date strip. On + a focused item or strip task, `T` moves it to today and `@` opens a + due-date menu, as in the desktop calendar. Unlike the desktop calendar, the terminal calendar also places a task on its scheduled date (marked) as well as its due date. - **Tags**: `Tab` multi-selects, `a` toggles any/all, `r`/`x` rename or diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 0915226..66a243c 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -1091,6 +1091,11 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { a.taskMenu(*t) } case k.IsRune('T'): + // As in the desktop calendar: T moves the task to today, @ picks a date. + if t != nil { + a.setTaskDue(*t, vault.TodayISO(calendarNow())) + } + case k.IsRune('@'): if t != nil { v.calendarDueMenu(a, *t) } diff --git a/internal/tui/views_tasks_calendar_duemenu_test.go b/internal/tui/views_tasks_calendar_duemenu_test.go index c5e1a88..e012fe3 100644 --- a/internal/tui/views_tasks_calendar_duemenu_test.go +++ b/internal/tui/views_tasks_calendar_duemenu_test.go @@ -11,17 +11,17 @@ import ( const calLooseNote = "- [ ] loose end\n" // calDueMenu focuses the item panel on 2026-11-18 (today fixed to 2026-10-29) -// and presses T. +// and presses @. func calDueMenu(t *testing.T, files map[string]string, vimOn bool) (*App, *tasksView, string, *menu) { t.Helper() calFixToday(t, calDate(2026, time.October, 29)) a, tv, root := calVaultApp(t, files) a.prefs.VimMode = vimOn calPress(a, tv, vim.KeyEnter) - calPress(a, tv, vim.R('T')) + calPress(a, tv, vim.R('@')) m, ok := a.overlay.(*menu) if !ok { - t.Fatalf("overlay after T on an item = %T, want the due-date menu", a.overlay) + t.Fatalf("overlay after @ on an item = %T, want the due-date menu", a.overlay) } return a, tv, root, m } @@ -52,11 +52,11 @@ func calPick(t *testing.T, a *App, m *menu, word string) { func TestDueMenuOpensFromItemPanel(t *testing.T) { // AC88 _, tv, _, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) - requireDay(t, tv, "2026-11-18", "after T") + requireDay(t, tv, "2026-11-18", "after @") if tv.calc().sub != calMonth { t.Errorf("sub-mode = %v, want month", tv.calc().sub) } - requireFocus(t, tv, calFocusItems, "after T") + requireFocus(t, tv, calFocusItems, "after @") } func TestDueMenuListsChoicesInOrder(t *testing.T) { // AC89 @@ -168,10 +168,10 @@ func TestDueMenuOpensFromStrip(t *testing.T) { // AC97 a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote, "loose.md": calLooseNote}) calPress(a, tv, vim.KeyTab, vim.KeyTab) requireFocus(t, tv, calFocusStrip, "second Tab") - calPress(a, tv, vim.R('T')) + calPress(a, tv, vim.R('@')) m, ok := a.overlay.(*menu) if !ok { - t.Fatalf("overlay after T on the strip = %T, want the due-date menu", a.overlay) + t.Fatalf("overlay after @ on the strip = %T, want the due-date menu", a.overlay) } calPick(t, a, m, "tomorrow") if got := calReadFile(t, root, "loose.md"); !strings.Contains(got, "loose end due:2026-10-30") { @@ -194,3 +194,24 @@ func TestDueMenuWithoutVimMode(t *testing.T) { // AC112 } requireFocus(t, tv, calFocusItems, "after the letters") } + +// T moves a focused task to today, as in the desktop calendar. +func TestTMovesFocusedTaskToToday(t *testing.T) { + calFixToday(t, calDate(2026, time.October, 29)) + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote, "loose.md": calLooseNote}) + calPress(a, tv, vim.KeyEnter, vim.R('T')) + if a.overlay != nil { + t.Errorf("T on an item opened %T, want no overlay", a.overlay) + } + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-10-29") { + t.Errorf("T on an item did not move it to today:\n%s", got) + } + + a, tv, root = calVaultApp(t, map[string]string{"work.md": calWorkNote, "loose.md": calLooseNote}) + calPress(a, tv, vim.KeyTab, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "second Tab") + calPress(a, tv, vim.R('T')) + if got := calReadFile(t, root, "loose.md"); !strings.Contains(got, "loose end due:2026-10-29") { + t.Errorf("T on a strip task did not move it to today:\n%s", got) + } +} From fc819fc10873aa10c23a0417e070d44f8e87ef2b Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 14:46:22 -0600 Subject: [PATCH 62/65] Fix(tui): the calendar's key hints follow its focus and view The calendar showed one hint line everywhere. It named `z hour step` on the month grid, where `z` does nothing, and `T due menu` while the grid had focus, where `T` goes to today. With a task focused it still named the grid's keys and none of the task's. The grid's hints now differ by view: the month grid names `h/j/k/l` for the day, and the week and day grids name `h/l` for the day, `j/k` to scroll and `z` for the hour step. With a task focused, the hints name the task keys: `j/k`, toggle, open, `T` due today, `@` due date, `<`/`>`, the menu, Tab and `Esc` back. `zn help` lists the calendar's views, focus and task keys, which the PR left at the old one-line entry. Test: the month grid hint names no hour step or due keys, the week grid hint names the hour step, and a focused task's hint names `T`, `@` and `Esc` back but no grid keys. --- internal/tui/helpdata.go | 4 ++- internal/tui/views_hints.go | 9 ++++++- .../tui/views_tasks_calendar_keys_test.go | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index 2a48e6b..c896fe8 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -196,7 +196,9 @@ func helpLines(a *App, filter string) []string { "n f F A\tNew task in the daily note, filter, clear the filter, include archived", "m\tTask menu (includes forward to today's daily note)", "Board: h l j k\tMove between columns and cards; H L move the card; g cycles the grouping", - "Calendar: h j k l [ ] t\tMove days and months, t is today; Enter lists the day's tasks; o opens the daily note", + "Calendar: h j k l [ ] t\tMove days and months, t is today; Esc cycles month, week and day; Enter and Tab move focus to the day's tasks and the No-date strip; o opens the daily note", + "Calendar task: T @\tMove the focused task to today; pick its due date from a menu", + "Week and day: j k z\tScroll the hour list, or a busy day when the list fits; z switches hourly and half-hourly rows", "Filter syntax\tDesktop substring matching: task/note text, !high, #tag, @field:value. Terminal operators require where:, e.g. where: is:open due:today -meeting", "Board status\tToday / Upcoming / No due date / In progress / Waiting / Done; custom status uses field:status. Folder boards do not move cards between folders.", "F2 / :boardoptions\tView actions, saved filters, grouping, folder root, labels and persistent card order", diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index 48b3423..63b4710 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -9,7 +9,14 @@ func (v *tasksView) hintPairs(a *App) []hintPair { case "kanban": return append([]hintPair{{"key:h/l", "column"}, {"key:j/k", "card"}, {"key:H/L", "move card"}, {"nav.toggleTask", "toggle"}, {"key:Enter", "open"}, {"key:g", "group by"}, {"key:m", "menu"}}, common...) case "calendar": - return append([]hintPair{{"key:h/j/k/l", "day"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}, {"key:z", "hour step"}, {"key:T", "due menu"}}, common...) + c := v.calc() + if c.focus != calFocusGrid { + return append([]hintPair{{"key:j/k", "task"}, {"nav.toggleTask", "toggle"}, {"key:Enter", "open"}, {"key:T", "due today"}, {"key:@", "due date"}, {"key:</>", "shift due"}, {"nav.contextMenu", "menu"}, {"key:Tab", "focus"}, {"key:Esc", "back"}}, common...) + } + if c.sub != calMonth { + return append([]hintPair{{"key:h/l", "day"}, {"key:j/k", "scroll"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}, {"key:z", "hour step"}}, common...) + } + return append([]hintPair{{"key:h/j/k/l", "day"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}}, common...) } return append([]hintPair{{"nav.moveDown|nav.moveUp", "move"}, {"nav.toggleTask", "toggle"}, {"nav.openResult", "open"}, {"key:p", "priority"}, {"key:d", "due"}, {"key:w", "waiting"}, {"key:c", "cancel"}, {"key:/", "in progress"}, {"key:e", "edit"}, {"key:s", "field"}, {"key:</>", "shift due"}, {"tasks.moveTaskUp|tasks.moveTaskDown", "reorder"}, {"nav.contextMenu", "menu"}}, common...) } diff --git a/internal/tui/views_tasks_calendar_keys_test.go b/internal/tui/views_tasks_calendar_keys_test.go index f029301..7ee83ef 100644 --- a/internal/tui/views_tasks_calendar_keys_test.go +++ b/internal/tui/views_tasks_calendar_keys_test.go @@ -368,3 +368,29 @@ func TestItemPanelArrowsWithoutVimMode(t *testing.T) { t.Errorf("Left left focus on %v, want the grid", tv.calc().focus) } } + +// The key hints name only the keys that work where the calendar's focus is. +func TestCalendarHintsFollowFocus(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + month := stripAnsi(tv.hint(a)) + for _, absent := range []string{"hour step", "due today", "due date"} { + if strings.Contains(month, absent) { + t.Errorf("month grid hint names %q: %s", absent, month) + } + } + calToSub(t, a, tv, calWeek) + if week := stripAnsi(tv.hint(a)); !strings.Contains(week, "z hour step") || strings.Contains(week, "due today") { + t.Errorf("week grid hint = %s", week) + } + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusItems, "Tab") + task := stripAnsi(tv.hint(a)) + for _, want := range []string{"T due today", "@ due date", "Esc back"} { + if !strings.Contains(task, want) { + t.Errorf("focused task hint lacks %q: %s", want, task) + } + } + if strings.Contains(task, "hour step") || strings.Contains(task, "month/week/day") { + t.Errorf("focused task hint names grid keys: %s", task) + } +} From 591d6601d5a3961ebc6ba0a9605f6d0767300ebd Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 14:46:22 -0600 Subject: [PATCH 63/65] Fix(tui): the Tasks calendar keeps a compact month on narrow terminals The month view needs a 72x18 body for its day boxes and showed "Terminal too small" below that. In an 80x24 terminal with the sidebar open the body is about 43x18, so the default view of `:tasks calendar` showed nothing there, where the calendar before the PR showed a month grid and the selected day's tasks. Below the minimum, the month view now draws a compact month: one line per week with the date and a count of the day's entries (as the side calendar panel does), the selected day's items under it, and the No-date strip at the bottom. It uses the same layout regions as the full month, so keys, Tab focus, clicks and menu anchors work unchanged. "Terminal too small" is left for bodies under 38 columns (with week numbers) or with no room for one item and one strip task under the grid. Tests: the compact month renders at one column and one row below the full minimum and at 43x18 and 38x12, with the day's count, its items and the strip; it is too small at 37x18 and 72x11; Enter, j, the menu anchor and a click on a day work in it. The too-small test that pressed Esc now uses a body too small for the compact month too. --- README.md | 4 +- internal/tui/views_tasks_calendar.go | 131 +++++++++++++++++- .../tui/views_tasks_calendar_modes_test.go | 4 +- internal/tui/views_tasks_calendar_test.go | 81 ++++++++++- 4 files changed, 208 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index db46029..c4c03aa 100644 --- a/README.md +++ b/README.md @@ -585,7 +585,9 @@ reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. manage shared named queries. Prefix terminal operators with `where:`, for example `where: is:open due:today -meeting`. The calendar has month, week and day views; `Esc` cycles them, and a - start in `calendar` mode always opens the month grid. `h`/`j`/`k`/`l` + start in `calendar` mode always opens the month grid. On a narrow + terminal the month shows a compact grid with a count per day and the + selected day's tasks under it. `h`/`j`/`k`/`l` move the day, `[`/`]` the month, `t` goes to today, `<C-D>`/`<C-U>` page by half a view, and `o` opens the day's daily note. Week and day view show an hour list: hourly by default, half-hourly after `z` (Vim mode), diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index 6e5a048..e834806 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -60,8 +60,11 @@ type calDayBox struct { // clicks and menu placement all read the last one, so the geometry is // written once. type calLayout struct { - w, h int - tooSmall bool + w, h int + tooSmall bool + // compact is the month view below its minimum: one line per week with a + // count per day, the selected day's items under the grid. + compact bool items calRect strip calRect boxes []calDayBox @@ -181,8 +184,7 @@ func (c *taskCalendar) selected(day time.Time) *vault.Task { func (c *taskCalendar) monthLayout(a *App, month time.Time, w, h int) calLayout { lay := calLayout{w: w, h: h} if m := calMin[calMonth]; w < m[0] || h < m[1] { - lay.tooSmall = true - return lay + return c.compactMonthLayout(a, month, w, h) } panelW := clampInt(w*30/100, 26, 44) gw := w - panelW @@ -209,6 +211,113 @@ func (c *taskCalendar) monthLayout(a *App, month time.Time, w, h int) calLayout return lay } +// calCompactCell is the narrowest day cell of the compact month: the date +// and a count, as in the side calendar panel. +const calCompactCell = 5 + +// compactMonthLayout computes the compact month for a body too small for +// the day boxes: the grid at one line per week, the selected day's items +// under it, and the strip at the bottom. It is too small only when even +// that does not fit. +func (c *taskCalendar) compactMonthLayout(a *App, month time.Time, w, h int) calLayout { + lay := calLayout{w: w, h: h, compact: true} + wnW := 0 + if a.prefs.CalendarShowWeekNumbers { + wnW = 3 + } + colW := min((w-wnW)/7, 2*calCompactCell) + first, weeks := monthGridStart(month, a.prefs.CalendarWeekStart != "sunday") + gridH := 2 + weeks + // Under the grid: the items' header and one item, then the strip's + // header and one task. + if colW < calCompactCell || h < gridH+4 { + lay.tooSmall = true + return lay + } + rest := h - gridH + stripRows := clampInt(rest/4, 1, 4) + stripH := 1 + stripRows + lay.items = calRect{x: 0, y: gridH, w: w, h: rest - stripH} + lay.strip = calRect{x: 0, y: h - stripH, w: w, h: stripH} + lay.stripRows = stripRows + day := first + for r := 0; r < weeks; r++ { + for col := 0; col < 7; col++ { + lay.boxes = append(lay.boxes, calDayBox{day: day, rect: calRect{x: wnW + col*colW, y: 2 + r, w: colW, h: 1}}) + day = day.AddDate(0, 0, 1) + } + } + return lay +} + +// renderCompactMonth draws the compact month: the grid with a count per +// day, the selected day's items and the strip. +func (c *taskCalendar) renderCompactMonth(a *App, month, day time.Time, lay calLayout, w, h int, focused bool) string { + th := a.theme + rows := make([]string, 0, h) + rows = append(rows, padRight(" "+th.Title.Render(month.Format("January 2006")), w)) + wnW := lay.boxes[0].rect.x + colW := lay.boxes[0].rect.w + names := []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + if a.prefs.CalendarWeekStart == "sunday" { + names = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + } + head := strings.Repeat(" ", wnW) + for _, n := range names { + head += padRight(" "+th.Dim.Render(n), colW) + } + rows = append(rows, padRight(head, w)) + today := calendarNow() + for r := 0; r < len(lay.boxes)/7; r++ { + var sb strings.Builder + if wnW > 0 { + anchor := lay.boxes[r*7].day + if a.prefs.CalendarWeekStart == "sunday" { + anchor = anchor.AddDate(0, 0, 1) + } + _, wk := anchor.ISOWeek() + sb.WriteString(th.Muted.Render(fmt.Sprintf("%2d ", wk))) + } + for col := 0; col < 7; col++ { + box := lay.boxes[r*7+col] + sb.WriteString(c.compactCell(a, box, sameDay(box.day, day), sameDay(box.day, today), box.day.Month() != month.Month(), focused)) + } + rows = append(rows, padRight(sb.String(), w)) + } + rows = append(rows, c.itemPanel(a, day, lay, focused)...) + rows = append(rows, c.stripLines(a, lay, w, focused)...) + return fitBlock(strings.Join(rows, "\n"), w, h) +} + +// compactCell is one day of the compact grid: the date, then how many +// entries the day has (+ for ten or more). +func (c *taskCalendar) compactCell(a *App, box calDayBox, selected, today, outside, focused bool) string { + th := a.theme + w := box.rect.w + num := fmt.Sprintf(" %2d", box.day.Day()) + count := "" + if n := len(c.dayEntries(box.day)); n > 9 { + count = " +" + } else if n > 0 { + count = fmt.Sprintf(" %d", n) + } + if selected { + style := th.Selected + if focused && c.focus == calFocusGrid { + style = th.SelectedFocus + } + return style.Render(padRight(num+count, w)) + } + style := th.Base + switch { + case today: + style = th.Base.Foreground(th.Accent).Bold(true) + case outside: + style = th.Muted + } + return padRight(style.Render(num)+th.Marker.Render(count), w) +} + // hourLabelWidth is the width of the widest hour label for the time format // and hour-list variant. func hourLabelWidth(a *App, half bool) int { @@ -529,6 +638,9 @@ func (c *taskCalendar) render(a *App, month, day time.Time, w, h int, focused bo if c.sub != calMonth { return c.renderColumns(a, day, lay, w, h, focused) } + if lay.compact { + return c.renderCompactMonth(a, month, day, lay, w, h, focused) + } weeks := len(lay.boxes) / 7 gw := lay.items.x wnW := lay.boxes[0].rect.x @@ -635,6 +747,11 @@ func (c *taskCalendar) itemPanel(a *App, day time.Time, lay calLayout, focused b th := a.theme pw := lay.items.w sep := th.Dim.Render("│") + if lay.items.x == 0 { + // The compact month lists the items under the grid, not beside it. + sep = "" + } + sepW := cellWidth(sep) entries := c.dayEntries(day) out := make([]string, lay.items.h) out[0] = padRight(sep+" "+th.Bold.Render(day.Format("Monday, January 2"))+th.Muted.Render(fmt.Sprintf(" (%d)", len(entries))), pw) @@ -654,13 +771,13 @@ func (c *taskCalendar) itemPanel(a *App, day time.Time, lay calLayout, focused b if e.scheduled { lead = calScheduledMark } - body := " " + lead + " " + truncateCells(e.task.Content, pw-4) + body := " " + lead + " " + truncateCells(e.task.Content, pw-3-sepW) if focused && c.focus == calFocusItems && idx == c.cursor { - body = th.SelectedFocus.Render(padRight(body, pw-1)) + body = th.SelectedFocus.Render(padRight(body, pw-sepW)) } line += body case len(entries) == 0 && i == 1: - line += " " + th.Muted.Render(truncateCells("Nothing here. n adds a task.", pw-2)) + line += " " + th.Muted.Render(truncateCells("Nothing here. n adds a task.", pw-1-sepW)) } out[i] = padRight(line, pw) } diff --git a/internal/tui/views_tasks_calendar_modes_test.go b/internal/tui/views_tasks_calendar_modes_test.go index 7df0f96..c7fd646 100644 --- a/internal/tui/views_tasks_calendar_modes_test.go +++ b/internal/tui/views_tasks_calendar_modes_test.go @@ -520,9 +520,9 @@ func TestDayTooSmallBelowMinimum(t *testing.T) { func TestTooSmallMonthDoesNotSwitchModeButEscDoes(t *testing.T) { a, tv := calTestApp(t, nil) - // A body that meets the week minimum but not the month one. + // A body too small for even the compact month. for i := 0; i < 2; i++ { - requireTooSmall(t, calScreen(a, tv, 64, 12), true, "month at 64x12") + requireTooSmall(t, calScreen(a, tv, 30, 12), true, "month at 30x12") if tv.calc().sub != calMonth { t.Fatalf("render switched the layout to %v by itself", tv.calc().sub) } diff --git a/internal/tui/views_tasks_calendar_test.go b/internal/tui/views_tasks_calendar_test.go index c0d372d..33e31ff 100644 --- a/internal/tui/views_tasks_calendar_test.go +++ b/internal/tui/views_tasks_calendar_test.go @@ -370,9 +370,48 @@ func TestMonthStripBelowGrid(t *testing.T) { } } -func TestMonthTooSmallBelowMinimum(t *testing.T) { +// Below the month minimum the calendar keeps a compact month: a line per +// week with a count per day, and the selected day's items under it. +func TestMonthCompactBelowMinimum(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "something", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "undated thing"}, + }) + for _, size := range [][2]int{{71, 18}, {72, 17}, {43, 18}, {38, 12}} { + ctx := fmt.Sprintf("%dx%d", size[0], size[1]) + lines := calScreen(a, tv, size[0], size[1]) + text := calScreenText(lines) + if strings.Contains(text, "Terminal too small") { + t.Errorf("%s: `Terminal too small` instead of the compact month:\n%s", ctx, text) + continue + } + if !tv.calc().layout.compact { + t.Errorf("%s: layout is not the compact month", ctx) + } + if calWeekdayHeader(lines, calWeekdayNames) == nil { + t.Errorf("%s: no weekday header:\n%s", ctx, text) + } + for _, want := range []string{"November 2026", "18 1", "Wednesday, November 18 (1)", "something", "No date (1)", "undated thing"} { + if !strings.Contains(text, want) { + t.Errorf("%s: compact month lacks %q:\n%s", ctx, want, text) + } + } + if len(lines) != size[1]+1 { + t.Errorf("%s: render is %d lines, want toolbar plus %d", ctx, len(lines), size[1]) + } + for i, l := range lines { + if lipgloss.Width(l) > size[0] { + t.Errorf("%s: line %d is %d cells wide", ctx, i, lipgloss.Width(l)) + } + } + } +} + +func TestMonthTooSmallBelowCompactMinimum(t *testing.T) { a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "something", Due: "2026-11-18"}}) - for _, size := range [][2]int{{71, 18}, {72, 17}} { + // Seven cells of five beside the week numbers need 38 columns; six weeks + // with the item and strip lines under them need 12 rows. + for _, size := range [][2]int{{37, 18}, {72, 11}} { text := calScreenText(calScreen(a, tv, size[0], size[1])) if !strings.Contains(text, "Terminal too small") { t.Errorf("%dx%d: no `Terminal too small`:\n%s", size[0], size[1], text) @@ -385,6 +424,44 @@ func TestMonthTooSmallBelowMinimum(t *testing.T) { } } +// The compact month takes the same keys and clicks as the full one. +func TestMonthCompactFocusAndClicks(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "first", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "second", Due: "2026-11-18"}, + {ID: "n#2", SourcePath: "n.md", Content: "on the 20th", Due: "2026-11-20"}, + }) + a.prefs.VimMode = true + calScreen(a, tv, 43, 18) + lay := tv.calc().layout + if !lay.compact { + t.Fatal("43x18 is not the compact month") + } + tv.handleKey(a, vim.KeyEnter) + tv.handleKey(a, vim.R('j')) + if got := tv.selectedTask(); got == nil || got.Content != "second" { + t.Fatalf("Enter then j selected %+v, want second", got) + } + calScreen(a, tv, 43, 18) + x, y := tv.calc().selectionCell(tv.day) + if !lay.items.contains(x, y) || y != lay.items.y+2 { + t.Errorf("menu anchor (%d,%d) is not on the second item row of %+v", x, y, lay.items) + } + for _, b := range lay.boxes { + if sameDay(b.day, calDate(2026, time.November, 20)) { + tv.calc().click(a, tv, b.rect.x+1, b.rect.y, false, false) + } + } + requireDay(t, tv, "2026-11-20", "click on the 20th") + if tv.calc().focus != calFocusGrid { + t.Errorf("click on a day left focus on %v", tv.calc().focus) + } + text := calScreenText(calScreen(a, tv, 43, 18)) + if !strings.Contains(text, "Friday, November 20 (1)") || !strings.Contains(text, "on the 20th") { + t.Errorf("the 20th's items are not listed after the click:\n%s", text) + } +} + func TestMonthRendersAtExactMinimum(t *testing.T) { a, tv := calTestApp(t, nil) lines := calScreen(a, tv, 72, 18) From a00fe9617a6b5fd219ffa8c707101faac7acce96 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 14:46:22 -0600 Subject: [PATCH 64/65] Chore(tui): drop spec ids from the due-menu tests and a stray Kanban gate The due-menu tests carried `// AC88` to `// AC112` comments, ids from a spec that is not in the repository, so they tell a reader nothing. The PR's commit for week and day navigation also added `tab` to the Kanban board's key gate, not the calendar's. Tab is not a printable key, so the gate let it through already and the change did nothing. It goes. --- internal/tui/views_tasks.go | 2 +- .../tui/views_tasks_calendar_duemenu_test.go | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 66a243c..4d805f0 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -933,7 +933,7 @@ func (v *tasksView) taskEditKey(a *App, k vim.Key, t *vault.Task) bool { } func (v *tasksView) boardKey(a *App, k vim.Key) bool { - if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("tab") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { + if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { return false } t := v.selectedTask() diff --git a/internal/tui/views_tasks_calendar_duemenu_test.go b/internal/tui/views_tasks_calendar_duemenu_test.go index e012fe3..cd18c99 100644 --- a/internal/tui/views_tasks_calendar_duemenu_test.go +++ b/internal/tui/views_tasks_calendar_duemenu_test.go @@ -50,7 +50,7 @@ func calPick(t *testing.T, a *App, m *menu, word string) { a.overlay.handleKey(a, vim.KeyEnter) } -func TestDueMenuOpensFromItemPanel(t *testing.T) { // AC88 +func TestDueMenuOpensFromItemPanel(t *testing.T) { _, tv, _, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) requireDay(t, tv, "2026-11-18", "after @") if tv.calc().sub != calMonth { @@ -59,7 +59,7 @@ func TestDueMenuOpensFromItemPanel(t *testing.T) { // AC88 requireFocus(t, tv, calFocusItems, "after @") } -func TestDueMenuListsChoicesInOrder(t *testing.T) { // AC89 +func TestDueMenuListsChoicesInOrder(t *testing.T) { _, _, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) if len(m.items) != 6 { t.Fatalf("menu has %d items, want 6: %+v", len(m.items), m.items) @@ -71,7 +71,7 @@ func TestDueMenuListsChoicesInOrder(t *testing.T) { // AC89 } } -func TestDueMenuArrowsAndEnterRunChoice(t *testing.T) { // AC90 +func TestDueMenuArrowsAndEnterRunChoice(t *testing.T) { a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) a.overlay.handleKey(a, vim.KeyDown) if m.cursor != 1 { @@ -87,7 +87,7 @@ func TestDueMenuArrowsAndEnterRunChoice(t *testing.T) { // AC90 } } -func TestDueMenuLettersRunNothing(t *testing.T) { // AC91 +func TestDueMenuLettersRunNothing(t *testing.T) { a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) for _, it := range m.items { if it.key != "" { @@ -102,7 +102,7 @@ func TestDueMenuLettersRunNothing(t *testing.T) { // AC91 } } -func TestDueMenuRelativeChoicesSetDue(t *testing.T) { // AC92 +func TestDueMenuRelativeChoicesSetDue(t *testing.T) { for _, c := range []struct{ word, due string }{ {"today", "2026-10-29"}, {"tomorrow", "2026-10-30"}, {"next week", "2026-11-05"}, } { @@ -118,7 +118,7 @@ func TestDueMenuRelativeChoicesSetDue(t *testing.T) { // AC92 } } -func TestDueMenuSelectedDaySetsDue(t *testing.T) { // AC93 +func TestDueMenuSelectedDaySetsDue(t *testing.T) { a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) calPick(t, a, m, "2026-11-18") if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-11-18") { @@ -126,7 +126,7 @@ func TestDueMenuSelectedDaySetsDue(t *testing.T) { // AC93 } } -func TestDueMenuClearRemovesDue(t *testing.T) { // AC94 +func TestDueMenuClearRemovesDue(t *testing.T) { a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) calPick(t, a, m, "clear") got := calReadFile(t, root, "work.md") @@ -138,7 +138,7 @@ func TestDueMenuClearRemovesDue(t *testing.T) { // AC94 } } -func TestDueMenuOtherOpensDuePrompt(t *testing.T) { // AC95 +func TestDueMenuOtherOpensDuePrompt(t *testing.T) { a, _, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) calPick(t, a, m, "other") p, ok := a.overlay.(*prompt) @@ -150,7 +150,7 @@ func TestDueMenuOtherOpensDuePrompt(t *testing.T) { // AC95 } } -func TestDueMenuDismissLeavesTaskUnchanged(t *testing.T) { // AC96 +func TestDueMenuDismissLeavesTaskUnchanged(t *testing.T) { for _, k := range []vim.Key{vim.KeyEsc, vim.R('q'), vim.Ctrl('c')} { a, _, root, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) a.overlay.handleKey(a, k) @@ -163,7 +163,7 @@ func TestDueMenuDismissLeavesTaskUnchanged(t *testing.T) { // AC96 } } -func TestDueMenuOpensFromStrip(t *testing.T) { // AC97 +func TestDueMenuOpensFromStrip(t *testing.T) { calFixToday(t, calDate(2026, time.October, 29)) a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote, "loose.md": calLooseNote}) calPress(a, tv, vim.KeyTab, vim.KeyTab) @@ -179,7 +179,7 @@ func TestDueMenuOpensFromStrip(t *testing.T) { // AC97 } } -func TestDueMenuWithoutVimMode(t *testing.T) { // AC112 +func TestDueMenuWithoutVimMode(t *testing.T) { calFixToday(t, calDate(2026, time.October, 29)) a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) a.prefs.VimMode = false From 9d1c18d600c209548d9c684b93cfd0c962e17d49 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Sun, 1 Nov 2026 14:47:31 -0600 Subject: [PATCH 65/65] Refactor(tui): week and day columns list tasks without an hour axis Tasks carry a date and no time of day (the desktop's new `@remind:` time is relative to the date-only due date), and the PR lists a day's entries from the top of its column on purpose. But it drew an hour list beside the columns, so the first task sat next to `00`, the second next to `01` (or `00:30` after `z`), and the week read as a schedule of tasks at midnight that nobody wrote. The week and day views are now columns of the day's tasks under the header, with the strip at the bottom. The hour list, its scrolling, the half-hourly variant and `z` are gone; `j`/`k` scroll a busy day's column, which the PR already did when the hour list fit. Columns use the whole height, so the week view needs 56x8 and the day view 30x7 instead of 64x12 and 40x10. The wheel moves the selected day a week in every view, as it did in the PR when the hour list fit. Tests: the hour-list tests go; the week and day show no hour labels in either time format, `z` changes nothing in any view, column scrolling and the overflow counts are checked on a body shorter than the busy day, the strip sits under the columns, and the minimum sizes follow calMin. The editor's fold keys test stays. --- README.md | 6 +- internal/tui/helpdata.go | 2 +- internal/tui/views_hints.go | 6 +- internal/tui/views_tasks.go | 22 +- internal/tui/views_tasks_calendar.go | 139 +----- .../views_tasks_calendar_colscroll_test.go | 21 +- .../tui/views_tasks_calendar_hours_test.go | 432 ------------------ .../tui/views_tasks_calendar_keys_test.go | 2 +- .../tui/views_tasks_calendar_modes_test.go | 199 ++++---- .../tui/views_tasks_calendar_mouse_test.go | 58 +-- 10 files changed, 147 insertions(+), 740 deletions(-) delete mode 100644 internal/tui/views_tasks_calendar_hours_test.go diff --git a/README.md b/README.md index c4c03aa..55c470e 100644 --- a/README.md +++ b/README.md @@ -589,9 +589,9 @@ reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. terminal the month shows a compact grid with a count per day and the selected day's tasks under it. `h`/`j`/`k`/`l` move the day, `[`/`]` the month, `t` goes to today, `<C-D>`/`<C-U>` page - by half a view, and `o` opens the day's daily note. Week and day view - show an hour list: hourly by default, half-hourly after `z` (Vim mode), - scrolling with `j`/`k` when it does not fit. `Enter` and `Tab` move focus + by half a view, and `o` opens the day's daily note. In week and day view + each day is a column of its tasks, and `j`/`k` scroll a busy day's + column. `Enter` and `Tab` move focus between the day grid, the selected day's items and the No-date strip. On a focused item or strip task, `T` moves it to today and `@` opens a due-date menu, as in the desktop calendar. Unlike the diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index c896fe8..9937104 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -198,7 +198,7 @@ func helpLines(a *App, filter string) []string { "Board: h l j k\tMove between columns and cards; H L move the card; g cycles the grouping", "Calendar: h j k l [ ] t\tMove days and months, t is today; Esc cycles month, week and day; Enter and Tab move focus to the day's tasks and the No-date strip; o opens the daily note", "Calendar task: T @\tMove the focused task to today; pick its due date from a menu", - "Week and day: j k z\tScroll the hour list, or a busy day when the list fits; z switches hourly and half-hourly rows", + "Week and day: j k\tScroll a busy day's column", "Filter syntax\tDesktop substring matching: task/note text, !high, #tag, @field:value. Terminal operators require where:, e.g. where: is:open due:today -meeting", "Board status\tToday / Upcoming / No due date / In progress / Waiting / Done; custom status uses field:status. Folder boards do not move cards between folders.", "F2 / :boardoptions\tView actions, saved filters, grouping, folder root, labels and persistent card order", diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index 63b4710..909898d 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -14,7 +14,7 @@ func (v *tasksView) hintPairs(a *App) []hintPair { return append([]hintPair{{"key:j/k", "task"}, {"nav.toggleTask", "toggle"}, {"key:Enter", "open"}, {"key:T", "due today"}, {"key:@", "due date"}, {"key:</>", "shift due"}, {"nav.contextMenu", "menu"}, {"key:Tab", "focus"}, {"key:Esc", "back"}}, common...) } if c.sub != calMonth { - return append([]hintPair{{"key:h/l", "day"}, {"key:j/k", "scroll"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}, {"key:z", "hour step"}}, common...) + return append([]hintPair{{"key:h/l", "day"}, {"key:j/k", "scroll"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}}, common...) } return append([]hintPair{{"key:h/j/k/l", "day"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}}, common...) } @@ -53,10 +53,6 @@ func (v *tasksView) scrollBy(a *App, delta int) { v.card = max(0, min(max(0, len(v.columns[v.col].cards)-1), v.card+delta)) } case "calendar": - if c := v.calc(); c.sub != calMonth && c.visibleHourRows() > 0 && !c.hourListWhole() { - c.scrollHours(sign(delta)) - return - } v.selectDay(a, v.day.AddDate(0, 0, 7*sign(delta))) default: v.list.move(delta, len(v.rows)) diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 4d805f0..cc57150 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -82,7 +82,6 @@ func (v *tasksView) setMode(mode string) { v.mode = "kanban" case "calendar": v.mode = "calendar" - v.calc().half = false default: v.mode = "list" } @@ -759,9 +758,6 @@ func (v *tasksView) handleKey(a *App, k vim.Key) bool { default: v.mode = "list" } - if v.mode == "calendar" { - v.calc().half = false - } a.prefs.TasksViewMode = v.mode v.refresh(a) return true @@ -1046,10 +1042,6 @@ func (v *tasksView) moveCard(a *App, t vault.Task, target int) { func (v *tasksView) calendarKey(a *App, k vim.Key) bool { t := v.selectedTask() - if k.IsRune('z') && a.prefs.VimMode && v.calc().sub != calMonth { - v.calc().toggleHalf() - return true - } if v.calc().focus != calFocusGrid { // With Vim off a focused task, like the list, takes no letters. if !a.listKeyAllowed(k) { @@ -1107,18 +1099,14 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { return false } - hourList := v.calc().sub != calMonth + columns := v.calc().sub != calMonth switch { case k.Is("esc"): v.calc().sub = (v.calc().sub + 1) % 3 - case hourList && (k.IsRune('j') || k.Is("down")): - if !v.calc().scrollColumn(v.day, 1) { - v.calc().scrollHours(1) - } - case hourList && (k.IsRune('k') || k.Is("up")): - if !v.calc().scrollColumn(v.day, -1) { - v.calc().scrollHours(-1) - } + case columns && (k.IsRune('j') || k.Is("down")): + v.calc().scrollColumn(v.day, 1) + case columns && (k.IsRune('k') || k.Is("up")): + v.calc().scrollColumn(v.day, -1) case k.IsRune('h') || k.Is("left"): v.selectDay(a, v.day.AddDate(0, 0, -1)) case k.IsRune('l') || k.Is("right"): diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go index e834806..8b152bb 100644 --- a/internal/tui/views_tasks_calendar.go +++ b/internal/tui/views_tasks_calendar.go @@ -35,8 +35,8 @@ const ( // Below it the body only says the terminal is too small. var calMin = map[calSub][2]int{ calMonth: {72, 18}, - calWeek: {64, 12}, - calDay: {40, 10}, + calWeek: {56, 8}, + calDay: {30, 7}, } // calScheduledMark prefixes an entry placed by its scheduled date. It must @@ -72,11 +72,6 @@ type calLayout struct { stripRows int // stripStart is the index of the first strip task shown. stripStart int - // gutter is the width of the hour labels left of the week and day columns. - gutter int - // hourStart is the index of the first hour row shown, and hourTotal the - // number of rows in the whole list. - hourStart, hourTotal int } // taskCalendar is the Tasks tab's calendar: day entries, the items beside @@ -86,14 +81,8 @@ type taskCalendar struct { focus calFocus cursor int strip int - // half selects half-hourly hour rows in place of hourly ones. - half bool - // scroll is the time of day, in minutes, of the first hour row shown. - // It is a time rather than a row index so it survives a change of - // variant. - scroll int // colScroll is how many entries the selected day's column has scrolled - // off its top. It only applies while the hour list is shown whole. + // off its top. colScroll int byDay map[string][]calEntry @@ -318,94 +307,6 @@ func (c *taskCalendar) compactCell(a *App, box calDayBox, selected, today, outsi return padRight(style.Render(num)+th.Marker.Render(count), w) } -// hourLabelWidth is the width of the widest hour label for the time format -// and hour-list variant. -func hourLabelWidth(a *App, half bool) int { - w := 2 - if a.prefs.TimeFormat == "12h" { - w = 5 - } - if half { - w += 3 - } - return w -} - -// hourLabel is the label of the row starting at the given minute of the day. -func hourLabel(a *App, minute int) string { - h, half := minute/60, minute%60 == 30 - if a.prefs.TimeFormat == "12h" { - n := h % 12 - if n == 0 { - n = 12 - } - suffix := "AM" - if h >= 12 { - suffix = "PM" - } - if half { - return fmt.Sprintf("%2d:30 %s", n, suffix) - } - return fmt.Sprintf("%2d %s", n, suffix) - } - if half { - return fmt.Sprintf("%02d:30", h) - } - return fmt.Sprintf("%02d", h) -} - -// hourStep is the minutes one hour row covers. -func (c *taskCalendar) hourStep() int { - if c.half { - return 30 - } - return 60 -} - -// hourRowCount is the number of rows in the whole hour list. -func (c *taskCalendar) hourRowCount() int { return 24 * 60 / c.hourStep() } - -// firstHourRow is the index of the first row shown when room rows fit. -func (c *taskCalendar) firstHourRow(room int) int { - return clampInt(c.scroll/c.hourStep(), 0, max(0, c.hourRowCount()-room)) -} - -// visibleHourRows is how many hour rows the last layout showed, or 0 when -// none was laid out. -func (c *taskCalendar) visibleHourRows() int { - if c.layout.tooSmall || len(c.layout.boxes) == 0 || c.sub == calMonth { - return 0 - } - return c.layout.boxes[0].rect.h -} - -// hourListWhole reports whether the last layout shows every hour row. -func (c *taskCalendar) hourListWhole() bool { - n := c.visibleHourRows() - return n > 0 && n >= c.hourRowCount() -} - -// scrollHours moves the hour list by delta rows, never past either end. -func (c *taskCalendar) scrollHours(delta int) { - step := c.hourStep() - room := c.visibleHourRows() - if room == 0 { - c.scroll = max(0, c.scroll+delta*step) - return - } - c.scroll = clampInt(c.firstHourRow(room)+delta, 0, max(0, c.hourRowCount()-room)) * step -} - -// toggleHalf switches between hourly and half-hourly rows. The list starts at -// the same time, or at the hour containing it, kept inside the new list. -func (c *taskCalendar) toggleHalf() { - c.half = !c.half - c.scroll = c.scroll / c.hourStep() * c.hourStep() - if room := c.visibleHourRows(); room > 0 { - c.scroll = c.firstHourRow(room) * c.hourStep() - } -} - // columnDays is the days a week or day layout shows as columns, in order. func (c *taskCalendar) columnDays(a *App, day time.Time) []time.Time { if c.sub == calDay { @@ -424,8 +325,8 @@ func (c *taskCalendar) columnDays(a *App, day time.Time) []time.Time { } // columnLayout computes the regions of the week or day view for a body of -// w x h. The hour list sits under the header rows with one column per day -// beside it, and the strip is pinned to the bottom of the body. +// w x h: one column per day under the header rows, and the strip pinned to +// the bottom of the body. func (c *taskCalendar) columnLayout(a *App, day time.Time, w, h int) calLayout { lay := calLayout{w: w, h: h} if m := calMin[c.sub]; w < m[0] || h < m[1] { @@ -441,31 +342,27 @@ func (c *taskCalendar) columnLayout(a *App, day time.Time, w, h int) calLayout { stripH := 1 + stripRows lay.stripRows = stripRows lay.strip = calRect{x: 0, y: h - stripH, w: w, h: stripH} - lay.gutter = hourLabelWidth(a, c.half) + 2 - lay.hourTotal = c.hourRowCount() - hourRows := clampInt(h-stripH-headRows, 0, lay.hourTotal) - lay.hourStart = c.firstHourRow(hourRows) - colW := (w - lay.gutter) / len(days) + colH := h - stripH - headRows + colW := w / len(days) for i, d := range days { - lay.boxes = append(lay.boxes, calDayBox{day: d, rect: calRect{x: lay.gutter + i*colW, y: headRows, w: colW, h: hourRows}}) + lay.boxes = append(lay.boxes, calDayBox{day: d, rect: calRect{x: i * colW, y: headRows, w: colW, h: colH}}) } return lay } -// renderColumns draws the week or day view: a header, the hour list with the -// day columns beside it, and the strip. Entries are listed from the top of -// their column, not placed by time of day. +// renderColumns draws the week or day view: a header, the day columns and +// the strip. Tasks carry a date but no time of day, so a column lists its +// entries from the top. func (c *taskCalendar) renderColumns(a *App, day time.Time, lay calLayout, w, h int, focused bool) string { th := a.theme rows := make([]string, 0, h) - blank := strings.Repeat(" ", lay.gutter) first, last := lay.boxes[0].day, lay.boxes[len(lay.boxes)-1].day if c.sub == calDay { rows = append(rows, padRight(" "+th.Title.Render(day.Format("Monday, January 2, 2006")), w)) } else { title := first.Format("January 2") + " – " + last.Format("January 2, 2006") rows = append(rows, padRight(" "+th.Title.Render(title), w)) - head := blank + head := "" for _, b := range lay.boxes { cell := padRight(" "+b.day.Format("Mon")[:2]+b.day.Format(" 2"), b.rect.w) if sameDay(b.day, day) { @@ -477,10 +374,8 @@ func (c *taskCalendar) renderColumns(a *App, day time.Time, lay calLayout, w, h } rows = append(rows, padRight(head, w)) } - hourRows := lay.boxes[0].rect.h - for i := 0; i < hourRows; i++ { + for i := 0; i < lay.boxes[0].rect.h; i++ { var sb strings.Builder - sb.WriteString(th.Muted.Render(padRight(hourLabel(a, (lay.hourStart+i)*c.hourStep()), lay.gutter-2)) + th.Dim.Render(" │")) for _, b := range lay.boxes { scroll := 0 if sameDay(b.day, day) { @@ -507,13 +402,9 @@ func (c *taskCalendar) maxColumnScroll(b calDayBox) int { return n - (b.rect.h - 1) } -// columnScroll is the scroll applied to box b, kept inside its range. A -// column only scrolls while the hour list is shown whole. +// columnScroll is the scroll applied to box b, kept inside its range. func (c *taskCalendar) columnScroll(b calDayBox) int { items := c.focus == calFocusItems - if !c.hourListWhole() && !items { - return 0 - } s := clampInt(c.colScroll, 0, c.maxColumnScroll(b)) if items { // Keep the selected entry on screen as the selection moves. @@ -538,7 +429,7 @@ func (c *taskCalendar) columnShown(b calDayBox, s int) int { // scrollColumn scrolls the selected day's column by delta entries, never past // either end. It reports whether there is a column to scroll. func (c *taskCalendar) scrollColumn(day time.Time, delta int) bool { - if c.sub == calMonth || !c.hourListWhole() { + if c.sub == calMonth { return false } for _, b := range c.layout.boxes { diff --git a/internal/tui/views_tasks_calendar_colscroll_test.go b/internal/tui/views_tasks_calendar_colscroll_test.go index 434ab43..e648418 100644 --- a/internal/tui/views_tasks_calendar_colscroll_test.go +++ b/internal/tui/views_tasks_calendar_colscroll_test.go @@ -69,26 +69,26 @@ func (v colView) String() string { return fmt.Sprintf("above=%d entries=%v below=%d", v.above, v.entries, v.below) } -// calColumnApp opens a layout with one very busy day and an hour list that is -// shown whole. +// calColumnBody is a body whose day columns hold far fewer lines than the +// busy day has entries. +const calColumnW, calColumnH = 120, 30 + +// calColumnApp opens a layout with one very busy day. func calColumnApp(t *testing.T, sub calSub, vimOn bool) (*App, *tasksView) { t.Helper() a, tv := calTestApp(t, calManyTasks("2026-11-18", colScrollTotal, "zq")) a.prefs.VimMode = vimOn - a.prefs.TimeFormat = "24h" calToSub(t, a, tv, sub) - if got := calLabels(a, tv, 120, 70); len(got) != 24 || got[0] != "00" { - t.Fatalf("layout %v: hour list not shown whole: %v", sub, got) - } + calScreen(a, tv, calColumnW, calColumnH) return a, tv } func calColumnAfter(t *testing.T, a *App, tv *tasksView) colView { t.Helper() - return calColumnView(t, calScreen(a, tv, 120, 70)) + return calColumnView(t, calScreen(a, tv, calColumnW, calColumnH)) } -func TestColumnScrollWhenHourListWhole(t *testing.T) { +func TestColumnScrollsWithJK(t *testing.T) { for _, sub := range []calSub{calWeek, calDay} { a, tv := calColumnApp(t, sub, true) ctx := fmt.Sprintf("layout %v", sub) @@ -109,9 +109,6 @@ func TestColumnScrollWhenHourListWhole(t *testing.T) { t.Errorf("%s: j selected entry %q, want none", ctx, sel.Content) } requireDay(t, tv, "2026-11-18", ctx+" after j") - if labels := calLabels(a, tv, 120, 70); len(labels) != 24 || labels[0] != "00" { - t.Errorf("%s: j scrolled the hour list: %v", ctx, labels) - } calPress(a, tv, calRunes("jj")...) if got := calColumnAfter(t, a, tv); got.above != 3 || got.entries[0] != 3 { @@ -154,7 +151,7 @@ func TestScrolledColumnShowsMoreAboveAndBelow(t *testing.T) { if want := colScrollTotal - 3 - wantEntries; got.below != want { t.Errorf("%s: bottom count = %d, want +%d more", ctx, got.below, want) } - lines := calScreen(a, tv, 120, 70) + lines := calScreen(a, tv, calColumnW, calColumnH) if top, firstEntry := calRowOf(lines, "+3 more"), calRowOf(lines, "zq03"); top < 0 || top != firstEntry-1 { t.Errorf("%s: `+3 more` on row %d, want the line right above the first entry (row %d)", ctx, top, firstEntry) } diff --git a/internal/tui/views_tasks_calendar_hours_test.go b/internal/tui/views_tasks_calendar_hours_test.go deleted file mode 100644 index 22fed9e..0000000 --- a/internal/tui/views_tasks_calendar_hours_test.go +++ /dev/null @@ -1,432 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - "testing" - - "github.com/ZenNotes/tui/internal/vim" -) - -// calHalfHours24 is the 48 half-hourly labels in the 24h format. -func calHalfHours24() []string { - var out []string - for h := 0; h < 24; h++ { - out = append(out, fmt.Sprintf("%02d", h), fmt.Sprintf("%02d:30", h)) - } - return out -} - -// calHalfHours12 is the 48 half-hourly labels in the 12h format. -func calHalfHours12() []string { - var out []string - for _, h := range calHours12() { - out = append(out, h, strings.Replace(h, " ", ":30 ", 1)) - } - return out -} - -func calLabels(a *App, tv *tasksView, w, bodyH int) []string { - return calHourLabels(calScreen(a, tv, w, bodyH), calLabel24) -} - -func requireVariant(t *testing.T, a *App, tv *tasksView, half bool, context string) { - t.Helper() - want := 24 - if half { - want = 48 - } - lines := calScreen(a, tv, 120, 110) - if got := calHourLabels(lines, calLabel24); len(got) != want { - t.Errorf("%s: %d hour rows shown, want %d (half-hourly=%v):\n%s", context, len(got), want, half, calScreenText(lines)) - } -} - -func TestMonthIgnoresZ(t *testing.T) { - a, tv := calTestApp(t, calManyTasks("2026-11-18", 3, "zt")) - a.prefs.VimMode = true - before := calScreenText(calScreen(a, tv, 120, 40)) - calPress(a, tv, vim.R('z')) - if after := calScreenText(calScreen(a, tv, 120, 40)); after != before { - t.Errorf("z changed the month view:\nbefore:\n%s\nafter:\n%s", before, after) - } - if tv.calc().sub != calMonth { - t.Errorf("layout after z = %v, want month", tv.calc().sub) - } - calEsc(a, tv) - requireVariant(t, a, tv, false, "week after z in month view") -} - -func TestHourListStartsHourlyAtMidnight(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - got := calLabels(a, tv, 120, 70) - if strings.Join(got, ",") != strings.Join(calHours24(), ",") { - t.Errorf("layout %v: rows = %v, want 00..23", sub, got) - } - if small := calLabels(a, tv, 120, 20); len(small) == 0 || small[0] != "00" { - t.Errorf("layout %v: first visible row in a short body = %v, want 00", sub, small) - } - } -} - -func TestZTogglesVariantInWeek(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, calWeek) - if !tv.handleKey(a, vim.R('z')) { - t.Error("z was not handled in week view") - } - requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), "week after z") - calPress(a, tv, vim.R('z')) - requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHours24(), "week after z twice") -} - -func TestZTogglesVariantInDay(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, calDay) - if !tv.handleKey(a, vim.R('z')) { - t.Error("z was not handled in day view") - } - requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), "day after z") - calPress(a, tv, vim.R('z')) - requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHours24(), "day after z twice") -} - -func TestZWorksInItemFocusWithVimMode(t *testing.T) { - a, tv := calTestApp(t, calManyTasks("2026-11-18", 2, "zi")) - a.prefs.VimMode = true - calToSub(t, a, tv, calWeek) - calPress(a, tv, vim.KeyEnter) - if tv.calc().focus != calFocusItems { - t.Fatalf("Enter left focus on %v, want the day's column", tv.calc().focus) - } - calPress(a, tv, vim.R('z')) - requireVariant(t, a, tv, true, "z in item focus") -} - -func TestVariantResetsOnCalendarEntry(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - calToSub(t, a, tv, calWeek) - calPress(a, tv, vim.R('z')) - requireVariant(t, a, tv, true, "before leaving") - for i := 0; i < 3; i++ { - calPress(a, tv, vim.R('v')) - } - if tv.mode != "calendar" { - t.Fatalf("mode after v round trip = %q, want calendar", tv.mode) - } - requireVariant(t, a, tv, false, "after the v round trip") - - calPress(a, tv, vim.R('z')) - requireVariant(t, a, tv, true, "before :tasks calendar") - tv.setMode("list") - if err := a.openTasksMode("calendar"); err != nil { - t.Fatal(err) - } - tv2, ok := a.activeTab().view.(*tasksView) - if !ok { - t.Fatalf("active tab is %T", a.activeTab().view) - } - requireVariant(t, a, tv2, false, "after :tasks calendar") -} - -func TestVariantSurvivesMovesAndWeekDaySwitch(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - calToSub(t, a, tv, calWeek) - calPress(a, tv, vim.R('z')) - // Over the end of the week into the next one, then back. - calPress(a, tv, calRunes("lllllll")...) - requireDay(t, tv, "2026-11-25", "seven l presses") - requireVariant(t, a, tv, true, "after moving to another week") - calPress(a, tv, calRunes("hhhhhhh")...) - requireVariant(t, a, tv, true, "after moving back") - calEsc(a, tv) - if tv.calc().sub != calDay { - t.Fatalf("Esc gave layout %v, want day", tv.calc().sub) - } - requireVariant(t, a, tv, true, "after switching to day view") -} - -func TestHourlyShownWholeWhenTallEnough(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v", sub)) - calPress(a, tv, vim.R('j')) - requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v after j", sub)) - } -} - -func TestHalfHourlyShownWholeWhenTallEnough(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - if sub == calWeek { - calPress(a, tv, vim.R('z')) - } - requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), fmt.Sprintf("layout %v", sub)) - } -} - -func TestHourListScrollsAtMinimumSize(t *testing.T) { - for _, c := range []struct { - sub calSub - w, h int - }{{calWeek, 64, 12}, {calDay, 40, 10}} { - for _, half := range []bool{false, true} { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, c.sub) - if half { - calPress(a, tv, vim.R('z')) - } - lines := calScreen(a, tv, c.w, c.h) - ctx := fmt.Sprintf("layout %v half=%v %dx%d", c.sub, half, c.w, c.h) - requireTooSmall(t, lines, false, ctx) - got := calHourLabels(lines, calLabel24) - total := 24 - if half { - total = 48 - } - if len(got) == 0 || len(got) >= total { - t.Fatalf("%s: %d rows shown, want some but fewer than %d:\n%s", ctx, len(got), total, calScreenText(lines)) - } - if got[0] != "00" { - t.Errorf("%s: first row = %s, want 00", ctx, got[0]) - } - calPress(a, tv, vim.R('j')) - scrolled := calHourLabels(calScreen(a, tv, c.w, c.h), calLabel24) - if len(scrolled) == 0 || scrolled[0] == "00" { - t.Errorf("%s: j did not scroll the list: %v", ctx, scrolled) - } - } - } -} - -func TestHalfHourlyLabels24h(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - if sub == calWeek { - calPress(a, tv, vim.R('z')) - } - requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), fmt.Sprintf("layout %v", sub)) - } -} - -func TestHalfHourlyLabels12h(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "12h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - if sub == calWeek { - calPress(a, tv, vim.R('z')) - } - requireLabels(t, calScreen(a, tv, 120, 110), calLabel12, calHalfHours12(), fmt.Sprintf("layout %v", sub)) - } -} - -func TestHourListJKScrollClamped(t *testing.T) { - for _, sub := range []calSub{calWeek, calDay} { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, sub) - first := calLabels(a, tv, 120, 20) - n := len(first) - if n == 0 || n >= 24 || first[0] != "00" { - t.Fatalf("layout %v: unexpected short-body rows %v", sub, first) - } - calPress(a, tv, vim.R('k')) - if got := calLabels(a, tv, 120, 20); got[0] != "00" { - t.Errorf("layout %v: k before the first row moved the list to %s", sub, got[0]) - } - calPress(a, tv, vim.R('j')) - if got := calLabels(a, tv, 120, 20); got[0] != "01" { - t.Errorf("layout %v: j moved the list to %s, want 01", sub, got[0]) - } - requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v after j", sub)) - for i := 0; i < 60; i++ { - calPress(a, tv, vim.R('j')) - } - got := calLabels(a, tv, 120, 20) - if len(got) != n || got[len(got)-1] != "23" || got[0] != fmt.Sprintf("%02d", 24-n) { - t.Errorf("layout %v: after scrolling to the end rows = %v, want %d rows ending at 23", sub, got, n) - } - requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v after many j", sub)) - for i := 0; i < 60; i++ { - calPress(a, tv, vim.R('k')) - } - if got := calLabels(a, tv, 120, 20); got[0] != "00" { - t.Errorf("layout %v: after scrolling back first row = %s, want 00", sub, got[0]) - } - } -} - -func TestHourListArrowsScrollWithoutVimMode(t *testing.T) { - for _, sub := range []calSub{calWeek, calDay} { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = false - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, sub) - if got := calLabels(a, tv, 120, 20); len(got) >= 24 || got[0] != "00" { - t.Fatalf("layout %v: unexpected rows %v", sub, got) - } - calPress(a, tv, vim.KeyDown) - if got := calLabels(a, tv, 120, 20); got[0] != "01" { - t.Errorf("layout %v: Down moved the list to %s, want 01", sub, got[0]) - } - calPress(a, tv, vim.KeyDown) - calPress(a, tv, vim.KeyUp) - if got := calLabels(a, tv, 120, 20); got[0] != "01" { - t.Errorf("layout %v: Down, Down, Up left the list at %s, want 01", sub, got[0]) - } - requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v", sub)) - } -} - -func TestHourListScrollPersists(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, calWeek) - calLabels(a, tv, 120, 20) - calPress(a, tv, calRunes("jjjjjj")...) - requireFirst := func(context string) { - t.Helper() - got := calLabels(a, tv, 120, 20) - if len(got) == 0 || got[0] != "06" { - t.Errorf("%s: first row = %v, want 06", context, got) - } - } - requireFirst("after scrolling") - calPress(a, tv, calRunes("lllllll")...) - requireFirst("in the next week") - calPress(a, tv, calRunes("hhhhhhh")...) - requireFirst("back in the first week") - calEsc(a, tv) - requireFirst("in day view") - calPress(a, tv, calRunes("l")...) - requireFirst("on another day") - calEsc(a, tv) - if tv.calc().sub != calMonth { - t.Fatalf("layout = %v, want month", tv.calc().sub) - } - calEsc(a, tv) - requireFirst("in week view after the month view") - calEsc(a, tv) - requireFirst("in day view after the month view") -} - -func TestHourListScrollAfterZKeepsTime(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, calWeek) - n := len(calLabels(a, tv, 120, 20)) - if n == 0 || n >= 24 { - t.Fatalf("unexpected short-body row count %d", n) - } - calPress(a, tv, calRunes("jjjjjj")...) - calPress(a, tv, vim.R('z')) - got := calLabels(a, tv, 120, 20) - if len(got) == 0 || got[0] != "06" { - t.Fatalf("hourly 06 became %v in the half-hourly variant, want first row 06", got) - } - calPress(a, tv, vim.R('j')) - if got := calLabels(a, tv, 120, 20); got[0] != "06:30" { - t.Fatalf("one j in the half-hourly variant gave %v, want first row 06:30", got) - } - calPress(a, tv, vim.R('z')) - if got := calLabels(a, tv, 120, 20); got[0] != "06" { - t.Errorf("06:30 became %v in the hourly variant, want first row 06", got) - } - - // At the end of the half-hourly list the same time is past the end of - // the hourly list, so the start is clamped to the last full page. - calPress(a, tv, vim.R('z')) - for i := 0; i < 120; i++ { - calPress(a, tv, vim.R('j')) - } - end := calLabels(a, tv, 120, 20) - if len(end) != n || end[len(end)-1] != "23:30" { - t.Fatalf("half-hourly end of list = %v, want %d rows ending at 23:30", end, n) - } - calPress(a, tv, vim.R('z')) - got = calLabels(a, tv, 120, 20) - if len(got) != n || got[len(got)-1] != "23" { - t.Errorf("hourly list after z at the end = %v, want %d rows ending at 23", got, n) - } -} - -func TestZWithoutVimModeInItemFocusDoesNothing(t *testing.T) { - a, tv := calTestApp(t, calManyTasks("2026-11-18", 2, "zv")) - a.prefs.VimMode = false - calToSub(t, a, tv, calWeek) - calPress(a, tv, vim.KeyEnter) - if tv.calc().focus != calFocusItems { - t.Fatalf("Enter left focus on %v, want the day's column", tv.calc().focus) - } - calPress(a, tv, vim.R('z')) - requireVariant(t, a, tv, false, "z in item focus without Vim mode") -} - -func TestZWithoutVimModeOnGridDoesNothing(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = false - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - calPress(a, tv, vim.R('z')) - requireVariant(t, a, tv, false, fmt.Sprintf("z on the %v grid without Vim mode", sub)) - } -} - -func TestEditorFoldKeysUnaffected(t *testing.T) { - a, _ := newFeatureTestApp(t) - a.prefs.VimMode = true - a.openNote("note.md", true) - buf := a.activeBuffer() - if buf == nil { - t.Fatal("no active note buffer") - } - buf.ed.ReplaceText("# A\na1\na2\n# B\nb1") - buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) - press := func(s string) { - for _, r := range s { - a.handleKey(vim.R(r)) - } - } - press("zc") - if end, ok := buf.ed.IsFoldStart(0); !ok || end != 2 { - t.Errorf("zc: fold at line 0 = %d %v, want 0..2", end, ok) - } - press("zo") - if _, ok := buf.ed.IsFoldStart(0); ok { - t.Error("zo did not open the fold") - } - press("zM") - if len(buf.ed.Folds()) != 2 { - t.Errorf("zM: folds = %v, want both sections", buf.ed.Folds()) - } - press("zR") - if len(buf.ed.Folds()) != 0 { - t.Errorf("zR: folds = %v, want none", buf.ed.Folds()) - } -} diff --git a/internal/tui/views_tasks_calendar_keys_test.go b/internal/tui/views_tasks_calendar_keys_test.go index 7ee83ef..99574c7 100644 --- a/internal/tui/views_tasks_calendar_keys_test.go +++ b/internal/tui/views_tasks_calendar_keys_test.go @@ -379,7 +379,7 @@ func TestCalendarHintsFollowFocus(t *testing.T) { } } calToSub(t, a, tv, calWeek) - if week := stripAnsi(tv.hint(a)); !strings.Contains(week, "z hour step") || strings.Contains(week, "due today") { + if week := stripAnsi(tv.hint(a)); !strings.Contains(week, "j/k scroll") || strings.Contains(week, "due today") { t.Errorf("week grid hint = %s", week) } calPress(a, tv, vim.KeyTab) diff --git a/internal/tui/views_tasks_calendar_modes_test.go b/internal/tui/views_tasks_calendar_modes_test.go index c7fd646..8accc7f 100644 --- a/internal/tui/views_tasks_calendar_modes_test.go +++ b/internal/tui/views_tasks_calendar_modes_test.go @@ -44,38 +44,6 @@ func calHourLabels(lines []string, re *regexp.Regexp) []string { return out } -func calHours24() []string { - var out []string - for h := 0; h < 24; h++ { - out = append(out, fmt.Sprintf("%02d", h)) - } - return out -} - -func calHours12() []string { - var out []string - for h := 0; h < 24; h++ { - n := h % 12 - if n == 0 { - n = 12 - } - suffix := "AM" - if h >= 12 { - suffix = "PM" - } - out = append(out, fmt.Sprintf("%d %s", n, suffix)) - } - return out -} - -func requireLabels(t *testing.T, lines []string, re *regexp.Regexp, want []string, context string) { - t.Helper() - got := calHourLabels(lines, re) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("%s: hour labels = %v, want %v\n%s", context, got, want, calScreenText(lines)) - } -} - // calColumnOf is the cell column where text starts, or -1. func calColumnOf(lines []string, text string) int { for _, l := range lines { @@ -290,45 +258,27 @@ func TestWeekDayViewsHideWeekNumbers(t *testing.T) { } } -func TestHourlyLabels24h(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.TimeFormat = "24h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v", sub)) - } -} - -func TestHourlyLabels12h(t *testing.T) { - a, tv := calTestApp(t, nil) - a.prefs.TimeFormat = "12h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - requireLabels(t, calScreen(a, tv, 120, 70), calLabel12, calHours12(), fmt.Sprintf("layout %v", sub)) - } -} - -func TestEntriesStayOutOfHourRows(t *testing.T) { +// Tasks carry a date but no time of day, so the week and day columns list +// them with no hour axis beside them, in either time format. +func TestColumnsShowNoHourAxis(t *testing.T) { a, tv := calTestApp(t, []vault.Task{ {ID: "n#0", SourcePath: "n.md", Content: "call at 15:00 sharp", Due: "2026-11-18"}, }) - a.prefs.TimeFormat = "24h" - for _, sub := range []calSub{calWeek, calDay} { - calToSub(t, a, tv, sub) - lines := calScreen(a, tv, 120, 70) - text := calScreenText(lines) - if got := strings.Count(text, "call at 15:00"); got != 1 { - t.Fatalf("layout %v: entry shown %d times, want once in its column:\n%s", sub, got, text) - } - if len(calHourLabels(lines, calLabel24)) != 24 { - t.Fatalf("layout %v: hour list missing:\n%s", sub, text) - } - // Entries are listed from the top of the column, not by time of day: - // the entry cannot sit as far down as the 15 row. - entryRow := calRowOf(lines, "call at 15:00") - above := calHourLabels(lines[:entryRow+1], calLabel24) - if len(above) > 6 { - t.Errorf("layout %v: entry placed %d hour rows down the column:\n%s", sub, len(above), text) + for _, format := range []string{"24h", "12h"} { + a.prefs.TimeFormat = format + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + lines := calScreen(a, tv, 120, 70) + text := calScreenText(lines) + if got := strings.Count(text, "call at 15:00"); got != 1 { + t.Fatalf("%s layout %v: entry shown %d times, want once in its column:\n%s", format, sub, got, text) + } + if got := calHourLabels(lines, calLabel24); len(got) != 0 { + t.Errorf("%s layout %v: hour labels %v shown:\n%s", format, sub, got, text) + } + if got := calHourLabels(lines, calLabel12); len(got) != 0 { + t.Errorf("%s layout %v: hour labels %v shown:\n%s", format, sub, got, text) + } } } } @@ -351,10 +301,7 @@ func TestWeekShowsSevenColumnsInWeekStartOrder(t *testing.T) { a.prefs.CalendarWeekStart = c.start calToSub(t, a, tv, calWeek) lines := calScreen(a, tv, 120, 70) - cols := requireWeekHeader(t, lines, c.order, c.start) - if cols[0] < 2 { - t.Errorf("%s: first day column starts at %d, leaving no room for the hour list", c.start, cols[0]) - } + requireWeekHeader(t, lines, c.order, c.start) prev := -1 for _, d := range c.in { x := calColumnOf(lines, fmt.Sprintf("day%d", d)) @@ -378,7 +325,7 @@ func TestWeekShowsSevenColumnsInWeekStartOrder(t *testing.T) { func TestWeekColumnOverflowShowsMoreCount(t *testing.T) { a, tv := calTestApp(t, calManyTasks("2026-11-18", 50, "zq")) calToSub(t, a, tv, calWeek) - lines := calScreen(a, tv, 120, 60) + lines := calScreen(a, tv, 120, 30) requireWeekHeader(t, lines, calWeekdayNames, "week view") requireOverflowCount(t, lines, 50) } @@ -386,7 +333,7 @@ func TestWeekColumnOverflowShowsMoreCount(t *testing.T) { func TestDayColumnOverflowShowsMoreCount(t *testing.T) { a, tv := calTestApp(t, calManyTasks("2026-11-18", 50, "zq")) calToSub(t, a, tv, calDay) - requireOverflowCount(t, calScreen(a, tv, 120, 60), 50) + requireOverflowCount(t, calScreen(a, tv, 120, 30), 50) } func requireOverflowCount(t *testing.T, lines []string, total int) { @@ -434,9 +381,6 @@ func TestDayShowsOneFullWidthColumn(t *testing.T) { if cols := calWeekdayHeader(lines, calWeekdayNames); cols != nil { t.Errorf("day view shows seven day columns:\n%s", text) } - if len(calHourLabels(lines, calLabel24)) != 24 { - t.Errorf("day view hour list missing:\n%s", text) - } } func TestWeekAndDayStripBelowGrid(t *testing.T) { @@ -452,20 +396,16 @@ func TestWeekAndDayStripBelowGrid(t *testing.T) { if got := strings.Count(text, "loose strip task"); got != 1 { t.Fatalf("layout %v: undated task shown %d times, want once:\n%s", sub, got, text) } - if calRowOf(lines, "column entry") < 0 { + entry := calRowOf(lines, "column entry") + if entry < 0 { t.Fatalf("layout %v: dated entry missing:\n%s", sub, text) } - last := -1 - for i, l := range lines { - if m := calLabel24.FindStringSubmatch(l); m != nil && m[1] == "23" { - last = i - } - } - if last < 0 { - t.Fatalf("layout %v: no 23 hour row:\n%s", sub, text) + strip := calRowOf(lines, "No date") + if strip <= entry || calRowOf(lines, "loose strip task") != strip+1 { + t.Errorf("layout %v: strip at row %d is not under the columns (entry row %d):\n%s", sub, strip, entry, text) } - if row := calRowOf(lines, "loose strip task"); row <= last { - t.Errorf("layout %v: strip row %d is not below the last hour row %d", sub, row, last) + if strip != len(lines)-1-tv.calc().layout.stripRows { + t.Errorf("layout %v: strip header on row %d is not pinned to the bottom:\n%s", sub, strip, text) } } } @@ -473,21 +413,25 @@ func TestWeekAndDayStripBelowGrid(t *testing.T) { func TestWeekTooSmallBelowMinimum(t *testing.T) { a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "something", Due: "2026-11-18"}}) calToSub(t, a, tv, calWeek) - for _, size := range [][2]int{{63, 12}, {64, 11}} { + m := calMin[calWeek] + for _, size := range [][2]int{{m[0] - 1, m[1]}, {m[0], m[1] - 1}} { lines := calScreen(a, tv, size[0], size[1]) requireTooSmall(t, lines, true, fmt.Sprintf("%dx%d", size[0], size[1])) if strings.Contains(calScreenText(lines), "something") { t.Errorf("%dx%d: entry shown while too small", size[0], size[1]) } } - lines := calScreen(a, tv, 64, 12) - requireTooSmall(t, lines, false, "64x12") - requireWeekHeader(t, lines, calWeekdayNames, "64x12") - if len(lines) != 13 { - t.Errorf("render is %d lines, want toolbar plus 12", len(lines)) + lines := calScreen(a, tv, m[0], m[1]) + requireTooSmall(t, lines, false, "at the minimum") + requireWeekHeader(t, lines, calWeekdayNames, "at the minimum") + if !strings.Contains(calScreenText(lines), "some") { + t.Errorf("at the minimum the week does not show the entry:\n%s", calScreenText(lines)) + } + if len(lines) != m[1]+1 { + t.Errorf("render is %d lines, want toolbar plus %d", len(lines), m[1]) } for i, l := range lines { - if lipgloss.Width(l) > 64 { + if lipgloss.Width(l) > m[0] { t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) } } @@ -496,23 +440,24 @@ func TestWeekTooSmallBelowMinimum(t *testing.T) { func TestDayTooSmallBelowMinimum(t *testing.T) { a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "dt1", Due: "2026-11-18"}}) calToSub(t, a, tv, calDay) - for _, size := range [][2]int{{39, 10}, {40, 9}} { + m := calMin[calDay] + for _, size := range [][2]int{{m[0] - 1, m[1]}, {m[0], m[1] - 1}} { lines := calScreen(a, tv, size[0], size[1]) requireTooSmall(t, lines, true, fmt.Sprintf("%dx%d", size[0], size[1])) if strings.Contains(calScreenText(lines), "dt1") { t.Errorf("%dx%d: entry shown while too small", size[0], size[1]) } } - lines := calScreen(a, tv, 40, 10) - requireTooSmall(t, lines, false, "40x10") + lines := calScreen(a, tv, m[0], m[1]) + requireTooSmall(t, lines, false, "at the minimum") if !strings.Contains(calScreenText(lines), "dt1") { - t.Errorf("40x10: day column does not show the entry:\n%s", calScreenText(lines)) + t.Errorf("at the minimum the day column does not show the entry:\n%s", calScreenText(lines)) } - if len(lines) != 11 { - t.Errorf("render is %d lines, want toolbar plus 10", len(lines)) + if len(lines) != m[1]+1 { + t.Errorf("render is %d lines, want toolbar plus %d", len(lines), m[1]) } for i, l := range lines { - if lipgloss.Width(l) > 40 { + if lipgloss.Width(l) > m[0] { t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) } } @@ -535,3 +480,53 @@ func TestTooSmallMonthDoesNotSwitchModeButEscDoes(t *testing.T) { requireTooSmall(t, lines, false, "week at 64x12") requireWeekHeader(t, lines, calWeekdayNames, "week at 64x12") } + +// z is not a calendar key in any view; the editor keeps its folds. +func TestCalendarIgnoresZ(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 3, "zt")) + a.prefs.VimMode = true + for _, sub := range []calSub{calMonth, calWeek, calDay} { + calToSub(t, a, tv, sub) + before := calScreenText(calScreen(a, tv, 120, 40)) + calPress(a, tv, vim.R('z')) + if after := calScreenText(calScreen(a, tv, 120, 40)); after != before { + t.Errorf("z changed layout %v:\nbefore:\n%s\nafter:\n%s", sub, before, after) + } + if tv.calc().sub != sub { + t.Errorf("layout after z = %v, want %v", tv.calc().sub, sub) + } + } +} + +func TestEditorFoldKeysUnaffected(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.VimMode = true + a.openNote("note.md", true) + buf := a.activeBuffer() + if buf == nil { + t.Fatal("no active note buffer") + } + buf.ed.ReplaceText("# A\na1\na2\n# B\nb1") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + press := func(s string) { + for _, r := range s { + a.handleKey(vim.R(r)) + } + } + press("zc") + if end, ok := buf.ed.IsFoldStart(0); !ok || end != 2 { + t.Errorf("zc: fold at line 0 = %d %v, want 0..2", end, ok) + } + press("zo") + if _, ok := buf.ed.IsFoldStart(0); ok { + t.Error("zo did not open the fold") + } + press("zM") + if len(buf.ed.Folds()) != 2 { + t.Errorf("zM: folds = %v, want both sections", buf.ed.Folds()) + } + press("zR") + if len(buf.ed.Folds()) != 0 { + t.Errorf("zR: folds = %v, want none", buf.ed.Folds()) + } +} diff --git a/internal/tui/views_tasks_calendar_mouse_test.go b/internal/tui/views_tasks_calendar_mouse_test.go index 8c65b34..e696808 100644 --- a/internal/tui/views_tasks_calendar_mouse_test.go +++ b/internal/tui/views_tasks_calendar_mouse_test.go @@ -247,49 +247,21 @@ func TestClickStripTaskFocusesStrip(t *testing.T) { } } -func TestWheelScrollsHourListOneRow(t *testing.T) { - for _, c := range []struct { - sub calSub - w, h int - }{{calWeek, 64, 12}, {calDay, 40, 10}} { - for _, half := range []bool{false, true} { - a, tv := calTestApp(t, nil) - a.prefs.VimMode = true - a.prefs.TimeFormat = "24h" - calToSub(t, a, tv, c.sub) - if half { - calPress(a, tv, vim.R('z')) - } - ctx := fmt.Sprintf("layout %v half=%v", c.sub, half) - first := func() string { - got := calHourLabels(calScreen(a, tv, c.w, c.h), calLabel24) - if len(got) == 0 { - t.Fatalf("%s: no hour rows shown", ctx) - } - return got[0] - } - day := calDayISO(tv) - if got := first(); got != "00" { - t.Fatalf("%s: first row = %s before the wheel, want 00", ctx, got) - } - next := "01" - if half { - next = "00:30" - } - // The app sends the wheel as three steps; the list moves one row. - tv.scrollBy(a, 3) - if got := first(); got != next { - t.Errorf("%s: first row after one wheel step down = %s, want %s", ctx, got, next) - } - tv.scrollBy(a, -3) - if got := first(); got != "00" { - t.Errorf("%s: first row after one step down and one up = %s, want 00", ctx, got) - } - tv.scrollBy(a, -3) - if got := first(); got != "00" { - t.Errorf("%s: wheel up at the top scrolled to %s, want 00", ctx, got) - } - requireDay(t, tv, day, ctx+" wheel") +// The wheel moves the selected day a week, in the week and day views as in +// the month. +func TestWheelMovesSelectedDayByWeek(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calTestApp(t, nil) + calToSub(t, a, tv, sub) + calScreen(a, tv, 120, 30) + ctx := fmt.Sprintf("layout %v", sub) + // The app sends one wheel notch as three steps. + tv.scrollBy(a, 3) + requireDay(t, tv, "2026-11-25", ctx+" wheel down") + tv.scrollBy(a, -3) + requireDay(t, tv, "2026-11-18", ctx+" wheel up") + if tv.calc().sub != sub { + t.Errorf("%s: the wheel changed the layout to %v", ctx, tv.calc().sub) } } }