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