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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/charming/application.rb +48 -0
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +12 -39
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_generator.rb +3 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +21 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +79 -2
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/error_screen.rb +1 -1
- data/lib/charming/presentation/components/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -4
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/table.rb +42 -1
- data/lib/charming/presentation/components/text_area.rb +1 -2
- data/lib/charming/presentation/components/text_input.rb +9 -4
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
- data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
- data/lib/charming/presentation/markdown/renderer.rb +1 -1
- data/lib/charming/presentation/markdown/style_config.rb +1 -0
- data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +11 -0
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/runtime.rb +154 -88
- data/lib/charming/version.rb +1 -1
- 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
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
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`,
|
|
76
|
-
# 5. paint border, 6. emit ANSI attribute/foreground/background
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
width = @options[:width] || Width.widest(lines)
|
|
165
|
+
max = @options[:max_width]
|
|
166
|
+
max ? [width, max].min : width
|
|
105
167
|
end
|
|
106
168
|
|
|
107
|
-
#
|
|
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,
|
|
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
|
|
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 =
|
|
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 + (" " *
|
|
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
|
-
|
|
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
|
|
5
|
-
# TextWrapper wraps
|
|
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
|
|
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? ||
|
|
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
|
-
|
|
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(
|
|
24
|
-
horizontal_line(
|
|
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
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
data/lib/charming/response.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|