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
185/// Truncates a string from the left, keeping the rightmost text cells that fit.
186#[must_use]
187pub fn truncate_right_to_width(value: &str, width: usize, config: &Utf8Config) -> String {
188    let cells = fold_text_cells(value, config);
189    let mut used = 0_usize;
190    let mut start = cells.len();
191
192    for (index, cell) in cells.iter().enumerate().rev() {
193        let cell_width = usize::from(cell.width);
194        if cell_width != 0 && used.saturating_add(cell_width) > width {
195            break;
196        }
197        used = used.saturating_add(cell_width);
198        start = index;
199    }
200
201    cells[start..]
202        .iter()
203        .map(|cell| cell.text.as_str())
204        .collect()
205}
206
207pub(crate) fn combine_char(
208    previous: Option<(&str, u8)>,
209    ch: char,
210    config: &Utf8Config,
211) -> CombineResult {
212    if ch == HANGUL_FILLER {
213        return CombineResult::Discard;
214    }
215
216    let width = config.width(ch);
217    let zero_width = ch == UTF8_ZWJ || ch == UTF8_VS16 || width == 0;
218
219    if ch.len_utf8() < 2 {
220        return CombineResult::Standalone { width };
221    }
222
223    let Some((previous_text, previous_width)) = previous else {
224        return if zero_width {
225            CombineResult::Discard
226        } else {
227            CombineResult::Standalone { width }
228        };
229    };
230    if previous_width == 0 || previous_text.is_empty() {
231        return if zero_width {
232            CombineResult::Discard
233        } else {
234            CombineResult::Standalone { width }
235        };
236    }
237
238    let mut force_wide = false;
239    if !zero_width {
240        match hanguljamo_check_state(previous_text, ch) {
241            HangulJamoState::NotComposable => return CombineResult::Discard,
242            HangulJamoState::Choseong => return CombineResult::Standalone { width },
243            HangulJamoState::Composable => {}
244            HangulJamoState::NotHangulJamo => {
245                let should_force_wide = single_codepoint(previous_text)
246                    .is_some_and(|previous_ch| utf8_should_combine(previous_ch, ch));
247                if should_force_wide {
248                    force_wide = true;
249                } else if !utf8_has_zwj(previous_text) {
250                    return CombineResult::Standalone { width };
251                }
252            }
253        }
254    } else if ch == UTF8_VS16 && config.variation_selector_always_wide {
255        force_wide = true;
256    }
257
258    if previous_text.len().saturating_add(ch.len_utf8()) > MAX_COMBINED_BYTES {
259        return CombineResult::Standalone { width };
260    }
261
262    let mut text = previous_text.to_owned();
263    text.push(ch);
264
265    let width = if previous_width == 1 && force_wide {
266        2
267    } else {
268        previous_width
269    };
270
271    CombineResult::Combined { text, width }
272}
273
274fn fold_text_cells(value: &str, config: &Utf8Config) -> Vec<TextCell> {
275    let mut cells: Vec<TextCell> = Vec::new();
276
277    for ch in value.chars() {
278        let previous = cells.last().map(|cell| (cell.text.as_str(), cell.width));
279        match combine_char(previous, ch, config) {
280            CombineResult::Standalone { width } => {
281                cells.push(TextCell {
282                    text: ch.to_string(),
283                    width,
284                });
285            }
286            CombineResult::Combined { text, width } => {
287                if let Some(cell) = cells.last_mut() {
288                    cell.text = text;
289                    cell.width = width;
290                }
291            }
292            CombineResult::Discard => {}
293        }
294    }
295
296    cells
297}
298
299fn option_flag_is_on(value: &str) -> bool {
300    matches!(value, "on" | "1")
301}
302
303fn parse_width_override(value: &str) -> Option<WidthOverride> {
304    let (codepoint_text, width_text) = value.rsplit_once('=')?;
305    let width = width_text.parse::<u8>().ok()?;
306    if width > 2 {
307        return None;
308    }
309
310    if let Some((start, end)) = parse_uplus_range(codepoint_text) {
311        return Some(WidthOverride::new(start, end, width));
312    }
313
314    let mut chars = codepoint_text.chars();
315    let ch = chars.next()?;
316    if chars.next().is_some() {
317        return None;
318    }
319    Some(WidthOverride::single(u32::from(ch), width))
320}
321
322fn parse_uplus_range(value: &str) -> Option<(u32, u32)> {
323    let parse_hex = |text: &str| u32::from_str_radix(text, 16).ok();
324
325    let (start, end) = match value.split_once('-') {
326        Some((start, end)) => (start, end),
327        None => (value, value),
328    };
329    let start = start.strip_prefix("U+")?;
330    let end = end.strip_prefix("U+")?;
331    let start = parse_hex(start)?;
332    let end = parse_hex(end)?;
333    if start == 0 || end == 0 || start > end {
334        return None;
335    }
336    Some((start, end))
337}
338
339fn fallback_width(ch: char) -> u8 {
340    if hanguljamo_class(ch) != HangulJamoClass::NotHangulJamo {
341        return 2;
342    }
343    match UnicodeWidthChar::width(ch) {
344        Some(width) => u8::try_from(width).unwrap_or(1),
345        None if is_c1_control(ch) => 0,
346        None => 1,
347    }
348}
349
350fn is_c1_control(ch: char) -> bool {
351    let codepoint = u32::from(ch);
352    (0x80..=0x9F).contains(&codepoint)
353}
354
355fn utf8_has_zwj(value: &str) -> bool {
356    value.ends_with(UTF8_ZWJ)
357}
358
359fn single_codepoint(value: &str) -> Option<char> {
360    let mut chars = value.chars();
361    let ch = chars.next()?;
362    if chars.next().is_some() {
363        return None;
364    }
365    Some(ch)
366}
367
368fn utf8_should_combine(with: char, add: char) -> bool {
369    let with = u32::from(with);
370    let add = u32::from(add);
371
372    if is_regional_indicator(add) && is_regional_indicator(with) {
373        return true;
374    }
375
376    emoji_accepts_skin_tone(with) && is_skin_tone_modifier(add)
377}
378
379fn is_regional_indicator(codepoint: u32) -> bool {
380    (0x1F1E6..=0x1F1FF).contains(&codepoint)
381}
382
383fn is_skin_tone_modifier(codepoint: u32) -> bool {
384    (0x1F3FB..=0x1F3FF).contains(&codepoint)
385}
386
387fn emoji_accepts_skin_tone(codepoint: u32) -> bool {
388    matches!(
389        codepoint,
390        0x1F44B
391            | 0x1F44C
392            | 0x1F44D
393            | 0x1F44E
394            | 0x1F44F
395            | 0x1F450
396            | 0x1F466
397            | 0x1F467
398            | 0x1F468
399            | 0x1F469
400            | 0x1F46E
401            | 0x1F470
402            | 0x1F471
403            | 0x1F472
404            | 0x1F473
405            | 0x1F474
406            | 0x1F475
407            | 0x1F476
408            | 0x1F477
409            | 0x1F478
410            | 0x1F47C
411            | 0x1F481
412            | 0x1F482
413            | 0x1F483
414            | 0x1F485
415            | 0x1F486
416            | 0x1F487
417            | 0x1F4AA
418            | 0x1F575
419            | 0x1F57A
420            | 0x1F590
421            | 0x1F595
422            | 0x1F596
423            | 0x1F645
424            | 0x1F646
425            | 0x1F647
426            | 0x1F64B
427            | 0x1F64C
428            | 0x1F64D
429            | 0x1F64E
430            | 0x1F64F
431            | 0x1F6B4
432            | 0x1F6B5
433            | 0x1F6B6
434            | 0x1F926
435            | 0x1F937
436            | 0x1F938
437            | 0x1F939
438            | 0x1F93D
439            | 0x1F93E
440            | 0x1F9B5
441            | 0x1F9B6
442            | 0x1F9B8
443            | 0x1F9B9
444            | 0x1F9CD
445            | 0x1F9CE
446            | 0x1F9CF
447            | 0x1F9D1
448            | 0x1F9D2
449            | 0x1F9D3
450            | 0x1F9D4
451            | 0x1F9D5
452            | 0x1F9D6
453            | 0x1F9D7
454            | 0x1F9D8
455            | 0x1F9D9
456            | 0x1F9DA
457            | 0x1F9DB
458            | 0x1F9DC
459            | 0x1F9DD
460            | 0x1F9DE
461            | 0x1F9DF
462    )
463}
464
465fn hanguljamo_check_state(previous_text: &str, ch: char) -> HangulJamoState {
466    if ch.len_utf8() != 3 {
467        return HangulJamoState::NotHangulJamo;
468    }
469
470    match hanguljamo_class(ch) {
471        HangulJamoClass::Choseong => HangulJamoState::Choseong,
472        HangulJamoClass::Jungseong => match previous_text.chars().last() {
473            Some(last)
474                if last.len_utf8() == 3 && hanguljamo_class(last) == HangulJamoClass::Choseong =>
475            {
476                HangulJamoState::Composable
477            }
478            _ => HangulJamoState::NotComposable,
479        },
480        HangulJamoClass::Jongseong => match previous_text.chars().last() {
481            Some(last)
482                if last.len_utf8() == 3 && hanguljamo_class(last) == HangulJamoClass::Jungseong =>
483            {
484                HangulJamoState::Composable
485            }
486            _ => HangulJamoState::NotComposable,
487        },
488        HangulJamoClass::NotHangulJamo => HangulJamoState::NotHangulJamo,
489    }
490}
491
492fn hanguljamo_class(ch: char) -> HangulJamoClass {
493    let codepoint = u32::from(ch);
494    if matches!(
495        codepoint,
496        0x1100..=0x115E | 0x115F | 0xA960..=0xA97C
497    ) {
498        HangulJamoClass::Choseong
499    } else if matches!(
500        codepoint,
501        0x1160 | 0x1161..=0x11A7 | 0xD7B0..=0xD7C6
502    ) {
503        HangulJamoClass::Jungseong
504    } else if matches!(codepoint, 0x11A8..=0x11FF | 0xD7CB..=0xD7FB) {
505        HangulJamoClass::Jongseong
506    } else {
507        HangulJamoClass::NotHangulJamo
508    }
509}
510
511#[cfg(test)]
512#[path = "utf8/tests.rs"]
513mod tests;