diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..0c3bc21 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" @@ -147,6 +148,16 @@ type App struct { // missing-TeX notice, so only a note's first LaTeX block carries it. latexNoticeShown bool + // cursor is where the terminal cursor belongs in the last frame drawn. + cursor cursorState + // cursorOut shapes the terminal cursor; nil when the app runs without a + // terminal, as in tests. + cursorOut *cursorOut + // cmdlineX is the column of the next typed character on the command + // line, set by renderCommandLine while a command line is open. + cmdlineX int + cmdlineOpen bool + systemDark bool exHistory []string mouse mouseState @@ -189,9 +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 @@ -314,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 { @@ -472,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: @@ -714,6 +741,10 @@ func (a *App) View() string { if len(a.toasts) > 0 { screen = a.renderToasts(screen) } + a.cursor = a.computeCursor() + if a.cursorOut != nil { + a.cursorOut.Set(a.cursor) + } return a.theme.paint(screen) } @@ -1010,6 +1041,7 @@ func (a *App) vaultLabel() string { func (a *App) renderCommandLine() string { t := a.theme + a.cmdlineOpen = false if buf := a.activeBuffer(); buf != nil && a.focus == focusPane { if kind, text, cursor, active := buf.ed.Cmdline(); active { runes := []rune(text) @@ -1018,7 +1050,8 @@ func (a *App) renderCommandLine() string { if cursor < len(runes) { after = string(runes[cursor:]) } - line := string(kind) + before + t.Cursor.Render(firstCellOr(after, " ")) + restAfterFirst(after) + a.cmdlineX, a.cmdlineOpen = min(cellWidth(string(kind)+before), max(0, a.width-1)), true + line := string(kind) + before + after if comps, idx := buf.ed.Completions(); len(comps) > 0 { line += " " + a.renderWildmenu(comps, idx) } diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index cbad444..1bf3985 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -30,6 +30,14 @@ type noteBuffer struct { afterLoad []func(*noteBuffer) loadErr error rows []rowInfo + // focusRows is the wrapping of the pane that last drew this buffer with + // focus; rows belongs to whichever pane drew it last. + focusRows []rowInfo + // focusW is the drawn width of that editor, gutter included. + focusW int + // standalone marks an editor drawn outside the tabs, such as quick + // capture's, whose cursor its host places. + standalone bool words int wordsVersion int diff --git a/internal/tui/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 new file mode 100644 index 0000000..5951e98 --- /dev/null +++ b/internal/tui/cursor.go @@ -0,0 +1,120 @@ +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 vim normal and visual mode, +// and while the note is still loading. It does not know about the command +// line; computeCursor handles that. +func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) { + if buf == nil || buf.loading { + return 0, false + } + if !a.prefs.VimMode { + return cursorShapeBar, true + } + switch buf.ed.Mode() { + case vim.ModeInsert: + return cursorShapeBar, true + case vim.ModeReplace: + return cursorShapeUnderline, true + } + if buf.ed.ReplacePending() { + return cursorShapeUnderline, true + } + return 0, false +} + +// terminalCursorOwns reports whether the terminal's own cursor stands in for +// the drawn block in buf. Only the active tab's buffer is placed that way; +// a standalone editor, such as quick capture's, is placed by its host. +func (a *App) terminalCursorOwns(buf *noteBuffer) bool { + if buf == nil || (!buf.standalone && buf != a.activeBuffer()) { + return false + } + _, ok := a.bufferCursorShape(buf) + return ok +} + +// insertionCell is the screen cell where the next typed character in buf +// lands: the pane's content origin, the gutter, and the width of the text +// before the cursor on its wrapped row, all taken from the focused pane's own +// layout. rc is the origin of the area buf was drawn in. +func (a *App) insertionCell(buf *noteBuffer, rc rect) (x, y int, ok bool) { + cur := buf.ed.Cursor() + line := buf.ed.LineRunes(cur.Line) + for _, row := range buf.focusRows { + if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd { + continue + } + if cur.Col == row.segEnd && row.segEnd < len(line) { + continue // the cursor starts the next wrapped row + } + x = rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col])) + if buf.focusW > 0 { + x = min(x, rc.x+buf.focusW-1) // keep the bar inside the editor + } + return x, rc.y + row.y, true + } + return 0, 0, false +} + +// computeCursor picks the cursor for the frame just drawn. +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 { + return cursorState{} + } + c := f.fieldCursor() + if c.visible { + c.x += a.overlayRect.x + c.y += a.overlayRect.y + } + return c + } + if a.focus != focusPane { + return cursorState{} + } + if a.cmdlineOpen { + return cursorState{visible: true, x: a.cmdlineX, y: a.height - 1, shape: cursorShapeBar} + } + t := a.activeTab() + if t == nil || t.view != nil || t.mode == modePreview { + return cursorState{} + } + buf := a.activeBuffer() + shape, ok := a.bufferCursorShape(buf) + if !ok { + return cursorState{} + } + x, y, ok := a.insertionCell(buf, a.contentRect(a.activePane)) + if !ok { + return cursorState{} + } + return cursorState{visible: true, x: x, y: y, shape: shape} +} + +// suspendCursor hands the terminal to something else (the shell or an +// external program): the cursor goes back to the terminal's default shape +// and the wrapper stops adding sequences until resumeCursor. +func (a *App) suspendCursor() { + if a.cursorOut != nil { + a.cursorOut.Reset() + a.cursorOut.Release() + } +} + +// resumeCursor takes the terminal back and writes the cursor again. +func (a *App) resumeCursor() { + if a.cursorOut != nil { + a.cursorOut.Hold() + } +} diff --git a/internal/tui/cursor_cmdline_test.go b/internal/tui/cursor_cmdline_test.go new file mode 100644 index 0000000..afd8c0b --- /dev/null +++ b/internal/tui/cursor_cmdline_test.go @@ -0,0 +1,88 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// lastRow is the screen row of the command line. +func lastRow(a *App) int { return a.height - 1 } + +func TestCmdlineCursorAfterText(t *testing.T) { + for _, kind := range []string{":", "/", "?"} { + for _, text := range []string{"", "wq"} { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("" + kind + text) + view := a.View() + want := 1 + len(text) + if !a.cursor.visible || a.cursor.shape != cursorShapeBar || + a.cursor.x != want || a.cursor.y != lastRow(a) { + t.Fatalf("%q %q: cursor = %+v, want a bar at (%d,%d)", kind, text, a.cursor, want, lastRow(a)) + } + for _, c := range reverseCells(view) { + if c.y == lastRow(a) { + t.Fatalf("%q %q: reverse cell in the command line at %v", kind, text, c) + } + } + if got := ansi.Strip(strings.Split(view, "\n")[lastRow(a)]); !strings.HasPrefix(got, kind+text) { + t.Fatalf("command line = %q", got) + } + } + } +} + +func TestCmdlineCursorMovedLeft(t *testing.T) { + cases := []struct { + name string + keys string + x int + on string + }{ + {"left once", "", 1 + 2 + 2, "c"}, + {"left twice, wide rune", "", 1 + 2, "日"}, + {"ctrl+b to the start", "", 1, "a"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed(":ab日c" + c.keys) + view := a.View() + if !a.cursor.visible || a.cursor.x != c.x || a.cursor.y != lastRow(a) { + t.Fatalf("cursor = %+v, want x %d on the last row", a.cursor, c.x) + } + if got := cellText2(view, a.cursor.x, a.cursor.y, cellWidth(c.on)); got != c.on { + t.Fatalf("bar on %q, want %q", got, c.on) + } + for _, cell := range reverseCells(view) { + if cell.y == lastRow(a) { + t.Fatalf("reverse cell in the command line at %v", cell) + } + } + }) + } +} + +func TestCmdlineCursorClosed(t *testing.T) { + for _, key := range []string{"", ""} { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed(":" + key) + view := a.View() + if a.cursor.visible { + t.Fatalf("%s: cursor = %+v, want hidden", key, a.cursor) + } + if cells := reverseCells(view); len(cells) == 0 { + t.Fatalf("%s: no block cursor drawn in the buffer", key) + } + } +} + +func TestCmdlineCursorWiderThanTerminal(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 40) + buf.ed.Feed(":" + strings.Repeat("a", 100)) + a.View() + if a.cursor.visible && (a.cursor.x < 0 || a.cursor.x >= a.width) { + t.Fatalf("cursor = %+v, want a column inside the %d-wide terminal", a.cursor, a.width) + } +} diff --git a/internal/tui/cursor_covered_test.go b/internal/tui/cursor_covered_test.go new file mode 100644 index 0000000..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, " ") + }) +} diff --git a/internal/tui/cursor_field_test.go b/internal/tui/cursor_field_test.go new file mode 100644 index 0000000..1e4a6f6 --- /dev/null +++ b/internal/tui/cursor_field_test.go @@ -0,0 +1,194 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +// assertFieldBar checks the cursor is a visible bar on the cell where the +// view draws want, and that no reverse-video cell sits on its row. +func assertFieldBar(t *testing.T, a *App, view, want string) { + t.Helper() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != want { + t.Fatalf("bar sits on %q, want the cell of %q", got, want) + } + for _, c := range reverseCells(view) { + if c.y == a.cursor.y { + t.Fatalf("reverse-video cell drawn in the field at %v", c) + } + } +} + +func TestTextFieldCursorPalette(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openNoteSearch("abc") + view := a.View() + assertFieldBar(t, a, view, " ") + if cellText(view, a.cursor.x-1, a.cursor.y) != "c" { + t.Fatal("bar is not right after the typed text") + } + a.overlay.handleKey(a, vim.Key{Name: "left"}) + assertFieldBar(t, a, a.View(), "c") +} + +func TestTextFieldCursorPlaceholder(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openNoteSearch("") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x+1, a.cursor.y); got != "T" { + t.Fatalf("placeholder starts at %q, want T", got) + } + a.overlay = newPickOverlay("Tags", "Filter options", []pickItem{{id: "a", label: "a"}}, nil) + view = a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x+1, a.cursor.y); got != "F" { + t.Fatalf("picker placeholder starts at %q, want F", got) + } +} + +func TestTextFieldCursorSecretPrompt(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.promptSecret("Token", "", func(*App, string) {}) + typeText(a, "abc") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "•" { + t.Fatalf("cell before the bar is %q, want the third dot", got) + } +} + +const wrappingTitle = "New note in Projects/Work/Clients/Acme Corporation/Meetings" + +func TestTextFieldCursorWrappedTitle(t *testing.T) { + open := map[string]func(a *App){ + "prompt": func(a *App) { a.promptFor(wrappingTitle, "", "", func(*App, string) {}) }, + "palette": func(a *App) { + a.overlay = &palette{title: wrappingTitle, input: newTextInput("")} + }, + "picker": func(a *App) { + a.overlay = newPickOverlay(wrappingTitle, "", []pickItem{{id: "a", label: "a"}}, nil) + }, + } + for name, fn := range open { + t.Run(name, func(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + fn(a) + typeText(a, "ab") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "b" { + t.Fatalf("cell before the bar is %q, want the last typed rune", got) + } + }) + } +} + +func TestTextFieldCursorClampedToFieldWidth(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 40) + a.promptFor("Name", "", "", func(*App, string) {}) + typeText(a, "this text is a lot longer than the prompt is wide") + a.View() + box := a.overlayRect + if !a.cursor.visible || a.cursor.x >= box.x+box.w || a.cursor.x <= box.x { + t.Fatalf("cursor = %+v escapes the overlay %+v", a.cursor, box) + } +} + +func TestTextFieldCursorPromptBeatsInsertionPoint(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + a.View() + insertion := a.cursor + a.promptFor("Name", "ab", "", func(*App, string) {}) + view := a.View() + assertFieldBar(t, a, view, " ") + if a.cursor == insertion { + t.Fatalf("bar stayed at the insertion point %+v", insertion) + } +} + +func TestTextFieldCursorEscapeBackToNormalBuffer(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("") + a.promptFor("Name", "ab", "", func(*App, string) {}) + a.View() + if !a.cursor.visible { + t.Fatal("no bar in the prompt") + } + a.overlay.handleKey(a, vim.Key{Name: "esc"}) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("the buffer's block is not drawn") + } +} + +func TestTextFieldCursorCellAndTaskPrompts(t *testing.T) { + prompts := map[string]func(a *App){ + "date": func(a *App) { a.datePrompt("Due", "2026-11-01", func(*App, string) {}) }, + "number": func(a *App) { a.numberPrompt("Count", "42", func(*App, string) {}) }, + "text": func(a *App) { a.textPrompt("Notes", "abc", "[[ links a note", func(*App, string) {}) }, + "due": func(a *App) { a.taskDuePrompt(vault.Task{Due: "2026-11-01"}) }, + "field": func(a *App) { a.taskFieldPrompt(vault.Task{}) }, + } + for name, open := range prompts { + t.Run(name, func(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + open(a) + p := a.overlay.(*prompt) + view := a.View() + assertFieldBar(t, a, view, " ") + if p.input.cursor > 0 { + want := string(p.input.runes[p.input.cursor-1]) + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != want { + t.Fatalf("cell before the bar is %q, want %q", got, want) + } + } + }) + } +} + +func TestTextFieldCursorExPrompt(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.overlay = &exPrompt{input: newTextInput("set")} + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "t" { + t.Fatalf("cell before the bar is %q, want t", got) + } +} + +func TestTextFieldCursorQuickCapture(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.openQuickCapture() + typeText(a, "ab") + view := a.View() + assertFieldBar(t, a, view, " ") + if got := cellText(view, a.cursor.x-1, a.cursor.y); got != "b" { + t.Fatalf("cell before the bar is %q, want b", got) + } + a.overlay.handleKey(a, vim.Key{Name: "esc"}) + view = a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v in normal mode, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("capture's block is not drawn in normal mode") + } +} + +func TestTextFieldCursorQuickCaptureVimOff(t *testing.T) { + a, _ := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = false + a.openQuickCapture() + typeText(a, "ab") + assertFieldBar(t, a, a.View(), " ") +} diff --git a/internal/tui/cursor_replace_test.go b/internal/tui/cursor_replace_test.go new file mode 100644 index 0000000..0df9433 --- /dev/null +++ b/internal/tui/cursor_replace_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/vim" +) + +// While a normal-mode r waits for its character the cursor is an underline at +// the cursor cell and no block is drawn. +func TestReplaceCharCursorUnderlineWhileWaiting(t *testing.T) { + for _, keys := range []string{"r", "3r"} { + t.Run("keys "+keys, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(keys) + if buf.ed.Mode() != vim.ModeNormal { + t.Fatalf("mode = %v, want normal", buf.ed.Mode()) + } + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeUnderline { + t.Fatalf("cursor = %+v, want a visible underline", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "o" { + t.Fatalf("underline on %q, want the cursor character", got) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn while r waits: %v", cells) + } + }) + } +} + +// The character, or Esc, ends the wait: the cursor hides and the block returns. +func TestReplaceCharCursorEndsWithCharOrEsc(t *testing.T) { + for _, tc := range []struct{ name, keys string }{ + {"character", "rx"}, + {"escape", "r"}, + {"counted character", "2rx"}, + } { + t.Run(tc.name, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(tc.keys) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if cells := reverseCells(view); len(cells) != 1 { + t.Fatalf("block cells = %v, want exactly one", cells) + } + }) + } +} + +// r typed in visual mode keeps the block, as every visual-mode key does. +func TestReplaceCharCursorVisualKeepsTheBlock(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("v") + buf.ed.Feed("r") + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + if len(reverseCells(view)) == 0 { + t.Fatal("no block drawn in visual mode") + } +} diff --git a/internal/tui/cursor_restore_test.go b/internal/tui/cursor_restore_test.go new file mode 100644 index 0000000..8e9d698 --- /dev/null +++ b/internal/tui/cursor_restore_test.go @@ -0,0 +1,167 @@ +package tui + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vim" +) + +// restoreRun runs app through runProgram on a fake terminal and a pipe for +// input. feed receives the pipe's writer once the first frame is out. +type restoreRun struct { + term *fakeTerm + in *io.PipeWriter + done chan error +} + +func startRestoreRun(t *testing.T, app *App, opts ...tea.ProgramOption) *restoreRun { + t.Helper() + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close() }) + r := &restoreRun{term: &fakeTerm{}, in: pw, done: make(chan error, 1)} + opts = append([]tea.ProgramOption{tea.WithoutSignals()}, opts...) + go func() { r.done <- runProgram(app, r.term, pr, opts...) }() + waitFor(t, func() bool { return strings.Contains(r.term.all(), "\x1b[") && app.program != nil }) + return r +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("timed out waiting") + } + time.Sleep(10 * time.Millisecond) + } +} + +func (r *restoreRun) wait(t *testing.T) error { + t.Helper() + select { + case err := <-r.done: + return err + case <-time.After(10 * time.Second): + t.Fatal("program did not exit") + return nil + } +} + +func tail(s string) string { + if len(s) > 80 { + return s[len(s)-80:] + } + return s +} + +// assertRestored checks the output ends with the default shape and a shown +// cursor, after every earlier shape. +func assertRestored(t *testing.T, out string) { + t.Helper() + reset := strings.LastIndex(out, defaultShape) + if reset < 0 { + t.Fatalf("no CSI 0 SP q in output (tail %q)", tail(out)) + } + for _, shape := range []string{steadyBar, steadyUnderline} { + if i := strings.LastIndex(out, shape); i > reset { + t.Fatalf("shape %q written after the reset", shape) + } + } + if !strings.Contains(out[reset:], "\x1b[?25h") { + t.Fatalf("cursor not shown after the reset (tail %q)", tail(out)) + } +} + +func TestCursorRestoreQuit(t *testing.T) { + a, _ := cursorApp(t, "hello", 80) + r := startRestoreRun(t, a) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) + for _, k := range []tea.KeyMsg{ + {Type: tea.KeyEsc}, + {Type: tea.KeyRunes, Runes: []rune(":")}, + {Type: tea.KeyRunes, Runes: []rune("q")}, + {Type: tea.KeyEnter}, + } { + a.program.Send(k) + } + if err := r.wait(t); err != nil { + t.Fatal(err) + } + assertRestored(t, r.term.all()) +} + +func TestCursorRestoreError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + a, _ := cursorApp(t, "hello", 80) + a.ctx = ctx + r := startRestoreRun(t, a, tea.WithContext(ctx)) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) + cancel() + if err := r.wait(t); err == nil { + t.Fatal("expected an error from the cancelled run") + } + assertRestored(t, r.term.all()) +} + +type panicOverlay struct{} + +func (panicOverlay) handleKey(*App, vim.Key) { panic("boom") } +func (panicOverlay) render(*App, int, int) string { return "" } + +// inLoop is an exec command whose body runs on the program's event loop, so a +// test can change the app without racing the loop. +type inLoop func() + +func (f inLoop) Run() error { f(); return nil } +func (inLoop) SetStdin(io.Reader) {} +func (inLoop) SetStdout(io.Writer) {} +func (inLoop) SetStderr(io.Writer) {} + +func TestCursorRestorePanic(t *testing.T) { + a, _ := cursorApp(t, "hello", 80) + r := startRestoreRun(t, a) + waitFor(t, func() bool { return strings.Contains(r.term.all(), steadyBar) }) + a.program.Send(tea.Exec(inLoop(func() { a.overlay = panicOverlay{} }), nil)()) + a.program.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if err := r.wait(t); err == nil { + t.Fatal("expected an error from the recovered panic") + } + assertRestored(t, r.term.all()) +} + +func TestCursorDefaultConfigGivesBar(t *testing.T) { + root := newSessionParityVault(t, "Alpha.md") + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.toml"), nil, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("ZENNOTES_CONFIG_DIR", dir) + prefs, _, err := config.LoadPrefs() + if err != nil { + t.Fatal(err) + } + target := backend.Target{Kind: backend.KindLocal, Root: root} + b, err := backend.New(target, backend.Options{}) + if err != nil { + t.Fatal(err) + } + a := newApp(context.Background(), Options{Backend: b, Target: target}, prefs, true) + a.openNote("Alpha.md", true) + a.sidebarOpen = false + a.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + a.activeBuffer().ed.Feed("i") + a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("insert mode with an empty config: cursor = %+v", a.cursor) + } +} diff --git a/internal/tui/cursor_suspend_test.go b/internal/tui/cursor_suspend_test.go new file mode 100644 index 0000000..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/cursor_test.go b/internal/tui/cursor_test.go new file mode 100644 index 0000000..9d27388 --- /dev/null +++ b/internal/tui/cursor_test.go @@ -0,0 +1,473 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/cellbuf" + "github.com/muesli/termenv" + + "github.com/ZenNotes/tui/internal/vim" +) + +// cursorApp opens one note holding text in a single pane, with true colour so +// the drawn block cursor can be told apart from other cells. +func cursorApp(t *testing.T, text string, width int) (*App, *noteBuffer) { + t.Helper() + profile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) + a := newSessionParityApp(t, newSessionParityVault(t, "Alpha.md")) + a.openNote("Alpha.md", true) + a.sidebarOpen = false + a.Update(tea.WindowSizeMsg{Width: width, Height: 24}) + buf := a.activeBuffer() + if buf == nil { + t.Fatal("no active buffer") + } + buf.ed.SetText(text) + buf.ed.Feed("i") + return a, buf +} + +type screenCell struct{ x, y int } + +// reverseCells lists the cells the view draws in reverse video. +func reverseCells(view string) []screenCell { + var out []screenCell + for y, row := range strings.Split(view, "\n") { + var style cellbuf.Style + parser := ansi.NewParser() + state, x := byte(0), 0 + for len(row) > 0 { + seq, width, n, next := ansi.DecodeSequence(row, state, parser) + if ansi.HasCsiPrefix(seq) && parser.Command() == 'm' { + cellbuf.ReadStyle(parser.Params(), &style) + } + if width > 0 && style.Attrs&cellbuf.ReverseAttr != 0 { + out = append(out, screenCell{x, y}) + } + x += width + row, state = row[n:], next + } + } + return out +} + +// cellText is the text the view draws in the cell at (x, y). +func cellText(view string, x, y int) string { + rows := strings.Split(ansi.Strip(view), "\n") + return ansi.Cut(rows[y], x, x+1) +} + +// cellText2 is the text of the n cells starting at (x, y), for wide runes. +func cellText2(view string, x, y, n int) string { + rows := strings.Split(ansi.Strip(view), "\n") + return ansi.Cut(rows[y], x, x+n) +} + +func TestInsertCursorBarInInsertMode(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "o" { + t.Fatalf("bar sits on %q, want the cell of %q", got, "o") + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn in insert mode: %v", cells) + } +} + +func TestInsertCursorWrappedLine(t *testing.T) { + text := strings.Repeat("abcdefghij ", 12) + "\n" + a, buf := cursorApp(t, text, 40) + a.View() + if len(buf.rows) < 3 { + t.Fatalf("line did not wrap: %d rows", len(buf.rows)) + } + second := buf.rows[1] + col := second.segStart + 3 + buf.ed.SetCursor(vim.Pos{Line: 0, Col: col}) + view := a.View() + rc := a.contentRect(a.activePane) + if a.cursor.y != rc.y+second.y { + t.Fatalf("bar row = %d, want the second wrapped row %d", a.cursor.y, rc.y+second.y) + } + if want := string([]rune(text)[col]); cellText(view, a.cursor.x, a.cursor.y) != want { + t.Fatalf("bar sits on %q, want %q", cellText(view, a.cursor.x, a.cursor.y), want) + } +} + +func TestInsertCursorWideRune(t *testing.T) { + a, buf := cursorApp(t, "ab日本cd\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 3}) + view := a.View() + if got := cellText2(view, a.cursor.x, a.cursor.y, 2); got != "本" { + t.Fatalf("bar sits on %q, want the first cell of 本", got) + } + if prev := cellText2(view, a.cursor.x-2, a.cursor.y, 2); prev != "日" { + t.Fatalf("two cells left of the bar is %q, want 日", prev) + } +} + +func TestInsertCursorTab(t *testing.T) { + a, buf := cursorApp(t, "a\tb\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 1}) + a.View() + start := a.cursor.x + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + view := a.View() + if a.cursor.x-start != 4 { + t.Fatalf("tab is %d cells wide, want 4", a.cursor.x-start) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "b" { + t.Fatalf("bar after the tab sits on %q, want b", got) + } + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + a.View() + if start-a.cursor.x != 1 { + t.Fatalf("a is %d cells wide, want 1", start-a.cursor.x) + } +} + +func TestInsertCursorPastEndOfLine(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + a.View() + last := a.cursor + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 5}) + view := a.View() + if !a.cursor.visible || a.cursor.x != last.x+1 || a.cursor.y != last.y { + t.Fatalf("cursor = %+v, want the cell after the last character %+v", a.cursor, last) + } + if got := cellText(view, last.x, last.y); got != "o" { + t.Fatalf("last character cell shows %q", got) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn past the end of the line: %v", cells) + } +} + +func TestInsertCursorReplaceModeIsUnderline(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.Feed("R") + if buf.ed.Mode() != vim.ModeReplace { + t.Fatalf("mode = %v, want replace", buf.ed.Mode()) + } + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeUnderline { + t.Fatalf("cursor = %+v, want a visible underline", a.cursor) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn in replace mode: %v", cells) + } +} + +func TestInsertCursorBarWithVimModeOff(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = false + buf.ed.SetOptions(a.editorOptions()) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + view := a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("cursor = %+v, want a visible bar", a.cursor) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("reverse-video cells drawn with vim mode off: %v", cells) + } +} + +// Normal and visual mode keep the drawn block and show no terminal cursor. +func TestInsertCursorNormalAndVisualKeepTheBlock(t *testing.T) { + for _, keys := range []string{"", "v", "V", ""} { + t.Run("keys "+keys, func(t *testing.T) { + a, buf := cursorApp(t, "hello world\n", 80) + buf.ed.Feed("") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 4}) + buf.ed.Feed(keys) + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v, want hidden", a.cursor) + } + cells := reverseCells(view) + found := false + for _, c := range cells { + if cellText(view, c.x, c.y) == "o" { + found = true + } + } + if !found { + t.Fatalf("no reverse-video cell on the cursor character; reverse cells: %v", cells) + } + // The block is the character rendered with the cursor style (plus + // the selection background inside a visual range), unchanged. + want := a.styleFor(styleKey{cursor: true, visual: keys != ""}).Render("o") + if !strings.Contains(view, want) { + t.Fatalf("block cell is not drawn as %q", want) + } + if keys == "" && len(cells) != 1 { + t.Fatalf("normal mode draws %d reverse cells, want 1", len(cells)) + } + }) + } +} + +func TestInsertCursorEscRestoresTheBlock(t *testing.T) { + a, buf := cursorApp(t, "hello\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) + a.View() + if !a.cursor.visible { + t.Fatal("no bar in insert mode") + } + buf.ed.Feed("") + view := a.View() + if a.cursor.visible { + t.Fatalf("cursor = %+v after Esc, want hidden", a.cursor) + } + if len(reverseCells(view)) != 1 { + t.Fatalf("block cursor not drawn after Esc: %v", reverseCells(view)) + } +} + +func TestInsertCursorWaitsForTheNoteToLoad(t *testing.T) { + for _, vimMode := range []bool{true, false} { + a, buf := cursorApp(t, "hello\n", 80) + a.prefs.VimMode = vimMode + buf.ed.SetOptions(a.editorOptions()) + buf.loading = true + view := a.View() + if a.cursor.visible { + t.Fatalf("vim=%v: cursor = %+v while loading, want hidden", vimMode, a.cursor) + } + if len(reverseCells(view)) != 1 { + t.Fatalf("vim=%v: block not drawn while loading: %v", vimMode, reverseCells(view)) + } + buf.loading = false + view = a.View() + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("vim=%v: cursor = %+v once loaded, want a bar", vimMode, a.cursor) + } + if len(reverseCells(view)) != 0 { + t.Fatalf("vim=%v: block still drawn once loaded", vimMode) + } + } +} + +// assertBarOnChar fails unless the bar is visible and sits on the cell where +// the view draws the character at the buffer's cursor. +func assertBarOnChar(t *testing.T, name string, a *App, buf *noteBuffer, view string) { + t.Helper() + cur := buf.ed.Cursor() + want := string(buf.ed.LineRunes(cur.Line)[cur.Col]) + if !a.cursor.visible || a.cursor.shape != cursorShapeBar { + t.Fatalf("%s: cursor = %+v, want a visible bar", name, a.cursor) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != want { + t.Fatalf("%s: bar at (%d,%d) sits on %q, want %q", name, a.cursor.x, a.cursor.y, got, want) + } + if cells := reverseCells(view); len(cells) != 0 { + t.Fatalf("%s: reverse-video cells drawn: %v", name, cells) + } +} + +func TestInsertCursorLayoutFollowsEditing(t *testing.T) { + a, buf := cursorApp(t, "first line\nsecond line\nthird line\n", 80) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + a.View() + start := a.cursor + + a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("xyz")}) + view := a.View() + if a.cursor.x != start.x+3 || a.cursor.y != start.y { + t.Fatalf("after typing the bar is %+v, want 3 cells right of %+v", a.cursor, start) + } + if got := cellText(view, a.cursor.x, a.cursor.y); got != "f" { + t.Fatalf("after typing the bar sits on %q, want f", got) + } + + a.Update(tea.KeyMsg{Type: tea.KeyDown}) + a.View() + if a.cursor.y != start.y+1 { + t.Fatalf("after Down the bar row is %d, want %d", a.cursor.y, start.y+1) + } + a.Update(tea.KeyMsg{Type: tea.KeyRight}) + view = a.View() + assertBarOnChar(t, "after arrows", a, buf, view) + + a.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + view = a.View() + assertBarOnChar(t, "after a deletion", a, buf, view) + + target := screenCell{x: start.x + 5, y: start.y + 2} + a.Update(tea.MouseMsg{X: target.x, Y: target.y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + view = a.View() + if a.cursor.x != target.x || a.cursor.y != target.y { + t.Fatalf("after a click the bar is %+v, want %+v", a.cursor, target) + } + assertBarOnChar(t, "after a click", a, buf, view) +} + +func TestInsertCursorLayoutAcrossLayouts(t *testing.T) { + cases := []struct { + name string + setup func(a *App) + }{ + {"single pane", func(a *App) {}}, + {"vertical split", func(a *App) { a.splitPane(true) }}, + {"horizontal split", func(a *App) { a.splitPane(false) }}, + {"sidebar open", func(a *App) { a.sidebarOpen = true }}, + {"side panel open", func(a *App) { a.outlineOpen = true }}, + {"tabs hidden", func(a *App) { a.prefs.TabsOff = true }}, + {"zen mode", func(a *App) { a.zen = true }}, + {"split note mode", func(a *App) { a.setPaneMode(modeSplit) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, "alpha beta\nline with Qcursor here\nomega\n", 100) + c.setup(a) + a.layout() + buf.ed.SetCursor(vim.Pos{Line: 1, Col: 10}) + assertBarOnChar(t, c.name, a, buf, a.View()) + }) + } +} + +func TestInsertCursorLayoutSecondPaneFocused(t *testing.T) { + for _, vertical := range []bool{true, false} { + a, buf := cursorApp(t, "alpha beta\nline with Qcursor here\nomega\n", 100) + first := a.activePane + a.splitPane(vertical) + if a.activePane == first { + t.Fatalf("vertical=%v: split did not move focus", vertical) + } + a.openNote("Alpha.md", true) + a.layout() + buf = a.activeBuffer() + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 1, Col: 10}) + assertBarOnChar(t, "second pane", a, buf, a.View()) + if rc := a.contentRect(a.activePane); a.cursor.x < rc.x || a.cursor.y < rc.y { + t.Fatalf("vertical=%v: bar %+v outside the focused pane %+v", vertical, a.cursor, rc) + } + } +} + +func TestInsertCursorLayoutFollowsResize(t *testing.T) { + text := strings.Repeat("abcdefghi ", 10) + "\n" + a, buf := cursorApp(t, text, 120) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 95}) + view := a.View() + assertBarOnChar(t, "wide", a, buf, view) + wide := a.cursor + + a.Update(tea.WindowSizeMsg{Width: 40, Height: 24}) + view = a.View() + if a.cursor == wide { + t.Fatalf("bar stayed at %+v after the line rewrapped", wide) + } + if a.cursor.y <= wide.y { + t.Fatalf("bar row %d, want below %d once the line rewraps", a.cursor.y, wide.y) + } + assertBarOnChar(t, "narrow", a, buf, view) +} + +// With the same note open in two panes of different sizes, the bar belongs to +// the focused pane's own wrapping whichever pane is painted first. +func TestInsertCursorLayoutFirstPaneFocusedUnequalPanes(t *testing.T) { + text := strings.Repeat("abcdefghi ", 20) + "\n" + for _, vertical := range []bool{true, false} { + for _, focusFirst := range []bool{true, false} { + a, _ := cursorApp(t, text, 100) + first := a.activePane + a.splitPane(vertical) + if a.activePane == first { + t.Fatalf("vertical=%v: split did not move focus", vertical) + } + a.openNote("Alpha.md", true) + a.resizePane(0.3) + a.layout() + if focusFirst { + a.activePane = first + } + buf := a.activeBuffer() + buf.ed.Feed("i") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 85}) + name := "first pane" + if !focusFirst { + name = "second pane" + } + assertBarOnChar(t, name, a, buf, a.View()) + } + } +} + +// The bar never leaves the editor's drawn text area: with word wrap off, a +// long line runs past the pane, and with it on, an exactly full row puts the +// end-of-line cell one past the text. +func TestInsertCursorLayoutStaysInsideEditor(t *testing.T) { + cases := []struct { + name string + wrap bool + setup func(a *App) + col int + }{ + {"wrap off, single pane", false, func(a *App) {}, 150}, + {"wrap off, vertical split", false, func(a *App) { a.splitPane(true) }, 150}, + {"wrap off, split note mode", false, func(a *App) { a.setPaneMode(modeSplit) }, 150}, + {"wrap off, side panel", false, func(a *App) { a.outlineOpen = true }, 150}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, buf := cursorApp(t, strings.Repeat("x", 200)+"\n", 100) + a.prefs.WordWrap = c.wrap + c.setup(a) + a.layout() + buf.ed.SetCursor(vim.Pos{Line: 0, Col: c.col}) + a.View() + assertCursorInEditor(t, a) + }) + } + t.Run("wrap on, exact width row", func(t *testing.T) { + for _, split := range []bool{false, true} { + a, buf := cursorApp(t, "x\n", 100) + a.prefs.WordWrap = true + if split { + a.setPaneMode(modeSplit) + } + a.layout() + a.View() + rc := a.contentRect(a.activePane) + w := rc.w + if split { + w /= 2 + } + textW := w - a.gutterWidth(buf) + buf.ed.SetText(strings.Repeat("x", textW) + "\n") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: textW}) + a.View() + assertCursorInEditor(t, a) + } + }) +} + +func assertCursorInEditor(t *testing.T, a *App) { + t.Helper() + if !a.cursor.visible { + return // hidden is acceptable + } + rc := a.contentRect(a.activePane) + w := rc.w + if t2 := a.activeTab(); t2 != nil && t2.mode == modeSplit { + w /= 2 + } + if a.cursor.x < rc.x || a.cursor.x >= rc.x+w { + t.Fatalf("bar x %d outside the editor columns [%d,%d)", a.cursor.x, rc.x, rc.x+w) + } +} diff --git a/internal/tui/cursorout.go b/internal/tui/cursorout.go new file mode 100644 index 0000000..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) + } +} 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/editorview.go b/internal/tui/editorview.go index e143cb1..fd92798 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -289,6 +289,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { lines := ed.Lines() blocks := computeBlockState(lines) cur := ed.Cursor() + drawBlock := focused && !a.terminalCursorOwns(buf) vStart, vEnd, vMode, inVisual := ed.VisualRange() blockLeft, blockRight, blockToEnd := 0, 0, false if inVisual && vMode == vim.ModeVisualBlock { @@ -382,7 +383,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { key.search = true } } - if focused && line == cur.Line && col == cur.Col { + if drawBlock && line == cur.Line && col == cur.Col { key.cursor = true } // Merge runs of identical style. @@ -403,7 +404,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { k2.search = true } } - if focused && line == cur.Line && j == cur.Col { + if drawBlock && line == cur.Line && j == cur.Col { k2.cursor = true } if k2 != key { @@ -422,7 +423,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { cells += cellWidth(string(run)) col = j } - if focused && line == cur.Line && cur.Col >= seg.end && si == len(segs)-1 { + if drawBlock && line == cur.Line && cur.Col >= seg.end && si == len(segs)-1 { b.WriteString(a.styleFor(styleKey{cursor: true}).Render(" ")) cells++ } @@ -443,6 +444,10 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { for len(out) < h { out = append(out, padRight(th.Muted.Render("~"), w)) } + if focused { + buf.focusRows = append(buf.focusRows[:0], buf.rows...) + buf.focusW = w + } return strings.Join(out, "\n") } @@ -508,13 +513,22 @@ func inVisualRange(line, col int, start, end vim.Pos, mode vim.Mode, bl, br int, } // renderPlainEditor draws a small editor (quick capture) without gutter. -func renderPlainEditor(a *App, ed *vim.Editor, w, h int, focused bool) string { - tmp := ¬eBuffer{ed: ed} +// It also returns the cursor it wants, as a cell inside the drawn block. +func renderPlainEditor(a *App, ed *vim.Editor, w, h int, focused bool) (string, cursorState) { + tmp := ¬eBuffer{ed: ed, standalone: true} saved := a.prefs.LineNumberMode a.prefs.LineNumberMode = "off" out := a.renderEditor(tmp, w, h, focused) a.prefs.LineNumberMode = saved - return out + shape, ok := a.bufferCursorShape(tmp) + if !ok { + return out, cursorState{} + } + x, y, ok := a.insertionCell(tmp, rect{}) + if !ok { + return out, cursorState{} + } + return out, cursorState{visible: true, x: x, y: y, shape: shape} } // editorHintTargets labels every link on the visible rows of a buffer. diff --git a/internal/tui/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/overlays.go b/internal/tui/overlays.go index a5f61fe..4023c6c 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -95,14 +95,42 @@ func (t *textInput) handle(k vim.Key) bool { return true } -func (t *textInput) render(th Theme, width int) string { - before := string(t.runes[:t.cursor]) - after := "" - if t.cursor < len(t.runes) { - after = string(t.runes[t.cursor:]) - } - line := before + th.Cursor.Render(firstCellOr(after, " ")) + restAfterFirst(after) - return padRight(line, width) +// render draws the text padded to width. The cursor is not drawn: the +// terminal's own cursor is placed at cell instead. +func (t *textInput) render(width int) string { + return padRight(string(t.runes), width) +} + +// cell is the column, within a field width cells wide, of the next typed +// character; a cursor past the field's right edge stays on its last cell. +func (t *textInput) cell(width int) int { + return min(cellWidth(string(t.runes[:t.cursor])), max(width-1, 0)) +} + +// cursorField is an overlay with its own text field. fieldCursor is the +// cursor the field wants, with its cell relative to the overlay box and +// taken from the last render; it is hidden when the field has none. +type cursorField interface { + fieldCursor() cursorState +} + +// overlayInset is the offset of an overlay's content inside its box: the +// border plus the padding. +func (a *App) overlayInset() (x, y int) { + s := a.theme.Overlay + return s.GetBorderLeftSize() + s.GetPaddingLeft(), s.GetBorderTopSize() + s.GetPaddingTop() +} + +// titleRows is how many rows an overlay title takes once it wraps at inner cells. +func titleRows(title string, inner int) int { + return lipgloss.Height(lipgloss.NewStyle().Width(inner).Render(title)) +} + +// fieldAt is the bar for a field whose first cell is at column col of the +// given content row of an overlay. +func (a *App) fieldAt(col, row int) cursorState { + ix, iy := a.overlayInset() + return cursorState{visible: true, x: ix + col, y: iy + row, shape: cursorShapeBar} } // --- palette --- @@ -122,6 +150,7 @@ type palette struct { title string placeholder string input textInput + cur cursorState items []paletteItem filtered []paletteItem cursor int @@ -249,6 +278,8 @@ func (p *palette) currentID() string { return p.filtered[p.cursor].id } +func (p *palette) fieldCursor() cursorState { return p.cur } + func (p *palette) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(40, w*2/3)) @@ -262,9 +293,11 @@ func (p *palette) render(a *App, w, h int) string { } lines := []string{th.OverlayTitle.Render(p.title)} prompt := "› " - lines = append(lines, prompt+p.input.render(th, inner-lipgloss.Width(prompt))) + fieldW := inner - lipgloss.Width(prompt) + lines = append(lines, prompt+p.input.render(fieldW)) + p.cur = a.fieldAt(lipgloss.Width(prompt)+p.input.cell(fieldW), titleRows(p.title, inner)) if p.input.String() == "" && p.placeholder != "" { - lines[len(lines)-1] = prompt + th.Cursor.Render(" ") + th.Muted.Render(padRight(p.placeholder, inner-3)) + lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } lines = append(lines, th.Muted.Render(strings.Repeat("─", inner))) if len(p.filtered) == 0 { @@ -330,8 +363,11 @@ type prompt struct { onLink func(a *App, p *prompt) // masked hides what is typed: tokens and other secrets. masked bool + cur cursorState } +func (p *prompt) fieldCursor() cursorState { return p.cur } + func (a *App) promptFor(title, initial, help string, onSubmit func(a *App, text string)) { a.overlay = &prompt{title: title, input: newTextInput(initial), help: help, onSubmit: onSubmit} } @@ -361,10 +397,12 @@ func (p *prompt) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(40, w/2)) inner := width - 2 - field := p.input.render(th, inner) + field := p.input.render(inner) + p.cur = a.fieldAt(p.input.cell(inner), titleRows(p.title, inner)) if p.masked { dots := strings.Repeat("•", len(p.input.runes)) - field = padRight(dots+th.Cursor.Render(" "), inner) + field = padRight(dots+" ", inner) + p.cur = a.fieldAt(min(len(p.input.runes), max(inner-1, 0)), titleRows(p.title, inner)) } lines := []string{th.OverlayTitle.Render(p.title), field} if p.help != "" { @@ -505,9 +543,12 @@ func (m *menu) render(a *App, w, h int) string { // --- quick capture --- type captureOverlay struct { - ed *vim.Editor + ed *vim.Editor + cur cursorState } +func (c *captureOverlay) fieldCursor() cursorState { return c.cur } + func (a *App) openQuickCapture() { opts := a.editorOptions() ed := vim.New("", opts, vim.Hooks{}) @@ -546,7 +587,13 @@ func (c *captureOverlay) render(a *App, w, h int) string { lines := []string{th.OverlayTitle.Render("Quick capture") + th.Muted.Render(" first line becomes the title · Ctrl+S save · Esc cancel")} c.ed.SetViewport(8) c.ed.EnsureCursorVisible() - body := renderPlainEditor(a, c.ed, inner, 8, true) + body, edCur := renderPlainEditor(a, c.ed, inner, 8, true) + c.cur = cursorState{} + if edCur.visible { + ix, iy := a.overlayInset() + titleRows := lipgloss.Height(lipgloss.NewStyle().Width(inner).Render(lines[0])) + c.cur = cursorState{visible: true, x: ix + edCur.x, y: iy + titleRows + edCur.y, shape: edCur.shape} + } lines = append(lines, strings.Split(body, "\n")...) mode := c.ed.Mode().Label() if !a.prefs.VimMode { @@ -767,8 +814,11 @@ type exPrompt struct { compBase string histIdx int saved string + cur cursorState } +func (p *exPrompt) fieldCursor() cursorState { return p.cur } + func (p *exPrompt) resetCompletion() { p.completions = nil p.compIdx = -1 @@ -866,7 +916,8 @@ func (p *exPrompt) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(50, w*2/3)) inner := width - 2 - lines := []string{th.OverlayTitle.Render(":") + " " + p.input.render(th, inner-2)} + p.cur = a.fieldAt(2+p.input.cell(inner-2), 0) + lines := []string{th.OverlayTitle.Render(":") + " " + p.input.render(inner-2)} if len(p.completions) > 1 { parts := []string{} shown := 0 diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index e30a188..16291a9 100644 --- a/internal/tui/pick_overlay.go +++ b/internal/tui/pick_overlay.go @@ -24,6 +24,7 @@ type pickOverlay struct { title string placeholder string input textInput + cur cursorState items []pickItem source func(a *App, query string) []pickItem checked map[string]bool @@ -177,6 +178,8 @@ func (p *pickOverlay) handleKey(a *App, k vim.Key) { } } +func (p *pickOverlay) fieldCursor() cursorState { return p.cur } + func (p *pickOverlay) render(a *App, w, h int) string { th := a.theme width := min(w-4, max(44, w*2/3)) @@ -190,9 +193,11 @@ func (p *pickOverlay) render(a *App, w, h int) string { } lines := []string{th.OverlayTitle.Render(p.title)} prompt := "› " - lines = append(lines, prompt+p.input.render(th, inner-cellWidth(prompt))) + fieldW := inner - cellWidth(prompt) + lines = append(lines, prompt+p.input.render(fieldW)) + p.cur = a.fieldAt(cellWidth(prompt)+p.input.cell(fieldW), titleRows(p.title, inner)) if p.input.String() == "" && p.placeholder != "" { - lines[len(lines)-1] = prompt + th.Cursor.Render(" ") + th.Muted.Render(padRight(p.placeholder, inner-3)) + lines[len(lines)-1] = prompt + " " + th.Muted.Render(padRight(p.placeholder, inner-3)) } lines = append(lines, th.Muted.Render(strings.Repeat("─", inner))) if len(p.filtered) == 0 { diff --git a/internal/tui/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 } 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) + } + }) + } +}