diff --git a/src/tui/light.go b/src/tui/light.go index 4f5ec4d5..114dac0f 100644 --- a/src/tui/light.go +++ b/src/tui/light.go @@ -348,6 +348,53 @@ func getEnv(name string, defaultValue int) int { func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f } func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e } +// csiEnd returns the length of the CSI sequence at the start of the buffer, or +// 0 if it has no final byte yet or is malformed. +func csiEnd(buffer []byte) int { + for i := 2; i < len(buffer); i++ { + if csiFinal(buffer[i]) { + return i + 1 + } + if !csiContinues(buffer[i]) { + return 0 + } + } + return 0 +} + +// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the +// start of the buffer, or 0 if its terminator has not arrived. The terminator is +// ST, and BEL as well because xterm has always allowed it. +func stringEnd(buffer []byte) int { + for i := 2; i < len(buffer); i++ { + switch buffer[i] { + case '\a': + return i + 1 + case Esc.Byte(): + if i+1 < len(buffer) && buffer[i+1] == '\\' { + return i + 2 + } + // A lone ESC is either a truncated payload or a terminator still + // arriving. Either way the sequence is not framed. + return 0 + } + } + return 0 +} + +// stringIntroducer reports whether the byte after ESC starts a string sequence. +// Only the three that terminals actually reply with: OSC for colors, title and +// clipboard, DCS for XTVERSION and XTGETTCAP, APC for Kitty graphics. SOS and PM +// are left out, as nothing sends them and waiting for a terminator that will +// never come would delay ALT-X and ALT-^. +func stringIntroducer(b byte) bool { + switch b { + case 'P', ']', '_': + return true + } + return false +} + // incompleteEscape reports whether the buffer ends in an escape sequence that // has not been terminated yet. The read loop keeps waiting in that case, so the // parser is never handed a fragment to guess at. @@ -376,6 +423,9 @@ func incompleteEscape(buffer []byte) bool { case 'O': return len(tail)-start < 3 } + if stringIntroducer(tail[start+1]) { + return stringEnd(tail[start:]) == 0 + } return false } @@ -483,8 +533,10 @@ func (r *LightRenderer) GetChar(cancellable bool) Event { return Event{CtrlSlash, 0, nil} case Esc.Byte(): ev := r.escSequence(&sz) - // Second chance - if ev.Type == Invalid { + // Second chance, but only for a sequence that has not finished + // arriving. Re-reading after a complete one blocks until the next + // keystroke, holding back whatever follows it in the buffer. + if ev.Type == Invalid && incompleteEscape(r.buffer) { r.buffer, result, err = r.getBytes(true) if err != nil { return Event{Fatal, 0, nil} @@ -525,7 +577,23 @@ func (r *LightRenderer) setCancel(f func()) { r.mutex.Unlock() } +// escSequence parses an escape sequence. A CSI sequence fzf has no event for is +// dropped whole: consuming only the part that parsed leaves the rest to be read +// as input and typed into the query. func (r *LightRenderer) escSequence(sz *int) Event { + ev := r.parseEscSequence(sz) + if ev.Type != Invalid || len(r.buffer) < 3 || r.buffer[1] != '[' { + return ev + } + // Only a framed sequence is dropped. One still missing its final byte may + // yet be arriving, and the caller gives it another chance. + if end := csiEnd(r.buffer); end > *sz { + *sz = end + } + return ev +} + +func (r *LightRenderer) parseEscSequence(sz *int) Event { if len(r.buffer) < 2 { return Event{Esc, 0, nil} } @@ -987,6 +1055,22 @@ func (r *LightRenderer) escSequence(sz *int) Event { } // r.buffer[2] } // r.buffer[2] } // r.buffer[1] + // Nothing matched. A framed sequence is dropped whole: reading its + // introducer as an ALT-key below would type the rest into the query. + // Unterminated ones are left alone, as that is how ALT-[ and ALT-] arrive. + if r.buffer[1] == '[' { + // A bare "\e[c" is ALT-[ followed by a character, not a CSI sequence + if end := csiEnd(r.buffer); end > 3 { + *sz = end + return Event{Invalid, 0, nil} + } + } else if stringIntroducer(r.buffer[1]) { + if end := stringEnd(r.buffer); end > 0 { + *sz = end + return Event{Invalid, 0, nil} + } + } + rest := bytes.NewBuffer(r.buffer[1:]) c, size, err := rest.ReadRune() if err == nil { diff --git a/src/tui/light_csi_test.go b/src/tui/light_csi_test.go new file mode 100644 index 00000000..7853994d --- /dev/null +++ b/src/tui/light_csi_test.go @@ -0,0 +1,102 @@ +package tui + +import "testing" + +// An unrecognized CSI sequence must be consumed whole. Consuming only part of +// it leaves the rest to be read as input and typed into the query. +func TestUnknownCSISequence(t *testing.T) { + for _, c := range []struct { + sequence string + event EventType + size int + }{ + // Key encodings fzf does not implement + {"\x1b[97;5u", Invalid, 7}, + {"\x1b[127;5u", Invalid, 8}, + {"\x1b[27;5;127~", Invalid, 11}, + {"\x1b[57441;1u", Invalid, 10}, + {"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped + + // Replies to queries fzf did not send, or sent and stopped waiting for + {"\x1b[?1;2c", Invalid, 7}, + {"\x1b[>0;95;0c", Invalid, 10}, + + // Mouse report arriving while mouse input is off + {"\x1b[<0;1;1M", Invalid, 9}, + + // String sequences: OSC, DCS, APC, PM, SOS + {"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply + {"\x1b]0;a title\a", Invalid, 12}, // BEL terminated + {"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply + {"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // kitty graphics reply + + // Left alone: this is how ALT-[ and ALT-] arrive + {"\x1b[a", Alt, 2}, + {"\x1b]abc", Alt, 2}, + {"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived + + // SOS and PM are not framed, so ALT-X and ALT-^ are not delayed + {"\x1bXsos\x1b\\", Alt, 2}, + {"\x1b^status\x1b\\", Alt, 2}, + + // Left alone: no final byte yet, so the sequence may still be arriving + {"\x1b[", Invalid, 2}, + + // Recognized sequences keep their existing parsing + {"\x1b[1;5A", CtrlUp, 6}, + {"\x1b[3;5~", CtrlDelete, 6}, + {"\x1b[2~", Insert, 4}, + {"\x1b[200~", BracketedPasteBegin, 6}, + {"\x1b[Z", ShiftTab, 3}, + {"\x1bOA", Up, 3}, + {"\x1b[12;34R", Invalid, 8}, + {"\x1b[?2004;2$y", Invalid, 11}, + } { + r := &LightRenderer{buffer: []byte(c.sequence)} + sz := 1 + event := r.escSequence(&sz) + if event.Type != c.event { + t.Errorf("escSequence(%q) = %s, want %s", + c.sequence, event.Type.String(), c.event.String()) + } + if sz != c.size { + t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size) + } + } +} + +func TestStringEnd(t *testing.T) { + for _, c := range []struct { + buffer string + want int + }{ + {"\x1b]0;t\a", 6}, + {"\x1b]0;t\x1b\\", 7}, + {"\x1b_G\x1b\\", 5}, + {"\x1b]0;t", 0}, // no terminator + {"\x1b]0;t\x1b", 0}, // terminator half arrived + } { + if got := stringEnd([]byte(c.buffer)); got != c.want { + t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want) + } + } +} + +func TestCsiEnd(t *testing.T) { + for _, c := range []struct { + buffer string + want int + }{ + {"\x1b[97;5u", 7}, + {"\x1b[A", 3}, + {"\x1b[<0;1;1M", 9}, + {"\x1b[?2004;2$y", 11}, + {"\x1b[97;5", 0}, // no final byte + {"\x1b[", 0}, // no final byte + {"\x1b[1\x01A", 0}, // malformed, do not frame it + } { + if got := csiEnd([]byte(c.buffer)); got != c.want { + t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want) + } + } +} diff --git a/src/tui/light_escape_test.go b/src/tui/light_escape_test.go index f83d3ac4..0dd7c59d 100644 --- a/src/tui/light_escape_test.go +++ b/src/tui/light_escape_test.go @@ -51,3 +51,23 @@ func TestIncompleteEscape(t *testing.T) { } } } + +// String sequences must be waited for until their terminator arrives +func TestIncompleteStringEscape(t *testing.T) { + for _, c := range []struct { + buffer string + want bool + }{ + {"\x1b]11;rgb:", true}, + {"\x1bP>|kitty", true}, + {"\x1b_Gi=1", true}, + {"\x1b]0;title\a", false}, + {"\x1b]0;title\x1b\\", false}, + {"\x1bP>|kitty(0.48.2)\x1b\\", false}, + {"ab\x1b]11;rgb:", true}, + } { + if got := incompleteEscape([]byte(c.buffer)); got != c.want { + t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want) + } + } +}