From 566703ffd6431dc88d1851fc88735715a59802cc Mon Sep 17 00:00:00 2001 From: Kerrick Long Date: Sun, 15 Feb 2026 19:05:21 -0600 Subject: [PATCH 1/7] chore: release v1.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Windows Compilation: Fixed gem compilation failures on Windows caused by clang header errors during Rust extension compilation. GVL Contention in poll_event: poll_event now releases Ruby's Global VM Lock while waiting for terminal events. Previously, background threads (e.g., those executing shell commands via Open3) were starved of the GVL during the blocking poll, causing up to 190× slower subprocess execution in multi-threaded applications. Gem Size: Reduced gem size from many MB to hundreds of KB by excluding doc/, examples/, and other development files. --- CHANGELOG.md | 10 ++++++++++ Gemfile.lock | 4 ++-- ext/ratatui_ruby/Cargo.lock | 2 +- ext/ratatui_ruby/Cargo.toml | 2 +- lib/ratatui_ruby/version.rb | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cc8b9..1156f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +### Changed + +### Fixed + +### 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 ffee569..a94a944 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ratatui_ruby (1.3.0) + ratatui_ruby (1.4.0) 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.0) rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rb_sys (0.9.123) sha256=c22ae84d1bca3eec0f13a45ae4ca9ba6eace93d5be270a40a9c0a9a5b92a34e5 diff --git a/ext/ratatui_ruby/Cargo.lock b/ext/ratatui_ruby/Cargo.lock index 33857a2..4e59361 100644 --- a/ext/ratatui_ruby/Cargo.lock +++ b/ext/ratatui_ruby/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "ratatui_ruby" -version = "1.3.0" +version = "1.4.0" dependencies = [ "bumpalo", "lazy_static", diff --git a/ext/ratatui_ruby/Cargo.toml b/ext/ratatui_ruby/Cargo.toml index 7897da7..04e0fa0 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.0" edition = "2021" [lib] diff --git a/lib/ratatui_ruby/version.rb b/lib/ratatui_ruby/version.rb index 9629246..dc2e9e5 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.0" end From 34d8bae2d40ab1f2c46254df47d24069cacf4110 Mon Sep 17 00:00:00 2001 From: Kerrick Long Date: Sun, 15 Feb 2026 21:41:40 -0600 Subject: [PATCH 2/7] fix: precompiled gems now support Ruby 3.2 through 4.0 The build workflow previously compiled only against Ruby 4.0, so precompiled gems were locked to that single version despite the gemspec declaring >= 3.2.9. The workflow now uses a 12-job matrix (4 Ruby versions x 3 OSes) with a packaging phase that assembles all binaries into a single platform gem per OS. The extension loader tries a versioned subdirectory first, falling back to the flat path for source gems. The monolithic NativeGemRelease class is decomposed into domain objects (CIRun, GitHubCli, NativeGemVersion, PlatformGem, VersionedBinary) following the conventions established in tasks/bump/. Generated with Antigravity (https://antigravity.google) Co-Authored-By: Claude Opus 4.6 (Thinking) --- .github/workflows/build-gems.yml | 79 +++++++++++++++++++-- CHANGELOG.md | 2 + lib/ratatui_ruby.rb | 7 +- tasks/release.rake | 4 +- tasks/release/ci_run.rb | 41 +++++++++++ tasks/release/github_cli.rb | 31 +++++++++ tasks/release/native_gem_release.rb | 103 ---------------------------- tasks/release/native_gem_version.rb | 68 ++++++++++++++++++ tasks/release/platform_gem.rb | 59 ++++++++++++++++ tasks/release/versioned_binary.rb | 27 ++++++++ 10 files changed, 308 insertions(+), 113 deletions(-) create mode 100644 tasks/release/ci_run.rb create mode 100644 tasks/release/github_cli.rb delete mode 100644 tasks/release/native_gem_release.rb create mode 100644 tasks/release/native_gem_version.rb create mode 100644 tasks/release/platform_gem.rb create mode 100644 tasks/release/versioned_binary.rb diff --git a/.github/workflows/build-gems.yml b/.github/workflows/build-gems.yml index cb0dcac..ec84962 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/CHANGELOG.md b/CHANGELOG.md index 1156f99..a7659b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### 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 diff --git a/lib/ratatui_ruby.rb b/lib/ratatui_ruby.rb index b19dd14..eb9c602 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/tasks/release.rake b/tasks/release.rake index fe10d80..93ffa12 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 0000000..b3aad51 --- /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 0000000..e001410 --- /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 5f5c431..0000000 --- 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 0000000..ff8b3ce --- /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 0000000..2e893c9 --- /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 0000000..a22aafd --- /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 From 6f150aa6b11642c63638b9e75b82a8b1d37f7da2 Mon Sep 17 00:00:00 2001 From: Kerrick Long Date: Sun, 15 Feb 2026 23:15:01 -0600 Subject: [PATCH 3/7] chore: release v1.4.1 Precompiled Native Gems: Now support Ruby 3.2, 3.3, 3.4, and 4.0 (previously only 4.0) --- CHANGELOG.md | 10 ++++++++++ Gemfile.lock | 4 ++-- ext/ratatui_ruby/Cargo.lock | 2 +- ext/ratatui_ruby/Cargo.toml | 2 +- lib/ratatui_ruby/version.rb | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7659b3..e580e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed +### 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 diff --git a/Gemfile.lock b/Gemfile.lock index a94a944..86b218d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ratatui_ruby (1.4.0) + ratatui_ruby (1.4.1) 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.4.0) + ratatui_ruby (1.4.1) rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rb_sys (0.9.123) sha256=c22ae84d1bca3eec0f13a45ae4ca9ba6eace93d5be270a40a9c0a9a5b92a34e5 diff --git a/ext/ratatui_ruby/Cargo.lock b/ext/ratatui_ruby/Cargo.lock index 4e59361..9cab2db 100644 --- a/ext/ratatui_ruby/Cargo.lock +++ b/ext/ratatui_ruby/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "ratatui_ruby" -version = "1.4.0" +version = "1.4.1" dependencies = [ "bumpalo", "lazy_static", diff --git a/ext/ratatui_ruby/Cargo.toml b/ext/ratatui_ruby/Cargo.toml index 04e0fa0..4a82fcf 100644 --- a/ext/ratatui_ruby/Cargo.toml +++ b/ext/ratatui_ruby/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "ratatui_ruby" -version = "1.4.0" +version = "1.4.1" edition = "2021" [lib] diff --git a/lib/ratatui_ruby/version.rb b/lib/ratatui_ruby/version.rb index dc2e9e5..88ee63e 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.4.0" + VERSION = "1.4.1" end From d07999833259b090f6419cfc300138f3cff89351 Mon Sep 17 00:00:00 2001 From: Kerrick Long Date: Sun, 15 Feb 2026 21:41:40 -0600 Subject: [PATCH 4/7] fix: correct wide character width in Text.width and buffer serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text.width measured per-character with UnicodeWidthChar, returning 1 for emoji with variation selectors (➡️ = U+27A1 + U+FE0F). Delegate to Ratatui's Text::width() which measures per-grapheme and returns 2. get_buffer_content naively concatenated all cell symbols including continuation cells after wide characters. Each continuation cell added an extra display column to the serialized string, breaking 23 snapshot assertions. Skip continuation cells using the same pattern as Ratatui's Buffer Debug formatter. Also adds the widget_table example's highlight symbol cycling (y key) and an integration test for emoji highlight border alignment. Generated with Antigravity (https://antigravity.google) Co-Authored-By: Claude Opus 4.6 (Thinking) --- CHANGELOG.md | 3 + examples/widget_table/README.md | 1 + examples/widget_table/app.rb | 17 +++- ext/ratatui_ruby/Cargo.lock | 17 ++-- ext/ratatui_ruby/Cargo.toml | 1 - ext/ratatui_ruby/src/string_width.rs | 87 ++----------------- ext/ratatui_ruby/src/terminal/queries.rs | 20 ++++- .../snapshots/after_col_space_increase.ansi | 2 +- .../snapshots/after_col_space_increase.txt | 2 +- .../snapshots/after_column_navigate.ansi | 2 +- .../snapshots/after_column_navigate.txt | 2 +- .../snapshots/after_emoji_highlight.ansi | 24 +++++ .../snapshots/after_emoji_highlight.txt | 24 +++++ .../snapshots/after_header_toggle.ansi | 2 +- .../snapshots/after_header_toggle.txt | 2 +- .../snapshots/after_navigate_down.ansi | 2 +- .../snapshots/after_navigate_down.txt | 2 +- .../snapshots/after_navigate_up_wrap.ansi | 2 +- .../snapshots/after_navigate_up_wrap.txt | 2 +- .../snapshots/after_offset_mode_cycle.ansi | 2 +- .../snapshots/after_offset_mode_cycle.txt | 2 +- .../snapshots/after_spacing_cycle.ansi | 2 +- .../snapshots/after_spacing_cycle.txt | 2 +- .../snapshots/after_style_switch.ansi | 2 +- .../snapshots/after_style_switch.txt | 2 +- .../snapshots/after_toggle_selection.ansi | 2 +- .../snapshots/after_toggle_selection.txt | 2 +- .../snapshots/initial_render.ansi | 2 +- .../widget_table/snapshots/initial_render.txt | 2 +- test/examples/widget_table/test_app.rb | 11 +++ .../snapshots/after_nav_down.txt | 2 +- .../snapshots/cjk_sample.txt | 2 +- .../snapshots/mixed_sample.txt | 2 +- test/ratatui_ruby/schema/test_table.rb | 28 ++++++ test/ratatui_ruby/test_text.rb | 5 ++ 35 files changed, 164 insertions(+), 120 deletions(-) create mode 100644 test/examples/widget_table/snapshots/after_emoji_highlight.ansi create mode 100644 test/examples/widget_table/snapshots/after_emoji_highlight.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index e580e35..0fd4b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed +- **`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 diff --git a/examples/widget_table/README.md b/examples/widget_table/README.md index 374a149..61302b5 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 3256f19..05f7a88 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 9cab2db..97c318b 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,7 +1054,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -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 4a82fcf..4a411e6 100644 --- a/ext/ratatui_ruby/Cargo.toml +++ b/ext/ratatui_ruby/Cargo.toml @@ -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/string_width.rs b/ext/ratatui_ruby/src/string_width.rs index d196281..cfb9705 100644 --- a/ext/ratatui_ruby/src/string_width.rs +++ b/ext/ratatui_ruby/src/string_width.rs @@ -2,16 +2,13 @@ // SPDX-License-Identifier: AGPL-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/terminal/queries.rs b/ext/ratatui_ruby/src/terminal/queries.rs index a645bbb..9460836 100644 --- a/ext/ratatui_ruby/src/terminal/queries.rs +++ b/ext/ratatui_ruby/src/terminal/queries.rs @@ -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/test/examples/widget_table/snapshots/after_col_space_increase.ansi b/test/examples/widget_table/snapshots/after_col_space_increase.ansi index b3885b0..5ce6eda 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 6f421ad..7d0caa1 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 4cb7556..18ba148 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 5414570..d87c3e5 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 0000000..6033c45 --- /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 0000000..d9ec874 --- /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 78b35de..8e10316 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 6da4823..6ff9eb1 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 533db83..3948859 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 ee95fea..db808ce 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 782e279..fceeea7 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 9683151..07aa6bd 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 fb4f6fe..78599ce 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 cae9cca..bad5a46 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 21badaf..75b3751 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 18a1690..e210384 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 55f796d..f99c87d 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 d9ec1fb..7da3681 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 5f6f503..08e91f3 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 4960772..c9d5129 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 d1bb26c..706dd1b 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 5414570..d87c3e5 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 7748838..ff35e18 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 cea25dc..eda67eb 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 cea25dc..eda67eb 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 75e7e2e..adc706c 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 47d9ebf..a4e43f3 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 501fe5c..c145cf0 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 From 88fe0d1ab43686bb2aa1f3030c676beb3eec0ae7 Mon Sep 17 00:00:00 2001 From: Kerrick Long Date: Sun, 22 Feb 2026 12:35:29 -0600 Subject: [PATCH 5/7] chore: fix SPDX license headers on all Rust source files All 42 .rs files in ext/ were incorrectly tagged AGPL-3.0-or-later when they should be LGPL-3.0-or-later per the project license policy. The root cause was that the license automation only processed .rb files, so Rust files were hand-annotated with the wrong license. This adds a headers_rs.rb script and wires it into the license rake tasks so future .rs files are handled automatically. AGENTS.md is also corrected to specify per-directory license rules instead of a blanket "use AGPL for code" instruction. Generated with Antigravity (https://antigravity.google) Co-Authored-By: Claude Opus 4.6 (Thinking) --- AGENTS.md | 2 +- CHANGELOG.md | 1 + ext/ratatui_ruby/src/color.rs | 2 +- ext/ratatui_ruby/src/errors.rs | 2 +- ext/ratatui_ruby/src/events.rs | 2 +- ext/ratatui_ruby/src/frame.rs | 2 +- ext/ratatui_ruby/src/lib.rs | 2 +- ext/ratatui_ruby/src/lib_header.rs | 2 +- ext/ratatui_ruby/src/rendering.rs | 2 +- ext/ratatui_ruby/src/string_width.rs | 2 +- ext/ratatui_ruby/src/style.rs | 2 +- ext/ratatui_ruby/src/terminal/capabilities.rs | 2 +- ext/ratatui_ruby/src/terminal/init.rs | 2 +- ext/ratatui_ruby/src/terminal/mod.rs | 2 +- ext/ratatui_ruby/src/terminal/mutations.rs | 2 +- ext/ratatui_ruby/src/terminal/queries.rs | 2 +- ext/ratatui_ruby/src/terminal/query.rs | 2 +- ext/ratatui_ruby/src/terminal/storage.rs | 2 +- ext/ratatui_ruby/src/terminal/wrapper.rs | 2 +- ext/ratatui_ruby/src/text.rs | 2 +- ext/ratatui_ruby/src/widgets/barchart.rs | 2 +- ext/ratatui_ruby/src/widgets/block.rs | 2 +- ext/ratatui_ruby/src/widgets/calendar.rs | 2 +- ext/ratatui_ruby/src/widgets/canvas.rs | 2 +- ext/ratatui_ruby/src/widgets/center.rs | 2 +- ext/ratatui_ruby/src/widgets/chart.rs | 2 +- ext/ratatui_ruby/src/widgets/clear.rs | 2 +- ext/ratatui_ruby/src/widgets/cursor.rs | 2 +- ext/ratatui_ruby/src/widgets/gauge.rs | 2 +- ext/ratatui_ruby/src/widgets/layout.rs | 2 +- ext/ratatui_ruby/src/widgets/line_gauge.rs | 2 +- ext/ratatui_ruby/src/widgets/list.rs | 2 +- ext/ratatui_ruby/src/widgets/list_state.rs | 2 +- ext/ratatui_ruby/src/widgets/mod.rs | 2 +- ext/ratatui_ruby/src/widgets/overlay.rs | 2 +- ext/ratatui_ruby/src/widgets/paragraph.rs | 2 +- ext/ratatui_ruby/src/widgets/ratatui_logo.rs | 2 +- .../src/widgets/ratatui_mascot.rs | 2 +- ext/ratatui_ruby/src/widgets/scrollbar.rs | 2 +- .../src/widgets/scrollbar_state.rs | 2 +- ext/ratatui_ruby/src/widgets/sparkline.rs | 2 +- ext/ratatui_ruby/src/widgets/table.rs | 2 +- ext/ratatui_ruby/src/widgets/table_state.rs | 2 +- ext/ratatui_ruby/src/widgets/tabs.rs | 2 +- tasks/license.rake | 27 ++- tasks/license/headers_rs.rb | 174 ++++++++++++++++++ 46 files changed, 241 insertions(+), 47 deletions(-) create mode 100644 tasks/license/headers_rs.rb diff --git a/AGENTS.md b/AGENTS.md index 7253567..39e676a 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 0fd4b1a..8f98206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### 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. diff --git a/ext/ratatui_ruby/src/color.rs b/ext/ratatui_ruby/src/color.rs index 13249cb..492dc18 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 9cbf785..c298221 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 fee6ae6..40b66bb 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 37590a8..bf9abbc 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 d37e024..72ebc62 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 cc42374..a45d0de 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 45dcf0d..b2571a2 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; diff --git a/ext/ratatui_ruby/src/string_width.rs b/ext/ratatui_ruby/src/string_width.rs index cfb9705..c7141c2 100644 --- a/ext/ratatui_ruby/src/string_width.rs +++ b/ext/ratatui_ruby/src/string_width.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::{prelude::*, Error, Value}; use ratatui::text::Text; diff --git a/ext/ratatui_ruby/src/style.rs b/ext/ratatui_ruby/src/style.rs index cfd11f3..68697f2 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 c541e12..f437788 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 cb6b01b..62b2e74 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 d9beca9..067ddaa 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 26a48a8..2a1b2b0 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 9460836..b1bde87 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). diff --git a/ext/ratatui_ruby/src/terminal/query.rs b/ext/ratatui_ruby/src/terminal/query.rs index e7c852b..d5ea4d6 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 c0a12e9..e589edc 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 8df520d..98d1837 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 747c7c5..5e0d70b 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 4670bbc..9094a7b 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 a2b6164..c0ddf03 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 a0ccfd9..911e451 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 3e7fb13..e5bd3a0 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 8d5e2f8..d4337de 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 98b0009..b6bd45c 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 5953424..c1b7c02 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 4ba5612..5775c55 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 f2a0106..4fd6472 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 721cf34..bd09daf 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 54aef04..b9dc0bf 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 9efc70c..2b274c5 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 4b2401a..b782f3d 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 f7cb3a4..bbaf97a 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; diff --git a/ext/ratatui_ruby/src/widgets/overlay.rs b/ext/ratatui_ruby/src/widgets/overlay.rs index c504c20..c14721d 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 998b8d2..74b256e 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 c3d7e9b..2f639b4 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 29b8d33..c9ecd35 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/scrollbar.rs b/ext/ratatui_ruby/src/widgets/scrollbar.rs index 21a180d..798c795 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 1073e99..fae2b3b 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 25ffcd7..0e3cae6 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 fca4eb1..237a6c9 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 5eafed4..dcf44cc 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 e9ee90a..ab9ee70 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/tasks/license.rake b/tasks/license.rake index 1100637..964d147 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 0000000..3963805 --- /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 From 41d6f5a93a8b688428ca147f04d5baaa03bb6ce5 Mon Sep 17 00:00:00 2001 From: Kerrick Long Date: Sun, 22 Feb 2026 14:08:40 -0600 Subject: [PATCH 6/7] chore: release v1.4.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 10 ++++++++++ Gemfile.lock | 4 ++-- ext/ratatui_ruby/Cargo.lock | 2 +- ext/ratatui_ruby/Cargo.toml | 2 +- lib/ratatui_ruby/version.rb | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f98206..b6ebc57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### 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. diff --git a/Gemfile.lock b/Gemfile.lock index 86b218d..7b8a0c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ratatui_ruby (1.4.1) + 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.4.1) + 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/ext/ratatui_ruby/Cargo.lock b/ext/ratatui_ruby/Cargo.lock index 97c318b..c8d6f44 100644 --- a/ext/ratatui_ruby/Cargo.lock +++ b/ext/ratatui_ruby/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "ratatui_ruby" -version = "1.4.1" +version = "1.4.2" dependencies = [ "bumpalo", "lazy_static", diff --git a/ext/ratatui_ruby/Cargo.toml b/ext/ratatui_ruby/Cargo.toml index 4a411e6..0482701 100644 --- a/ext/ratatui_ruby/Cargo.toml +++ b/ext/ratatui_ruby/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "ratatui_ruby" -version = "1.4.1" +version = "1.4.2" edition = "2021" [lib] diff --git a/lib/ratatui_ruby/version.rb b/lib/ratatui_ruby/version.rb index 88ee63e..79f1d96 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.4.1" + VERSION = "1.4.2" end From f39ec2d7322a3d6677dfe0caf0549d6da166dbb9 Mon Sep 17 00:00:00 2001 From: Doug Bitting Date: Sat, 28 Mar 2026 20:44:16 -0700 Subject: [PATCH 7/7] Add ScrollView widget for scrolling arbitrary widget content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paragraph supports scroll natively, but there's no way to scroll composite content (layouts, nested blocks, styled widgets). ScrollView fills this gap by rendering child widgets into a virtual buffer and copying the visible viewport based on a scroll offset. The approach: create an oversized Buffer::empty(), render the full child widget tree into it via the existing render_node pipeline, then copy the visible rows (based on scroll offset and viewport height) into the real frame buffer. This reuses all existing rendering infrastructure — render_node is already buffer-agnostic. API: tui.scroll_view(child: widget, scroll: [y, x], content_height: n) The content_height parameter tells the widget how tall the virtual buffer needs to be. The caller computes this from their data model (e.g. number of feed items * lines per item). When scroll is [0,0] and content fits in the viewport, it renders directly without the virtual buffer allocation. --- ext/ratatui_ruby/src/rendering.rs | 1 + ext/ratatui_ruby/src/widgets/mod.rs | 1 + ext/ratatui_ruby/src/widgets/scroll_view.rs | 62 +++++++++++++++++++++ lib/ratatui_ruby/tui/widget_factories.rb | 7 +++ lib/ratatui_ruby/widgets.rb | 1 + lib/ratatui_ruby/widgets/scroll_view.rb | 39 +++++++++++++ 6 files changed, 111 insertions(+) create mode 100644 ext/ratatui_ruby/src/widgets/scroll_view.rs create mode 100644 lib/ratatui_ruby/widgets/scroll_view.rb diff --git a/ext/ratatui_ruby/src/rendering.rs b/ext/ratatui_ruby/src/rendering.rs index b2571a2..cc6b139 100644 --- a/ext/ratatui_ruby/src/rendering.rs +++ b/ext/ratatui_ruby/src/rendering.rs @@ -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/widgets/mod.rs b/ext/ratatui_ruby/src/widgets/mod.rs index bbaf97a..7df9f42 100644 --- a/ext/ratatui_ruby/src/widgets/mod.rs +++ b/ext/ratatui_ruby/src/widgets/mod.rs @@ -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/scroll_view.rs b/ext/ratatui_ruby/src/widgets/scroll_view.rs new file mode 100644 index 0000000..53317bb --- /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/lib/ratatui_ruby/tui/widget_factories.rb b/lib/ratatui_ruby/tui/widget_factories.rb index f4f3b71..de76dd7 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/widgets.rb b/lib/ratatui_ruby/widgets.rb index 70db3d6..a15f833 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 0000000..9b06bd7 --- /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