Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/fresh-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
58 changes: 58 additions & 0 deletions crates/fresh-core/src/display_width.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions crates/fresh-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions crates/fresh-editor/plugins/lib/fresh.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 5 additions & 21 deletions crates/fresh-editor/src/primitives/display_width.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/fresh-plugin-runtime/src/ts_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,8 @@ mod tests {
"addConceal",
"clearConcealNamespace",
"clearConcealsInRange",
"charWidth",
"stringWidth",
"addSoftBreak",
"clearSoftBreakNamespace",
"clearSoftBreaksInRange",
Expand Down