diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ccb6478 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,52 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: cargo test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install stable Rust toolchain + uses: dtolnay/rust-toolchain@18f0487477426dce244608110aa2660c2893deb7 # stable + with: + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --locked --all-targets -- -D warnings + + - name: Run workspace tests + run: cargo test --workspace --locked --no-fail-fast + + catalog: + name: catalog metadata + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Validate catalog + run: python3 scripts/generate_catalog.py --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 877cc03..cd7e44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 2026-11-06 — Banner width fix + +- `banner` 0.2.1: centre the proverb against the visible logo width using + Unicode terminal width instead of counting ANSI escape bytes. Coloured logos + no longer widen the logo block or push the box layout to the right. + +## 2026-11-06 — CI and wasm validation + +- Added a `Tests` workflow: fmt, clippy, workspace tests and catalog + verification on pull requests and on main after merge. It does not build or + publish release artifacts. +- Validated the wasm guests with their native toolchains: Rust + (`wasm-crypto`), componentize-py (`wasm-ip-geo`), Go (`wasm-pacman`) and + clang/wasm-ld (`wasm-proc`), including runtime checks against xfetch 1.0.3. + +## 2026-11-06 — SDK 0.3.0 migration + +- Updated the workspace to `xfetch-plugin-api` 0.3.0 and + `xfetch-guest-api` 0.3.0. +- The JSON protocol remains version 1 and the WIT remains + `xfetch:runtime@0.1.0`. +- Plugin code, manifests and catalog versions are unchanged; this is a + dependency alignment with the core 0.3.0 SDK. + ## 2026-11-03 — v1.0.2 Release train v1.0.2 (catalog schema 1, catalog_version 1.0.2). diff --git a/Cargo.lock b/Cargo.lock index d772adb..f08d2d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bitflags" @@ -14,6 +14,18 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys", +] + [[package]] name = "dirs" version = "7.0.0" @@ -45,6 +57,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "itoa" version = "1.0.18" @@ -278,6 +296,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + [[package]] name = "winapi" version = "0.3.9" @@ -419,11 +443,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "xfetch-api-common" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7a35b6c4f442aea26e73bb821978a637bd31261d80d4e97c11185876dd684c" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "xfetch-guest-api" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af1966a017fadc5f8412d7e8e28ea4774d436004caaaeb2a5174a581a790e2" +checksum = "a48b8ca8b0a8f1218f09dbd93570cef036cd9bd19f9b3763e25b912ad1805ab3" dependencies = [ "base64", "serde", @@ -441,18 +475,20 @@ dependencies = [ [[package]] name = "xfetch-plugin-api" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54bc785c4c4bb18d4041edff98baeff88a9ff5595b52212a3a01c7d56e74fb8d" +checksum = "0455e40808edcf2da4ced9c032eaee0c89b130ab76aa214257b8d90d1a5f5df5" dependencies = [ "serde", "serde_json", + "xfetch-api-common", ] [[package]] name = "xfetch-plugin-banner" -version = "0.2.0" +version = "0.2.1" dependencies = [ + "console", "xfetch-plugin-api", ] diff --git a/Cargo.toml b/Cargo.toml index 379fb22..2490def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,5 +21,5 @@ members = [ resolver = "2" [workspace.dependencies] -xfetch-plugin-api = { version = "0.2.0", package = "xfetch-plugin-api" } -xfetch-guest-api = { version = "0.2.0", package = "xfetch-guest-api" } +xfetch-plugin-api = { version = "0.3.0", package = "xfetch-plugin-api" } +xfetch-guest-api = { version = "0.3.0", package = "xfetch-guest-api" } diff --git a/docs/compatibility.md b/docs/compatibility.md index 64fa6a6..1faece7 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,5 +1,15 @@ # Platform Compatibility +

SDK compatibility

+ +

+ The plugins in this repository target xfetch-plugin-api 0.3.0 + and xfetch-guest-api 0.3.0. The JSON protocol remains + 1 and the WIT package remains + xfetch:runtime@0.1.0. Plugins built against the previous + 0.2.0 SDK remain runtime-compatible. +

+

Platform support per plugin. ✓ = works, ~ = works with caveats (see notes), ✗ = not supported (the plugin diff --git a/index.json b/index.json index cd61989..1ec668b 100644 --- a/index.json +++ b/index.json @@ -14,7 +14,7 @@ { "name": "banner", "description": "Xfetch logo-animation plugin that prints a Chinese/Japanese proverb banner under (or above) the logo", - "version": "0.2.0", + "version": "0.2.1", "kind": "logo_animation", "runtime": "native", "path": "plugins/banner", diff --git a/plugins/banner/Cargo.toml b/plugins/banner/Cargo.toml index 8bdd1ef..f88464a 100644 --- a/plugins/banner/Cargo.toml +++ b/plugins/banner/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "xfetch-plugin-banner" -version = "0.2.0" +version = "0.2.1" edition = "2024" license = "MIT" description = "Xfetch logo-animation plugin that prints a Chinese/Japanese proverb banner under (or above) the logo" [dependencies] +console = "0.16.6" xfetch-plugin-api = { workspace = true } diff --git a/plugins/banner/README.md b/plugins/banner/README.md index 612ac2c..43dbc7a 100644 --- a/plugins/banner/README.md +++ b/plugins/banner/README.md @@ -28,8 +28,10 @@ without modifying the logo file. Set `frames_path` to a text file to use its first `===`-separated frame as the banner text instead of the built-in list. -The banner is centred to the logo width, counting CJK characters as two -columns. Requires a terminal with a CJK font (see the repo notes) and a TTY. +The banner is centred to the logo width, counting terminal display width so +ANSI colour codes are ignored. Longer banners wrap to the logo width instead +of widening the logo block, which keeps box layouts aligned. Requires a +terminal with a CJK font (see the repo notes) and a TTY. ## Proverb list diff --git a/plugins/banner/src/main.rs b/plugins/banner/src/main.rs index 526fcf8..d812dc8 100644 --- a/plugins/banner/src/main.rs +++ b/plugins/banner/src/main.rs @@ -61,7 +61,11 @@ fn build_frame(request: &LogoAnimationRequest) -> AnimationFrame { let above = style.contains("above"); let text = banner_text(request, style); let width = logo_width(&request.lines); - let banner: Vec = text.iter().map(|line| center(line, width)).collect(); + let banner: Vec = text + .iter() + .flat_map(|line| wrap(line, width)) + .map(|line| center(&line, width)) + .collect(); let mut lines = Vec::new(); if above { @@ -111,6 +115,38 @@ fn logo_width(lines: &[String]) -> usize { .unwrap_or(0) } +/// Splits a banner line into chunks that fit the logo width. +/// +/// CJK text has no spaces, so wrapping is per character and each chunk is +/// measured with the same terminal-width rules used for centering. This keeps +/// the logo block from growing horizontally and pushing a box layout right. +fn wrap(text: &str, width: usize) -> Vec { + if width == 0 { + return vec![text.to_string()]; + } + + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_width = 0; + + for ch in text.chars() { + let ch_width = display_width(&ch.to_string()); + if current_width + ch_width > width && !current.is_empty() { + lines.push(std::mem::take(&mut current)); + current_width = 0; + } + current.push(ch); + current_width += ch_width; + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + fn center(text: &str, width: usize) -> String { let text_width = display_width(text); if text_width >= width { @@ -121,27 +157,14 @@ fn center(text: &str, width: usize) -> String { format!("{}{}{}", " ".repeat(left), text, " ".repeat(right)) } -/// Display width: East Asian wide characters count as two columns. +/// Display width in terminal columns. +/// +/// `console` skips ANSI sequences and uses Unicode display width, so the +/// banner is centred against the logo the user actually sees. The previous +/// character-count implementation treated the escape bytes of coloured logo +/// lines as visible columns and widened the whole logo block. fn display_width(text: &str) -> usize { - text.chars().map(|ch| if is_wide(ch) { 2 } else { 1 }).sum() -} - -fn is_wide(ch: char) -> bool { - let c = ch as u32; - (0x1100..=0x115F).contains(&c) - || (0x2E80..=0x303E).contains(&c) - || (0x3041..=0x33FF).contains(&c) - || (0x3400..=0x4DBF).contains(&c) - || (0x4E00..=0x9FFF).contains(&c) - || (0xA000..=0xA4CF).contains(&c) - || (0xAC00..=0xD7A3).contains(&c) - || (0xF900..=0xFAFF).contains(&c) - || (0xFE30..=0xFE4F).contains(&c) - || (0xFF00..=0xFF60).contains(&c) - || (0xFFE0..=0xFFE6).contains(&c) - || (0x1F300..=0x1F64F).contains(&c) - || (0x1F900..=0x1F9FF).contains(&c) - || (0x20000..=0x3FFFD).contains(&c) + console::measure_text_width(text) } #[cfg(test)] @@ -163,17 +186,27 @@ mod tests { #[test] fn banner_goes_below_the_logo() { let frame = build_frame(&request("banner-static")); - assert_eq!(frame.lines.len(), 4); assert_eq!(frame.lines[0], " ██ "); - assert!(frame.lines[3].contains(PROVERBS[0])); + assert_eq!(frame.lines[1], " ████ "); + assert_eq!(frame.lines[2], ""); + let banner: String = frame.lines[3..].iter().map(|line| line.trim()).collect(); + assert!(banner.contains(PROVERBS[0])); } #[test] fn banner_above_puts_it_first() { let frame = build_frame(&request("banner-above")); - assert!(frame.lines[0].contains(PROVERBS[0])); - assert_eq!(frame.lines[1], ""); - assert_eq!(frame.lines[2], " ██ "); + let blank = frame + .lines + .iter() + .position(String::is_empty) + .expect("blank separator"); + let banner: String = frame.lines[..blank] + .iter() + .map(|line| line.trim()) + .collect(); + assert!(banner.contains(PROVERBS[0])); + assert_eq!(frame.lines[blank + 1], " ██ "); } #[test] @@ -191,6 +224,42 @@ mod tests { assert_eq!(display_width(banner), display_width(" ██ ")); } + #[test] + fn wraps_a_banner_wider_than_the_logo() { + let req = LogoAnimationRequest::new( + vec![" ██ ".to_string()], + Some(vec![vec!["千里之行".to_string()]]), + LogoAnimationArgs { + style: Some("banner-static".to_string()), + ..LogoAnimationArgs::default() + }, + ); + let frame = build_frame(&req); + let banner: Vec<&String> = frame.lines.iter().skip(2).collect(); + assert!(banner.len() > 1, "long banner must wrap"); + for line in banner { + assert!( + display_width(line) <= display_width(" ██ "), + "banner line wider than logo: {line:?}" + ); + } + } + + #[test] + fn centered_line_ignores_ansi_codes() { + let req = LogoAnimationRequest::new( + vec!["\x1b[32m ██ \x1b[0m".to_string()], + Some(vec![vec!["X".to_string()]]), + LogoAnimationArgs { + style: Some("banner".to_string()), + ..LogoAnimationArgs::default() + }, + ); + let frame = build_frame(&req); + let banner = frame.lines.last().expect("banner line"); + assert_eq!(display_width(banner), display_width(" ██ ")); + } + #[test] fn frames_path_overrides_the_list() { let req = LogoAnimationRequest::new(