From e234a94c7533bb902d2c39fc7ce1738932faaea3 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:44:26 -0400 Subject: [PATCH 01/12] Feat(tui): a terminal output wrapper that places and shapes the cursor zn draws its own cursor as a reverse-video cell because Bubble Tea v1 hides the terminal cursor and parks it at the bottom after every frame. A bar or underline cursor in insert and replace mode needs the real terminal cursor, so cursorOut sits between the program and the terminal: it appends the wanted position and shape (steady bar, CSI 6 SP q, or steady underline, CSI 4 SP q) to every frame, writes it on its own when only the cursor moved, since Bubble Tea skips unchanged frames, and resets the terminal to its default shape on request. A write that is a lone control sequence gets nothing appended, and while released (for suspend or an external program) the wrapper writes no cursor bytes at all. It keeps the method set of Bubble Tea's term.File so window-size and raw mode detection still work, and uses only charmbracelet/x/ansi, which is already a dependency. Nothing uses it yet; later steps wire it in. Tests, written red first: steady shapes, the reset sequence, frame and lone-sequence writes, set-on-change, release, concurrent writes under -race, and the forwarded file descriptor. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursorout.go | 134 +++++++++++++++++++++ internal/tui/cursorout_test.go | 211 +++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 internal/tui/cursorout.go create mode 100644 internal/tui/cursorout_test.go diff --git a/internal/tui/cursorout.go b/internal/tui/cursorout.go new file mode 100644 index 0000000..e444007 --- /dev/null +++ b/internal/tui/cursorout.go @@ -0,0 +1,134 @@ +package tui + +import ( + "io" + "sync" + + "github.com/charmbracelet/x/ansi" +) + +// cursorShape is the terminal cursor shape zn asks for. +type cursorShape int + +const ( + // cursorShapeBar is a steady vertical bar (DECSCUSR 6). + cursorShapeBar cursorShape = iota + // cursorShapeUnderline is a steady underline (DECSCUSR 4). + cursorShapeUnderline +) + +// cursorState is the cursor the terminal should show for one frame: hidden, +// or visible at a screen cell (zero-based) with a shape. +type cursorState struct { + visible bool + x, y int + shape cursorShape +} + +// cursorFile is the terminal handle the program reads from and writes to. It +// has the same method set as Bubble Tea's term.File, so window-size and +// raw-mode detection keep working through the wrapper. +type cursorFile interface { + io.ReadWriteCloser + Fd() uintptr +} + +// cursorOut wraps the terminal output. Bubble Tea parks the cursor at the +// bottom after each frame and skips frames that did not change, so the +// wrapper appends the wanted cursor to every frame and writes it on its own +// when only the cursor changed. One mutex serialises every write. +type cursorOut struct { + mu sync.Mutex + dst cursorFile + want cursorState + released bool +} + +func newCursorOut(dst cursorFile) *cursorOut { + return &cursorOut{dst: dst} +} + +// sequence returns the bytes that put the terminal's cursor into state c. +func (c cursorState) sequence() string { + if !c.visible { + return ansi.HideCursor + } + style := 6 + if c.shape == cursorShapeUnderline { + style = 4 + } + return ansi.CursorPosition(c.x+1, c.y+1) + ansi.SetCursorStyle(style) + ansi.ShowCursor +} + +// loneControlSequence reports whether p is exactly one CSI sequence, such as +// a lone cursor hide or show, rather than a rendered frame. +func loneControlSequence(p []byte) bool { + if len(p) < 3 || p[0] != 0x1b || p[1] != '[' { + return false + } + for i := 2; i < len(p); i++ { + if p[i] >= 0x40 && p[i] <= 0x7e { + return i == len(p)-1 + } + } + return false +} + +// Write forwards p and, when p is a frame, appends the wanted cursor in the +// same write so nothing can land between them. +func (w *cursorOut) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.released || loneControlSequence(p) { + return w.dst.Write(p) + } + out := make([]byte, 0, len(p)+32) + out = append(out, p...) + out = append(out, w.want.sequence()...) + n, err := w.dst.Write(out) + if n > len(p) { + n = len(p) + } + return n, err +} + +// Set records the wanted cursor and writes it at once if it changed. +func (w *cursorOut) Set(c cursorState) { + w.mu.Lock() + defer w.mu.Unlock() + if c == w.want { + return + } + w.want = c + if w.released { + return + } + _, _ = w.dst.Write([]byte(c.sequence())) +} + +// Reset puts the terminal back to its default cursor shape and shows it. +func (w *cursorOut) Reset() { + w.mu.Lock() + defer w.mu.Unlock() + _, _ = w.dst.Write([]byte(ansi.SetCursorStyle(0) + ansi.ShowCursor)) +} + +// Release stops the wrapper from adding cursor sequences, for suspend and +// external programs that own the terminal. +func (w *cursorOut) Release() { + w.mu.Lock() + defer w.mu.Unlock() + w.released = true +} + +// Hold resumes cursor control and writes the wanted cursor again. +func (w *cursorOut) Hold() { + w.mu.Lock() + defer w.mu.Unlock() + w.released = false + _, _ = w.dst.Write([]byte(w.want.sequence())) +} + +func (w *cursorOut) Read(p []byte) (int, error) { return w.dst.Read(p) } +func (w *cursorOut) Close() error { return w.dst.Close() } +func (w *cursorOut) Fd() uintptr { return w.dst.Fd() } diff --git a/internal/tui/cursorout_test.go b/internal/tui/cursorout_test.go new file mode 100644 index 0000000..1f120f4 --- /dev/null +++ b/internal/tui/cursorout_test.go @@ -0,0 +1,211 @@ +package tui + +import ( + "strings" + "sync" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// fakeTerm is an in-memory term.File that records every Write call. +type fakeTerm struct { + mu sync.Mutex + writes []string + fd uintptr +} + +func (f *fakeTerm) Write(p []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.writes = append(f.writes, string(p)) + return len(p), nil +} +func (f *fakeTerm) Read(p []byte) (int, error) { return 0, nil } +func (f *fakeTerm) Close() error { return nil } +func (f *fakeTerm) Fd() uintptr { return f.fd } + +func (f *fakeTerm) all() string { + f.mu.Lock() + defer f.mu.Unlock() + return strings.Join(f.writes, "") +} + +func (f *fakeTerm) calls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.writes...) +} + +func (f *fakeTerm) clear() { + f.mu.Lock() + defer f.mu.Unlock() + f.writes = nil +} + +const ( + steadyBar = "\x1b[6 q" + blinkingBar = "\x1b[5 q" + steadyUnderline = "\x1b[4 q" + blinkUnderline = "\x1b[3 q" + defaultShape = "\x1b[0 q" +) + +func TestCursorOutBarIsSteadyBar(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 3, y: 2, shape: cursorShapeBar}) + f.clear() + _, _ = w.Write([]byte("frame text")) + got := f.all() + if !strings.Contains(got, steadyBar) { + t.Fatalf("bar frame lacks CSI 6 SP q: %q", got) + } + if strings.Contains(got, blinkingBar) { + t.Fatalf("bar frame contains blinking CSI 5 SP q: %q", got) + } +} + +func TestCursorOutUnderlineIsSteadyUnderline(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 3, y: 2, shape: cursorShapeUnderline}) + f.clear() + _, _ = w.Write([]byte("frame text")) + got := f.all() + if !strings.Contains(got, steadyUnderline) { + t.Fatalf("underline frame lacks CSI 4 SP q: %q", got) + } + if strings.Contains(got, blinkUnderline) { + t.Fatalf("underline frame contains blinking CSI 3 SP q: %q", got) + } +} + +func TestCursorOutFrameWriteIsFollowedByPositionShapeShow(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 7, y: 4, shape: cursorShapeBar}) + f.clear() + _, _ = w.Write([]byte("frame text")) + want := "frame text" + ansi.CursorPosition(8, 5) + steadyBar + ansi.ShowCursor + if got := f.all(); got != want { + t.Fatalf("frame output = %q, want %q", got, want) + } +} + +func TestCursorOutHiddenAppendsHideCursor(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{}) + f.clear() + _, _ = w.Write([]byte("frame text")) + want := "frame text" + ansi.HideCursor + if got := f.all(); got != want { + t.Fatalf("frame output = %q, want %q", got, want) + } +} + +func TestCursorOutLoneControlSequenceGetsNothingAppended(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + f.clear() + _, _ = w.Write([]byte(ansi.HideCursor)) + if got := f.all(); got != ansi.HideCursor { + t.Fatalf("lone control sequence output = %q, want %q", got, ansi.HideCursor) + } +} + +func TestCursorOutSetChangedWritesAtOnce(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + f.clear() + w.Set(cursorState{visible: true, x: 5, y: 1, shape: cursorShapeBar}) + got := f.all() + if !strings.Contains(got, ansi.CursorPosition(6, 2)) || !strings.Contains(got, steadyBar) { + t.Fatalf("changed Set wrote %q, want position and shape", got) + } +} + +func TestCursorOutSetUnchangedWritesNothing(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + c := cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar} + w.Set(c) + f.clear() + w.Set(c) + if got := f.all(); got != "" { + t.Fatalf("unchanged Set wrote %q, want nothing", got) + } +} + +func TestCursorOutReleasedAppendsNothing(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + w.Release() + f.clear() + _, _ = w.Write([]byte("frame text")) + if got := f.all(); got != "frame text" { + t.Fatalf("released frame output = %q, want %q", got, "frame text") + } + w.Set(cursorState{visible: true, x: 9, y: 9, shape: cursorShapeBar}) + if got := f.all(); got != "frame text" { + t.Fatalf("released Set wrote %q", got) + } +} + +func TestCursorOutResetWritesDefaultShapeAndShowCursor(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + w.Set(cursorState{visible: true, x: 1, y: 1, shape: cursorShapeBar}) + f.clear() + w.Reset() + want := defaultShape + ansi.ShowCursor + if got := f.all(); got != want { + t.Fatalf("Reset wrote %q, want %q", got, want) + } +} + +func TestCursorOutConcurrentWritesInterleaveOnlyAtSequenceBoundaries(t *testing.T) { + f := &fakeTerm{} + w := newCursorOut(f) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + _, _ = w.Write([]byte("frame text")) + } + }() + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + w.Set(cursorState{visible: true, x: i % 20, y: i % 5, shape: cursorShapeBar}) + } + }() + wg.Wait() + // Each recorded write is a whole unit: a frame followed by its cursor + // sequence, or a complete cursor sequence. Never two frames in one + // write, and a frame always starts a write. + for i, c := range f.calls() { + if strings.Count(c, "frame text") > 1 { + t.Fatalf("write %d holds two frames: %q", i, c) + } + if idx := strings.Index(c, "frame text"); idx > 0 { + t.Fatalf("write %d has a cursor sequence before the frame: %q", i, c) + } + if strings.HasSuffix(c, "\x1b") || strings.HasSuffix(c, "\x1b[") { + t.Fatalf("write %d ends inside an escape sequence: %q", i, c) + } + } +} + +func TestCursorOutFdIsForwarded(t *testing.T) { + f := &fakeTerm{fd: 42} + w := newCursorOut(f) + if got := w.Fd(); got != 42 { + t.Fatalf("Fd() = %d, want 42", got) + } +} From 3fe9eae1c5821d668fff3ee24d10f5aed78c1dcc Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:37:26 -0500 Subject: [PATCH 02/12] Feat(vim): the editor reports when r waits for its character After `r`, with or without a count or register, the editor stays in normal mode until the replacement character arrives, so a host cannot tell from the mode that a character is pending. ReplacePending answers that: it is true only in normal mode while the pending keys are an optional register and count followed by `r`, and false once the character or Esc arrives, in visual mode and in insert mode. The TUI uses it to show an underline cursor while it waits. Tests: `r`, `3r` and `"ar` are pending; `rx` replaces and clears it; `r` clears it with the text unchanged; `vr`, insert mode, a typed `r` in insert mode and another pending operator are not. Co-Authored-By: Claude Opus 5.5 --- internal/vim/editor.go | 10 ++++++++++ internal/vim/editor_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/vim/editor.go b/internal/vim/editor.go index ca9a6ee..c414d08 100644 --- a/internal/vim/editor.go +++ b/internal/vim/editor.go @@ -1703,3 +1703,13 @@ func (e *Editor) PendingCount() (int, bool) { } return n, true } + +// ReplacePending reports whether a normal-mode `r` (with an optional +// register and count before it) is waiting for the character to put in. +func (e *Editor) ReplacePending() bool { + if e.mode != ModeNormal || len(e.pending) == 0 { + return false + } + _, _, _, rest, status := splitPrefix(e.pending) + return status == parseComplete && len(rest) == 1 && rest[0].IsRune('r') +} diff --git a/internal/vim/editor_test.go b/internal/vim/editor_test.go index 4cc5b4e..ce77f66 100644 --- a/internal/vim/editor_test.go +++ b/internal/vim/editor_test.go @@ -493,3 +493,39 @@ func TestReplaceAtCursorJoinsTheInsert(t *testing.T) { t.Fatalf("after undo: %q", got) } } + +func TestReplacePending(t *testing.T) { + cases := []struct { + name string + text string + keys string + pending bool + wantText string + }{ + {"r waits", "abc", "r", true, "abc"}, + {"count then r waits", "abcd", "3r", true, "abcd"}, + {"register then r waits", "abc", "\"ar", true, "abc"}, + {"r then character", "abc", "rx", false, "xbc"}, + {"r then esc", "abc", "r", false, "abc"}, + {"visual r", "abc", "vr", false, "abc"}, + {"insert mode", "abc", "i", false, "abc"}, + {"typed r in insert mode", "abc", "ir", false, "rabc"}, + {"other pending key", "abc", "d", false, "abc"}, + {"nothing pending", "abc", "", false, "abc"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + opts := DefaultOptions() + opts.TextReplacementsEnabled = false + e := New(c.text, opts, Hooks{}) + e.SetViewport(10) + e.Feed(c.keys) + if got := e.ReplacePending(); got != c.pending { + t.Fatalf("ReplacePending: got %v want %v", got, c.pending) + } + if got := e.Text(); got != c.wantText { + t.Fatalf("text: got %q want %q", got, c.wantText) + } + }) + } +} From 7f3e26948e1a5f87ba458088d0bd412070699cd1 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:53 -0500 Subject: [PATCH 03/12] Feat(tui): a bar or underline cursor for the note being edited While a note is edited, the reverse-video cell that stands in for the cursor hides the character under it and gives no sign of the mode. The TUI now works out, for every frame, the cursor the terminal should show: a steady bar in insert mode or with vim mode off, a steady underline in replace mode, and nothing in normal, visual and command-line mode or while the note is loading, where the drawn block stays exactly as it was. The cell is found the way the tag menu finds its anchor: the pane origin, the gutter, then the width of the text before the cursor on its wrapped row, so tabs and wide runes land on the right cell. While the terminal cursor owns the insertion point the editor stops drawing the block there and past the end of the line. Nothing reaches the terminal yet; a later commit connects the frame's cursor to the output wrapper. Tests: the bar on a plain, wrapped, CJK, tab and end-of-line position with no reverse cell drawn; the underline in replace mode; the bar with vim mode off; the block in normal and visual mode and after Esc; and the block while the note loads, with vim mode on and off. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 4 + internal/tui/cursor.go | 74 +++++++++++ internal/tui/cursor_test.go | 255 ++++++++++++++++++++++++++++++++++++ internal/tui/editorview.go | 7 +- 4 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 internal/tui/cursor.go create mode 100644 internal/tui/cursor_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..d9ae958 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -147,6 +147,9 @@ type App struct { // missing-TeX notice, so only a note's first LaTeX block carries it. latexNoticeShown bool + // cursor is where the terminal cursor belongs in the last frame drawn. + cursor cursorState + systemDark bool exHistory []string mouse mouseState @@ -714,6 +717,7 @@ func (a *App) View() string { if len(a.toasts) > 0 { screen = a.renderToasts(screen) } + a.cursor = a.computeCursor() return a.theme.paint(screen) } diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go new file mode 100644 index 0000000..662ee31 --- /dev/null +++ b/internal/tui/cursor.go @@ -0,0 +1,74 @@ +package tui + +import "github.com/ZenNotes/tui/internal/vim" + +// bufferCursorShape is the terminal cursor shape the buffer wants while its +// text is being edited. ok is false when the buffer keeps the drawn block +// instead: in normal and visual mode, in the command line, and while the +// note is still loading. +func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { + if buf == nil || buf.loading { + return 0, false + } + if !a.prefs.VimMode { + return cursorShapeBar, true + } + switch buf.ed.Mode() { + case vim.ModeInsert: + return cursorShapeBar, true + case vim.ModeReplace: + return cursorShapeUnderline, true + } + return 0, false +} + +// terminalCursorOwns reports whether the terminal's own cursor stands in for +// the drawn block in buf. Only the active tab's buffer is placed that way; +// other editors, such as quick capture's, keep drawing their block. +func (a *App) terminalCursorOwns(buf *noteBuffer) bool { + if buf == nil || buf != a.activeBuffer() { + return false + } + _, ok := a.bufferCursorShape(buf) + return ok +} + +// insertionCell is the screen cell where the next typed character in buf +// lands: the pane's content origin, the gutter, and the width of the text +// before the cursor on its wrapped row. +func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { + cur := buf.ed.Cursor() + line := buf.ed.LineRunes(cur.Line) + rc := a.contentRect(a.activePane) + for _, row := range buf.rows { + if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd { + continue + } + if cur.Col == row.segEnd && row.segEnd < len(line) { + continue // the cursor starts the next wrapped row + } + return rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col])), rc.y + row.y, true + } + return 0, 0, false +} + +// computeCursor picks the cursor for the frame just drawn. +func (a *App) computeCursor() cursorState { + if a.overlay != nil || a.focus != focusPane { + return cursorState{} + } + t := a.activeTab() + if t == nil || t.view != nil || t.mode == modePreview { + return cursorState{} + } + buf := a.activeBuffer() + shape, ok := a.bufferCursorShape(buf) + if !ok { + return cursorState{} + } + x, y, ok := a.insertionCell(buf) + if !ok { + return cursorState{} + } + return cursorState{visible: true, x: x, y: y, shape: shape} +} diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go new file mode 100644 index 0000000..fdeed53 --- /dev/null +++ b/internal/tui/cursor_test.go @@ -0,0 +1,255 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/cellbuf" + "github.com/muesli/termenv" + + "github.com/ZenNotes/tui/internal/vim" +) + +// cursorApp opens one note holding text in a single pane, with true colour so +// the drawn block cursor can be told apart from other cells. +func cursorApp(t *testing.T, text string, width int) (*App, *noteBuffer) { + t.Helper() + profile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) + a := newSessionParityApp(t, newSessionParityVault(t, "Alpha.md")) + a.openNote("Alpha.md", true) + a.sidebarOpen = false + a.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + buf := a.activeBuffer() + if buf == nil { + t.Fatal("no active buffer") + } + buf.ed.SetText(text) + buf.ed.Feed("i") + return a, buf +} + +type screenCell struct{ x, y int } + +// reverseCells lists the cells the view draws in reverse video. +func reverseCells(view string) []screenCell { + var out []screenCell + for y, row := range strings.Split(view, "\n") { + var style cellbuf.Style + parser := ansi.NewParser() + state, x := byte(0), 0 + for len(row) > 0 { + seq, width, n, next := ansi.DecodeSequence(row, state, parser) + if ansi.HasCsiPrefix(seq) && parser.Command() == 'm' { + cellbuf.ReadStyle(parser.Params(), &style) + } + if width > 0 && style.Attrs&cellbuf.ReverseAttr != 0 { + out = append(out, screenCell{x, y}) + } + x += width + row, state = row[n:], next + } + } + return out +} + +// cellText is the text the view draws in the cell at (x, y). +func cellText(view string, x, y int) string { + rows := strings.Split(ansi.Strip(view), "\n") + return ansi.Cut(rows[y], x, x+1) +} + +// cellText2 is the text of the n cells starting at (x, y), for wide runes. +func cellText2(view string, x, y, n int) string { + rows := strings.Split(ansi.Strip(view), "\n") + return ansi.Cut(rows[y], x, x+n) +} + +func TestInsertCursorBarInInsertMode(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "o" { + t.Fatalf("bar sits on %q, want the cell of %q", got, "o") + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn in insert mode: %v", cells) + } +} + +func TestInsertCursorWrappedLine(t *testing.T) { + text := strings.Repeat("abcdefghij ", 12) + "\n" + a, buf := cursorApp(t, text, 40) + a.View() + if len(buf.rows) < 3 { + t.Fatalf("line did not wrap: %d rows", len(buf.rows)) + } + second := buf.rows[1] + col := second.segStart + 3 + buf.ed.SetCursor(vim.Pos{Line: 0, Col: col}) + view := a.View() + rc := a.contentRect(a.activePane) + if a.cursor.y != rc.y+second.y { + t.Fatalf("bar row = %d, want the second wrapped row %d", a.cursor.y, rc.y+second.y) + } + if want := string([]rune(text)[col]); cellText(view, a.cursor.x, a.cursor.y) != want { + t.Fatalf("bar sits on %q, want %q", cellText(view, a.cursor.x, a.cursor.y), want) + } +} + +func TestInsertCursorWideRune(t *testing.T) { + a, buf := cursorApp(t, "ab日本cd\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 3}) + view := a.View() + if got := cellText2(view, a.cursor.x, a.cursor.y, 2); got != "本" { + t.Fatalf("bar sits on %q, want the first cell of 本", got) + } + if prev := cellText2(view, a.cursor.x-2, a.cursor.y, 2); prev != "日" { + t.Fatalf("two cells left of the bar is %q, want 日", prev) + } +} + +func TestInsertCursorTab(t *testing.T) { + a, buf := cursorApp(t, "a\tb\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 1}) + a.View() + start := a.cursor.x + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + view := a.View() + if a.cursor.x-start != 4 { + t.Fatalf("tab is %d cells wide, want 4", a.cursor.x-start) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "b" { + t.Fatalf("bar after the tab sits on %q, want b", got) + } + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + a.View() + if start-a.cursor.x != 1 { + t.Fatalf("a is %d cells wide, want 1", start-a.cursor.x) + } +} + +func TestInsertCursorPastEndOfLine(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + a.View() + last := a.cursor + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 5}) + view := a.View() + if !a.cursor.visible || a.cursor.x != last.x+1 || a.cursor.y != last.y { + t.Fatalf("cursor = %+v, want the cell after the last character %+v", a.cursor, last) + } + if got := cellText(view, last.x, last.y); got != "o" { + t.Fatalf("last character cell shows %q", got) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn past the end of the line: %v", cells) + } +} + +func TestInsertCursorReplaceModeIsUnderline(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("R") + if buf.ed.Mode() != vim.ModeReplace { + t.Fatalf("mode = %v, want replace", buf.ed.Mode()) + } + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeUnderline { + t.Fatalf("cursor = %+v, want a visible underline", a.cursor) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn in replace mode: %v", cells) + } +} + +func TestInsertCursorBarWithVimModeOff(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = false + buf.ed.SetOptions(a.editorOptions()) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn with vim mode off: %v", cells) + } +} + +// Normal and visual mode keep the drawn block and show no terminal cursor. +func TestInsertCursorNormalAndVisualKeepTheBlock(t *testing.T) { + for _, keys := range []string{"", "v", "V", ""} { + t.Run("keys "+keys, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(keys) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + cells := reverseCells(view) + found := false + for _, c := range cells { + if cellText(view, c.x, c.y) == "o" { + found = true + } + } + if !found { + t.Fatalf("no reverse-video cell on the cursor character; reverse cells: %v", cells) + } + if keys == "" && len(cells) != 1 { + t.Fatalf("normal mode draws %d reverse cells, want 1", len(cells)) + } + }) + } +} + +func TestInsertCursorEscRestoresTheBlock(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + a.View() + if !a.cursor.visible { + t.Fatal("no bar in insert mode") + } + buf.ed.Feed("") + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v after Esc, want hidden", a.cursor) + } + if len(reverseCells(view)) != 1 { + t.Fatalf("block cursor not drawn after Esc: %v", reverseCells(view)) + } +} + +func TestInsertCursorWaitsForTheNoteToLoad(t *testing.T) { + for _, vimMode := range []bool{true, false} { + a, buf := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = vimMode + buf.ed.SetOptions(a.editorOptions()) + buf.loading = true + view := a.View() + if a.cursor.visible { + t.Fatalf("vim=%v: cursor = %+v while loading, want hidden", vimMode, a.cursor) + } + if len(reverseCells(view)) != 1 { + t.Fatalf("vim=%v: block not drawn while loading: %v", vimMode, reverseCells(view)) + } + buf.loading = false + view = a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("vim=%v: cursor = %+v once loaded, want a bar", vimMode, a.cursor) + } + if len(reverseCells(view)) != 0 { + t.Fatalf("vim=%v: block still drawn once loaded", vimMode) + } + } +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index e143cb1..897dafe 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -289,6 +289,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { lines := ed.Lines() blocks := computeBlockState(lines) cur := ed.Cursor() + drawBlock := focused && !a.terminalCursorOwns(buf) vStart, vEnd, vMode, inVisual := ed.VisualRange() blockLeft, blockRight, blockToEnd := 0, 0, false if inVisual && vMode == vim.ModeVisualBlock { @@ -382,7 +383,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { key.search = true } } - if focused && line == cur.Line && col == cur.Col { + if drawBlock && line == cur.Line && col == cur.Col { key.cursor = true } // Merge runs of identical style. @@ -403,7 +404,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { k2.search = true } } - if focused && line == cur.Line && j == cur.Col { + if drawBlock && line == cur.Line && j == cur.Col { k2.cursor = true } if k2 != key { @@ -422,7 +423,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { cells += cellWidth(string(run)) col = j } - if focused && line == cur.Line && cur.Col >= seg.end && si == len(segs)-1 { + if drawBlock && line == cur.Line && cur.Col >= seg.end && si == len(segs)-1 { b.WriteString(a.styleFor(styleKey{cursor: true}).Render(" ")) cells++ } From 481b47bc663ab3eeab41ee649248a86846d68105 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:54 -0500 Subject: [PATCH 04/12] Feat(tui): the cursor finds its cell in every pane layout The cursor follows typing, arrow keys, backspace and mouse clicks, and lands on the character under it in a single pane, either split, with the sidebar or a side panel open, with tabs hidden, in zen mode and in split note mode, and again after a resize rewraps the line. A buffer's wrapped rows are shared by every pane that draws it, and the last pane drawn wins. With one note open in two panes of different widths, a focused pane drawn first would have placed the cursor using the other pane's wrapping, on a blank cell. The buffer now keeps a copy of the rows taken when the focused pane draws it, and the cursor reads that copy. Tests: the cursor through editing and mouse moves, across the layouts above, in the second pane of each split, after a resize, and with the same wrapping note in two unequal panes with either pane focused. The normal and visual mode block is compared byte for byte with the cursor style, so a change to how it looks fails. Co-Authored-By: Claude Opus 5.5 --- internal/tui/buffers.go | 3 + internal/tui/cursor.go | 5 +- internal/tui/cursor_test.go | 154 ++++++++++++++++++++++++++++++++++++ internal/tui/editorview.go | 3 + 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index cbad444..df4db83 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -30,6 +30,9 @@ type noteBuffer struct { afterLoad []func(*noteBuffer) loadErr error rows []rowInfo + // focusRows is the wrapping of the pane that last drew this buffer with + // focus; rows belongs to whichever pane drew it last. + focusRows []rowInfo words int wordsVersion int diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 662ee31..7c3b735 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -35,12 +35,13 @@ func (a *App) terminalCursorOwns(buf *noteBuffer) bool { // insertionCell is the screen cell where the next typed character in buf // lands: the pane's content origin, the gutter, and the width of the text -// before the cursor on its wrapped row. +// before the cursor on its wrapped row, all taken from the focused pane's own +// layout. func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { cur := buf.ed.Cursor() line := buf.ed.LineRunes(cur.Line) rc := a.contentRect(a.activePane) - for _, row := range buf.rows { + for _, row := range buf.focusRows { if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd { continue } diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go index fdeed53..0ee12f9 100644 --- a/internal/tui/cursor_test.go +++ b/internal/tui/cursor_test.go @@ -206,6 +206,12 @@ func TestInsertCursorNormalAndVisualKeepTheBlock(t *testing.T) { if !found { t.Fatalf("no reverse-video cell on the cursor character; reverse cells: %v", cells) } + // The block is the character rendered with the cursor style (plus + // the selection background inside a visual range), unchanged. + want := a.styleFor(styleKey{cursor: true, visual: keys != ""}).Render("o") + if !strings.Contains(view, want) { + t.Fatalf("block cell is not drawn as %q", want) + } if keys == "" && len(cells) != 1 { t.Fatalf("normal mode draws %d reverse cells, want 1", len(cells)) } @@ -253,3 +259,151 @@ func TestInsertCursorWaitsForTheNoteToLoad(t *testing.T) { } } } + +// assertBarOnChar fails unless the bar is visible and sits on the cell where +// the view draws the character at the buffer's cursor. +func assertBarOnChar(t *testing.T, name string, a *App, buf *noteBuffer, view string) { + t.Helper() + cur := buf.ed.Cursor() + want := string(buf.ed.LineRunes(cur.Line)[cur.Col]) + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("%s: cursor = %+v, want a visible bar", name, a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != want { + t.Fatalf("%s: bar at (%d,%d) sits on %q, want %q", name, a.cursor.x, a.cursor.y, got, want) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("%s: reverse-video cells drawn: %v", name, cells) + } +} + +func TestInsertCursorLayoutFollowsEditing(t *testing.T) { + a, buf := cursorApp(t, "first line\nsecond line\nthird line\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + a.View() + start := a.cursor + + a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("xyz")}) + view := a.View() + if a.cursor.x != start.x+3 || a.cursor.y != start.y { + t.Fatalf("after typing the bar is %+v, want 3 cells right of %+v", a.cursor, start) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "f" { + t.Fatalf("after typing the bar sits on %q, want f", got) + } + + a.Update(tea.KeyMsg{Type: tea.KeyDown}) + a.View() + if a.cursor.y != start.y+1 { + t.Fatalf("after Down the bar row is %d, want %d", a.cursor.y, start.y+1) + } + a.Update(tea.KeyMsg{Type: tea.KeyRight}) + view = a.View() + assertBarOnChar(t, "after arrows", a, buf, view) + + a.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + view = a.View() + assertBarOnChar(t, "after a deletion", a, buf, view) + + target := screenCell{x: start.x + 5, y: start.y + 2} + a.Update(tea.MouseMsg{X: target.x, Y: target.y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + view = a.View() + if a.cursor.x != target.x || a.cursor.y != target.y { + t.Fatalf("after a click the bar is %+v, want %+v", a.cursor, target) + } + assertBarOnChar(t, "after a click", a, buf, view) +} + +func TestInsertCursorLayoutAcrossLayouts(t *testing.T) { + cases := []struct { + name string + setup func(a *App) + }{ + {"single pane", func(a *App) {}}, + {"vertical split", func(a *App) { a.splitPane(true) }}, + {"horizontal split", func(a *App) { a.splitPane(false) }}, + {"sidebar open", func(a *App) { a.sidebarOpen = true }}, + {"side panel open", func(a *App) { a.outlineOpen = true }}, + {"tabs hidden", func(a *App) { a.prefs.TabsOff = true }}, + {"zen mode", func(a *App) { a.zen = true }}, + {"split note mode", func(a *App) { a.setPaneMode(modeSplit) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, "alpha beta\nline with Qcursor here\nomega\n", 100) + c.setup(a) + a.layout() + buf.ed.SetCursor(vim.Pos{Line: 1, Col: 10}) + assertBarOnChar(t, c.name, a, buf, a.View()) + }) + } +} + +func TestInsertCursorLayoutSecondPaneFocused(t *testing.T) { + for _, vertical := range []bool{true, false} { + a, buf := cursorApp(t, "alpha beta\nline with Qcursor here\nomega\n", 100) + first := a.activePane + a.splitPane(vertical) + if a.activePane == first { + t.Fatalf("vertical=%v: split did not move focus", vertical) + } + a.openNote("Alpha.md", true) + a.layout() + buf = a.activeBuffer() + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 1, Col: 10}) + assertBarOnChar(t, "second pane", a, buf, a.View()) + if rc := a.contentRect(a.activePane); a.cursor.x < rc.x || a.cursor.y < rc.y { + t.Fatalf("vertical=%v: bar %+v outside the focused pane %+v", vertical, a.cursor, rc) + } + } +} + +func TestInsertCursorLayoutFollowsResize(t *testing.T) { + text := strings.Repeat("abcdefghi ", 10) + "\n" + a, buf := cursorApp(t, text, 120) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 95}) + view := a.View() + assertBarOnChar(t, "wide", a, buf, view) + wide := a.cursor + + a.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + view = a.View() + if a.cursor == wide { + t.Fatalf("bar stayed at %+v after the line rewrapped", wide) + } + if a.cursor.y <= wide.y { + t.Fatalf("bar row %d, want below %d once the line rewraps", a.cursor.y, wide.y) + } + assertBarOnChar(t, "narrow", a, buf, view) +} + +// With the same note open in two panes of different sizes, the bar belongs to +// the focused pane's own wrapping whichever pane is painted first. +func TestInsertCursorLayoutFirstPaneFocusedUnequalPanes(t *testing.T) { + text := strings.Repeat("abcdefghi ", 20) + "\n" + for _, vertical := range []bool{true, false} { + for _, focusFirst := range []bool{true, false} { + a, _ := cursorApp(t, text, 100) + first := a.activePane + a.splitPane(vertical) + if a.activePane == first { + t.Fatalf("vertical=%v: split did not move focus", vertical) + } + a.openNote("Alpha.md", true) + a.resizePane(0.3) + a.layout() + if focusFirst { + a.activePane = first + } + buf := a.activeBuffer() + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 85}) + name := "first pane" + if !focusFirst { + name = "second pane" + } + assertBarOnChar(t, name, a, buf, a.View()) + } + } +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index 897dafe..be6d3d1 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -444,6 +444,9 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { for len(out) < h { out = append(out, padRight(th.Muted.Render("~"), w)) } + if focused { + buf.focusRows = append(buf.focusRows[:0], buf.rows...) + } return strings.Join(out, "\n") } From 49237b9d5b20044be8813c7301c96cbc6d99db81 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:55 -0500 Subject: [PATCH 05/12] Feat(tui): an underline while r waits for its character nvim shows an underline while `r` waits for the replacement character, which tells the user the next key replaces rather than moves. zn now does the same: while the editor reports a pending `r`, the terminal cursor is an underline on the character to be replaced, and once the character or Esc arrives the drawn block comes back. In visual mode `r` keeps the block. Tests: `r` and `3r` show the underline on the cursor character with no reverse cell; `rx`, `r` and `2rx` end with one block and no terminal cursor; `v` then `r` keeps the block. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursor.go | 5 ++- internal/tui/cursor_replace_test.go | 70 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 internal/tui/cursor_replace_test.go diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 7c3b735..5f8373f 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -3,7 +3,7 @@ package tui import "github.com/ZenNotes/tui/internal/vim" // bufferCursorShape is the terminal cursor shape the buffer wants while its -// text is being edited. ok is false when the buffer keeps the drawn block +// text is being edited, or a normal-mode r waits for its character. ok is false when the buffer keeps the drawn block // instead: in normal and visual mode, in the command line, and while the // note is still loading. func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { @@ -19,6 +19,9 @@ func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { case vim.ModeReplace: return cursorShapeUnderline, true } + if buf.ed.ReplacePending() { + return cursorShapeUnderline, true + } return 0, false } diff --git a/internal/tui/cursor_replace_test.go b/internal/tui/cursor_replace_test.go new file mode 100644 index 0000000..0df9433 --- /dev/null +++ b/internal/tui/cursor_replace_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/vim" +) + +// While a normal-mode r waits for its character the cursor is an underline at +// the cursor cell and no block is drawn. +func TestReplaceCharCursorUnderlineWhileWaiting(t *testing.T) { + for _, keys := range []string{"r", "3r"} { + t.Run("keys "+keys, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(keys) + if buf.ed.Mode() != vim.ModeNormal { + t.Fatalf("mode = %v, want normal", buf.ed.Mode()) + } + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeUnderline { + t.Fatalf("cursor = %+v, want a visible underline", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "o" { + t.Fatalf("underline on %q, want the cursor character", got) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn while r waits: %v", cells) + } + }) + } +} + +// The character, or Esc, ends the wait: the cursor hides and the block returns. +func TestReplaceCharCursorEndsWithCharOrEsc(t *testing.T) { + for _, tc := range []struct{ name, keys string }{ + {"character", "rx"}, + {"escape", "r"}, + {"counted character", "2rx"}, + } { + t.Run(tc.name, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(tc.keys) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if cells := reverseCells(view); len(cells) != 1 { + t.Fatalf("block cells = %v, want exactly one", cells) + } + }) + } +} + +// r typed in visual mode keeps the block, as every visual-mode key does. +func TestReplaceCharCursorVisualKeepsTheBlock(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("v") + buf.ed.Feed("r") + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("no block drawn in visual mode") + } +} From 0c97bab2a1c624e7049b81c82bd2fc5f43b28b3e Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:56 -0500 Subject: [PATCH 06/12] Feat(tui): a bar on the command line The `:`, `/` and `?` lines drew the same reverse-video cell as the editor. They now show the bar after the typed text, following Left and Ctrl+B (start of line), over wide runes too, and the wildmenu after the text does not move it. When the line closes with Enter or Esc, the buffer's own cursor rules apply again. Tests: the bar after empty and typed text for each of `:`, `/` and `?` with no reverse cell on the line; after Left once, twice over a wide rune, and after Ctrl+B; and the buffer's block after Esc and Enter. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 8 ++- internal/tui/cursor.go | 3 ++ internal/tui/cursor_cmdline_test.go | 79 +++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 internal/tui/cursor_cmdline_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index d9ae958..af72989 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -149,6 +149,10 @@ type App struct { // cursor is where the terminal cursor belongs in the last frame drawn. cursor cursorState + // cmdlineX is the column of the next typed character on the command + // line, set by renderCommandLine while a command line is open. + cmdlineX int + cmdlineOpen bool systemDark bool exHistory []string @@ -1014,6 +1018,7 @@ func (a *App) vaultLabel() string { func (a *App) renderCommandLine() string { t := a.theme + a.cmdlineOpen = false if buf := a.activeBuffer(); buf != nil && a.focus == focusPane { if kind, text, cursor, active := buf.ed.Cmdline(); active { runes := []rune(text) @@ -1022,7 +1027,8 @@ func (a *App) renderCommandLine() string { if cursor < len(runes) { after = string(runes[cursor:]) } - line := string(kind) + before + t.Cursor.Render(firstCellOr(after, " ")) + restAfterFirst(after) + a.cmdlineX, a.cmdlineOpen = cellWidth(string(kind)+before), true + line := string(kind) + before + after if comps, idx := buf.ed.Completions(); len(comps) > 0 { line += " " + a.renderWildmenu(comps, idx) } diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 5f8373f..59a7687 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -61,6 +61,9 @@ func (a *App) computeCursor() cursorState { if a.overlay != nil || a.focus != focusPane { return cursorState{} } + if a.cmdlineOpen { + return cursorState{visible: true, x: a.cmdlineX, y: a.height - 1, shape: cursorShapeBar} + } t := a.activeTab() if t == nil || t.view != nil || t.mode == modePreview { return cursorState{} diff --git a/internal/tui/cursor_cmdline_test.go b/internal/tui/cursor_cmdline_test.go new file mode 100644 index 0000000..6aa983c --- /dev/null +++ b/internal/tui/cursor_cmdline_test.go @@ -0,0 +1,79 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// lastRow is the screen row of the command line. +func lastRow(a *App) int { return a.height - 1 } + +func TestCmdlineCursorAfterText(t *testing.T) { + for _, kind := range []string{":", "/", "?"} { + for _, text := range []string{"", "wq"} { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("" + kind + text) + view := a.View() + want := 1 + len(text) + if !a.cursor.visible || a.cursor.shape != cursorShapeBar || + a.cursor.x != want || a.cursor.y != lastRow(a) { + t.Fatalf("%q %q: cursor = %+v, want a bar at (%d,%d)", kind, text, a.cursor, want, lastRow(a)) + } + for _, c := range reverseCells(view) { + if c.y == lastRow(a) { + t.Fatalf("%q %q: reverse cell in the command line at %v", kind, text, c) + } + } + if got := ansi.Strip(strings.Split(view, "\n")[lastRow(a)]); !strings.HasPrefix(got, kind+text) { + t.Fatalf("command line = %q", got) + } + } + } +} + +func TestCmdlineCursorMovedLeft(t *testing.T) { + cases := []struct { + name string + keys string + x int + on string + }{ + {"left once", "", 1 + 2 + 2, "c"}, + {"left twice, wide rune", "", 1 + 2, "日"}, + {"ctrl+b to the start", "", 1, "a"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed(":ab日c" + c.keys) + view := a.View() + if !a.cursor.visible || a.cursor.x != c.x || a.cursor.y != lastRow(a) { + t.Fatalf("cursor = %+v, want x %d on the last row", a.cursor, c.x) + } + if got := cellText2(view, a.cursor.x, a.cursor.y, cellWidth(c.on)); got != c.on { + t.Fatalf("bar on %q, want %q", got, c.on) + } + for _, cell := range reverseCells(view) { + if cell.y == lastRow(a) { + t.Fatalf("reverse cell in the command line at %v", cell) + } + } + }) + } +} + +func TestCmdlineCursorClosed(t *testing.T) { + for _, key := range []string{"", ""} { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed(":" + key) + view := a.View() + if a.cursor.visible { + t.Fatalf("%s: cursor = %+v, want hidden", key, a.cursor) + } + if cells := reverseCells(view); len(cells) == 0 { + t.Fatalf("%s: no block cursor drawn in the buffer", key) + } + } +} From 7d9cc36577c8a45b75573e18664ae621b2ba55cf Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:57 -0500 Subject: [PATCH 07/12] Fix(tui): keep the terminal cursor inside the editor and on screen The drawn block used to be clipped where it did not fit, so nobody saw it. The terminal cursor is not clipped: with word wrap off, a long line put the bar on the pane frame, a neighbouring split, the side panel or the preview half in split note mode, and a full wrapped row put the end-of-line bar one cell past the text. A command line longer than the terminal sent the cursor off screen, where the terminal pins it to the last column over other text. The focused pane now records the editor's drawn width next to its rows, and the cursor is clamped to the last cell of that width rather than hidden, so the bar at the end of a full row stays visible. The command line's cursor is clamped to the last column. Tests, written red first: word wrap off in a single pane, a vertical split, split note mode and with the side panel open; a full row with wrap on in a single pane and in split note mode; and a command line wider than the terminal. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 2 +- internal/tui/buffers.go | 2 + internal/tui/cursor.go | 13 ++++-- internal/tui/cursor_cmdline_test.go | 9 ++++ internal/tui/cursor_test.go | 64 +++++++++++++++++++++++++++++ internal/tui/editorview.go | 1 + 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index af72989..96ed938 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1027,7 +1027,7 @@ func (a *App) renderCommandLine() string { if cursor < len(runes) { after = string(runes[cursor:]) } - a.cmdlineX, a.cmdlineOpen = cellWidth(string(kind)+before), true + a.cmdlineX, a.cmdlineOpen = min(cellWidth(string(kind)+before), max(0, a.width-1)), true line := string(kind) + before + after if comps, idx := buf.ed.Completions(); len(comps) > 0 { line += " " + a.renderWildmenu(comps, idx) diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index df4db83..7c7a55d 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -33,6 +33,8 @@ type noteBuffer struct { // focusRows is the wrapping of the pane that last drew this buffer with // focus; rows belongs to whichever pane drew it last. focusRows []rowInfo + // focusW is the drawn width of that editor, gutter included. + focusW int words int wordsVersion int diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 59a7687..6130de9 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -3,9 +3,10 @@ package tui import "github.com/ZenNotes/tui/internal/vim" // bufferCursorShape is the terminal cursor shape the buffer wants while its -// text is being edited, or a normal-mode r waits for its character. ok is false when the buffer keeps the drawn block -// instead: in normal and visual mode, in the command line, and while the -// note is still loading. +// text is being edited, or a normal-mode r waits for its character. ok is false +// when the buffer keeps the drawn block instead: in vim normal and visual mode, +// and while the note is still loading. It does not know about the command +// line; computeCursor handles that. func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { if buf == nil || buf.loading { return 0, false @@ -51,7 +52,11 @@ func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { if cur.Col == row.segEnd && row.segEnd < len(line) { continue // the cursor starts the next wrapped row } - return rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col])), rc.y + row.y, true + x = rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col])) + if buf.focusW > 0 { + x = min(x, rc.x+buf.focusW-1) // keep the bar inside the editor + } + return x, rc.y + row.y, true } return 0, 0, false } diff --git a/internal/tui/cursor_cmdline_test.go b/internal/tui/cursor_cmdline_test.go index 6aa983c..afd8c0b 100644 --- a/internal/tui/cursor_cmdline_test.go +++ b/internal/tui/cursor_cmdline_test.go @@ -77,3 +77,12 @@ func TestCmdlineCursorClosed(t *testing.T) { } } } + +func TestCmdlineCursorWiderThanTerminal(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 40) + buf.ed.Feed(":" + strings.Repeat("a", 100)) + a.View() + if a.cursor.visible && (a.cursor.x < 0 || a.cursor.x >= a.width) { + t.Fatalf("cursor = %+v, want a column inside the %d-wide terminal", a.cursor, a.width) + } +} diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go index 0ee12f9..9d27388 100644 --- a/internal/tui/cursor_test.go +++ b/internal/tui/cursor_test.go @@ -407,3 +407,67 @@ func TestInsertCursorLayoutFirstPaneFocusedUnequalPanes(t *testing.T) { } } } + +// The bar never leaves the editor's drawn text area: with word wrap off, a +// long line runs past the pane, and with it on, an exactly full row puts the +// end-of-line cell one past the text. +func TestInsertCursorLayoutStaysInsideEditor(t *testing.T) { + cases := []struct { + name string + wrap bool + setup func(a *App) + col int + }{ + {"wrap off, single pane", false, func(a *App) {}, 150}, + {"wrap off, vertical split", false, func(a *App) { a.splitPane(true) }, 150}, + {"wrap off, split note mode", false, func(a *App) { a.setPaneMode(modeSplit) }, 150}, + {"wrap off, side panel", false, func(a *App) { a.outlineOpen = true }, 150}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, strings.Repeat("x", 200)+"\n", 100) + a.prefs.WordWrap = c.wrap + c.setup(a) + a.layout() + buf.ed.SetCursor(vim.Pos{Line: 0, Col: c.col}) + a.View() + assertCursorInEditor(t, a) + }) + } + t.Run("wrap on, exact width row", func(t *testing.T) { + for _, split := range []bool{false, true} { + a, buf := cursorApp(t, "x\n", 100) + a.prefs.WordWrap = true + if split { + a.setPaneMode(modeSplit) + } + a.layout() + a.View() + rc := a.contentRect(a.activePane) + w := rc.w + if split { + w /= 2 + } + textW := w - a.gutterWidth(buf) + buf.ed.SetText(strings.Repeat("x", textW) + "\n") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: textW}) + a.View() + assertCursorInEditor(t, a) + } + }) +} + +func assertCursorInEditor(t *testing.T, a *App) { + t.Helper() + if !a.cursor.visible { + return // hidden is acceptable + } + rc := a.contentRect(a.activePane) + w := rc.w + if t2 := a.activeTab(); t2 != nil && t2.mode == modeSplit { + w /= 2 + } + if a.cursor.x < rc.x || a.cursor.x >= rc.x+w { + t.Fatalf("bar x %d outside the editor columns [%d,%d)", a.cursor.x, rc.x, rc.x+w) + } +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index be6d3d1..f5a8bba 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -446,6 +446,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { } if focused { buf.focusRows = append(buf.focusRows[:0], buf.rows...) + buf.focusW = w } return strings.Join(out, "\n") } From 986d91b276eabe23b60642c5ad435fba4ff6a236 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:38:59 -0500 Subject: [PATCH 08/12] Feat(tui): a bar in text fields and quick capture Anywhere text is typed outside a note, the field drew its own reverse-video cell. The palette, the pickers, prompts including the masked password prompt, the ex prompt, database cell and date editing, and the task due and field prompts now report their cursor cell, and the terminal shows the bar there: after the typed text, after the last dot in a masked prompt, at the start of an empty field with a placeholder, and clamped inside the box for long input. A field opened over a note being edited takes the bar; closing it hands the cursor back to the note. Quick capture is a small vim editor, so it follows the note rules: bar in insert mode or with vim mode off, underline in replace mode, block in normal mode. Its title can wrap, so its height is measured. Tests: each field above, a placeholder in an empty palette and picker, a long input, a prompt over a note in insert mode, Esc back to a note in normal mode, and quick capture in insert mode and with vim mode off. Co-Authored-By: Claude Opus 5.5 --- internal/tui/buffers.go | 3 + internal/tui/cursor.go | 25 +++-- internal/tui/cursor_field_test.go | 168 ++++++++++++++++++++++++++++++ internal/tui/editorview.go | 15 ++- internal/tui/overlays.go | 76 +++++++++++--- internal/tui/pick_overlay.go | 9 +- 6 files changed, 269 insertions(+), 27 deletions(-) create mode 100644 internal/tui/cursor_field_test.go diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index 7c7a55d..1bf3985 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -35,6 +35,9 @@ type noteBuffer struct { focusRows []rowInfo // focusW is the drawn width of that editor, gutter included. focusW int + // standalone marks an editor drawn outside the tabs, such as quick + // capture's, whose cursor its host places. + standalone bool words int wordsVersion int diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 6130de9..508e084 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -28,9 +28,9 @@ func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { // terminalCursorOwns reports whether the terminal's own cursor stands in for // the drawn block in buf. Only the active tab's buffer is placed that way; -// other editors, such as quick capture's, keep drawing their block. +// a standalone editor, such as quick capture's, is placed by its host. func (a *App) terminalCursorOwns(buf *noteBuffer) bool { - if buf == nil || buf != a.activeBuffer() { + if buf == nil || (!buf.standalone && buf != a.activeBuffer()) { return false } _, ok := a.bufferCursorShape(buf) @@ -40,11 +40,10 @@ func (a *App) terminalCursorOwns(buf *noteBuffer) bool { // insertionCell is the screen cell where the next typed character in buf // lands: the pane's content origin, the gutter, and the width of the text // before the cursor on its wrapped row, all taken from the focused pane's own -// layout. -func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { +// layout. rc is the origin of the area buf was drawn in. +func (a *App) insertionCell(buf *noteBuffer, rc rect) (x, y int, ok bool) { cur := buf.ed.Cursor() line := buf.ed.LineRunes(cur.Line) - rc := a.contentRect(a.activePane) for _, row := range buf.focusRows { if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd { continue @@ -63,7 +62,19 @@ func (a *App) insertionCell(buf *noteBuffer) (x, y int, ok bool) { // computeCursor picks the cursor for the frame just drawn. func (a *App) computeCursor() cursorState { - if a.overlay != nil || a.focus != focusPane { + if a.overlay != nil { + f, ok := a.overlay.(cursorField) + if !ok { + return cursorState{} + } + c := f.fieldCursor() + if c.visible { + c.x += a.overlayRect.x + c.y += a.overlayRect.y + } + return c + } + if a.focus != focusPane { return cursorState{} } if a.cmdlineOpen { @@ -78,7 +89,7 @@ func (a *App) computeCursor() cursorState { if !ok { return cursorState{} } - x, y, ok := a.insertionCell(buf) + x, y, ok := a.insertionCell(buf, a.contentRect(a.activePane)) if !ok { return cursorState{} } diff --git a/internal/tui/cursor_field_test.go b/internal/tui/cursor_field_test.go new file mode 100644 index 0000000..65654a9 --- /dev/null +++ b/internal/tui/cursor_field_test.go @@ -0,0 +1,168 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +// assertFieldBar checks the cursor is a visible bar on the cell where the +// view draws want, and that no reverse-video cell sits on its row. +func assertFieldBar(t *testing.T, a *App, view, want string) { + t.Helper() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != want { + t.Fatalf("bar sits on %q, want the cell of %q", got, want) + } + for _, c := range reverseCells(view) { + if c.y == a.cursor.y { + t.Fatalf("reverse-video cell drawn in the field at %v", c) + } + } +} + +func TestTextFieldCursorPalette(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openNoteSearch("abc") + view := a.View() + assertFieldBar(t, a, view, " ") + if cellText(view, a.cursor.x-1, a.cursor.y) != "c" { + t.Fatal("bar is not right after the typed text") + } + a.overlay.handleKey(a, vim.Key{Name: "left"}) + assertFieldBar(t, a, a.View(), "c") +} + +func TestTextFieldCursorPlaceholder(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openNoteSearch("") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x+1, a.cursor.y); got != "T" { + t.Fatalf("placeholder starts at %q, want T", got) + } + a.overlay = newPickOverlay("Tags", "Filter options", []pickItem{{id: "a", label: "a"}}, nil) + view = a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x+1, a.cursor.y); got != "F" { + t.Fatalf("picker placeholder starts at %q, want F", got) + } +} + +func TestTextFieldCursorSecretPrompt(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.promptSecret("Token", "", func(*App, string) {}) + typeText(a, "abc") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "•" { + t.Fatalf("cell before the bar is %q, want the third dot", got) + } +} + +func TestTextFieldCursorClampedToFieldWidth(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 40) + a.promptFor("Name", "", "", func(*App, string) {}) + typeText(a, "this text is a lot longer than the prompt is wide") + a.View() + box := a.overlayRect + if !a.cursor.visible || a.cursor.x >= box.x+box.w || a.cursor.x <= box.x { + t.Fatalf("cursor = %+v escapes the overlay %+v", a.cursor, box) + } +} + +func TestTextFieldCursorPromptBeatsInsertionPoint(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + a.View() + insertion := a.cursor + a.promptFor("Name", "ab", "", func(*App, string) {}) + view := a.View() + assertFieldBar(t, a, view, " ") + if a.cursor == insertion { + t.Fatalf("bar stayed at the insertion point %+v", insertion) + } +} + +func TestTextFieldCursorEscapeBackToNormalBuffer(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("") + a.promptFor("Name", "ab", "", func(*App, string) {}) + a.View() + if !a.cursor.visible { + t.Fatal("no bar in the prompt") + } + a.overlay.handleKey(a, vim.Key{Name: "esc"}) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("the buffer's block is not drawn") + } +} + +func TestTextFieldCursorCellAndTaskPrompts(t *testing.T) { + prompts := map[string]func(a *App){ + "date": func(a *App) { a.datePrompt("Due", "2026-11-01", func(*App, string) {}) }, + "number": func(a *App) { a.numberPrompt("Count", "42", func(*App, string) {}) }, + "text": func(a *App) { a.textPrompt("Notes", "abc", "[[ links a note", func(*App, string) {}) }, + "due": func(a *App) { a.taskDuePrompt(vault.Task{Due: "2026-11-01"}) }, + "field": func(a *App) { a.taskFieldPrompt(vault.Task{}) }, + } + for name, open := range prompts { + t.Run(name, func(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + open(a) + p := a.overlay.(*prompt) + view := a.View() + assertFieldBar(t, a, view, " ") + if p.input.cursor > 0 { + want := string(p.input.runes[p.input.cursor-1]) + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != want { + t.Fatalf("cell before the bar is %q, want %q", got, want) + } + } + }) + } +} + +func TestTextFieldCursorExPrompt(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.overlay = &exPrompt{input: newTextInput("set")} + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "t" { + t.Fatalf("cell before the bar is %q, want t", got) + } +} + +func TestTextFieldCursorQuickCapture(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openQuickCapture() + typeText(a, "ab") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "b" { + t.Fatalf("cell before the bar is %q, want b", got) + } + a.overlay.handleKey(a, vim.Key{Name: "esc"}) + view = a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v in normal mode, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("capture's block is not drawn in normal mode") + } +} + +func TestTextFieldCursorQuickCaptureVimOff(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = false + a.openQuickCapture() + typeText(a, "ab") + assertFieldBar(t, a, a.View(), " ") +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index f5a8bba..fd92798 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -513,13 +513,22 @@ func inVisualRange(line, col int, start, end vim.Pos, mode vim.Mode, bl, br int, } // renderPlainEditor draws a small editor (quick capture) without gutter. -func renderPlainEditor(a *App, ed *vim.Editor, w, h int, focused bool) string { - tmp := ¬eBuffer{ed: ed} +// It also returns the cursor it wants, as a cell inside the drawn block. +func renderPlainEditor(a *App, ed *vim.Editor, w, h int, focused bool) (string, cursorState) { + tmp := ¬eBuffer{ed: ed, standalone: true} saved := a.prefs.LineNumberMode a.prefs.LineNumberMode = "off" out := a.renderEditor(tmp, w, h, focused) a.prefs.LineNumberMode = saved - return out + shape, ok := a.bufferCursorShape(tmp) + if !ok { + return out, cursorState{} + } + x, y, ok := a.insertionCell(tmp, rect{}) + if !ok { + return out, cursorState{} + } + return out, cursorState{visible: true, x: x, y: y, shape: shape} } // editorHintTargets labels every link on the visible rows of a buffer. diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index a5f61fe..47be95b 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -95,14 +95,37 @@ func (t *textInput) handle(k vim.Key) bool { return true } -func (t *textInput) render(th Theme, width int) string { - before := string(t.runes[:t.cursor]) - after := "" - if t.cursor < len(t.runes) { - after = string(t.runes[t.cursor:]) - } - line := before + th.Cursor.Render(firstCellOr(after, " ")) + restAfterFirst(after) - return padRight(line, width) +// render draws the text padded to width. The cursor is not drawn: the +// terminal's own cursor is placed at cell instead. +func (t *textInput) render(width int) string { + return padRight(string(t.runes), width) +} + +// cell is the column, within a field width cells wide, of the next typed +// character; a cursor past the field's right edge stays on its last cell. +func (t *textInput) cell(width int) int { + return min(cellWidth(string(t.runes[:t.cursor])), max(width-1, 0)) +} + +// cursorField is an overlay with its own text field. fieldCursor is the +// cursor the field wants, with its cell relative to the overlay box and +// taken from the last render; it is hidden when the field has none. +type cursorField interface { + fieldCursor() cursorState +} + +// overlayInset is the offset of an overlay's content inside its box: the +// border plus the padding. +func (a *App) overlayInset() (x, y int) { + s := a.theme.Overlay + return s.GetBorderLeftSize() + s.GetPaddingLeft(), s.GetBorderTopSize() + s.GetPaddingTop() +} + +// fieldAt is the bar for a field whose first cell is at column col of the +// given content row of an overlay. +func (a *App) fieldAt(col, row int) cursorState { + ix, iy := a.overlayInset() + return cursorState{visible: true, x: ix + col, y: iy + row, shape: cursorShapeBar} } // --- palette --- @@ -122,6 +145,7 @@ type palette struct { title string placeholder string input textInput + cur cursorState items []paletteItem filtered []paletteItem cursor int @@ -249,6 +273,8 @@ func (p *palette) currentID() string { return p.filtered[p.cursor].id } +func (p *palette) fieldCursor() cursorState { return p.cur } + func (p *palette) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(40, w*2/3)) @@ -262,9 +288,11 @@ func (p *palette) render(a *App, w, h int) string { } lines := []string{th.OverlayTitle.Render(p.title)} prompt := "› " - lines = append(lines, prompt+p.input.render(th, inner-lipgloss.Width(prompt))) + fieldW := inner - lipgloss.Width(prompt) + lines = append(lines, prompt+p.input.render(fieldW)) + p.cur = a.fieldAt(lipgloss.Width(prompt)+p.input.cell(fieldW), 1) if p.input.String() == "" && p.placeholder != "" { - lines[len(lines)-1] = prompt + th.Cursor.Render(" ") + th.Muted.Render(padRight(p.placeholder, inner-3)) + lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } lines = append(lines, th.Muted.Render(strings.Repeat("─", inner))) if len(p.filtered) == 0 { @@ -330,8 +358,11 @@ type prompt struct { onLink func(a *App, p *prompt) // masked hides what is typed: tokens and other secrets. masked bool + cur cursorState } +func (p *prompt) fieldCursor() cursorState { return p.cur } + func (a *App) promptFor(title, initial, help string, onSubmit func(a *App, text string)) { a.overlay = &prompt{title: title, input: newTextInput(initial), help: help, onSubmit: onSubmit} } @@ -361,10 +392,12 @@ func (p *prompt) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(40, w/2)) inner := width - 2 - field := p.input.render(th, inner) + field := p.input.render(inner) + p.cur = a.fieldAt(p.input.cell(inner), 1) if p.masked { dots := strings.Repeat("•", len(p.input.runes)) - field = padRight(dots+th.Cursor.Render(" "), inner) + field = padRight(dots+" ", inner) + p.cur = a.fieldAt(min(len(p.input.runes), max(inner-1, 0)), 1) } lines := []string{th.OverlayTitle.Render(p.title), field} if p.help != "" { @@ -505,9 +538,12 @@ func (m *menu) render(a *App, w, h int) string { // --- quick capture --- type captureOverlay struct { - ed *vim.Editor + ed *vim.Editor + cur cursorState } +func (c *captureOverlay) fieldCursor() cursorState { return c.cur } + func (a *App) openQuickCapture() { opts := a.editorOptions() ed := vim.New("", opts, vim.Hooks{}) @@ -546,7 +582,13 @@ func (c *captureOverlay) render(a *App, w, h int) string { lines := []string{th.OverlayTitle.Render("Quick capture") + th.Muted.Render(" first line becomes the title · Ctrl+S save · Esc cancel")} c.ed.SetViewport(8) c.ed.EnsureCursorVisible() - body := renderPlainEditor(a, c.ed, inner, 8, true) + body, edCur := renderPlainEditor(a, c.ed, inner, 8, true) + c.cur = cursorState{} + if edCur.visible { + ix, iy := a.overlayInset() + titleRows := lipgloss.Height(lipgloss.NewStyle().Width(inner).Render(lines[0])) + c.cur = cursorState{visible: true, x: ix + edCur.x, y: iy + titleRows + edCur.y, shape: edCur.shape} + } lines = append(lines, strings.Split(body, "\n")...) mode := c.ed.Mode().Label() if !a.prefs.VimMode { @@ -767,8 +809,11 @@ type exPrompt struct { compBase string histIdx int saved string + cur cursorState } +func (p *exPrompt) fieldCursor() cursorState { return p.cur } + func (p *exPrompt) resetCompletion() { p.completions = nil p.compIdx = -1 @@ -866,7 +911,8 @@ func (p *exPrompt) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(50, w*2/3)) inner := width - 2 - lines := []string{th.OverlayTitle.Render(":") + " " + p.input.render(th, inner-2)} + p.cur = a.fieldAt(2+p.input.cell(inner-2), 0) + lines := []string{th.OverlayTitle.Render(":") + " " + p.input.render(inner-2)} if len(p.completions) > 1 { parts := []string{} shown := 0 diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index e30a188..0e36333 100644 --- a/internal/tui/pick_overlay.go +++ b/internal/tui/pick_overlay.go @@ -24,6 +24,7 @@ type pickOverlay struct { title string placeholder string input textInput + cur cursorState items []pickItem source func(a *App, query string) []pickItem checked map[string]bool @@ -177,6 +178,8 @@ func (p *pickOverlay) handleKey(a *App, k vim.Key) { } } +func (p *pickOverlay) fieldCursor() cursorState { return p.cur } + func (p *pickOverlay) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(44, w*2/3)) @@ -190,9 +193,11 @@ func (p *pickOverlay) render(a *App, w, h int) string { } lines := []string{th.OverlayTitle.Render(p.title)} prompt := "› " - lines = append(lines, prompt+p.input.render(th, inner-cellWidth(prompt))) + fieldW := inner - cellWidth(prompt) + lines = append(lines, prompt+p.input.render(fieldW)) + p.cur = a.fieldAt(cellWidth(prompt)+p.input.cell(fieldW), 1) if p.input.String() == "" && p.placeholder != "" { - lines[len(lines)-1] = prompt + th.Cursor.Render(" ") + th.Muted.Render(padRight(p.placeholder, inner-3)) + lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } lines = append(lines, th.Muted.Render(strings.Repeat("─", inner))) if len(p.filtered) == 0 { From 85815a9f5dbf61e35373fa00b96bc73a084ee865 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:00 -0500 Subject: [PATCH 09/12] Feat(tui): menus, toasts and sidebar focus hide the cursor A bar blinking through a menu or a toast looks like the menu takes text. While a context menu, a centred menu, the hints overlay, the which-key popup or a toast is showing, the terminal cursor is hidden, and it comes back on the same cell when it closes or expires. A pop-up with its own text field still shows the bar in that field, and the tag menu keeps the bar at the insertion point, since the user is still typing. With focus on the sidebar or a side panel the cursor is hidden until focus returns to the pane. Tests, with vim mode on and off: each covering element hides the cursor and restores it on the same cell; the tag menu keeps it; the sidebar and outline panel hide it and focusing the pane brings it back; a prompt opened from the sidebar shows the bar in the prompt. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursor.go | 6 + internal/tui/cursor_covered_test.go | 169 ++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 internal/tui/cursor_covered_test.go diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index 508e084..b00b12c 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -62,6 +62,12 @@ func (a *App) insertionCell(buf *noteBuffer, rc rect) (x, y int, ok bool) { // computeCursor picks the cursor for the frame just drawn. func (a *App) computeCursor() cursorState { + if len(a.toasts) > 0 { + return cursorState{} // a toast covers the corner it sits in + } + if a.leader != nil && a.leader.showHints { + return cursorState{} // the which-key popup covers the pane + } if a.overlay != nil { f, ok := a.overlay.(cursorField) if !ok { diff --git a/internal/tui/cursor_covered_test.go b/internal/tui/cursor_covered_test.go new file mode 100644 index 0000000..48cff2c --- /dev/null +++ b/internal/tui/cursor_covered_test.go @@ -0,0 +1,169 @@ +package tui + +import ( + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vim" +) + +// coveredModes runs fn once with vim mode on and once with it off. +func coveredModes(t *testing.T, fn func(t *testing.T, vimMode bool)) { + t.Helper() + for _, vimMode := range []bool{true, false} { + name := "vim" + if !vimMode { + name = "plain" + } + t.Run(name, func(t *testing.T) { fn(t, vimMode) }) + } +} + +// coveredApp is cursorApp in the requested editing style, with the bar +// confirmed at the insertion point before anything covers it. +func coveredApp(t *testing.T, vimMode bool) (*App, cursorState) { + t.Helper() + a, buf := cursorApp(t, "hello world\n", 80) + a.prefs.VimMode = vimMode + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 3}) + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v before covering, want a visible bar", a.cursor) + } + return a, a.cursor +} + +func assertCursorHidden(t *testing.T, a *App, what string) { + t.Helper() + a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v under %s, want hidden", a.cursor, what) + } +} + +func assertCursorAt(t *testing.T, a *App, want cursorState, what string) { + t.Helper() + a.View() + if a.cursor != want { + t.Fatalf("cursor = %+v %s, want %+v", a.cursor, what, want) + } +} + +func TestCursorCoveredContextMenu(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.noteContextMenu("Alpha.md") + assertCursorHidden(t, a, "a context menu") + a.overlay = nil + assertCursorAt(t, a, want, "after the menu closed") + }) +} + +func TestCursorCoveredCentredMenu(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.overlay = &menu{title: "Centre", items: []menuItem{{key: "a", label: "A", run: func(*App) {}}}} + assertCursorHidden(t, a, "a centred menu") + a.overlay = nil + assertCursorAt(t, a, want, "after the menu closed") + }) +} + +func TestCursorCoveredHintsOverlay(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.overlay = &hintOverlay{targets: []hintTarget{{label: "a", x: 1, y: 1, length: 2, run: func(*App) {}}}} + assertCursorHidden(t, a, "the hints overlay") + a.overlay = nil + assertCursorAt(t, a, want, "after hints closed") + }) +} + +func TestCursorCoveredWhichKey(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.startLeader() + a.leader.showHints = true + assertCursorHidden(t, a, "the which-key popup") + a.leader = nil + assertCursorAt(t, a, want, "after the popup closed") + }) +} + +func TestCursorCoveredToast(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, want := coveredApp(t, vimMode) + a.notify("saved") + if len(a.toasts) == 0 { + t.Fatal("no toast") + } + assertCursorHidden(t, a, "a toast") + for i := range a.toasts { + a.toasts[i].until = time.Now().Add(-time.Second) + } + a.expireToasts() + assertCursorAt(t, a, want, "after the toast expired") + }) +} + +func TestCursorCoveredTagMenuKeepsBar(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + prefs := vimPrefs() + prefs.VimMode = vimMode + a, buf := newTagTestApp(t, prefs, "\n") + a.sidebarOpen = false + a.layout() + if vimMode { + feed(a, vim.R('i')) + } + typeKeys(a, "see #pro") + if a.liveTagMenu() == nil { + t.Fatal("the tag menu did not open") + } + a.View() + x, y, ok := a.insertionCell(buf, a.contentRect(a.activePane)) + if !ok { + t.Fatal("no insertion cell") + } + want := cursorState{visible: true, x: x, y: y, shape: cursorShapeBar} + if a.cursor != want { + t.Fatalf("cursor = %+v with the tag menu open, want %+v", a.cursor, want) + } + }) +} + +func TestCursorCoveredSidePanelFocus(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, _ := coveredApp(t, vimMode) + a.sidebarOpen = true + a.layout() + a.View() + want := a.cursor + if !want.visible { + t.Fatalf("cursor = %+v with the sidebar open, want a bar", want) + } + a.focus = focusSidebar + assertCursorHidden(t, a, "the sidebar's focus") + a.focus = focusPane + assertCursorAt(t, a, want, "back in the pane") + + a.toggleSidePanel(&a.outlineOpen, focusOutline) + a.focus = focusPane + a.View() + want = a.cursor + a.focus = focusOutline + assertCursorHidden(t, a, "a side panel's focus") + a.focus = focusPane + assertCursorAt(t, a, want, "back in the pane") + }) +} + +func TestCursorCoveredPromptFromSidebar(t *testing.T) { + coveredModes(t, func(t *testing.T, vimMode bool) { + a, _ := coveredApp(t, vimMode) + a.focus = focusSidebar + a.promptFor("Name", "ab", "", func(*App, string) {}) + view := a.View() + assertFieldBar(t, a, view, " ") + }) +} From b437c106a8b03c5b8e0ac4f38909765172459c2e Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:01 -0500 Subject: [PATCH 10/12] Feat(tui): zn shows the terminal cursor and resets its shape on exit The program now writes through the cursor output wrapper, and every frame hands it the cursor worked out for that frame, so the bar and underline appear in the terminal. zn cannot read the shape the terminal used before it started, so on the way out it resets the terminal to its default shape: after a normal quit, after an error and after a panic Bubble Tea recovers. zn has no recover of its own, so the reset runs after the program returns, on every path. There is no setting; a default configuration gets the bar. Tests: the output after `:q`, after the context is cancelled and after a panic ends with the default-shape reset following the last bar, and an empty config.toml gives the bar in insert mode. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 20 +++- internal/tui/cursor_restore_test.go | 141 ++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 internal/tui/cursor_restore_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 96ed938..9a45321 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "errors" "fmt" + "io" "os" "os/exec" "runtime" @@ -149,6 +150,9 @@ type App struct { // cursor is where the terminal cursor belongs in the last frame drawn. cursor cursorState + // cursorOut shapes the terminal cursor; nil when the app runs without a + // terminal, as in tests. + cursorOut *cursorOut // cmdlineX is the column of the next typed character on the command // line, set by renderCommandLine while a command line is open. cmdlineX int @@ -196,9 +200,20 @@ func Run(ctx context.Context, opts Options) error { if prefs.TerminalMouse { teaOpts = append(teaOpts, tea.WithMouseCellMotion()) } + return runProgram(app, os.Stdout, os.Stdin, teaOpts...) +} + +// runProgram runs app on out and in with the cursor wrapper between Bubble Tea +// and the terminal. Bubble Tea recovers panics and p.Run returns an error, so +// the cursor is restored on every path after p.Run returns. +func runProgram(app *App, out cursorFile, in io.Reader, teaOpts ...tea.ProgramOption) error { + wrapper := newCursorOut(out) + app.cursorOut = wrapper + defer wrapper.Reset() + teaOpts = append([]tea.ProgramOption{tea.WithOutput(wrapper), tea.WithInput(in)}, teaOpts...) p := tea.NewProgram(app, teaOpts...) app.program = p - _, err = p.Run() + _, err := p.Run() saveErr := app.shutdown() if err == nil { err = saveErr @@ -722,6 +737,9 @@ func (a *App) View() string { screen = a.renderToasts(screen) } a.cursor = a.computeCursor() + if a.cursorOut != nil { + a.cursorOut.Set(a.cursor) + } return a.theme.paint(screen) } diff --git a/internal/tui/cursor_restore_test.go b/internal/tui/cursor_restore_test.go new file mode 100644 index 0000000..a383f66 --- /dev/null +++ b/internal/tui/cursor_restore_test.go @@ -0,0 +1,141 @@ +package tui + +import ( + "context" + "io" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vim" +) + +// restoreRun runs app through runProgram on a fake terminal and a pipe for +// input. feed receives the pipe's writer once the first frame is out. +type restoreRun struct { + term *fakeTerm + in *io.PipeWriter + done chan error +} + +func startRestoreRun(t *testing.T, app *App, opts ...tea.ProgramOption) *restoreRun { + t.Helper() + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close() }) + r := &restoreRun{term: &fakeTerm{}, in: pw, done: make(chan error, 1)} + opts = append([]tea.ProgramOption{tea.WithoutSignals()}, opts...) + go func() { r.done <- runProgram(app, r.term, pr, opts...) }() + waitFor(t, func() bool { return strings.Contains(r.term.all(), "\x1b[") && app.program != nil }) + return r +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("timed out waiting") + } + time.Sleep(10 * time.Millisecond) + } +} + +func (r *restoreRun) wait(t *testing.T) error { + t.Helper() + select { + case err := <-r.done: + return err + case <-time.After(10 * time.Second): + t.Fatal("program did not exit") + return nil + } +} + +func tail(s string) string { + if len(s) > 80 { + return s[len(s)-80:] + } + return s +} + +// assertRestored checks the output ends with the default shape and a shown +// cursor, after every earlier shape. +func assertRestored(t *testing.T, out string) { + t.Helper() + reset := strings.LastIndex(out, defaultShape) + if reset < 0 { + t.Fatalf("no CSI 0 SP q in output (tail %q)", tail(out)) + } + for _, shape := range []string{steadyBar, steadyUnderline} { + if i := strings.LastIndex(out, shape); i > reset { + t.Fatalf("shape %q written after the reset", shape) + } + } + if !strings.Contains(out[reset:], "\x1b[?25h") { + t.Fatalf("cursor not shown after the reset (tail %q)", tail(out)) + } +} + +func TestCursorRestoreQuit(t *testing.T) { + a, _ := cursorApp(t, "hello", 80) + r := startRestoreRun(t, a) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) + for _, k := range []tea.KeyMsg{ + {Type: tea.KeyEsc}, + {Type: tea.KeyRunes, Runes: []rune(":")}, + {Type: tea.KeyRunes, Runes: []rune("q")}, + {Type: tea.KeyEnter}, + } { + a.program.Send(k) + } + if err := r.wait(t); err != nil { + t.Fatal(err) + } + assertRestored(t, r.term.all()) +} + +func TestCursorRestoreError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + a, _ := cursorApp(t, "hello", 80) + a.ctx = ctx + r := startRestoreRun(t, a, tea.WithContext(ctx)) + cancel() + if err := r.wait(t); err == nil { + t.Fatal("expected an error from the cancelled run") + } + assertRestored(t, r.term.all()) +} + +type panicOverlay struct{} + +func (panicOverlay) handleKey(*App, vim.Key) { panic("boom") } +func (panicOverlay) render(*App, int, int) string { return "" } + +func TestCursorRestorePanic(t *testing.T) { + a, _ := cursorApp(t, "hello", 80) + a.overlay = panicOverlay{} + r := startRestoreRun(t, a) + a.program.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if err := r.wait(t); err == nil { + t.Fatal("expected an error from the recovered panic") + } + assertRestored(t, r.term.all()) +} + +func TestCursorRestoreDefaultPrefsGiveBar(t *testing.T) { + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + prefs, _, err := config.LoadPrefs() + if err != nil { + t.Fatal(err) + } + a, buf := cursorApp(t, "hello", 40) + a.rawPref, a.prefs = prefs, prefsView{Prefs: prefs, TabsOff: !prefs.TabsEnabled} + buf.ed.Feed("i") + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("insert mode with default prefs: cursor = %+v", a.cursor) + } +} From 9077380ad87d0f18c6d90d5dfacbc68b3c751174 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:02 -0500 Subject: [PATCH 11/12] Feat(tui): reset the cursor shape around suspend and external programs A shell or editor started from zn should see the terminal's own cursor, not zn's bar. Before Ctrl+Z, `:suspend`, the external editor and opening the config file, zn resets the cursor to the terminal default and stops writing cursor sequences. On resume, or when the external program returns, it takes the cursor back and writes the current one, which the next frame corrects if the mode changed. Tests: Ctrl+Z and `:suspend` write the reset and release the wrapper; resuming writes the bar at the insertion point; the external editor and config actions write the reset; returning in insert mode shows the bar and in normal mode hides it. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 5 ++ internal/tui/commands.go | 1 + internal/tui/cursor.go | 17 ++++ internal/tui/cursor_suspend_test.go | 135 ++++++++++++++++++++++++++++ internal/tui/editorext.go | 1 + internal/tui/input.go | 1 + internal/tui/settings_live.go | 1 + 7 files changed, 161 insertions(+) create mode 100644 internal/tui/cursor_suspend_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 9a45321..0c3bc21 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -336,7 +336,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return a, tea.Batch(configPollCmd(), a.flush()) + case tea.ResumeMsg: + a.resumeCursor() + return a, a.flush() case configEditedMsg: + a.resumeCursor() if m.err != nil { a.notifyError(m.err.Error()) } else if err := a.reloadPreferences(); err != nil { @@ -494,6 +498,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.invalidatePreviews() return a, a.flush() case editorDoneMsg: + a.resumeCursor() a.finishExternalEdit(m) return a, a.flush() case leaderHintMsg: diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 870cd38..947293d 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -635,6 +635,7 @@ func (a *App) commandTable() []command { return nil }}, {names: []string{"suspend", "stop"}, title: "Suspend to the shell (fg to return)", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + a.suspendCursor() a.queue(tea.Suspend) return nil }}, diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go index b00b12c..5951e98 100644 --- a/internal/tui/cursor.go +++ b/internal/tui/cursor.go @@ -101,3 +101,20 @@ func (a *App) computeCursor() cursorState { } return cursorState{visible: true, x: x, y: y, shape: shape} } + +// suspendCursor hands the terminal to something else (the shell or an +// external program): the cursor goes back to the terminal's default shape +// and the wrapper stops adding sequences until resumeCursor. +func (a *App) suspendCursor() { + if a.cursorOut != nil { + a.cursorOut.Reset() + a.cursorOut.Release() + } +} + +// resumeCursor takes the terminal back and writes the cursor again. +func (a *App) resumeCursor() { + if a.cursorOut != nil { + a.cursorOut.Hold() + } +} diff --git a/internal/tui/cursor_suspend_test.go b/internal/tui/cursor_suspend_test.go new file mode 100644 index 0000000..ecc211b --- /dev/null +++ b/internal/tui/cursor_suspend_test.go @@ -0,0 +1,135 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/vim" +) + +// suspendApp is an insert-mode app whose wrapper sits over a fake terminal, +// with the bar already written at the insertion point. +func suspendApp(t *testing.T) (*App, *noteBuffer, *fakeTerm) { + t.Helper() + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 3}) + term := &fakeTerm{} + a.cursorOut = newCursorOut(term) + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + term.clear() + return a, buf, term +} + +func cursorReleased(a *App) bool { + a.cursorOut.mu.Lock() + defer a.cursorOut.mu.Unlock() + return a.cursorOut.released +} + +func assertReleasedWithReset(t *testing.T, a *App, term *fakeTerm, what string) { + t.Helper() + if !strings.Contains(term.all(), defaultShape) { + t.Fatalf("%s: wrote %q, want %q", what, term.all(), defaultShape) + } + if !cursorReleased(a) { + t.Fatalf("%s: the wrapper is not released", what) + } +} + +func TestCursorSuspendCtrlZ(t *testing.T) { + a, _, term := suspendApp(t) + a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) + assertReleasedWithReset(t, a, term, "Ctrl+Z") +} + +func TestCursorSuspendExCommand(t *testing.T) { + a, buf, term := suspendApp(t) + if _, err := a.runEx(buf, vim.ExCommand{Name: "suspend"}); err != nil { + t.Fatal(err) + } + assertReleasedWithReset(t, a, term, ":suspend") +} + +func TestCursorSuspendResumeWritesBar(t *testing.T) { + a, _, term := suspendApp(t) + a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) + term.clear() + a.Update(tea.ResumeMsg{}) + want := cursorState{visible: true, x: a.cursor.x, y: a.cursor.y, shape: cursorShapeBar}.sequence() + if !strings.HasSuffix(term.all(), want) { + t.Fatalf("after resume wrote %q, want it to end with %q", term.all(), want) + } + if cursorReleased(a) { + t.Fatal("the wrapper is still released after resume") + } +} + +func TestCursorSuspendExternalEditor(t *testing.T) { + t.Setenv("EDITOR", "true") + t.Setenv("VISUAL", "") + a, _, term := suspendApp(t) + a.editActiveInEditor() + assertReleasedWithReset(t, a, term, "the external editor") +} + +func TestCursorSuspendOpenConfig(t *testing.T) { + t.Setenv("EDITOR", "true") + t.Setenv("VISUAL", "") + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + a, _, term := suspendApp(t) + if err := a.editConfig(); err != nil { + t.Fatal(err) + } + assertReleasedWithReset(t, a, term, "open config") +} + +func returnMsgs() map[string]tea.Msg { + return map[string]tea.Msg{ + "editor": editorDoneMsg{path: "Alpha.md"}, + "config": configEditedMsg{}, + } +} + +func TestCursorSuspendReturnInInsertShowsBar(t *testing.T) { + for name, msg := range returnMsgs() { + t.Run(name, func(t *testing.T) { + a, _, term := suspendApp(t) + a.suspendCursor() + term.clear() + a.Update(msg) + a.toasts = nil + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if want := a.cursor.sequence(); !strings.HasSuffix(term.all(), want) { + t.Fatalf("wrote %q, want it to end with %q", term.all(), want) + } + }) + } +} + +func TestCursorSuspendReturnInNormalHidesCursor(t *testing.T) { + for name, msg := range returnMsgs() { + t.Run(name, func(t *testing.T) { + a, buf, term := suspendApp(t) + a.suspendCursor() + buf.ed.Feed("") + term.clear() + a.Update(msg) + a.toasts = nil + a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v in normal mode, want hidden", a.cursor) + } + if !strings.HasSuffix(term.all(), "\x1b[?25l") { + t.Fatalf("wrote %q, want it to end with the hide sequence", term.all()) + } + }) + } +} diff --git a/internal/tui/editorext.go b/internal/tui/editorext.go index 40f440f..b751d0a 100644 --- a/internal/tui/editorext.go +++ b/internal/tui/editorext.go @@ -56,6 +56,7 @@ func (a *App) editActiveInEditor() { return } path := buf.path + a.suspendCursor() a.queue(tea.ExecProcess(cmd, func(err error) tea.Msg { return editorDoneMsg{path: path, tmp: tmp, err: err} })) diff --git a/internal/tui/input.go b/internal/tui/input.go index b07d287..21507c3 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -264,6 +264,7 @@ func (a *App) chordKey(k vim.Key, editing bool) bool { if editing && !a.prefs.VimMode { return false } + a.suspendCursor() a.queue(tea.Suspend) case a.tabSelectChord(k): case editing && a.prefs.VimMode: diff --git a/internal/tui/settings_live.go b/internal/tui/settings_live.go index 31a1857..e96a2f7 100644 --- a/internal/tui/settings_live.go +++ b/internal/tui/settings_live.go @@ -113,6 +113,7 @@ func (a *App) editConfig() error { if err != nil { return err } + a.suspendCursor() a.queue(tea.ExecProcess(cmd, func(err error) tea.Msg { return configEditedMsg{err} })) return nil } From 87a78386e3a0426d00f989b804b262a34d005aa6 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 08:39:03 -0500 Subject: [PATCH 12/12] Fix(tui): the field cursor sits on the field under a wrapping title The prompt, the palette and the picker assumed their title fits on one row and put the field's cursor on the second content row. A title wider than the box wraps, as the sidebar's "New note in " does for a deep folder on an 80-column terminal, and the bar then sat on the title's second line, one row above the typed text. They now measure the rendered title height, as quick capture already did. The restore tests now show the bar before the panic or the cancelled context, so they prove the reset follows a shown shape, and the default-config test builds the app the way zn does, from an empty config.toml. Tests, written red first: a prompt, palette and picker with a title wider than the field put the bar right after the last typed rune. Co-Authored-By: Claude Opus 5.5 --- internal/tui/cursor_field_test.go | 26 +++++++++++++++++++ internal/tui/cursor_restore_test.go | 40 ++++++++++++++++++++++++----- internal/tui/overlays.go | 11 +++++--- internal/tui/pick_overlay.go | 2 +- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/internal/tui/cursor_field_test.go b/internal/tui/cursor_field_test.go index 65654a9..1e4a6f6 100644 --- a/internal/tui/cursor_field_test.go +++ b/internal/tui/cursor_field_test.go @@ -63,6 +63,32 @@ func TestTextFieldCursorSecretPrompt(t *testing.T) { } } +const wrappingTitle = "New note in Projects/Work/Clients/Acme Corporation/Meetings" + +func TestTextFieldCursorWrappedTitle(t *testing.T) { + open := map[string]func(a *App){ + "prompt": func(a *App) { a.promptFor(wrappingTitle, "", "", func(*App, string) {}) }, + "palette": func(a *App) { + a.overlay = &palette{title: wrappingTitle, input: newTextInput("")} + }, + "picker": func(a *App) { + a.overlay = newPickOverlay(wrappingTitle, "", []pickItem{{id: "a", label: "a"}}, nil) + }, + } + for name, fn := range open { + t.Run(name, func(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + fn(a) + typeText(a, "ab") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "b" { + t.Fatalf("cell before the bar is %q, want the last typed rune", got) + } + }) + } +} + func TestTextFieldCursorClampedToFieldWidth(t *testing.T) { a, _ := cursorApp(t, "hello\n", 40) a.promptFor("Name", "", "", func(*App, string) {}) diff --git a/internal/tui/cursor_restore_test.go b/internal/tui/cursor_restore_test.go index a383f66..8e9d698 100644 --- a/internal/tui/cursor_restore_test.go +++ b/internal/tui/cursor_restore_test.go @@ -3,12 +3,15 @@ package tui import ( "context" "io" + "os" + "path/filepath" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/vim" ) @@ -102,6 +105,7 @@ func TestCursorRestoreError(t *testing.T) { a, _ := cursorApp(t, "hello", 80) a.ctx = ctx r := startRestoreRun(t, a, tea.WithContext(ctx)) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) cancel() if err := r.wait(t); err == nil { t.Fatal("expected an error from the cancelled run") @@ -114,10 +118,20 @@ type panicOverlay struct{} func (panicOverlay) handleKey(*App, vim.Key) { panic("boom") } func (panicOverlay) render(*App, int, int) string { return "" } +// inLoop is an exec command whose body runs on the program's event loop, so a +// test can change the app without racing the loop. +type inLoop func() + +func (f inLoop) Run() error { f(); return nil } +func (inLoop) SetStdin(io.Reader) {} +func (inLoop) SetStdout(io.Writer) {} +func (inLoop) SetStderr(io.Writer) {} + func TestCursorRestorePanic(t *testing.T) { a, _ := cursorApp(t, "hello", 80) - a.overlay = panicOverlay{} r := startRestoreRun(t, a) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) + a.program.Send(tea.Exec(inLoop(func() { a.overlay = panicOverlay{} }), nil)()) a.program.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) if err := r.wait(t); err == nil { t.Fatal("expected an error from the recovered panic") @@ -125,17 +139,29 @@ func TestCursorRestorePanic(t *testing.T) { assertRestored(t, r.term.all()) } -func TestCursorRestoreDefaultPrefsGiveBar(t *testing.T) { - t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) +func TestCursorDefaultConfigGivesBar(t *testing.T) { + root := newSessionParityVault(t, "Alpha.md") + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.toml"), nil, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("ZENNOTES_CONFIG_DIR", dir) prefs, _, err := config.LoadPrefs() if err != nil { t.Fatal(err) } - a, buf := cursorApp(t, "hello", 40) - a.rawPref, a.prefs = prefs, prefsView{Prefs: prefs, TabsOff: !prefs.TabsEnabled} - buf.ed.Feed("i") + target := backend.Target{Kind: backend.KindLocal, Root: root} + b, err := backend.New(target, backend.Options{}) + if err != nil { + t.Fatal(err) + } + a := newApp(context.Background(), Options{Backend: b, Target: target}, prefs, true) + a.openNote("Alpha.md", true) + a.sidebarOpen = false + a.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + a.activeBuffer().ed.Feed("i") a.View() if !a.cursor.visible || a.cursor.shape != cursorShapeBar { - t.Fatalf("insert mode with default prefs: cursor = %+v", a.cursor) + t.Fatalf("insert mode with an empty config: cursor = %+v", a.cursor) } } diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index 47be95b..4023c6c 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -121,6 +121,11 @@ func (a *App) overlayInset() (x, y int) { return s.GetBorderLeftSize() + s.GetPaddingLeft(), s.GetBorderTopSize() + s.GetPaddingTop() } +// titleRows is how many rows an overlay title takes once it wraps at inner cells. +func titleRows(title string, inner int) int { + return lipgloss.Height(lipgloss.NewStyle().Width(inner).Render(title)) +} + // fieldAt is the bar for a field whose first cell is at column col of the // given content row of an overlay. func (a *App) fieldAt(col, row int) cursorState { @@ -290,7 +295,7 @@ func (p *palette) render(a *App, w, h int) string { prompt := "› " fieldW := inner - lipgloss.Width(prompt) lines = append(lines, prompt+p.input.render(fieldW)) - p.cur = a.fieldAt(lipgloss.Width(prompt)+p.input.cell(fieldW), 1) + p.cur = a.fieldAt(lipgloss.Width(prompt)+p.input.cell(fieldW), titleRows(p.title, inner)) if p.input.String() == "" && p.placeholder != "" { lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } @@ -393,11 +398,11 @@ func (p *prompt) render(a *App, w, h int) string { width := min(w-4, max(40, w/2)) inner := width - 2 field := p.input.render(inner) - p.cur = a.fieldAt(p.input.cell(inner), 1) + p.cur = a.fieldAt(p.input.cell(inner), titleRows(p.title, inner)) if p.masked { dots := strings.Repeat("•", len(p.input.runes)) field = padRight(dots+" ", inner) - p.cur = a.fieldAt(min(len(p.input.runes), max(inner-1, 0)), 1) + p.cur = a.fieldAt(min(len(p.input.runes), max(inner-1, 0)), titleRows(p.title, inner)) } lines := []string{th.OverlayTitle.Render(p.title), field} if p.help != "" { diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index 0e36333..16291a9 100644 --- a/internal/tui/pick_overlay.go +++ b/internal/tui/pick_overlay.go @@ -195,7 +195,7 @@ func (p *pickOverlay) render(a *App, w, h int) string { prompt := "› " fieldW := inner - cellWidth(prompt) lines = append(lines, prompt+p.input.render(fieldW)) - p.cur = a.fieldAt(cellWidth(prompt)+p.input.cell(fieldW), 1) + p.cur = a.fieldAt(cellWidth(prompt)+p.input.cell(fieldW), titleRows(p.title, inner)) if p.input.String() == "" && p.placeholder != "" { lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) }