diff --git a/lib/reline/line_editor.rb b/lib/reline/line_editor.rb index 763a2171e5..c777f45ab9 100644 --- a/lib/reline/line_editor.rb +++ b/lib/reline/line_editor.rb @@ -403,6 +403,27 @@ def calculate_overlay_levels(overlay_levels) levels end + def render_line_item(x, content) + segments = Reline::Unicode.split_ambiguous_emoji(content) + if segments + Reline::IOGate.write Reline::IOGate.reset_color_sequence + segments.each do |segment, ambiguous| + w = Reline::Unicode.calculate_width(segment, true) + Reline::IOGate.move_cursor_column(x) + if ambiguous + Reline::IOGate.write(' ' * w) + Reline::IOGate.move_cursor_column(x) + end + Reline::IOGate.write(segment) + x += w + end + Reline::IOGate.write Reline::IOGate.reset_color_sequence + else + Reline::IOGate.move_cursor_column(x) + Reline::IOGate.write "#{Reline::IOGate.reset_color_sequence}#{content}#{Reline::IOGate.reset_color_sequence}" + end + end + def render_line_differential(old_items, new_items) 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) 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) unless x == base_x && w == width content, pos = Reline::Unicode.take_mbchar_range(content, base_x - x, width, cover_begin: cover_begin, cover_end: cover_end, padding: true) end - Reline::IOGate.move_cursor_column x + pos - Reline::IOGate.write "#{Reline::IOGate.reset_color_sequence}#{content}#{Reline::IOGate.reset_color_sequence}" + render_line_item(x + pos, content) end base_x += width end diff --git a/lib/reline/unicode.rb b/lib/reline/unicode.rb index 28d6b1b2b9..c74df72341 100644 --- a/lib/reline/unicode.rb +++ b/lib/reline/unicode.rb @@ -41,6 +41,11 @@ class Reline::Unicode OSC_REGEXP = /\e\]\d+(?:;[^;\a\e]+)*(?:\a|\e\\)/ WIDTH_SCANNER = /\G(?:(#{NON_PRINTING_START})|(#{NON_PRINTING_END})|(#{CSI_REGEXP})|(#{OSC_REGEXP})|(\X))/o + AMBIGUOUS_WIDTH_EMOJI_CLUSTER = Regexp.union( + /\p{Grapheme_Cluster_Break=Regional_Indicator}{2}/, # Flag emoji + /\p{Extended_Pictographic}\u{FE0F}/ # Variation selector-16 + ) + def self.escape_for_print(str) str.chars.map! { |gr| case gr @@ -78,6 +83,20 @@ def self.east_asian_width(ord) size == -1 ? Reline.ambiguous_width : size end + # Some emoji needs special handling on rendering because width is ambiguous depending on terminal emulator and configuration. + # For example, iTerm can configure flag-emoji width and variation selector 16 emoji width. + # split_ambiguous_emoji('abc') #=> nil (no ambiguous emoji) + # split_ambiguous_emoji('abc[flag][flag]de') #=> [['abc', false], ['[flag]', true], ['[flag]', true], ['de', false]] + def self.split_ambiguous_emoji(str) + return if str.ascii_only? || !str.match?(AMBIGUOUS_WIDTH_EMOJI_CLUSTER) + + str.grapheme_clusters.chunk.with_index do |gc, idx| + gc.match?(AMBIGUOUS_WIDTH_EMOJI_CLUSTER) ? idx : -1 + end.map do |key, gcs| + [gcs.join, key != -1] + end + end + def self.get_mbchar_width(mbchar) ord = mbchar.ord if ord <= 0x1F # in EscapedPairs @@ -87,8 +106,16 @@ def self.get_mbchar_width(mbchar) end utf8_mbchar = mbchar.encode(Encoding::UTF_8) + return east_asian_width(utf8_mbchar.ord) if utf8_mbchar.size == 1 + + width = 0 + if utf8_mbchar.match?(AMBIGUOUS_WIDTH_EMOJI_CLUSTER) + width += 2 + utf8_mbchar = utf8_mbchar.sub(AMBIGUOUS_WIDTH_EMOJI_CLUSTER, '') + end + zwj = false - utf8_mbchar.chars.sum do |c| + width + utf8_mbchar.chars.sum do |c| if zwj zwj = false 0 diff --git a/test/reline/test_line_editor.rb b/test/reline/test_line_editor.rb index 28fcbfa6df..94141e66a6 100644 --- a/test/reline/test_line_editor.rb +++ b/test/reline/test_line_editor.rb @@ -242,6 +242,14 @@ def test_complicated @line_editor.render_line_differential(state_b, state_a) end end + + def test_ambiguous_emoji + state_a = [nil] + state_b = [[0, 12, 'πŸ˜„πŸ˜„Β©οΈπŸ‡―πŸ‡΅πŸ˜„πŸ˜„']] + assert_output '[COL_0]πŸ˜„πŸ˜„[COL_4] [COL_4]©️[COL_6] [COL_6]πŸ‡―πŸ‡΅[COL_8]πŸ˜„πŸ˜„' do + @line_editor.render_line_differential(state_a, state_b) + end + end end def test_menu_info_format diff --git a/test/reline/test_unicode.rb b/test/reline/test_unicode.rb index d86dc4d759..efa7576f6e 100644 --- a/test/reline/test_unicode.rb +++ b/test/reline/test_unicode.rb @@ -296,6 +296,15 @@ def test_halfwidth_dakuten_handakuten_combinations assert_equal 3, Reline::Unicode.get_mbchar_width("η΄…οΎž") end + def test_split_ambiguous_emoji + variant_selector_emoji = "©️" + flag_emoji = 'πŸ‡―πŸ‡΅' + normal_text = 'πŸ˜€abcπŸ˜€πŸ‘¨β€πŸ‘©β€πŸ‘§' + text_target = normal_text + variant_selector_emoji + flag_emoji + normal_text + flag_emoji + normal_text + expected = [[normal_text, false], [variant_selector_emoji, true], [flag_emoji, true], [normal_text, false], [flag_emoji, true], [normal_text, false]] + assert_equal expected, Reline::Unicode.split_ambiguous_emoji(text_target) + end + def test_grapheme_cluster_width # GB6, GB7, GB8: Hangul syllable assert_equal 2, Reline::Unicode.get_mbchar_width('ν•œ'.unicode_normalize(:nfd))