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