charming 0.1.2 → 0.1.3
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/lib/charming/application.rb +3 -3
- data/lib/charming/controller/class_methods.rb +2 -2
- data/lib/charming/controller/command_palette.rb +2 -2
- data/lib/charming/controller/rendering.rb +2 -2
- data/lib/charming/controller/session_state.rb +1 -1
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/templates/app/application.template +1 -1
- data/lib/charming/generators/templates/app/layout.template +3 -6
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/generators/templates/component/component.rb.template +1 -1
- data/lib/charming/generators/templates/screen/view.rb.template +1 -1
- data/lib/charming/generators/templates/view/view.rb.template +1 -1
- data/lib/charming/internal/renderer/differential.rb +13 -5
- data/lib/charming/internal/terminal/tty_backend.rb +22 -2
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -49
- data/lib/charming/presentation/components/form/builder.rb +52 -54
- data/lib/charming/presentation/components/form/confirm.rb +49 -51
- data/lib/charming/presentation/components/form/field.rb +94 -96
- data/lib/charming/presentation/components/form/input.rb +53 -55
- data/lib/charming/presentation/components/form/note.rb +27 -29
- data/lib/charming/presentation/components/form/select.rb +84 -86
- data/lib/charming/presentation/components/form/textarea.rb +67 -69
- data/lib/charming/presentation/components/form.rb +120 -122
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -125
- data/lib/charming/presentation/components/markdown.rb +21 -23
- data/lib/charming/presentation/components/modal.rb +46 -48
- data/lib/charming/presentation/components/progressbar.rb +51 -53
- data/lib/charming/presentation/components/spinner.rb +40 -42
- data/lib/charming/presentation/components/table.rb +109 -111
- data/lib/charming/presentation/components/text_area.rb +219 -221
- data/lib/charming/presentation/components/text_input.rb +120 -122
- data/lib/charming/presentation/components/viewport.rb +218 -220
- data/lib/charming/presentation/layout/builder.rb +64 -66
- data/lib/charming/presentation/layout/overlay.rb +48 -50
- data/lib/charming/presentation/layout/pane.rb +122 -118
- data/lib/charming/presentation/layout/rect.rb +14 -16
- data/lib/charming/presentation/layout/screen_layout.rb +40 -42
- data/lib/charming/presentation/layout/split.rb +101 -103
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +94 -96
- data/lib/charming/presentation/markdown/inline_renderers.rb +52 -54
- data/lib/charming/presentation/markdown/render_context.rb +12 -14
- data/lib/charming/presentation/markdown/renderer.rb +84 -86
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +57 -59
- data/lib/charming/presentation/markdown.rb +4 -6
- data/lib/charming/presentation/template_view.rb +22 -24
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -49
- data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
- data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +37 -39
- data/lib/charming/presentation/ui/canvas.rb +59 -61
- data/lib/charming/presentation/ui/style.rb +173 -175
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +69 -71
- data/lib/charming/presentation/view.rb +103 -105
- data/lib/charming/runtime.rb +23 -10
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +3 -2
- metadata +2 -1
|
@@ -3,176 +3,174 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Charming
|
|
6
|
-
module
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.load_file(path)
|
|
26
|
-
from_hash(JSON.parse(File.read(path)))
|
|
27
|
-
end
|
|
6
|
+
module UI
|
|
7
|
+
class Theme
|
|
8
|
+
BUILT_IN_ROOT = File.expand_path("themes", __dir__)
|
|
9
|
+
|
|
10
|
+
DEFAULT_TOKENS = {
|
|
11
|
+
text: {foreground: :bright_white},
|
|
12
|
+
title: {foreground: :bright_cyan, bold: true},
|
|
13
|
+
muted: {foreground: :bright_black},
|
|
14
|
+
border: {foreground: :bright_magenta},
|
|
15
|
+
selected: {reverse: true},
|
|
16
|
+
info: {foreground: :bright_cyan},
|
|
17
|
+
warn: {foreground: :yellow}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.default
|
|
21
|
+
@default ||= load_builtin("phosphor")
|
|
22
|
+
end
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
def self.load_file(path)
|
|
25
|
+
from_hash(JSON.parse(File.read(path)))
|
|
26
|
+
end
|
|
32
27
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
def self.load_builtin(name)
|
|
29
|
+
load_file(built_in_path(name))
|
|
30
|
+
end
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
def self.built_in_names
|
|
33
|
+
Dir.glob(File.join(BUILT_IN_ROOT, "*.json")).map { |path| File.basename(path, ".json") }.sort
|
|
34
|
+
end
|
|
39
35
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
36
|
+
def self.from_hash(value)
|
|
37
|
+
raise ArgumentError, "theme file must contain an object" unless value.is_a?(Hash)
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
resolve_palette_references(styles, palette),
|
|
47
|
-
background: resolve_background(value["background"], palette)
|
|
48
|
-
)
|
|
39
|
+
styles = value.fetch("styles") do
|
|
40
|
+
raise ArgumentError, "theme file must contain styles"
|
|
49
41
|
end
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
palette = value.fetch("palette", {})
|
|
44
|
+
new(
|
|
45
|
+
resolve_palette_references(styles, palette),
|
|
46
|
+
background: resolve_background(value["background"], palette)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
def self.resolve_background(value, palette)
|
|
51
|
+
return unless value
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
raise ArgumentError, "unknown built-in theme: #{name.inspect}" unless built_in_names.include?(slug)
|
|
53
|
+
deep_resolve_colors(value, normalize_colors(palette))
|
|
54
|
+
end
|
|
60
55
|
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
def self.built_in_path(name)
|
|
57
|
+
slug = name.to_s
|
|
58
|
+
raise ArgumentError, "unknown built-in theme: #{name.inspect}" unless built_in_names.include?(slug)
|
|
63
59
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
deep_resolve_colors(styles, palette)
|
|
67
|
-
end
|
|
60
|
+
File.join(BUILT_IN_ROOT, "#{slug}.json")
|
|
61
|
+
end
|
|
68
62
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
when Array
|
|
74
|
-
value.map { |item| deep_resolve_colors(item, palette) }
|
|
75
|
-
when String
|
|
76
|
-
palette.fetch(value, normalize_color(value) || value)
|
|
77
|
-
else
|
|
78
|
-
value
|
|
79
|
-
end
|
|
80
|
-
end
|
|
63
|
+
def self.resolve_palette_references(styles, palette)
|
|
64
|
+
palette = normalize_colors(palette)
|
|
65
|
+
deep_resolve_colors(styles, palette)
|
|
66
|
+
end
|
|
81
67
|
|
|
82
|
-
|
|
83
|
-
|
|
68
|
+
def self.deep_resolve_colors(value, palette)
|
|
69
|
+
case value
|
|
70
|
+
when Hash
|
|
71
|
+
value.transform_values { |item| deep_resolve_colors(item, palette) }
|
|
72
|
+
when Array
|
|
73
|
+
value.map { |item| deep_resolve_colors(item, palette) }
|
|
74
|
+
when String
|
|
75
|
+
palette.fetch(value, normalize_color(value) || value)
|
|
76
|
+
else
|
|
77
|
+
value
|
|
84
78
|
end
|
|
79
|
+
end
|
|
85
80
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
case value
|
|
90
|
-
when /\A#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])(?:[0-9a-fA-F])?\z/
|
|
91
|
-
"#{$1 * 2}#{$2 * 2}#{$3 * 2}".prepend("#")
|
|
92
|
-
when /\A#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\z/
|
|
93
|
-
value[0, 7]
|
|
94
|
-
end
|
|
95
|
-
end
|
|
81
|
+
def self.normalize_colors(values)
|
|
82
|
+
values.transform_values { |value| normalize_color(value) }.compact
|
|
83
|
+
end
|
|
96
84
|
|
|
97
|
-
|
|
85
|
+
def self.normalize_color(value)
|
|
86
|
+
return unless value.is_a?(String)
|
|
98
87
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
88
|
+
case value
|
|
89
|
+
when /\A#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])(?:[0-9a-fA-F])?\z/
|
|
90
|
+
"#{$1 * 2}#{$2 * 2}#{$3 * 2}".prepend("#")
|
|
91
|
+
when /\A#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\z/
|
|
92
|
+
value[0, 7]
|
|
102
93
|
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
attr_reader :background
|
|
103
97
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
98
|
+
def initialize(tokens = {}, background: nil)
|
|
99
|
+
@tokens = symbolize_keys(tokens)
|
|
100
|
+
@background = background
|
|
101
|
+
end
|
|
108
102
|
|
|
109
|
-
|
|
103
|
+
def style(name)
|
|
104
|
+
spec = @tokens.fetch(name.to_sym) do
|
|
105
|
+
raise ArgumentError, "unknown theme token: #{name.inspect}"
|
|
110
106
|
end
|
|
111
|
-
alias_method :[], :style
|
|
112
107
|
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
build_style(spec)
|
|
109
|
+
end
|
|
110
|
+
alias_method :[], :style
|
|
115
111
|
|
|
116
|
-
|
|
117
|
-
|
|
112
|
+
def method_missing(name, ...)
|
|
113
|
+
return style(name) if @tokens.key?(name)
|
|
118
114
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
115
|
+
super
|
|
116
|
+
end
|
|
122
117
|
|
|
123
|
-
|
|
118
|
+
def respond_to_missing?(name, include_private = false)
|
|
119
|
+
@tokens.key?(name) || super
|
|
120
|
+
end
|
|
124
121
|
|
|
125
|
-
|
|
126
|
-
return spec if spec.is_a?(Style)
|
|
127
|
-
return UI.style.foreground(spec) unless spec.is_a?(Hash)
|
|
122
|
+
private
|
|
128
123
|
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
def build_style(spec)
|
|
125
|
+
return spec if spec.is_a?(Style)
|
|
126
|
+
return UI.style.foreground(spec) unless spec.is_a?(Hash)
|
|
131
127
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
styled = apply_attributes(styled, spec)
|
|
135
|
-
apply_layout(styled, spec)
|
|
136
|
-
end
|
|
128
|
+
apply_options(UI.style, symbolize_keys(spec))
|
|
129
|
+
end
|
|
137
130
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
end
|
|
131
|
+
def apply_options(base_style, spec)
|
|
132
|
+
styled = apply_colors(base_style, spec)
|
|
133
|
+
styled = apply_attributes(styled, spec)
|
|
134
|
+
apply_layout(styled, spec)
|
|
135
|
+
end
|
|
144
136
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
137
|
+
def apply_colors(base_style, spec)
|
|
138
|
+
styled = base_style
|
|
139
|
+
styled = styled.foreground(spec[:foreground] || spec[:fg]) if spec.key?(:foreground) || spec.key?(:fg)
|
|
140
|
+
styled = styled.background(spec[:background] || spec[:bg]) if spec.key?(:background) || spec.key?(:bg)
|
|
141
|
+
styled
|
|
142
|
+
end
|
|
150
143
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
styled = apply_border(styled, spec[:border]) if spec.key?(:border)
|
|
155
|
-
styled = styled.width(spec[:width]) if spec.key?(:width)
|
|
156
|
-
styled = styled.height(spec[:height]) if spec.key?(:height)
|
|
157
|
-
styled = styled.align(spec[:align].to_sym) if spec.key?(:align)
|
|
158
|
-
styled
|
|
144
|
+
def apply_attributes(base_style, spec)
|
|
145
|
+
Style::ATTRIBUTES.each_key.reduce(base_style) do |styled, attribute|
|
|
146
|
+
spec[attribute] ? styled.public_send(attribute) : styled
|
|
159
147
|
end
|
|
148
|
+
end
|
|
160
149
|
|
|
161
|
-
|
|
162
|
-
|
|
150
|
+
def apply_layout(base_style, spec)
|
|
151
|
+
styled = base_style
|
|
152
|
+
styled = styled.padding(*Array(spec[:padding])) if spec.key?(:padding)
|
|
153
|
+
styled = apply_border(styled, spec[:border]) if spec.key?(:border)
|
|
154
|
+
styled = styled.width(spec[:width]) if spec.key?(:width)
|
|
155
|
+
styled = styled.height(spec[:height]) if spec.key?(:height)
|
|
156
|
+
styled = styled.align(spec[:align].to_sym) if spec.key?(:align)
|
|
157
|
+
styled
|
|
158
|
+
end
|
|
163
159
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
170
|
-
|
|
160
|
+
def apply_border(base_style, border_spec)
|
|
161
|
+
return base_style.border(border_spec) unless border_spec.is_a?(Hash)
|
|
162
|
+
|
|
163
|
+
border_spec = symbolize_keys(border_spec)
|
|
164
|
+
base_style.border(
|
|
165
|
+
border_spec.fetch(:style, :normal),
|
|
166
|
+
sides: border_spec[:sides],
|
|
167
|
+
foreground: border_spec[:foreground] || border_spec[:fg]
|
|
168
|
+
)
|
|
169
|
+
end
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
end
|
|
171
|
+
def symbolize_keys(value)
|
|
172
|
+
value.each_with_object({}) do |(key, item), result|
|
|
173
|
+
result[key.to_sym] = item.is_a?(Hash) ? symbolize_keys(item) : item
|
|
176
174
|
end
|
|
177
175
|
end
|
|
178
176
|
end
|
|
@@ -3,23 +3,21 @@
|
|
|
3
3
|
require "unicode/display_width"
|
|
4
4
|
|
|
5
5
|
module Charming
|
|
6
|
-
module
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
6
|
+
module UI
|
|
7
|
+
# Width is a namespace for measuring and normalising the visual width of strings that may contain
|
|
8
|
+
# ANSI escape sequences. It delegates to `Unicode::DisplayWidth` while automatically stripping
|
|
9
|
+
# formatting codes so layout primitives can calculate exact character positions.
|
|
10
|
+
module Width
|
|
11
|
+
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
module_function
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
def measure(value)
|
|
16
|
+
Unicode::DisplayWidth.of(strip_ansi(value.to_s))
|
|
17
|
+
end
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
end
|
|
19
|
+
def strip_ansi(value)
|
|
20
|
+
value.to_s.gsub(ANSI_PATTERN, "")
|
|
23
21
|
end
|
|
24
22
|
end
|
|
25
23
|
end
|
|
@@ -1,90 +1,88 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
module_function
|
|
4
|
+
# UI is a module of layout primitives for composing and positioning ANSI-styled
|
|
5
|
+
# terminal text. It provides functions to join blocks horizontally or vertically,
|
|
6
|
+
# place content on fixed-size canvases, overlay elements, and slice strings that
|
|
7
|
+
# contain ANSI escape sequences while preserving their styling.
|
|
8
|
+
module UI
|
|
9
|
+
module_function
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
# Builds a new {Style} instance for chaining color, padding, alignment, and other visual properties.
|
|
12
|
+
def style
|
|
13
|
+
Style.new
|
|
14
|
+
end
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
# Horizontally concatenates *blocks* into a single multi-line string, padding each block's
|
|
17
|
+
# rows to match the widest row. A *gap* argument (in spaces) can separate adjacent columns.
|
|
18
|
+
def join_horizontal(*blocks, gap: 0)
|
|
19
|
+
normalized = normalize_blocks(blocks)
|
|
20
|
+
widths = block_widths(normalized)
|
|
21
|
+
separator = " " * gap
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
Array.new(block_height(normalized)) do |index|
|
|
24
|
+
horizontal_line(normalized, widths, index).join(separator)
|
|
25
|
+
end.join("\n")
|
|
26
|
+
end
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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))
|
|
32
|
+
end
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
# Places *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
|
|
35
|
+
# and *left* (column). Non-:center values are treated as absolute positions. When *background* is
|
|
36
|
+
# given, the assembled frame is wrapped so the theme bg paints the entire canvas — overlay content
|
|
37
|
+
# with its own bg overrides per-cell; resets re-apply the canvas bg.
|
|
38
|
+
def place(block, width:, height:, top: 0, left: 0, background: nil)
|
|
39
|
+
Canvas.new(width, height).place(block, top: top, left: left, background: background)
|
|
40
|
+
end
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
# Draws *overlay* on top of a base at the specified *top* (row) and *left* (column) coordinates,
|
|
43
|
+
# defaulting to center in both directions. ANSI styling on the base content is preserved underneath.
|
|
44
|
+
def overlay(base, overlay, top: :center, left: :center)
|
|
45
|
+
Canvas.parse(base).overlay(overlay, top: top, left: left).to_s
|
|
46
|
+
end
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
# Centers a *block* within a canvas of the given *width* and *height*, then returns the result.
|
|
49
|
+
def center(block, width:, height:, background: nil)
|
|
50
|
+
place(block, width: width, height: height, top: :center, left: :center, background: background)
|
|
51
|
+
end
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
# Returns a visible-slice of *line* starting at *start_column* spanning *width* characters, preserving any
|
|
54
|
+
# ANSI escape sequences that were active at the start of the slice. Non-positive widths return `""`.
|
|
55
|
+
def visible_slice(line, start_column, width)
|
|
56
|
+
ANSISlicer.slice(line, start_column, width)
|
|
57
|
+
end
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
# Normalizes an array of mixed objects into arrays of lines by calling `#to_s` on each element.
|
|
60
|
+
def normalize_blocks(blocks)
|
|
61
|
+
blocks.map { |block| block.to_s.lines(chomp: true) }
|
|
62
|
+
end
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
# Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
|
|
65
|
+
def block_widths(blocks)
|
|
66
|
+
blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
|
|
67
|
+
end
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
# Returns the maximum visual character width across all *lines*, accounting for multi-column characters
|
|
70
|
+
# (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
|
|
71
|
+
def block_width(lines)
|
|
72
|
+
lines.map { |line| Width.measure(line) }.max || 0
|
|
73
|
+
end
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
# Returns the height in rows of each normalised block, taking the maximum across all blocks.
|
|
76
|
+
def block_height(blocks)
|
|
77
|
+
blocks.map(&:length).max || 0
|
|
78
|
+
end
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
end
|
|
80
|
+
# Builds a single horizontal row by concatenating one line from each *block* at index *index*, padding
|
|
81
|
+
# every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
|
|
82
|
+
def horizontal_line(blocks, widths, index)
|
|
83
|
+
blocks.each_with_index.map do |lines, block_index|
|
|
84
|
+
line = lines[index] || ""
|
|
85
|
+
line + (" " * (widths[block_index] - Width.measure(line)))
|
|
88
86
|
end
|
|
89
87
|
end
|
|
90
88
|
end
|