tui: real terminal cursor in composer + window title (v2)
Two v2-unlocked polish items now that the TUI is on bubbletea v2: Native cursor — the composer caret was a frame-animated "▏" that forced the redraw loop to keep ticking in insert mode. v2's View.Cursor lets the terminal draw and blink a real cursor, so: - editorLines/launcherInput drop a width-1 marker rune (U+E123, same cell width as "▏") at the caret; View() locates it in the fully-composited frame via splitCursor → (X,Y), swaps it for a space, and sets a blinking CursorBar there. - render() (preview/golden tests) resolves the marker to the static "▏" glyph, so screenshots are unchanged and the marker never leaks into output. - nil cursor in normal mode hides it (vim-correct); gated on overlay==None so an open palette/files modal (which keeps editMode==Insert) doesn't draw a cursor behind it. - animating() no longer forces a redraw in insert mode — the terminal blinks the cursor itself, so an idle insert screen holds still (native text selection survives) and burns no frames. Window title — View.WindowTitle names the terminal tab after the active session, falling back to the model name then "correx". Tests: new cursor_test.go (coord extraction, mode/overlay gating, no marker leak); updated the animating-gate test for the new insert-mode contract. Build, vet, gofmt, full suite green; compose preview shows the static caret with no marker leak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// splitCursor must report the caret's display column/row from a composited frame
|
||||
// (ANSI ignored) and swap the marker for the replacement glyph.
|
||||
func TestSplitCursorCoords(t *testing.T) {
|
||||
// marker sits after 3 visible cells ("a", "b", "c") on the second line.
|
||||
raw := "first line\n" + "ab\x1b[38;2;1;2;3mc\x1b[m" + cursorMarker + "d"
|
||||
clean, cur := splitCursor(raw, " ")
|
||||
if cur == nil {
|
||||
t.Fatal("expected a cursor when the marker is present")
|
||||
}
|
||||
if cur.X != 3 || cur.Y != 1 {
|
||||
t.Fatalf("cursor at (%d,%d), want (3,1)", cur.X, cur.Y)
|
||||
}
|
||||
if strings.Contains(clean, cursorMarker) {
|
||||
t.Error("marker must be stripped from the returned frame")
|
||||
}
|
||||
if strings.Count(clean, " ") == 0 {
|
||||
t.Error("marker should have been replaced by the repl glyph")
|
||||
}
|
||||
}
|
||||
|
||||
// No marker (e.g. normal mode) → no cursor, frame unchanged.
|
||||
func TestSplitCursorAbsent(t *testing.T) {
|
||||
raw := "no caret here"
|
||||
clean, cur := splitCursor(raw, "▏")
|
||||
if cur != nil {
|
||||
t.Error("no marker must yield a nil cursor (hidden)")
|
||||
}
|
||||
if clean != raw {
|
||||
t.Error("frame must be unchanged when no marker is present")
|
||||
}
|
||||
}
|
||||
|
||||
// The live View hides the cursor in normal mode and shows a real one in insert
|
||||
// mode; the marker must never leak into the rendered content either way.
|
||||
func TestViewCursorByMode(t *testing.T) {
|
||||
m := inSessionModel(120, 40)
|
||||
|
||||
m.editMode = ModeNormal
|
||||
v := m.View()
|
||||
if v.Cursor != nil {
|
||||
t.Error("normal mode must hide the cursor (nil)")
|
||||
}
|
||||
if strings.Contains(v.Content, cursorMarker) {
|
||||
t.Error("content must not contain the raw cursor marker")
|
||||
}
|
||||
|
||||
m.editMode = ModeInsert
|
||||
m.inputMode = ModeRouter
|
||||
m.inputBuffer = "hello"
|
||||
m.inputCursor = len(m.inputBuffer)
|
||||
v = m.View()
|
||||
if v.Cursor == nil {
|
||||
t.Fatal("insert mode must show a real cursor")
|
||||
}
|
||||
if v.Cursor.Y < 0 || v.Cursor.Y >= m.height || v.Cursor.X < 0 {
|
||||
t.Errorf("cursor (%d,%d) out of frame bounds", v.Cursor.X, v.Cursor.Y)
|
||||
}
|
||||
if strings.Contains(v.Content, cursorMarker) {
|
||||
t.Error("content must not contain the raw cursor marker")
|
||||
}
|
||||
|
||||
// The static render keeps a visible glyph and also strips the marker.
|
||||
if strings.Contains(m.render(), cursorMarker) {
|
||||
t.Error("render() must strip the marker")
|
||||
}
|
||||
|
||||
// An open overlay keeps editMode==Insert (so closing resumes typing) but the
|
||||
// composer must not draw a live cursor behind the modal.
|
||||
m.overlay = OverlayPalette
|
||||
if v = m.View(); v.Cursor != nil {
|
||||
t.Error("an open overlay must suppress the composer cursor")
|
||||
}
|
||||
if strings.Contains(v.Content, cursorMarker) {
|
||||
t.Error("content must not contain the marker with an overlay open")
|
||||
}
|
||||
}
|
||||
@@ -78,12 +78,22 @@ func TestAnimatingGatesIdleRedraw(t *testing.T) {
|
||||
t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped")
|
||||
}
|
||||
|
||||
// Insert mode must NOT force the frame loop anymore: the composer caret is a real
|
||||
// terminal cursor (View.Cursor) that the terminal blinks itself, so an idle insert
|
||||
// screen holds still (and native text selection survives).
|
||||
m.editMode = ModeInsert
|
||||
if !m.animating() {
|
||||
t.Fatal("insert mode must animate (blinking caret)")
|
||||
if m.animating() {
|
||||
t.Fatal("insert mode must NOT animate — the native cursor blinks itself")
|
||||
}
|
||||
m.editMode = ModeNormal
|
||||
|
||||
// Other typing surfaces still draw their own carets and keep ticking.
|
||||
m.steering = true
|
||||
if !m.animating() {
|
||||
t.Fatal("steering (drawn caret) must animate")
|
||||
}
|
||||
m.steering = false
|
||||
|
||||
m.sessions = []Session{{ID: "s1", Active: true}}
|
||||
if !m.animating() {
|
||||
t.Fatal("an active session must animate (spinner)")
|
||||
|
||||
@@ -48,7 +48,10 @@ func (m Model) Init() tea.Cmd {
|
||||
// When false, the tick loop stops and the screen holds still — so a native terminal
|
||||
// text selection survives instead of being wiped by the next redraw.
|
||||
func (m Model) animating() bool {
|
||||
if m.editMode == ModeInsert || m.steering || m.clarTyping || m.proposeTyping || m.configEditing {
|
||||
// Note: ModeInsert is intentionally NOT here — the composer caret is now a real
|
||||
// terminal cursor (View.Cursor) that the terminal blinks itself, so insert mode no
|
||||
// longer needs the frame loop. The other typing surfaces keep their drawn carets.
|
||||
if m.steering || m.clarTyping || m.proposeTyping || m.configEditing {
|
||||
return true
|
||||
}
|
||||
if m.reconnecting {
|
||||
|
||||
@@ -30,15 +30,63 @@ const (
|
||||
// and full-width chrome.
|
||||
func (m Model) narrow() bool { return m.width < narrowWidth }
|
||||
|
||||
// cursorMarker is a private-use rune the composer drops into the caret cell so
|
||||
// View() can locate it in the fully-composited frame and place a real terminal
|
||||
// cursor there (see splitCursor). It measures one display column — same as the
|
||||
// "▏" glyph it stands in for — so the layout is identical whether it resolves to
|
||||
// a live cursor or the static glyph.
|
||||
const cursorMarker = ""
|
||||
|
||||
// View renders the whole frame purely from the model (immediate-mode). In
|
||||
// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so
|
||||
// render() builds the string and View() wraps it with the screen settings.
|
||||
// bubbletea v2 the View carries terminal state, so renderRaw() builds the string
|
||||
// and View() resolves the caret to a real blinking cursor + sets the window title.
|
||||
func (m Model) View() tea.View {
|
||||
return tea.View{Content: m.render(), AltScreen: true}
|
||||
content, cur := splitCursor(m.renderRaw(), " ")
|
||||
return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()}
|
||||
}
|
||||
|
||||
// render builds the frame string.
|
||||
// render returns the frame as a plain string with the caret drawn as the static
|
||||
// "▏" glyph — for the preview tool and golden tests, which have no live cursor.
|
||||
func (m Model) render() string {
|
||||
content, _ := splitCursor(m.renderRaw(), "▏")
|
||||
return content
|
||||
}
|
||||
|
||||
// splitCursor finds the caret marker in a composited frame, replaces it with repl
|
||||
// (a space for the live cursor cell, "▏" for static), and returns the cursor's
|
||||
// screen position — nil when no marker is present (e.g. vim normal mode), which
|
||||
// tells bubbletea to hide the cursor.
|
||||
func splitCursor(raw, repl string) (string, *tea.Cursor) {
|
||||
idx := strings.Index(raw, cursorMarker)
|
||||
if idx < 0 {
|
||||
return raw, nil
|
||||
}
|
||||
pre := raw[:idx]
|
||||
y := strings.Count(pre, "\n")
|
||||
lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line
|
||||
x := ansi.StringWidth(raw[lineStart:idx])
|
||||
clean := strings.Replace(raw, cursorMarker, repl, 1)
|
||||
cur := tea.NewCursor(x, y)
|
||||
cur.Shape = tea.CursorBar
|
||||
return clean, cur
|
||||
}
|
||||
|
||||
// windowTitle names the terminal window/tab after the active session, falling
|
||||
// back to the model name and then the bare program name.
|
||||
func (m Model) windowTitle() string {
|
||||
if m.displayState() == StateInSession {
|
||||
if s := m.session(m.selectedID); s != nil && s.Name != "" {
|
||||
return "correx — " + s.Name
|
||||
}
|
||||
}
|
||||
if m.currentModel != "" {
|
||||
return "correx — " + m.currentModel
|
||||
}
|
||||
return "correx"
|
||||
}
|
||||
|
||||
// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode).
|
||||
func (m Model) renderRaw() string {
|
||||
if m.quitting {
|
||||
return ""
|
||||
}
|
||||
@@ -403,8 +451,10 @@ const maxInputLines = 6
|
||||
func (m Model) editorLines() []string {
|
||||
t := m.theme
|
||||
caret := ""
|
||||
if m.editMode == ModeInsert && m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||||
// Only the focused composer (no modal over it) owns the live cursor; an open
|
||||
// overlay keeps editMode==Insert but draws its own filter caret instead.
|
||||
if m.editMode == ModeInsert && m.overlay == OverlayNone {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
|
||||
}
|
||||
buf := m.inputBuffer
|
||||
cur := m.inputCursor
|
||||
@@ -551,8 +601,8 @@ func (m Model) renderInput() string {
|
||||
|
||||
insert := m.editMode == ModeInsert
|
||||
caret := t.span(" ", t.P.Bg)
|
||||
if insert && m.caretVisible() {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
|
||||
if insert && m.overlay == OverlayNone {
|
||||
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
|
||||
}
|
||||
var content []string
|
||||
switch {
|
||||
|
||||
Reference in New Issue
Block a user