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",