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