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