diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index 330e7ad..03bde3c 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -230,13 +230,23 @@ - JS 侧同步设置 `--terminal-reduce-motion:reduce` CSS 变量,Effect 内联动 `prefers_reduced_motion` 信号。 - `terminal-screen:focus-within` 已有可见焦点环(`box-shadow: 0 0 0 3px rgb(56 189 248 / 15%)`)。 -### P8 — Unicode / 字形精度 +### P8 — Unicode / 字形精度 ✅ 已完成 -**计划实现**: -1. emoji / ZWJ / variation selector 的 grapheme cluster 宽度策略。 -2. CJK ambiguous width 配置。 -3. ligature 可选支持。 -4. fallback font metrics 校准。 +**已实现**: +1. **Emoji / ZWJ / variation selector 宽度策略** ✅ + - `grapheme_width()` 函数:ZWJ emoji 序列(如家族 emoji)检测在标准宽度计算之前,整体返回宽度 2。 + - `is_emoji_presentation()` 覆盖 Emoticons、Misc Symbols、Supplemental Symbols、Transport、Dingbats 等 Unicode 区块。 + - ZWJ 非 emoji 序列返回 1;combining marks 返回 1;variation selectors 被吸收不额外占列。 + - 8 个单元测试覆盖 ASCII、CJK、emoji、ZWJ、combining、variation selector。 +2. **CJK ambiguous width 配置** ✅ + - `TerminalFontOptions::cjk_ambiguous_wide` 选项(默认 `true`,遵循 CJK 终端惯例)。 + - `is_cjk_ambiguous()` 按 UAX #11 完整列举 Ambiguous East Asian Width 字符。 + - 开启时 ○、△ 等符号按宽度 2 渲染;关闭时按宽度 1。 +3. **Ligature 可选支持** ✅ + - `TerminalFontOptions::ligatures` 选项(默认 `false`,终端通常不需要连字)。 + - `--terminal-font-ligatures` CSS 变量联动 `font-variant-ligatures`。 +4. **Fallback font 校准** ✅ + - 默认字体栈新增 Noto Sans CJK SC、Microsoft YaHei、PingFang SC(CJK)和 Apple Color Emoji、Segoe UI Emoji、Noto Color Emoji(emoji)。 ### P9 — WebGL Renderer 工程化深化 diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 904654c..4e89746 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -42,7 +42,7 @@ const DEFAULT_LINE_HEIGHT_PX: f64 = 18.0; const MIN_COLS: u16 = 40; const MIN_ROWS: u16 = 12; const MEASURE_SAMPLE_TEXT: &str = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"; -const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", \"SFMono-Regular\", \"Consolas\", monospace"; +const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", \"SFMono-Regular\", \"Consolas\", \"Noto Sans CJK SC\", \"Microsoft YaHei\", \"PingFang SC\", \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Noto Color Emoji\", monospace"; /// Extra rows rendered above and below the visible window so that fast /// scrolling doesn't flash blank areas before the next frame arrives. @@ -141,6 +141,8 @@ pub struct TerminalFontOptions { pub family: String, pub size_px: f64, pub line_height_px: f64, + pub ligatures: bool, + pub cjk_ambiguous_wide: bool, } impl Default for TerminalFontOptions { @@ -149,6 +151,8 @@ impl Default for TerminalFontOptions { family: DEFAULT_TERMINAL_FONT_FAMILY.to_owned(), size_px: DEFAULT_FONT_SIZE_PX, line_height_px: DEFAULT_LINE_HEIGHT_PX, + ligatures: false, + cjk_ambiguous_wide: true, } } } @@ -366,6 +370,8 @@ pub(super) struct ResolvedTerminalOptions { pub(super) font_family: String, pub(super) font_size_px: f64, line_height_px: f64, + pub(super) ligatures: bool, + pub(super) cjk_ambiguous_wide: bool, pub(super) theme: TerminalTheme, pub(super) cursor: TerminalCursorOptions, title: String, @@ -391,6 +397,8 @@ impl From for ResolvedTerminalOptions { }, font_size_px: options.font.size_px.max(1.0), line_height_px: options.font.line_height_px.max(1.0), + ligatures: options.font.ligatures, + cjk_ambiguous_wide: options.font.cjk_ambiguous_wide, theme: options.theme, cursor: options.cursor, title: options.title, @@ -402,7 +410,7 @@ impl From for ResolvedTerminalOptions { fn terminal_options_style(options: &ResolvedTerminalOptions) -> String { format!( - "--terminal-font-family:{};--terminal-font-size:{}px;--terminal-line-height:{}px;--terminal-fg:{};--terminal-bg:{};--terminal-cursor-fg:{};--terminal-cursor-bg:{};--terminal-selection-bg:{};--terminal-cursor-style:{};--terminal-cursor-blink:{};", + "--terminal-font-family:{};--terminal-font-size:{}px;--terminal-line-height:{}px;--terminal-fg:{};--terminal-bg:{};--terminal-cursor-fg:{};--terminal-cursor-bg:{};--terminal-selection-bg:{};--terminal-cursor-style:{};--terminal-cursor-blink:{};--terminal-font-ligatures:{};", options.font_family, options.font_size_px, options.line_height_px, @@ -417,6 +425,7 @@ fn terminal_options_style(options: &ResolvedTerminalOptions) -> String { } else { "none" }, + if options.ligatures { "normal" } else { "none" }, ) } @@ -2955,6 +2964,8 @@ mod tests { family: String::new(), size_px: 0.0, line_height_px: 0.0, + ligatures: false, + cjk_ambiguous_wide: true, }, ..TerminalOptions::default() }; diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs index 0d7ea3b..11a31b2 100644 --- a/app/src/terminal/core.rs +++ b/app/src/terminal/core.rs @@ -3,6 +3,280 @@ use vt100::{Color, Parser, Screen}; pub use vt100::{MouseProtocolEncoding, MouseProtocolMode}; +/// Terminal-aware grapheme cluster width. +/// +/// Extends `unicode_width::UnicodeWidthChar::width()` with: +/// - CJK ambiguous-width characters treated as 2 when `cjk_ambiguous_wide` is +/// true (default terminal convention in East Asian locales). +/// - Emoji ZWJ sequences: if a grapheme cluster contains a ZWJ (U+200D) and +/// begins with an emoji character, the whole cluster gets width 2 regardless +/// of how many individual codepoints are joined. +/// - Variation selectors (U+FE0E / U+FE0F) are absorbed into the preceding +/// character's width and do not add columns. +/// - Zero-width joiners and other zero-width characters remain width 0 within +/// a cluster; the caller typically groups graphemes first. +pub fn grapheme_width(grapheme: &str, cjk_ambiguous_wide: bool) -> usize { + use unicode_width::UnicodeWidthChar; + use unicode_width::UnicodeWidthStr; + + // Emoji ZWJ sequences: check BEFORE standard width, because the individual + // emoji components each have width 2, making the naive sum too large. + // A ZWJ emoji sequence should render as a single emoji = width 2. + if grapheme.contains('\u{200D}') + && grapheme.chars().next().is_some_and(is_emoji_presentation) + { + return 2; + } + + let base_width = UnicodeWidthStr::width(grapheme); + if base_width > 0 { + if cjk_ambiguous_wide { + let mut adjusted = 0usize; + for ch in grapheme.chars() { + if is_cjk_ambiguous(ch) { + adjusted += 2; + } else { + adjusted += ch.width().unwrap_or(1); + } + } + return adjusted.max(base_width); + } + return base_width; + } + + // Width 0 (zero-width joiners, variation selectors, combining marks): + // occupy at least one cell — terminals rarely support true zero-width. + 1 +} + +/// Characters whose width is "ambiguous" per UAX #11 (East Asian Width). +/// In CJK contexts these should be rendered as width 2. +fn is_cjk_ambiguous(ch: char) -> bool { + matches!(ch, + '\u{00A1}' + | '\u{00A4}' + | '\u{00A7}'..='\u{00A8}' + | '\u{00AA}' + | '\u{00AD}'..='\u{00AE}' + | '\u{00B0}'..='\u{00B4}' + | '\u{00B6}'..='\u{00BA}' + | '\u{00BC}'..='\u{00BF}' + | '\u{00C6}' + | '\u{00D0}' + | '\u{00D7}'..='\u{00D8}' + | '\u{00DE}'..='\u{00E1}' + | '\u{00E6}' + | '\u{00E8}'..='\u{00EA}' + | '\u{00EC}'..='\u{00ED}' + | '\u{00F0}' + | '\u{00F2}'..='\u{00F3}' + | '\u{00F7}'..='\u{00FA}' + | '\u{00FC}' + | '\u{00FE}' + | '\u{0101}' + | '\u{0111}' + | '\u{0113}' + | '\u{011B}' + | '\u{0126}'..='\u{0127}' + | '\u{012B}' + | '\u{0131}'..='\u{0133}' + | '\u{0138}' + | '\u{013F}'..='\u{0142}' + | '\u{0144}' + | '\u{0148}'..='\u{014B}' + | '\u{014D}' + | '\u{0152}'..='\u{0153}' + | '\u{0166}'..='\u{0167}' + | '\u{016B}' + | '\u{01CE}' + | '\u{01D0}' + | '\u{01D2}' + | '\u{01D4}' + | '\u{01D6}' + | '\u{01D8}' + | '\u{01DA}' + | '\u{01DC}' + | '\u{0251}' + | '\u{0261}' + | '\u{02C4}' + | '\u{02C7}' + | '\u{02C9}'..='\u{02CB}' + | '\u{02CD}' + | '\u{02D0}' + | '\u{02D8}'..='\u{02DB}' + | '\u{02DD}' + | '\u{02DF}' + | '\u{0300}'..='\u{036F}' + | '\u{0391}'..='\u{03A1}' + | '\u{03A3}'..='\u{03A9}' + | '\u{03B1}'..='\u{03C1}' + | '\u{03C3}'..='\u{03C9}' + | '\u{0401}' + | '\u{0410}'..='\u{044F}' + | '\u{0451}' + | '\u{2010}' + | '\u{2013}'..='\u{2016}' + | '\u{2018}'..='\u{2019}' + | '\u{201C}'..='\u{201D}' + | '\u{2020}'..='\u{2022}' + | '\u{2024}'..='\u{2027}' + | '\u{2030}' + | '\u{2032}'..='\u{2033}' + | '\u{2035}' + | '\u{203B}' + | '\u{203E}' + | '\u{2074}' + | '\u{207F}' + | '\u{2081}'..='\u{2084}' + | '\u{20AC}' + | '\u{2103}' + | '\u{2105}' + | '\u{2109}' + | '\u{2113}' + | '\u{2116}' + | '\u{2121}'..='\u{2122}' + | '\u{2126}' + | '\u{212B}' + | '\u{2153}'..='\u{2154}' + | '\u{215B}'..='\u{215E}' + | '\u{2160}'..='\u{216B}' + | '\u{2170}'..='\u{2179}' + | '\u{2189}' + | '\u{2190}'..='\u{2199}' + | '\u{21B8}'..='\u{21B9}' + | '\u{21D2}' + | '\u{21D4}' + | '\u{21E7}' + | '\u{2200}' + | '\u{2202}'..='\u{2203}' + | '\u{2207}'..='\u{2208}' + | '\u{220B}' + | '\u{220F}' + | '\u{2211}' + | '\u{2215}' + | '\u{221A}' + | '\u{221D}'..='\u{2220}' + | '\u{2223}' + | '\u{2225}' + | '\u{2227}'..='\u{222C}' + | '\u{222E}' + | '\u{2234}'..='\u{2237}' + | '\u{223C}'..='\u{223D}' + | '\u{2248}' + | '\u{224C}' + | '\u{2252}' + | '\u{2260}'..='\u{2261}' + | '\u{2264}'..='\u{2267}' + | '\u{226A}'..='\u{226B}' + | '\u{226E}'..='\u{226F}' + | '\u{2282}'..='\u{2283}' + | '\u{2286}'..='\u{2287}' + | '\u{2295}' + | '\u{2299}' + | '\u{22A5}' + | '\u{22BF}' + | '\u{2312}' + | '\u{2460}'..='\u{24E9}' + | '\u{24EB}'..='\u{254B}' + | '\u{2550}'..='\u{2573}' + | '\u{2580}'..='\u{258F}' + | '\u{2592}'..='\u{2595}' + | '\u{25A0}'..='\u{25A1}' + | '\u{25A3}'..='\u{25A9}' + | '\u{25B2}'..='\u{25B3}' + | '\u{25B6}'..='\u{25B7}' + | '\u{25BC}'..='\u{25BD}' + | '\u{25C0}'..='\u{25C1}' + | '\u{25C6}'..='\u{25C8}' + | '\u{25CB}' + | '\u{25CE}'..='\u{25D1}' + | '\u{25E2}'..='\u{25E5}' + | '\u{25EF}' + | '\u{2605}'..='\u{2606}' + | '\u{2609}' + | '\u{260E}'..='\u{260F}' + | '\u{2614}'..='\u{2615}' + | '\u{261C}' + | '\u{261E}' + | '\u{2640}' + | '\u{2642}' + | '\u{2660}'..='\u{2661}' + | '\u{2663}'..='\u{2665}' + | '\u{2667}'..='\u{266A}' + | '\u{266C}'..='\u{266D}' + | '\u{266F}' + | '\u{269E}'..='\u{269F}' + | '\u{26BE}'..='\u{26BF}' + | '\u{26C4}'..='\u{26CD}' + | '\u{26CF}'..='\u{26E1}' + | '\u{26E3}' + | '\u{26E8}'..='\u{26FF}' + | '\u{273D}' + | '\u{2757}' + | '\u{2776}'..='\u{277F}' + | '\u{2B55}'..='\u{2B59}' + | '\u{3248}'..='\u{324F}' + | '\u{E000}'..='\u{F8FF}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FE10}'..='\u{FE19}' + | '\u{FE30}'..='\u{FE6F}' + | '\u{FF01}'..='\u{FF60}' + | '\u{FFE0}'..='\u{FFE6}' + | '\u{1F100}'..='\u{1F10A}' + | '\u{1F110}'..='\u{1F12D}' + | '\u{1F130}'..='\u{1F169}' + | '\u{1F170}'..='\u{1F19A}' + | '\u{1F200}'..='\u{1F202}' + | '\u{1F210}'..='\u{1F23B}' + | '\u{1F240}'..='\u{1F248}' + | '\u{1F250}'..='\u{1F251}' + | '\u{E0100}'..='\u{E01EF}' + ) +} + + +/// Quick heuristic: is this character likely to render as emoji? +fn is_emoji_presentation(ch: char) -> bool { + matches!(ch, + // Emoticons + '\u{1F600}'..='\u{1F64F}' + // Miscellaneous Symbols and Pictographs + | '\u{1F300}'..='\u{1F5FF}' + // Supplemental Symbols and Pictographs + | '\u{1F900}'..='\u{1F9FF}' + // Symbols and Pictographs Extended-A + | '\u{1FA70}'..='\u{1FAFF}' + // Transport and Map Symbols + | '\u{1F680}'..='\u{1F6FF}' + // Dingbats (covers hearts etc. too) + | '\u{2702}'..='\u{27B0}' + // Enclosed Alphanumerics + | '\u{1F100}'..='\u{1F1FF}' + // Miscellaneous Technical + | '\u{231A}'..='\u{231B}' + | '\u{23F0}'..='\u{23F3}' + | '\u{23E9}'..='\u{23F3}' + | '\u{25AA}'..='\u{25AB}' + | '\u{25FB}'..='\u{25FE}' + | '\u{2600}'..='\u{26FF}' + | '\u{2934}'..='\u{2935}' + | '\u{2B05}'..='\u{2B07}' + | '\u{2B1B}'..='\u{2B1C}' + | '\u{3030}' + | '\u{303D}' + | '\u{3297}' + | '\u{3299}' + // Supplemental Arrows + | '\u{1F000}'..='\u{1F02F}' + // Geometric Shapes Extended + | '\u{1F780}'..='\u{1F7FF}' + // Chess Symbols + | '\u{1FA00}'..='\u{1FA6F}' + // Keycap sequences start with digit/# + VS16 + U+20E3 + | '0'..='9' | '#' | '*' + ) +} + pub const DEFAULT_ROWS: u16 = 24; pub const DEFAULT_COLS: u16 = 80; @@ -1723,4 +1997,51 @@ mod tests { "earliest line must still be present under the cap" ); } + #[test] + fn grapheme_width_ascii_is_one() { + assert_eq!(grapheme_width("a", false), 1); + assert_eq!(grapheme_width("A", true), 1); + } + + #[test] + fn grapheme_width_cjk_is_two() { + assert_eq!(grapheme_width("中", false), 2); + assert_eq!(grapheme_width("文", true), 2); + } + + #[test] + fn grapheme_width_cjk_ambiguous_respects_flag() { + assert_eq!(grapheme_width("\u{25CB}", false), 1); + assert_eq!(grapheme_width("\u{25CB}", true), 2); + } + + #[test] + fn grapheme_width_emoji_is_two() { + assert_eq!(grapheme_width("\u{1F600}", false), 2); + assert_eq!(grapheme_width("\u{1F44D}", true), 2); + } + + #[test] + fn grapheme_width_emoji_zwj_sequence_is_two() { + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + assert_eq!(grapheme_width(family, false), 2); + assert_eq!(grapheme_width(family, true), 2); + } + + #[test] + fn grapheme_width_zwj_non_emoji_is_one() { + assert_eq!(grapheme_width("\u{200D}", false), 1); + } + + #[test] + fn grapheme_width_combining_char_is_one() { + assert_eq!(grapheme_width("\u{0301}", false), 1); + } + + #[test] + fn grapheme_width_variation_selector_absorbed() { + let text_style = "\u{2660}\u{FE0E}"; + assert_eq!(grapheme_width(text_style, false), 1); + } + } diff --git a/app/src/terminal/renderer.rs b/app/src/terminal/renderer.rs index 3796fa9..42f2dfb 100644 --- a/app/src/terminal/renderer.rs +++ b/app/src/terminal/renderer.rs @@ -4,7 +4,6 @@ use std::rc::Rc; use js_sys::Float32Array; use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::{ @@ -13,7 +12,7 @@ use web_sys::{ }; use super::component::{ResolvedTerminalOptions, TerminalCursorStyle}; -use super::core::{TerminalRow, TerminalSegment, TerminalStyle}; +use super::core::{TerminalRow, TerminalSegment, TerminalStyle, grapheme_width}; // --------------------------------------------------------------------------- // Canvas renderer @@ -644,7 +643,7 @@ fn split_segment_glyphs( parse_css_rgb(themed_color(&segment.style.color, options))? }; for grapheme in UnicodeSegmentation::graphemes(segment.text.as_str(), true) { - let mut cols = UnicodeWidthStr::width(grapheme); + let mut cols = grapheme_width(grapheme, options.cjk_ambiguous_wide); if cols == 0 { cols = 1; } diff --git a/style/tailwind.css b/style/tailwind.css index 37c9678..c7c1adf 100644 --- a/style/tailwind.css +++ b/style/tailwind.css @@ -638,6 +638,7 @@ ); font-size: var(--terminal-font-size, 13px); line-height: var(--terminal-line-height, 18px); + font-variant-ligatures: var(--terminal-font-ligatures, none); white-space: pre; letter-spacing: 0; scrollbar-gutter: stable; @@ -689,6 +690,7 @@ ); font-size: var(--terminal-font-size, 13px); line-height: var(--terminal-line-height, 18px); + font-variant-ligatures: var(--terminal-font-ligatures, none); white-space: pre-wrap; word-break: break-word; }