rich-ruby 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/rich/cells.rb CHANGED
@@ -1,512 +1,524 @@
1
- # frozen_string_literal: true
2
-
3
- module Rich
4
- # Cell width calculation for Unicode characters.
5
- # Handles East Asian Width and emoji character widths for proper terminal alignment.
6
- module Cells
7
- # Zero-width character categories (based on Unicode East Asian Width)
8
- # These characters do not take up any visual space
9
- ZERO_WIDTH_RANGES = [
10
- 0x0000..0x001F, # C0 control codes
11
- 0x007F..0x009F, # C1 control codes
12
- 0x00AD..0x00AD, # Soft hyphen
13
- 0x0300..0x036F, # Combining diacritical marks
14
- 0x0483..0x0489, # Combining Cyrillic marks
15
- 0x0591..0x05BD, # Hebrew combining marks
16
- 0x05BF..0x05BF,
17
- 0x05C1..0x05C2,
18
- 0x05C4..0x05C5,
19
- 0x05C7..0x05C7,
20
- 0x0600..0x0605, # Arabic marks
21
- 0x0610..0x061A,
22
- 0x061C..0x061C,
23
- 0x064B..0x065F,
24
- 0x0670..0x0670,
25
- 0x06D6..0x06DC,
26
- 0x06DF..0x06E4,
27
- 0x06E7..0x06E8,
28
- 0x06EA..0x06ED,
29
- 0x070F..0x070F,
30
- 0x0711..0x0711,
31
- 0x0730..0x074A,
32
- 0x07A6..0x07B0,
33
- 0x07EB..0x07F3,
34
- 0x0816..0x0819,
35
- 0x081B..0x0823,
36
- 0x0825..0x0827,
37
- 0x0829..0x082D,
38
- 0x0859..0x085B,
39
- 0x08D4..0x08E1,
40
- 0x08E3..0x0902,
41
- 0x093A..0x093A,
42
- 0x093C..0x093C,
43
- 0x0941..0x0948,
44
- 0x094D..0x094D,
45
- 0x0951..0x0957,
46
- 0x0962..0x0963,
47
- 0x0981..0x0981,
48
- 0x09BC..0x09BC,
49
- 0x09C1..0x09C4,
50
- 0x09CD..0x09CD,
51
- 0x09E2..0x09E3,
52
- 0x0A01..0x0A02,
53
- 0x0A3C..0x0A3C,
54
- 0x0A41..0x0A42,
55
- 0x0A47..0x0A48,
56
- 0x0A4B..0x0A4D,
57
- 0x0A51..0x0A51,
58
- 0x0A70..0x0A71,
59
- 0x0A75..0x0A75,
60
- 0x0A81..0x0A82,
61
- 0x0ABC..0x0ABC,
62
- 0x0AC1..0x0AC5,
63
- 0x0AC7..0x0AC8,
64
- 0x0ACD..0x0ACD,
65
- 0x0AE2..0x0AE3,
66
- 0x0B01..0x0B01,
67
- 0x0B3C..0x0B3C,
68
- 0x0B3F..0x0B3F,
69
- 0x0B41..0x0B44,
70
- 0x0B4D..0x0B4D,
71
- 0x0B56..0x0B56,
72
- 0x0B62..0x0B63,
73
- 0x0B82..0x0B82,
74
- 0x0BC0..0x0BC0,
75
- 0x0BCD..0x0BCD,
76
- 0x0C00..0x0C00,
77
- 0x0C3E..0x0C40,
78
- 0x0C46..0x0C48,
79
- 0x0C4A..0x0C4D,
80
- 0x0C55..0x0C56,
81
- 0x0C62..0x0C63,
82
- 0x0C81..0x0C81,
83
- 0x0CBC..0x0CBC,
84
- 0x0CBF..0x0CBF,
85
- 0x0CC6..0x0CC6,
86
- 0x0CCC..0x0CCD,
87
- 0x0CE2..0x0CE3,
88
- 0x0D01..0x0D01,
89
- 0x0D41..0x0D44,
90
- 0x0D4D..0x0D4D,
91
- 0x0D62..0x0D63,
92
- 0x0DCA..0x0DCA,
93
- 0x0DD2..0x0DD4,
94
- 0x0DD6..0x0DD6,
95
- 0x0E31..0x0E31,
96
- 0x0E34..0x0E3A,
97
- 0x0E47..0x0E4E,
98
- 0x0EB1..0x0EB1,
99
- 0x0EB4..0x0EB9,
100
- 0x0EBB..0x0EBC,
101
- 0x0EC8..0x0ECD,
102
- 0x0F18..0x0F19,
103
- 0x0F35..0x0F35,
104
- 0x0F37..0x0F37,
105
- 0x0F39..0x0F39,
106
- 0x0F71..0x0F7E,
107
- 0x0F80..0x0F84,
108
- 0x0F86..0x0F87,
109
- 0x0F8D..0x0F97,
110
- 0x0F99..0x0FBC,
111
- 0x0FC6..0x0FC6,
112
- 0x102D..0x1030,
113
- 0x1032..0x1037,
114
- 0x1039..0x103A,
115
- 0x103D..0x103E,
116
- 0x1058..0x1059,
117
- 0x105E..0x1060,
118
- 0x1071..0x1074,
119
- 0x1082..0x1082,
120
- 0x1085..0x1086,
121
- 0x108D..0x108D,
122
- 0x109D..0x109D,
123
- 0x1160..0x11FF, # Hangul Jungseong/Jongseong
124
- 0x135D..0x135F,
125
- 0x1712..0x1714,
126
- 0x1732..0x1734,
127
- 0x1752..0x1753,
128
- 0x1772..0x1773,
129
- 0x17B4..0x17B5,
130
- 0x17B7..0x17BD,
131
- 0x17C6..0x17C6,
132
- 0x17C9..0x17D3,
133
- 0x17DD..0x17DD,
134
- 0x180B..0x180D,
135
- 0x1885..0x1886,
136
- 0x18A9..0x18A9,
137
- 0x1920..0x1922,
138
- 0x1927..0x1928,
139
- 0x1932..0x1932,
140
- 0x1939..0x193B,
141
- 0x1A17..0x1A18,
142
- 0x1A1B..0x1A1B,
143
- 0x1A56..0x1A56,
144
- 0x1A58..0x1A5E,
145
- 0x1A60..0x1A60,
146
- 0x1A62..0x1A62,
147
- 0x1A65..0x1A6C,
148
- 0x1A73..0x1A7C,
149
- 0x1A7F..0x1A7F,
150
- 0x1AB0..0x1ABE,
151
- 0x1B00..0x1B03,
152
- 0x1B34..0x1B34,
153
- 0x1B36..0x1B3A,
154
- 0x1B3C..0x1B3C,
155
- 0x1B42..0x1B42,
156
- 0x1B6B..0x1B73,
157
- 0x1B80..0x1B81,
158
- 0x1BA2..0x1BA5,
159
- 0x1BA8..0x1BA9,
160
- 0x1BAB..0x1BAD,
161
- 0x1BE6..0x1BE6,
162
- 0x1BE8..0x1BE9,
163
- 0x1BED..0x1BED,
164
- 0x1BEF..0x1BF1,
165
- 0x1C2C..0x1C33,
166
- 0x1C36..0x1C37,
167
- 0x1CD0..0x1CD2,
168
- 0x1CD4..0x1CE0,
169
- 0x1CE2..0x1CE8,
170
- 0x1CED..0x1CED,
171
- 0x1CF4..0x1CF4,
172
- 0x1CF8..0x1CF9,
173
- 0x1DC0..0x1DF5,
174
- 0x1DFC..0x1DFF,
175
- 0x200B..0x200F, # Zero-width spaces and direction marks
176
- 0x202A..0x202E,
177
- 0x2060..0x2064,
178
- 0x2066..0x206F,
179
- 0x20D0..0x20F0, # Combining marks for symbols
180
- 0x2CEF..0x2CF1,
181
- 0x2D7F..0x2D7F,
182
- 0x2DE0..0x2DFF,
183
- 0x302A..0x302D,
184
- 0x3099..0x309A,
185
- 0xA66F..0xA672,
186
- 0xA674..0xA67D,
187
- 0xA69E..0xA69F,
188
- 0xA6F0..0xA6F1,
189
- 0xA802..0xA802,
190
- 0xA806..0xA806,
191
- 0xA80B..0xA80B,
192
- 0xA825..0xA826,
193
- 0xA8C4..0xA8C4,
194
- 0xA8E0..0xA8F1,
195
- 0xA926..0xA92D,
196
- 0xA947..0xA951,
197
- 0xA980..0xA982,
198
- 0xA9B3..0xA9B3,
199
- 0xA9B6..0xA9B9,
200
- 0xA9BC..0xA9BC,
201
- 0xA9E5..0xA9E5,
202
- 0xAA29..0xAA2E,
203
- 0xAA31..0xAA32,
204
- 0xAA35..0xAA36,
205
- 0xAA43..0xAA43,
206
- 0xAA4C..0xAA4C,
207
- 0xAA7C..0xAA7C,
208
- 0xAAB0..0xAAB0,
209
- 0xAAB2..0xAAB4,
210
- 0xAAB7..0xAAB8,
211
- 0xAABE..0xAABF,
212
- 0xAAC1..0xAAC1,
213
- 0xAAEC..0xAAED,
214
- 0xAAF6..0xAAF6,
215
- 0xABE5..0xABE5,
216
- 0xABE8..0xABE8,
217
- 0xABED..0xABED,
218
- 0xFB1E..0xFB1E,
219
- 0xFE00..0xFE0F, # Variation selectors
220
- 0xFE20..0xFE2F,
221
- 0xFEFF..0xFEFF, # BOM/ZWNBSP
222
- 0xFFF9..0xFFFB,
223
- 0x101FD..0x101FD,
224
- 0x102E0..0x102E0,
225
- 0x10376..0x1037A,
226
- 0x10A01..0x10A03,
227
- 0x10A05..0x10A06,
228
- 0x10A0C..0x10A0F,
229
- 0x10A38..0x10A3A,
230
- 0x10A3F..0x10A3F,
231
- 0x10AE5..0x10AE6,
232
- 0x11001..0x11001,
233
- 0x11038..0x11046,
234
- 0x1107F..0x11081,
235
- 0x110B3..0x110B6,
236
- 0x110B9..0x110BA,
237
- 0x11100..0x11102,
238
- 0x11127..0x1112B,
239
- 0x1112D..0x11134,
240
- 0x11173..0x11173,
241
- 0x11180..0x11181,
242
- 0x111B6..0x111BE,
243
- 0x111CA..0x111CC,
244
- 0x1122F..0x11231,
245
- 0x11234..0x11234,
246
- 0x11236..0x11237,
247
- 0x1123E..0x1123E,
248
- 0x112DF..0x112DF,
249
- 0x112E3..0x112EA,
250
- 0x11300..0x11301,
251
- 0x1133C..0x1133C,
252
- 0x11340..0x11340,
253
- 0x11366..0x1136C,
254
- 0x11370..0x11374,
255
- 0x11438..0x1143F,
256
- 0x11442..0x11444,
257
- 0x11446..0x11446,
258
- 0x114B3..0x114B8,
259
- 0x114BA..0x114BA,
260
- 0x114BF..0x114C0,
261
- 0x114C2..0x114C3,
262
- 0x115B2..0x115B5,
263
- 0x115BC..0x115BD,
264
- 0x115BF..0x115C0,
265
- 0x115DC..0x115DD,
266
- 0x11633..0x1163A,
267
- 0x1163D..0x1163D,
268
- 0x1163F..0x11640,
269
- 0x116AB..0x116AB,
270
- 0x116AD..0x116AD,
271
- 0x116B0..0x116B5,
272
- 0x116B7..0x116B7,
273
- 0x1171D..0x1171F,
274
- 0x11722..0x11725,
275
- 0x11727..0x1172B,
276
- 0x11C30..0x11C36,
277
- 0x11C38..0x11C3D,
278
- 0x11C3F..0x11C3F,
279
- 0x11C92..0x11CA7,
280
- 0x11CAA..0x11CB0,
281
- 0x11CB2..0x11CB3,
282
- 0x11CB5..0x11CB6,
283
- 0x16AF0..0x16AF4,
284
- 0x16B30..0x16B36,
285
- 0x16F8F..0x16F92,
286
- 0x1BC9D..0x1BC9E,
287
- 0x1D167..0x1D169,
288
- 0x1D173..0x1D182,
289
- 0x1D185..0x1D18B,
290
- 0x1D1AA..0x1D1AD,
291
- 0x1D242..0x1D244,
292
- 0x1DA00..0x1DA36,
293
- 0x1DA3B..0x1DA6C,
294
- 0x1DA75..0x1DA75,
295
- 0x1DA84..0x1DA84,
296
- 0x1DA9B..0x1DA9F,
297
- 0x1DAA1..0x1DAAF,
298
- 0x1E000..0x1E006,
299
- 0x1E008..0x1E018,
300
- 0x1E01B..0x1E021,
301
- 0x1E023..0x1E024,
302
- 0x1E026..0x1E02A,
303
- 0x1E8D0..0x1E8D6,
304
- 0x1E944..0x1E94A,
305
- 0xE0001..0xE0001,
306
- 0xE0020..0xE007F,
307
- 0xE0100..0xE01EF
308
- ].freeze
309
-
310
- # Wide (double-width) character ranges (CJK, etc.)
311
- WIDE_RANGES = [
312
- 0x1100..0x115F, # Hangul Jamo
313
- 0x231A..0x231B, # Watch, Hourglass
314
- 0x2329..0x232A, # Angle brackets
315
- 0x23E9..0x23F3, # Media controls
316
- 0x23F8..0x23FA,
317
- 0x25FD..0x25FE, # Squares
318
- 0x2614..0x2615, # Umbrella, Hot beverage
319
- 0x2648..0x2653, # Zodiac
320
- 0x267F..0x267F, # Wheelchair
321
- 0x2693..0x2693, # Anchor
322
- 0x26A1..0x26A1, # Lightning
323
- 0x26AA..0x26AB, # Circles
324
- 0x26BD..0x26BE, # Sports
325
- 0x26C4..0x26C5, # Weather
326
- 0x26CE..0x26CE, # Ophiuchus
327
- 0x26D4..0x26D4, # No entry
328
- 0x26EA..0x26EA, # Church
329
- 0x26F2..0x26F3, # Fountain, Golf
330
- 0x26F5..0x26F5, # Sailboat
331
- 0x26FA..0x26FA, # Tent
332
- 0x26FD..0x26FD, # Fuel pump
333
- 0x2702..0x2702, # Scissors
334
- 0x2705..0x2705, # Check mark
335
- 0x2708..0x270D, # Various
336
- 0x270F..0x270F, # Pencil
337
- 0x2712..0x2712, # Black nib
338
- 0x2714..0x2714, # Check mark
339
- 0x2716..0x2716, # X mark
340
- 0x271D..0x271D, # Cross
341
- 0x2721..0x2721, # Star of David
342
- 0x2728..0x2728, # Sparkles
343
- 0x2733..0x2734, # Asterisks
344
- 0x2744..0x2744, # Snowflake
345
- 0x2747..0x2747, # Sparkle
346
- 0x274C..0x274C, # Cross mark
347
- 0x274E..0x274E,
348
- 0x2753..0x2755, # Question marks
349
- 0x2757..0x2757, # Exclamation
350
- 0x2763..0x2764, # Heart
351
- 0x2795..0x2797, # Math symbols
352
- 0x27A1..0x27A1, # Arrow
353
- 0x27B0..0x27B0, # Loop
354
- 0x27BF..0x27BF, # Loop
355
- 0x2934..0x2935, # Arrows
356
- 0x2B05..0x2B07, # Arrows
357
- 0x2B1B..0x2B1C, # Squares
358
- 0x2B50..0x2B50, # Star
359
- 0x2B55..0x2B55, # Circle
360
- 0x2E80..0x2E99, # CJK radicals
361
- 0x2E9B..0x2EF3,
362
- 0x2F00..0x2FD5, # Kangxi radicals
363
- 0x2FF0..0x2FFB, # Ideographic description
364
- 0x3000..0x303E, # CJK punctuation
365
- 0x3041..0x3096, # Hiragana
366
- 0x3099..0x30FF, # Katakana
367
- 0x3105..0x312D, # Bopomofo
368
- 0x3131..0x318E, # Hangul compatibility
369
- 0x3190..0x31BA, # Kanbun
370
- 0x31C0..0x31E3, # CJK strokes
371
- 0x31F0..0x321E, # Katakana extensions
372
- 0x3220..0x3247, # Enclosed CJK
373
- 0x3250..0x32FE,
374
- 0x3300..0x4DBF, # CJK unified
375
- 0x4E00..0x9FFF, # CJK unified ideographs
376
- 0xA000..0xA48C, # Yi syllables
377
- 0xA490..0xA4C6, # Yi radicals
378
- 0xA960..0xA97C, # Hangul Jamo extended
379
- 0xAC00..0xD7A3, # Hangul syllables
380
- 0xF900..0xFAFF, # CJK compatibility ideographs
381
- 0xFE10..0xFE19, # Vertical forms
382
- 0xFE30..0xFE52, # CJK compatibility forms
383
- 0xFE54..0xFE66,
384
- 0xFE68..0xFE6B,
385
- 0xFF01..0xFF60, # Fullwidth forms
386
- 0xFFE0..0xFFE6,
387
- 0x16FE0..0x16FE1, # Various
388
- 0x17000..0x187EC, # Tangut
389
- 0x18800..0x18AF2,
390
- 0x1B000..0x1B11E, # Kana supplement
391
- 0x1B170..0x1B2FB,
392
- 0x1F004..0x1F004, # Mahjong
393
- 0x1F0CF..0x1F0CF, # Playing card
394
- 0x1F18E..0x1F18E, # Squared AB
395
- 0x1F191..0x1F19A, # Squared
396
- 0x1F200..0x1F202,
397
- 0x1F210..0x1F23B,
398
- 0x1F240..0x1F248,
399
- 0x1F250..0x1F251,
400
- 0x1F260..0x1F265,
401
- 0x1F300..0x1F64F, # Emoji
402
- 0x1F680..0x1F6C5,
403
- 0x1F6CC..0x1F6CC,
404
- 0x1F6D0..0x1F6D2,
405
- 0x1F6EB..0x1F6EC,
406
- 0x1F6F4..0x1F6F8,
407
- 0x1F910..0x1F93E,
408
- 0x1F940..0x1F94C,
409
- 0x1F950..0x1F96B,
410
- 0x1F980..0x1F997,
411
- 0x1F9C0..0x1F9C0,
412
- 0x1F9D0..0x1F9E6,
413
- 0x20000..0x2FFFD, # CJK extension B
414
- 0x30000..0x3FFFD # CJK extension G
415
- ].freeze
416
-
417
- class << self
418
- # Get the display width of a single character
419
- # @param char [String] Single character
420
- # @return [Integer] Width (0, 1, or 2)
421
- def char_width(char)
422
- return 0 if char.nil? || char.empty?
423
-
424
- codepoint = char.ord
425
-
426
- # Tab, newline, and carriage return should have width 1 for measurement
427
- # (even though they may have different visual behavior)
428
- return 1 if codepoint == 0x09 || codepoint == 0x0A || codepoint == 0x0D
429
-
430
- # Check zero-width first (most common for combining marks)
431
- ZERO_WIDTH_RANGES.each do |range|
432
- return 0 if range.cover?(codepoint)
433
- end
434
-
435
- # Check wide characters
436
- WIDE_RANGES.each do |range|
437
- return 2 if range.cover?(codepoint)
438
- end
439
-
440
- # Default to single width
441
- 1
442
- end
443
-
444
- # Get the display width of a string
445
- # @param text [String] Text to measure
446
- # @return [Integer] Total display width in cells
447
- def cell_len(text)
448
- return 0 if text.nil? || text.empty?
449
-
450
- # On Windows legacy console without ANSI, use byte-based width
451
- if Gem.win_platform? && defined?(Rich::Win32Console) && !Rich::Win32Console.supports_ansi?
452
- return text.bytesize
453
- end
454
-
455
- text.each_char.sum { |c| char_width(c) }
456
- end
457
-
458
- # Get the display width of a string, with cache
459
- # @param text [String] Text to measure
460
- # @return [Integer] Total display width
461
- def cached_cell_len(text)
462
- @cache_mutex ||= Mutex.new
463
- @cache ||= {}
464
-
465
- return 0 if text.nil? || text.empty?
466
-
467
- # Use cache for repeated lookups
468
- @cache_mutex.synchronize do
469
- return @cache[text] if @cache.key?(text)
470
-
471
- result = cell_len(text)
472
-
473
- # Limit cache size
474
- @cache.shift if @cache.size > 10000
475
- @cache[text] = result
476
-
477
- result
478
- end
479
- end
480
-
481
- # Set the cell size for a specific character (override)
482
- # @param char [String] Character to override
483
- # @param size [Integer] Width to use
484
- def set_cell_size(char, size)
485
- @overrides ||= {}
486
- @overrides[char] = size
487
- end
488
-
489
- # Clear the cell width cache
490
- def clear_cache
491
- @cache_mutex ||= Mutex.new
492
- @cache_mutex.synchronize do
493
- @cache&.clear
494
- end
495
- end
496
-
497
- # Check if a character is a zero-width character
498
- # @param char [String] Character to check
499
- # @return [Boolean]
500
- def zero_width?(char)
501
- char_width(char) == 0
502
- end
503
-
504
- # Check if a character is a wide (double-width) character
505
- # @param char [String] Character to check
506
- # @return [Boolean]
507
- def wide?(char)
508
- char_width(char) == 2
509
- end
510
- end
511
- end
512
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Rich
4
+ # Cell width calculation for Unicode characters.
5
+ # Handles East Asian Width and emoji character widths for proper terminal alignment.
6
+ module Cells
7
+ # Zero-width character categories (based on Unicode East Asian Width)
8
+ # These characters do not take up any visual space
9
+ ZERO_WIDTH_RANGES = [
10
+ 0x0000..0x001F, # C0 control codes
11
+ 0x007F..0x009F, # C1 control codes
12
+ 0x00AD..0x00AD, # Soft hyphen
13
+ 0x0300..0x036F, # Combining diacritical marks
14
+ 0x0483..0x0489, # Combining Cyrillic marks
15
+ 0x0591..0x05BD, # Hebrew combining marks
16
+ 0x05BF..0x05BF,
17
+ 0x05C1..0x05C2,
18
+ 0x05C4..0x05C5,
19
+ 0x05C7..0x05C7,
20
+ 0x0600..0x0605, # Arabic marks
21
+ 0x0610..0x061A,
22
+ 0x061C..0x061C,
23
+ 0x064B..0x065F,
24
+ 0x0670..0x0670,
25
+ 0x06D6..0x06DC,
26
+ 0x06DF..0x06E4,
27
+ 0x06E7..0x06E8,
28
+ 0x06EA..0x06ED,
29
+ 0x070F..0x070F,
30
+ 0x0711..0x0711,
31
+ 0x0730..0x074A,
32
+ 0x07A6..0x07B0,
33
+ 0x07EB..0x07F3,
34
+ 0x0816..0x0819,
35
+ 0x081B..0x0823,
36
+ 0x0825..0x0827,
37
+ 0x0829..0x082D,
38
+ 0x0859..0x085B,
39
+ 0x08D4..0x08E1,
40
+ 0x08E3..0x0902,
41
+ 0x093A..0x093A,
42
+ 0x093C..0x093C,
43
+ 0x0941..0x0948,
44
+ 0x094D..0x094D,
45
+ 0x0951..0x0957,
46
+ 0x0962..0x0963,
47
+ 0x0981..0x0981,
48
+ 0x09BC..0x09BC,
49
+ 0x09C1..0x09C4,
50
+ 0x09CD..0x09CD,
51
+ 0x09E2..0x09E3,
52
+ 0x0A01..0x0A02,
53
+ 0x0A3C..0x0A3C,
54
+ 0x0A41..0x0A42,
55
+ 0x0A47..0x0A48,
56
+ 0x0A4B..0x0A4D,
57
+ 0x0A51..0x0A51,
58
+ 0x0A70..0x0A71,
59
+ 0x0A75..0x0A75,
60
+ 0x0A81..0x0A82,
61
+ 0x0ABC..0x0ABC,
62
+ 0x0AC1..0x0AC5,
63
+ 0x0AC7..0x0AC8,
64
+ 0x0ACD..0x0ACD,
65
+ 0x0AE2..0x0AE3,
66
+ 0x0B01..0x0B01,
67
+ 0x0B3C..0x0B3C,
68
+ 0x0B3F..0x0B3F,
69
+ 0x0B41..0x0B44,
70
+ 0x0B4D..0x0B4D,
71
+ 0x0B56..0x0B56,
72
+ 0x0B62..0x0B63,
73
+ 0x0B82..0x0B82,
74
+ 0x0BC0..0x0BC0,
75
+ 0x0BCD..0x0BCD,
76
+ 0x0C00..0x0C00,
77
+ 0x0C3E..0x0C40,
78
+ 0x0C46..0x0C48,
79
+ 0x0C4A..0x0C4D,
80
+ 0x0C55..0x0C56,
81
+ 0x0C62..0x0C63,
82
+ 0x0C81..0x0C81,
83
+ 0x0CBC..0x0CBC,
84
+ 0x0CBF..0x0CBF,
85
+ 0x0CC6..0x0CC6,
86
+ 0x0CCC..0x0CCD,
87
+ 0x0CE2..0x0CE3,
88
+ 0x0D01..0x0D01,
89
+ 0x0D41..0x0D44,
90
+ 0x0D4D..0x0D4D,
91
+ 0x0D62..0x0D63,
92
+ 0x0DCA..0x0DCA,
93
+ 0x0DD2..0x0DD4,
94
+ 0x0DD6..0x0DD6,
95
+ 0x0E31..0x0E31,
96
+ 0x0E34..0x0E3A,
97
+ 0x0E47..0x0E4E,
98
+ 0x0EB1..0x0EB1,
99
+ 0x0EB4..0x0EB9,
100
+ 0x0EBB..0x0EBC,
101
+ 0x0EC8..0x0ECD,
102
+ 0x0F18..0x0F19,
103
+ 0x0F35..0x0F35,
104
+ 0x0F37..0x0F37,
105
+ 0x0F39..0x0F39,
106
+ 0x0F71..0x0F7E,
107
+ 0x0F80..0x0F84,
108
+ 0x0F86..0x0F87,
109
+ 0x0F8D..0x0F97,
110
+ 0x0F99..0x0FBC,
111
+ 0x0FC6..0x0FC6,
112
+ 0x102D..0x1030,
113
+ 0x1032..0x1037,
114
+ 0x1039..0x103A,
115
+ 0x103D..0x103E,
116
+ 0x1058..0x1059,
117
+ 0x105E..0x1060,
118
+ 0x1071..0x1074,
119
+ 0x1082..0x1082,
120
+ 0x1085..0x1086,
121
+ 0x108D..0x108D,
122
+ 0x109D..0x109D,
123
+ 0x1160..0x11FF, # Hangul Jungseong/Jongseong
124
+ 0x135D..0x135F,
125
+ 0x1712..0x1714,
126
+ 0x1732..0x1734,
127
+ 0x1752..0x1753,
128
+ 0x1772..0x1773,
129
+ 0x17B4..0x17B5,
130
+ 0x17B7..0x17BD,
131
+ 0x17C6..0x17C6,
132
+ 0x17C9..0x17D3,
133
+ 0x17DD..0x17DD,
134
+ 0x180B..0x180D,
135
+ 0x1885..0x1886,
136
+ 0x18A9..0x18A9,
137
+ 0x1920..0x1922,
138
+ 0x1927..0x1928,
139
+ 0x1932..0x1932,
140
+ 0x1939..0x193B,
141
+ 0x1A17..0x1A18,
142
+ 0x1A1B..0x1A1B,
143
+ 0x1A56..0x1A56,
144
+ 0x1A58..0x1A5E,
145
+ 0x1A60..0x1A60,
146
+ 0x1A62..0x1A62,
147
+ 0x1A65..0x1A6C,
148
+ 0x1A73..0x1A7C,
149
+ 0x1A7F..0x1A7F,
150
+ 0x1AB0..0x1ABE,
151
+ 0x1B00..0x1B03,
152
+ 0x1B34..0x1B34,
153
+ 0x1B36..0x1B3A,
154
+ 0x1B3C..0x1B3C,
155
+ 0x1B42..0x1B42,
156
+ 0x1B6B..0x1B73,
157
+ 0x1B80..0x1B81,
158
+ 0x1BA2..0x1BA5,
159
+ 0x1BA8..0x1BA9,
160
+ 0x1BAB..0x1BAD,
161
+ 0x1BE6..0x1BE6,
162
+ 0x1BE8..0x1BE9,
163
+ 0x1BED..0x1BED,
164
+ 0x1BEF..0x1BF1,
165
+ 0x1C2C..0x1C33,
166
+ 0x1C36..0x1C37,
167
+ 0x1CD0..0x1CD2,
168
+ 0x1CD4..0x1CE0,
169
+ 0x1CE2..0x1CE8,
170
+ 0x1CED..0x1CED,
171
+ 0x1CF4..0x1CF4,
172
+ 0x1CF8..0x1CF9,
173
+ 0x1DC0..0x1DF5,
174
+ 0x1DFC..0x1DFF,
175
+ 0x200B..0x200F, # Zero-width spaces and direction marks
176
+ 0x202A..0x202E,
177
+ 0x2060..0x2064,
178
+ 0x2066..0x206F,
179
+ 0x20D0..0x20F0, # Combining marks for symbols
180
+ 0x2CEF..0x2CF1,
181
+ 0x2D7F..0x2D7F,
182
+ 0x2DE0..0x2DFF,
183
+ 0x302A..0x302D,
184
+ 0x3099..0x309A,
185
+ 0xA66F..0xA672,
186
+ 0xA674..0xA67D,
187
+ 0xA69E..0xA69F,
188
+ 0xA6F0..0xA6F1,
189
+ 0xA802..0xA802,
190
+ 0xA806..0xA806,
191
+ 0xA80B..0xA80B,
192
+ 0xA825..0xA826,
193
+ 0xA8C4..0xA8C4,
194
+ 0xA8E0..0xA8F1,
195
+ 0xA926..0xA92D,
196
+ 0xA947..0xA951,
197
+ 0xA980..0xA982,
198
+ 0xA9B3..0xA9B3,
199
+ 0xA9B6..0xA9B9,
200
+ 0xA9BC..0xA9BC,
201
+ 0xA9E5..0xA9E5,
202
+ 0xAA29..0xAA2E,
203
+ 0xAA31..0xAA32,
204
+ 0xAA35..0xAA36,
205
+ 0xAA43..0xAA43,
206
+ 0xAA4C..0xAA4C,
207
+ 0xAA7C..0xAA7C,
208
+ 0xAAB0..0xAAB0,
209
+ 0xAAB2..0xAAB4,
210
+ 0xAAB7..0xAAB8,
211
+ 0xAABE..0xAABF,
212
+ 0xAAC1..0xAAC1,
213
+ 0xAAEC..0xAAED,
214
+ 0xAAF6..0xAAF6,
215
+ 0xABE5..0xABE5,
216
+ 0xABE8..0xABE8,
217
+ 0xABED..0xABED,
218
+ 0xFB1E..0xFB1E,
219
+ 0xFE00..0xFE0F, # Variation selectors
220
+ 0xFE20..0xFE2F,
221
+ 0xFEFF..0xFEFF, # BOM/ZWNBSP
222
+ 0xFFF9..0xFFFB,
223
+ 0x101FD..0x101FD,
224
+ 0x102E0..0x102E0,
225
+ 0x10376..0x1037A,
226
+ 0x10A01..0x10A03,
227
+ 0x10A05..0x10A06,
228
+ 0x10A0C..0x10A0F,
229
+ 0x10A38..0x10A3A,
230
+ 0x10A3F..0x10A3F,
231
+ 0x10AE5..0x10AE6,
232
+ 0x11001..0x11001,
233
+ 0x11038..0x11046,
234
+ 0x1107F..0x11081,
235
+ 0x110B3..0x110B6,
236
+ 0x110B9..0x110BA,
237
+ 0x11100..0x11102,
238
+ 0x11127..0x1112B,
239
+ 0x1112D..0x11134,
240
+ 0x11173..0x11173,
241
+ 0x11180..0x11181,
242
+ 0x111B6..0x111BE,
243
+ 0x111CA..0x111CC,
244
+ 0x1122F..0x11231,
245
+ 0x11234..0x11234,
246
+ 0x11236..0x11237,
247
+ 0x1123E..0x1123E,
248
+ 0x112DF..0x112DF,
249
+ 0x112E3..0x112EA,
250
+ 0x11300..0x11301,
251
+ 0x1133C..0x1133C,
252
+ 0x11340..0x11340,
253
+ 0x11366..0x1136C,
254
+ 0x11370..0x11374,
255
+ 0x11438..0x1143F,
256
+ 0x11442..0x11444,
257
+ 0x11446..0x11446,
258
+ 0x114B3..0x114B8,
259
+ 0x114BA..0x114BA,
260
+ 0x114BF..0x114C0,
261
+ 0x114C2..0x114C3,
262
+ 0x115B2..0x115B5,
263
+ 0x115BC..0x115BD,
264
+ 0x115BF..0x115C0,
265
+ 0x115DC..0x115DD,
266
+ 0x11633..0x1163A,
267
+ 0x1163D..0x1163D,
268
+ 0x1163F..0x11640,
269
+ 0x116AB..0x116AB,
270
+ 0x116AD..0x116AD,
271
+ 0x116B0..0x116B5,
272
+ 0x116B7..0x116B7,
273
+ 0x1171D..0x1171F,
274
+ 0x11722..0x11725,
275
+ 0x11727..0x1172B,
276
+ 0x11C30..0x11C36,
277
+ 0x11C38..0x11C3D,
278
+ 0x11C3F..0x11C3F,
279
+ 0x11C92..0x11CA7,
280
+ 0x11CAA..0x11CB0,
281
+ 0x11CB2..0x11CB3,
282
+ 0x11CB5..0x11CB6,
283
+ 0x16AF0..0x16AF4,
284
+ 0x16B30..0x16B36,
285
+ 0x16F8F..0x16F92,
286
+ 0x1BC9D..0x1BC9E,
287
+ 0x1D167..0x1D169,
288
+ 0x1D173..0x1D182,
289
+ 0x1D185..0x1D18B,
290
+ 0x1D1AA..0x1D1AD,
291
+ 0x1D242..0x1D244,
292
+ 0x1DA00..0x1DA36,
293
+ 0x1DA3B..0x1DA6C,
294
+ 0x1DA75..0x1DA75,
295
+ 0x1DA84..0x1DA84,
296
+ 0x1DA9B..0x1DA9F,
297
+ 0x1DAA1..0x1DAAF,
298
+ 0x1E000..0x1E006,
299
+ 0x1E008..0x1E018,
300
+ 0x1E01B..0x1E021,
301
+ 0x1E023..0x1E024,
302
+ 0x1E026..0x1E02A,
303
+ 0x1E8D0..0x1E8D6,
304
+ 0x1E944..0x1E94A,
305
+ 0xE0001..0xE0001,
306
+ 0xE0020..0xE007F,
307
+ 0xE0100..0xE01EF
308
+ ].freeze
309
+
310
+ # Wide (double-width) character ranges (CJK, etc.)
311
+ WIDE_RANGES = [
312
+ 0x1100..0x115F, # Hangul Jamo
313
+ 0x231A..0x231B, # Watch, Hourglass
314
+ 0x2329..0x232A, # Angle brackets
315
+ 0x23E9..0x23F3, # Media controls
316
+ 0x23F8..0x23FA,
317
+ 0x25FD..0x25FE, # Squares
318
+ 0x2614..0x2615, # Umbrella, Hot beverage
319
+ 0x2648..0x2653, # Zodiac
320
+ 0x267F..0x267F, # Wheelchair
321
+ 0x2693..0x2693, # Anchor
322
+ 0x26A1..0x26A1, # Lightning
323
+ 0x26AA..0x26AB, # Circles
324
+ 0x26BD..0x26BE, # Sports
325
+ 0x26C4..0x26C5, # Weather
326
+ 0x26CE..0x26CE, # Ophiuchus
327
+ 0x26D4..0x26D4, # No entry
328
+ 0x26EA..0x26EA, # Church
329
+ 0x26F2..0x26F3, # Fountain, Golf
330
+ 0x26F5..0x26F5, # Sailboat
331
+ 0x26FA..0x26FA, # Tent
332
+ 0x26FD..0x26FD, # Fuel pump
333
+ 0x2702..0x2702, # Scissors
334
+ 0x2705..0x2705, # Check mark
335
+ 0x2708..0x270D, # Various
336
+ 0x270F..0x270F, # Pencil
337
+ 0x2712..0x2712, # Black nib
338
+ 0x2714..0x2714, # Check mark
339
+ 0x2716..0x2716, # X mark
340
+ 0x271D..0x271D, # Cross
341
+ 0x2721..0x2721, # Star of David
342
+ 0x2728..0x2728, # Sparkles
343
+ 0x2733..0x2734, # Asterisks
344
+ 0x2744..0x2744, # Snowflake
345
+ 0x2747..0x2747, # Sparkle
346
+ 0x274C..0x274C, # Cross mark
347
+ 0x274E..0x274E,
348
+ 0x2753..0x2755, # Question marks
349
+ 0x2757..0x2757, # Exclamation
350
+ 0x2763..0x2764, # Heart
351
+ 0x2795..0x2797, # Math symbols
352
+ 0x27A1..0x27A1, # Arrow
353
+ 0x27B0..0x27B0, # Loop
354
+ 0x27BF..0x27BF, # Loop
355
+ 0x2934..0x2935, # Arrows
356
+ 0x2B05..0x2B07, # Arrows
357
+ 0x2B1B..0x2B1C, # Squares
358
+ 0x2B50..0x2B50, # Star
359
+ 0x2B55..0x2B55, # Circle
360
+ 0x2E80..0x2E99, # CJK radicals
361
+ 0x2E9B..0x2EF3,
362
+ 0x2F00..0x2FD5, # Kangxi radicals
363
+ 0x2FF0..0x2FFB, # Ideographic description
364
+ 0x3000..0x303E, # CJK punctuation
365
+ 0x3041..0x3096, # Hiragana
366
+ 0x3099..0x30FF, # Katakana
367
+ 0x3105..0x312D, # Bopomofo
368
+ 0x3131..0x318E, # Hangul compatibility
369
+ 0x3190..0x31BA, # Kanbun
370
+ 0x31C0..0x31E3, # CJK strokes
371
+ 0x31F0..0x321E, # Katakana extensions
372
+ 0x3220..0x3247, # Enclosed CJK
373
+ 0x3250..0x32FE,
374
+ 0x3300..0x4DBF, # CJK unified
375
+ 0x4E00..0x9FFF, # CJK unified ideographs
376
+ 0xA000..0xA48C, # Yi syllables
377
+ 0xA490..0xA4C6, # Yi radicals
378
+ 0xA960..0xA97C, # Hangul Jamo extended
379
+ 0xAC00..0xD7A3, # Hangul syllables
380
+ 0xF900..0xFAFF, # CJK compatibility ideographs
381
+ 0xFE10..0xFE19, # Vertical forms
382
+ 0xFE30..0xFE52, # CJK compatibility forms
383
+ 0xFE54..0xFE66,
384
+ 0xFE68..0xFE6B,
385
+ 0xFF01..0xFF60, # Fullwidth forms
386
+ 0xFFE0..0xFFE6,
387
+ 0x16FE0..0x16FE1, # Various
388
+ 0x17000..0x187EC, # Tangut
389
+ 0x18800..0x18AF2,
390
+ 0x1B000..0x1B11E, # Kana supplement
391
+ 0x1B170..0x1B2FB,
392
+ 0x1F004..0x1F004, # Mahjong
393
+ 0x1F0CF..0x1F0CF, # Playing card
394
+ 0x1F18E..0x1F18E, # Squared AB
395
+ 0x1F191..0x1F19A, # Squared
396
+ 0x1F200..0x1F202,
397
+ 0x1F210..0x1F23B,
398
+ 0x1F240..0x1F248,
399
+ 0x1F250..0x1F251,
400
+ 0x1F260..0x1F265,
401
+ 0x1F300..0x1F64F, # Emoji
402
+ 0x1F680..0x1F6C5,
403
+ 0x1F6CC..0x1F6CC,
404
+ 0x1F6D0..0x1F6D2,
405
+ 0x1F6EB..0x1F6EC,
406
+ 0x1F6F4..0x1F6F8,
407
+ 0x1F910..0x1F93E,
408
+ 0x1F940..0x1F94C,
409
+ 0x1F950..0x1F96B,
410
+ 0x1F980..0x1F997,
411
+ 0x1F9C0..0x1F9C0,
412
+ 0x1F9D0..0x1F9E6,
413
+ 0x20000..0x2FFFD, # CJK extension B
414
+ 0x30000..0x3FFFD # CJK extension G
415
+ ].freeze
416
+
417
+ # Eagerly initialize the shared width cache and its lock so the mutex is a
418
+ # stable singleton before any thread calls in. Lazy `@mutex ||= Mutex.new`
419
+ # inside the method could let two racing callers create distinct locks and
420
+ # leave the cache Hash effectively unguarded.
421
+ @cache = {}
422
+ @cache_mutex = Mutex.new
423
+ @overrides = {}
424
+
425
+ class << self
426
+ # Get the display width of a single character
427
+ # @param char [String] Single character
428
+ # @return [Integer] Width (0, 1, or 2)
429
+ def char_width(char)
430
+ return 0 if char.nil? || char.empty?
431
+
432
+ # Honor explicit per-character overrides registered via set_cell_size.
433
+ if defined?(@overrides) && @overrides && (override = @overrides[char])
434
+ return override
435
+ end
436
+
437
+ codepoint = char.ord
438
+
439
+ # Tab, newline, and carriage return should have width 1 for measurement
440
+ # (even though they may have different visual behavior)
441
+ return 1 if codepoint == 0x09 || codepoint == 0x0A || codepoint == 0x0D
442
+
443
+ # Fast path: printable ASCII is always width 1. This is the overwhelming
444
+ # common case and avoids scanning the ~340 zero-width/wide ranges below.
445
+ return 1 if codepoint >= 0x20 && codepoint <= 0x7E
446
+
447
+ # Check zero-width first (most common for combining marks)
448
+ ZERO_WIDTH_RANGES.each do |range|
449
+ return 0 if range.cover?(codepoint)
450
+ end
451
+
452
+ # Check wide characters
453
+ WIDE_RANGES.each do |range|
454
+ return 2 if range.cover?(codepoint)
455
+ end
456
+
457
+ # Default to single width
458
+ 1
459
+ end
460
+
461
+ # Get the display width of a string
462
+ # @param text [String] Text to measure
463
+ # @return [Integer] Total display width in cells
464
+ def cell_len(text)
465
+ return 0 if text.nil? || text.empty?
466
+
467
+ # Display width is the sum of per-character cell widths. This is correct
468
+ # on every platform: legacy Windows consoles still render a CJK glyph as
469
+ # 2 cells and ASCII as 1 — UTF-8 byte count is NOT a display width.
470
+ text.each_char.sum { |c| char_width(c) }
471
+ end
472
+
473
+ # Get the display width of a string, with cache
474
+ # @param text [String] Text to measure
475
+ # @return [Integer] Total display width
476
+ def cached_cell_len(text)
477
+ return 0 if text.nil? || text.empty?
478
+
479
+ # Lock-free read of the common cache-hit path (Hash reads are safe under
480
+ # MRI's GVL); only take the lock to populate. A 0 width is truthy in
481
+ # Ruby, so a cached zero-width string still short-circuits correctly.
482
+ hit = @cache[text]
483
+ return hit if hit
484
+
485
+ result = cell_len(text)
486
+ @cache_mutex.synchronize do
487
+ # Bulk-clear once over the cap rather than shifting one entry per
488
+ # insert while permanently parked at the boundary.
489
+ @cache.clear if @cache.size > 10_000
490
+ @cache[text] = result
491
+ end
492
+ result
493
+ end
494
+
495
+ # Set the cell size for a specific character (override)
496
+ # @param char [String] Character to override
497
+ # @param size [Integer] Width to use
498
+ def set_cell_size(char, size)
499
+ @overrides[char] = size
500
+ # Existing cached widths may include this character; invalidate them.
501
+ clear_cache
502
+ end
503
+
504
+ # Clear the cell width cache
505
+ def clear_cache
506
+ @cache_mutex.synchronize { @cache.clear }
507
+ end
508
+
509
+ # Check if a character is a zero-width character
510
+ # @param char [String] Character to check
511
+ # @return [Boolean]
512
+ def zero_width?(char)
513
+ char_width(char) == 0
514
+ end
515
+
516
+ # Check if a character is a wide (double-width) character
517
+ # @param char [String] Character to check
518
+ # @return [Boolean]
519
+ def wide?(char)
520
+ char_width(char) == 2
521
+ end
522
+ end
523
+ end
524
+ end