Skip to content

Commit 00fcced

Browse files
authored
Merge pull request #105 from reeflective/dev
Emacs backspace fix + buffered terminal output (one flush per frame)
2 parents aa7cc0a + 3a5d66c commit 00fcced

17 files changed

Lines changed: 442 additions & 50 deletions

File tree

internal/completion/display.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func Display(eng *Engine, maxRows int) {
2020
// little more time. The engine itself is responsible for
2121
// deleting those lists when it deems them useless.
2222
if eng.Matches() == 0 || eng.skipDisplay {
23-
fmt.Print(term.ClearLineAfter)
23+
term.WriteString(term.ClearLineAfter)
2424
return
2525
}
2626

@@ -38,7 +38,7 @@ func Display(eng *Engine, maxRows int) {
3838
completions, eng.usedY = eng.cropCompletions(completions, maxRows)
3939

4040
if completions != "" {
41-
fmt.Print(completions)
41+
term.WriteString(completions)
4242
}
4343
}
4444

internal/core/api_windows.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"reflect"
1010
"syscall"
1111
"unsafe"
12+
13+
"github.com/reeflective/readline/internal/term"
1214
)
1315

1416
var (
@@ -167,6 +169,10 @@ func setConsoleCursorPosition(c *_COORD) error {
167169

168170
// GetCursorPos returns the current cursor position on Windows.
169171
func (k *Keys) GetCursorPos() (x, y int) {
172+
// Flush any buffered frame output so the console cursor reflects everything
173+
// printed so far before we read its position.
174+
term.FlushBuffer()
175+
170176
t := new(_CONSOLE_SCREEN_BUFFER_INFO)
171177
kernel.GetConsoleScreenBufferInfo(
172178
stdout,

internal/core/keys_unix.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ func (k *Keys) GetCursorPos() (x, y int) {
2828
var cursor []byte
2929
var match [][]string
3030

31+
// Flush any buffered frame output first: the cursor position we are about
32+
// to query is only correct once the prompt printed so far is actually on
33+
// screen, not still sitting in the output buffer.
34+
term.FlushBuffer()
35+
3136
// Echo the query and wait for the main key
3237
// reading routine to send us the response back.
3338
fmt.Print("\x1b[6n")

internal/core/line.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ func DisplayLine(l *Line, indent int) {
311311

312312
builtLine.WriteString(color.BgDefault)
313313

314-
fmt.Print(builtLine.String())
314+
term.WriteString(builtLine.String())
315315
}
316316

317317
// CoordinatesLine returns the number of real terminal lines on which the input line spans, considering

internal/display/engine.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package display
22

33
import (
4-
"fmt"
54
"regexp"
65

76
"github.com/reeflective/readline/inputrc"
@@ -82,8 +81,11 @@ func (e *Engine) PrintPrimaryPrompt() {
8281

8382
// ClearHelpers clears the hint and completion sections below the line.
8483
func (e *Engine) ClearHelpers() {
84+
term.BeginBuffer()
85+
defer term.EndBuffer()
86+
8587
e.CursorBelowLine()
86-
fmt.Print(term.ClearScreenBelow)
88+
term.WriteString(term.ClearScreenBelow)
8789

8890
term.MoveCursorUp(1)
8991
term.MoveCursorUp(e.lineRows)
@@ -102,6 +104,9 @@ func (e *Engine) ResetHelpers() {
102104
// hints, completions and some right prompts, the shell will put the
103105
// display at the start of the line immediately following the line.
104106
func (e *Engine) AcceptLine() {
107+
term.BeginBuffer()
108+
defer term.EndBuffer()
109+
105110
e.CursorToLineStart()
106111

107112
e.computeCoordinates(false)
@@ -110,14 +115,14 @@ func (e *Engine) AcceptLine() {
110115
term.MoveCursorBackwards(term.GetWidth())
111116
term.MoveCursorDown(e.lineRows)
112117
term.MoveCursorForwards(e.lineCol)
113-
fmt.Print(term.ClearScreenBelow)
118+
term.WriteString(term.ClearScreenBelow)
114119

115120
// Reprint the right-side prompt if it's not a tooltip one.
116121
e.prompt.RightPrint(e.lineCol, false)
117122

118123
// Go below this non-suggested line and clear everything.
119124
term.MoveCursorBackwards(term.GetWidth())
120-
fmt.Print(term.NewlineReturn)
125+
term.WriteString(term.NewlineReturn)
121126
}
122127

123128
// RefreshTransient goes back to the first line of the input buffer
@@ -127,14 +132,17 @@ func (e *Engine) RefreshTransient() {
127132
return
128133
}
129134

135+
term.BeginBuffer()
136+
defer term.EndBuffer()
137+
130138
// Go to the beginning of the primary prompt.
131139
e.CursorToLineStart()
132140
term.MoveCursorUp(e.prompt.PrimaryUsed())
133141

134142
// And redisplay the transient/primary/line.
135143
e.prompt.TransientPrint()
136144
e.displayLine()
137-
fmt.Print(term.NewlineReturn)
145+
term.WriteString(term.NewlineReturn)
138146
}
139147

140148
// CursorToLineStart moves the cursor just after the primary prompt.
@@ -153,7 +161,7 @@ func (e *Engine) CursorToLineStart() {
153161
func (e *Engine) CursorBelowLine() {
154162
term.MoveCursorUp(e.cursorRow)
155163
term.MoveCursorDown(e.lineRows)
156-
fmt.Print(term.NewlineReturn)
164+
term.WriteString(term.NewlineReturn)
157165
}
158166

159167
func (e *Engine) computeCoordinates(suggested bool) {
@@ -236,8 +244,8 @@ func (e *Engine) displayLine() {
236244

237245
// Adjust the cursor if the line fits exactly in the terminal width.
238246
if e.lineCol == 0 {
239-
fmt.Print(term.NewlineReturn)
240-
fmt.Print(term.ClearLineAfter)
247+
term.WriteString(term.NewlineReturn)
248+
term.WriteString(term.ClearLineAfter)
241249
}
242250
}
243251

internal/display/refresh.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ import (
1515
// Refresh recomputes and redisplays the entire readline interface, except
1616
// the first lines of the primary prompt when the latter is a multiline one.
1717
func (e *Engine) Refresh() {
18+
// Buffer the whole frame and flush once, so the terminal never shows a
19+
// partial repaint and we issue a single write instead of dozens.
20+
term.BeginBuffer()
21+
defer term.EndBuffer()
22+
1823
// 1. Preparation & Coordinates
19-
fmt.Print(term.HideCursor)
24+
term.WriteString(term.HideCursor)
2025
// Go back to the first column, and if the primary prompt
2126
// was not printed yet, back up to the line's beginning row.
2227
term.MoveCursorBackwards(term.GetWidth())
@@ -63,10 +68,11 @@ func (e *Engine) Refresh() {
6368
// CUD + clear + CUU to clean up artifacts from previous renders.
6469
termHeight := term.GetLength()
6570
atBottom := (e.startRows + e.lineRows) >= termHeight
71+
6672
if !atBottom {
6773
term.MoveCursorDown(1)
6874
term.MoveCursorBackwards(term.GetWidth())
69-
fmt.Print(term.ClearScreenBelow)
75+
term.WriteString(term.ClearScreenBelow)
7076
term.MoveCursorUp(1)
7177
term.MoveCursorForwards(e.lineCol)
7278
}
@@ -83,7 +89,7 @@ func (e *Engine) Refresh() {
8389
term.MoveCursorBackwards(term.GetWidth())
8490
term.MoveCursorForwards(e.cursorCol)
8591

86-
fmt.Print(term.ShowCursor)
92+
term.WriteString(term.ShowCursor)
8793
}
8894

8995
// repaintPromptUpperLines reprints the upper lines of a multi-line prompt at
@@ -131,7 +137,7 @@ func (e *Engine) renderHelpers() {
131137
return
132138
}
133139

134-
fmt.Print(term.NewlineReturn)
140+
term.WriteString(term.NewlineReturn)
135141

136142
// 3. Display Hints
137143
ui.DisplayHint(e.hint)
@@ -190,13 +196,13 @@ func (e *Engine) ensureIndicatorSpace() {
190196

191197
e.startCols += indicatorWidth
192198
// Print the indicator on the first line.
193-
fmt.Print(indicator)
199+
term.WriteString(indicator)
194200
} else if e.line.Lines() > 0 && e.startCols < indicatorWidth {
195201
// If the prompt is shorter than the indicator, pad with spaces
196202
// to ensure the input text starts aligned with subsequent lines
197203
// and isn't overwritten by the indicator.
198204
padding := indicatorWidth - e.startCols
199-
fmt.Printf("%*s", padding, "")
205+
term.Printf("%*s", padding, "")
200206

201207
e.startCols = indicatorWidth
202208
}
@@ -237,7 +243,7 @@ func (e *Engine) ensureInputSpace() {
237243
term.MoveCursorDown(e.lineRows)
238244

239245
for range deficit {
240-
fmt.Print(term.NewlineReturn)
246+
term.WriteString(term.NewlineReturn)
241247
}
242248

243249
e.startRows -= deficit
@@ -301,15 +307,15 @@ func (e *Engine) renderMultilineIndicators() {
301307
pipe := ui.DefaultMultilineColumn
302308

303309
for i := 1; i <= e.line.Lines(); i++ {
304-
fmt.Print("\n")
310+
term.WriteString("\n")
305311

306312
switch {
307313
case numbered:
308-
fmt.Printf(color.FgBlackBright+"%d"+color.Reset+" ", i+1)
314+
term.Printf(color.FgBlackBright+"%d"+color.Reset+" ", i+1)
309315
case i == e.line.Lines():
310316
e.prompt.SecondaryPrint()
311317
default:
312-
fmt.Print(pipe)
318+
term.WriteString(pipe)
313319
}
314320

315321
printedLines++

internal/keymap/cursor.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package keymap
22

33
import (
4-
"fmt"
54
"strings"
5+
6+
"github.com/reeflective/readline/internal/term"
67
)
78

89
// CursorStyle is the style of the cursor
@@ -58,14 +59,14 @@ func (m *Engine) PrintCursor(keymap Mode) {
5859
modeSet := strings.TrimSpace(m.config.GetString(cursorOptname))
5960

6061
if _, valid := cursors[CursorStyle(modeSet)]; valid {
61-
fmt.Print(cursors[CursorStyle(modeSet)])
62+
term.WriteString(cursors[CursorStyle(modeSet)])
6263
return
6364
}
6465

6566
if defaultCur, valid := defaultCursors[keymap]; valid {
66-
fmt.Print(cursors[defaultCur])
67+
term.WriteString(cursors[defaultCur])
6768
return
6869
}
6970

70-
fmt.Print(cursors[cursor])
71+
term.WriteString(cursors[cursor])
7172
}

internal/keymap/dispatch_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package keymap
2+
3+
import (
4+
"testing"
5+
6+
"github.com/reeflective/readline/inputrc"
7+
"github.com/reeflective/readline/internal/core"
8+
"github.com/reeflective/readline/internal/strutil"
9+
)
10+
11+
// builtinBindMaps returns every keymap exactly as the dispatcher sees it after
12+
// a default engine is built (GNU defaults + this library's builtin overlays).
13+
func builtinBindMaps(t *testing.T) map[string]map[string]inputrc.Bind {
14+
t.Helper()
15+
16+
_, cfg := NewEngine(&core.Keys{}, &core.Iterations{})
17+
18+
return cfg.Binds
19+
}
20+
21+
// collisions returns, for a keymap, the ConvertMeta key bytes that more than one
22+
// sequence maps to with DIFFERING binds — inputs whose exact match would depend
23+
// on iteration order if matchBind did not impose a deterministic order.
24+
func collisions(binds map[string]inputrc.Bind) map[string][]inputrc.Bind {
25+
bySeq := make(map[string][]inputrc.Bind)
26+
27+
for sequence, bind := range binds {
28+
seq := strutil.ConvertMeta([]rune(sequence))
29+
bySeq[seq] = append(bySeq[seq], bind)
30+
}
31+
32+
out := make(map[string][]inputrc.Bind)
33+
34+
for seq, list := range bySeq {
35+
differ := false
36+
37+
for _, b := range list[1:] {
38+
if b != list[0] {
39+
differ = true
40+
41+
break
42+
}
43+
}
44+
45+
if differ {
46+
out[seq] = list
47+
}
48+
}
49+
50+
return out
51+
}
52+
53+
// TestMatchBindSortIsLoadBearing documents why matchBind must impose a
54+
// deterministic order: the default keymaps DO contain sequences that collapse
55+
// (via ConvertMeta) to the same key bytes with different actions, e.g. ESC-prefixed
56+
// meta bindings overlapping a self-insert. With those collisions present, dropping
57+
// the ordering (as a naive optimization would) makes the exact `match` depend on
58+
// Go's randomized map iteration — i.e. nondeterministic keybind resolution. If
59+
// this ever reports zero collisions, the determinism requirement can be revisited.
60+
func TestMatchBindSortIsLoadBearing(t *testing.T) {
61+
binds := builtinBindMaps(t)[string(Emacs)]
62+
63+
cols := collisions(binds)
64+
if len(cols) == 0 {
65+
t.Fatal("expected ConvertMeta collisions in the emacs keymap; if truly none, matchBind no longer needs to order its matches")
66+
}
67+
68+
t.Logf("emacs keymap has %d colliding key sequences whose exact match is disambiguated only by matchBind's ordering", len(cols))
69+
}
70+
71+
// TestMatchBindResolvesDeterministically guards the property the ordering buys:
72+
// for a colliding input, matchBind must return the SAME exact match on every
73+
// call despite map iteration randomness. Runs many times so a regression to
74+
// unordered iteration would be caught.
75+
func TestMatchBindResolvesDeterministically(t *testing.T) {
76+
eng := &Engine{}
77+
binds := builtinBindMaps(t)[string(Emacs)]
78+
79+
cols := collisions(binds)
80+
if len(cols) == 0 {
81+
t.Skip("no collisions to probe")
82+
}
83+
84+
// Pick one colliding input deterministically (smallest key bytes).
85+
var probe string
86+
87+
for seq := range cols {
88+
if probe == "" || seq < probe {
89+
probe = seq
90+
}
91+
}
92+
93+
want, _ := eng.matchBind([]byte(probe), binds)
94+
95+
for range 64 {
96+
got, _ := eng.matchBind([]byte(probe), binds)
97+
if got != want {
98+
t.Fatalf("matchBind(%q) is nondeterministic: got %+v, first saw %+v", probe, got, want)
99+
}
100+
}
101+
}

internal/keymap/emacs.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ var unescape = inputrc.Unescape
66

77
// emacsKeys are the default keymaps in Emacs mode.
88
var emacsKeys = map[string]inputrc.Bind{
9-
unescape(`\C-D`): {Action: "end-of-file"},
10-
unescape(`\C-h`): {Action: "backward-kill-word"},
9+
unescape(`\C-D`): {Action: "end-of-file"},
10+
// NOTE: \C-h is deliberately NOT overridden here. Many terminals send ^H
11+
// for Backspace, and the GNU default (inputrc DefaultBinds) binds \C-h to
12+
// backward-delete-char. Binding it to backward-kill-word made Backspace
13+
// delete a whole word on those terminals; leaving it unset restores the
14+
// standard char-delete behaviour.
1115
unescape(`\C-N`): {Action: "down-line-or-history"},
1216
unescape(`\C-P`): {Action: "up-line-or-history"},
1317
unescape(`\C-x\C-b`): {Action: "vi-match"},

0 commit comments

Comments
 (0)