Skip to content

Commit 1776644

Browse files
committed
Always render ambiguous width emoji in full-width
Some emoji such as flag emoji and Extended_Pictographic followed by variation-selector-16 are rendered in half-width or full-width depend on terminal emulator and its configuration. Adds a special rendering flow to always render these character in full-width.
1 parent 99dc2a3 commit 1776644

File tree

4 files changed

+67
-3
lines changed

4 files changed

+67
-3
lines changed

lib/reline/line_editor.rb

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,27 @@ def calculate_overlay_levels(overlay_levels)
403403
levels
404404
end
405405

406+
def render_line_item(x, content)
407+
segments = Reline::Unicode.split_ambiguous_emoji(content)
408+
if segments
409+
Reline::IOGate.write Reline::IOGate.reset_color_sequence
410+
segments.each do |segment, ambiguous|
411+
w = Reline::Unicode.calculate_width(segment, true)
412+
Reline::IOGate.move_cursor_column(x)
413+
if ambiguous
414+
Reline::IOGate.write(' ' * w)
415+
Reline::IOGate.move_cursor_column(x)
416+
end
417+
Reline::IOGate.write(segment)
418+
x += w
419+
end
420+
Reline::IOGate.write Reline::IOGate.reset_color_sequence
421+
else
422+
Reline::IOGate.move_cursor_column(x)
423+
Reline::IOGate.write "#{Reline::IOGate.reset_color_sequence}#{content}#{Reline::IOGate.reset_color_sequence}"
424+
end
425+
end
426+
406427
def render_line_differential(old_items, new_items)
407428
old_levels = calculate_overlay_levels(old_items.zip(new_items).each_with_index.map {|((x, w, c), (nx, _nw, nc)), i| [x, w, c == nc && x == nx ? i : -1] if x }.compact)
408429
new_levels = calculate_overlay_levels(new_items.each_with_index.map { |(x, w), i| [x, w, i] if x }.compact).take(screen_width)
@@ -422,8 +443,7 @@ def render_line_differential(old_items, new_items)
422443
unless x == base_x && w == width
423444
content, pos = Reline::Unicode.take_mbchar_range(content, base_x - x, width, cover_begin: cover_begin, cover_end: cover_end, padding: true)
424445
end
425-
Reline::IOGate.move_cursor_column x + pos
426-
Reline::IOGate.write "#{Reline::IOGate.reset_color_sequence}#{content}#{Reline::IOGate.reset_color_sequence}"
446+
render_line_item(x + pos, content)
427447
end
428448
base_x += width
429449
end

lib/reline/unicode.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ class Reline::Unicode
4141
OSC_REGEXP = /\e\]\d+(?:;[^;\a\e]+)*(?:\a|\e\\)/
4242
WIDTH_SCANNER = /\G(?:(#{NON_PRINTING_START})|(#{NON_PRINTING_END})|(#{CSI_REGEXP})|(#{OSC_REGEXP})|(\X))/o
4343

44+
AMBIGUOUS_WIDTH_EMOJI_CLUSTER = Regexp.union(
45+
/\p{Grapheme_Cluster_Break=Regional_Indicator}{2}/, # Flag emoji
46+
/\p{Extended_Pictographic}\u{FE0F}/ # Variation selector-16
47+
)
48+
4449
def self.escape_for_print(str)
4550
str.chars.map! { |gr|
4651
case gr
@@ -78,6 +83,20 @@ def self.east_asian_width(ord)
7883
size == -1 ? Reline.ambiguous_width : size
7984
end
8085

86+
# Some emoji needs special handling on rendering because width is ambiguous depending on terminal emulator and configuration.
87+
# For example, iTerm can configure flag-emoji width and variation selector 16 emoji width.
88+
# split_ambiguous_emoji('abc') #=> nil (no ambiguous emoji)
89+
# split_ambiguous_emoji('abc[flag][flag]de') #=> [['abc', false], ['[flag]', true], ['[flag]', true], ['de', false]]
90+
def self.split_ambiguous_emoji(str)
91+
return if str.ascii_only? || !str.match?(AMBIGUOUS_WIDTH_EMOJI_CLUSTER)
92+
93+
str.grapheme_clusters.chunk.with_index do |gc, idx|
94+
gc.match?(AMBIGUOUS_WIDTH_EMOJI_CLUSTER) ? idx : -1
95+
end.map do |key, gcs|
96+
[gcs.join, key != -1]
97+
end
98+
end
99+
81100
def self.get_mbchar_width(mbchar)
82101
ord = mbchar.ord
83102
if ord <= 0x1F # in EscapedPairs
@@ -87,8 +106,16 @@ def self.get_mbchar_width(mbchar)
87106
end
88107

89108
utf8_mbchar = mbchar.encode(Encoding::UTF_8)
109+
return east_asian_width(utf8_mbchar.ord) if utf8_mbchar.size == 1
110+
111+
width = 0
112+
if utf8_mbchar.match?(AMBIGUOUS_WIDTH_EMOJI_CLUSTER)
113+
width += 2
114+
utf8_mbchar = utf8_mbchar.sub(AMBIGUOUS_WIDTH_EMOJI_CLUSTER, '')
115+
end
116+
90117
zwj = false
91-
utf8_mbchar.chars.sum do |c|
118+
width + utf8_mbchar.chars.sum do |c|
92119
if zwj
93120
zwj = false
94121
0

test/reline/test_line_editor.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ def test_complicated
242242
@line_editor.render_line_differential(state_b, state_a)
243243
end
244244
end
245+
246+
def test_ambiguous_emoji
247+
state_a = [nil]
248+
state_b = [[0, 12, '😄😄©️🇯🇵😄😄']]
249+
assert_output '[COL_0]😄😄[COL_4] [COL_4]©️[COL_6] [COL_6]🇯🇵[COL_8]😄😄' do
250+
@line_editor.render_line_differential(state_a, state_b)
251+
end
252+
end
245253
end
246254

247255
def test_menu_info_format

test/reline/test_unicode.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,15 @@ def test_halfwidth_dakuten_handakuten_combinations
296296
assert_equal 3, Reline::Unicode.get_mbchar_width("紅゙")
297297
end
298298

299+
def test_split_ambiguous_emoji
300+
variant_selector_emoji = "©️"
301+
flag_emoji = '🇯🇵'
302+
normal_text = '😀abc😀👨‍👩‍👧'
303+
text_target = normal_text + variant_selector_emoji + flag_emoji + normal_text + flag_emoji + normal_text
304+
expected = [[normal_text, false], [variant_selector_emoji, true], [flag_emoji, true], [normal_text, false], [flag_emoji, true], [normal_text, false]]
305+
assert_equal expected, Reline::Unicode.split_ambiguous_emoji(text_target)
306+
end
307+
299308
def test_grapheme_cluster_width
300309
# GB6, GB7, GB8: Hangul syllable
301310
assert_equal 2, Reline::Unicode.get_mbchar_width('한'.unicode_normalize(:nfd))

0 commit comments

Comments
 (0)