diff --git a/docs/architecture.md b/docs/architecture.md index 76ef1e3..88e2d17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,6 +74,8 @@ It should: - construct DEC auto wrap enable/disable sequences - construct scrolling-margin and reverse-index sequences - construct low-level SGR sequences and fixed SGR attribute bytes +- construct foreground and background SGR sequences from semantic + `@color.Color` values for root `Tty` methods - document the standard or terminal family each sequence comes from It should not: @@ -107,8 +109,8 @@ It should not: - decide whether colors should be enabled - mutate terminal palettes or query terminal color state -Root `Tty` methods map color values onto SGR byte sequences when writing to a -terminal. +Root `Tty` methods delegate color-to-SGR byte encoding to `internal/vt` when +writing to a terminal. OSC dynamic color queries for the terminal default foreground, default background, and text cursor color belong on root `Tty` because they write a diff --git a/docs/plans/2026-06-15-move-color-encoding-to-internal-vt.md b/docs/plans/2026-06-15-move-color-encoding-to-internal-vt.md new file mode 100644 index 0000000..a06c880 --- /dev/null +++ b/docs/plans/2026-06-15-move-color-encoding-to-internal-vt.md @@ -0,0 +1,125 @@ +# Move Color Encoding To Internal VT + +## Goal + +Move the VT byte encoding for semantic color values out of root `style.mbt` and +into `moonbit-community/tty/internal/vt`, while keeping root `Tty` color command +methods as the public user-facing API. + +## Status + +Done. + +## Context And Decisions + +- The root package currently owns private helpers that map `@color.Color` values + to SGR byte sequences. +- `internal/vt` already owns pure VT byte construction for cursor, screen, + erase, scroll, mode, and fixed SGR attribute commands. +- `color/` remains the semantic color value package and should not construct + terminal byte sequences or write to streams. +- `internal/vt` may depend on `moonbit-community/tty/color` because it is an + implementation package used by root `Tty` methods, not downstream API. +- Root `Tty` methods remain command-style methods that only write generated + bytes and do not track terminal style state. + +## References Or Standards + +- ECMA-48 SGR parameters: + - SGR 0 resets all SGR style attributes. + - SGR 30-37 and 90-97 set basic and bright foreground colors. + - SGR 40-47 and 100-107 set basic and bright background colors. + - SGR 38;5;n and 48;5;n set indexed foreground/background colors. + - SGR 38;2;r;g;b and 48;2;r;g;b set truecolor foreground/background colors. + - SGR 39 and 49 reset foreground/background colors to terminal defaults. + +## Target Files + +- `docs/plans/2026-06-15-move-color-encoding-to-internal-vt.md` +- `docs/architecture.md` +- `internal/vt/moon.pkg` +- `internal/vt/sgr.mbt` +- `internal/vt/sgr_test.mbt` +- `internal/vt/pkg.generated.mbti` +- `style.mbt` +- `tty_wbtest.mbt` + +## Public API Changes + +Root package public API should remain unchanged: + +- `pkg.generated.mbti` should have no diff. +- `color/pkg.generated.mbti` should have no diff. + +Internal VT package API is expected to gain: + +- `reset_style : Bytes` +- `reset_foreground : Bytes` +- `reset_background : Bytes` +- `set_foreground(@color.Color) -> Bytes` +- `set_background(@color.Color) -> Bytes` + +The internal VT package `.mbti` is not downstream public API, but it is still +reviewed as an internal package boundary. + +## Invariants + +- `internal/vt` remains pure byte construction and does not write to or own an + output stream. +- `internal/vt` does not import or depend on the root `tty` package. +- `color/` remains value-only and does not construct SGR bytes. +- Root `Tty` color methods keep their signatures and behavior. +- Numeric indexed and RGB channels remain clamped to `0..255` at the SGR + encoding boundary. + +## Acceptance Criteria + +- The root `style.mbt` no longer contains color-to-SGR encoding helpers. +- Root `Tty::set_foreground`, `Tty::set_background`, `Tty::reset_foreground`, + `Tty::reset_background`, and `Tty::reset_style` delegate to `@vt`. +- `internal/vt` tests cover default, basic, bright, indexed, truecolor, and + reset color sequences. +- Existing root pipe tests still prove `Tty` writes the same command bytes. + +## Validation Commands + +```sh +moon fmt +moon test internal/vt +moon test . +moon check +moon info +moon test +git diff --check +``` + +## Public API Audit + +- Root `pkg.generated.mbti` has no diff. +- `color/pkg.generated.mbti` has no diff. +- `internal/vt/pkg.generated.mbti` gained only the planned `@color` import, + reset byte constants, and foreground/background color SGR constructors. +- No root private helper leaked into downstream public API. +- The internal VT `.mbti` diff was reviewed after `moon info`. + +## Result Notes + +- Moved semantic color SGR encoding helpers from root `style.mbt` into + `internal/vt/sgr.mbt`. +- Root `Tty` color command methods now delegate to `@vt.set_foreground`, + `@vt.set_background`, `@vt.reset_foreground`, `@vt.reset_background`, and + `@vt.reset_style`. +- Moved direct color encoding tests from root white-box tests to + `internal/vt/sgr_test.mbt`; root pipe tests still cover end-to-end command + bytes. +- `moon fmt` passed. +- `moon test internal/vt` passed: 20 tests. +- `moon test .` passed: 24 tests. +- `moon check` passed. +- `moon info` passed. +- `moon test` passed: 165 tests. +- `git diff --check` passed. + +## Open Questions + +- None after approval on 2026-06-15. diff --git a/internal/vt/moon.pkg b/internal/vt/moon.pkg index 92fcc88..7c59fa6 100644 --- a/internal/vt/moon.pkg +++ b/internal/vt/moon.pkg @@ -1,3 +1,7 @@ +import { + "moonbit-community/tty/color", +} + import { "moonbitlang/core/bench", "moonbitlang/core/debug", diff --git a/internal/vt/pkg.generated.mbti b/internal/vt/pkg.generated.mbti index a0a18cc..0bebdbd 100644 --- a/internal/vt/pkg.generated.mbti +++ b/internal/vt/pkg.generated.mbti @@ -1,6 +1,10 @@ // Generated using `moon info`, DON'T EDIT IT package "moonbit-community/tty/internal/vt" +import { + "moonbit-community/tty/color", +} + // Values pub let begin_synchronized_update : Bytes @@ -86,12 +90,18 @@ pub let request_cursor_position : Bytes pub let request_primary_device_attributes : Bytes +pub let reset_background : Bytes + pub let reset_bold : Bytes +pub let reset_foreground : Bytes + pub let reset_italic : Bytes pub let reset_reverse : Bytes +pub let reset_style : Bytes + pub let reset_top_bottom_margins : Bytes pub let reset_underline : Bytes @@ -100,6 +110,10 @@ pub let reverse : Bytes pub let reverse_index : Bytes +pub fn set_background(@color.Color) -> Bytes + +pub fn set_foreground(@color.Color) -> Bytes + pub fn set_top_bottom_margins(Int, Int) -> Bytes pub fn sgr1(Int) -> Bytes diff --git a/internal/vt/sgr.mbt b/internal/vt/sgr.mbt index 53e5db6..d3513e2 100644 --- a/internal/vt/sgr.mbt +++ b/internal/vt/sgr.mbt @@ -1,3 +1,15 @@ +///| +/// Reset all SGR style attributes (SGR 0), ECMA-48. +pub let reset_style : Bytes = b"\x1b[0m" + +///| +/// Reset foreground color to terminal default (SGR 39), ECMA-48. +pub let reset_foreground : Bytes = b"\x1b[39m" + +///| +/// Reset background color to terminal default (SGR 49), ECMA-48. +pub let reset_background : Bytes = b"\x1b[49m" + ///| /// Bold or increased intensity (SGR 1), ECMA-48. pub let bold : Bytes = b"\x1b[1m" @@ -80,3 +92,82 @@ pub fn sgr5(p1 : Int, p2 : Int, p3 : Int, p4 : Int, p5 : Int) -> Bytes { buf[offset] = b'm' buf.unsafe_reinterpret_as_bytes() } + +///| +fn clamp_byte_value(value : Int) -> Int { + if value < 0 { + 0 + } else if value > 255 { + 255 + } else { + value + } +} + +///| +fn basic_foreground_code(color : @color.BasicColor) -> Int { + match color { + Black => 30 + Red => 31 + Green => 32 + Yellow => 33 + Blue => 34 + Magenta => 35 + Cyan => 36 + White => 37 + BrightBlack => 90 + BrightRed => 91 + BrightGreen => 92 + BrightYellow => 93 + BrightBlue => 94 + BrightMagenta => 95 + BrightCyan => 96 + BrightWhite => 97 + } +} + +///| +fn basic_background_code(color : @color.BasicColor) -> Int { + match color { + Black => 40 + Red => 41 + Green => 42 + Yellow => 43 + Blue => 44 + Magenta => 45 + Cyan => 46 + White => 47 + BrightBlack => 100 + BrightRed => 101 + BrightGreen => 102 + BrightYellow => 103 + BrightBlue => 104 + BrightMagenta => 105 + BrightCyan => 106 + BrightWhite => 107 + } +} + +///| +/// 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)) + Rgb(r, g, b) => + sgr5(38, 2, clamp_byte_value(r), clamp_byte_value(g), clamp_byte_value(b)) + } +} + +///| +/// Construct the SGR sequence for a background color command. +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)) + } +} diff --git a/internal/vt/sgr_test.mbt b/internal/vt/sgr_test.mbt index 32349c8..f756e90 100644 --- a/internal/vt/sgr_test.mbt +++ b/internal/vt/sgr_test.mbt @@ -8,7 +8,42 @@ test "sgr sequences" { @debug.assert_eq(@vt.reset_underline, b"\x1b[24m") @debug.assert_eq(@vt.reverse, b"\x1b[7m") @debug.assert_eq(@vt.reset_reverse, b"\x1b[27m") + @debug.assert_eq(@vt.reset_style, b"\x1b[0m") + @debug.assert_eq(@vt.reset_foreground, b"\x1b[39m") + @debug.assert_eq(@vt.reset_background, b"\x1b[49m") @debug.assert_eq(@vt.sgr1(0), b"\x1b[0m") @debug.assert_eq(@vt.sgr3(38, 5, 42), b"\x1b[38;5;42m") @debug.assert_eq(@vt.sgr5(38, 2, 1, 127, 255), b"\x1b[38;2;1;127;255m") } + +///| +test "sgr color sequences encode semantic colors" { + @debug.assert_eq(@vt.set_foreground(Default), b"\x1b[39m") + @debug.assert_eq(@vt.set_foreground(Basic(Black)), b"\x1b[30m") + @debug.assert_eq(@vt.set_foreground(Basic(Red)), b"\x1b[31m") + @debug.assert_eq(@vt.set_foreground(Basic(White)), b"\x1b[37m") + @debug.assert_eq(@vt.set_foreground(Basic(BrightBlack)), b"\x1b[90m") + @debug.assert_eq(@vt.set_foreground(Basic(BrightRed)), b"\x1b[91m") + @debug.assert_eq(@vt.set_foreground(Basic(BrightWhite)), b"\x1b[97m") + @debug.assert_eq(@vt.set_background(Default), b"\x1b[49m") + @debug.assert_eq(@vt.set_background(Basic(Black)), b"\x1b[40m") + @debug.assert_eq(@vt.set_background(Basic(Red)), b"\x1b[41m") + @debug.assert_eq(@vt.set_background(Basic(White)), b"\x1b[47m") + @debug.assert_eq(@vt.set_background(Basic(BrightBlack)), b"\x1b[100m") + @debug.assert_eq(@vt.set_background(Basic(BrightRed)), b"\x1b[101m") + @debug.assert_eq(@vt.set_background(Basic(BrightWhite)), b"\x1b[107m") + @debug.assert_eq(@vt.set_foreground(Indexed(-1)), b"\x1b[38;5;0m") + @debug.assert_eq(@vt.set_foreground(Indexed(42)), b"\x1b[38;5;42m") + @debug.assert_eq(@vt.set_foreground(Indexed(999)), b"\x1b[38;5;255m") + @debug.assert_eq(@vt.set_background(Indexed(-1)), b"\x1b[48;5;0m") + @debug.assert_eq(@vt.set_background(Indexed(42)), b"\x1b[48;5;42m") + @debug.assert_eq(@vt.set_background(Indexed(999)), b"\x1b[48;5;255m") + @debug.assert_eq( + @vt.set_foreground(Rgb(-1, 127, 999)), + b"\x1b[38;2;0;127;255m", + ) + @debug.assert_eq( + @vt.set_background(Rgb(-1, 127, 999)), + b"\x1b[48;2;0;127;255m", + ) +} diff --git a/moon.pkg b/moon.pkg index 340a859..20209cc 100644 --- a/moon.pkg +++ b/moon.pkg @@ -20,6 +20,9 @@ import { "moonbitlang/async", "moonbitlang/async/pipe" @async/pipe, "moonbitlang/async/stdio" @async/stdio, + "moonbitlang/core/debug", + "moonbit-community/tty/color", + "moonbit-community/tty/input" @public_input, } for "test" import { diff --git a/style.mbt b/style.mbt index f0c5a06..ec56b69 100644 --- a/style.mbt +++ b/style.mbt @@ -1,101 +1,3 @@ -///| -let reset_style_sequence : Bytes = @vt.sgr1(0) - -///| -let reset_foreground_sequence : Bytes = @vt.sgr1(39) - -///| -let reset_background_sequence : Bytes = @vt.sgr1(49) - -///| -fn clamp_byte_value(value : Int) -> Int { - if value < 0 { - 0 - } else if value > 255 { - 255 - } else { - value - } -} - -///| -fn basic_foreground_code(color : @color.BasicColor) -> Int { - match color { - Black => 30 - Red => 31 - Green => 32 - Yellow => 33 - Blue => 34 - Magenta => 35 - Cyan => 36 - White => 37 - BrightBlack => 90 - BrightRed => 91 - BrightGreen => 92 - BrightYellow => 93 - BrightBlue => 94 - BrightMagenta => 95 - BrightCyan => 96 - BrightWhite => 97 - } -} - -///| -fn basic_background_code(color : @color.BasicColor) -> Int { - match color { - Black => 40 - Red => 41 - Green => 42 - Yellow => 43 - Blue => 44 - Magenta => 45 - Cyan => 46 - White => 47 - BrightBlack => 100 - BrightRed => 101 - BrightGreen => 102 - BrightYellow => 103 - BrightBlue => 104 - BrightMagenta => 105 - BrightCyan => 106 - BrightWhite => 107 - } -} - -///| -fn foreground_sequence(color : @color.Color) -> Bytes { - match color { - Default => reset_foreground_sequence - Basic(color) => @vt.sgr1(basic_foreground_code(color)) - Indexed(index) => @vt.sgr3(38, 5, clamp_byte_value(index)) - Rgb(r, g, b) => - @vt.sgr5( - 38, - 2, - clamp_byte_value(r), - clamp_byte_value(g), - clamp_byte_value(b), - ) - } -} - -///| -fn background_sequence(color : @color.Color) -> Bytes { - match color { - Default => reset_background_sequence - Basic(color) => @vt.sgr1(basic_background_code(color)) - Indexed(index) => @vt.sgr3(48, 5, clamp_byte_value(index)) - Rgb(r, g, b) => - @vt.sgr5( - 48, - 2, - clamp_byte_value(r), - clamp_byte_value(g), - clamp_byte_value(b), - ) - } -} - ///| /// Enter the alternate screen buffer. pub async fn Tty::enter_alt_screen(self : Self) -> Unit { @@ -523,25 +425,25 @@ pub async fn Tty::reverse_index(self : Self) -> Unit { ///| /// Set the foreground color. pub async fn Tty::set_foreground(self : Self, color : @color.Color) -> Unit { - self.write(foreground_sequence(color)) + self.write(@vt.set_foreground(color)) } ///| /// Set the background color. pub async fn Tty::set_background(self : Self, color : @color.Color) -> Unit { - self.write(background_sequence(color)) + self.write(@vt.set_background(color)) } ///| /// Reset the foreground color to the terminal default. pub async fn Tty::reset_foreground(self : Self) -> Unit { - self.write(reset_foreground_sequence) + self.write(@vt.reset_foreground) } ///| /// Reset the background color to the terminal default. pub async fn Tty::reset_background(self : Self) -> Unit { - self.write(reset_background_sequence) + self.write(@vt.reset_background) } ///| @@ -595,5 +497,5 @@ pub async fn Tty::reset_reverse(self : Self) -> Unit { ///| /// Reset all SGR style attributes. pub async fn Tty::reset_style(self : Self) -> Unit { - self.write(reset_style_sequence) + self.write(@vt.reset_style) } diff --git a/tty_test.mbt b/tty_test.mbt index ff6cd7a..e771de0 100644 --- a/tty_test.mbt +++ b/tty_test.mbt @@ -1,15 +1,323 @@ ///| -async fn with_pipe_tty_output(f : async (@tty.Tty) -> Unit) -> String { - let (input, input_writer) = @async/pipe.pipe() - let (output_reader, output) = @async/pipe.pipe() - defer output_reader.close() - input_writer.close() - { - let tty = @tty.Tty::new(input, output) - defer tty.close() - f(tty) +async fn with_pipe_tty( + input_bytes : Bytes, + f : async (@tty.Tty) -> Unit, +) -> String { + @async.with_task_group() <| group => { + let (input, input_writer) = @async/pipe.pipe() + let (output_reader, output) = @async/pipe.pipe() + defer output_reader.close() + if input_bytes.length() == 0 { + input_writer.close() + } else { + group.spawn_bg() <| () => { + defer input_writer.close() + input_writer.write(input_bytes) + } + } + { + let tty = @tty.Tty::new(input, output) + defer tty.close() + f(tty) + } + output_reader.read_all().text() } - output_reader.read_all().text() +} + +///| +async test "pipe tty terminal commands write sequences" { + let output = with_pipe_tty(b"", tty => { + tty.enter_alt_screen() + tty.enable_bracketed_paste() + tty.hide_cursor() + tty.set_cursor_position(3, 4) + tty.cursor_up(2) + tty.cursor_forward(3) + tty.erase_line_all() + tty.erase_display() + tty.erase_scrollback() + tty.set_top_bottom_margins(1, 5) + tty.reverse_index() + tty.reset_top_bottom_margins() + tty.set_foreground(Basic(Red)) + tty.reset_foreground() + tty.set_background(Indexed(42)) + tty.reset_background() + tty.bold() + tty.reset_bold() + tty.italic() + tty.reset_italic() + tty.underline() + tty.reset_underline() + tty.reverse() + tty.reset_reverse() + tty.reset_style() + tty.show_cursor() + tty.disable_bracketed_paste() + tty.leave_alt_screen() + }) + inspect( + output, + content="\u{1B}[?1049h\u{1B}[?2004h\u{1B}[?25l\u{1B}[3;4H\u{1B}[2A\u{1B}[3C\u{1B}[2K\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}[?25h\u{1B}[?2004l\u{1B}[?1049l", + ) +} + +///| +async test "pipe tty mouse mode commands write sequences" { + let output = with_pipe_tty(b"", tty => { + tty.enable_mouse(Click) + tty.disable_mouse() + tty.enable_mouse(Drag) + tty.disable_mouse() + tty.enable_mouse(Motion) + tty.disable_mouse() + }) + inspect( + output, + content="\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1000h\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1002h\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1003h\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l", + ) +} + +///| +async test "pipe tty focus tracking commands write sequences" { + let output = with_pipe_tty(b"", tty => { + tty.enable_focus_tracking() + tty.disable_focus_tracking() + }) + inspect(output, content="\u{1B}[?1004h\u{1B}[?1004l") +} + +///| +async test "pipe tty auto wrap commands write sequences" { + let output = with_pipe_tty(b"", tty => { + tty.enable_auto_wrap() + tty.disable_auto_wrap() + }) + inspect(output, content="\u{1B}[?7h\u{1B}[?7l") +} + +///| +async test "pipe tty with focus tracking restores on success" { + let output = with_pipe_tty(b"", tty => { + tty.with_focus_tracking(() => tty.write("body")) + }) + inspect(output, content="\u{1B}[?1004hbody\u{1B}[?1004l") +} + +///| +test "keyboard enhancement flags encode bits" { + let flags = @tty.KeyboardEnhancementFlags::new( + disambiguate_escape_codes=true, + report_event_types=true, + report_alternate_keys=true, + report_all_keys_as_escape_codes=true, + report_associated_text=true, + ) + @debug.assert_eq(flags.bits(), 31) + @debug.assert_eq(flags.disambiguate_escape_codes(), true) + @debug.assert_eq(flags.report_event_types(), true) + @debug.assert_eq(flags.report_alternate_keys(), true) + @debug.assert_eq(flags.report_all_keys_as_escape_codes(), true) + @debug.assert_eq(flags.report_associated_text(), true) + @debug.assert_eq(@tty.KeyboardEnhancementFlags::disambiguate().bits(), 1) + @debug.assert_eq(@tty.KeyboardEnhancementFlags::full().bits(), 31) +} + +///| +async test "pipe tty keyboard enhancement commands write sequences" { + let output = with_pipe_tty(b"", tty => { + tty.push_keyboard_enhancement_flags( + @tty.KeyboardEnhancementFlags::new( + disambiguate_escape_codes=true, + report_event_types=true, + ), + ) + tty.pop_keyboard_enhancement_flags() + }) + inspect(output, content="\u{1B}[>3u\u{1B}[<1u") +} + +///| +async test "pipe tty with keyboard enhancements restores on success" { + let output = with_pipe_tty(b"", tty => { + tty.with_keyboard_enhancements( + @tty.KeyboardEnhancementFlags::disambiguate(), + () => tty.write("body"), + ) + }) + inspect(output, content="\u{1B}[>1ubody\u{1B}[<1u") +} + +///| +async test "pipe tty with kitty keyboard uses full flags" { + let output = with_pipe_tty(b"", tty => { + tty.with_kitty_keyboard(() => tty.write("body")) + }) + inspect(output, content="\u{1B}[>31ubody\u{1B}[<1u") +} + +///| +async test "pipe tty with mouse restores on success" { + let output = with_pipe_tty(b"", tty => { + tty.with_mouse(Drag, () => tty.write("body")) + }) + inspect( + output, + content="\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1002hbody\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l", + ) +} + +///| +async test "tty query cursor position coordinates input and output" { + let output = with_pipe_tty(b"a\x1b[7;9R", tty => { + match tty.query_cursor_position() { + Some(pos) => { + @debug.assert_eq(pos.row, 7) + @debug.assert_eq(pos.col, 9) + } + None => fail("expected cursor position") + } + match tty.read_event() { + Input(event) => + @debug.assert_eq( + event, + Key(@public_input.KeyEvent::new(Char('a'), text="a")), + ) + Resize(_) => fail("expected pending input") + } + }) + inspect(output, content="\u{1B}[6n") +} + +///| +async test "tty query cursor position preserves interleaved focus input" { + let output = with_pipe_tty(b"\x1b[I\x1b[7;9R", tty => { + match tty.query_cursor_position() { + Some(pos) => { + @debug.assert_eq(pos.row, 7) + @debug.assert_eq(pos.col, 9) + } + None => fail("expected cursor position") + } + match tty.read_event() { + Input(event) => @debug.assert_eq(event, FocusIn) + Resize(_) => fail("expected pending input") + } + }) + inspect(output, content="\u{1B}[6n") +} + +///| +async test "tty query dynamic colors coordinates input and output" { + let output = with_pipe_tty( + b"a\x1b]10;rgb:ff/00/80\x07\x1b]11;rgb:0000/ffff/0000\x1b\\\x1b]12;rgb:0/0/f\x07", + tty => { + match tty.query_default_foreground_color() { + Some(color) => { + @debug.assert_eq(color.red, 65535) + @debug.assert_eq(color.green, 0) + @debug.assert_eq(color.blue, 32896) + } + None => fail("expected foreground color") + } + match tty.query_default_background_color() { + Some(color) => { + @debug.assert_eq(color.red, 0) + @debug.assert_eq(color.green, 65535) + @debug.assert_eq(color.blue, 0) + } + None => fail("expected background color") + } + match tty.query_cursor_color() { + Some(color) => { + @debug.assert_eq(color.red, 0) + @debug.assert_eq(color.green, 0) + @debug.assert_eq(color.blue, 65535) + } + None => fail("expected cursor color") + } + match tty.read_event() { + Input(event) => + @debug.assert_eq( + event, + Key(@public_input.KeyEvent::new(Char('a'), text="a")), + ) + Resize(_) => fail("expected pending input") + } + }, + ) + inspect( + output, + content="\u{1B}]10;?\u{1B}\\\u{1B}]11;?\u{1B}\\\u{1B}]12;?\u{1B}\\", + ) +} + +///| +async test "tty query kitty keyboard support returns true for flags reply" { + let output = with_pipe_tty(b"\x1b[?1u", tty => { + @debug.assert_eq(tty.query_kitty_keyboard_support(), true) + }) + inspect(output, content="\u{1B}[?u\u{1B}[c") +} + +///| +async test "tty query kitty keyboard support returns false for da reply" { + let output = with_pipe_tty(b"\x1b[?1;2c", tty => { + @debug.assert_eq(tty.query_kitty_keyboard_support(), false) + }) + inspect(output, content="\u{1B}[?u\u{1B}[c") +} + +///| +async test "tty query kitty keyboard support preserves interleaved input" { + let output = with_pipe_tty(b"a\x1b[?1u", tty => { + @debug.assert_eq(tty.query_kitty_keyboard_support(), true) + match tty.read_event() { + Input(event) => + @debug.assert_eq( + event, + Key(@public_input.KeyEvent::new(Char('a'), text="a")), + ) + Resize(_) => fail("expected pending input") + } + }) + inspect(output, content="\u{1B}[?u\u{1B}[c") +} + +///| +async test "tty window size rejects non tty output" { + ignore( + with_pipe_tty(b"", tty => { + try tty.window_size() catch { + _ => () + } noraise { + _ => fail("expected window_size to reject non tty output") + } + }), + ) +} + +///| +async test "tty raw mode operations reject non tty pipe" { + ignore( + with_pipe_tty(b"", tty => { + try tty.get_state() catch { + _ => () + } noraise { + _ => fail("expected get_state to reject non tty pipe") + } + try tty.enter_raw_mode() catch { + _ => () + } noraise { + _ => fail("expected enter_raw_mode to reject non tty pipe") + } + try tty.with_raw_mode(() => ()) catch { + _ => () + } noraise { + _ => fail("expected with_raw_mode to reject non tty pipe") + } + }), + ) } ///| @@ -35,7 +343,7 @@ async test "Tty::open() when controlling tty is available" { ///| async test "pipe tty with alt screen restores on success" { - let output = with_pipe_tty_output(tty => { + let output = with_pipe_tty(b"", tty => { tty.with_alt_screen(() => tty.write("body")) }) inspect(output, content="\u{1B}[?1049hbody\u{1B}[?1049l") @@ -43,7 +351,7 @@ async test "pipe tty with alt screen restores on success" { ///| async test "pipe tty with top bottom margins restores on success" { - let output = with_pipe_tty_output(tty => { + let output = with_pipe_tty(b"", tty => { let value = tty.with_top_bottom_margins(2, 7, () => { tty.write("body") 42 diff --git a/tty_wbtest.mbt b/tty_wbtest.mbt index 13fcf0b..edadc71 100644 --- a/tty_wbtest.mbt +++ b/tty_wbtest.mbt @@ -1,328 +1,15 @@ ///| -async fn with_pipe_tty(input_bytes : Bytes, f : async (Tty) -> Unit) -> String { - @async.with_task_group() <| group => { - let (input, input_writer) = @async/pipe.pipe() - let (output_reader, output) = @async/pipe.pipe() - defer output_reader.close() - if input_bytes.length() == 0 { - input_writer.close() - } else { - group.spawn_bg() <| () => { - defer input_writer.close() - input_writer.write(input_bytes) - } - } - { - let tty = Tty::new(input, output) - defer tty.close() - f(tty) - } - output_reader.read_all().text() +async fn with_pipe_tty(f : async (Tty) -> Unit) -> String { + let (input, input_writer) = @async/pipe.pipe() + let (output_reader, output) = @async/pipe.pipe() + defer output_reader.close() + input_writer.close() + { + let tty = Tty::new(input, output) + defer tty.close() + f(tty) } -} - -///| -test "style color sequences encode semantic colors" { - @debug.assert_eq(foreground_sequence(Default), b"\x1b[39m") - @debug.assert_eq(foreground_sequence(Basic(Black)), b"\x1b[30m") - @debug.assert_eq(foreground_sequence(Basic(Red)), b"\x1b[31m") - @debug.assert_eq(foreground_sequence(Basic(White)), b"\x1b[37m") - @debug.assert_eq(foreground_sequence(Basic(BrightBlack)), b"\x1b[90m") - @debug.assert_eq(foreground_sequence(Basic(BrightRed)), b"\x1b[91m") - @debug.assert_eq(foreground_sequence(Basic(BrightWhite)), b"\x1b[97m") - @debug.assert_eq(background_sequence(Default), b"\x1b[49m") - @debug.assert_eq(background_sequence(Basic(Black)), b"\x1b[40m") - @debug.assert_eq(background_sequence(Basic(Red)), b"\x1b[41m") - @debug.assert_eq(background_sequence(Basic(White)), b"\x1b[47m") - @debug.assert_eq(background_sequence(Basic(BrightBlack)), b"\x1b[100m") - @debug.assert_eq(background_sequence(Basic(BrightRed)), b"\x1b[101m") - @debug.assert_eq(background_sequence(Basic(BrightWhite)), b"\x1b[107m") - @debug.assert_eq(foreground_sequence(Indexed(-1)), b"\x1b[38;5;0m") - @debug.assert_eq(foreground_sequence(Indexed(42)), b"\x1b[38;5;42m") - @debug.assert_eq(foreground_sequence(Indexed(999)), b"\x1b[38;5;255m") - @debug.assert_eq(background_sequence(Indexed(-1)), b"\x1b[48;5;0m") - @debug.assert_eq(background_sequence(Indexed(42)), b"\x1b[48;5;42m") - @debug.assert_eq(background_sequence(Indexed(999)), b"\x1b[48;5;255m") - @debug.assert_eq( - foreground_sequence(Rgb(-1, 127, 999)), - b"\x1b[38;2;0;127;255m", - ) - @debug.assert_eq( - background_sequence(Rgb(-1, 127, 999)), - b"\x1b[48;2;0;127;255m", - ) -} - -///| -async test "pipe tty terminal commands write sequences" { - let output = with_pipe_tty(b"", tty => { - tty.enter_alt_screen() - tty.enable_bracketed_paste() - tty.hide_cursor() - tty.set_cursor_position(3, 4) - tty.cursor_up(2) - tty.cursor_forward(3) - tty.erase_line_all() - tty.erase_display() - tty.erase_scrollback() - tty.set_top_bottom_margins(1, 5) - tty.reverse_index() - tty.reset_top_bottom_margins() - tty.set_foreground(Basic(Red)) - tty.reset_foreground() - tty.set_background(Indexed(42)) - tty.reset_background() - tty.bold() - tty.reset_bold() - tty.italic() - tty.reset_italic() - tty.underline() - tty.reset_underline() - tty.reverse() - tty.reset_reverse() - tty.reset_style() - tty.show_cursor() - tty.disable_bracketed_paste() - tty.leave_alt_screen() - }) - inspect( - output, - content="\u{1B}[?1049h\u{1B}[?2004h\u{1B}[?25l\u{1B}[3;4H\u{1B}[2A\u{1B}[3C\u{1B}[2K\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}[?25h\u{1B}[?2004l\u{1B}[?1049l", - ) -} - -///| -async test "pipe tty mouse mode commands write sequences" { - let output = with_pipe_tty(b"", tty => { - tty.enable_mouse(Click) - tty.disable_mouse() - tty.enable_mouse(Drag) - tty.disable_mouse() - tty.enable_mouse(Motion) - tty.disable_mouse() - }) - inspect( - output, - content="\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1000h\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1002h\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1003h\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l", - ) -} - -///| -async test "pipe tty focus tracking commands write sequences" { - let output = with_pipe_tty(b"", tty => { - tty.enable_focus_tracking() - tty.disable_focus_tracking() - }) - inspect(output, content="\u{1B}[?1004h\u{1B}[?1004l") -} - -///| -async test "pipe tty auto wrap commands write sequences" { - let output = with_pipe_tty(b"", tty => { - tty.enable_auto_wrap() - tty.disable_auto_wrap() - }) - inspect(output, content="\u{1B}[?7h\u{1B}[?7l") -} - -///| -async test "pipe tty with focus tracking restores on success" { - let output = with_pipe_tty(b"", tty => { - tty.with_focus_tracking(() => tty.write("body")) - }) - inspect(output, content="\u{1B}[?1004hbody\u{1B}[?1004l") -} - -///| -test "keyboard enhancement flags encode bits" { - let flags = KeyboardEnhancementFlags::new( - disambiguate_escape_codes=true, - report_event_types=true, - report_alternate_keys=true, - report_all_keys_as_escape_codes=true, - report_associated_text=true, - ) - @debug.assert_eq(flags.bits(), 31) - @debug.assert_eq(flags.disambiguate_escape_codes(), true) - @debug.assert_eq(flags.report_event_types(), true) - @debug.assert_eq(flags.report_alternate_keys(), true) - @debug.assert_eq(flags.report_all_keys_as_escape_codes(), true) - @debug.assert_eq(flags.report_associated_text(), true) - @debug.assert_eq(KeyboardEnhancementFlags::disambiguate().bits(), 1) - @debug.assert_eq(KeyboardEnhancementFlags::full().bits(), 31) -} - -///| -async test "pipe tty keyboard enhancement commands write sequences" { - let output = with_pipe_tty(b"", tty => { - tty.push_keyboard_enhancement_flags( - KeyboardEnhancementFlags::new( - disambiguate_escape_codes=true, - report_event_types=true, - ), - ) - tty.pop_keyboard_enhancement_flags() - }) - inspect(output, content="\u{1B}[>3u\u{1B}[<1u") -} - -///| -async test "pipe tty with keyboard enhancements restores on success" { - let output = with_pipe_tty(b"", tty => { - tty.with_keyboard_enhancements(KeyboardEnhancementFlags::disambiguate(), () => { - tty.write("body") - }) - }) - inspect(output, content="\u{1B}[>1ubody\u{1B}[<1u") -} - -///| -async test "pipe tty with kitty keyboard uses full flags" { - let output = with_pipe_tty(b"", tty => { - tty.with_kitty_keyboard(() => tty.write("body")) - }) - inspect(output, content="\u{1B}[>31ubody\u{1B}[<1u") -} - -///| -async test "pipe tty with mouse restores on success" { - let output = with_pipe_tty(b"", tty => { - tty.with_mouse(Drag, () => tty.write("body")) - }) - inspect( - output, - content="\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006h\u{1B}[?1002hbody\u{1B}[?1003l\u{1B}[?1002l\u{1B}[?1000l\u{1B}[?1006l", - ) -} - -///| -async test "tty query cursor position coordinates input and output" { - let output = with_pipe_tty(b"a\x1b[7;9R", tty => { - match tty.query_cursor_position() { - Some(pos) => { - @debug.assert_eq(pos.row, 7) - @debug.assert_eq(pos.col, 9) - } - None => fail("expected cursor position") - } - match tty.read_event() { - Input(event) => - @debug.assert_eq( - event, - Key(@public_input.KeyEvent::new(Char('a'), text="a")), - ) - Resize(_) => fail("expected pending input") - } - }) - inspect(output, content="\u{1B}[6n") -} - -///| -async test "tty query cursor position preserves interleaved focus input" { - let output = with_pipe_tty(b"\x1b[I\x1b[7;9R", tty => { - match tty.query_cursor_position() { - Some(pos) => { - @debug.assert_eq(pos.row, 7) - @debug.assert_eq(pos.col, 9) - } - None => fail("expected cursor position") - } - match tty.read_event() { - Input(event) => @debug.assert_eq(event, FocusIn) - Resize(_) => fail("expected pending input") - } - }) - inspect(output, content="\u{1B}[6n") -} - -///| -async test "tty query dynamic colors coordinates input and output" { - let output = with_pipe_tty( - b"a\x1b]10;rgb:ff/00/80\x07\x1b]11;rgb:0000/ffff/0000\x1b\\\x1b]12;rgb:0/0/f\x07", - tty => { - match tty.query_default_foreground_color() { - Some(color) => { - @debug.assert_eq(color.red, 65535) - @debug.assert_eq(color.green, 0) - @debug.assert_eq(color.blue, 32896) - } - None => fail("expected foreground color") - } - match tty.query_default_background_color() { - Some(color) => { - @debug.assert_eq(color.red, 0) - @debug.assert_eq(color.green, 65535) - @debug.assert_eq(color.blue, 0) - } - None => fail("expected background color") - } - match tty.query_cursor_color() { - Some(color) => { - @debug.assert_eq(color.red, 0) - @debug.assert_eq(color.green, 0) - @debug.assert_eq(color.blue, 65535) - } - None => fail("expected cursor color") - } - match tty.read_event() { - Input(event) => - @debug.assert_eq( - event, - Key(@public_input.KeyEvent::new(Char('a'), text="a")), - ) - Resize(_) => fail("expected pending input") - } - }, - ) - inspect( - output, - content="\u{1B}]10;?\u{1B}\\\u{1B}]11;?\u{1B}\\\u{1B}]12;?\u{1B}\\", - ) -} - -///| -async test "tty query kitty keyboard support returns true for flags reply" { - let output = with_pipe_tty(b"\x1b[?1u", tty => { - @debug.assert_eq(tty.query_kitty_keyboard_support(), true) - }) - inspect(output, content="\u{1B}[?u\u{1B}[c") -} - -///| -async test "tty query kitty keyboard support returns false for da reply" { - let output = with_pipe_tty(b"\x1b[?1;2c", tty => { - @debug.assert_eq(tty.query_kitty_keyboard_support(), false) - }) - inspect(output, content="\u{1B}[?u\u{1B}[c") -} - -///| -async test "tty query kitty keyboard support preserves interleaved input" { - let output = with_pipe_tty(b"a\x1b[?1u", tty => { - @debug.assert_eq(tty.query_kitty_keyboard_support(), true) - match tty.read_event() { - Input(event) => - @debug.assert_eq( - event, - Key(@public_input.KeyEvent::new(Char('a'), text="a")), - ) - Resize(_) => fail("expected pending input") - } - }) - inspect(output, content="\u{1B}[?u\u{1B}[c") -} - -///| -async test "tty window size rejects non tty output" { - ignore( - with_pipe_tty(b"", tty => { - try tty.window_size() catch { - _ => () - } noraise { - _ => fail("expected window_size to reject non tty output") - } - }), - ) + output_reader.read_all().text() } ///| @@ -337,7 +24,7 @@ test "state buffer can be made raw" { ///| async test "tty state operations reject non tty pipe" { ignore( - with_pipe_tty(b"", tty => { + with_pipe_tty(tty => { let state = State::new() try tty.get_state() catch { _ => ()