From 8533dee71ee78189af2cc0e8ea6080b015e3644f Mon Sep 17 00:00:00 2001 From: James DeMeuse Date: Fri, 19 Jun 2026 07:24:47 -0500 Subject: [PATCH] feat(plugins): expose core unicode display-width to plugins (charWidth/stringWidth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single source of truth for terminal display width in `fresh-core` (`display_width::{char_width, str_width}`, backed by the `unicode-width` crate) and exposes it to plugins as `editor.charWidth(codePoint)` and `editor.stringWidth(text)`. Why: plugins currently have to re-derive their own Unicode width tables (e.g. markdown_compose's hand-rolled `charDisplayWidth`), which inevitably drift from how the editor itself measures cells. With this, a plugin measures width with the exact same logic the renderer uses for layout and cursor positioning — so column widths, wrapping, and alignment agree by construction. - `fresh-core::display_width` is now canonical; `fresh-editor`'s `primitives::display_width` re-exports `char_width`/`str_width` from it (its byte/column helpers and the `DisplayWidth` trait are unchanged), so there's one implementation, not two. - The plugin runtime calls the core function directly (pure, no command round-trip), matching the existing synchronous backend methods. No behavior change to the editor; this only adds APIs. The follow-up markdown-compose PR will use these to delete its duplicated width table (addresses @sinelaw's review note on #2325). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/fresh-core/Cargo.toml | 1 + crates/fresh-core/src/display_width.rs | 58 +++++++++++++++++++ crates/fresh-core/src/lib.rs | 1 + crates/fresh-editor/plugins/lib/fresh.d.ts | 15 +++++ .../src/primitives/display_width.rs | 26 ++------- .../src/backend/quickjs_backend.rs | 21 +++++++ crates/fresh-plugin-runtime/src/ts_export.rs | 2 + 8 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 crates/fresh-core/src/display_width.rs diff --git a/Cargo.lock b/Cargo.lock index 82183245b4..a6ae17bbd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1390,6 +1390,7 @@ dependencies = [ "serde", "serde_json", "ts-rs", + "unicode-width", ] [[package]] diff --git a/crates/fresh-core/Cargo.toml b/crates/fresh-core/Cargo.toml index 35ccf3b454..b53d1b684f 100644 --- a/crates/fresh-core/Cargo.toml +++ b/crates/fresh-core/Cargo.toml @@ -15,6 +15,7 @@ schemars.workspace = true anyhow.workspace = true lsp-types.workspace = true ts-rs.workspace = true +unicode-width = "0.2" rquickjs = { workspace = true, optional = true } rquickjs-serde = { workspace = true, optional = true } diff --git a/crates/fresh-core/src/display_width.rs b/crates/fresh-core/src/display_width.rs new file mode 100644 index 0000000000..487b9aed93 --- /dev/null +++ b/crates/fresh-core/src/display_width.rs @@ -0,0 +1,58 @@ +//! Display width calculation for Unicode text. +//! +//! The single source of truth for "how many terminal columns does this text +//! occupy", backed by the `unicode-width` crate. Used for cursor positioning, +//! line wrapping, and UI layout with CJK characters, emoji, and other +//! double-width or zero-width characters. +//! +//! This lives in `fresh-core` so that both the editor (layout/rendering) and +//! the plugin runtime (the `charWidth` / `stringWidth` plugin APIs) compute +//! width with the *same* logic — plugins must not re-derive their own width +//! tables, or their measurements drift from how the editor actually lays out +//! cells. + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Display width of a single character, in terminal columns. +/// +/// Returns 0 for control and zero-width characters, 2 for CJK/fullwidth +/// characters and most emoji, and 1 for everything else. +#[inline] +pub fn char_width(c: char) -> usize { + // unicode_width returns None for control characters. + c.width().unwrap_or(0) +} + +/// Display width of a string, in terminal columns (the sum of its characters' +/// widths). Use this instead of `.chars().count()` for visual layout. +#[inline] +pub fn str_width(s: &str) -> usize { + s.width() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii() { + assert_eq!(str_width("Hello"), 5); + assert_eq!(str_width(""), 0); + assert_eq!(char_width('a'), 1); + } + + #[test] + fn cjk_and_emoji_are_two_columns() { + assert_eq!(char_width('你'), 2); + assert_eq!(char_width('🚀'), 2); + assert_eq!(str_width("你好"), 4); + assert_eq!(str_width("Hi🚀"), 4); + } + + #[test] + fn control_and_zero_width_are_zero() { + assert_eq!(char_width('\0'), 0); + assert_eq!(char_width('\t'), 0); + assert_eq!(char_width('\u{200B}'), 0); // zero-width space + } +} diff --git a/crates/fresh-core/src/lib.rs b/crates/fresh-core/src/lib.rs index 40c67184cd..abab5a8263 100644 --- a/crates/fresh-core/src/lib.rs +++ b/crates/fresh-core/src/lib.rs @@ -123,6 +123,7 @@ impl std::fmt::Display for WindowTerminalId { } pub mod config; +pub mod display_width; pub mod file_explorer; pub mod file_uri; pub mod menu; diff --git a/crates/fresh-editor/plugins/lib/fresh.d.ts b/crates/fresh-editor/plugins/lib/fresh.d.ts index 8053570a73..b4908b37bc 100644 --- a/crates/fresh-editor/plugins/lib/fresh.d.ts +++ b/crates/fresh-editor/plugins/lib/fresh.d.ts @@ -2523,6 +2523,21 @@ interface EditorAPI { */ clearConcealsInRange(bufferId: number, start: number, end: number): boolean; /** + * Display width of a single Unicode code point, in terminal columns + * (0 for control/zero-width, 2 for CJK/fullwidth and most emoji, else 1). + * + * Backed by the editor's own width logic (`fresh_core::display_width`), so + * plugins measure width exactly as the editor lays out cells — no + * per-plugin width tables. An invalid code point returns 0. + */ + charWidth(codePoint: number): number; + /** + * Display width of a string, in terminal columns (the sum of its + * characters' widths). Prefer this over per-character `charWidth` calls + * when measuring whole cells — one boundary crossing instead of many. + */ + stringWidth(text: string): number; + /** * Add a collapsed fold range. Hides bytes [start, end) from * rendering — the line containing `start - 1` (the fold "header") * stays visible, while subsequent lines covered by the range are diff --git a/crates/fresh-editor/src/primitives/display_width.rs b/crates/fresh-editor/src/primitives/display_width.rs index f38c28fc97..5a0adab6d4 100644 --- a/crates/fresh-editor/src/primitives/display_width.rs +++ b/crates/fresh-editor/src/primitives/display_width.rs @@ -5,27 +5,11 @@ //! cursor positioning, line wrapping, and UI layout with CJK characters, //! emoji, and other double-width or zero-width characters. -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -/// Calculate the display width of a single character. -/// -/// Returns 0 for control characters and zero-width characters, -/// 2 for CJK/fullwidth characters and emoji, -/// 1 for most other characters. -#[inline] -pub fn char_width(c: char) -> usize { - // unicode_width returns None for control characters - c.width().unwrap_or(0) -} - -/// Calculate the display width of a string. -/// -/// This is the sum of display widths of all characters in the string. -/// Use this instead of `.chars().count()` when calculating visual layout. -#[inline] -pub fn str_width(s: &str) -> usize { - s.width() -} +// `char_width` / `str_width` are the single source of truth in `fresh-core`, +// shared with the plugin runtime's `charWidth` / `stringWidth` APIs so plugins +// measure width exactly the way the editor lays out cells. The editor-specific +// byte/column helpers below build on them. +pub use fresh_core::display_width::{char_width, str_width}; /// Extension trait for convenient width calculation on string types. pub trait DisplayWidth { diff --git a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs index 89b578748f..c8271a4c23 100644 --- a/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs +++ b/crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs @@ -3540,6 +3540,27 @@ impl JsEditorApi { .is_ok() } + // === Text measurement === + + /// Display width of a single Unicode code point, in terminal columns + /// (0 for control/zero-width, 2 for CJK/fullwidth and most emoji, else 1). + /// + /// Backed by the editor's own width logic (`fresh_core::display_width`), so + /// plugins measure width exactly as the editor lays out cells — no + /// per-plugin width tables. An invalid code point returns 0. + pub fn char_width(&self, code_point: u32) -> u32 { + char::from_u32(code_point) + .map(fresh_core::display_width::char_width) + .unwrap_or(0) as u32 + } + + /// Display width of a string, in terminal columns (the sum of its + /// characters' widths). Prefer this over per-character `charWidth` calls + /// when measuring whole cells — one boundary crossing instead of many. + pub fn string_width(&self, text: String) -> u32 { + fresh_core::display_width::str_width(&text) as u32 + } + // === Folds === /// Add a collapsed fold range. Hides bytes [start, end) from diff --git a/crates/fresh-plugin-runtime/src/ts_export.rs b/crates/fresh-plugin-runtime/src/ts_export.rs index 47580116ef..5a742e4e25 100644 --- a/crates/fresh-plugin-runtime/src/ts_export.rs +++ b/crates/fresh-plugin-runtime/src/ts_export.rs @@ -1355,6 +1355,8 @@ mod tests { "addConceal", "clearConcealNamespace", "clearConcealsInRange", + "charWidth", + "stringWidth", "addSoftBreak", "clearSoftBreakNamespace", "clearSoftBreaksInRange",