charming 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/controller/key_dispatch.rb +113 -0
  5. data/lib/charming/controller/session_state.rb +11 -0
  6. data/lib/charming/controller/terminal.rb +33 -0
  7. data/lib/charming/controller.rb +12 -39
  8. data/lib/charming/escape.rb +81 -0
  9. data/lib/charming/events/mouse_event.rb +22 -9
  10. data/lib/charming/generators/app_generator.rb +3 -2
  11. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  12. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  13. data/lib/charming/image/protocol/kitty.rb +126 -0
  14. data/lib/charming/image/protocol.rb +18 -0
  15. data/lib/charming/image/source.rb +85 -0
  16. data/lib/charming/image/terminal.rb +52 -0
  17. data/lib/charming/image/transmit.rb +11 -0
  18. data/lib/charming/image.rb +21 -0
  19. data/lib/charming/internal/event_loop.rb +155 -0
  20. data/lib/charming/internal/terminal/adapter.rb +14 -0
  21. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  22. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  23. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  24. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  25. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  26. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  27. data/lib/charming/presentation/components/chart.rb +80 -0
  28. data/lib/charming/presentation/components/error_screen.rb +1 -1
  29. data/lib/charming/presentation/components/filepicker.rb +101 -0
  30. data/lib/charming/presentation/components/form/builder.rb +5 -0
  31. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  32. data/lib/charming/presentation/components/image.rb +38 -0
  33. data/lib/charming/presentation/components/list.rb +22 -4
  34. data/lib/charming/presentation/components/paginator.rb +54 -0
  35. data/lib/charming/presentation/components/progressbar.rb +26 -4
  36. data/lib/charming/presentation/components/sparkline.rb +38 -0
  37. data/lib/charming/presentation/components/spinner.rb +22 -3
  38. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  39. data/lib/charming/presentation/components/table.rb +42 -1
  40. data/lib/charming/presentation/components/text_area.rb +1 -2
  41. data/lib/charming/presentation/components/text_input.rb +9 -4
  42. data/lib/charming/presentation/components/time_display.rb +20 -0
  43. data/lib/charming/presentation/components/timer.rb +43 -0
  44. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  45. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  46. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  47. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  48. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  49. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  50. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  51. data/lib/charming/presentation/ui/background.rb +58 -0
  52. data/lib/charming/presentation/ui/border.rb +14 -1
  53. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  54. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  55. data/lib/charming/presentation/ui/canvas.rb +1 -0
  56. data/lib/charming/presentation/ui/gradient.rb +47 -0
  57. data/lib/charming/presentation/ui/style.rb +119 -19
  58. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  59. data/lib/charming/presentation/ui/truncate.rb +29 -0
  60. data/lib/charming/presentation/ui/width.rb +11 -0
  61. data/lib/charming/presentation/ui.rb +52 -11
  62. data/lib/charming/presentation/view.rb +8 -6
  63. data/lib/charming/response.rb +11 -6
  64. data/lib/charming/runtime.rb +154 -88
  65. data/lib/charming/version.rb +1 -1
  66. metadata +29 -3
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # Gradient interpolates between two hex colors: blend a single point, build
6
+ # an evenly spaced ramp, or paint text with a per-character color sweep.
7
+ module Gradient
8
+ module_function
9
+
10
+ # Blends *start_hex* and *end_hex* ("#rrggbb") at fractional *amount*
11
+ # (0.0 → start, 1.0 → end), returning a "#rrggbb" string.
12
+ def blend(start_hex, end_hex, amount)
13
+ mixed = rgb(start_hex).zip(rgb(end_hex)).map do |from, to|
14
+ (from + ((to - from) * amount)).round
15
+ end
16
+ format("#%02x%02x%02x", *mixed)
17
+ end
18
+
19
+ # An evenly spaced ramp of *count* colors from *start_hex* to *end_hex*,
20
+ # endpoints included.
21
+ def steps(start_hex, end_hex, count)
22
+ return [blend(start_hex, end_hex, 0.0)] if count <= 1
23
+
24
+ Array.new(count) { |index| blend(start_hex, end_hex, index.to_f / (count - 1)) }
25
+ end
26
+
27
+ # Paints each grapheme cluster of plain-text *text* with a foreground color
28
+ # swept from *from* to *to* across its visible characters.
29
+ def colorize(text, from:, to:)
30
+ clusters = text.to_s.scan(Width::GRAPHEME)
31
+ span = [clusters.length - 1, 1].max
32
+
33
+ clusters.each_with_index.map do |cluster, index|
34
+ Style.new(foreground: blend(from, to, index.to_f / span)).render(cluster)
35
+ end.join
36
+ end
37
+
38
+ # Decomposes "#rrggbb" into [r, g, b] integers.
39
+ def rgb(hex)
40
+ value = hex.to_s.delete_prefix("#")
41
+ raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
42
+
43
+ [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -18,6 +18,7 @@ module Charming
18
18
  @options = {
19
19
  attributes: [],
20
20
  padding: [0, 0, 0, 0],
21
+ margin: [0, 0, 0, 0],
21
22
  align: :left
22
23
  }.merge(options)
23
24
  end
@@ -49,11 +50,31 @@ module Charming
49
50
  with(padding: expand_box_values(values))
50
51
  end
51
52
 
53
+ # Returns a new Style with the margin set — blank space applied outside the border and
54
+ # untouched by the style's colors. Accepts the same CSS-style shorthand as `padding`.
55
+ def margin(*values)
56
+ with(margin: expand_box_values(values))
57
+ end
58
+
59
+ # Per-side setters: padding_top/right/bottom/left and margin_top/right/bottom/left,
60
+ # each returning a new Style with just that side changed.
61
+ %i[padding margin].each do |box|
62
+ %i[top right bottom left].each_with_index do |side, index|
63
+ define_method(:"#{box}_#{side}") do |value|
64
+ values = @options.fetch(box).dup
65
+ values[index] = value
66
+ with(box => values)
67
+ end
68
+ end
69
+ end
70
+
52
71
  # Returns a new Style with the border set. *style* is a border name (e.g., :normal,
53
- # :rounded). *sides* optionally restricts the border to specific sides. *foreground*
54
- # sets the border color.
55
- def border(style = :normal, sides: nil, foreground: nil)
56
- with(border: style, border_sides: sides, border_foreground: foreground)
72
+ # :rounded, :square, :hidden, :block) or a custom Border instance. *sides* optionally
73
+ # restricts the border to specific sides. *foreground* colors the border — a single
74
+ # color, or a per-side hash like `{top: :red, left: :green}`. *background* colors the
75
+ # border independently of the box background.
76
+ def border(style = :normal, sides: nil, foreground: nil, background: nil)
77
+ with(border: style, border_sides: sides, border_foreground: foreground, border_background: background)
57
78
  end
58
79
 
59
80
  # Returns a new Style that fixes the rendered width to *value* (in display columns).
@@ -66,19 +87,50 @@ module Charming
66
87
  with(height: value)
67
88
  end
68
89
 
90
+ # Returns a new Style that caps the rendered width to *value* display columns.
91
+ # Unlike `width`, content narrower than the cap is not padded out to it.
92
+ def max_width(value)
93
+ with(max_width: value)
94
+ end
95
+
96
+ # Returns a new Style that caps the rendered height to *value* rows. Unlike
97
+ # `height`, content shorter than the cap is not filled with blank rows.
98
+ def max_height(value)
99
+ with(max_height: value)
100
+ end
101
+
102
+ # Returns a new Style that word-wraps overflowing lines at the fixed width
103
+ # instead of clipping them.
104
+ def wrap
105
+ with(fit: :wrap)
106
+ end
107
+
108
+ # Returns a new Style that marks lines clipped at the fixed width with a
109
+ # trailing *ellipsis* instead of cutting them silently.
110
+ def truncate(ellipsis: Truncate::ELLIPSIS)
111
+ with(fit: :truncate, ellipsis: ellipsis)
112
+ end
113
+
69
114
  # Returns a new Style with horizontal alignment set (`:left`, `:right`, or `:center`).
70
115
  def align(value)
71
116
  with(align: value)
72
117
  end
73
118
 
119
+ # Returns a new Style with vertical alignment within a fixed height set
120
+ # (`:top`, `:middle`, or `:bottom`).
121
+ def align_vertical(value)
122
+ with(align_vertical: value)
123
+ end
124
+
74
125
  # Applies the configured style to *value* and returns the styled string. Steps:
75
- # 1. wrap to `:width`, 2. align horizontally, 3. expand to `:height`, 4. apply padding,
76
- # 5. paint border, 6. emit ANSI attribute/foreground/background escapes.
126
+ # 1. wrap to `:width`, 2. align horizontally and vertically, 3. expand to `:height`,
127
+ # 4. apply padding, 5. paint border, 6. emit ANSI attribute/foreground/background
128
+ # escapes, 7. surround with unstyled margin space.
77
129
  def render(value)
78
130
  lines = apply_dimensions(value.to_s.lines(chomp: true))
79
131
  lines = apply_padding(lines)
80
132
  lines = apply_border(lines)
81
- apply_ansi(lines.join("\n"))
133
+ apply_margin(apply_ansi(lines.join("\n")))
82
134
  end
83
135
 
84
136
  private
@@ -91,40 +143,87 @@ module Charming
91
143
  # Wraps each line to the target width and applies horizontal alignment, then expands
92
144
  # to the target height.
93
145
  def apply_dimensions(lines)
146
+ lines = wrap_lines(lines)
94
147
  content_width = target_content_width(lines)
95
148
  dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
96
- apply_height(dimensioned, content_width)
149
+ apply_max_height(apply_height(dimensioned, content_width))
150
+ end
151
+
152
+ # Word-wraps lines at the fixed width when the style is in wrap mode.
153
+ def wrap_lines(lines)
154
+ wrap_width = @options[:width]
155
+ return lines unless wrap_width && @options[:fit] == :wrap
156
+
157
+ wrapper = TextWrapper.new(width: wrap_width)
158
+ lines.flat_map { |line| wrapper.wrap(line).lines(chomp: true) }
97
159
  end
98
160
 
99
161
  # Returns the target content width: the explicit :width if set, otherwise the natural
100
- # max display width of the lines.
162
+ # max display width of the lines, capped to :max_width when configured.
101
163
  def target_content_width(lines)
102
- explicit_width = @options[:width]
103
- natural_width = lines.map { |line| Width.measure(line) }.max || 0
104
- explicit_width || natural_width
164
+ width = @options[:width] || Width.widest(lines)
165
+ max = @options[:max_width]
166
+ max ? [width, max].min : width
105
167
  end
106
168
 
107
- # Clips *line* to *width* display columns, preserving ANSI styling where possible.
169
+ # Fits *line* into *width* display columns: clipped by default, or marked with an
170
+ # ellipsis in truncate mode. Preserves ANSI styling active at the cut.
108
171
  def fit_line(line, width)
109
172
  return line if Width.measure(line) <= width
173
+ return Truncate.tail(line, width, ellipsis: @options.fetch(:ellipsis, Truncate::ELLIPSIS)) if @options[:fit] == :truncate
110
174
 
111
175
  UI.visible_slice(line, 0, width)
112
176
  end
113
177
 
114
- # Truncates or pads the lines array to *height* rows, filling with blank rows.
178
+ # Truncates or pads the lines array to *height* rows, distributing blank fill
179
+ # rows according to :align_vertical (:top, :middle, or :bottom).
115
180
  def apply_height(lines, width)
116
181
  height = @options[:height]
117
182
  return lines unless height
118
183
 
119
184
  visible = lines.first(height)
120
- visible + Array.new([height - visible.length, 0].max) { " " * width }
185
+ distribute_rows(visible, height - visible.length, width)
186
+ end
187
+
188
+ # Places *missing* blank rows around *visible* per the vertical alignment.
189
+ def distribute_rows(visible, missing, width)
190
+ blank = ->(count) { Array.new([count, 0].max) { " " * width } }
191
+ case @options.fetch(:align_vertical, :top)
192
+ when :bottom
193
+ blank.call(missing) + visible
194
+ when :middle
195
+ top = missing / 2
196
+ blank.call(top) + visible + blank.call(missing - top)
197
+ else
198
+ visible + blank.call(missing)
199
+ end
200
+ end
201
+
202
+ # Surrounds the styled block with unstyled margin space: blank rows above and
203
+ # below, plain-space columns left and right. Applied after ANSI styling so the
204
+ # margin never carries the style's colors.
205
+ def apply_margin(styled)
206
+ top, right, bottom, left = @options.fetch(:margin)
207
+ return styled if [top, right, bottom, left].all?(&:zero?)
208
+
209
+ lines = styled.lines(chomp: true)
210
+ width = Width.widest(lines)
211
+ body = lines.map { |line| (" " * left) + Width.pad_to(line, width) + (" " * right) }
212
+ blank = " " * (left + width + right)
213
+ (Array.new(top, blank) + body + Array.new(bottom, blank)).join("\n")
214
+ end
215
+
216
+ # Caps the lines array to :max_height rows without filling missing rows.
217
+ def apply_max_height(lines)
218
+ max = @options[:max_height]
219
+ max ? lines.first(max) : lines
121
220
  end
122
221
 
123
222
  # Applies padding by prepending/appending blank rows (vertical) and indenting each
124
223
  # line (horizontal).
125
224
  def apply_padding(lines)
126
225
  top, right, bottom, left = @options.fetch(:padding)
127
- inner_width = lines.map { |line| Width.measure(line) }.max || 0
226
+ inner_width = Width.widest(lines)
128
227
  empty = " " * (left + inner_width + right)
129
228
  padded = lines.map do |line|
130
229
  pad_line(line, inner_width, left, right)
@@ -143,7 +242,7 @@ module Charming
143
242
 
144
243
  # Pads a single line to *inner_width*, with *left* and *right* padding spaces.
145
244
  def pad_line(line, inner_width, left, right)
146
- (" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
245
+ (" " * left) + Width.pad_to(line, inner_width) + (" " * right)
147
246
  end
148
247
 
149
248
  # Builds a BorderPainter configured for the current border options.
@@ -152,13 +251,14 @@ module Charming
152
251
  border: Border.fetch(border_name),
153
252
  sides: @options[:border_sides],
154
253
  foreground: @options[:border_foreground],
155
- background: @options[:background]
254
+ background: @options[:border_background] || @options[:background],
255
+ background_explicit: !@options[:border_background].nil?
156
256
  )
157
257
  end
158
258
 
159
259
  # Returns the natural display width of the longest line in *lines*.
160
260
  def content_width(lines)
161
- lines.map { |line| Width.measure(line) }.max || 0
261
+ Width.widest(lines)
162
262
  end
163
263
 
164
264
  # Applies the active ANSI attribute/foreground/background codes to *value*.
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Markdown
5
- # TextWrapper wraps Markdown text blocks to a configured terminal width.
4
+ module UI
5
+ # TextWrapper greedily word-wraps text blocks to a display width.
6
6
  class TextWrapper
7
7
  def initialize(width:)
8
8
  @width = width
@@ -19,7 +19,7 @@ module Charming
19
19
  attr_reader :width
20
20
 
21
21
  def wrap_line(line)
22
- return line if UI::Width.measure(line) <= width
22
+ return line if Width.measure(line) <= width
23
23
 
24
24
  wrap_words(line.split(/\s+/))
25
25
  end
@@ -31,7 +31,7 @@ module Charming
31
31
  def append_word(lines, word)
32
32
  current = lines.pop.to_s
33
33
  candidate = current.empty? ? word : "#{current} #{word}"
34
- return lines.push(candidate) if current.empty? || UI::Width.measure(candidate) <= width
34
+ return lines.push(candidate) if current.empty? || Width.measure(candidate) <= width
35
35
 
36
36
  lines.push(current.rstrip, word)
37
37
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module UI
5
+ # Truncate cuts ANSI-styled text down to a display width, marking the cut
6
+ # with a trailing ellipsis. Styling active at the cut is preserved and
7
+ # terminated, and multi-column glyphs are never split (matching ANSISlicer).
8
+ module Truncate
9
+ ELLIPSIS = "…"
10
+
11
+ module_function
12
+
13
+ # Truncates each line of *text* to *width* display columns, appending
14
+ # *ellipsis* to lines that overflow. Lines that fit are returned unchanged.
15
+ def tail(text, width, ellipsis: ELLIPSIS)
16
+ text.to_s.lines(chomp: true).map { |line| tail_line(line, width, ellipsis) }.join("\n")
17
+ end
18
+
19
+ def tail_line(line, width, ellipsis)
20
+ return line if Width.measure(line) <= width
21
+
22
+ ellipsis_width = Width.measure(ellipsis)
23
+ return ANSISlicer.slice(line, 0, width) if ellipsis_width >= width
24
+
25
+ ANSISlicer.slice(line, 0, width - ellipsis_width) + ellipsis
26
+ end
27
+ end
28
+ end
29
+ end
@@ -44,6 +44,17 @@ module Charming
44
44
  def strip_ansi(value)
45
45
  value.to_s.gsub(ANSI_PATTERN, "")
46
46
  end
47
+
48
+ # Pads *line* with trailing spaces to *width* display columns. Lines already at
49
+ # or beyond the target are returned unchanged.
50
+ def pad_to(line, width)
51
+ line + (" " * [width - measure(line), 0].max)
52
+ end
53
+
54
+ # The maximum display width across *lines*; 0 when there are none.
55
+ def widest(lines)
56
+ lines.map { |line| measure(line) }.max || 0
57
+ end
47
58
  end
48
59
  end
49
60
  end
@@ -13,22 +13,38 @@ module Charming
13
13
  Style.new
14
14
  end
15
15
 
16
+ # Builds a color that resolves to *light* or *dark* at render time based on
17
+ # the terminal background. Usable anywhere a color is accepted.
18
+ def adaptive(light:, dark:)
19
+ AdaptiveColor.new(light: light, dark: dark)
20
+ end
21
+
16
22
  # Horizontally concatenates *blocks* into a single multi-line string, padding each block's
17
23
  # rows to match the widest row. A *gap* argument (in spaces) can separate adjacent columns.
18
- def join_horizontal(*blocks, gap: 0)
24
+ # *align* positions shorter blocks along the cross axis: `:top` (default), `:center`,
25
+ # `:bottom`, or a fraction between 0.0 and 1.0.
26
+ def join_horizontal(*blocks, gap: 0, align: :top)
19
27
  normalized = normalize_blocks(blocks)
28
+ height = block_height(normalized)
29
+ aligned = normalized.map { |lines| offset_rows(lines, height, align) }
20
30
  widths = block_widths(normalized)
21
31
  separator = " " * gap
22
32
 
23
- Array.new(block_height(normalized)) do |index|
24
- horizontal_line(normalized, widths, index).join(separator)
33
+ Array.new(height) do |index|
34
+ horizontal_line(aligned, widths, index).join(separator)
25
35
  end.join("\n")
26
36
  end
27
37
 
28
- # Stacks *blocks* vertically separated by one or more blank lines. A *gap* of N inserts N
29
- # extra newline characters between blocks (1 gap = 1 blank line, 2 gaps = 2 blank lines, etc.).
30
- def join_vertical(*blocks, gap: 0)
31
- blocks.join("\n" * (gap + 1))
38
+ # Stacks *blocks* vertically separated by one or more blank lines, padding narrower
39
+ # blocks' lines to the widest block. A *gap* of N inserts N extra newline characters
40
+ # between blocks. *align* positions narrower lines along the cross axis: `:left`
41
+ # (default), `:center`, `:right`, or a fraction between 0.0 and 1.0.
42
+ def join_vertical(*blocks, gap: 0, align: :left)
43
+ normalized = normalize_blocks(blocks)
44
+ width = block_widths(normalized).max || 0
45
+
46
+ normalized.map { |lines| lines.map { |line| align_to(line, width, align) }.join("\n") }
47
+ .join("\n" * (gap + 1))
32
48
  end
33
49
 
34
50
  # Places *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
@@ -63,13 +79,13 @@ module Charming
63
79
 
64
80
  # Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
65
81
  def block_widths(blocks)
66
- blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
82
+ blocks.map { |lines| Width.widest(lines) }
67
83
  end
68
84
 
69
85
  # Returns the maximum visual character width across all *lines*, accounting for multi-column characters
70
86
  # (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
71
87
  def block_width(lines)
72
- lines.map { |line| Width.measure(line) }.max || 0
88
+ Width.widest(lines)
73
89
  end
74
90
 
75
91
  # Returns the height in rows of each normalised block, taking the maximum across all blocks.
@@ -81,9 +97,34 @@ module Charming
81
97
  # every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
82
98
  def horizontal_line(blocks, widths, index)
83
99
  blocks.each_with_index.map do |lines, block_index|
84
- line = lines[index] || ""
85
- line + (" " * (widths[block_index] - Width.measure(line)))
100
+ Width.pad_to(lines[index] || "", widths[block_index])
86
101
  end
87
102
  end
103
+
104
+ # Prepends blank rows to *lines* so the block sits at the cross-axis position
105
+ # given by *align* within *height* total rows.
106
+ def offset_rows(lines, height, align)
107
+ Array.new(cross_offset(align, height - lines.length), "") + lines
108
+ end
109
+
110
+ # Pads *line* to *width*, splitting the slack per the cross-axis *align* position.
111
+ def align_to(line, width, align)
112
+ slack = width - Width.measure(line)
113
+ return line if slack <= 0
114
+
115
+ leading = cross_offset(align, slack)
116
+ (" " * leading) + line + (" " * (slack - leading))
117
+ end
118
+
119
+ # Resolves an alignment (`:top`/`:left` → 0.0, `:center` → 0.5, `:bottom`/`:right` → 1.0,
120
+ # or a fraction) into a whole-cell offset within *slack* spare cells.
121
+ CROSS_POSITIONS = {top: 0.0, left: 0.0, center: 0.5, middle: 0.5, bottom: 1.0, right: 1.0}.freeze
122
+
123
+ def cross_offset(align, slack)
124
+ return 0 if slack <= 0
125
+
126
+ position = CROSS_POSITIONS.fetch(align, align)
127
+ (slack * position.to_f).round.clamp(0, slack)
128
+ end
88
129
  end
89
130
  end
@@ -57,14 +57,16 @@ module Charming
57
57
  apply_style(content, style)
58
58
  end
59
59
 
60
- # Joins items horizontally (side-by-side) using the UI rendering engine. Supports a `gap:` parameter.
61
- def row(*items, gap: 0)
62
- UI.join_horizontal(*items, gap: gap)
60
+ # Joins items horizontally (side-by-side) using the UI rendering engine. Supports `gap:`
61
+ # spacing and cross-axis `align:` (`:top`/`:center`/`:bottom` or a 0.0–1.0 fraction).
62
+ def row(*items, gap: 0, align: :top)
63
+ UI.join_horizontal(*items, gap: gap, align: align)
63
64
  end
64
65
 
65
- # Stacks items vertically using the UI rendering engine. Supports a `gap:` parameter for spacing.
66
- def column(*items, gap: 0)
67
- UI.join_vertical(*items, gap: gap)
66
+ # Stacks items vertically using the UI rendering engine. Supports `gap:` spacing and
67
+ # cross-axis `align:` (`:left`/`:center`/`:right` or a 0.0–1.0 fraction).
68
+ def column(*items, gap: 0, align: :left)
69
+ UI.join_vertical(*items, gap: gap, align: align)
68
70
  end
69
71
 
70
72
  # Renders a component (e.g., a ProgressBar, Spinner, Modal) and returns its string output.
@@ -4,20 +4,25 @@ module Charming
4
4
  # Response encapsulates a controller's dispatch outcome — one of render text, navigate to another route, or quit.
5
5
  # Rails-style factories (`render`, `navigate`, `quit`) serve as the public API and map to :kind values
6
6
  # that the Runtime interprets at the end of each event loop iteration.
7
- Response = Data.define(:kind, :body, :path) do
8
- # Factory constructing a Render response for displaying *body* text on the current screen.
9
- def self.render(body)
10
- new(kind: :render, body: body, path: nil)
7
+ #
8
+ # *escapes* carries any out-of-band terminal sequences (image transmissions, clipboard writes,
9
+ # notifications, window-title changes) gathered during the dispatch. The Runtime flushes them straight
10
+ # to the backend, bypassing the line-based frame pipeline. It is empty for ordinary responses.
11
+ Response = Data.define(:kind, :body, :path, :escapes) do
12
+ # Factory constructing a Render response for displaying *body* text on the current screen. *escapes*
13
+ # is the list of out-of-band sequences gathered during the dispatch (defaults to none).
14
+ def self.render(body, escapes: [])
15
+ new(kind: :render, body: body, path: nil, escapes: escapes)
11
16
  end
12
17
 
13
18
  # Factory constructing a NavigateResponse routing to the named *path* (string).
14
19
  def self.navigate(path)
15
- new(kind: :navigate, body: "", path: path)
20
+ new(kind: :navigate, body: "", path: path, escapes: [])
16
21
  end
17
22
 
18
23
  # Factory constructing a QuitResponse signalling termination of the top-level event loop.
19
24
  def self.quit
20
- new(kind: :quit, body: "", path: nil)
25
+ new(kind: :quit, body: "", path: nil, escapes: [])
21
26
  end
22
27
 
23
28
  # Returns `true` when this response is navigating to another screen or route.