Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/cli/ui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 68 additions & 19 deletions lib/cli/ui/ansi.rb
Original file line number Diff line number Diff line change
@@ -1,40 +1,82 @@
# typed: true
# frozen_string_literal: true

require 'strscan'

module CLI
module UI
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;[^;]*;(?<uri>.*)(?:\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)
TEXT_RUN = /[^\x1b]+/
# EMOJI_RANGE 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

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 stray ESC heading no
# well-formed sequence is yielded as text.
#
#: (String str) { (Symbol kind, String token) -> void } -> void
def each_token(str, &block)
scanner = StringScanner.new(str)
until scanner.eos?
if (sequence = scanner.scan(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
else
acc + 1
width = 0 #: Integer
each_token(str) do |kind, token|
next unless kind == :text

token.grapheme_clusters.each do |cluster|
width += grapheme_width(cluster)
end
end
width
end

# The number of terminal columns one grapheme cluster occupies:
# none for a newline, two for emoji, one for everything else. Still
# a basic version of the width tables something like utf8proc would
# give us for real (wide CJK characters, for one, are counted 1).
#
#: (String cluster) -> Integer
def grapheme_width(cluster)
case cluster
when "\n", "\r", "\r\n"
0
else
EMOJI_RANGE.cover?(cluster.ord) ? 2 : 1
end
end

# Strips ANSI codes from a str
Expand Down Expand Up @@ -66,6 +108,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
Expand Down
109 changes: 33 additions & 76 deletions lib/cli/ui/truncater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,74 +5,43 @@ 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

width = 0
mode = PARSE_ROOT
truncation_index = nil #: Integer?

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
# 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 #: Integer
truncated = false #: bool
open_hyperlink = false #: bool
prefix = +''

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_ESC
mode = case cp
when LEFT_SQUARE_BRACKET
PARSE_ANSI
else
PARSE_ROOT
when :text
token.grapheme_clusters.each do |cluster|
width += ANSI.grapheme_width(cluster)
# 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_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
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

Expand All @@ -81,22 +50,10 @@ 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

slice = codepoints[0...truncation_index] #: as !nil
slice.pack('U*') + TRUNCATED
end
return text if !truncated || width <= printing_width

private

#: (Integer printable_codepoint) -> Integer
def width(printable_codepoint)
case printable_codepoint
when EMOJI_RANGE
2
else
1
end
prefix << ANSI::HYPERLINK_END if open_hyperlink
prefix << TRUNCATED
end
end
end
Expand Down
81 changes: 50 additions & 31 deletions lib/cli/ui/wrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
module CLI
module UI
class Wrap
SGR_RESET = /\A\x1b\[0?m\z/
# 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
Expand All @@ -14,43 +19,57 @@ 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.
codes = +''
open_hyperlink = nil #: String?
break_line = -> do
final << ANSI::HYPERLINK_END if open_hyperlink
final << "\n" << codes << open_hyperlink.to_s
width = 0
end

ANSI.each_token(@input) do |kind, token|
if kind == :sequence
case token
when SGR_RESET
codes = +''
when SGR
codes << token
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
end
end
Expand Down
Loading
Loading