diff --git a/command.mbt b/command.mbt new file mode 100644 index 0000000..b163e87 --- /dev/null +++ b/command.mbt @@ -0,0 +1,117 @@ +///| +/// Terminal output command. +/// +/// A `Command` is inert data. It only writes bytes when passed to +/// `Tty::execute`. +pub(all) enum Command { + Print(StringView) + EnterAltScreen + LeaveAltScreen + BeginSynchronizedUpdate + EndSynchronizedUpdate + EnableBracketedPaste + DisableBracketedPaste + EnableFocusTracking + DisableFocusTracking + EnableAutoWrap + DisableAutoWrap + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags) + PopKeyboardEnhancementFlags + HideCursor + ShowCursor + CursorUp(Int) + CursorDown(Int) + CursorForward(Int) + CursorBack(Int) + CursorNextLine(Int) + CursorPrevLine(Int) + CursorHorizontalAbsolute(Int) + CursorPosition(Int, Int) + EraseLineAll + EraseLineLeft + EraseLineRight + EraseDisplay + EraseScrollback + SetTopBottomMargins(Int, Int) + ResetTopBottomMargins + ReverseIndex + SetForeground(@color.Color) + SetBackground(@color.Color) + ResetForeground + ResetBackground + SetBold + ResetBold + SetItalic + ResetItalic + SetUnderline + ResetUnderline + SetReverse + ResetReverse + ResetStyle +} + +///| +fn Command::write_to(self : Command, buf : @buffer.Buffer) -> Unit { + match self { + Print(text) => buf.write_string_utf8(text) + EnterAltScreen => buf.write_bytes(@vt.enter_alt_screen) + LeaveAltScreen => buf.write_bytes(@vt.leave_alt_screen) + BeginSynchronizedUpdate => buf.write_bytes(@vt.begin_synchronized_update) + EndSynchronizedUpdate => buf.write_bytes(@vt.end_synchronized_update) + EnableBracketedPaste => buf.write_bytes(@vt.enable_bracketed_paste) + DisableBracketedPaste => buf.write_bytes(@vt.disable_bracketed_paste) + EnableFocusTracking => buf.write_bytes(@vt.enable_focus_tracking) + DisableFocusTracking => buf.write_bytes(@vt.disable_focus_tracking) + EnableAutoWrap => buf.write_bytes(@vt.enable_auto_wrap) + DisableAutoWrap => buf.write_bytes(@vt.disable_auto_wrap) + PushKeyboardEnhancementFlags(flags) => + @vt.write_push_keyboard_enhancement_flags(buf, flags.bits()) + PopKeyboardEnhancementFlags => + buf.write_bytes(@vt.pop_keyboard_enhancement_flags) + HideCursor => buf.write_bytes(@vt.hide_cursor) + ShowCursor => buf.write_bytes(@vt.show_cursor) + CursorUp(n) => @vt.write_cursor_up(buf, n) + CursorDown(n) => @vt.write_cursor_down(buf, n) + CursorForward(n) => @vt.write_cursor_forward(buf, n) + CursorBack(n) => @vt.write_cursor_back(buf, n) + CursorNextLine(n) => @vt.write_cursor_next_line(buf, n) + CursorPrevLine(n) => @vt.write_cursor_prev_line(buf, n) + CursorHorizontalAbsolute(n) => @vt.write_cursor_horizontal_absolute(buf, n) + CursorPosition(row, col) => @vt.write_cursor_position(buf, row, col) + EraseLineAll => buf.write_bytes(@vt.erase_line_all) + EraseLineLeft => buf.write_bytes(@vt.erase_line_left) + EraseLineRight => buf.write_bytes(@vt.erase_line_right) + EraseDisplay => buf.write_bytes(@vt.erase_display) + EraseScrollback => buf.write_bytes(@vt.erase_scrollback) + SetTopBottomMargins(top, bottom) => + @vt.write_set_top_bottom_margins(buf, top, bottom) + ResetTopBottomMargins => buf.write_bytes(@vt.reset_top_bottom_margins) + ReverseIndex => buf.write_bytes(@vt.reverse_index) + SetForeground(color) => @vt.write_set_foreground(buf, color) + SetBackground(color) => @vt.write_set_background(buf, color) + ResetForeground => buf.write_bytes(@vt.reset_foreground) + ResetBackground => buf.write_bytes(@vt.reset_background) + SetBold => buf.write_bytes(@vt.bold) + ResetBold => buf.write_bytes(@vt.reset_bold) + SetItalic => buf.write_bytes(@vt.italic) + ResetItalic => buf.write_bytes(@vt.reset_italic) + SetUnderline => buf.write_bytes(@vt.underline) + ResetUnderline => buf.write_bytes(@vt.reset_underline) + SetReverse => buf.write_bytes(@vt.reverse) + ResetReverse => buf.write_bytes(@vt.reset_reverse) + ResetStyle => buf.write_bytes(@vt.reset_style) + } +} + +///| +/// Execute terminal output commands with one serialized write. +pub async fn Tty::execute(self : Self, commands : Array[Command]) -> Unit { + if commands.length() == 0 { + return + } + let buf = @buffer.Buffer() + for command in commands { + command.write_to(buf) + } + self.write(buf.to_bytes()) +} diff --git a/docs/plans/2026-06-15-command-execute.md b/docs/plans/2026-06-15-command-execute.md new file mode 100644 index 0000000..4d9b3b4 --- /dev/null +++ b/docs/plans/2026-06-15-command-execute.md @@ -0,0 +1,88 @@ +# Command Execute + +## Goal + +Add a first batch of pure output commands so callers can describe terminal +operations as data and execute a command batch with one serialized terminal +write. + +## Accepted Design + +- Add a root-package `pub(all) enum Command`. +- Keep command values as inert data. No command writes to the terminal until it + is passed to `Tty::execute`. +- Keep `Command::write_to(buf)` private to the root package. +- `Tty::execute(commands)` builds one `@buffer.Buffer`, appends each command in + order, and calls `Tty::write` once. An empty command array is a no-op. +- Only include pure write-only commands in this batch. Exclude terminal query + operations, `with_*` scope helpers, and mouse composite commands for now. +- Add `Print(StringView)` as the text-output command. It writes the string view + as UTF-8 into the command batch buffer and does not escape or sanitize + terminal control bytes embedded in the text. + +## Target Files / Surfaces + +- `command.mbt`: command enum, private encoder, and `Tty::execute`. +- `moon.pkg`: add `moonbitlang/core/buffer` to normal imports. +- `tty_test.mbt`: black-box tests for command batching and empty batches. +- `pkg.generated.mbti`: generated root public API diff. + +## API / Interface Diff + +- Root package gains `pub(all) enum Command`. +- Root package gains `pub async fn Tty::execute(Self, Array[Command]) -> Unit`. +- `Command` variants cover cursor movement, screen mode constants, + erase/cursor-visibility commands, style/color commands, scroll margins, + synchronized updates, bracketed paste/focus/auto-wrap modes, kitty keyboard + enhancement push/pop, and `Print(StringView)`. +- This command task should not add further `internal/vt` API. The same PR also + includes the prior VT write-helper task; its `internal/vt` API diff is tracked + in `docs/plans/2026-06-15-vt-write-helpers.md`. + +## Open Questions + +- Mouse enable/disable commands are intentionally deferred because they are + composite sequences rather than one VT command each. +- Query commands are intentionally deferred because they require coordinating + writes with input responses. + +## Next Implementation Step + +Replace the draft `command.mbt` with the accepted command enum and buffer-backed +executor, then add tests that validate batch output order and empty no-op +behavior. + +## Validation Plan + +- `moon fmt` +- `moon test .` +- `moon check` +- `moon info` +- Review root `pkg.generated.mbti` and `internal/vt/pkg.generated.mbti` diffs. +- `git diff --check` + +## Validation Results + +- `moon fmt`: passed. +- `moon test .`: passed, 27 tests. +- `moon test`: passed, 172 tests. +- `moon check`: passed. +- `moon info`: passed. +- `git diff --cached --check`: passed. +- `Print(StringView)` addendum: + - `moon fmt`: passed. + - `moon test .`: passed, 27 tests. + - `moon test`: passed, 172 tests. + - `moon check`: passed. + - `moon info`: passed. + +## Public API Audit + +- Root `pkg.generated.mbti` gained only the accepted `pub(all) enum Command` + and `pub async fn Tty::execute(Self, Array[Command]) -> Unit`. +- The `Print(StringView)` addendum changes only the accepted command enum + surface. +- Relative to the command task, `internal/vt/pkg.generated.mbti` remained + unchanged. The PR-level `internal/vt` API additions from the earlier + destination-passing VT work are audited in + `docs/plans/2026-06-15-vt-write-helpers.md`. diff --git a/docs/plans/2026-06-15-vt-write-helpers.md b/docs/plans/2026-06-15-vt-write-helpers.md new file mode 100644 index 0000000..6133b4c --- /dev/null +++ b/docs/plans/2026-06-15-vt-write-helpers.md @@ -0,0 +1,78 @@ +# VT Write Helpers + +## Goal + +Add destination-passing VT sequence helpers so command batching can encode +multiple terminal operations into one buffer before a single `Tty::write`. + +## Accepted Design + +- Keep `moonbit-community/tty/internal/vt` as the only owner of VT byte + encoding. +- Add `write_*` helpers that take `buf : @buffer.Buffer` as their first + argument and append the requested sequence into that buffer. +- Keep existing `Bytes`-returning helpers with their current names and behavior. + These helpers may become thin wrappers around the new `write_*` helpers. +- Do not expose `write_*` helpers from the root package; they are internal + implementation surface for root `Tty`/command batching work. +- Constant byte sequences may continue as `pub let` values. A `write_*` helper is + only required where avoiding per-command temporary `Bytes` allocation matters. + +## Target Files / Surfaces + +- `internal/vt/moon.pkg`: import `moonbitlang/core/buffer`. +- `internal/vt/sequence.mbt`: add low-level CSI and decimal write helpers. +- `internal/vt/cursor.mbt`: add cursor `write_*` helpers and keep existing + `Bytes` helpers. +- `internal/vt/sgr.mbt`: add SGR/color `write_*` helpers and keep existing + `Bytes` helpers. +- `internal/vt/screen.mbt`: add write helper for kitty keyboard flag push. +- `internal/vt/scroll.mbt`: add write helper for top/bottom margins. +- `internal/vt/*_test.mbt`: verify `write_*` output matches existing byte APIs. + +## API / Interface Diff + +- Root package `.mbti` should remain unchanged. +- `internal/vt/pkg.generated.mbti` will gain public internal-package functions: + `write_cursor_*`, `write_sgr*`, `write_set_*`, + `write_push_keyboard_enhancement_flags`, and + `write_set_top_bottom_margins`. +- Existing `internal/vt` public `Bytes` helpers remain present and compatible. + +## Open Questions + +- Future command batching may decide whether all constants need corresponding + `write_*` helpers for call-site uniformity. This task only adds dynamic + helpers. + +## Next Implementation Step + +Add buffer-backed sequence writing primitives, then refactor dynamic cursor, +SGR/color, keyboard flag, and scroll-margin helpers to use them. + +## Validation Plan + +- `moon fmt` +- `moon test internal/vt` +- `moon check` +- `moon info` +- Review `internal/vt/pkg.generated.mbti` and root `pkg.generated.mbti` diffs. + +## Validation Results + +- `moon fmt internal/vt`: passed. +- `moon test internal/vt`: passed, 24 tests. +- `moon check`: passed with warnings only from the separate draft + `command.mbt` file (`write_to` unused, missing explicit core/buffer import, + unfinished `Tty::execute` placeholder). +- `moon info internal/vt`: passed. +- `git diff --cached --check`: passed. + +## Public API Audit + +- Root package `pkg.generated.mbti` was intentionally not regenerated because + the root `command.mbt` draft is incomplete and would pollute the root public + interface. +- `internal/vt/pkg.generated.mbti` gained only the intended internal-package + `write_*` helpers plus its `moonbitlang/core/buffer` import. +- Existing `internal/vt` `Bytes` helpers remain present and behavior-compatible. diff --git a/internal/vt/cursor.mbt b/internal/vt/cursor.mbt index 37a572d..56ffe6b 100644 --- a/internal/vt/cursor.mbt +++ b/internal/vt/cursor.mbt @@ -1,3 +1,13 @@ +///| +/// Cursor Up (CUU), ECMA-48. +pub fn write_cursor_up(buf : @buffer.Buffer, n : Int) -> Unit { + if n <= 1 { + buf.write_bytes(b"\x1b[A") + } else { + write_csi1(buf, n, b'A') + } +} + ///| /// Cursor Up (CUU), ECMA-48. pub fn cursor_up(n : Int) -> Bytes { @@ -16,6 +26,16 @@ pub let hide_cursor : Bytes = b"\x1b[?25l" /// Show cursor, DEC private mode 25. pub let show_cursor : Bytes = b"\x1b[?25h" +///| +/// Cursor Down (CUD), ECMA-48. +pub fn write_cursor_down(buf : @buffer.Buffer, n : Int) -> Unit { + if n <= 1 { + buf.write_bytes(b"\x1b[B") + } else { + write_csi1(buf, n, b'B') + } +} + ///| /// Cursor Down (CUD), ECMA-48. pub fn cursor_down(n : Int) -> Bytes { @@ -26,6 +46,16 @@ pub fn cursor_down(n : Int) -> Bytes { } } +///| +/// Cursor Forward (CUF), ECMA-48. +pub fn write_cursor_forward(buf : @buffer.Buffer, n : Int) -> Unit { + if n <= 1 { + buf.write_bytes(b"\x1b[C") + } else { + write_csi1(buf, n, b'C') + } +} + ///| /// Cursor Forward (CUF), ECMA-48. pub fn cursor_forward(n : Int) -> Bytes { @@ -36,6 +66,16 @@ pub fn cursor_forward(n : Int) -> Bytes { } } +///| +/// Cursor Backward (CUB), ECMA-48. +pub fn write_cursor_back(buf : @buffer.Buffer, n : Int) -> Unit { + if n <= 1 { + buf.write_bytes(b"\x1b[D") + } else { + write_csi1(buf, n, b'D') + } +} + ///| /// Cursor Backward (CUB), ECMA-48. pub fn cursor_back(n : Int) -> Bytes { @@ -46,6 +86,16 @@ pub fn cursor_back(n : Int) -> Bytes { } } +///| +/// Cursor Next Line (CNL), ECMA-48. +pub fn write_cursor_next_line(buf : @buffer.Buffer, n : Int) -> Unit { + if n <= 1 { + buf.write_bytes(b"\x1b[E") + } else { + write_csi1(buf, n, b'E') + } +} + ///| /// Cursor Next Line (CNL), ECMA-48. pub fn cursor_next_line(n : Int) -> Bytes { @@ -56,6 +106,16 @@ pub fn cursor_next_line(n : Int) -> Bytes { } } +///| +/// Cursor Previous Line (CPL), ECMA-48. +pub fn write_cursor_prev_line(buf : @buffer.Buffer, n : Int) -> Unit { + if n <= 1 { + buf.write_bytes(b"\x1b[F") + } else { + write_csi1(buf, n, b'F') + } +} + ///| /// Cursor Previous Line (CPL), ECMA-48. pub fn cursor_prev_line(n : Int) -> Bytes { @@ -66,6 +126,16 @@ pub fn cursor_prev_line(n : Int) -> Bytes { } } +///| +/// Cursor Character Absolute (CHA), ECMA-48. +pub fn write_cursor_horizontal_absolute(buf : @buffer.Buffer, n : Int) -> Unit { + if n <= 1 { + buf.write_bytes(b"\x1b[G") + } else { + write_csi1(buf, n, b'G') + } +} + ///| /// Cursor Character Absolute (CHA), ECMA-48. pub fn cursor_horizontal_absolute(n : Int) -> Bytes { @@ -76,6 +146,24 @@ pub fn cursor_horizontal_absolute(n : Int) -> Bytes { } } +///| +/// Cursor Position (CUP), ECMA-48. +/// +/// `row` and `col` are 1-based. Values less than 1 are treated as 1. +pub fn write_cursor_position( + buf : @buffer.Buffer, + row : Int, + col : Int, +) -> Unit { + let row = if row <= 1 { 1 } else { row } + let col = if col <= 1 { 1 } else { col } + if row == 1 && col == 1 { + buf.write_bytes(b"\x1b[H") + } else { + write_csi2(buf, row, col, b'H') + } +} + ///| /// Cursor Position (CUP), ECMA-48. /// diff --git a/internal/vt/moon.pkg b/internal/vt/moon.pkg index 7c59fa6..c949d25 100644 --- a/internal/vt/moon.pkg +++ b/internal/vt/moon.pkg @@ -1,9 +1,11 @@ import { "moonbit-community/tty/color", + "moonbitlang/core/buffer", } import { "moonbitlang/core/bench", + "moonbitlang/core/buffer", "moonbitlang/core/debug", "moonbitlang/core/encoding/utf8", "moonbitlang/core/random", diff --git a/internal/vt/pkg.generated.mbti b/internal/vt/pkg.generated.mbti index 0bebdbd..e172167 100644 --- a/internal/vt/pkg.generated.mbti +++ b/internal/vt/pkg.generated.mbti @@ -3,6 +3,7 @@ package "moonbit-community/tty/internal/vt" import { "moonbit-community/tty/color", + "moonbitlang/core/buffer", } // Values @@ -126,6 +127,36 @@ pub let show_cursor : Bytes pub let underline : Bytes +pub fn write_cursor_back(@buffer.Buffer, Int) -> Unit + +pub fn write_cursor_down(@buffer.Buffer, Int) -> Unit + +pub fn write_cursor_forward(@buffer.Buffer, Int) -> Unit + +pub fn write_cursor_horizontal_absolute(@buffer.Buffer, Int) -> Unit + +pub fn write_cursor_next_line(@buffer.Buffer, Int) -> Unit + +pub fn write_cursor_position(@buffer.Buffer, Int, Int) -> Unit + +pub fn write_cursor_prev_line(@buffer.Buffer, Int) -> Unit + +pub fn write_cursor_up(@buffer.Buffer, Int) -> Unit + +pub fn write_push_keyboard_enhancement_flags(@buffer.Buffer, Int) -> Unit + +pub fn write_set_background(@buffer.Buffer, @color.Color) -> Unit + +pub fn write_set_foreground(@buffer.Buffer, @color.Color) -> Unit + +pub fn write_set_top_bottom_margins(@buffer.Buffer, Int, Int) -> Unit + +pub fn write_sgr1(@buffer.Buffer, Int) -> Unit + +pub fn write_sgr3(@buffer.Buffer, Int, Int, Int) -> Unit + +pub fn write_sgr5(@buffer.Buffer, Int, Int, Int, Int, Int) -> Unit + // Errors // Types and methods diff --git a/internal/vt/screen.mbt b/internal/vt/screen.mbt index bdfcfcf..0d633c6 100644 --- a/internal/vt/screen.mbt +++ b/internal/vt/screen.mbt @@ -62,18 +62,28 @@ pub let query_default_background_color : Bytes = b"\x1b]11;?\x1b\\" /// Query xterm text cursor color, OSC 12. pub let query_cursor_color : Bytes = b"\x1b]12;?\x1b\\" +///| +/// Push kitty keyboard protocol progressive enhancement flags. +pub fn write_push_keyboard_enhancement_flags( + buf : @buffer.Buffer, + flags : Int, +) -> Unit { + let flags = if flags < 0 { 0 } else { flags } + buf.write_byte(b'\x1b') + buf.write_byte(b'[') + buf.write_byte(b'>') + write_decimal(buf, flags) + buf.write_byte(b'u') +} + ///| /// Push kitty keyboard protocol progressive enhancement flags. pub fn push_keyboard_enhancement_flags(flags : Int) -> Bytes { let flags = if flags < 0 { 0 } else { flags } let len = count_decimal_digits(flags) - let buf : FixedArray[Byte] = FixedArray::make(4 + len, 0) - buf[0] = b'\x1b' - buf[1] = b'[' - buf[2] = b'>' - let offset = write_decimal_digits(buf, flags, offset=3, length=len) - buf[offset] = b'u' - buf.unsafe_reinterpret_as_bytes() + bytes_from_buffer(4 + len, buf => { + write_push_keyboard_enhancement_flags(buf, flags) + }) } ///| diff --git a/internal/vt/scroll.mbt b/internal/vt/scroll.mbt index edc0ebf..989e38c 100644 --- a/internal/vt/scroll.mbt +++ b/internal/vt/scroll.mbt @@ -1,3 +1,18 @@ +///| +/// Set Top and Bottom Margins (DECSTBM), DEC private sequence. +/// +/// `top` and `bottom` are 1-based inclusive rows. Values less than 1 are +/// treated as 1. If `bottom` is less than `top`, it is treated as `top`. +pub fn write_set_top_bottom_margins( + buf : @buffer.Buffer, + top : Int, + bottom : Int, +) -> Unit { + let top = if top <= 1 { 1 } else { top } + let bottom = if bottom < top { top } else { bottom } + write_csi2(buf, top, bottom, b'r') +} + ///| /// Set Top and Bottom Margins (DECSTBM), DEC private sequence. /// @@ -6,7 +21,11 @@ pub fn set_top_bottom_margins(top : Int, bottom : Int) -> Bytes { let top = if top <= 1 { 1 } else { top } let bottom = if bottom < top { top } else { bottom } - csi2(top, bottom, b'r') + let len1 = count_decimal_digits(top) + let len2 = count_decimal_digits(bottom) + bytes_from_buffer(4 + len1 + len2, buf => { + write_set_top_bottom_margins(buf, top, bottom) + }) } ///| diff --git a/internal/vt/sequence.mbt b/internal/vt/sequence.mbt index 4c09da5..8de7982 100644 --- a/internal/vt/sequence.mbt +++ b/internal/vt/sequence.mbt @@ -16,42 +16,107 @@ fn count_decimal_digits(n : Int) -> Int { } ///| -fn write_decimal_digits( - buf : FixedArray[Byte], - n : Int, - offset? : Int = 0, - length? : Int = count_decimal_digits(n), -) -> Int { - let mut value = n - for i in 0.. Unit, +) -> Bytes { + let buf = @buffer.Buffer::Buffer(size_hint~) + write(buf) + buf.to_bytes() +} + +///| +fn write_decimal_nonnegative(buf : @buffer.Buffer, n : Int) -> Unit { + if n >= 10 { + write_decimal_nonnegative(buf, n / 10) } - offset + length + buf.write_byte((n % 10 + b'0'.to_int()).to_byte()) +} + +///| +fn write_decimal(buf : @buffer.Buffer, n : Int) -> Unit { + guard n >= 0 else { + abort( + "@moonbit-community/tty/internal/vt.write_decimal: n should be greater or equal to 0", + ) + } + write_decimal_nonnegative(buf, n) +} + +///| +fn write_csi1(buf : @buffer.Buffer, n : Int, final_byte : Byte) -> Unit { + buf.write_byte(Esc) + buf.write_byte(b'[') + write_decimal(buf, n) + buf.write_byte(final_byte) } ///| fn csi1(n : Int, final_byte : Byte) -> Bytes { let len = count_decimal_digits(n) - let buf : FixedArray[Byte] = FixedArray::make(3 + len, 0) - buf[0] = Esc - buf[1] = b'[' - let offset = write_decimal_digits(buf, n, offset=2, length=len) - buf[offset] = final_byte - buf.unsafe_reinterpret_as_bytes() + bytes_from_buffer(3 + len, buf => write_csi1(buf, n, final_byte)) +} + +///| +fn write_csi2( + buf : @buffer.Buffer, + n1 : Int, + n2 : Int, + final_byte : Byte, +) -> Unit { + buf.write_byte(Esc) + buf.write_byte(b'[') + write_decimal(buf, n1) + buf.write_byte(b';') + write_decimal(buf, n2) + buf.write_byte(final_byte) } ///| fn csi2(n1 : Int, n2 : Int, final_byte : Byte) -> Bytes { let len1 = count_decimal_digits(n1) let len2 = count_decimal_digits(n2) - let buf : FixedArray[Byte] = FixedArray::make(4 + len1 + len2, 0) - buf[0] = Esc - buf[1] = b'[' - let offset = write_decimal_digits(buf, n1, offset=2, length=len1) - buf[offset] = b';' - let offset = write_decimal_digits(buf, n2, offset=offset + 1, length=len2) - buf[offset] = final_byte - buf.unsafe_reinterpret_as_bytes() + bytes_from_buffer(4 + len1 + len2, buf => write_csi2(buf, n1, n2, final_byte)) +} + +///| +fn write_csi3( + buf : @buffer.Buffer, + n1 : Int, + n2 : Int, + n3 : Int, + final_byte : Byte, +) -> Unit { + buf.write_byte(Esc) + buf.write_byte(b'[') + write_decimal(buf, n1) + buf.write_byte(b';') + write_decimal(buf, n2) + buf.write_byte(b';') + write_decimal(buf, n3) + buf.write_byte(final_byte) +} + +///| +fn write_csi5( + buf : @buffer.Buffer, + n1 : Int, + n2 : Int, + n3 : Int, + n4 : Int, + n5 : Int, + final_byte : Byte, +) -> Unit { + buf.write_byte(Esc) + buf.write_byte(b'[') + write_decimal(buf, n1) + buf.write_byte(b';') + write_decimal(buf, n2) + buf.write_byte(b';') + write_decimal(buf, n3) + buf.write_byte(b';') + write_decimal(buf, n4) + buf.write_byte(b';') + write_decimal(buf, n5) + buf.write_byte(final_byte) } diff --git a/internal/vt/sgr.mbt b/internal/vt/sgr.mbt index d3513e2..e27c731 100644 --- a/internal/vt/sgr.mbt +++ b/internal/vt/sgr.mbt @@ -42,28 +42,44 @@ pub let reverse : Bytes = b"\x1b[7m" /// Positive image (SGR 27), ECMA-48. pub let reset_reverse : Bytes = b"\x1b[27m" +///| +/// Construct an SGR sequence with one parameter. +pub fn write_sgr1(buf : @buffer.Buffer, p1 : Int) -> Unit { + write_csi1(buf, p1, b'm') +} + ///| /// Construct an SGR sequence with one parameter. pub fn sgr1(p1 : Int) -> Bytes { csi1(p1, b'm') } +///| +/// Construct an SGR sequence with three parameters. +pub fn write_sgr3(buf : @buffer.Buffer, p1 : Int, p2 : Int, p3 : Int) -> Unit { + write_csi3(buf, p1, p2, p3, b'm') +} + ///| /// Construct an SGR sequence with three parameters. pub fn sgr3(p1 : Int, p2 : Int, p3 : Int) -> Bytes { let len1 = count_decimal_digits(p1) let len2 = count_decimal_digits(p2) let len3 = count_decimal_digits(p3) - let buf : FixedArray[Byte] = FixedArray::make(5 + len1 + len2 + len3, 0) - buf[0] = Esc - buf[1] = b'[' - let offset = write_decimal_digits(buf, p1, offset=2, length=len1) - buf[offset] = b';' - let offset = write_decimal_digits(buf, p2, offset=offset + 1, length=len2) - buf[offset] = b';' - let offset = write_decimal_digits(buf, p3, offset=offset + 1, length=len3) - buf[offset] = b'm' - buf.unsafe_reinterpret_as_bytes() + bytes_from_buffer(5 + len1 + len2 + len3, buf => write_sgr3(buf, p1, p2, p3)) +} + +///| +/// Construct an SGR sequence with five parameters. +pub fn write_sgr5( + buf : @buffer.Buffer, + p1 : Int, + p2 : Int, + p3 : Int, + p4 : Int, + p5 : Int, +) -> Unit { + write_csi5(buf, p1, p2, p3, p4, p5, b'm') } ///| @@ -74,23 +90,9 @@ pub fn sgr5(p1 : Int, p2 : Int, p3 : Int, p4 : Int, p5 : Int) -> Bytes { let len3 = count_decimal_digits(p3) let len4 = count_decimal_digits(p4) let len5 = count_decimal_digits(p5) - let buf : FixedArray[Byte] = FixedArray::make( - 7 + len1 + len2 + len3 + len4 + len5, - 0, - ) - buf[0] = Esc - buf[1] = b'[' - let offset = write_decimal_digits(buf, p1, offset=2, length=len1) - buf[offset] = b';' - let offset = write_decimal_digits(buf, p2, offset=offset + 1, length=len2) - buf[offset] = b';' - let offset = write_decimal_digits(buf, p3, offset=offset + 1, length=len3) - buf[offset] = b';' - let offset = write_decimal_digits(buf, p4, offset=offset + 1, length=len4) - buf[offset] = b';' - let offset = write_decimal_digits(buf, p5, offset=offset + 1, length=len5) - buf[offset] = b'm' - buf.unsafe_reinterpret_as_bytes() + bytes_from_buffer(7 + len1 + len2 + len3 + len4 + len5, buf => { + write_sgr5(buf, p1, p2, p3, p4, p5) + }) } ///| @@ -148,15 +150,50 @@ fn basic_background_code(color : @color.BasicColor) -> Int { } } +///| +/// Construct the SGR sequence for a foreground color command. +pub fn write_set_foreground(buf : @buffer.Buffer, color : @color.Color) -> Unit { + match color { + Default => buf.write_bytes(reset_foreground) + Basic(color) => write_sgr1(buf, basic_foreground_code(color)) + Indexed(index) => write_sgr3(buf, 38, 5, clamp_byte_value(index)) + Rgb(r, g, b) => + write_sgr5( + buf, + 38, + 2, + clamp_byte_value(r), + clamp_byte_value(g), + clamp_byte_value(b), + ) + } +} + ///| /// Construct the SGR sequence for a foreground color command. pub fn set_foreground(color : @color.Color) -> Bytes { match color { Default => reset_foreground - Basic(color) => sgr1(basic_foreground_code(color)) - Indexed(index) => sgr3(38, 5, clamp_byte_value(index)) + _ => bytes_from_buffer(20, buf => write_set_foreground(buf, color)) + } +} + +///| +/// Construct the SGR sequence for a background color command. +pub fn write_set_background(buf : @buffer.Buffer, color : @color.Color) -> Unit { + match color { + Default => buf.write_bytes(reset_background) + Basic(color) => write_sgr1(buf, basic_background_code(color)) + Indexed(index) => write_sgr3(buf, 48, 5, clamp_byte_value(index)) Rgb(r, g, b) => - sgr5(38, 2, clamp_byte_value(r), clamp_byte_value(g), clamp_byte_value(b)) + write_sgr5( + buf, + 48, + 2, + clamp_byte_value(r), + clamp_byte_value(g), + clamp_byte_value(b), + ) } } @@ -165,9 +202,6 @@ pub fn set_foreground(color : @color.Color) -> Bytes { pub fn set_background(color : @color.Color) -> Bytes { match color { Default => reset_background - Basic(color) => sgr1(basic_background_code(color)) - Indexed(index) => sgr3(48, 5, clamp_byte_value(index)) - Rgb(r, g, b) => - sgr5(48, 2, clamp_byte_value(r), clamp_byte_value(g), clamp_byte_value(b)) + _ => bytes_from_buffer(20, buf => write_set_background(buf, color)) } } diff --git a/internal/vt/write_test.mbt b/internal/vt/write_test.mbt new file mode 100644 index 0000000..352f2f1 --- /dev/null +++ b/internal/vt/write_test.mbt @@ -0,0 +1,143 @@ +///| +fn written(write : (@buffer.Buffer) -> Unit) -> Bytes { + let buf = @buffer.Buffer::Buffer() + write(buf) + buf.to_bytes() +} + +///| +test "write cursor sequences" { + @debug.assert_eq( + written(buf => @vt.write_cursor_up(buf, 1)), + @vt.cursor_up(1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_up(buf, 12)), + @vt.cursor_up(12), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_down(buf, 1)), + @vt.cursor_down(1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_down(buf, 12)), + @vt.cursor_down(12), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_forward(buf, 1)), + @vt.cursor_forward(1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_forward(buf, 12)), + @vt.cursor_forward(12), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_back(buf, 1)), + @vt.cursor_back(1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_back(buf, 12)), + @vt.cursor_back(12), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_next_line(buf, 1)), + @vt.cursor_next_line(1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_next_line(buf, 12)), + @vt.cursor_next_line(12), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_prev_line(buf, 1)), + @vt.cursor_prev_line(1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_prev_line(buf, 12)), + @vt.cursor_prev_line(12), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_horizontal_absolute(buf, 1)), + @vt.cursor_horizontal_absolute(1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_horizontal_absolute(buf, 12)), + @vt.cursor_horizontal_absolute(12), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_position(buf, 1, 1)), + @vt.cursor_position(1, 1), + ) + @debug.assert_eq( + written(buf => @vt.write_cursor_position(buf, 3, 4)), + @vt.cursor_position(3, 4), + ) +} + +///| +test "write sgr sequences" { + @debug.assert_eq(written(buf => @vt.write_sgr1(buf, 0)), @vt.sgr1(0)) + @debug.assert_eq( + written(buf => @vt.write_sgr3(buf, 38, 5, 42)), + @vt.sgr3(38, 5, 42), + ) + @debug.assert_eq( + written(buf => @vt.write_sgr5(buf, 38, 2, 1, 127, 255)), + @vt.sgr5(38, 2, 1, 127, 255), + ) +} + +///| +test "write color sequences" { + @debug.assert_eq( + written(buf => @vt.write_set_foreground(buf, Default)), + @vt.set_foreground(Default), + ) + @debug.assert_eq( + written(buf => @vt.write_set_foreground(buf, Basic(Red))), + @vt.set_foreground(Basic(Red)), + ) + @debug.assert_eq( + written(buf => @vt.write_set_foreground(buf, Indexed(42))), + @vt.set_foreground(Indexed(42)), + ) + @debug.assert_eq( + written(buf => @vt.write_set_foreground(buf, Rgb(-1, 127, 999))), + @vt.set_foreground(Rgb(-1, 127, 999)), + ) + @debug.assert_eq( + written(buf => @vt.write_set_background(buf, Default)), + @vt.set_background(Default), + ) + @debug.assert_eq( + written(buf => @vt.write_set_background(buf, Basic(Red))), + @vt.set_background(Basic(Red)), + ) + @debug.assert_eq( + written(buf => @vt.write_set_background(buf, Indexed(42))), + @vt.set_background(Indexed(42)), + ) + @debug.assert_eq( + written(buf => @vt.write_set_background(buf, Rgb(-1, 127, 999))), + @vt.set_background(Rgb(-1, 127, 999)), + ) +} + +///| +test "write keyboard and scroll sequences" { + @debug.assert_eq( + written(buf => @vt.write_push_keyboard_enhancement_flags(buf, 31)), + @vt.push_keyboard_enhancement_flags(31), + ) + @debug.assert_eq( + written(buf => @vt.write_push_keyboard_enhancement_flags(buf, -1)), + @vt.push_keyboard_enhancement_flags(-1), + ) + @debug.assert_eq( + written(buf => @vt.write_set_top_bottom_margins(buf, 4, 12)), + @vt.set_top_bottom_margins(4, 12), + ) + @debug.assert_eq( + written(buf => @vt.write_set_top_bottom_margins(buf, 8, 3)), + @vt.set_top_bottom_margins(8, 3), + ) +} diff --git a/moon.pkg b/moon.pkg index 20209cc..645cbc7 100644 --- a/moon.pkg +++ b/moon.pkg @@ -7,6 +7,7 @@ import { "moonbitlang/async/stdio" @async/stdio, "moonbitlang/async/os_error", "moonbitlang/async/io" @async/io, + "moonbitlang/core/buffer", "moonbit-community/tty/color", "moonbit-community/tty/input" @public_input, "moonbit-community/tty/internal/io" @internal_io, diff --git a/pkg.generated.mbti b/pkg.generated.mbti index 1986085..ae95564 100644 --- a/pkg.generated.mbti +++ b/pkg.generated.mbti @@ -18,6 +18,53 @@ pub fn[T : Fd] isatty(T) -> Bool raise @os_error.OSError // Errors // Types and methods +pub(all) enum Command { + Print(StringView) + EnterAltScreen + LeaveAltScreen + BeginSynchronizedUpdate + EndSynchronizedUpdate + EnableBracketedPaste + DisableBracketedPaste + EnableFocusTracking + DisableFocusTracking + EnableAutoWrap + DisableAutoWrap + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags) + PopKeyboardEnhancementFlags + HideCursor + ShowCursor + CursorUp(Int) + CursorDown(Int) + CursorForward(Int) + CursorBack(Int) + CursorNextLine(Int) + CursorPrevLine(Int) + CursorHorizontalAbsolute(Int) + CursorPosition(Int, Int) + EraseLineAll + EraseLineLeft + EraseLineRight + EraseDisplay + EraseScrollback + SetTopBottomMargins(Int, Int) + ResetTopBottomMargins + ReverseIndex + SetForeground(@color.Color) + SetBackground(@color.Color) + ResetForeground + ResetBackground + SetBold + ResetBold + SetItalic + ResetItalic + SetUnderline + ResetUnderline + SetReverse + ResetReverse + ResetStyle +} + pub struct CursorPosition { row : Int col : Int @@ -68,6 +115,7 @@ pub fn Tty::enter_raw_mode(Self) -> State raise @os_error.OSError pub async fn Tty::erase_display(Self) -> Unit pub async fn Tty::erase_line_all(Self) -> Unit pub async fn Tty::erase_scrollback(Self) -> Unit +pub async fn Tty::execute(Self, Array[Command]) -> Unit pub fn Tty::get_state(Self) -> State raise @os_error.OSError pub async fn Tty::hide_cursor(Self) -> Unit pub async fn Tty::leave_alt_screen(Self) -> Unit diff --git a/tty_test.mbt b/tty_test.mbt index 16ac92d..9ca8822 100644 --- a/tty_test.mbt +++ b/tty_test.mbt @@ -62,6 +62,75 @@ async test "pipe tty terminal commands write sequences" { ) } +///| +async test "pipe tty execute command batch writes sequences" { + let output = with_pipe_tty(b"", tty => { + let world : String = "world" + tty.execute([ + EnterAltScreen, + BeginSynchronizedUpdate, + EnableBracketedPaste, + EnableFocusTracking, + EnableAutoWrap, + HideCursor, + Print("hello"), + Print(world), + CursorUp(2), + CursorDown(3), + CursorForward(4), + CursorBack(5), + CursorNextLine(6), + CursorPrevLine(7), + CursorHorizontalAbsolute(8), + CursorPosition(3, 4), + EraseLineAll, + EraseLineLeft, + EraseLineRight, + EraseDisplay, + EraseScrollback, + SetTopBottomMargins(1, 5), + ReverseIndex, + ResetTopBottomMargins, + SetForeground(Basic(Red)), + ResetForeground, + SetBackground(Indexed(42)), + ResetBackground, + SetBold, + ResetBold, + SetItalic, + ResetItalic, + SetUnderline, + ResetUnderline, + SetReverse, + ResetReverse, + ResetStyle, + PushKeyboardEnhancementFlags( + @tty.KeyboardEnhancementFlags::new( + disambiguate_escape_codes=true, + report_event_types=true, + ), + ), + PopKeyboardEnhancementFlags, + ShowCursor, + DisableAutoWrap, + DisableFocusTracking, + DisableBracketedPaste, + EndSynchronizedUpdate, + LeaveAltScreen, + ]) + }) + inspect( + output, + content="\u{1B}[?1049h\u{1B}[?2026h\u{1B}[?2004h\u{1B}[?1004h\u{1B}[?7h\u{1B}[?25lhelloworld\u{1B}[2A\u{1B}[3B\u{1B}[4C\u{1B}[5D\u{1B}[6E\u{1B}[7F\u{1B}[8G\u{1B}[3;4H\u{1B}[2K\u{1B}[1K\u{1B}[K\u{1B}[2J\u{1B}[3J\u{1B}[1;5r\u{1B}M\u{1B}[r\u{1B}[31m\u{1B}[39m\u{1B}[48;5;42m\u{1B}[49m\u{1B}[1m\u{1B}[22m\u{1B}[3m\u{1B}[23m\u{1B}[4m\u{1B}[24m\u{1B}[7m\u{1B}[27m\u{1B}[0m\u{1B}[>3u\u{1B}[<1u\u{1B}[?25h\u{1B}[?7l\u{1B}[?1004l\u{1B}[?2004l\u{1B}[?2026l\u{1B}[?1049l", + ) +} + +///| +async test "pipe tty execute empty command batch writes nothing" { + let output = with_pipe_tty(b"", tty => tty.execute([])) + inspect(output, content="") +} + ///| async test "pipe tty mouse mode commands write sequences" { let output = with_pipe_tty(b"", tty => {