Module: Kward::ANSI
- Defined in:
- lib/kward/terminal/ansi.rb
Overview
ANSI SGR styling and terminal-text helpers.
Terminal control output sequences live in TerminalSequences, and input key
sequences live in TerminalKeys. This module owns text-level concerns:
colorizing strings, stripping/sanitizing escape sequences, visible wrapping,
and lightweight Markdown rendering for terminal output.
Defined Under Namespace
Classes: MarkdownStream
Constant Summary collapse
- SGR_PATTERN =
/\e\[[0-9;:]*m/.freeze
- STYLES =
{ reset: 0, bold: 1, dim: 2, italic: 3, strikethrough: 9, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, gray: 90, grey: 90, white: 97, activity: 36, success: 32, caution: 33, failure: 31, tool: 35, metadata: 90, primary_green: "38;2;138;160;106", bright_accent_green: "38;2;155;255;0", augen: "38;2;155;255;0", dark_forest_green: "38;2;78;88;53", stone: "38;2;196;192;178", metal_dark: "38;2;42;42;42", background: "38;2;22;24;22" }.freeze
Class Method Summary collapse
- .blockquote(text, enabled: enabled?) ) ⇒ Object
- .colorize(text, *styles, enabled: enabled?) ) ⇒ Object
- .disabled_color?(env) ⇒ Boolean
- .enabled?(output = $stdout, env: ENV) ⇒ Boolean
- .escape_sequence_at(string, index) ⇒ Object
- .forced_color?(env) ⇒ Boolean
- .inline_bold(text, enabled: enabled?) ) ⇒ Object
- .inline_code(line, enabled: enabled?) ) ⇒ Object
- .inline_emphasis(text, enabled: enabled?) ) ⇒ Object
- .inline_italic(text, enabled: enabled?) ) ⇒ Object
- .inline_links(text, enabled: enabled?) ) ⇒ Object
- .inline_markdown(line, enabled: enabled?) ) ⇒ Object
- .inline_strikethrough(text, enabled: enabled?) ) ⇒ Object
- .markdown(text, enabled: enabled?) ) ⇒ Object
- .markdown_heading(marker, text, enabled: enabled?) ) ⇒ Object
- .markdown_line(line, enabled: enabled?) ) ⇒ Object
- .normalize_transcript_encoding(text) ⇒ Object
-
.sanitize_transcript(text) ⇒ Object
Drops unsafe terminal controls from transcript text while preserving SGR color.
-
.scan_escape_tokens(text) ⇒ Object
Splits text into visible chunks and terminal escape sequence chunks.
- .strip(text) ⇒ Object
-
.strip_control_sequences(text) ⇒ Object
Removes terminal escape/control sequences while preserving visible text.
- .task_list_item(indent, marker, text, enabled: enabled?) ) ⇒ Object
- .wrap_visible(text, width) ⇒ Object
Class Method Details
.blockquote(text, enabled: enabled?) ) ⇒ Object
359 360 361 |
# File 'lib/kward/terminal/ansi.rb', line 359 def blockquote(text, enabled: enabled?) "#{colorize("│", :gray, enabled: enabled)} #{inline_markdown(text, enabled: enabled)}" end |
.colorize(text, *styles, enabled: enabled?) ) ⇒ Object
53 54 55 56 57 58 59 60 61 |
# File 'lib/kward/terminal/ansi.rb', line 53 def colorize(text, *styles, enabled: enabled?) string = text.to_s return string unless enabled codes = styles.flatten.map { |style| STYLES.fetch(style, style) }.compact return string if codes.empty? "\e[#{codes.join(";")}m#{string}\e[0m" end |
.disabled_color?(env) ⇒ Boolean
420 421 422 423 424 425 |
# File 'lib/kward/terminal/ansi.rb', line 420 def disabled_color?(env) return true if env.key?("NO_COLOR") && !env["NO_COLOR"].to_s.empty? return true if env["CLICOLOR"] == "0" env["TERM"] == "dumb" end |
.enabled?(output = $stdout, env: ENV) ⇒ Boolean
43 44 45 46 47 48 49 50 51 |
# File 'lib/kward/terminal/ansi.rb', line 43 def enabled?(output = $stdout, env: ENV) setting = env["KWARD_COLOR"].to_s.downcase return true if %w[always force forced true yes 1].include?(setting) return false if %w[never false no 0].include?(setting) return true if forced_color?(env) return false if disabled_color?(env) output.respond_to?(:tty?) && output.tty? end |
.escape_sequence_at(string, index) ⇒ Object
145 146 147 148 149 150 151 |
# File 'lib/kward/terminal/ansi.rb', line 145 def escape_sequence_at(string, index) chunk = string[index..] chunk.match(/\A\e\][^\a]*(?:\a|\e\\)/m)&.[](0) || chunk.match(/\A\e[P_X^][\s\S]*?\e\\/m)&.[](0) || chunk.match(/\A\e\[[0-9;:?]*[ -\/]*[@-~]/)&.[](0) || chunk[0, 2] end |
.forced_color?(env) ⇒ Boolean
414 415 416 417 418 |
# File 'lib/kward/terminal/ansi.rb', line 414 def forced_color?(env) force_color = env["FORCE_COLOR"] clicolor_force = env["CLICOLOR_FORCE"] (force_color && force_color != "0") || (clicolor_force && clicolor_force != "0") end |
.inline_bold(text, enabled: enabled?) ) ⇒ Object
389 390 391 392 393 |
# File 'lib/kward/terminal/ansi.rb', line 389 def inline_bold(text, enabled: enabled?) text.gsub(/\*\*([^\n]+?)\*\*/) do colorize(Regexp.last_match(1), :bold, enabled: enabled) end end |
.inline_code(line, enabled: enabled?) ) ⇒ Object
410 411 412 |
# File 'lib/kward/terminal/ansi.rb', line 410 def inline_code(line, enabled: enabled?) inline_markdown(line, enabled: enabled) end |
.inline_emphasis(text, enabled: enabled?) ) ⇒ Object
383 384 385 386 387 |
# File 'lib/kward/terminal/ansi.rb', line 383 def inline_emphasis(text, enabled: enabled?) rendered = inline_bold(text, enabled: enabled) rendered = inline_strikethrough(rendered, enabled: enabled) inline_italic(rendered, enabled: enabled) end |
.inline_italic(text, enabled: enabled?) ) ⇒ Object
401 402 403 404 405 406 407 408 |
# File 'lib/kward/terminal/ansi.rb', line 401 def inline_italic(text, enabled: enabled?) rendered = text.gsub(/(^|[\s\(\[{])\*([^*\n]+?)\*(?=$|[\s\)\]},.!?:;])/) do "#{Regexp.last_match(1)}#{colorize(Regexp.last_match(2), :italic, enabled: enabled)}" end rendered.gsub(/(^|[\s\(\[{])_([^_\n]+?)_(?=$|[\s\)\]},.!?:;])/) do "#{Regexp.last_match(1)}#{colorize(Regexp.last_match(2), :italic, enabled: enabled)}" end end |
.inline_links(text, enabled: enabled?) ) ⇒ Object
373 374 375 376 377 378 379 380 381 |
# File 'lib/kward/terminal/ansi.rb', line 373 def inline_links(text, enabled: enabled?) text.split(/(\[[^\n\]]+\]\([^)\s]+\))/).map do |part| if (match = part.match(/\A\[([^\n\]]+)\]\(([^)\s]+)\)\z/)) "#{colorize(match[1], :cyan, enabled: enabled)} (#{colorize(match[2], :dim, enabled: enabled)})" else inline_emphasis(part, enabled: enabled) end end.join end |
.inline_markdown(line, enabled: enabled?) ) ⇒ Object
363 364 365 366 367 368 369 370 371 |
# File 'lib/kward/terminal/ansi.rb', line 363 def inline_markdown(line, enabled: enabled?) line.to_s.split(/(`[^`\n]+`)/).map do |part| if part.start_with?("`") && part.end_with?("`") && part.length > 1 "`#{colorize(part[1...-1], :dim, enabled: enabled)}`" else inline_links(part, enabled: enabled) end end.join end |
.inline_strikethrough(text, enabled: enabled?) ) ⇒ Object
395 396 397 398 399 |
# File 'lib/kward/terminal/ansi.rb', line 395 def inline_strikethrough(text, enabled: enabled?) text.gsub(/~~([^\n]+?)~~/) do colorize(Regexp.last_match(1), :strikethrough, enabled: enabled) end end |
.markdown(text, enabled: enabled?) ) ⇒ Object
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 |
# File 'lib/kward/terminal/ansi.rb', line 153 def markdown(text, enabled: enabled?) string = text.to_s lines = string.lines(chomp: true) rendered = [] in_fence = false lines.each do |line| if (match = line.match(/\A\s*```([^`]*)\s*\z/)) if in_fence rendered << colorize("└" + "─" * 39, :gray, enabled: enabled) in_fence = false else language = match[1].to_s.strip label = language.empty? ? "code" : "code #{language}" rendered << colorize("┌─ #{label}", :gray, enabled: enabled) in_fence = true end next end if in_fence rendered << colorize("│ #{line}", :dim, enabled: enabled) else rendered << markdown_line(line, enabled: enabled) end end rendered << colorize("└" + "─" * 39, :gray, enabled: enabled) if in_fence rendered.join("\n") + (string.end_with?("\n") ? "\n" : "") end |
.markdown_heading(marker, text, enabled: enabled?) ) ⇒ Object
349 350 351 |
# File 'lib/kward/terminal/ansi.rb', line 349 def markdown_heading(marker, text, enabled: enabled?) "#{marker}#{colorize(text, :bold, enabled: enabled)}" end |
.markdown_line(line, enabled: enabled?) ) ⇒ Object
337 338 339 340 341 342 343 344 345 346 347 |
# File 'lib/kward/terminal/ansi.rb', line 337 def markdown_line(line, enabled: enabled?) if (match = line.match(/\A(\#{1,6}\s+)(.+)\z/)) markdown_heading(match[1], match[2], enabled: enabled) elsif (match = line.match(/\A(\s*)[-*]\s+\[([ xX])\]\s+(.+)\z/)) task_list_item(match[1], match[2], match[3], enabled: enabled) elsif (match = line.match(/\A>\s?(.*)\z/)) blockquote(match[1], enabled: enabled) else inline_markdown(line, enabled: enabled) end end |
.normalize_transcript_encoding(text) ⇒ Object
85 86 87 88 89 90 91 |
# File 'lib/kward/terminal/ansi.rb', line 85 def normalize_transcript_encoding(text) string = text.to_s.dup return string unless string.encoding == Encoding::ASCII_8BIT string.force_encoding(Encoding::UTF_8) string.valid_encoding? ? string : string.scrub end |
.sanitize_transcript(text) ⇒ Object
Drops unsafe terminal controls from transcript text while preserving SGR color.
75 76 77 78 79 80 81 82 83 |
# File 'lib/kward/terminal/ansi.rb', line 75 def sanitize_transcript(text) scan_escape_tokens(normalize_transcript_encoding(text)).each_with_object(+"") do |token, sanitized| if token[:escape] sanitized << token[:text] if token[:text].match?(SGR_PATTERN) else sanitized << token[:text] end end end |
.scan_escape_tokens(text) ⇒ Object
Splits text into visible chunks and terminal escape sequence chunks.
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/kward/terminal/ansi.rb', line 127 def scan_escape_tokens(text) string = text.to_s tokens = [] index = 0 while index < string.length if string[index] == "\e" && (escape = escape_sequence_at(string, index)) tokens << { text: escape, escape: true } index += escape.length next end next_escape = string.index("\e", index) || string.length tokens << { text: string[index...next_escape], escape: false } if next_escape > index index = next_escape end tokens end |
.strip(text) ⇒ Object
63 64 65 |
# File 'lib/kward/terminal/ansi.rb', line 63 def strip(text) strip_control_sequences(text) end |
.strip_control_sequences(text) ⇒ Object
Removes terminal escape/control sequences while preserving visible text.
68 69 70 71 72 |
# File 'lib/kward/terminal/ansi.rb', line 68 def strip_control_sequences(text) scan_escape_tokens(text).each_with_object(+"") do |token, stripped| stripped << token[:text] unless token[:escape] end end |
.task_list_item(indent, marker, text, enabled: enabled?) ) ⇒ Object
353 354 355 356 357 |
# File 'lib/kward/terminal/ansi.rb', line 353 def task_list_item(indent, marker, text, enabled: enabled?) checked = marker.downcase == "x" box = checked ? colorize("☑", :green, enabled: enabled) : colorize("☐", :gray, enabled: enabled) "#{indent}#{box} #{inline_markdown(text, enabled: enabled)}" end |
.wrap_visible(text, width) ⇒ Object
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/kward/terminal/ansi.rb', line 93 def wrap_visible(text, width) line_width = [width.to_i, 1].max rows = [] current = +"" visible_width = 0 scan_escape_tokens(text).each do |token| if token[:escape] next unless token[:text].match?(SGR_PATTERN) if current.empty? && rows.any? rows[-1] << token[:text] else current << token[:text] end next end token[:text].each_char do |char| current << char visible_width += 1 if visible_width >= line_width rows << current current = +"" visible_width = 0 end end end rows << current unless current.empty? rows end |