Module: Fatty::Ansi

Defined in:
lib/fatty/ansi.rb,
lib/fatty/ansi.rb,
lib/fatty/ansi/renderer.rb

Overview

Parse ANSI escape sequences (primarily SGR, i.e. "\e[...m") into styled text segments. This is intentionally curses-agnostic: the renderer/context decides how to map Style -> terminal attributes / color pairs.

Supported SGR:

  • 0 reset
  • 1 bold
  • 22 normal intensity (clears bold)
  • 7 reverse
  • 27 reverse off
  • 30-37 / 90-97 foreground (16-color)
  • 40-47 / 100-107 background (16-color)
  • 38;5;n foreground (256-color index)
  • 48;5;n background (256-color index)

Everything else is ignored (but does not break parsing).

Defined Under Namespace

Classes: Renderer, Style

Constant Summary collapse

ESC =
"\e"
CSI =
"#{ESC}["
COMBINING_MARK_RE =
/\p{M}/
ANSI_ESCAPE =
%r{
  \e\[ [0-9;?]* [A-Za-z]   | # CSI sequences
  \e\] .*? (?:\a|\e\\)     | # OSC sequences
  \e[@-Z\\-_]                # single-char escapes
}x

Class Method Summary collapse

Class Method Details

.plain_text(str) ⇒ Object



307
308
309
# File 'lib/fatty/ansi.rb', line 307

def self.plain_text(str)
  segment(str).map { |text, _style| text }.join
end

.segment(str, base: nil) ⇒ Object

Public: segment a string into [[text, Style], ...]

The returned Style objects are independent copies; you can safely mutate them downstream if you want.



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/fatty/ansi.rb', line 91

def self.segment(str, base: nil)
  s = str.to_s
  style = (base ? base.dup : Style.default).normalize!

  out = []
  buf = +""

  i = 0
  n = s.bytesize
  while i < n
    if s.getbyte(i) == 27 # ESC
      # Flush buffered text before processing escape
      unless buf.empty?
        out << [buf, style.dup]
        buf = +""
      end

      consumed = consume_escape!(s, i, style)
      if consumed > 0
        i += consumed
      else
        # Not a recognized/complete escape; treat ESC as literal.
        buf << ESC
        i += 1
      end
    else
      buf << s.byteslice(i, 1)
      i += 1
    end
  end

  out << [buf, style.dup] unless buf.empty?
  merge_adjacent_segments(out)
end

.strip(text) ⇒ Object

Remove any ANSI escape sequences from text.



83
84
85
# File 'lib/fatty/ansi.rb', line 83

def self.strip(text)
  text.to_s.gsub(ANSI_ESCAPE, "")
end

.truncate_visible(text, max_width) ⇒ Object



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/fatty/ansi.rb', line 326

def self.truncate_visible(text, max_width)
  max_width = max_width.to_i
  return "" if max_width <= 0

  out = +""
  visible = 0
  scanner = StringScanner.new(text.to_s)

  until scanner.eos? || visible >= max_width
    if (esc = scanner.scan(ANSI_ESCAPE))
      out << esc
    else
      ch = scanner.getch
      width = visible_length(ch)
      break if visible + width > max_width

      out << ch
      visible += width
    end
  end
  out
end

.visible_char?(ch) ⇒ Boolean

Don't count a "combining character" as visible in the sense that it contributes to width. It overlays the prior character so it does not add to the visible width.

Returns:

  • (Boolean)


322
323
324
# File 'lib/fatty/ansi.rb', line 322

def self.visible_char?(ch)
  !ch.match?(COMBINING_MARK_RE)
end

.visible_length(str) ⇒ Object

Return the screen width taken up by an ANSI-encoded sequence, taking into account Unicode combining characters.



313
314
315
316
317
# File 'lib/fatty/ansi.rb', line 313

def self.visible_length(str)
  segment(str.to_s).sum do |segment_text, _style|
    segment_text.each_char.count { |ch| visible_char?(ch) }
  end
end