Module: Charming::Presentation::UI
- Defined in:
- lib/charming/presentation/ui.rb,
lib/charming/presentation/ui/style.rb,
lib/charming/presentation/ui/theme.rb,
lib/charming/presentation/ui/width.rb,
lib/charming/presentation/ui/border.rb
Overview
UI is a module of layout primitives for composing and positioning ANSI-styled terminal text. It provides functions to join blocks horizontally or vertically, place content on fixed-size canvases, overlay elements, and slice strings that contain ANSI escape sequences while preserving their styling.
Defined Under Namespace
Modules: Width Classes: Border, Style, Theme
Class Method Summary collapse
-
.block_height(blocks) ⇒ Object
Returns the height in rows of each normalised block, taking the maximum across all blocks.
-
.block_width(lines) ⇒ Object
Returns the maximum visual character width across all lines, accounting for multi-column characters (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
-
.block_widths(blocks) ⇒ Object
Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
-
.center(block, width:, height:, background: nil) ⇒ Object
Centers a block within a canvas of the given width and height, then returns the result.
-
.composed_overlay_line(base_line, overlay_line, column, width) ⇒ Object
Merges an overlay_line into a base_line at the given column, returning the combined string.
-
.draw_lines(canvas, lines, row:, column:, width:) ⇒ Object
Overlays lines onto a canvas starting at (row, column), writing each overlaid line into the canvas via ‘composed_overlay_line`.
-
.each_ansi_or_char(line) ⇒ Object
Splits a line into token-range pieces bounded by start_column and end_column, preserving ANSI escapes that fall within the visible range.
-
.horizontal_line(blocks, widths, index) ⇒ Object
Builds a single horizontal row by concatenating one line from each block at index index, padding every segment to its corresponding width in spaces.
-
.join_horizontal(*blocks, gap: 0) ⇒ Object
Horizontally concatenates blocks into a single multi-line string, padding each block’s rows to match the widest row.
-
.join_vertical(*blocks, gap: 0) ⇒ Object
Stacks blocks vertically separated by one or more blank lines.
-
.normalize_blocks(blocks) ⇒ Object
Normalizes an array of mixed objects into arrays of lines by calling ‘#to_s` on each element.
-
.offset(value, available, size) ⇒ Object
Computes a placement coordinate: if value is ‘:center` the result centres the size within available; otherwise value is returned verbatim as an absolute integer position.
-
.overlay(base, overlay, top: :center, left: :center) ⇒ Object
Draws overlay on top of a base at the specified top (row) and left (column) coordinates, defaulting to center in both directions.
-
.place(block, width:, height:, top: 0, left: 0, background: nil) ⇒ Object
Places a block onto a blank canvas of width × height at an offset determined by top (row) and left (column).
-
.slice_ansi(token, state, start_column, end_column) ⇒ Object
Slices an ANSI token (escape sequence) into state, writing active markers to the output if the current column falls within the [start_column, end_column) range.
-
.slice_char(char, state, start_column, end_column) ⇒ Object
Slices a plain char into state, advancing the column tracker by the character’s visual width.
-
.slice_visible_text(line, start_column, end_column) ⇒ Object
Slices a string by visible terminal columns while preserving ANSI style state.
-
.start_slice(state) ⇒ Object
Starts writing to the output buffer, flushing any active ANSI markers if this is the first character placed.
-
.style ⇒ Object
Builds a new Style instance for chaining color, padding, alignment, and other visual properties.
-
.terminate_slice(state) ⇒ Object
Closes the slice by appending a final ‘[0m` reset escape to the output unless no active styling exists or nothing was written. Returns the fully constructed output string with trailing reset applied..
-
.update_active_styles(active, token) ⇒ Object
Updates state[:active] with an ANSI token: resets all active styles on ‘[0m` or appends the token as a new active marker otherwise. Called during each_ansi_or_char iteration..
-
.visible_slice(line, start_column, width) ⇒ Object
Returns a visible-slice of line starting at start_column spanning width characters, preserving any ANSI escape sequences that were active at the start of the slice.
Class Method Details
.block_height(blocks) ⇒ Object
Returns the height in rows of each normalised block, taking the maximum across all blocks.
84 85 86 |
# File 'lib/charming/presentation/ui.rb', line 84 def block_height(blocks) blocks.map(&:length).max || 0 end |
.block_width(lines) ⇒ Object
Returns the maximum visual character width across all lines, accounting for multi-column characters (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
79 80 81 |
# File 'lib/charming/presentation/ui.rb', line 79 def block_width(lines) lines.map { |line| Width.measure(line) }.max || 0 end |
.block_widths(blocks) ⇒ Object
Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
73 74 75 |
# File 'lib/charming/presentation/ui.rb', line 73 def block_widths(blocks) blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 } end |
.center(block, width:, height:, background: nil) ⇒ Object
Centers a block within a canvas of the given width and height, then returns the result.
36 37 38 |
# File 'lib/charming/presentation/ui.rb', line 36 def center(block, width:, height:, background: nil) place(block, width: width, height: height, top: :center, left: :center, background: background) end |
.composed_overlay_line(base_line, overlay_line, column, width) ⇒ Object
Merges an overlay_line into a base_line at the given column, returning the combined string. The overlay replaces (covers) underlying characters; anything to the right that exceeds width is truncated.
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/charming/presentation/ui.rb', line 107 def (base_line, , column, width) return visible_slice(base_line, 0, width) if column >= width return visible_slice(base_line, 0, width) if column + Width.measure() <= 0 target_column = [column, 0].max = [0 - column, 0].max = visible_slice(, , width - target_column) = Width.measure() return visible_slice(base_line, 0, width) if .zero? right_column = target_column + visible_slice(base_line, 0, target_column) + + visible_slice(base_line, right_column, [width - right_column, 0].max) end |
.draw_lines(canvas, lines, row:, column:, width:) ⇒ Object
Overlays lines onto a canvas starting at (row, column), writing each overlaid line into the canvas via ‘composed_overlay_line`. Returns the final canvas joined by newlines.
220 221 222 223 224 225 226 227 228 229 |
# File 'lib/charming/presentation/ui.rb', line 220 def draw_lines(canvas, lines, row:, column:, width:) lines.each_with_index do |line, index| line_index = row + index next if line_index.negative? || line_index >= canvas.length canvas[line_index] = (canvas[line_index], line, column, width) end canvas.join("\n") end |
.each_ansi_or_char(line) ⇒ Object
Splits a line into token-range pieces bounded by start_column and end_column, preserving ANSI escapes that fall within the visible range. Yields each character or escape sequence along with whether it is ANSI.
149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/charming/presentation/ui.rb', line 149 def each_ansi_or_char(line) index = 0 while index < line.length match = line.match(Width::ANSI_PATTERN, index) if match&.begin(0) == index yield match[0], true index = match.end(0) else char = line[index] yield char, false index += 1 end end end |
.horizontal_line(blocks, widths, index) ⇒ Object
Builds a single horizontal row by concatenating one line from each block at index index, padding every segment to its corresponding width in spaces. Returns the assembled array of padded segments.
90 91 92 93 94 95 |
# File 'lib/charming/presentation/ui.rb', line 90 def horizontal_line(blocks, widths, index) blocks.each_with_index.map do |lines, block_index| line = lines[index] || "" line + (" " * (widths[block_index] - Width.measure(line))) end end |
.join_horizontal(*blocks, gap: 0) ⇒ Object
Horizontally concatenates blocks into a single multi-line string, padding each block’s rows to match the widest row. A gap argument (in spaces) can separate adjacent columns.
19 20 21 22 23 24 25 26 27 |
# File 'lib/charming/presentation/ui.rb', line 19 def join_horizontal(*blocks, gap: 0) normalized = normalize_blocks(blocks) widths = block_widths(normalized) separator = " " * gap Array.new(block_height(normalized)) do |index| horizontal_line(normalized, widths, index).join(separator) end.join("\n") end |
.join_vertical(*blocks, gap: 0) ⇒ Object
Stacks blocks vertically separated by one or more blank lines. A gap of N inserts N extra newline characters between blocks (1 gap = 1 blank line, 2 gaps = 2 blank lines, etc.).
31 32 33 |
# File 'lib/charming/presentation/ui.rb', line 31 def join_vertical(*blocks, gap: 0) blocks.join("\n" * (gap + 1)) end |
.normalize_blocks(blocks) ⇒ Object
Normalizes an array of mixed objects into arrays of lines by calling ‘#to_s` on each element.
68 69 70 |
# File 'lib/charming/presentation/ui.rb', line 68 def normalize_blocks(blocks) blocks.map { |block| block.to_s.lines(chomp: true) } end |
.offset(value, available, size) ⇒ Object
Computes a placement coordinate: if value is ‘:center` the result centres the size within available; otherwise value is returned verbatim as an absolute integer position.
99 100 101 102 103 |
# File 'lib/charming/presentation/ui.rb', line 99 def offset(value, available, size) return [(available - size) / 2, 0].max if value == :center value end |
.overlay(base, overlay, top: :center, left: :center) ⇒ Object
Draws overlay on top of a base at the specified top (row) and left (column) coordinates, defaulting to center in both directions. ANSI styling on the base content is preserved underneath.
42 43 44 45 46 47 48 49 50 |
# File 'lib/charming/presentation/ui.rb', line 42 def (base, , top: :center, left: :center) base_lines = base.to_s.lines(chomp: true) = .to_s.lines(chomp: true) width = block_width(base_lines) row = offset(top, base_lines.length, .length) column = offset(left, width, block_width()) draw_lines(base_lines, , row: row, column: column, width: width) end |
.place(block, width:, height:, top: 0, left: 0, background: nil) ⇒ Object
Places a block onto a blank canvas of width × height at an offset determined by top (row) and left (column). Non-:center values are treated as absolute positions. When background is given, the assembled frame is wrapped so the theme bg paints the entire canvas — overlay content with its own bg overrides per-cell; resets re-apply the canvas bg.
56 57 58 59 60 61 62 63 64 65 |
# File 'lib/charming/presentation/ui.rb', line 56 def place(block, width:, height:, top: 0, left: 0, background: nil) lines = block.to_s.lines(chomp: true) row = offset(top, height, lines.length) column = offset(left, width, block_width(lines)) canvas = Array.new(height) { " " * width } composed = draw_lines(canvas, lines, row: row, column: column, width: width) return composed unless background Style.new.background(background).render(composed) end |
.slice_ansi(token, state, start_column, end_column) ⇒ Object
Slices an ANSI token (escape sequence) into state, writing active markers to the output if the current column falls within the [start_column, end_column) range. Resets styles on ‘[0m` sequences.
166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/charming/presentation/ui.rb', line 166 def slice_ansi(token, state, start_column, end_column) started = state[:started] update_active_styles(state[:active], token) return unless state[:column].between?(start_column, end_column - 1) start_slice(state) if started state[:output] << token state[:styled] = !token.include?("[0m") end end |
.slice_char(char, state, start_column, end_column) ⇒ Object
Slices a plain char into state, advancing the column tracker by the character’s visual width. If the character overlaps with the [start_column, end_column) range it is appended to the output.
180 181 182 183 184 185 186 187 188 189 |
# File 'lib/charming/presentation/ui.rb', line 180 def slice_char(char, state, start_column, end_column) char_width = Width.measure(char) char_start = state[:column] char_end = char_start + char_width state[:column] = char_end return unless char_end > start_column && char_start < end_column start_slice(state) state[:output] << char end |
.slice_visible_text(line, start_column, end_column) ⇒ Object
Slices a string by visible terminal columns while preserving ANSI style state.
133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/charming/presentation/ui.rb', line 133 def slice_visible_text(line, start_column, end_column) state = {column: 0, output: +"", active: [], started: false, styled: false} each_ansi_or_char(line) do |token, ansi| if ansi slice_ansi(token, state, start_column, end_column) else slice_char(token, state, start_column, end_column) end end terminate_slice(state) end |
.start_slice(state) ⇒ Object
Starts writing to the output buffer, flushing any active ANSI markers if this is the first character placed.
192 193 194 195 196 197 198 |
# File 'lib/charming/presentation/ui.rb', line 192 def start_slice(state) return if state[:started] state[:output] << state[:active].join state[:styled] = true unless state[:active].empty? state[:started] = true end |
.style ⇒ Object
Builds a new Style instance for chaining color, padding, alignment, and other visual properties.
13 14 15 |
# File 'lib/charming/presentation/ui.rb', line 13 def style Style.new end |
.terminate_slice(state) ⇒ Object
Closes the slice by appending a final ‘[0m` reset escape to the output unless no active styling exists or nothing was written. Returns the fully constructed output string with trailing reset applied.
202 203 204 205 206 |
# File 'lib/charming/presentation/ui.rb', line 202 def terminate_slice(state) return state[:output] if !state[:styled] || state[:output].empty? "#{state[:output]}\e[0m" end |
.update_active_styles(active, token) ⇒ Object
Updates state[:active] with an ANSI token: resets all active styles on ‘[0m` or appends the token as a new active marker otherwise. Called during each_ansi_or_char iteration.
210 211 212 213 214 215 216 |
# File 'lib/charming/presentation/ui.rb', line 210 def update_active_styles(active, token) if token.include?("[0m") active.clear else active << token end end |
.visible_slice(line, start_column, width) ⇒ Object
Returns a visible-slice of line starting at start_column spanning width characters, preserving any ANSI escape sequences that were active at the start of the slice. Non-positive widths return ‘“”`.
126 127 128 129 130 |
# File 'lib/charming/presentation/ui.rb', line 126 def visible_slice(line, start_column, width) return "" unless width.positive? slice_visible_text(line.to_s, start_column, start_column + width) end |