diff --git a/.github/workflows/build-gems.yml b/.github/workflows/build-gems.yml index cb0dcac2..ec849621 100644 --- a/.github/workflows/build-gems.yml +++ b/.github/workflows/build-gems.yml @@ -11,11 +11,12 @@ on: jobs: build: - name: Build (${{ matrix.os }}) + name: Build (${{ matrix.os }}, Ruby ${{ matrix.ruby }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + ruby: ["3.2", "3.3", "3.4", "4.0"] os: [ubuntu-latest, macos-latest, windows-latest] env: CI: "true" @@ -25,13 +26,13 @@ jobs: - name: Override Ruby version in mise.toml shell: bash - run: sed -i.bak 's/ruby = .*/ruby = "4.0"/' mise.toml + run: sed -i.bak 's/ruby = .*/ruby = "${{ matrix.ruby }}"/' mise.toml - name: Install Ruby via RubyInstaller (Windows) if: runner.os == 'Windows' uses: ruby/setup-ruby@v1 with: - ruby-version: "4.0" + ruby-version: ${{ matrix.ruby }} - name: Configure Windows build environment if: runner.os == 'Windows' @@ -60,11 +61,79 @@ jobs: - name: Default task run: mise x -- bundle exec rake - - name: Build native gem - run: mise x -- bundle exec rake native gem + - name: Upload compiled binary + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.os }}-ruby${{ matrix.ruby }} + path: lib/ratatui_ruby/ratatui_ruby.* + if-no-files-found: error + + package: + name: Package (${{ matrix.os }}) + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install Ruby via RubyInstaller (Windows) + if: runner.os == 'Windows' + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + + - name: Override Ruby version in mise.toml (Unix) + if: runner.os != 'Windows' + shell: bash + run: sed -i.bak 's/ruby = .*/ruby = "3.4"/' mise.toml + + - name: Install toolchain via mise (Unix) + if: runner.os != 'Windows' + uses: jdx/mise-action@v2 + + - name: Configure Windows mise (skip Ruby) + if: runner.os == 'Windows' + shell: pwsh + run: | + Add-Content -Path .mise.local.toml -Value "[settings]`ndisable_tools = [`"ruby`"]" + + - name: Install toolchain via mise (Windows) + if: runner.os == 'Windows' + uses: jdx/mise-action@v2 + + - name: Download Ruby 3.2 binary + uses: actions/download-artifact@v4 + with: + name: native-${{ matrix.os }}-ruby3.2 + path: lib/ratatui_ruby/3.2 + + - name: Download Ruby 3.3 binary + uses: actions/download-artifact@v4 + with: + name: native-${{ matrix.os }}-ruby3.3 + path: lib/ratatui_ruby/3.3 + + - name: Download Ruby 3.4 binary + uses: actions/download-artifact@v4 + with: + name: native-${{ matrix.os }}-ruby3.4 + path: lib/ratatui_ruby/3.4 + + - name: Download Ruby 4.0 binary + uses: actions/download-artifact@v4 + with: + name: native-${{ matrix.os }}-ruby4.0 + path: lib/ratatui_ruby/4.0 + + - name: Build platform gem + run: ruby tasks/release/platform_gem.rb - name: Upload gem artifact uses: actions/upload-artifact@v4 with: name: gem-${{ matrix.os }} path: pkg/*.gem + if-no-files-found: error diff --git a/AGENTS.md b/AGENTS.md index 7253567b..39e676a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Architecture: ### STRICT REQUIREMENTS - **Check Before Implementing:** FIRST check tests for existing coverage. If it works, say so and point to the test. -- Every file MUST begin with an SPDX-compliant header. Use `AGPL-3.0-or-later` for code; `CC-BY-SA-4.0` for documentation. `reuse annotate` can help you generate the header. **For Ruby files**, wrap SPDX comments in `#--` / `#++` to hide them from RDoc output. +- Every file MUST begin with an SPDX-compliant header. License by directory: `LGPL-3.0-or-later` for library source (`lib/`, `ext/`, `test/`, `sig/`); `MIT-0` for widget and verify examples (`examples/widget_*`, `examples/verify_*`); `AGPL-3.0-or-later` for app examples, tasks, and tooling (`examples/app_*`, `tasks/`, `bin/`); `CC-BY-SA-4.0` for documentation. `reuse annotate` can help you generate the header. **For Ruby files**, wrap SPDX comments in `#--` / `#++` to hide them from RDoc output. - Every line of Ruby MUST be covered by tests that would stand up to mutation testing. - Tests must be meaningful and verify specific behavior or rendering output; simply verifying that code "doesn't crash" is insufficient and unacceptable. - **Prefer snapshot tests** (`assert_snapshots`, plural) over manual `buffer_content` assertions for UI widgets. Snapshots are self-documenting and easier to maintain. diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cc8b9f..b6ebc578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,42 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +### Changed + +### Fixed + +### Removed + +## [1.4.2] - 2026-02-22 + +### Added + +### Changed + +### Fixed + +- **Rust source SPDX headers**: All 42 `.rs` files in `ext/` were incorrectly tagged `AGPL-3.0-or-later` instead of `LGPL-3.0-or-later`. The v0.9.0 relicensing to LGPL was applied to `lib/` and `sig/` but missed the Rust extension source in `ext/`. +- **`Text.width` emoji width**: `Text.width` now delegates to Ratatui's `Text::width()` instead of summing per-character widths. The per-character approach returned 1 for emoji with variation selectors (e.g. `➡️`), while the grapheme-aware `Text::width()` correctly returns 2. +- **Buffer serialization of wide characters**: `buffer_content` now skips continuation cells after wide characters (emoji, CJK). Previously, the continuation cell's space was included in the serialized string, inflating its display width by 1 per wide character. + +### Removed + +## [1.4.1] - 2026-02-15 + +### Added + +### Changed + +### Fixed + +- **Precompiled Native Gems**: Now support Ruby 3.2, 3.3, 3.4, and 4.0 (previously only 4.0) + +### Removed + +## [1.4.0] - 2026-02-15 + +### Added + - **Class-Wide Snapshot Normalization**: `TestHelper::Snapshot` now supports a `normalize_snapshots` template method hook. Override it in your test class to mask dynamic content (timestamps, PIDs, temp paths) across all snapshot assertions without repeating normalization blocks. The hook composes with per-call blocks: the hook runs first, then the block. - **Precompiled Native Gems**: Precompiled native gems are now published for Windows (x64-mingw-ucrt), macOS, and Linux, eliminating the need to compile the Rust extension from source. diff --git a/Gemfile.lock b/Gemfile.lock index ffee5692..7b8a0c7e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ratatui_ruby (1.3.0) + ratatui_ruby (1.4.2) rb_sys (~> 0.9) rexml (~> 3.4) @@ -354,7 +354,7 @@ CHECKSUMS rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c rake-compiler (1.3.1) sha256=6b351612b6e2d73ddd5563ee799bb58685176e05363db6758504bd11573d670a rake-compiler-dock (1.10.0) sha256=dd62ee19df2a185a3315697e560cfa8cc9129901332152851e023fab0e94bf11 - ratatui_ruby (1.3.0) + ratatui_ruby (1.4.2) rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rb_sys (0.9.123) sha256=c22ae84d1bca3eec0f13a45ae4ca9ba6eace93d5be270a40a9c0a9a5b92a34e5 diff --git a/examples/widget_table/README.md b/examples/widget_table/README.md index 374a1494..61302b5a 100644 --- a/examples/widget_table/README.md +++ b/examples/widget_table/README.md @@ -27,6 +27,7 @@ Data grids are complex. Users expect to navigate them with keys, select rows, an - **Arrows (←/→)**: Navigate Columns (`selected_column`) - **x**: Toggle Row Selection (`selected_row` = nil) - **s**: Cycle Table Style (`style`) +- **y**: Cycle Symbol (`highlight_symbol`) - **p**: Cycle Spacing (`highlight_spacing`) - **c**: Toggle Column Highlight (`column_highlight_style`) - **z**: Toggle Cell Highlight (`cell_highlight_style`) diff --git a/examples/widget_table/app.rb b/examples/widget_table/app.rb index 3256f190..05f7a88b 100644 --- a/examples/widget_table/app.rb +++ b/examples/widget_table/app.rb @@ -21,7 +21,14 @@ ].freeze class WidgetTable - attr_reader :selected_index, :selected_col, :current_style_index, :column_spacing, :highlight_spacing, :column_highlight_style, :cell_highlight_style + attr_reader :selected_index, :selected_col, :current_style_index, :column_spacing, :column_highlight_style, :cell_highlight_style + + HIGHLIGHT_SYMBOLS = [ + "> ", + ">", + "➡️", + "→", + ].freeze HIGHLIGHT_SPACINGS = [ { name: "When Selected", spacing: :when_selected }, @@ -50,6 +57,7 @@ def initialize @selected_col = 1 @current_style_index = 0 @column_spacing = 1 + @highlight_symbol_index = 0 @highlight_spacing_index = 0 @show_column_highlight = true @show_cell_highlight = true @@ -126,6 +134,7 @@ def run current_style_entry = @styles[@current_style_index] current_spacing_entry = HIGHLIGHT_SPACINGS[@highlight_spacing_index] + current_symbol_entry = HIGHLIGHT_SYMBOLS[@highlight_symbol_index] offset_mode_entry = OFFSET_MODES[@offset_mode_index] flex_mode_entry = FLEX_MODES[@flex_mode_index] @@ -145,7 +154,7 @@ def run selected_column: @selected_col, offset: effective_offset, row_highlight_style:, - highlight_symbol: "> ", + highlight_symbol: current_symbol_entry, highlight_spacing: current_spacing_entry[:spacing], column_highlight_style: @show_column_highlight ? @column_highlight_style : nil, cell_highlight_style: @show_cell_highlight ? @cell_highlight_style : nil, @@ -183,6 +192,8 @@ def run @tui.text_span(content: ": Style (#{current_style_entry[:name]}) "), @tui.text_span(content: "p", style: @hotkey_style), @tui.text_span(content: ": Spacing (#{current_spacing_entry[:name]}) "), + @tui.text_span(content: "y", style: @hotkey_style), + @tui.text_span(content: ": Symbol (#{current_symbol_entry}) "), @tui.text_span(content: "t", style: @hotkey_style), @tui.text_span(content: ": Tamp Row"), ]), @@ -250,6 +261,8 @@ def run @column_spacing += 1 in type: :key, code: "-" @column_spacing = [@column_spacing - 1, 0].max + in type: :key, code: "y" + @highlight_symbol_index = (@highlight_symbol_index + 1) % HIGHLIGHT_SYMBOLS.length in type: :key, code: "p" @highlight_spacing_index = (@highlight_spacing_index + 1) % HIGHLIGHT_SPACINGS.length in type: :key, code: "x" diff --git a/ext/ratatui_ruby/Cargo.lock b/ext/ratatui_ruby/Cargo.lock index 33857a2e..c8d6f447 100644 --- a/ext/ratatui_ruby/Cargo.lock +++ b/ext/ratatui_ruby/Cargo.lock @@ -1003,7 +1003,7 @@ dependencies = [ "thiserror 2.0.17", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -1054,12 +1054,12 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] name = "ratatui_ruby" -version = "1.3.0" +version = "1.4.2" dependencies = [ "bumpalo", "lazy_static", @@ -1067,7 +1067,6 @@ dependencies = [ "ratatui", "rb-sys", "time", - "unicode-width 0.1.14", ] [[package]] @@ -1513,20 +1512,14 @@ checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" dependencies = [ "itertools 0.13.0", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "utf8parse" diff --git a/ext/ratatui_ruby/Cargo.toml b/ext/ratatui_ruby/Cargo.toml index 7897da75..04827011 100644 --- a/ext/ratatui_ruby/Cargo.toml +++ b/ext/ratatui_ruby/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "ratatui_ruby" -version = "1.3.0" +version = "1.4.2" edition = "2021" [lib] @@ -13,7 +13,6 @@ crate-type = ["cdylib", "staticlib"] magnus = "0.8.2" rb-sys = "*" ratatui = { version = "0.30", features = ["widget-calendar", "layout-cache", "unstable-rendered-line-info", "palette"] } -unicode-width = "0.1" bumpalo = "3.16" lazy_static = "1.4" diff --git a/ext/ratatui_ruby/src/color.rs b/ext/ratatui_ruby/src/color.rs index 13249cb7..492dc18c 100644 --- a/ext/ratatui_ruby/src/color.rs +++ b/ext/ratatui_ruby/src/color.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Color conversion functions exposed to Ruby. //! diff --git a/ext/ratatui_ruby/src/errors.rs b/ext/ratatui_ruby/src/errors.rs index 9cbf7858..c2982218 100644 --- a/ext/ratatui_ruby/src/errors.rs +++ b/ext/ratatui_ruby/src/errors.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2026 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use magnus::{prelude::*, Error, Value}; diff --git a/ext/ratatui_ruby/src/events.rs b/ext/ratatui_ruby/src/events.rs index fee6ae6c..40b66bba 100644 --- a/ext/ratatui_ruby/src/events.rs +++ b/ext/ratatui_ruby/src/events.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use magnus::{Error, IntoValue, TryConvert, Value}; use rb_sys::rb_thread_call_without_gvl; diff --git a/ext/ratatui_ruby/src/frame.rs b/ext/ratatui_ruby/src/frame.rs index 37590a8a..bf9abbc0 100644 --- a/ext/ratatui_ruby/src/frame.rs +++ b/ext/ratatui_ruby/src/frame.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Frame wrapper for exposing Ratatui's Frame to Ruby. //! diff --git a/ext/ratatui_ruby/src/lib.rs b/ext/ratatui_ruby/src/lib.rs index d37e0244..72ebc62d 100644 --- a/ext/ratatui_ruby/src/lib.rs +++ b/ext/ratatui_ruby/src/lib.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later // Require SAFETY comments on all unsafe blocks #![warn(clippy::undocumented_unsafe_blocks)] diff --git a/ext/ratatui_ruby/src/lib_header.rs b/ext/ratatui_ruby/src/lib_header.rs index cc42374d..a45d0de4 100644 --- a/ext/ratatui_ruby/src/lib_header.rs +++ b/ext/ratatui_ruby/src/lib_header.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later // Require SAFETY comments on all unsafe blocks #![warn(clippy::undocumented_unsafe_blocks)] diff --git a/ext/ratatui_ruby/src/rendering.rs b/ext/ratatui_ruby/src/rendering.rs index 45dcf0d6..cc6b1392 100644 --- a/ext/ratatui_ruby/src/rendering.rs +++ b/ext/ratatui_ruby/src/rendering.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_color_value, parse_modifier_str, parse_style}; use crate::widgets; @@ -56,6 +56,7 @@ pub fn render_node(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), E "RatatuiRuby::Widgets::Table" => widgets::table::render(buffer, area, node)?, "RatatuiRuby::Widgets::Block" => widgets::block::render(buffer, area, node)?, "RatatuiRuby::Widgets::Tabs" => widgets::tabs::render(buffer, area, node)?, + "RatatuiRuby::Widgets::ScrollView" => widgets::scroll_view::render(buffer, area, node)?, "RatatuiRuby::Widgets::Scrollbar" => widgets::scrollbar::render(buffer, area, node)?, "RatatuiRuby::Widgets::BarChart" => widgets::barchart::render(buffer, area, node)?, "RatatuiRuby::Widgets::Canvas" => widgets::canvas::render(buffer, area, node)?, diff --git a/ext/ratatui_ruby/src/string_width.rs b/ext/ratatui_ruby/src/string_width.rs index d196281d..c7141c28 100644 --- a/ext/ratatui_ruby/src/string_width.rs +++ b/ext/ratatui_ruby/src/string_width.rs @@ -1,17 +1,14 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use magnus::{prelude::*, Error, Value}; +use ratatui::text::Text; /// Calculate the display width of a string in terminal cells. /// -/// Handles unicode correctly, including: -/// - Regular ASCII characters: 1 cell each -/// - CJK characters: 2 cells each (full-width) -/// - Emoji: typically 2 cells each (varies by terminal) -/// - Combining marks and zero-width characters: 0 cells -/// -/// This uses the same `unicode-width` crate that Ratatui uses internally. +/// Delegates to Ratatui's own `Text::width()`, which uses `unicode-width` +/// internally. This guarantees the width we report to Ruby matches the +/// width Ratatui uses when laying out widgets like Table highlight symbols. /// /// Returns the total display width in cells (not bytes or characters). pub fn text_width(string: Value) -> Result { @@ -24,78 +21,6 @@ pub fn text_width(string: Value) -> Result { ) })?; - // Use unicode_width's width calculation. - // This is the same mechanism Ratatui uses internally for Paragraph.line_width(). - let width = s - .chars() - .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)) - .sum(); - - Ok(width) -} - -#[cfg(test)] -mod tests { - use unicode_width::UnicodeWidthChar; - - fn measure_width(s: &str) -> usize { - s.chars() - .map(|c| UnicodeWidthChar::width(c).unwrap_or(0)) - .sum() - } - - #[test] - fn test_ascii_width() { - // ASCII is 1 cell per character - assert_eq!(measure_width("hello"), 5); - assert_eq!(measure_width("Hello, World!"), 13); - } - - #[test] - fn test_emoji_width() { - // Emoji typically take 2 cells - // 👍 is U+1F44D THUMBS UP SIGN, width 2 - assert_eq!(measure_width("👍"), 2); - // 🌍 is U+1F30D EARTH GLOBE EUROPE-AFRICA, width 2 - assert_eq!(measure_width("🌍"), 2); - // "Hello 👍" = 5 + 1 + 2 = 8 - assert_eq!(measure_width("Hello 👍"), 8); - } - - #[test] - fn test_cjk_width() { - // CJK characters are full-width, 2 cells each - // 你 (U+4F60) is width 2 - assert_eq!(measure_width("你"), 2); - // 好 (U+597D) is width 2 - assert_eq!(measure_width("好"), 2); - // "你好" should be 4 - assert_eq!(measure_width("你好"), 4); - } - - #[test] - fn test_mixed_width() { - // "a你b好" = 1 + 2 + 1 + 2 = 6 - assert_eq!(measure_width("a你b好"), 6); - } - - #[test] - fn test_empty_string() { - assert_eq!(measure_width(""), 0); - } - - #[test] - fn test_spaces_and_punctuation() { - // Regular ASCII space and punctuation are 1 cell each - assert_eq!(measure_width("a b c"), 5); - assert_eq!(measure_width("!!!"), 3); - } - - #[test] - fn test_combining_marks() { - // Zero-width marks don't add to width - // "a" + combining acute accent (U+0301) - let combining = "a\u{0301}"; - assert_eq!(measure_width(combining), 1); - } + let text = Text::raw(&s); + Ok(text.width()) } diff --git a/ext/ratatui_ruby/src/style.rs b/ext/ratatui_ruby/src/style.rs index cfd11f30..68697f24 100644 --- a/ext/ratatui_ruby/src/style.rs +++ b/ext/ratatui_ruby/src/style.rs @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long // -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use bumpalo::Bump; diff --git a/ext/ratatui_ruby/src/terminal/capabilities.rs b/ext/ratatui_ruby/src/terminal/capabilities.rs index c541e122..f4377886 100644 --- a/ext/ratatui_ruby/src/terminal/capabilities.rs +++ b/ext/ratatui_ruby/src/terminal/capabilities.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Terminal capability detection functions. diff --git a/ext/ratatui_ruby/src/terminal/init.rs b/ext/ratatui_ruby/src/terminal/init.rs index cb6b01bf..62b2e74c 100644 --- a/ext/ratatui_ruby/src/terminal/init.rs +++ b/ext/ratatui_ruby/src/terminal/init.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Terminal initialization and restoration functions. diff --git a/ext/ratatui_ruby/src/terminal/mod.rs b/ext/ratatui_ruby/src/terminal/mod.rs index d9beca94..067ddaa6 100644 --- a/ext/ratatui_ruby/src/terminal/mod.rs +++ b/ext/ratatui_ruby/src/terminal/mod.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Terminal management module. //! diff --git a/ext/ratatui_ruby/src/terminal/mutations.rs b/ext/ratatui_ruby/src/terminal/mutations.rs index 26a48a8b..2a1b2b02 100644 --- a/ext/ratatui_ruby/src/terminal/mutations.rs +++ b/ext/ratatui_ruby/src/terminal/mutations.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Terminal mutation functions (write operations). diff --git a/ext/ratatui_ruby/src/terminal/queries.rs b/ext/ratatui_ruby/src/terminal/queries.rs index a645bbb7..b1bde873 100644 --- a/ext/ratatui_ruby/src/terminal/queries.rs +++ b/ext/ratatui_ruby/src/terminal/queries.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Terminal query functions (read-only access to terminal state). @@ -18,10 +18,28 @@ pub fn get_buffer_content() -> Result { let area = buffer.area; let mut result = String::new(); for y in 0..area.height { + // SPDX-SnippetBegin + // SPDX-License-Identifier: MIT + // SPDX-SnippetCopyrightText: The Ratatui Developers + // Derived from ratatui-core/src/buffer/buffer.rs (Buffer Debug impl) + let mut skip: usize = 0; for x in 0..area.width { + if skip > 0 { + skip -= 1; + continue; + } let cell = buffer.cell((x, y)).unwrap(); - result.push_str(cell.symbol()); + let symbol = cell.symbol(); + result.push_str(symbol); + // Skip continuation cells after wide characters (e.g. emoji, CJK). + // Ratatui's set_stringn resets these to " ", but they don't represent + // an additional display column — the preceding wide symbol covers them. + let width = ratatui::text::Text::raw(symbol).width(); + if width > 1 { + skip = width - 1; + } } + // SPDX-SnippetEnd result.push('\n'); } Ok(result) diff --git a/ext/ratatui_ruby/src/terminal/query.rs b/ext/ratatui_ruby/src/terminal/query.rs index e7c852b1..d5ea4d69 100644 --- a/ext/ratatui_ruby/src/terminal/query.rs +++ b/ext/ratatui_ruby/src/terminal/query.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Terminal query trait and implementations. //! diff --git a/ext/ratatui_ruby/src/terminal/storage.rs b/ext/ratatui_ruby/src/terminal/storage.rs index c0a12e91..e589edc3 100644 --- a/ext/ratatui_ruby/src/terminal/storage.rs +++ b/ext/ratatui_ruby/src/terminal/storage.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Private terminal storage with safe accessor functions. //! diff --git a/ext/ratatui_ruby/src/terminal/wrapper.rs b/ext/ratatui_ruby/src/terminal/wrapper.rs index 8df520d4..98d1837b 100644 --- a/ext/ratatui_ruby/src/terminal/wrapper.rs +++ b/ext/ratatui_ruby/src/terminal/wrapper.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! Terminal wrapper enum for different backend types. diff --git a/ext/ratatui_ruby/src/text.rs b/ext/ratatui_ruby/src/text.rs index 747c7c59..5e0d70b3 100644 --- a/ext/ratatui_ruby/src/text.rs +++ b/ext/ratatui_ruby/src/text.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use crate::style::parse_style; diff --git a/ext/ratatui_ruby/src/widgets/barchart.rs b/ext/ratatui_ruby/src/widgets/barchart.rs index 4670bbc4..9094a7b4 100644 --- a/ext/ratatui_ruby/src/widgets/barchart.rs +++ b/ext/ratatui_ruby/src/widgets/barchart.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_bar_set, parse_block, parse_style}; use crate::text::{parse_line, parse_span}; diff --git a/ext/ratatui_ruby/src/widgets/block.rs b/ext/ratatui_ruby/src/widgets/block.rs index a2b61641..c0ddf037 100644 --- a/ext/ratatui_ruby/src/widgets/block.rs +++ b/ext/ratatui_ruby/src/widgets/block.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::rendering::render_node; use crate::style::parse_block; diff --git a/ext/ratatui_ruby/src/widgets/calendar.rs b/ext/ratatui_ruby/src/widgets/calendar.rs index a0ccfd9f..911e4519 100644 --- a/ext/ratatui_ruby/src/widgets/calendar.rs +++ b/ext/ratatui_ruby/src/widgets/calendar.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_block, parse_style}; use bumpalo::Bump; diff --git a/ext/ratatui_ruby/src/widgets/canvas.rs b/ext/ratatui_ruby/src/widgets/canvas.rs index 3e7fb130..e5bd3a0d 100644 --- a/ext/ratatui_ruby/src/widgets/canvas.rs +++ b/ext/ratatui_ruby/src/widgets/canvas.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_block, parse_color, parse_style}; use crate::text::parse_text; diff --git a/ext/ratatui_ruby/src/widgets/center.rs b/ext/ratatui_ruby/src/widgets/center.rs index 8d5e2f84..d4337def 100644 --- a/ext/ratatui_ruby/src/widgets/center.rs +++ b/ext/ratatui_ruby/src/widgets/center.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::rendering::render_node; use magnus::{prelude::*, Error, Value}; diff --git a/ext/ratatui_ruby/src/widgets/chart.rs b/ext/ratatui_ruby/src/widgets/chart.rs index 98b0009a..b6bd45cc 100644 --- a/ext/ratatui_ruby/src/widgets/chart.rs +++ b/ext/ratatui_ruby/src/widgets/chart.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use crate::style::{parse_block, parse_style}; diff --git a/ext/ratatui_ruby/src/widgets/clear.rs b/ext/ratatui_ruby/src/widgets/clear.rs index 59534249..c1b7c02e 100644 --- a/ext/ratatui_ruby/src/widgets/clear.rs +++ b/ext/ratatui_ruby/src/widgets/clear.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use bumpalo::Bump; use magnus::{prelude::*, Error, Value}; diff --git a/ext/ratatui_ruby/src/widgets/cursor.rs b/ext/ratatui_ruby/src/widgets/cursor.rs index 4ba56123..5775c551 100644 --- a/ext/ratatui_ruby/src/widgets/cursor.rs +++ b/ext/ratatui_ruby/src/widgets/cursor.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use magnus::{Error, Value}; use ratatui::{buffer::Buffer, layout::Rect}; diff --git a/ext/ratatui_ruby/src/widgets/gauge.rs b/ext/ratatui_ruby/src/widgets/gauge.rs index f2a01060..4fd64721 100644 --- a/ext/ratatui_ruby/src/widgets/gauge.rs +++ b/ext/ratatui_ruby/src/widgets/gauge.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_block, parse_style}; use crate::text::parse_span; diff --git a/ext/ratatui_ruby/src/widgets/layout.rs b/ext/ratatui_ruby/src/widgets/layout.rs index 721cf347..bd09daf1 100644 --- a/ext/ratatui_ruby/src/widgets/layout.rs +++ b/ext/ratatui_ruby/src/widgets/layout.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use crate::rendering::render_node; diff --git a/ext/ratatui_ruby/src/widgets/line_gauge.rs b/ext/ratatui_ruby/src/widgets/line_gauge.rs index 54aef042..b9dc0bf7 100644 --- a/ext/ratatui_ruby/src/widgets/line_gauge.rs +++ b/ext/ratatui_ruby/src/widgets/line_gauge.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_block, parse_style}; use crate::text::parse_span; diff --git a/ext/ratatui_ruby/src/widgets/list.rs b/ext/ratatui_ruby/src/widgets/list.rs index 9efc70cf..2b274c5d 100644 --- a/ext/ratatui_ruby/src/widgets/list.rs +++ b/ext/ratatui_ruby/src/widgets/list.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use crate::style::{parse_block, parse_style}; diff --git a/ext/ratatui_ruby/src/widgets/list_state.rs b/ext/ratatui_ruby/src/widgets/list_state.rs index 4b2401a5..b782f3dd 100644 --- a/ext/ratatui_ruby/src/widgets/list_state.rs +++ b/ext/ratatui_ruby/src/widgets/list_state.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! `ListState` wrapper for exposing Ratatui's `ListState` to Ruby. //! diff --git a/ext/ratatui_ruby/src/widgets/mod.rs b/ext/ratatui_ruby/src/widgets/mod.rs index f7cb3a4a..7df9f42f 100644 --- a/ext/ratatui_ruby/src/widgets/mod.rs +++ b/ext/ratatui_ruby/src/widgets/mod.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later pub mod barchart; pub mod block; @@ -18,6 +18,7 @@ pub mod overlay; pub mod paragraph; pub mod ratatui_logo; pub mod ratatui_mascot; +pub mod scroll_view; pub mod scrollbar; pub mod scrollbar_state; pub mod sparkline; diff --git a/ext/ratatui_ruby/src/widgets/overlay.rs b/ext/ratatui_ruby/src/widgets/overlay.rs index c504c20f..c14721de 100644 --- a/ext/ratatui_ruby/src/widgets/overlay.rs +++ b/ext/ratatui_ruby/src/widgets/overlay.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use crate::rendering::render_node; diff --git a/ext/ratatui_ruby/src/widgets/paragraph.rs b/ext/ratatui_ruby/src/widgets/paragraph.rs index 998b8d28..74b256e5 100644 --- a/ext/ratatui_ruby/src/widgets/paragraph.rs +++ b/ext/ratatui_ruby/src/widgets/paragraph.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_block, parse_style}; use bumpalo::Bump; diff --git a/ext/ratatui_ruby/src/widgets/ratatui_logo.rs b/ext/ratatui_ruby/src/widgets/ratatui_logo.rs index c3d7e9b1..2f639b49 100644 --- a/ext/ratatui_ruby/src/widgets/ratatui_logo.rs +++ b/ext/ratatui_ruby/src/widgets/ratatui_logo.rs @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long // -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use magnus::Value; use ratatui::{ diff --git a/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs b/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs index 29b8d337..c9ecd358 100644 --- a/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs +++ b/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long // -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::parse_block; use bumpalo::Bump; diff --git a/ext/ratatui_ruby/src/widgets/scroll_view.rs b/ext/ratatui_ruby/src/widgets/scroll_view.rs new file mode 100644 index 00000000..53317bb7 --- /dev/null +++ b/ext/ratatui_ruby/src/widgets/scroll_view.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Kerrick Long +// SPDX-License-Identifier: LGPL-3.0-or-later + +use crate::rendering::render_node; +use magnus::{prelude::*, Error, Value}; +use ratatui::{buffer::Buffer, layout::Rect}; + +pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> { + let child: Value = node.funcall("child", ())?; + let content_height: u16 = node.funcall("content_height", ())?; + let scroll_val: Value = node.funcall("scroll", ())?; + + let scroll_y: u16 = if scroll_val.is_nil() { + 0 + } else { + let arr = magnus::RArray::from_value(scroll_val).ok_or_else(|| { + let ruby = magnus::Ruby::get().unwrap(); + Error::new( + ruby.exception_type_error(), + "scroll must be [y, x] array or nil", + ) + })?; + if arr.len() > 0 { + arr.entry::(0)? + } else { + 0 + } + }; + + // If no scrolling needed, render directly + if scroll_y == 0 && content_height <= area.height { + return render_node(buffer, area, child); + } + + // Create virtual buffer tall enough for all content + let virtual_height = content_height.max(area.height); + let virtual_area = Rect::new(0, 0, area.width, virtual_height); + let mut virtual_buf = Buffer::empty(virtual_area); + + // Render child widget tree into virtual buffer + render_node(&mut virtual_buf, virtual_area, child)?; + + // Copy visible viewport from virtual buffer to real buffer + let clamped_scroll = scroll_y.min(virtual_height.saturating_sub(area.height)); + for y in 0..area.height { + let src_y = y + clamped_scroll; + if src_y >= virtual_height { + break; + } + for x in 0..area.width { + if let Some(src_cell) = virtual_buf.cell((x, src_y)) { + if let Some(dst_cell) = buffer.cell_mut((area.x + x, area.y + y)) { + dst_cell + .set_symbol(src_cell.symbol()) + .set_style(src_cell.style()); + } + } + } + } + + Ok(()) +} diff --git a/ext/ratatui_ruby/src/widgets/scrollbar.rs b/ext/ratatui_ruby/src/widgets/scrollbar.rs index 21a180d1..798c7952 100644 --- a/ext/ratatui_ruby/src/widgets/scrollbar.rs +++ b/ext/ratatui_ruby/src/widgets/scrollbar.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::parse_block; use crate::widgets::scrollbar_state::RubyScrollbarState; diff --git a/ext/ratatui_ruby/src/widgets/scrollbar_state.rs b/ext/ratatui_ruby/src/widgets/scrollbar_state.rs index 1073e999..fae2b3b1 100644 --- a/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +++ b/ext/ratatui_ruby/src/widgets/scrollbar_state.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! `ScrollbarState` wrapper for exposing Ratatui's `ScrollbarState` to Ruby. //! diff --git a/ext/ratatui_ruby/src/widgets/sparkline.rs b/ext/ratatui_ruby/src/widgets/sparkline.rs index 25ffcd79..0e3cae65 100644 --- a/ext/ratatui_ruby/src/widgets/sparkline.rs +++ b/ext/ratatui_ruby/src/widgets/sparkline.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::style::{parse_bar_set, parse_block, parse_style}; use bumpalo::Bump; diff --git a/ext/ratatui_ruby/src/widgets/table.rs b/ext/ratatui_ruby/src/widgets/table.rs index fca4eb1e..237a6c96 100644 --- a/ext/ratatui_ruby/src/widgets/table.rs +++ b/ext/ratatui_ruby/src/widgets/table.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use crate::style::{parse_block, parse_style}; diff --git a/ext/ratatui_ruby/src/widgets/table_state.rs b/ext/ratatui_ruby/src/widgets/table_state.rs index 5eafed4c..dcf44cc9 100644 --- a/ext/ratatui_ruby/src/widgets/table_state.rs +++ b/ext/ratatui_ruby/src/widgets/table_state.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later //! `TableState` wrapper for exposing Ratatui's `TableState` to Ruby. //! diff --git a/ext/ratatui_ruby/src/widgets/tabs.rs b/ext/ratatui_ruby/src/widgets/tabs.rs index e9ee90a1..ab9ee70b 100644 --- a/ext/ratatui_ruby/src/widgets/tabs.rs +++ b/ext/ratatui_ruby/src/widgets/tabs.rs @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Kerrick Long -// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later use crate::errors::type_error_with_context; use crate::style::parse_block; diff --git a/lib/ratatui_ruby.rb b/lib/ratatui_ruby.rb index b19dd141..eb9c6021 100644 --- a/lib/ratatui_ruby.rb +++ b/lib/ratatui_ruby.rb @@ -38,11 +38,12 @@ # Synthetic events queue (for async synchronization) require_relative "ratatui_ruby/synthetic_events" +# Precompiled gems store binaries in versioned subdirectories begin - require "ratatui_ruby/ratatui_ruby" + RUBY_VERSION =~ /(\d+\.\d+)/ + require "ratatui_ruby/#{$1}/ratatui_ruby" rescue LoadError - # Fallback for development/CI if the bundle is not in the load path - require_relative "ratatui_ruby/ratatui_ruby" + require "ratatui_ruby/ratatui_ruby" end # Debug mode (for Rust backtraces and diagnostic features) diff --git a/lib/ratatui_ruby/tui/widget_factories.rb b/lib/ratatui_ruby/tui/widget_factories.rb index f4f3b711..de76dd72 100644 --- a/lib/ratatui_ruby/tui/widget_factories.rb +++ b/lib/ratatui_ruby/tui/widget_factories.rb @@ -143,6 +143,12 @@ def axis(first = nil, **kwargs) Widgets::Axis.coerce_args(first, kwargs) end + # Creates a Widgets::ScrollView. + # @return [Widgets::ScrollView] + def scroll_view(first = nil, **kwargs) + Widgets::ScrollView.coerce_args(first, kwargs) + end + # Creates a Widgets::Scrollbar. # @return [Widgets::Scrollbar] def scrollbar(first = nil, **kwargs) @@ -251,6 +257,7 @@ def widget(type, first = nil, **) when :sparkline then sparkline(first, **) when :bar_chart then bar_chart(first, **) when :chart then chart(first, **) + when :scroll_view then scroll_view(first, **) when :scrollbar then scrollbar(first, **) when :calendar then calendar(first, **) when :canvas then canvas(first, **) diff --git a/lib/ratatui_ruby/version.rb b/lib/ratatui_ruby/version.rb index 96292465..79f1d968 100644 --- a/lib/ratatui_ruby/version.rb +++ b/lib/ratatui_ruby/version.rb @@ -8,5 +8,5 @@ module RatatuiRuby # The version of the ratatui_ruby gem. # See https://semver.org/spec/v2.0.0.html - VERSION = "1.3.0" + VERSION = "1.4.2" end diff --git a/lib/ratatui_ruby/widgets.rb b/lib/ratatui_ruby/widgets.rb index 70db3d62..a15f8334 100644 --- a/lib/ratatui_ruby/widgets.rb +++ b/lib/ratatui_ruby/widgets.rb @@ -31,6 +31,7 @@ module Widgets require_relative "widgets/bar_chart/bar" require_relative "widgets/bar_chart/bar_group" require_relative "widgets/chart" +require_relative "widgets/scroll_view" require_relative "widgets/scrollbar" require_relative "widgets/calendar" require_relative "widgets/canvas" diff --git a/lib/ratatui_ruby/widgets/scroll_view.rb b/lib/ratatui_ruby/widgets/scroll_view.rb new file mode 100644 index 00000000..9b06bd79 --- /dev/null +++ b/lib/ratatui_ruby/widgets/scroll_view.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- +# SPDX-FileCopyrightText: 2026 Kerrick Long +# SPDX-License-Identifier: LGPL-3.0-or-later +#++ + +module RatatuiRuby + module Widgets + # Scrolls arbitrary widget content by rendering to a virtual buffer + # and copying the visible viewport. + # + # Unlike Paragraph's built-in scroll, this works with any widget tree: + # layouts, nested blocks, styled text, tables, etc. + # + # === Examples + # + # ScrollView.new( + # child: tui.layout(direction: :vertical, ...), + # scroll: [scroll_y, 0], + # content_height: total_lines + # ) + class ScrollView < Data.define(:child, :scroll, :content_height) + include CoerceableWidget + + # Creates a new ScrollView. + # + # [child] + # The widget tree to scroll. + # [scroll] + # Scroll offset as [y, x]. Only vertical (y) is used currently. + # [content_height] + # Total height of the content in rows. Used to size the virtual buffer. + def initialize(child:, scroll: [0, 0], content_height: 0) + super + end + end + end +end diff --git a/tasks/license.rake b/tasks/license.rake index 11006377..964d1471 100644 --- a/tasks/license.rake +++ b/tasks/license.rake @@ -13,16 +13,23 @@ namespace :license do ruby "tasks/license/headers_md.rb #{files}" end - desc "Ensure Ruby files have correct AGPL-3.0-or-later headers" + desc "Ensure Ruby files have correct SPDX headers" task :rb, [:files] do |_t, args| files = args[:files] || "" ruby "tasks/license/headers_rb.rb #{files}" end + desc "Ensure Rust files have correct SPDX headers" + task :rs, [:files] do |_t, args| + files = args[:files] || "" + ruby "tasks/license/headers_rs.rb #{files}" + end + desc "Ensure all files have correct license headers" task :all do Rake::Task["license:headers:md"].invoke Rake::Task["license:headers:rb"].invoke + Rake::Task["license:headers:rs"].invoke end end @@ -51,25 +58,31 @@ namespace :license do desc "Run license tasks on changed files only (staged + unstaged)" task :new do - # Get changed .md and .rb files (staged and unstaged) + # Get changed .md, .rb, and .rs files (staged and unstaged) changed_md = `git diff --name-only --diff-filter=ACMR HEAD -- '*.md' 2>/dev/null`.split("\n") staged_md = `git diff --name-only --cached --diff-filter=ACMR -- '*.md' 2>/dev/null`.split("\n") changed_rb = `git diff --name-only --diff-filter=ACMR HEAD -- '*.rb' 2>/dev/null`.split("\n") staged_rb = `git diff --name-only --cached --diff-filter=ACMR -- '*.rb' 2>/dev/null`.split("\n") + changed_rs = `git diff --name-only --diff-filter=ACMR HEAD -- '*.rs' 2>/dev/null`.split("\n") + staged_rs = `git diff --name-only --cached --diff-filter=ACMR -- '*.rs' 2>/dev/null`.split("\n") # Also get untracked new files untracked = `git ls-files --others --exclude-standard`.split("\n") untracked_md = untracked.select { |f| f.end_with?(".md") } untracked_rb = untracked.select { |f| f.end_with?(".rb") } + untracked_rs = untracked.select { |f| f.end_with?(".rs") } md_files = (changed_md + staged_md + untracked_md).uniq.join(" ") rb_files = (changed_rb + staged_rb + untracked_rb).uniq + rs_files = (changed_rs + staged_rs + untracked_rs).uniq # Filter rb files to only lib/ lib_rb_files = rb_files.select { |f| f.start_with?("lib/") }.join(" ") + # Filter rs files to only ext/ + ext_rs_files = rs_files.select { |f| f.start_with?("ext/") }.join(" ") - if md_files.empty? && lib_rb_files.empty? - puts "No changed .md or lib/*.rb files to process" + if md_files.empty? && lib_rb_files.empty? && ext_rs_files.empty? + puts "No changed .md, lib/*.rb, or ext/*.rs files to process" else unless md_files.empty? puts "Processing #{md_files.split.count} changed .md file(s)..." @@ -86,6 +99,12 @@ namespace :license do Rake::Task["license:snippets:rdoc"].invoke(lib_rb_files) Rake::Task["license:snippets:rdoc"].reenable end + + unless ext_rs_files.empty? + puts "Processing #{ext_rs_files.split.count} changed ext/*.rs file(s)..." + Rake::Task["license:headers:rs"].invoke(ext_rs_files) + Rake::Task["license:headers:rs"].reenable + end end end end diff --git a/tasks/license/headers_rs.rb b/tasks/license/headers_rs.rb new file mode 100644 index 00000000..39638058 --- /dev/null +++ b/tasks/license/headers_rs.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +#-- +# SPDX-FileCopyrightText: 2026 Kerrick Long +# SPDX-License-Identifier: AGPL-3.0-or-later +#++ + +# Script to ensure Rust files have correct SPDX file headers. +# +# Usage: ruby tasks/license/headers_rs.rb [path...] +# +# If no paths are given, processes ext/. +# +# License selection by directory: +# - ext/ → LGPL-3.0-or-later + +require_relative "license_utils" + +YOUR_NAME = "Kerrick Long" +YOUR_EMAIL = "me@kerricklong.com" +YOUR_IDENTIFIERS = [YOUR_NAME, YOUR_EMAIL].freeze +YOUR_COPYRIGHT = "#{YOUR_NAME} <#{YOUR_EMAIL}>" + +def license_for_file(filepath) + case filepath + when %r{^ext/} + "LGPL-3.0-or-later" + else + "AGPL-3.0-or-later" + end +end + +def parse_existing_header(lines) + # Rust files have // with no #-- or #++ wrappers + + copyrights = [] + license = nil + header_end = nil + + lines.each_with_index do |line, i| + if line =~ %r{^//\s*SPDX-FileCopyrightText:\s*(\d{4})\s+(.+)$} + copyrights << { year: $1.to_i, holder: $2.strip } + # REUSE-IgnoreStart + elsif line =~ %r{^//\s*SPDX-License-Identifier:\s*(.+)$} + # REUSE-IgnoreEnd + license = $1.strip + header_end = i + elsif !line.start_with?("//") && line.strip.empty? && header_end + # Blank line after header — we're done + break + elsif !line.start_with?("//") && !line.strip.empty? + # Non-comment, non-blank line — header is over + break + end + end + + return nil if copyrights.empty? && license.nil? + + { end_line: header_end || 0, copyrights:, license: } +end + +def process_file(filepath) + content = File.read(filepath) + lines = content.lines + + target_license = license_for_file(filepath) + + # Get contributors from git for year lookups + all_contributors = LicenseUtils.get_contributors_for_lines(filepath) + your_year = LicenseUtils.get_your_latest_year(filepath, YOUR_IDENTIFIERS) + + existing = parse_existing_header(lines) + + if existing + # File has existing header - only update years for EXISTING contributors + needs_update = false + updated_copyrights = [] + + existing[:copyrights].each do |c| + # Find this contributor's latest year from git + git_year = nil + all_contributors.each do |contributor, year| + if c[:holder].split.any? { |word| contributor.include?(word) } + git_year = [git_year || 0, year].max + end + end + + if git_year && git_year != c[:year] + puts " Updated #{c[:holder].split.first}'s copyright year: #{c[:year]} -> #{git_year}" + updated_copyrights << { year: git_year, holder: c[:holder] } + needs_update = true + else + updated_copyrights << c + end + end + + # Check if YOUR year needs updating (if you're a contributor) + your_existing = updated_copyrights.find { |c| YOUR_IDENTIFIERS.any? { |id| c[:holder].include?(id) } } + if your_existing.nil? + puts " Adding your copyright" + updated_copyrights << { year: your_year, holder: YOUR_COPYRIGHT } + needs_update = true + end + + # Check license + if existing[:license] != target_license + puts " Fixing license: #{existing[:license]} -> #{target_license}" + needs_update = true + end + + if needs_update + header_lines = [] + + # REUSE-IgnoreStart + updated_copyrights.each do |c| + header_lines << "// SPDX-FileCopyrightText: #{c[:year]} #{c[:holder]}\n" + end + header_lines << "// SPDX-License-Identifier: #{target_license}\n" + # REUSE-IgnoreEnd + + content_start = existing[:end_line] + 1 + while content_start < lines.length && lines[content_start].strip.empty? + content_start += 1 + end + + remaining = lines[content_start..] + + new_content = "#{header_lines.join}\n#{remaining.join}" + + File.write(filepath, new_content) + puts "Updated: #{filepath}" + end + else + # No header - add one with YOUR copyright only + header = [] + # REUSE-IgnoreStart + header << "// SPDX-FileCopyrightText: #{your_year} #{YOUR_COPYRIGHT}\n" + header << "// SPDX-License-Identifier: #{target_license}\n" + # REUSE-IgnoreEnd + header << "\n" + + File.write(filepath, header.join + lines.join) + puts "Added header: #{filepath}" + end +end + +def find_rs_files(paths) + if paths.empty? + dirs = %w[ext] + files = dirs.flat_map do |dir| + root_files = `git ls-files '#{dir}/*.rs' 2>/dev/null`.split("\n") + sub_files = `git ls-files '#{dir}/**/*.rs' 2>/dev/null`.split("\n") + root_files + sub_files + end + files.uniq + else + paths.flat_map do |path| + if File.directory?(path) + `git ls-files '#{path}/**/*.rs'`.split("\n") + else + path + end + end + end +end + +if __FILE__ == $0 + paths = ARGV.empty? ? [] : ARGV + files = find_rs_files(paths) + + files.each do |file| + process_file(file) + end +end diff --git a/tasks/release.rake b/tasks/release.rake index fe10d801..93ffa12b 100644 --- a/tasks/release.rake +++ b/tasks/release.rake @@ -5,7 +5,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later #++ -require_relative "release/native_gem_release" +require_relative "release/native_gem_version" namespace :release do desc "Update stable branch to match release and set as default" @@ -72,7 +72,7 @@ if Rake::Task.task_defined?("release") end release_sha = `git rev-parse v#{version}^{}`.strip - NativeGemRelease.new(version:, sha: release_sha).call + NativeGemVersion.new(version:, sha: release_sha).release Rake::Task["release:update_stable"].invoke end diff --git a/tasks/release/ci_run.rb b/tasks/release/ci_run.rb new file mode 100644 index 00000000..b3aad513 --- /dev/null +++ b/tasks/release/ci_run.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- +# SPDX-FileCopyrightText: 2026 Kerrick Long +# SPDX-License-Identifier: AGPL-3.0-or-later +#++ + +require "open3" +require "json" +require "tmpdir" + +# A completed GitHub Actions workflow run for a specific commit. +class CIRun < Data.define(:id) + WORKFLOW_NAME = "Build Gems" + + # Finds the most recent successful run for the given commit SHA. + def self.for_commit(sha) + out, status = Open3.capture2( + "gh", "run", "list", + "--workflow", WORKFLOW_NAME, + "--commit", sha, + "--status", "completed", + "--json", "databaseId,conclusion", + "--limit", "1" + ) + return nil unless status.success? + + runs = JSON.parse(out) + run = runs.first + return nil unless run + return nil unless run.fetch("conclusion") == "success" + + new(id: run.fetch("databaseId")) + end + + def download(dir) + puts "Downloading native gem artifacts from run #{id}..." + system("gh", "run", "download", id.to_s, "--dir", dir, exception: true) + Dir.glob("#{dir}/**/*.gem") + end +end diff --git a/tasks/release/github_cli.rb b/tasks/release/github_cli.rb new file mode 100644 index 00000000..e0014109 --- /dev/null +++ b/tasks/release/github_cli.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +#-- +# SPDX-FileCopyrightText: 2026 Kerrick Long +# SPDX-License-Identifier: AGPL-3.0-or-later +#++ + +# GitHubCli wraps availability and authentication checks for the `gh` CLI. +class GitHubCli + def available? + system("command", "-v", "gh", out: File::NULL, err: File::NULL) + end + + def authenticated? + system("gh", "auth", "status", out: File::NULL, err: File::NULL) + end + + def ready? + available? && authenticated? + end + + def warn_unavailable + warn "\n⚠ 'gh' CLI not found — skipping native gem push." + warn " Install: https://cli.github.com\n\n" + end + + def warn_unauthenticated + warn "\n⚠ 'gh' is not authenticated — skipping native gem push." + warn " Run: gh auth login\n\n" + end +end diff --git a/tasks/release/native_gem_release.rb b/tasks/release/native_gem_release.rb deleted file mode 100644 index 5f5c431b..00000000 --- a/tasks/release/native_gem_release.rb +++ /dev/null @@ -1,103 +0,0 @@ -# frozen_string_literal: true - -#-- -# SPDX-FileCopyrightText: 2026 Kerrick Long -# SPDX-License-Identifier: AGPL-3.0-or-later -#++ - -require "open3" -require "json" -require "tmpdir" -require "fileutils" - -# NativeGemRelease downloads CI-built native gem artifacts from GitHub Actions -# and pushes them to RubyGems.org. -class NativeGemRelease < Data.define(:version, :sha) - WORKFLOW_NAME = "Build Gems" - - def call - unless gh_available? - warn "\n⚠ 'gh' CLI not found — skipping native gem push." - warn " Install: https://cli.github.com\n\n" - return - end - - unless gh_authenticated? - warn "\n⚠ 'gh' is not authenticated — skipping native gem push." - warn " Run: gh auth login\n\n" - return - end - - run_id = find_completed_run(sha) - - unless run_id - warn "\n⚠ No completed '#{WORKFLOW_NAME}' run found for v#{version} (#{sha[0, 7]})." - warn " Native gems were not pushed to RubyGems.org.\n\n" - return - end - - Dir.mktmpdir("native-gems") do |dir| - download_artifacts(run_id, dir) - gems = Dir.glob("#{dir}/**/*.gem") - - if gems.empty? - warn "\n⚠ No .gem files found in artifacts for run #{run_id}." - warn " Native gems were not pushed to RubyGems.org.\n\n" - else - verify_versions!(gems) - push_gems(gems) - end - end - end - - private def gh_available? - system("command", "-v", "gh", out: File::NULL, err: File::NULL) - end - - private def gh_authenticated? - system("gh", "auth", "status", out: File::NULL, err: File::NULL) - end - - private def find_completed_run(sha) - out, status = Open3.capture2( - "gh", "run", "list", - "--workflow", WORKFLOW_NAME, - "--commit", sha, - "--status", "completed", - "--json", "databaseId,conclusion", - "--limit", "1" - ) - return nil unless status.success? - - runs = JSON.parse(out) - run = runs.first - return nil unless run - return nil unless run.fetch("conclusion") == "success" - - run.fetch("databaseId") - end - - private def verify_versions!(gems) - expected = Gem::Version.new(version).to_s - mismatched = gems.reject { |path| File.basename(path).include?(expected) } - return if mismatched.empty? - - names = mismatched.map { |path| " - #{File.basename(path)}" }.join("\n") - abort "Fatal: Version mismatch in downloaded artifacts!\n" \ - "Expected version #{expected} but found:\n#{names}" - end - - private def download_artifacts(run_id, dir) - puts "Downloading native gem artifacts from run #{run_id}..." - system("gh", "run", "download", run_id.to_s, "--dir", dir, exception: true) - end - - private def push_gems(gems) - gems.each do |gem_path| - name = File.basename(gem_path) - puts "Pushing #{name} to RubyGems.org..." - system("gem", "push", gem_path, exception: true) - end - puts "\n✓ Pushed #{gems.size} native gem#{'s' if gems.size != 1} to RubyGems.org." - end -end diff --git a/tasks/release/native_gem_version.rb b/tasks/release/native_gem_version.rb new file mode 100644 index 00000000..ff8b3ce6 --- /dev/null +++ b/tasks/release/native_gem_version.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +#-- +# SPDX-FileCopyrightText: 2026 Kerrick Long +# SPDX-License-Identifier: AGPL-3.0-or-later +#++ + +require "tmpdir" + +require_relative "github_cli" +require_relative "ci_run" + +# A native gem version identified by its version string and commit SHA. +# Knows how to release itself by downloading CI artifacts and pushing to RubyGems. +class NativeGemVersion < Data.define(:version, :sha) + def release + cli = GitHubCli.new + + unless cli.available? + cli.warn_unavailable + return + end + + unless cli.authenticated? + cli.warn_unauthenticated + return + end + + run = CIRun.for_commit(sha) + + unless run + warn "\n⚠ No completed '#{CIRun::WORKFLOW_NAME}' run found for v#{version} (#{sha[0, 7]})." + warn " Native gems were not pushed to RubyGems.org.\n\n" + return + end + + Dir.mktmpdir("native-gems") do |dir| + gem_paths = run.download(dir) + + if gem_paths.empty? + warn "\n⚠ No .gem files found in artifacts for run #{run.id}." + warn " Native gems were not pushed to RubyGems.org.\n\n" + else + verify_versions!(gem_paths) + push(gem_paths) + end + end + end + + private def verify_versions!(gem_paths) + expected = Gem::Version.new(version).to_s + mismatched = gem_paths.reject { |path| File.basename(path).include?(expected) } + return if mismatched.empty? + + names = mismatched.map { |path| " - #{File.basename(path)}" }.join("\n") + abort "Fatal: Version mismatch in downloaded artifacts!\n" \ + "Expected version #{expected} but found:\n#{names}" + end + + private def push(gem_paths) + gem_paths.each do |gem_path| + name = File.basename(gem_path) + puts "Pushing #{name} to RubyGems.org..." + system("gem", "push", gem_path, exception: true) + end + puts "\n✓ Pushed #{gem_paths.size} native gem#{'s' if gem_paths.size != 1} to RubyGems.org." + end +end diff --git a/tasks/release/platform_gem.rb b/tasks/release/platform_gem.rb new file mode 100644 index 00000000..2e893c91 --- /dev/null +++ b/tasks/release/platform_gem.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- +# SPDX-FileCopyrightText: 2026 Kerrick Long +# SPDX-License-Identifier: AGPL-3.0-or-later +#++ + +require "rubygems" +require "rubygems/package" +require "fileutils" + +require_relative "versioned_binary" + +# A platform-specific gem assembled from pre-compiled versioned binaries. +# +# CI builds produce one compiled binary per Ruby version per OS. +# A PlatformGem collects those binaries and packages them into a single +# installable gem that serves all supported Ruby versions. +class PlatformGem + LIB_DIR = "lib/ratatui_ruby" + + def initialize(spec = Gem::Specification.load("ratatui_ruby.gemspec")) + @spec = spec + @binaries = VersionedBinary.scan(LIB_DIR) + end + + def build + abort "No versioned binaries found in #{LIB_DIR}/*/" if @binaries.empty? + + @spec.platform = Gem::Platform.local + @spec.extensions.clear + @spec.files += @binaries.map(&:path) + + #-- + # SPDX-SnippetBegin + # SPDX-SnippetCopyrightText: rake-compiler contributors + # SPDX-License-Identifier: MIT + # + # Version constraint pattern derived from rake-compiler's + # ExtensionTask#define_native_tasks (lib/rake/extensiontask.rb). + #++ + @spec.required_ruby_version = [ + ">= #{@binaries.first.api_version}", + "< #{@binaries.last.api_version.succ}.dev", + ] + #-- + # SPDX-SnippetEnd + #++ + + FileUtils.mkdir_p("pkg") + gem_file = Gem::Package.build(@spec) + FileUtils.mv(gem_file, "pkg") + puts "Built pkg/#{File.basename(gem_file)}" + end +end + +if $PROGRAM_NAME == __FILE__ + PlatformGem.new.build +end diff --git a/tasks/release/versioned_binary.rb b/tasks/release/versioned_binary.rb new file mode 100644 index 00000000..a22aafd7 --- /dev/null +++ b/tasks/release/versioned_binary.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +#-- +# SPDX-FileCopyrightText: 2026 Kerrick Long +# SPDX-License-Identifier: AGPL-3.0-or-later +#++ + +require_relative "../bump/sem_ver" + +# A compiled native extension binary for a specific Ruby version. +class VersionedBinary < Data.define(:path) + def self.scan(lib_dir) + Dir.glob("#{lib_dir}/*/ratatui_ruby.*") + .reject { |p| p.end_with?(".rb") } + .map { |p| new(path: p) } + .sort + end + + def ruby_version = File.basename(File.dirname(path)) + + def api_version + semver = SemVer.parse(ruby_version) + "#{semver.major}.#{semver.minor}" + end + + def <=>(other) = api_version <=> other.api_version +end diff --git a/test/examples/widget_table/snapshots/after_col_space_increase.ansi b/test/examples/widget_table/snapshots/after_col_space_increase.ansi index b3885b00..5ce6eda6 100644 --- a/test/examples/widget_table/snapshots/after_col_space_increase.ansi +++ b/test/examples/widget_table/snapshots/after_col_space_increase.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (2) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_col_space_increase.txt b/test/examples/widget_table/snapshots/after_col_space_increase.txt index 6f421ade..7d0caa14 100644 --- a/test/examples/widget_table/snapshots/after_col_space_increase.txt +++ b/test/examples/widget_table/snapshots/after_col_space_increase.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (2) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_column_navigate.ansi b/test/examples/widget_table/snapshots/after_column_navigate.ansi index 4cb75561..18ba1480 100644 --- a/test/examples/widget_table/snapshots/after_column_navigate.ansi +++ b/test/examples/widget_table/snapshots/after_column_navigate.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_column_navigate.txt b/test/examples/widget_table/snapshots/after_column_navigate.txt index 54145703..d87c3e5c 100644 --- a/test/examples/widget_table/snapshots/after_column_navigate.txt +++ b/test/examples/widget_table/snapshots/after_column_navigate.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_emoji_highlight.ansi b/test/examples/widget_table/snapshots/after_emoji_highlight.ansi new file mode 100644 index 00000000..6033c453 --- /dev/null +++ b/test/examples/widget_table/snapshots/after_emoji_highlight.ansi @@ -0,0 +1,24 @@ +┌Processes | Sel: 1 | Offset: auto | Flex: Legacy (Default)────────────────────┐ +│ PID Name CPU │ +│ 1234 ruby  15.2% │ +│➡️ 5678 postgres  8.7% │ +│ 9012 nginx  3.1% │ +│ 3456 redis  12.4% │ +│ 7890 sidekiq  22.8% │ +│ 2345 webpack  45.3% │ +│ 6789 node  18.9% │ +│   │ +│   │ +│   │ +│   │ +│   │ +│   │ +│   │ +│ Total: 7 Total CPU: 126. │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Controls──────────────────────────────────────────────────────────────────────┐ +│↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (➡️ ) t: Tamp Row │ +│+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ +│z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_emoji_highlight.txt b/test/examples/widget_table/snapshots/after_emoji_highlight.txt new file mode 100644 index 00000000..d9ec8742 --- /dev/null +++ b/test/examples/widget_table/snapshots/after_emoji_highlight.txt @@ -0,0 +1,24 @@ +┌Processes | Sel: 1 | Offset: auto | Flex: Legacy (Default)────────────────────┐ +│ PID Name CPU │ +│ 1234 ruby 15.2% │ +│➡️5678 postgres 8.7% │ +│ 9012 nginx 3.1% │ +│ 3456 redis 12.4% │ +│ 7890 sidekiq 22.8% │ +│ 2345 webpack 45.3% │ +│ 6789 node 18.9% │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Total: 7 Total CPU: 126. │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Controls──────────────────────────────────────────────────────────────────────┐ +│↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (➡️) t: Tamp Row │ +│+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ +│z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_header_toggle.ansi b/test/examples/widget_table/snapshots/after_header_toggle.ansi index 78b35de5..8e10316c 100644 --- a/test/examples/widget_table/snapshots/after_header_toggle.ansi +++ b/test/examples/widget_table/snapshots/after_header_toggle.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (Off) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_header_toggle.txt b/test/examples/widget_table/snapshots/after_header_toggle.txt index 6da48235..6ff9eb18 100644 --- a/test/examples/widget_table/snapshots/after_header_toggle.txt +++ b/test/examples/widget_table/snapshots/after_header_toggle.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (Off) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_navigate_down.ansi b/test/examples/widget_table/snapshots/after_navigate_down.ansi index 533db834..39488591 100644 --- a/test/examples/widget_table/snapshots/after_navigate_down.ansi +++ b/test/examples/widget_table/snapshots/after_navigate_down.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (2) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_navigate_down.txt b/test/examples/widget_table/snapshots/after_navigate_down.txt index ee95feaa..db808ce2 100644 --- a/test/examples/widget_table/snapshots/after_navigate_down.txt +++ b/test/examples/widget_table/snapshots/after_navigate_down.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (2) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_navigate_up_wrap.ansi b/test/examples/widget_table/snapshots/after_navigate_up_wrap.ansi index 782e279f..fceeea73 100644 --- a/test/examples/widget_table/snapshots/after_navigate_up_wrap.ansi +++ b/test/examples/widget_table/snapshots/after_navigate_up_wrap.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (6) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_navigate_up_wrap.txt b/test/examples/widget_table/snapshots/after_navigate_up_wrap.txt index 96831519..07aa6bdc 100644 --- a/test/examples/widget_table/snapshots/after_navigate_up_wrap.txt +++ b/test/examples/widget_table/snapshots/after_navigate_up_wrap.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (6) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_offset_mode_cycle.ansi b/test/examples/widget_table/snapshots/after_offset_mode_cycle.ansi index fb4f6fef..78599cec 100644 --- a/test/examples/widget_table/snapshots/after_offset_mode_cycle.ansi +++ b/test/examples/widget_table/snapshots/after_offset_mode_cycle.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (none) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Offset Only (row 3)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_offset_mode_cycle.txt b/test/examples/widget_table/snapshots/after_offset_mode_cycle.txt index cae9ccaa..bad5a46c 100644 --- a/test/examples/widget_table/snapshots/after_offset_mode_cycle.txt +++ b/test/examples/widget_table/snapshots/after_offset_mode_cycle.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (none) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Offset Only (row 3)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_spacing_cycle.ansi b/test/examples/widget_table/snapshots/after_spacing_cycle.ansi index 21badaf9..75b37516 100644 --- a/test/examples/widget_table/snapshots/after_spacing_cycle.ansi +++ b/test/examples/widget_table/snapshots/after_spacing_cycle.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (Always) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (Always) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_spacing_cycle.txt b/test/examples/widget_table/snapshots/after_spacing_cycle.txt index 18a16901..e2103845 100644 --- a/test/examples/widget_table/snapshots/after_spacing_cycle.txt +++ b/test/examples/widget_table/snapshots/after_spacing_cycle.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (Always) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (Always) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_style_switch.ansi b/test/examples/widget_table/snapshots/after_style_switch.ansi index 55f796d8..f99c87d6 100644 --- a/test/examples/widget_table/snapshots/after_style_switch.ansi +++ b/test/examples/widget_table/snapshots/after_style_switch.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Red) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Red) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_style_switch.txt b/test/examples/widget_table/snapshots/after_style_switch.txt index d9ec1fb5..7da3681f 100644 --- a/test/examples/widget_table/snapshots/after_style_switch.txt +++ b/test/examples/widget_table/snapshots/after_style_switch.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Red) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Red) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_toggle_selection.ansi b/test/examples/widget_table/snapshots/after_toggle_selection.ansi index 5f6f5036..08e91f38 100644 --- a/test/examples/widget_table/snapshots/after_toggle_selection.ansi +++ b/test/examples/widget_table/snapshots/after_toggle_selection.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (none) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/after_toggle_selection.txt b/test/examples/widget_table/snapshots/after_toggle_selection.txt index 49607722..c9d51291 100644 --- a/test/examples/widget_table/snapshots/after_toggle_selection.txt +++ b/test/examples/widget_table/snapshots/after_toggle_selection.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (none) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/initial_render.ansi b/test/examples/widget_table/snapshots/initial_render.ansi index d1bb26ca..706dd1b5 100644 --- a/test/examples/widget_table/snapshots/initial_render.ansi +++ b/test/examples/widget_table/snapshots/initial_render.ansi @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/snapshots/initial_render.txt b/test/examples/widget_table/snapshots/initial_render.txt index 54145703..d87c3e5c 100644 --- a/test/examples/widget_table/snapshots/initial_render.txt +++ b/test/examples/widget_table/snapshots/initial_render.txt @@ -18,7 +18,7 @@ └──────────────────────────────────────────────────────────────────────────────┘ ┌Controls──────────────────────────────────────────────────────────────────────┐ │↑/↓: Nav Row ←/→: Nav Col x: Toggle Row (1) q: Quit │ -│s: Style (Cyan) p: Spacing (When Selected) t: Tamp Row │ +│s: Style (Cyan) p: Spacing (When Selected) y: Symbol (> ) t: Tamp Row │ │+/-: Col Space (1) c: Col Highlight (On) f: Flex Mode (Legacy (Default)) │ │z: Cell Highlight (On) o: Offset Mode (Auto (No Offset)) d: Header (On) │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/test/examples/widget_table/test_app.rb b/test/examples/widget_table/test_app.rb index 77488384..ff35e181 100755 --- a/test/examples/widget_table/test_app.rb +++ b/test/examples/widget_table/test_app.rb @@ -117,4 +117,15 @@ def test_header_toggle assert_rich_snapshot("after_header_toggle") end end + + def test_emoji_highlight_symbol + with_test_terminal do + # Cycle highlight symbol: "> " → ">" → "➡️" + inject_keys(:y, :y, :q) + @app.run + + assert_snapshots("after_emoji_highlight") + assert_rich_snapshot("after_emoji_highlight") + end + end end diff --git a/test/examples/widget_text_width/snapshots/after_nav_down.txt b/test/examples/widget_text_width/snapshots/after_nav_down.txt index cea25dcd..eda67eb2 100644 --- a/test/examples/widget_text_width/snapshots/after_nav_down.txt +++ b/test/examples/widget_text_width/snapshots/after_nav_down.txt @@ -1,5 +1,5 @@ ┌Text Width Calculator─────────────────────────────────────────────────────────┐ -│Sample: 你e好l世,界W │ +│Sample: 你好世界 │ │ │ │Display Width (text_width): 8 cells │ │Display Width (span.width): 8 cells │ diff --git a/test/examples/widget_text_width/snapshots/cjk_sample.txt b/test/examples/widget_text_width/snapshots/cjk_sample.txt index cea25dcd..eda67eb2 100644 --- a/test/examples/widget_text_width/snapshots/cjk_sample.txt +++ b/test/examples/widget_text_width/snapshots/cjk_sample.txt @@ -1,5 +1,5 @@ ┌Text Width Calculator─────────────────────────────────────────────────────────┐ -│Sample: 你e好l世,界W │ +│Sample: 你好世界 │ │ │ │Display Width (text_width): 8 cells │ │Display Width (span.width): 8 cells │ diff --git a/test/examples/widget_text_width/snapshots/mixed_sample.txt b/test/examples/widget_text_width/snapshots/mixed_sample.txt index 75e7e2e3..adc706cd 100644 --- a/test/examples/widget_text_width/snapshots/mixed_sample.txt +++ b/test/examples/widget_text_width/snapshots/mixed_sample.txt @@ -1,5 +1,5 @@ ┌Text Width Calculator─────────────────────────────────────────────────────────┐ -│Sample: Hi 你o好👍 👍W │ +│Sample: Hi 你好 👍 │ │ │ │Display Width (text_width): 10 cells │ │Display Width (span.width): 10 cells │ diff --git a/test/ratatui_ruby/schema/test_table.rb b/test/ratatui_ruby/schema/test_table.rb index 47d9ebff..a4e43f3a 100644 --- a/test/ratatui_ruby/schema/test_table.rb +++ b/test/ratatui_ruby/schema/test_table.rb @@ -720,6 +720,34 @@ def test_flex_constants assert_equal :space_evenly, RatatuiRuby::Widgets::Table::FLEX_SPACE_EVENLY end + # Reduced test case: emoji highlight symbol must not displace the right block border. + # ➡️ (U+27A1 + U+FE0F) is 2 columns wide in terminals and in Ratatui's own Text::width(). + # If the binding miscalculates its width, the selected row's content shifts and the + # right │ border no longer aligns with unselected rows. + def test_emoji_highlight_symbol_right_border_alignment + with_test_terminal(20, 4) do + table = RatatuiRuby::Widgets::Table.new( + rows: [["Row 1"], ["Row 2"]], + widths: [RatatuiRuby::Layout::Constraint.length(14)], + selected_row: 0, + highlight_symbol: "➡️", + highlight_spacing: :always, + block: RatatuiRuby::Widgets::Block.new(borders: :all) + ) + RatatuiRuby.draw { |f| f.render_widget(table, f.area) } + + # Every line of the buffer output should have exactly 20 display columns. + # If the emoji is stored as width 1 in the buffer, the line containing it + # will measure as 21 display columns (emoji renders as 2 but only 1 cell + # was allocated), causing the right border to shift visually. + buffer_content.each_with_index do |line, y| + width = RatatuiRuby::Text.width(line) + assert_equal 20, width, + "Line #{y} should be 20 display columns wide, got #{width}: #{line.inspect}" + end + end + end + # NOTE: No 'selection' alias - it's ambiguous whether it returns a row or an index. # Use selected_row for the row index, selected_column for the column index. end diff --git a/test/ratatui_ruby/test_text.rb b/test/ratatui_ruby/test_text.rb index 501fe5c0..c145cf03 100644 --- a/test/ratatui_ruby/test_text.rb +++ b/test/ratatui_ruby/test_text.rb @@ -22,6 +22,11 @@ def test_width_emoji assert_equal 2, RatatuiRuby::Text.width("🌍") # "Hello 👍" = 5 + space (1) + emoji (2) = 8 assert_equal 8, RatatuiRuby::Text.width("Hello 👍") + # ➡️ is U+27A1 + U+FE0F (variation selector). Terminals render it as 2 cells. + # Must match what Ratatui's own Text::width() reports. + assert_equal 2, RatatuiRuby::Text.width("➡️") + # ⭐️ is U+2B50 + U+FE0F (variation selector), as used in the Rooibos TUI. + assert_equal 2, RatatuiRuby::Text.width("⭐️") end def test_width_cjk