diff --git a/lib/cli/ui.rb b/lib/cli/ui.rb index 3360d3b5..52e717e4 100644 --- a/lib/cli/ui.rb +++ b/lib/cli/ui.rb @@ -346,7 +346,7 @@ def link(url, text, format: true, blue_underline: format) text = "{{blue:{{underline:#{text}}}}}" if blue_underline text = CLI::UI.fmt(text) if format - "\x1b]8;;#{url}\x1b\\#{text}\x1b]8;;\x1b\\" + ANSI.hyperlink(url, text) end end diff --git a/lib/cli/ui/ansi.rb b/lib/cli/ui/ansi.rb index 85399533..fe5cb0c6 100644 --- a/lib/cli/ui/ansi.rb +++ b/lib/cli/ui/ansi.rb @@ -1,6 +1,9 @@ # typed: true # frozen_string_literal: true +require 'strscan' +require_relative 'ansi/terminal_width' + module CLI module UI module ANSI @@ -9,35 +12,82 @@ module ANSI ESC = "\x1b" # https://ghostty.org/docs/vt/concepts/sequences#csi-sequences - CSI_SEQUENCE = /\x1b\[[\d;:]+[\x20-\x2f]*?[\x40-\x7e]/ + CSI_SEQUENCE = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/ # https://ghostty.org/docs/vt/concepts/sequences#osc-sequences # OSC sequences can be terminated with either ST (\x1b\x5c) or BEL (\x07) OSC_SEQUENCE = /\x1b\][^\x07\x1b]*?(?:\x07|\x1b\x5c)/ - + # An OSC 8 hyperlink: \x1b]8;params;URI, terminated like any OSC + # sequence. One with a URI opens a link, one without closes it. + # Anchored, to classify a whole sequence as yielded by each_token. + HYPERLINK = /\A\x1b\]8;[^;]*;(?.*)(?:\x07|\x1b\x5c)\z/m + HYPERLINK_END = "\x1b]8;;\x1b\x5c" + # Any whole control sequence, for walking a string as alternating + # sequence and text runs. + SEQUENCE = Regexp.union(CSI_SEQUENCE, OSC_SEQUENCE) + # A CSI or OSC introducer whose sequence runs to the end of the + # string without a terminator β€” usually one sliced open by an + # upstream cut. Treating it as a sequence keeps its bytes out of + # width measurements and truncation windows. + UNTERMINATED_SEQUENCE = /\x1b[\[\]][^\x1b]*\z/ + TEXT_RUN = /[^\x1b]+/ class << self - # ANSI escape sequences (like \x1b[31m) have zero width. - # when calculating the padding width, we must exclude them. - # This also implements a basic version of utf8 character width calculation like - # we could get for real from something like utf8proc. + # Yields str as alternating runs of :sequence (one whole CSI or OSC + # sequence) and :text (everything between them). Sequences never + # straddle tokens, so a consumer that measures or cuts only at + # token boundaries cannot slice one open. A CSI or OSC sequence + # left unterminated at the end of the string is yielded as one + # :sequence token; any other stray ESC is yielded as text. + # + #: (String str) ?{ (Symbol kind, String token) -> void } -> Enumerator[[Symbol, String]]? + def each_token(str, &block) + return to_enum(:each_token, str) unless block_given? + + scanner = StringScanner.new(str) + until scanner.eos? + if (sequence = scanner.scan(SEQUENCE) || scanner.scan(UNTERMINATED_SEQUENCE)) + yield(:sequence, sequence) + elsif (text = scanner.scan(TEXT_RUN)) + yield(:text, text) + else + yield(:text, scanner.getch.to_s) + end + end + end + + # The number of terminal columns str occupies when printed: control + # sequences take none, and each grapheme cluster (not codepoint: + # πŸ‘©β€πŸ’» is one cluster) is measured by grapheme_width. # #: (String str) -> Integer def printing_width(str) - zwj = false #: bool - strip_codes(str).codepoints.reduce(0) do |acc, cp| - if zwj - zwj = false - next acc - end - case cp - when 0x200d # zero-width joiner - zwj = true - acc - when "\n" - acc + # ASCII fast paths. Every ASCII grapheme cluster is one character + # wide except \n and \r, which are zero, so counting stands in for + # the cluster walk; with no ESC there are no sequences to skip and + # the whole string can be counted without tokenizing. + if str.ascii_only? && !str.include?(ESC) + return str.length - str.count("\n\r") + end + + width = 0 #: Integer + each_token(str) do |kind, token| + next unless kind == :text + + if token.ascii_only? + width += token.length - token.count("\n\r") else - acc + 1 + token.grapheme_clusters.each do |cluster| + width += grapheme_width(cluster) + end end end + width + end + + # The number of terminal columns one grapheme cluster occupies. + # + #: (String cluster) -> Integer + def grapheme_width(cluster) + TerminalWidth.grapheme_width(cluster) end # Strips ANSI codes from a str @@ -96,6 +146,13 @@ def sgr(params) control(params, 'm') end + # Renders text as an OSC 8 hyperlink to url + # + #: (String url, String text) -> String + def hyperlink(url, text) + "\x1b]8;;#{url}\x1b\x5c#{text}#{HYPERLINK_END}" + end + # Cursor Movement # Move the cursor up n lines diff --git a/lib/cli/ui/ansi/replay.rb b/lib/cli/ui/ansi/replay.rb index d79b3a8b..1b25eb8f 100644 --- a/lib/cli/ui/ansi/replay.rb +++ b/lib/cli/ui/ansi/replay.rb @@ -474,9 +474,26 @@ def escape(screen, scanner) when "\e" then next when "\x18", "\x1a" then return :ground when /[\x20-\x2f]/ - # nF sequences: intermediate bytes, then one final byte. - scanner.skip(/[\x20-\x2f]*[\x30-\x7e]?/) - return :ground + return escape_intermediate(screen, scanner) + when SIMPLE_CONTROL then simple_control(screen, char) + else return :ground + end + end + :ground + end + + # ESC intermediate state: collect through the final byte while + # executing embedded C0 controls and ignoring DEL. Bulk-skipping this + # tail would end the sequence at an embedded control, exposing its + # final byte as printable text. + #: (Screen screen, StringScanner scanner) -> Symbol + def escape_intermediate(screen, scanner) + until scanner.eos? + case (char = scanner.getch.to_s) + when /[\x30-\x7e]/ then return :ground + when /[\x20-\x2f]/ then next + when "\e" then return :escape + when "\x18", "\x1a" then return :ground when SIMPLE_CONTROL then simple_control(screen, char) else return :ground end @@ -548,7 +565,10 @@ def apply(screen, params, intermediates, final) # tracks, except the alternate screen: a full-screen UI draws # there and a terminal discards it on exit, so it must not reach # the replayed scrollback either. - if params == '?1049' + if params.match?(/\A\?[\d;]*\z/) + modes = params.delete_prefix('?').split(';') + return unless modes.include?('1049') + case final when 'h' then screen.enter_alternate when 'l' then screen.exit_alternate diff --git a/lib/cli/ui/truncater.rb b/lib/cli/ui/truncater.rb index f5c2332e..cca6abb1 100644 --- a/lib/cli/ui/truncater.rb +++ b/lib/cli/ui/truncater.rb @@ -5,74 +5,57 @@ module CLI module UI # Truncater truncates a string to a provided printable width. module Truncater - PARSE_ROOT = :root - PARSE_ANSI = :ansi - PARSE_ESC = :esc - PARSE_ZWJ = :zwj - - ESC = 0x1b - LEFT_SQUARE_BRACKET = 0x5b - ZWJ = 0x200d # emojipedia.org/emoji-zwj-sequences - SEMICOLON = 0x3b - - # EMOJI_RANGE in particular is super inaccurate. This is best-effort. - # If you need this to be more accurate, we'll almost certainly accept a - # PR improving it. - EMOJI_RANGE = 0x1f300..0x1f5ff - NUMERIC_RANGE = 0x30..0x39 - LC_ALPHA_RANGE = 0x40..0x5a - UC_ALPHA_RANGE = 0x60..0x71 - TRUNCATED = "\x1b[0m…" class << self #: (String text, Integer printing_width) -> String def call(text, printing_width) - return text if text.size <= printing_width + # Fast path. Only sound for ASCII, where no character is wider + # than a column: an emoji string can occupy up to twice as many + # columns as it has characters. + return text if text.ascii_only? && text.size <= printing_width - width = 0 - mode = PARSE_ROOT - truncation_index = nil #: Integer? + width = 0 #: Integer + truncated = false #: bool + open_hyperlink = false #: bool + # Preserve the caller's encoding. Printer can deliberately pass + # ASCII-compatible strings in encodings other than UTF-8, and an + # empty UTF-8 buffer becomes incompatible after binary text has + # been appended to it. + prefix = String.new(encoding: text.encoding) - codepoints = text.codepoints - codepoints.each.with_index do |cp, index| - case mode - when PARSE_ROOT - case cp - when ESC # non-printable, followed by some more non-printables. - mode = PARSE_ESC - when ZWJ # non-printable, followed by another non-printable. - mode = PARSE_ZWJ - else - width += width(cp) - if width >= printing_width - truncation_index ||= index - # it looks like we could break here but we still want the - # width calculation for the rest of the characters. - end - end - when PARSE_ESC - mode = case cp - when LEFT_SQUARE_BRACKET - PARSE_ANSI - else - PARSE_ROOT + ANSI.each_token(text) do |kind, token| + case kind + when :sequence + # Sequences occupy no columns. Any that fall past the cut are + # dropped: TRUNCATED resets SGR state itself, and an open + # hyperlink gets closed below. + next if truncated + + prefix << token + if (match = ANSI::HYPERLINK.match(token)) + open_hyperlink = !match[:uri].to_s.empty? end - when PARSE_ANSI - # ANSI escape codes preeeetty much have the format of: - # \x1b[0-9;]+[A-Za-z] - case cp - when NUMERIC_RANGE, SEMICOLON - when LC_ALPHA_RANGE, UC_ALPHA_RANGE - mode = PARSE_ROOT - else - # unexpected. let's just go back to the root state I guess? - mode = PARSE_ROOT + when :text + token.grapheme_clusters.each do |cluster| + # A line break is zero columns to printing_width, but a + # truncated string must stay one line: count it as a column + # so the cut lands before it, never absorbing it silently. + # Other zero-width clusters remain zero-width. + cluster_width = case cluster + when "\n", "\r", "\r\n" + 1 + else + ANSI.grapheme_width(cluster) + end + width += cluster_width + # We cut before the cluster that reaches printing_width, + # leaving one column for TRUNCATED's ellipsis, but keep + # measuring: if the rest of the string turns out not to + # exceed printing_width after all, no cut is needed. + truncated ||= width >= printing_width + prefix << cluster unless truncated end - when PARSE_ZWJ - # consume any character and consider it as having no width - # width(x+ZWJ+y) = width(x). - mode = PARSE_ROOT end end @@ -81,22 +64,21 @@ def call(text, printing_width) # It's specifically for the case where we decided "Yes, this is the # point at which we'd have to add a truncation!" but it's actually # the end of the string. - return text if !truncation_index || width <= printing_width + return text if !truncated || width <= printing_width - slice = codepoints[0...truncation_index] #: as !nil - slice.pack('U*') + TRUNCATED + prefix << ANSI::HYPERLINK_END.encode(text.encoding) if open_hyperlink + prefix << truncation_marker(text.encoding) end private - #: (Integer printable_codepoint) -> Integer - def width(printable_codepoint) - case printable_codepoint - when EMOJI_RANGE - 2 - else - 1 - end + # Keep the reset and marker in the input encoding. Some + # ASCII-compatible encodings cannot represent U+2026; a one-column + # question mark preserves the width contract in that case. + # + #: (Encoding encoding) -> String + def truncation_marker(encoding) + TRUNCATED.encode(encoding, invalid: :replace, undef: :replace, replace: '?') end end end diff --git a/lib/cli/ui/wrap.rb b/lib/cli/ui/wrap.rb index 05ce0afd..db1cd2a7 100644 --- a/lib/cli/ui/wrap.rb +++ b/lib/cli/ui/wrap.rb @@ -5,6 +5,10 @@ module CLI module UI class Wrap + # SGR parameters are separated by ; or, in the underspecified-but-real + # colon form of extended colors (\x1b[38:2::255:0:0m), by :. + SGR = /\A\x1b\[[\d;:]*m\z/ + #: (String input) -> void def initialize(input) @input = input @@ -14,43 +18,143 @@ def initialize(input) def wrap(total_width = Terminal.width) max_width = total_width - Frame.prefix_width width = 0 #: Integer - final = [] - # Create an alternation of format codes of parameter lengths 1-20, since + and {1,n} not allowed in lookbehind - format_codes = (1..20).map { |n| /\x1b\[[\d;]{#{n}}m/ }.join('|') - codes = '' - @input.split(/(?=\s|\x1b\[[\d;]+m|\r)|(?<=\s|#{format_codes})/).each do |token| - case token - when '\x1B[0?m' - codes = '' - final << token - when /\x1b\[[\d;]+m/ - codes += token # Track in use format codes so that they are resent after frame coloring + final = +'' + # SGR codes in effect, resent after each line break so that frame + # coloring doesn't clobber them mid-paragraph. An open hyperlink + # likewise gets closed at the break and reopened after it, keeping + # the frame gutter outside the link. + sgr_state = {} #: Hash[String, String] + open_hyperlink = nil #: String? + break_line = -> do + final << ANSI::HYPERLINK_END if open_hyperlink + final << "\n" << active_sgr(sgr_state) << open_hyperlink.to_s + width = 0 + end + + ANSI.each_token(@input) do |kind, token| + if kind == :sequence + case token + when SGR + track_sgr(token, sgr_state) + when ANSI::HYPERLINK + match = ANSI::HYPERLINK.match(token) #: as !nil + open_hyperlink = match[:uri].to_s.empty? ? nil : token + end final << token - when "\n" - final << "\n#{codes}" - width = 0 - when /\s/ - token_width = ANSI.printing_width(token) - if width + token_width <= max_width - final << token - width += token_width - else - final << "\n#{codes}" - width = 0 + next + end + + # Split the text run so each whitespace character is its own + # token: lines break at whitespace, and a space that would sit in + # the last column becomes the break itself. + token.split(/(?=\s)|(?<=\s)/).each do |chunk| + if chunk == "\n" + break_line.call + next end - else - token_width = ANSI.printing_width(token) - if width + token_width <= max_width - final << token - width += token_width + + chunk_width = ANSI.printing_width(chunk) + if width + chunk_width <= max_width + final << chunk + width += chunk_width + elsif chunk.match?(/\A\s\z/) + break_line.call else - final << "\n#{codes}" - final << token - width = token_width + break_line.call + final << chunk + width = chunk_width end end end - final.join + final + end + + private + + # Reconstructs the active SGR commands as one deduplicated sequence. + # + #: (Hash[String, String] state) -> String + def active_sgr(state) + state.empty? ? '' : "\e[#{state.values.join(";")}m" + end + + # Keeps only the most recent command for each SGR parameter, with shared + # slots for the color commands whose payloads can vary. Deleting before + # reinserting preserves the order of each command's last occurrence, so + # an on/off/on sequence replays in the same effective order without + # retaining the full formatting history. + # + # A reset can hide mid-list: parameters reset at a 0 or an empty entry + # (\e[0;33m, \e[;1m), while zeros inside a colon-form parameter are + # subparameters rather than commands. + # + #: (String token, Hash[String, String] state) -> void + def track_sgr(token, state) + params = token[2...-1].to_s.split(';', -1) + params = ['0'] if params.empty? + index = 0 + while index < params.length + param = params.fetch(index) + param = '0' if param.empty? + code = param.split(':', 2).first.to_i + command = param + consumed = 0 + + if code == 38 || code == 48 || code == 58 + command, consumed = color_command(params, index) + end + remember_sgr(state, code, command) if command + index += consumed + 1 + end + end + + # Semicolon-form extended colors consume their following parameters; + # colon-form colors are already one parameter and need no grouping. + # + #: (Array[String] params, Integer index) -> [String?, Integer] + def color_command(params, index) + param = params.fetch(index) + return [param, 0] if param.include?(':') + + case params[index + 1] + when '5' + return [nil, params.length - index - 1] if params[index + 2].nil? + + [params[index, 3].to_a.join(';'), 2] + when '2' + length = params[index + 2].to_s.empty? ? 6 : 5 + return [nil, params.length - index - 1] if params.length < index + length + + [params[index, length].to_a.join(';'), length - 1] + else + [nil, 0] + end + end + + #: (Hash[String, String] state, Integer code, String command) -> void + def remember_sgr(state, code, command) + if code.zero? + state.clear + return + end + + key = sgr_key(code) + state.delete(key) + state[key] = command + end + + #: (Integer code) -> String + def sgr_key(code) + case code + when 30..39, 90..97 + 'foreground' + when 40..49, 100..107 + 'background' + when 58, 59 + 'underline_color' + else + code.to_s + end end end end diff --git a/test/cli/ui/ansi/replay_test.rb b/test/cli/ui/ansi/replay_test.rb index 22dfb428..c8d0e174 100644 --- a/test/cli/ui/ansi/replay_test.rb +++ b/test/cli/ui/ansi/replay_test.rb @@ -134,6 +134,14 @@ def test_embedded_controls_execute_inside_csi_sequences assert_equal("x\nred", Replay.render("x\e[3\n1mred")) end + # Controls embedded after an ESC intermediate do not end its sequence: + # C0 controls execute, DEL is ignored, and the eventual final is still + # consumed. + def test_embedded_controls_do_not_end_escape_intermediate_sequences + assert_equal('az', Replay.render("ab\e(\bBz")) + assert_equal('Az', Replay.render("A\e(\x7fBz")) + end + # A parameter byte arriving after an intermediate puts a terminal in # its ignore state: the sequence is consumed but not executed. def test_out_of_order_csi_bytes_ignore_the_sequence @@ -157,6 +165,8 @@ def test_alternate_screen_content_is_discarded # An unmatched exit and a doubled enter change nothing. assert_equal('ab', Replay.render("a\e[?1049lb")) assert_equal('ab', Replay.render("a\e[?1049h\e[?1049hx\e[?1049lb")) + # DECSET and DECRST apply every mode in a semicolon-separated list. + assert_equal('ab', Replay.render("a\e[?25;1049hLOST\e[?25;1049lb")) end # StdoutRouter's in_alternate_screen re-prints everything captured diff --git a/test/cli/ui/ansi_test.rb b/test/cli/ui/ansi_test.rb index 86c82f19..04f93894 100644 --- a/test/cli/ui/ansi_test.rb +++ b/test/cli/ui/ansi_test.rb @@ -9,16 +9,142 @@ def test_sgr assert_equal("\x1b[1;34m", ANSI.sgr('1;34')) end + def test_hyperlink + assert_equal("\e]8;;https://example.com\e\\text\e]8;;\e\\", ANSI.hyperlink('https://example.com', 'text')) + end + def test_printing_width assert_equal(4, ANSI.printing_width("\x1b[38;2;100;100;100mtest\x1b[0m")) assert_equal(0, ANSI.printing_width('')) - assert_equal(3, ANSI.printing_width('>πŸ”§<')) - assert_equal(1, ANSI.printing_width('πŸ‘©β€πŸ’»')) + # Emoji occupy two columns, matching what Truncater has always + # assumed. A ZWJ sequence is one grapheme cluster, so one emoji. + assert_equal(4, ANSI.printing_width('>πŸ”§<')) + assert_equal(2, ANSI.printing_width('πŸ‘©β€πŸ’»')) + + # Newlines and combining marks occupy no columns. + assert_equal(2, ANSI.printing_width("a\nb")) + assert_equal(1, ANSI.printing_width("e\u0301")) + + # Mixed sequences, emoji, and ASCII in one string. + assert_equal(5, ANSI.printing_width("\e[31m\u{1f527} ok\e[0m")) assert_equal(4, ANSI.printing_width(UI.link('url', 'text'))) end + def test_printing_width_covers_wide_glyphs_beyond_the_core_emoji_block + # BMP emoji outside the U+1F300 block, SMP emoji beyond U+1F5FF, + # and CJK are all two columns wide. + assert_equal(2, ANSI.printing_width('βœ…')) + assert_equal(2, ANSI.printing_width('⭐')) + assert_equal(2, ANSI.printing_width('πŸš€')) + assert_equal(2, ANSI.printing_width('πŸ›’')) + assert_equal(2, ANSI.printing_width('πŸ˜€')) + assert_equal(4, ANSI.printing_width('ζΌ’ε­—')) + + # VS16 asks for emoji presentation: U+26A0 alone is a narrow text + # glyph, but ⚠️ (U+26A0 + VS16) renders two columns wide. This is + # Glyph::WARNING's form. + assert_equal(1, ANSI.printing_width("\u{26a0}")) + assert_equal(2, ANSI.printing_width("\u{26a0}\u{fe0f}")) + + # A flag is two regional indicators forming one wide cluster. + assert_equal(2, ANSI.printing_width('πŸ‡¨πŸ‡¦')) + + # Narrow neighbours of wide ranges stay narrow. + assert_equal(1, ANSI.printing_width('βœ“')) + assert_equal(1, ANSI.printing_width('β­‘')) + end + + def test_ascii_measurement_does_not_tokenize + ANSI.expects(:each_token).never + + assert_equal(5, ANSI.printing_width('plain')) + end + + def test_each_token_rejoins_every_input + rng = Random.new(20260812) + fragments = [ + 'plain', + ' ', + "\n", + 'ζΌ’ε­—', + 'πŸ‘©β€πŸ’»', + "e\u0301", + "\e[31m", + "\e[0m", + "\e[?25l", + "\e[K", + "\e]8;;https://example.com\e\\", + ANSI::HYPERLINK_END, + "\e", + ] + + 500.times do + input = Array.new(rng.rand(0..30)) { fragments.sample(random: rng) }.join + rejoined = ANSI.each_token(input).map { |_kind, token| token }.join + + assert_equal(input, rejoined) + end + end + + def test_each_token_yields_whole_sequences_and_text + tokens = [] + ANSI.each_token("a\e[?25l\e]8;;https://x\e\\b") { |kind, token| tokens << [kind, token] } + assert_equal( + [ + [:text, 'a'], + [:sequence, "\e[?25l"], + [:sequence, "\e]8;;https://x\e\\"], + [:text, 'b'], + ], + tokens, + ) + end + + def test_each_token_without_a_block_returns_an_enumerator + enum = ANSI.each_token("a\e[31mb") + + assert_kind_of(Enumerator, enum) + assert_equal([[:text, 'a'], [:sequence, "\e[31m"], [:text, 'b']], enum.to_a) + assert_equal([], ANSI.each_token('').to_a) + end + + def test_each_token_yields_stray_escape_as_text + tokens = [] + ANSI.each_token("a\eb") { |kind, token| tokens << [kind, token] } + assert_equal([[:text, 'a'], [:text, "\e"], [:text, 'b']], tokens) + end + + def test_each_token_yields_a_trailing_unterminated_sequence_whole + # A sequence sliced open by an upstream cut runs to the end of the + # string with no terminator. It stays one zero-width token instead + # of being counted (and sliced again) as text. + assert_equal([[:text, 'a'], [:sequence, "\e[31"]], ANSI.each_token("a\e[31").to_a) + assert_equal([[:sequence, "\e]8;;http://x"]], ANSI.each_token("\e]8;;http://x").to_a) + + # Mid-string, an unterminated sequence is still text: only at the + # end of the string is a missing terminator unambiguous. + assert_equal( + [[:text, "\e"], [:text, '[31'], [:sequence, "\e[0m"]], + ANSI.each_token("\e[31\e[0m").to_a, + ) + end + + # CSI sequences aren't required to carry parameters (\e[K, \e[m), and + # private-mode sequences mark theirs with ? (\e[?25l). None of them + # print anything. + def test_printing_width_of_parameterless_and_private_sequences_is_zero + assert_equal(1, ANSI.printing_width("\e[?25lx\e[K")) + assert_equal(4, ANSI.printing_width("\e[mtest\e[0m")) + end + + def test_strip_codes_removes_parameterless_and_private_sequences + assert_equal('x', ANSI.strip_codes("\e[?25lx\e[K")) + assert_equal('shown', ANSI.strip_codes("#{ANSI.hide_cursor}shown#{ANSI.show_cursor}")) + assert_equal('saved', ANSI.strip_codes("#{ANSI.cursor_save}saved#{ANSI.cursor_restore}")) + end + def test_strip_codes_preserves_text_between_osc8_hyperlinks hyperlink = CLI::UI.link('https://example.com', 'text', format: false) input = "Before #{hyperlink} after" diff --git a/test/cli/ui/truncater_test.rb b/test/cli/ui/truncater_test.rb index b399face..1c6103cd 100644 --- a/test/cli/ui/truncater_test.rb +++ b/test/cli/ui/truncater_test.rb @@ -22,6 +22,108 @@ def test_truncate assert_example(3, 'AB' + MAN_COOKING, 'AB' + Truncater::TRUNCATED) end + def test_truncate_never_slices_a_sequence + # Private-mode (\x1b[?25l) and parameterless (\x1b[K) sequences pass + # through whole and spend no width; those past the cut are dropped. + assert_example(3, "\x1b[?25lfoobar\x1b[K", "\x1b[?25lfo" + Truncater::TRUNCATED) + end + + def test_truncate_treats_a_trailing_unterminated_sequence_as_a_sequence + # A sequence already sliced open (by an upstream cut, say) spends + # no width: past the cut it drops, and alone it passes unchanged. + assert_example(3, "foobar\x1b]8;;http://x", 'fo' + Truncater::TRUNCATED) + input = "\x1b]8;;http://x no-terminator" + assert_example(5, input, input) + end + + def test_truncate_measures_by_column_not_character + # Each 🌈 is one character but two columns; a character-count + # shortcut would pass these through six columns wide. + assert_example(1, 'πŸ”§', Truncater::TRUNCATED) + assert_example(3, '🌈🌈🌈', '🌈' + Truncater::TRUNCATED) + end + + def test_truncate_cuts_before_a_line_break + # printing_width counts a newline as zero columns, but a truncated + # string must stay one line: the cut lands before the break. + assert_example(3, "ab\ncd", 'ab' + Truncater::TRUNCATED) + assert_example(3, "🌈\ncd", '🌈' + Truncater::TRUNCATED) + end + + def test_truncate_does_not_count_other_zero_width_clusters + zero_width = "\u200b" + ANSI.stubs(:grapheme_width).returns(1) + ANSI.stubs(:grapheme_width).with(zero_width).returns(0) + + assert_example(3, "a#{zero_width}bcde", "a#{zero_width}b" + Truncater::TRUNCATED) + end + + def test_truncate_closes_an_open_hyperlink + link = "\x1b]8;;https://example.com\x1b\\foobar\x1b]8;;\x1b\\" + assert_example( + 3, + link, + "\x1b]8;;https://example.com\x1b\\fo" + ANSI::HYPERLINK_END + Truncater::TRUNCATED, + ) + end + + def test_truncate_does_not_close_an_already_closed_hyperlink + input = "\x1b]8;;u\x1b\\a\x1b]8;;\x1b\\bcdef" + assert_example(3, input, "\x1b]8;;u\x1b\\a\x1b]8;;\x1b\\b" + Truncater::TRUNCATED) + end + + def test_truncate_preserves_non_utf_8_encodings + binary = "\xc3\xa9abcdef".b + binary_result = Truncater.call(binary, 4) + assert_equal("\xc3\xa9a\e[0m?".b, binary_result) + assert_equal(Encoding::ASCII_8BIT, binary_result.encoding) + + latin1 = 'Γ©abcdef'.encode(Encoding::ISO_8859_1) + latin1_result = Truncater.call(latin1, 4) + assert_equal("Γ©ab\e[0m?".encode(Encoding::ISO_8859_1), latin1_result) + assert_equal(Encoding::ISO_8859_1, latin1_result.encoding) + + windows_1252 = 'Γ©abcdef'.encode(Encoding::Windows_1252) + windows_1252_result = Truncater.call(windows_1252, 4) + assert_equal("Γ©ab\e[0m…".encode(Encoding::Windows_1252), windows_1252_result) + assert_equal(Encoding::Windows_1252, windows_1252_result.encoding) + end + + def test_truncate_invariants + rng = Random.new(20260812) + fragments = [ + 'plain', + ' ', + "\n", + 'ζΌ’ε­—', + 'πŸ”§', + "e\u0301", + "\e[31m", + "\e[0m", + "\e[?25l", + "\e[K", + "\e]8;;https://example.com\e\\", + ANSI::HYPERLINK_END, + ] + + 250.times do + input = Array.new(rng.rand(1..20)) { fragments.sample(random: rng) }.join + (1..12).each do |width| + result = Truncater.call(input, width) + + assert_operator(ANSI.printing_width(result), :<=, width) + next if result == input + + ANSI.each_token(result) do |kind, token| + next unless kind == :sequence + + complete = ANSI::CSI_SEQUENCE.match?(token) || ANSI::OSC_SEQUENCE.match?(token) + assert(complete, "truncation left an incomplete sequence: #{token.inspect}") + end + end + end + end + private def assert_example(width, from, to) diff --git a/test/cli/ui/wide_glyph_layout_test.rb b/test/cli/ui/wide_glyph_layout_test.rb new file mode 100644 index 00000000..5b6f9f8b --- /dev/null +++ b/test/cli/ui/wide_glyph_layout_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'test_helper' + +module CLI + module UI + # Locks ANSI's width table into real layout: a wide glyph measured one + # column short would shift every character after it in these strings. + # Expectations are spelled out as exact output rather than measured + # with printing_width, which is the very thing under test. Color and + # cursor movement are disabled so layout arrives as plain text instead + # of repaints. + class WideGlyphLayoutTest < Minitest::Test + def setup + CLI::UI.enable_color = false + CLI::UI.enable_cursor = false + super + end + + def teardown + CLI::UI.enable_color = true + CLI::UI.enable_cursor = true + super + end + + def test_frame_pads_an_emoji_title_like_a_plain_one + Terminal.stubs(:width).returns(20) + + with_emoji = capture_io { Frame.open('πŸš€ go', timing: false) {} }.first.lines.first.chomp + plain = capture_io { Frame.open('ab go', timing: false) {} }.first.lines.first.chomp + + assert_equal('┏━━ πŸš€ go ━━━━━━━━━', with_emoji) + # πŸš€ spans two columns, like 'ab': the rules must line up. + assert_equal('┏━━ ab go ━━━━━━━━━', plain) + end + + def test_table_pads_emoji_cells_by_column + rows = Table.capture_table([['βœ… pass', 'ok'], ['status', 'ok']]) + + assert_equal(['βœ… pass ok', 'status ok'], rows) + end + + def test_spin_group_truncates_a_vs16_glyph_title_by_column + Terminal.stubs(:width).returns(12) + + out, _ = capture_io do + StdoutRouter.ensure_activated + sg = Spinner::SpinGroup.new + sg.add('⚠️ wide glyph title') { true } + sg.wait + end + + # ⚠️ (U+26A0 + VS16) takes two columns, so twelve fill at the g. + assert_equal("βœ“ ⚠️ wide g\e[0m…", out.lines.last.chomp) + end + end + end +end diff --git a/test/cli/ui/wrap_test.rb b/test/cli/ui/wrap_test.rb index 41050913..f5515a84 100644 --- a/test/cli/ui/wrap_test.rb +++ b/test/cli/ui/wrap_test.rb @@ -14,6 +14,85 @@ def test_wrap Terminal.stubs(:width).returns(20) assert_equal(ex, w.wrap) end + + def test_wrap_resends_active_codes_after_a_break + wrapped = Wrap.new("\x1b[31maaaa bbbb cccc").wrap(9) + + assert_equal("\x1b[31maaaa bbbb\n\x1b[31mcccc", wrapped) + end + + def test_wrap_stops_resending_codes_after_a_reset + wrapped = Wrap.new("\x1b[31maaaa\x1b[0m bbbb cccc").wrap(9) + + assert_equal("\x1b[31maaaa\x1b[0m bbbb\ncccc", wrapped) + end + + def test_wrap_detects_a_reset_hidden_in_a_parameter_list + # \e[0;33m resets, then applies 33: earlier codes die at the reset + # and only the survivors are resent after a break. + wrapped = Wrap.new("\x1b[1m\x1b[0;33maaaa bbbb cccc").wrap(9) + + assert_equal("\x1b[1m\x1b[0;33maaaa bbbb\n\x1b[33mcccc", wrapped) + end + + def test_wrap_detects_an_empty_parameter_as_a_reset + # An empty SGR parameter (\e[;m) is a 0 to a terminal. + wrapped = Wrap.new("\x1b[31maaaa\x1b[;m bbbb cccc").wrap(9) + + assert_equal("\x1b[31maaaa\x1b[;m bbbb\ncccc", wrapped) + end + + def test_wrap_tracks_colon_form_sgr_codes + wrapped = Wrap.new("\e[38:2::255:0:0maaaa bbbb cccc").wrap(9) + + assert_equal("\e[38:2::255:0:0maaaa bbbb\n\e[38:2::255:0:0mcccc", wrapped) + end + + def test_wrap_keeps_sgr_replay_bounded + input = 8.times.map { |i| "\e[#{31 + (i % 7)}m#{(97 + i).chr * 4}" }.join(' ') + wrapped = Wrap.new(input).wrap(5) + + assert_equal([2, 2, 2, 2, 2, 2, 2, 1], wrapped.lines.map { |line| line.scan(/\e\[[\d;:]*m/).length }) + assert_operator(wrapped.bytesize, :<, input.bytesize * 2) + end + + def test_wrap_reconstructs_independent_sgr_attributes + wrapped = Wrap.new("\e[1m\e[31maaaa bbbb").wrap(4) + + assert_equal("\e[1m\e[31maaaa\n\e[1;31mbbbb", wrapped) + end + + def test_wrap_groups_semicolon_form_extended_colors + wrapped = Wrap.new("\e[1m\e[38;2;1;2;3maaaa bbbb").wrap(4) + + assert_equal("\e[1m\e[38;2;1;2;3maaaa\n\e[1;38;2;1;2;3mbbbb", wrapped) + end + + def test_wrap_does_not_reinterpret_incomplete_extended_colors + wrapped = Wrap.new("\e[38;2;255maaaa bbbb").wrap(4) + + assert_equal("\e[38;2;255maaaa\nbbbb", wrapped) + end + + def test_wrap_deduplicates_parameters_in_last_used_order + wrapped = Wrap.new("\e[1m\e[22m\e[1maaaa bbbb").wrap(4) + + assert_equal("\e[1m\e[22m\e[1maaaa\n\e[22;1mbbbb", wrapped) + end + + def test_wrap_preserves_distinct_unrecognized_parameters + wrapped = Wrap.new("\e[76m\e[77maaaa bbbb").wrap(4) + + assert_equal("\e[76m\e[77maaaa\n\e[76;77mbbbb", wrapped) + end + + def test_wrap_reopens_a_hyperlink_after_a_break + open_link = "\e]8;;https://example.com\e\\" + close_link = ANSI::HYPERLINK_END + wrapped = Wrap.new("#{open_link}aaaa bbbb cccc#{close_link}").wrap(9) + + assert_equal("#{open_link}aaaa bbbb#{close_link}\n#{open_link}cccc#{close_link}", wrapped) + end end end end