diff --git a/README.md b/README.md index ccf1344..e740589 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ fun main() { - `Mouse(MouseEvent)`: kind (`Down`, `Up`, `Drag`, `Move`, `ScrollUp`, `ScrollDown`), button, and a 1-based cell position. Call `mouse_on()` first. +- `Paste(String)`: one whole bracketed paste, newlines normalized to + `\n`, so pasted text arrives as a single event instead of a stream of + keystrokes (and a pasted newline cannot act as Enter). Call + `paste_on()` first. - `Idle`: the timeout passed without input. This is what makes render loops tick. - `Closed`: stdin ended. @@ -85,6 +89,7 @@ as `Ctrl("c")` like any other key: quitting is your program's decision. when stdin is not a terminal, so piped runs can bail out cleanly. - `alt_on() / alt_off()`: the alternate screen; scrollback survives. - `mouse_on() / mouse_off()`: SGR mouse reporting. +- `paste_on() / paste_off()`: bracketed paste, delivered as `Paste` events. - `size() -> Size`: terminal width and height in cells, 80x24 fallback. - `write(s) / flush()`: straight to the terminal, no line buffering. - `read_event(timeout_ms) -> InputEvent`: see above. diff --git a/ansi.rv b/ansi.rv index 3da9c00..0ca0bb8 100644 --- a/ansi.rv +++ b/ansi.rv @@ -41,6 +41,16 @@ fun mouse_off() -> String { return "\u{1b}[?1006l\u{1b}[?1002l" } +// Bracketed paste: the terminal wraps pasted text in ESC[200~ .. ESC[201~ +// instead of replaying it as keystrokes. +fun paste_on() -> String { + return "\u{1b}[?2004h" +} + +fun paste_off() -> String { + return "\u{1b}[?2004l" +} + // A Select Graphic Rendition sequence from raw code numbers, so callers can // compose styles without depending on any particular style type. fun sgr(codes: List) -> String { diff --git a/input.rv b/input.rv index 96ddaf1..b630a60 100644 --- a/input.rv +++ b/input.rv @@ -16,15 +16,25 @@ enum Step { struct Parser { buf: List, + // Bracketed paste: between ESC[200~ and ESC[201~ every byte is content, + // collected in `paste`; `tail` holds a partial match of the terminator. + pasting: Bool, + paste: List, + tail: List, } impl Parser { fun new() -> Parser { let b: List = [] - return Parser { buf: b } + let p: List = [] + let t: List = [] + return Parser { buf: b, pasting: false, paste: p, tail: t } } fun feed(self, b: Int) -> Step { + if self.pasting { + return self._paste_feed(b) + } if self.buf.len() == 0 { return self._start(b) } @@ -35,8 +45,13 @@ impl Parser { } // Settle a pending sequence after a read timeout. A lone ESC is the Esc - // key; any other partial sequence is dropped. + // key; any other partial sequence is dropped. Mid-paste, nothing settles: + // a large paste can span reads, so the content keeps accumulating until + // the terminator arrives. fun flush(self) -> InputEvent { + if self.pasting { + return InputEvent.Idle + } let lone_esc = self.buf.len() == 1 && self.buf[0] == 27 self._clear() if lone_esc { @@ -45,6 +60,61 @@ impl Parser { return InputEvent.Idle } + // One byte of paste content. The terminator ESC [ 2 0 1 ~ is matched + // incrementally through `tail`; on a mismatch the held bytes were real + // content, so they move into the paste before the byte is reconsidered. + fun _paste_feed(self, b: Int) -> Step { + let expected = _paste_end() + if b == expected[self.tail.len()] { + self.tail.push(b) + if self.tail.len() == expected.len() { + return Step.Ready(self._paste_done()) + } + return Step.More + } + let held = self.tail + let fresh: List = [] + self.tail = fresh + let i = 0 + while i < held.len() { + self.paste.push(held[i]) + i = i + 1 + } + // The mismatched byte may itself start a terminator (its first byte + // cannot appear later inside one, so a single re-check suffices). + if b == expected[0] { + self.tail.push(b) + return Step.More + } + self.paste.push(b) + return Step.More + } + + // The finished paste as one event, newlines normalized: terminals send + // \r (or \r\n) for line breaks in pasted text. + fun _paste_done(self) -> InputEvent { + let s = "" + let i = 0 + while i < self.paste.len() { + let b = self.paste[i] + if b == 13 { + s = s.concat("\n") + if i + 1 < self.paste.len() && self.paste[i + 1] == 10 { + i = i + 1 + } + } else { + s = s.concat(byte_str(b)) + } + i = i + 1 + } + self.pasting = false + let p: List = [] + self.paste = p + let t: List = [] + self.tail = t + return InputEvent.Paste(s) + } + fun _clear(self) { let empty: List = [] self.buf = empty @@ -105,6 +175,18 @@ impl Parser { if b >= 64 && b <= 126 { let params = self._params() self._clear() + if b == 126 { + let nums = _numbers(params) + if nums.len() > 0 && nums[0] == 200 { + // Paste start: everything until ESC[201~ is content. + self.pasting = true + return Step.More + } + if nums.len() > 0 && nums[0] == 201 { + // A stray paste end with no start; ignore it. + return Step.More + } + } return Step.Ready(_decode_csi(params, b)) } if b >= 32 && b <= 63 { @@ -184,6 +266,11 @@ impl Parser { } } +// The paste terminator byte sequence: ESC [ 2 0 1 ~. +fun _paste_end() -> List { + return [27, 91, 50, 48, 49, 126] +} + // Total bytes in a UTF-8 character with this lead byte. fun _utf8_len(lead: Int) -> Int { if lead < 224 { diff --git a/input_test.rv b/input_test.rv index e563867..274fcdc 100644 --- a/input_test.rv +++ b/input_test.rv @@ -27,6 +27,7 @@ fun event_tag(e: InputEvent) -> String { return match e { KeyPress(k) -> "key ${key_tag(k)}", Mouse(m) -> "mouse ${mouse_tag(m)}", + Paste(s) -> "paste:${s}", Idle -> "idle", Closed -> "closed", } @@ -198,3 +199,49 @@ fun test_parser_reuse_across_events() { assert_eq_str(event_tag(feed_all(p, [98])), "key char:b") assert_eq_str(event_tag(feed_all(p, [27, 91, 60, 48, 59, 49, 59, 49, 77])), "mouse down b0 1,1") } + +// Bracketed paste: ESC[200~ starts, ESC[201~ ends, everything between is +// content delivered as one Paste event. +fun test_paste_arrives_as_one_event() { + // ESC[200~ h i ESC[201~ + let bytes = [27, 91, 50, 48, 48, 126, 104, 105, 27, 91, 50, 48, 49, 126] + assert_eq_str(decode(bytes), "paste:hi") +} + +fun test_paste_newlines_normalize_and_do_not_submit() { + // "a" CR "b" pastes as a\nb: the CR is content, not the Enter key. + let bytes = [27, 91, 50, 48, 48, 126, 97, 13, 98, 27, 91, 50, 48, 49, 126] + assert_eq_str(decode(bytes), "paste:a\nb") + // CRLF collapses to one newline. + let crlf = [27, 91, 50, 48, 48, 126, 97, 13, 10, 98, 27, 91, 50, 48, 49, 126] + assert_eq_str(decode(crlf), "paste:a\nb") +} + +fun test_paste_keeps_a_partial_terminator_as_content() { + // An ESC then z inside the content: the held ESC flushes back into the + // paste when the match fails. + let bytes = [27, 91, 50, 48, 48, 126, 97, 27, 122, 98, 27, 91, 50, 48, 49, 126] + assert_eq_str(decode(bytes), "paste:a\u{1b}zb") +} + +fun test_paste_carries_utf8_content() { + // é is 195 169. + let bytes = [27, 91, 50, 48, 48, 126, 195, 169, 27, 91, 50, 48, 49, 126] + assert_eq_str(decode(bytes), "paste:é") +} + +fun test_paste_survives_a_flush_between_reads() { + let p = Parser.new() + let head = [27, 91, 50, 48, 48, 126, 104, 105] + assert_eq_str(event_tag(feed_all(p, head)), "idle") + // A read timeout mid-paste settles nothing and keeps the content. + assert_eq_str(event_tag(p.flush()), "idle") + let tail = [33, 27, 91, 50, 48, 49, 126] + assert_eq_str(event_tag(feed_all(p, tail)), "paste:hi!") +} + +fun test_stray_paste_end_is_ignored() { + let p = Parser.new() + assert_eq_str(event_tag(feed_all(p, [27, 91, 50, 48, 49, 126])), "idle") + assert_eq_str(event_tag(feed_all(p, [113])), "key char:q") +} diff --git a/keys.rv b/keys.rv index da3077f..e758541 100644 --- a/keys.rv +++ b/keys.rv @@ -42,10 +42,13 @@ struct MouseEvent { } // What read_event hands back. Idle means the timeout passed with nothing -// decoded; Closed means stdin ended. +// decoded; Closed means stdin ended. Paste is one whole bracketed paste, +// newlines normalized to \n, so a pasted block arrives as a single event +// instead of a stream of keys (and its newlines cannot submit early). enum InputEvent { KeyPress(Key), Mouse(MouseEvent), + Paste(String), Idle, Closed, } diff --git a/lib.rv b/lib.rv index a8d3aef..5b3da5f 100644 --- a/lib.rv +++ b/lib.rv @@ -44,11 +44,12 @@ struct Terminal { raw: Bool, alt: Bool, mouse: Bool, + paste: Bool, } impl Terminal { fun new() -> Terminal { - return Terminal { parser: Parser.new(), raw: false, alt: false, mouse: false } + return Terminal { parser: Parser.new(), raw: false, alt: false, mouse: false, paste: false } } // Switch raw mode on. False when stdin is not a terminal, which is the @@ -90,6 +91,18 @@ impl Terminal { self.mouse = false } + // Bracketed paste: pasted text arrives as one Paste event instead of a + // stream of keystrokes. + fun paste_on(self) { + self.write(ansi.paste_on()) + self.paste = true + } + + fun paste_off(self) { + self.write(ansi.paste_off()) + self.paste = false + } + // Current size, with an 80x24 fallback when stdout is not a terminal. fun size(self) -> Size { let w = term_cols() @@ -159,6 +172,9 @@ impl Terminal { // Undo whatever setup and mouse_on switched on, in the right order. fun restore(self) { + if self.paste { + self.paste_off() + } if self.mouse { self.mouse_off() } diff --git a/rv.toml b/rv.toml index 489c89d..37365af 100644 --- a/rv.toml +++ b/rv.toml @@ -1,6 +1,6 @@ [package] name = "perch" -version = "0.1.0" +version = "0.2.0" edition = "v2" [dependencies] diff --git a/src/main.rv b/src/main.rv index c11e37d..7a41d4f 100644 --- a/src/main.rv +++ b/src/main.rv @@ -48,6 +48,7 @@ fun main() { return } t.mouse_on() + t.paste_on() t.write("raw mode on: press keys or click, q or esc quits\r\n") let running = true while running { @@ -75,6 +76,9 @@ fun main() { Mouse(m) -> { t.write("${describe_mouse(m)}\r\n") }, + Paste(s) -> { + t.write("paste (${s.length()} bytes)\r\n") + }, Idle -> {}, Closed -> { running = false