1use 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#[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 #[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#[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#[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#[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;