Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions ansi.rv
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>) -> String {
Expand Down
91 changes: 89 additions & 2 deletions input.rv
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,25 @@ enum Step {

struct Parser {
buf: List<Int>,
// 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<Int>,
tail: List<Int>,
}

impl Parser {
fun new() -> Parser {
let b: List<Int> = []
return Parser { buf: b }
let p: List<Int> = []
let t: List<Int> = []
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)
}
Expand All @@ -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 {
Expand All @@ -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<Int> = []
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<Int> = []
self.paste = p
let t: List<Int> = []
self.tail = t
return InputEvent.Paste(s)
}

fun _clear(self) {
let empty: List<Int> = []
self.buf = empty
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -184,6 +266,11 @@ impl Parser {
}
}

// The paste terminator byte sequence: ESC [ 2 0 1 ~.
fun _paste_end() -> List<Int> {
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 {
Expand Down
47 changes: 47 additions & 0 deletions input_test.rv
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down Expand Up @@ -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")
}
5 changes: 4 additions & 1 deletion keys.rv
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
18 changes: 17 additions & 1 deletion lib.rv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion rv.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "perch"
version = "0.1.0"
version = "0.2.0"
edition = "v2"

[dependencies]
Expand Down
4 changes: 4 additions & 0 deletions src/main.rv
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading