mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
tui: use DECSC/DECRC for bottom-band anchor; normalize \r in editor input
DrawLog now saves/restores the cursor at the top of the bottom band instead of relying on relative up-N math that drifted when the terminal naturally scrolled between frames. This fixes duplicated transcript blocks with empty gaps (previously only ctrl+l recovered). Also strip literal carriage returns from pasted/typed editor text before rendering. A bare \r moves the terminal cursor to column 0 and overwrites the left side of the input row, which looked like missing highlight segments on continuation lines.
This commit is contained in:
parent
07c073055d
commit
ae4e019ee4
4 changed files with 65 additions and 38 deletions
26
internal/tui/crlf_test.go
Normal file
26
internal/tui/crlf_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPasteNormalizesCarriageReturns(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "alpha\rbeta\r\ngamma"})
|
||||
|
||||
want := "alpha\nbeta\ngamma"
|
||||
if got := e.Value(); got != want {
|
||||
t.Fatalf("Value() = %q, want %q", got, want)
|
||||
}
|
||||
if got := e.SubmitValue(); got != want {
|
||||
t.Fatalf("SubmitValue() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetValueNormalizesCarriageReturns(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.SetValue("one\rtwo\r\nthree")
|
||||
|
||||
want := "one\ntwo\nthree"
|
||||
if got := e.Value(); got != want {
|
||||
t.Fatalf("Value() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,7 @@ func (e *Editor) SubmitValue() string {
|
|||
// Also drops any stored pastes because the placeholders they back
|
||||
// are now gone from the visible text.
|
||||
func (e *Editor) SetValue(s string) {
|
||||
s = normalizeEditorText(s)
|
||||
e.Lines = strings.Split(s, "\n")
|
||||
if len(e.Lines) == 0 {
|
||||
e.Lines = []string{""}
|
||||
|
|
@ -175,6 +176,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
|
|||
case KeyCtrlW:
|
||||
e.deleteWord()
|
||||
case KeyPaste:
|
||||
paste := normalizeEditorText(k.Paste)
|
||||
// Large multi-line pastes are collapsed to a short
|
||||
// placeholder token so the editor doesn't balloon to
|
||||
// hundreds of rows. The full body is stashed in e.pastes,
|
||||
|
|
@ -183,19 +185,19 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
|
|||
// two or more newlines triggers collapse; one-liners and
|
||||
// drag-dropped file paths fall through to the original
|
||||
// insert path (including file-path quoting).
|
||||
if pasteShouldCollapse(k.Paste) {
|
||||
if pasteShouldCollapse(paste) {
|
||||
if e.pastes == nil {
|
||||
e.pastes = map[int]string{}
|
||||
}
|
||||
e.pasteSeq++
|
||||
id := e.pasteSeq
|
||||
e.pastes[id] = k.Paste
|
||||
e.insert(formatPastePlaceholder(id, k.Paste))
|
||||
e.pastes[id] = paste
|
||||
e.insert(formatPastePlaceholder(id, paste))
|
||||
} else {
|
||||
// macOS Terminal / iTerm / Ghostty deliver drag-dropped
|
||||
// files as bracketed-paste text. Detect that pattern
|
||||
// and collapse long paths to a [file:basename] chip.
|
||||
inserted := e.collapseOrQuoteFilePaths(k.Paste)
|
||||
inserted := e.collapseOrQuoteFilePaths(paste)
|
||||
e.insert(inserted)
|
||||
}
|
||||
case KeyEsc:
|
||||
|
|
@ -420,6 +422,7 @@ func singleQuote(s string) string {
|
|||
func (e *Editor) Insert(s string) { e.insert(s) }
|
||||
|
||||
func (e *Editor) insert(s string) {
|
||||
s = normalizeEditorText(s)
|
||||
line := e.Lines[e.CursorR]
|
||||
pre := substringBefore(line, e.CursorC)
|
||||
post := substringAfter(line, e.CursorC)
|
||||
|
|
@ -849,6 +852,17 @@ func substringAfter(s string, col int) string {
|
|||
|
||||
func runeLen(s string) int { return len([]rune(s)) }
|
||||
|
||||
// normalizeEditorText converts all common line endings to \n before text
|
||||
// reaches the renderer. A literal carriage return would move the terminal
|
||||
// cursor back to column 0 and overwrite the left side of the input row.
|
||||
func normalizeEditorText(s string) string {
|
||||
if !strings.ContainsRune(s, '\r') {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
return strings.ReplaceAll(s, "\r", "\n")
|
||||
}
|
||||
|
||||
func visualColumn(s string, runeCol int) int {
|
||||
r := []rune(s)
|
||||
if runeCol > len(r) {
|
||||
|
|
|
|||
|
|
@ -36,10 +36,9 @@ type Renderer struct {
|
|||
// Main-screen flow renderer state. logChat is the full chat buffer
|
||||
// already emitted into terminal scrollback. logBottom is the
|
||||
// editable/status block currently drawn after the chat.
|
||||
logChat []string
|
||||
logBottom []string
|
||||
logInit bool
|
||||
logCursorR int
|
||||
logChat []string
|
||||
logBottom []string
|
||||
logInit bool
|
||||
}
|
||||
|
||||
// NewRenderer returns a renderer that writes to out.
|
||||
|
|
@ -61,7 +60,6 @@ func (r *Renderer) Resize(cols, rows int) {
|
|||
r.logChat = nil
|
||||
r.logBottom = nil
|
||||
r.logInit = false
|
||||
r.logCursorR = 0
|
||||
if r.out != nil {
|
||||
_, _ = io.WriteString(r.out, SeqClearScreen)
|
||||
}
|
||||
|
|
@ -78,7 +76,6 @@ func (r *Renderer) Clear() {
|
|||
r.logChat = nil
|
||||
r.logBottom = nil
|
||||
r.logInit = false
|
||||
r.logCursorR = 0
|
||||
_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
|
||||
}
|
||||
|
||||
|
|
@ -305,17 +302,17 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
|
|||
w.WriteString(line)
|
||||
w.WriteString("\r\n")
|
||||
}
|
||||
w.WriteString(SeqSaveCursor)
|
||||
writeBlock(&w, bottomFrame)
|
||||
r.logInit = true
|
||||
} else {
|
||||
// Move from the currently exposed cursor back to the top of the
|
||||
// live bottom block, then erase the old block. We track the row
|
||||
// inside the bottom block where we left the cursor last Draw.
|
||||
// Return to the saved top-of-bottom-band anchor instead of relying
|
||||
// on relative cursor movement from the last exposed editor cursor.
|
||||
// If the terminal naturally scrolled between frames, save/restore is
|
||||
// less prone to drift that leaves duplicated transcript blocks until
|
||||
// ctrl+l forces a clear repaint.
|
||||
w.WriteString(SeqRestoreCursor)
|
||||
w.WriteString("\r")
|
||||
if r.logCursorR > 0 {
|
||||
w.WriteString("\x1b[" + itoa(r.logCursorR) + "A")
|
||||
}
|
||||
eraseRows(&w, len(r.logBottom))
|
||||
|
||||
prefix := len(r.logChat) <= len(chatFrame)
|
||||
if prefix {
|
||||
|
|
@ -327,9 +324,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
|
|||
}
|
||||
}
|
||||
if prefix {
|
||||
// Append only genuinely new chat rows. They become real terminal
|
||||
// Erase old bottom (and anything below the saved anchor), then
|
||||
// append only genuinely new chat rows. They become real terminal
|
||||
// scrollback, and inline image escapes are emitted once here — not
|
||||
// on every keystroke.
|
||||
w.WriteString(SeqEraseToEnd)
|
||||
for _, line := range chatFrame[len(r.logChat):] {
|
||||
w.WriteString("\x1b[0m")
|
||||
w.WriteString(SeqClearLine)
|
||||
|
|
@ -355,6 +354,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
|
|||
w.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
w.WriteString(SeqSaveCursor)
|
||||
writeBlock(&w, bottomFrame)
|
||||
}
|
||||
|
||||
|
|
@ -370,9 +370,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
|
|||
w.WriteString("\x1b[" + itoa(cursorCol) + "C")
|
||||
}
|
||||
w.WriteString(SeqShowCursor)
|
||||
r.logCursorR = cursorBottomRow
|
||||
} else {
|
||||
r.logCursorR = len(bottomFrame) - 1
|
||||
}
|
||||
|
||||
w.WriteString(SeqSynchronizedOff)
|
||||
|
|
@ -395,23 +392,6 @@ func writeBlock(w *strings.Builder, lines []string) {
|
|||
}
|
||||
}
|
||||
|
||||
func eraseRows(w *strings.Builder, n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
w.WriteString("\x1b[0m")
|
||||
w.WriteString(SeqClearLine)
|
||||
if i < n-1 {
|
||||
w.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
if n > 1 {
|
||||
w.WriteString("\x1b[" + itoa(n-1) + "A")
|
||||
}
|
||||
w.WriteString("\r")
|
||||
}
|
||||
|
||||
func tailTruncated(lines []string, maxRows, cols int) []string {
|
||||
if maxRows <= 0 {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -102,6 +102,13 @@ const (
|
|||
SeqAltScreenOff = "\x1b[?1049l"
|
||||
SeqSynchronizedOn = "\x1b[?2026h"
|
||||
SeqSynchronizedOff = "\x1b[?2026l"
|
||||
// SeqSaveCursor / SeqRestoreCursor use DECSC/DECRC. All terminals
|
||||
// we target adjust the saved row when natural scrolling occurs, so
|
||||
// these survive scroll-on-write inside the bottom band.
|
||||
SeqSaveCursor = "\x1b7"
|
||||
SeqRestoreCursor = "\x1b8"
|
||||
// SeqEraseToEnd erases from the cursor to the end of the screen.
|
||||
SeqEraseToEnd = "\x1b[J"
|
||||
)
|
||||
|
||||
// MoveTo moves the cursor to 1-indexed (row, col).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue