diff --git a/README.md b/README.md index b5a6785..7a73766 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 | @@ -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; @@ -590,11 +597,23 @@ reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. `g` regroups by status, priority, due date, folder or `field:`. `: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. 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, ``/`` page + 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 + 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 @@ -717,6 +736,67 @@ 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. +- 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. + +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, 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 `:vault` (or `Space v`) lists every saved vault and server, with entries to @@ -789,14 +869,19 @@ 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. 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 @@ -810,7 +895,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/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_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/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) + } +} diff --git a/internal/config/prefs.go b/internal/config/prefs.go index cffb461..83f72e9 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "strings" @@ -80,6 +81,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 +122,7 @@ func DefaultPrefs() Prefs { PreviewStyle: "auto", TerminalMouse: true, TerminalMath: "typst", + ViewModeRules: map[string]string{}, RenderTimeout: 10, RenderConcurrency: 2, UnifiedSidebar: true, @@ -236,6 +244,19 @@ 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 = 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) 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..f22d403 100644 --- a/internal/config/prefs_write.go +++ b/internal/config/prefs_write.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strings" "sync" "github.com/BurntSushi/toml" @@ -33,6 +35,87 @@ func SetValue(section, key string, value any) error { }) } +// SetViewModeRule adds or changes one [terminal.view_mode_rules] entry, +// 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 + 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_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) + } +} 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..b22b9d4 --- /dev/null +++ b/internal/config/viewmode_rules.go @@ -0,0 +1,117 @@ +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; 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 { + return ViewModeRule{}, `no "="` + } + k = normalizeViewModePart(k) + 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: v, Mode: m}, "" +} + +// ValidateViewModeRule is the check writers run before storing a rule. +func ValidateViewModeRule(key, mode string) error { + if _, reason := ParseViewModeRule(key, mode); 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..13cbda3 --- /dev/null +++ b/internal/config/viewmode_rules_test.go @@ -0,0 +1,88 @@ +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) + } +} + +// 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) + } + } +} + +// 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) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..26b9b4f 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" @@ -87,6 +88,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 @@ -107,6 +109,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 @@ -120,9 +123,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 @@ -147,6 +152,16 @@ 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 + // 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 + cmdlineOpen bool + systemDark bool exHistory []string mouse mouseState @@ -189,9 +204,24 @@ func Run(ctx context.Context, opts Options) error { if prefs.TerminalMouse { teaOpts = append(teaOpts, tea.WithMouseCellMotion()) } + 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. 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)}, teaOpts...) + if in != nil { + teaOpts = append(teaOpts, tea.WithInput(in)) + } p := tea.NewProgram(app, teaOpts...) app.program = p - _, err = p.Run() + _, err := p.Run() saveErr := app.shutdown() if err == nil { err = saveErr @@ -216,6 +246,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, @@ -314,7 +345,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 { @@ -472,6 +507,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: @@ -714,6 +750,10 @@ func (a *App) View() string { if len(a.toasts) > 0 { screen = a.renderToasts(screen) } + a.cursor = a.computeCursor() + if a.cursorOut != nil { + a.cursorOut.Set(a.cursor) + } return a.theme.paint(screen) } @@ -905,6 +945,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" } @@ -1010,6 +1052,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) @@ -1018,7 +1061,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 = 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) } @@ -1040,21 +1084,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 { @@ -1074,6 +1103,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 { @@ -1083,13 +1113,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/buffers.go b/internal/tui/buffers.go index cbad444..1bf3985 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -30,6 +30,14 @@ 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 + // 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/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/commands.go b/internal/tui/commands.go index 870cd38..dbdcbd6 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -635,7 +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.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/commands_parity.go b/internal/tui/commands_parity.go index f041189..4670ffc 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,18 @@ 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) + if created { + } return nil } @@ -721,7 +728,7 @@ func (a *App) openReference(path string) { if t == nil { return } - t.mode = modePreview + forceTabMode(t, modePreview) if !t.pinned { a.togglePin(p, p.active) } @@ -905,6 +912,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/cursor.go b/internal/tui/cursor.go new file mode 100644 index 0000000..ebfaa61 --- /dev/null +++ b/internal/tui/cursor.go @@ -0,0 +1,164 @@ +package tui + +import ( + "os/exec" + "runtime" + + 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 +// 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 + } + if !a.prefs.VimMode { + return cursorShapeBar, true + } + switch buf.ed.Mode() { + case vim.ModeInsert: + return cursorShapeBar, true + case vim.ModeReplace: + return cursorShapeUnderline, true + } + if buf.ed.ReplacePending() { + 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; +// a standalone editor, such as quick capture's, is placed by its host. +func (a *App) terminalCursorOwns(buf *noteBuffer) bool { + if buf == nil || (!buf.standalone && 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, all taken from the focused pane's own +// 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) + for _, row := range buf.focusRows { + 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 + } + 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 +} + +// 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 { + 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 + } + 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 { + 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{} + } + buf := a.activeBuffer() + shape, ok := a.bufferCursorShape(buf) + if !ok { + return cursorState{} + } + x, y, ok := a.insertionCell(buf, a.contentRect(a.activePane)) + if !ok { + return 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() + } +} + +// 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 +// 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_cmdline_test.go b/internal/tui/cursor_cmdline_test.go new file mode 100644 index 0000000..afd8c0b --- /dev/null +++ b/internal/tui/cursor_cmdline_test.go @@ -0,0 +1,88 @@ +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) + } + } +} + +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_covered_test.go b/internal/tui/cursor_covered_test.go new file mode 100644 index 0000000..0a08a1b --- /dev/null +++ b/internal/tui/cursor_covered_test.go @@ -0,0 +1,200 @@ +package tui + +import ( + "strings" + "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") + }) +} + +// 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") + } + 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) + } + assertCursorHidden(t, a, "a toast over its cell") + expireToasts(a) + 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, " ") + }) +} diff --git a/internal/tui/cursor_field_test.go b/internal/tui/cursor_field_test.go new file mode 100644 index 0000000..1e4a6f6 --- /dev/null +++ b/internal/tui/cursor_field_test.go @@ -0,0 +1,194 @@ +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) + } +} + +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) {}) + 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/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") + } +} diff --git a/internal/tui/cursor_restore_test.go b/internal/tui/cursor_restore_test.go new file mode 100644 index 0000000..8e9d698 --- /dev/null +++ b/internal/tui/cursor_restore_test.go @@ -0,0 +1,167 @@ +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" +) + +// 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)) + 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") + } + assertRestored(t, r.term.all()) +} + +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) + 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") + } + assertRestored(t, r.term.all()) +} + +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) + } + 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 an empty config: cursor = %+v", a.cursor) + } +} diff --git a/internal/tui/cursor_suspend_test.go b/internal/tui/cursor_suspend_test.go new file mode 100644 index 0000000..49f3cd5 --- /dev/null +++ b/internal/tui/cursor_suspend_test.go @@ -0,0 +1,187 @@ +package tui + +import ( + "io" + "os/exec" + "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) + } +} + +// 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) + } + 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() + 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 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"}, + "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/cursor_test.go b/internal/tui/cursor_test.go new file mode 100644 index 0000000..9d27388 --- /dev/null +++ b/internal/tui/cursor_test.go @@ -0,0 +1,473 @@ +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) + } + // 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)) + } + }) + } +} + +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) + } + } +} + +// 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()) + } + } +} + +// 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/cursorout.go b/internal/tui/cursorout.go new file mode 100644 index 0000000..7bf2604 --- /dev/null +++ b/internal/tui/cursorout.go @@ -0,0 +1,138 @@ +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())) +} + +// 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/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) + } +} diff --git a/internal/tui/editorext.go b/internal/tui/editorext.go index 40f440f..bd49162 100644 --- a/internal/tui/editorext.go +++ b/internal/tui/editorext.go @@ -56,9 +56,9 @@ func (a *App) editActiveInEditor() { return } path := buf.path - 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/editorview.go b/internal/tui/editorview.go index e143cb1..fd92798 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++ } @@ -443,6 +444,10 @@ 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...) + buf.focusW = w + } return strings.Join(out, "\n") } @@ -508,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/helpdata.go b/internal/tui/helpdata.go index dcb1498..514450f 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)", @@ -106,7 +107,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", @@ -119,7 +120,7 @@ func helpLines(a *App, filter string) []string { ":template <name> :insert <name>\tTemplates", ":newtask <text> :filter <query>\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 <name> :dbnew <name>\tDatabases", ":bind <id> <keys> :unbind <id> :keymaps\tPersistent keymaps; select an action in :keymaps to change its binding", @@ -196,7 +197,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\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/input.go b/internal/tui/input.go index b07d287..84c7bb5 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,72 @@ 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 + } + if a.surfaceOwnsKey(k) { + 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 +} + +// 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 @@ -264,7 +340,7 @@ func (a *App) chordKey(k vim.Key, editing bool) bool { if editing && !a.prefs.VimMode { return false } - 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. @@ -597,6 +673,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/mouse.go b/internal/tui/mouse.go index 7fda616..4cf9232 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 = "" @@ -505,8 +508,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) + } +} diff --git a/internal/tui/newnote_mode_test.go b/internal/tui/newnote_mode_test.go new file mode 100644 index 0000000..d9303e6 --- /dev/null +++ b/internal/tui/newnote_mode_test.go @@ -0,0 +1,124 @@ +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 +// 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) + } +} + +// 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 b013b50..4d12459 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,8 +924,15 @@ func (a *App) openNoteQuiet(path string) { if mode == "" { mode = modeEdit } - pane.tabs = append(pane.tabs, &tab{path: path, mode: mode}) + if justCreated { + mode = modeEdit + } + t := &tab{path: path, mode: mode} + pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 + if !justCreated { + a.watchFrontmatterMode(t) + } a.touchRecent(path) a.markSessionDirty() } diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index a5f61fe..4023c6c 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -95,14 +95,42 @@ 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() +} + +// 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 { + ix, iy := a.overlayInset() + return cursorState{visible: true, x: ix + col, y: iy + row, shape: cursorShapeBar} } // --- palette --- @@ -122,6 +150,7 @@ type palette struct { title string placeholder string input textInput + cur cursorState items []paletteItem filtered []paletteItem cursor int @@ -249,6 +278,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 +293,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), titleRows(p.title, inner)) 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 +363,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 +397,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), titleRows(p.title, inner)) 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)), titleRows(p.title, inner)) } lines := []string{th.OverlayTitle.Render(p.title), field} if p.help != "" { @@ -505,9 +543,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 +587,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 +814,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 +916,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/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/panes.go b/internal/tui/panes.go index fbf071a..211ae2f 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. @@ -109,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 @@ -222,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 { @@ -236,9 +241,15 @@ func (a *App) openNote(path string, focus bool) { if remembered, ok := a.noteModes[path]; ok && !a.prefs.KeepViewModeAcrossNotes() { mode = remembered } + if justCreated { + mode = modeEdit + } t := &tab{path: path, mode: mode} pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 + if !justCreated { + a.watchFrontmatterMode(t) + } } if cur := a.activeBuffer(); cur != nil && cur.path != path { a.pushJump(cur.path, cur.ed.Cursor()) @@ -322,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 { + 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. + if !reused { + a.watchFrontmatterMode(t) + } } } diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index e30a188..16291a9 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), titleRows(p.title, inner)) 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 { 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/settings_controls.go b/internal/tui/settings_controls.go index 45f6646..e0f35c0 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,34 +110,219 @@ 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) } v.rows = max(1, h-2) - v.list.ensureVisible(v.rows) - v.scroll = v.list.scroll + th := a.theme 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 { - line = a.theme.SelectedFocus.Render(line) + disp, cur := v.display(w) + // 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.scroll { + v.scroll = start + } else if cur >= v.scroll+v.rows { + v.scroll = cur - v.rows + 1 + } + } + v.scroll = max(0, min(v.scroll, len(disp)-v.rows)) + for i := v.scroll; i < len(disp) && len(lines) < h; i++ { + d := disp[i] + switch { + 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 + scope string // set on the heading that opens the User or Vault group +} + +// 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 + scope, sub := "", "" + for i, s := range v.settings { + name := "User" + if s.vault { + name = "Vault" + } + if name != scope { + scope, sub = name, "" + out = append(out, settingDisplay{idx: -1, scope: name}) + } + if s.section != sub { + sub = s.section + 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{text: truncateCells(" "+text, w), idx: i}) + } + return out, cur +} func (v *settingsView) handleKey(a *App, k vim.Key) bool { if k.IsRune('v') { _ = 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 +398,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_live.go b/internal/tui/settings_live.go index 31a1857..bdb243e 100644 --- a/internal/tui/settings_live.go +++ b/internal/tui/settings_live.go @@ -113,7 +113,7 @@ func (a *App) editConfig() error { if err != nil { return err } - 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 { diff --git a/internal/tui/settings_tasks_test.go b/internal/tui/settings_tasks_test.go index 12173a5..532f76b 100644 --- a/internal/tui/settings_tasks_test.go +++ b/internal/tui/settings_tasks_test.go @@ -1,7 +1,10 @@ package tui import ( + "fmt" "reflect" + "slices" + "strings" "testing" ) @@ -27,3 +30,112 @@ func TestSetTasksFolderList(t *testing.T) { t.Fatalf("emptying both lists should drop tasks, got %#v", raw) } } + +func TestSettingsDisplayGroupsUserAndVaultByPrefix(t *testing.T) { + 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, 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"} + 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) + } +} + +// 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 d.scope != "" { + scope = d.scope + 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 · 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 · .zennotes/vault.json", "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 · .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 · config.toml" { + t.Fatalf("after growing, top line = %q", got) + } +} diff --git a/internal/tui/settings_viewmode_test.go b/internal/tui/settings_viewmode_test.go new file mode 100644 index 0000000..a424420 --- /dev/null +++ b/internal/tui/settings_viewmode_test.go @@ -0,0 +1,572 @@ +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", "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"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"remove"}) { + t.Fatalf("%q palette items = %v", line, got) + } + 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/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/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/tui/tabsequence_test.go b/internal/tui/tabsequence_test.go new file mode 100644 index 0000000..56863d1 --- /dev/null +++ b/internal/tui/tabsequence_test.go @@ -0,0 +1,211 @@ +package tui + +import ( + "strings" + "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) +} + +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") + } +} + +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()) + } +} + +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) + } +} 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{} diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go new file mode 100644 index 0000000..8b8f8ee --- /dev/null +++ b/internal/tui/viewmode_frontmatter.go @@ -0,0 +1,130 @@ +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 +} + +// 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() +} + +// 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 the default +// view mode, a remembered mode or frontmatter, as the desktop app does. +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_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) + } + }) + } +} 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") + } +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go new file mode 100644 index 0000000..8c0e959 --- /dev/null +++ b/internal/tui/viewmode_open_test.go @@ -0,0 +1,778 @@ +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" + "github.com/ZenNotes/tui/internal/vim" +) + +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") + } +} + +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)) + } + }) + } +} + +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) + } +} + +// 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() + 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") + } +} + +// 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) + } +} + +// 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) + } +} 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_hints.go b/internal/tui/views_hints.go index d483e54..baefc86 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:Enter", "day tasks"}, {"key:o", "daily note"}}, 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"}}, 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...) } @@ -34,7 +41,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. @@ -67,10 +78,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 { 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..74eb00f 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 @@ -766,6 +732,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.calc().focus == calFocusGrid && (k.IsRune('[') || k.IsRune(']')) + } + return false +} + func (v *tasksView) handleKey(a *App, k vim.Key) bool { if k.IsRune('S') { a.savedTaskFilters() @@ -847,11 +823,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 +1052,33 @@ 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 { + // 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.dayFocus = false + v.calc().focus = calFocusGrid + case k.Is("tab"): + v.calc().cycleFocus(v.day) case k.IsRune('j') || k.Is("down"): - if v.dayCursor < len(v.dayTasks)-1 { - v.dayCursor++ + 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.dayCursor > 0 { - v.dayCursor-- + 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 { @@ -1104,6 +1092,15 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { if t != nil { 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) + } default: return v.taskEditKey(a, k, t) } @@ -1112,7 +1109,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 } + columns := v.calc().sub != calMonth switch { + case k.Is("esc"): + v.calc().sub = (v.calc().sub + 1) % 3 + 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"): @@ -1121,6 +1125,10 @@ 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.pageDay(a, -1) + case a.bound(k, "nav.halfPageDown"): + 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)) @@ -1128,14 +1136,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("tab"): + return v.calc().cycleFocus(v.day) case k.Is("enter"): - if len(v.dayTasks) > 0 { - v.dayFocus = true - v.dayCursor = 0 - } else { + if !v.calc().cycleFocus(v.day) { a.openPeriodicOn(periodic.Daily, v.day) } case k.IsRune('o'): @@ -1148,13 +1155,44 @@ 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) { + 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() { 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 + 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 new file mode 100644 index 0000000..8b152bb --- /dev/null +++ b/internal/tui/views_tasks_calendar.go @@ -0,0 +1,800 @@ +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: {56, 8}, + calDay: {30, 7}, +} + +// 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 + // 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 + itemStart int + stripRows int + // stripStart is the index of the first strip task shown. + stripStart 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 + // colScroll is how many entries the selected day's column has scrolled + // off its top. + colScroll 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 + } + 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 { + 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, 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 +} + +// 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 + } else if next == calFocusStrip { + c.strip = 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 { + 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] { + return c.compactMonthLayout(a, month, w, h) + } + 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 +} + +// 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) +} + +// 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: 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] { + 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} + colH := h - stripH - headRows + colW := w / len(days) + for i, d := range days { + 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 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) + 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 := "" + 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)) + } + for i := 0; i < lay.boxes[0].rect.h; i++ { + var sb strings.Builder + for _, b := range lay.boxes { + 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)) + } + 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) +} + +// 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. +func (c *taskCalendar) columnScroll(b calDayBox) int { + items := c.focus == calFocusItems + 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 { + 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 +} + +// 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. +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) + more := func(n int) string { + return th.Dim.Render(padRight(calMoreLabel(n, w), 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: + 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 && idx == c.cursor { + return th.SelectedFocus.Render(text) + } + return text + case li == shown && shown < len(rest): + return more(len(rest) - shown) + } + 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) { + 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 { + 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[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]) + lines[len(lines)/2] = centerLine(th.Muted.Render(msg), w) + } + return fitBlock(strings.Join(lines, "\n"), w, h) + } + + 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 + 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 = calMoreLabel(len(entries)-shown, w) + 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("│") + 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) + 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-3-sepW) + if focused && c.focus == calFocusItems && idx == c.cursor { + 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-1-sepW)) + } + 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 + } + c.layout.stripStart = start + 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 +} + +// 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) { + 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 + } + } + 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 + c.pressTask(a, entries[idx].task, right, double) + } + } +} + +// selectionCell is the body cell menus open next to. +func (c *taskCalendar) selectionCell(day time.Time) (int, int) { + lay := c.layout + 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) { + return b.rect.x + 1, b.rect.y + } + } + return 0, 0 +} 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..e648418 --- /dev/null +++ b/internal/tui/views_tasks_calendar_colscroll_test.go @@ -0,0 +1,248 @@ +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) +} + +// 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 + calToSub(t, a, tv, sub) + 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, calColumnW, calColumnH)) +} + +func TestColumnScrollsWithJK(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") + + 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, 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) + } + } +} + +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) + } + } +} 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..cd18c99 --- /dev/null +++ b/internal/tui/views_tasks_calendar_duemenu_test.go @@ -0,0 +1,217 @@ +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 @. +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('@')) + m, ok := a.overlay.(*menu) + if !ok { + t.Fatalf("overlay after @ 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) { + _, tv, _, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + 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 @") +} + +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) + } + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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('@')) + m, ok := a.overlay.(*menu) + if !ok { + 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") { + t.Errorf("strip task did not get the due date:\n%s", got) + } +} + +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 + 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) + } + 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") +} + +// 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) + } +} 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..99574c7 --- /dev/null +++ b/internal/tui/views_tasks_calendar_keys_test.go @@ -0,0 +1,396 @@ +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, "<C-D> after rebinding") + requireDay(t, tv, "2026-11-18", "<C-D> 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 !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) + } + 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) + } +} + +// 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, "j/k scroll") || 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) + } +} 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..8accc7f --- /dev/null +++ b/internal/tui/views_tasks_calendar_modes_test.go @@ -0,0 +1,532 @@ +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 +} + +// 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") + } + } +} + +// 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"}, + }) + 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) + } + } + } +} + +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) + requireWeekHeader(t, lines, c.order, c.start) + 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, 30) + 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, 30), 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) + } +} + +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) + } + entry := calRowOf(lines, "column entry") + if entry < 0 { + t.Fatalf("layout %v: dated entry missing:\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 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) + } + } +} + +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) + 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, 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) > m[0] { + 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) + 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, m[0], m[1]) + requireTooSmall(t, lines, false, "at the minimum") + if !strings.Contains(calScreenText(lines), "dt1") { + t.Errorf("at the minimum the day column 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) > m[0] { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } +} + +func TestTooSmallMonthDoesNotSwitchModeButEscDoes(t *testing.T) { + a, tv := calTestApp(t, nil) + // A body too small for even the compact month. + for i := 0; i < 2; i++ { + 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) + } + } + 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") +} + +// 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_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) + } + } +} 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..e696808 --- /dev/null +++ b/internal/tui/views_tasks_calendar_mouse_test.go @@ -0,0 +1,321 @@ +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) + } + } +} + +// 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) + } + } +} + +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) + } + } +} 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..ec32b18 --- /dev/null +++ b/internal/tui/views_tasks_calendar_nav_test.go @@ -0,0 +1,319 @@ +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 !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) + } + 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) + } + } +} 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..69037f5 --- /dev/null +++ b/internal/tui/views_tasks_calendar_strip_test.go @@ -0,0 +1,318 @@ +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) + } + } +} + +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) + } +} diff --git a/internal/tui/views_tasks_calendar_test.go b/internal/tui/views_tasks_calendar_test.go new file mode 100644 index 0000000..33e31ff --- /dev/null +++ b/internal/tui/views_tasks_calendar_test.go @@ -0,0 +1,532 @@ +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) + } +} + +// 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"}}) + // 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) + } + 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) + } + } + } +} + +// 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) + 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) + } +} 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) + } +} 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<Esc>", 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) + } + }) + } +}