Module: OKF::TUI::Ui
- Defined in:
- lib/okf/tui/ui.rb
Overview
Layout primitives. Everything the views draw goes through here, because the one thing that breaks a composed terminal UI is a line whose display width disagrees with its String#length — which is exactly what happens the moment colour is involved. So width is always measured on the ANSI-stripped text, and colour is only ever applied to a segment already clipped to fit.
Defined Under Namespace
Classes: Line
Constant Summary collapse
- ANSI =
/\e\[[0-9;]*[a-zA-Z]/.freeze
- PASTEL =
Pastel disables colour when stdout is not a terminal, which is right for a pipe but means a captured frame exercises none of the ANSI paths the layout depends on. FORCE_COLOR=1 turns it back on so those can be checked.
Pastel.new(enabled: ENV["FORCE_COLOR"] ? true : nil)
- TABULAR =
Box-drawing glyphs, i.e. a rendered table or code fence. Re-flowing one of those destroys the alignment that carries its meaning, so such a row is clipped instead of wrapped.
/[┌┬┐├┼┤└┴┘─│┃━╭╮╰╯]/.freeze
- LIST_START =
A line that opens a list item — a boundary reflow must not cross.
/\A\s*(?:[•▪◦*+-]|\d+[.)])\s/.freeze
Class Method Summary collapse
- .blank_line(limit) ⇒ Object
-
.box(rows, width:, height:, title: nil, active: false) ⇒ Object
A framed pane.
-
.clip(string, limit) ⇒ Object
Clip a plain string to at most
limitcolumns, ellipsizing when it does not fit. -
.clip_ansi(string, limit) ⇒ Object
Clip a string that already carries colour.
-
.fit_block(rows, width:, height:) ⇒ Object
Force a list of rows to exactly
heightrows of exactlywidthcolumns — the invariant every pane must satisfy before it can be joined to another. -
.hjoin(*panes) ⇒ Object
Join panes side by side, row for row.
- .line(limit) {|row| ... } ⇒ Object
- .pastel ⇒ Object
-
.reflow(lines, limit) ⇒ Object
Re-flow rendered markdown to
limitcolumns. -
.reset_if_styled(row) ⇒ Object
Close a row that opened a colour, so the style cannot bleed into the pane beside it.
- .title_label(title, active) ⇒ Object
-
.width(string) ⇒ Object
Display columns a string occupies, ignoring colour escapes.
-
.words(line) ⇒ Object
Split a line into words, each carrying the escape sequences that preceded it so styling survives the re-flow.
-
.wrap_ansi(string, limit) ⇒ Object
Word-wrap a line that may already carry colour, into rows of at most
limitcolumns.
Class Method Details
.blank_line(limit) ⇒ Object
203 204 205 |
# File 'lib/okf/tui/ui.rb', line 203 def blank_line(limit) " " * [ limit, 0 ].max end |
.box(rows, width:, height:, title: nil, active: false) ⇒ Object
A framed pane. TTY::Box draws the border; we hand it content that is already clipped to the inner width so its own padding never has to guess.
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/okf/tui/ui.rb', line 284 def box(rows, width:, height:, title: nil, active: false) inner_width = width - 2 inner_height = height - 2 body = fit_block(rows, width: inner_width, height: inner_height) border_fg = active ? :cyan : :bright_black titles = title ? { top_left: title_label(title, active) } : {} frame = TTY::Box.frame( width: width, height: height, border: { type: active ? :thick : :light }, style: { border: { fg: border_fg } }, title: titles ) { body.join("\n") } frame.lines.map(&:chomp) end |
.clip(string, limit) ⇒ Object
Clip a plain string to at most limit columns, ellipsizing when it does
not fit. Never called on coloured text — see Line.
44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/okf/tui/ui.rb', line 44 def clip(string, limit) string = string.to_s.tr("\t", " ").delete("\n") return "" if limit <= 0 return string if width(string) <= limit return "…" if limit == 1 out = +"" string.each_char do |char| break if width(out) + width(char) > limit - 1 out << char end "#{out}…" end |
.clip_ansi(string, limit) ⇒ Object
Clip a string that already carries colour. Escape sequences cost no columns and are copied through, so the result keeps its styling and is cut only on visible characters — a plain clip would slice an escape in half and leave the rest of the screen wearing whatever colour it opened.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/okf/tui/ui.rb', line 63 def clip_ansi(string, limit) return clip(string, limit) unless string.match?(ANSI) out = +"" spent = 0 scanner = string.to_s.scan(/\e\[[0-9;]*[a-zA-Z]|./m) scanner.each do |token| if token.start_with?("\e") out << token next end break if spent + width(token) > limit out << token spent += width(token) end reset_if_styled(out) end |
.fit_block(rows, width:, height:) ⇒ Object
Force a list of rows to exactly height rows of exactly width columns —
the invariant every pane must satisfy before it can be joined to another.
Both directions matter: a short row leaves the pane beside it smeared
across the gap, and a long one wraps onto the next terminal row and pushes
the whole frame down.
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/okf/tui/ui.rb', line 257 def fit_block(rows, width:, height:) rows = rows.first(height) rows += [ blank_line(width) ] * (height - rows.length) rows.map do |row| spent = Ui.width(row) if spent < width row + (" " * (width - spent)) elsif spent > width clip_ansi(row, width) else row end end end |
.hjoin(*panes) ⇒ Object
Join panes side by side, row for row. Each pane must already be a rectangle (see fit_block), which is what makes this a plain zip.
275 276 277 278 279 280 |
# File 'lib/okf/tui/ui.rb', line 275 def hjoin(*panes) height = panes.map(&:length).max.to_i Array.new(height) do |index| panes.map { |pane| pane[index].to_s }.join end end |
.line(limit) {|row| ... } ⇒ Object
246 247 248 249 250 |
# File 'lib/okf/tui/ui.rb', line 246 def line(limit) row = Line.new(limit) yield row if block_given? row.to_s end |
.pastel ⇒ Object
27 28 29 |
# File 'lib/okf/tui/ui.rb', line 27 def pastel PASTEL end |
.reflow(lines, limit) ⇒ Object
Re-flow rendered markdown to limit columns.
Wrapping alone is not enough: the bodies are authored at ~80 columns and tty-markdown keeps those hard breaks, so wrapping each line in isolation leaves a short orphan after every one it splits. Consecutive prose lines are therefore joined back into a paragraph and wrapped as a unit. Blank lines, tables and list items end a paragraph, which is what keeps headings and structure from being swallowed into the prose beneath them.
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/okf/tui/ui.rb', line 167 def reflow(lines, limit) out = [] paragraph = [] flush = lambda do next if paragraph.empty? indent = paragraph.first[/\A */] out.concat(wrap_ansi(indent + paragraph.map(&:strip).join(" "), limit)) paragraph = [] end lines.each do |line| line = line.chomp plain = line.gsub(ANSI, "") if plain.strip.empty? || plain.match?(TABULAR) || plain.match?(LIST_START) flush.call out.concat(wrap_ansi(line, limit)) else paragraph << line end end flush.call out end |
.reset_if_styled(row) ⇒ Object
Close a row that opened a colour, so the style cannot bleed into the pane beside it. A row carrying no escapes needs no reset — and withholding it there keeps uncoloured output byte-clean, which is what makes a captured frame worth diffing.
199 200 201 |
# File 'lib/okf/tui/ui.rb', line 199 def reset_if_styled(row) row.match?(ANSI) ? "#{row}\e[0m" : row end |
.title_label(title, active) ⇒ Object
303 304 305 306 |
# File 'lib/okf/tui/ui.rb', line 303 def title_label(title, active) label = " #{title} " active ? pastel.decorate(label, :black, :on_cyan, :bold) : pastel.decorate(label, :bright_white, :bold) end |
.width(string) ⇒ Object
Display columns a string occupies, ignoring colour escapes.
32 33 34 35 36 37 38 39 40 |
# File 'lib/okf/tui/ui.rb', line 32 def width(string) plain = string.to_s.gsub(ANSI, "") if defined?(Unicode::DisplayWidth) Unicode::DisplayWidth.of(plain) else plain.length end end |
.words(line) ⇒ Object
Split a line into words, each carrying the escape sequences that preceded it so styling survives the re-flow.
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/okf/tui/ui.rb', line 133 def words(line) out = [] pending = [] current = +"" line.scan(/\e\[[0-9;]*[a-zA-Z]|\s+|[^\s\e]+/) do |token| if token.start_with?("\e") current.empty? ? pending << token : (out << { escapes: pending, text: current }; pending = [ token ]; current = +"") elsif token.strip.empty? unless current.empty? out << { escapes: pending, text: current } pending = [] current = +"" end else current << token end end out << { escapes: pending, text: current } unless current.empty? out end |
.wrap_ansi(string, limit) ⇒ Object
Word-wrap a line that may already carry colour, into rows of at most
limit columns. tty-markdown keeps the source's own hard line breaks
rather than reflowing to the width it is given, so bodies authored at 80
columns have to be re-wrapped here to fit a narrower pane.
Escapes cost no columns and ride along with the word they precede; the style open at a break is re-opened on the next row so a colour spanning a wrap does not stop halfway.
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 125 126 127 128 129 |
# File 'lib/okf/tui/ui.rb', line 98 def wrap_ansi(string, limit) line = string.to_s.chomp return [ clip_ansi(line, limit) ] if limit <= 0 || line.match?(TABULAR) return [ line ] if width(line) <= limit indent = " " * [ line[/\A */].length, [ limit - 8, 0 ].max ].min rows = [] current = +"" spent = 0 style = nil words(line).each do |word| visible = width(word[:text]) next if visible.zero? && word[:escapes].empty? if spent.positive? && spent + 1 + visible > limit rows << reset_if_styled(current) current = +"#{indent}#{style}" spent = width(indent) elsif spent > width(indent) current << " " spent += 1 end style = word[:escapes].last if word[:escapes].any? { |code| code != "\e[0m" } current << word[:escapes].join << word[:text] spent += visible end rows << reset_if_styled(current) unless current.strip.empty? rows.empty? ? [ "" ] : rows end |