Skip to main content

rmux_core/
utf8.rs

1//! tmux-aligned UTF-8 width, combining, and truncation rules.
2
3use crate::OptionStore;
4use rmux_proto::OptionName;
5use unicode_width::UnicodeWidthChar;
6
7const UTF8_ZWJ: char = '\u{200D}';
8const UTF8_VS16: char = '\u{FE0F}';
9const HANGUL_FILLER: char = '\u{3164}';
10const MAX_COMBINED_BYTES: usize = 21;
11
12const DEFAULT_WIDTH_OVERRIDES: &[WidthOverride] = &[
13    WidthOverride::single(0x261D, 2),
14    WidthOverride::single(0x26F9, 2),
15    WidthOverride::new(0x270A, 0x270D, 2),
16    WidthOverride::new(0x1F1E6, 0x1F1FF, 1),
17    WidthOverride::single(0x1F385, 2),
18    WidthOverride::new(0x1F3C2, 0x1F3C4, 2),
19    WidthOverride::single(0x1F3C7, 2),
20    WidthOverride::new(0x1F3CA, 0x1F3CC, 2),
21    WidthOverride::new(0x1F3FB, 0x1F3FF, 2),
22    WidthOverride::new(0x1F442, 0x1F443, 2),
23    WidthOverride::new(0x1F446, 0x1F450, 2),
24    WidthOverride::new(0x1F466, 0x1F469, 2),
25    WidthOverride::new(0x1F46B, 0x1F46E, 2),
26    WidthOverride::new(0x1F470, 0x1F478, 2),
27    WidthOverride::single(0x1F47C, 2),
28    WidthOverride::new(0x1F481, 0x1F483, 2),
29    WidthOverride::new(0x1F485, 0x1F487, 2),
30    WidthOverride::single(0x1F48F, 2),
31    WidthOverride::single(0x1F491, 2),
32    WidthOverride::single(0x1F4AA, 2),
33    WidthOverride::new(0x1F574, 0x1F575, 2),
34    WidthOverride::single(0x1F57A, 2),
35    WidthOverride::single(0x1F590, 2),
36    WidthOverride::new(0x1F595, 0x1F596, 2),
37    WidthOverride::new(0x1F645, 0x1F647, 2),
38    WidthOverride::new(0x1F64B, 0x1F64F, 2),
39    WidthOverride::single(0x1F6A3, 2),
40    WidthOverride::new(0x1F6B4, 0x1F6B6, 2),
41    WidthOverride::single(0x1F6C0, 2),
42    WidthOverride::single(0x1F6CC, 2),
43    WidthOverride::single(0x1F90C, 2),
44    WidthOverride::single(0x1F90F, 2),
45    WidthOverride::new(0x1F918, 0x1F91F, 2),
46    WidthOverride::single(0x1F926, 2),
47    WidthOverride::new(0x1F930, 0x1F939, 2),
48    WidthOverride::new(0x1F93D, 0x1F93E, 2),
49    WidthOverride::single(0x1F977, 2),
50    WidthOverride::new(0x1F9B5, 0x1F9B6, 2),
51    WidthOverride::new(0x1F9B8, 0x1F9B9, 2),
52    WidthOverride::single(0x1F9BB, 2),
53    WidthOverride::new(0x1F9CD, 0x1F9CF, 2),
54    WidthOverride::new(0x1F9D1, 0x1F9DD, 2),
55    WidthOverride::new(0x1FAC3, 0x1FAC5, 2),
56    WidthOverride::new(0x1FAF0, 0x1FAF8, 2),
57];
58
59/// tmux-compatible runtime width configuration.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Utf8Config {
62    variation_selector_always_wide: bool,
63    overrides: Vec<WidthOverride>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67struct WidthOverride {
68    start: u32,
69    end: u32,
70    width: u8,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74struct TextCell {
75    text: String,
76    width: u8,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub(crate) enum CombineResult {
81    Standalone { width: u8 },
82    Combined { text: String, width: u8 },
83    Discard,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87enum HangulJamoState {
88    NotComposable,
89    Choseong,
90    Composable,
91    NotHangulJamo,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95enum HangulJamoClass {
96    NotHangulJamo,
97    Choseong,
98    Jungseong,
99    Jongseong,
100}
101
102impl Default for Utf8Config {
103    fn default() -> Self {
104        Self {
105            variation_selector_always_wide: true,
106            overrides: DEFAULT_WIDTH_OVERRIDES.to_vec(),
107        }
108    }
109}
110
111impl Utf8Config {
112    /// Resolves the current tmux-style width configuration from options.
113    #[must_use]
114    pub fn from_options(options: &OptionStore) -> Self {
115        let mut config = Self {
116            variation_selector_always_wide: options
117                .resolve(None, OptionName::VariationSelectorAlwaysWide)
118                .map(option_flag_is_on)
119                .unwrap_or(true),
120            ..Self::default()
121        };
122        for entry in options.resolve_array_values(None, OptionName::CodepointWidths) {
123            if let Some(width_override) = parse_width_override(&entry) {
124                config.overrides.push(width_override);
125            }
126        }
127        config
128    }
129
130    pub(crate) fn width(&self, ch: char) -> u8 {
131        let codepoint = u32::from(ch);
132        if let Some(width_override) = self
133            .overrides
134            .iter()
135            .rev()
136            .find(|override_| override_.contains(codepoint))
137        {
138            return width_override.width;
139        }
140        fallback_width(ch)
141    }
142}
143
144impl WidthOverride {
145    const fn new(start: u32, end: u32, width: u8) -> Self {
146        Self { start, end, width }
147    }
148
149    const fn single(codepoint: u32, width: u8) -> Self {
150        Self::new(codepoint, codepoint, width)
151    }
152
153    const fn contains(self, codepoint: u32) -> bool {
154        self.start <= codepoint && codepoint <= self.end
155    }
156}
157
158/// Returns the tmux-style display width of a string.
159#[must_use]
160pub fn text_width(value: &str, config: &Utf8Config) -> usize {
161    fold_text_cells(value, config)
162        .iter()
163        .map(|cell| usize::from(cell.width))
164        .sum()
165}
166
167/// Truncates a string to the requested display width.
168#[must_use]
169pub fn truncate_to_width(value: &str, width: usize, config: &Utf8Config) -> String {
170    let mut output = String::new();
171    let mut used = 0_usize;
172
173    for cell in fold_text_cells(value, config) {
174        let cell_width = usize::from(cell.width);
175        if cell_width != 0 && used.saturating_add(cell_width) > width {
176            break;
177        }
178        output.push_str(&cell.text);
179        used = used.saturating_add(cell_width);
180    }
181
182    output
183}
184
185pub(crate) fn combine_char(
186    previous: Option<(&str, u8)>,
187    ch: char,
188    config: &Utf8Config,
189) -> CombineResult {
190    if ch == HANGUL_FILLER {
191        return CombineResult::Discard;
192    }
193
194    let width = config.width(ch);
195    let zero_width = ch == UTF8_ZWJ || ch == UTF8_VS16 || width == 0;
196
197    if ch.len_utf8() < 2 {
198        return CombineResult::Standalone { width };
199    }
200
201    let Some((previous_text, previous_width)) = previous else {
202        return if zero_width {
203            CombineResult::Discard
204        } else {
205            CombineResult::Standalone { width }
206        };
207    };
208    if previous_width == 0 || previous_text.is_empty() {
209        return if zero_width {
210            CombineResult::Discard
211        } else {
212            CombineResult::Standalone { width }
213        };
214    }
215
216    let mut force_wide = false;
217    if !zero_width {
218        match hanguljamo_check_state(previous_text, ch) {
219            HangulJamoState::NotComposable => return CombineResult::Discard,
220            HangulJamoState::Choseong => return CombineResult::Standalone { width },
221            HangulJamoState::Composable => {}
222            HangulJamoState::NotHangulJamo => {
223                let should_force_wide = single_codepoint(previous_text)
224                    .is_some_and(|previous_ch| utf8_should_combine(previous_ch, ch));
225                if should_force_wide {
226                    force_wide = true;
227                } else if !utf8_has_zwj(previous_text) {
228                    return CombineResult::Standalone { width };
229                }
230            }
231        }
232    } else if ch == UTF8_VS16 && config.variation_selector_always_wide {
233        force_wide = true;
234    }
235
236    if previous_text.len().saturating_add(ch.len_utf8()) > MAX_COMBINED_BYTES {
237        return CombineResult::Standalone { width };
238    }
239
240    let mut text = previous_text.to_owned();
241    text.push(ch);
242
243    let width = if previous_width == 1 && force_wide {
244        2
245    } else {
246        previous_width
247    };
248
249    CombineResult::Combined { text, width }
250}
251
252fn fold_text_cells(value: &str, config: &Utf8Config) -> Vec<TextCell> {
253    let mut cells: Vec<TextCell> = Vec::new();
254
255    for ch in value.chars() {
256        let previous = cells.last().map(|cell| (cell.text.as_str(), cell.width));
257        match combine_char(previous, ch, config) {
258            CombineResult::Standalone { width } => {
259                cells.push(TextCell {
260                    text: ch.to_string(),
261                    width,
262                });
263            }
264            CombineResult::Combined { text, width } => {
265                if let Some(cell) = cells.last_mut() {
266                    cell.text = text;
267                    cell.width = width;
268                }
269            }
270            CombineResult::Discard => {}
271        }
272    }
273
274    cells
275}
276
277fn option_flag_is_on(value: &str) -> bool {
278    matches!(value, "on" | "1")
279}
280
281fn parse_width_override(value: &str) -> Option<WidthOverride> {
282    let (codepoint_text, width_text) = value.rsplit_once('=')?;
283    let width = width_text.parse::<u8>().ok()?;
284    if width > 2 {
285        return None;
286    }
287
288    if let Some((start, end)) = parse_uplus_range(codepoint_text) {
289        return Some(WidthOverride::new(start, end, width));
290    }
291
292    let mut chars = codepoint_text.chars();
293    let ch = chars.next()?;
294    if chars.next().is_some() {
295        return None;
296    }
297    Some(WidthOverride::single(u32::from(ch), width))
298}
299
300fn parse_uplus_range(value: &str) -> Option<(u32, u32)> {
301    let parse_hex = |text: &str| u32::from_str_radix(text, 16).ok();
302
303    let (start, end) = match value.split_once('-') {
304        Some((start, end)) => (start, end),
305        None => (value, value),
306    };
307    let start = start.strip_prefix("U+")?;
308    let end = end.strip_prefix("U+")?;
309    let start = parse_hex(start)?;
310    let end = parse_hex(end)?;
311    if start == 0 || end == 0 || start > end {
312        return None;
313    }
314    Some((start, end))
315}
316
317fn fallback_width(ch: char) -> u8 {
318    if hanguljamo_class(ch) != HangulJamoClass::NotHangulJamo {
319        return 2;
320    }
321    match UnicodeWidthChar::width(ch) {
322        Some(width) => u8::try_from(width).unwrap_or(1),
323        None if is_c1_control(ch) => 0,
324        None => 1,
325    }
326}
327
328fn is_c1_control(ch: char) -> bool {
329    let codepoint = u32::from(ch);
330    (0x80..=0x9F).contains(&codepoint)
331}
332
333fn utf8_has_zwj(value: &str) -> bool {
334    value.ends_with(UTF8_ZWJ)
335}
336
337fn single_codepoint(value: &str) -> Option<char> {
338    let mut chars = value.chars();
339    let ch = chars.next()?;
340    if chars.next().is_some() {
341        return None;
342    }
343    Some(ch)
344}
345
346fn utf8_should_combine(with: char, add: char) -> bool {
347    let with = u32::from(with);
348    let add = u32::from(add);
349
350    if is_regional_indicator(add) && is_regional_indicator(with) {
351        return true;
352    }
353
354    emoji_accepts_skin_tone(with) && is_skin_tone_modifier(add)
355}
356
357fn is_regional_indicator(codepoint: u32) -> bool {
358    (0x1F1E6..=0x1F1FF).contains(&codepoint)
359}
360
361fn is_skin_tone_modifier(codepoint: u32) -> bool {
362    (0x1F3FB..=0x1F3FF).contains(&codepoint)
363}
364
365fn emoji_accepts_skin_tone(codepoint: u32) -> bool {
366    matches!(
367        codepoint,
368        0x1F44B
369            | 0x1F44C
370            | 0x1F44D
371            | 0x1F44E
372            | 0x1F44F
373            | 0x1F450
374            | 0x1F466
375            | 0x1F467
376            | 0x1F468
377            | 0x1F469
378            | 0x1F46E
379            | 0x1F470
380            | 0x1F471
381            | 0x1F472
382            | 0x1F473
383            | 0x1F474
384            | 0x1F475
385            | 0x1F476
386            | 0x1F477
387            | 0x1F478
388            | 0x1F47C
389            | 0x1F481
390            | 0x1F482
391            | 0x1F483
392            | 0x1F485
393            | 0x1F486
394            | 0x1F487
395            | 0x1F4AA
396            | 0x1F575
397            | 0x1F57A
398            | 0x1F590
399            | 0x1F595
400            | 0x1F596
401            | 0x1F645
402            | 0x1F646
403            | 0x1F647
404            | 0x1F64B
405            | 0x1F64C
406            | 0x1F64D
407            | 0x1F64E
408            | 0x1F64F
409            | 0x1F6B4
410            | 0x1F6B5
411            | 0x1F6B6
412            | 0x1F926
413            | 0x1F937
414            | 0x1F938
415            | 0x1F939
416            | 0x1F93D
417            | 0x1F93E
418            | 0x1F9B5
419            | 0x1F9B6
420            | 0x1F9B8
421            | 0x1F9B9
422            | 0x1F9CD
423            | 0x1F9CE
424            | 0x1F9CF
425            | 0x1F9D1
426            | 0x1F9D2
427            | 0x1F9D3
428            | 0x1F9D4
429            | 0x1F9D5
430            | 0x1F9D6
431            | 0x1F9D7
432            | 0x1F9D8
433            | 0x1F9D9
434            | 0x1F9DA
435            | 0x1F9DB
436            | 0x1F9DC
437            | 0x1F9DD
438            | 0x1F9DE
439            | 0x1F9DF
440    )
441}
442
443fn hanguljamo_check_state(previous_text: &str, ch: char) -> HangulJamoState {
444    if ch.len_utf8() != 3 {
445        return HangulJamoState::NotHangulJamo;
446    }
447
448    match hanguljamo_class(ch) {
449        HangulJamoClass::Choseong => HangulJamoState::Choseong,
450        HangulJamoClass::Jungseong => match previous_text.chars().last() {
451            Some(last)
452                if last.len_utf8() == 3 && hanguljamo_class(last) == HangulJamoClass::Choseong =>
453            {
454                HangulJamoState::Composable
455            }
456            _ => HangulJamoState::NotComposable,
457        },
458        HangulJamoClass::Jongseong => match previous_text.chars().last() {
459            Some(last)
460                if last.len_utf8() == 3 && hanguljamo_class(last) == HangulJamoClass::Jungseong =>
461            {
462                HangulJamoState::Composable
463            }
464            _ => HangulJamoState::NotComposable,
465        },
466        HangulJamoClass::NotHangulJamo => HangulJamoState::NotHangulJamo,
467    }
468}
469
470fn hanguljamo_class(ch: char) -> HangulJamoClass {
471    let codepoint = u32::from(ch);
472    if matches!(
473        codepoint,
474        0x1100..=0x115E | 0x115F | 0xA960..=0xA97C
475    ) {
476        HangulJamoClass::Choseong
477    } else if matches!(
478        codepoint,
479        0x1160 | 0x1161..=0x11A7 | 0xD7B0..=0xD7C6
480    ) {
481        HangulJamoClass::Jungseong
482    } else if matches!(codepoint, 0x11A8..=0x11FF | 0xD7CB..=0xD7FB) {
483        HangulJamoClass::Jongseong
484    } else {
485        HangulJamoClass::NotHangulJamo
486    }
487}
488
489#[cfg(test)]
490#[path = "utf8/tests.rs"]
491mod tests;