charming 0.1.0 → 0.1.1

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +3 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +39 -3
  6. data/lib/charming/controller.rb +146 -24
  7. data/lib/charming/database_commands.rb +87 -0
  8. data/lib/charming/database_installer.rb +125 -0
  9. data/lib/charming/events/key_event.rb +15 -0
  10. data/lib/charming/events/mouse_event.rb +42 -0
  11. data/lib/charming/events/resize_event.rb +9 -0
  12. data/lib/charming/events/task_event.rb +19 -0
  13. data/lib/charming/events/timer_event.rb +9 -0
  14. data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
  15. data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
  16. data/lib/charming/generators/app_generator/component_templates.rb +1 -1
  17. data/lib/charming/generators/app_generator/controller_template.rb +3 -12
  18. data/lib/charming/generators/app_generator/database_templates.rb +45 -0
  19. data/lib/charming/generators/app_generator/layout_template.rb +51 -145
  20. data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
  21. data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
  22. data/lib/charming/generators/app_generator/view_template.rb +12 -18
  23. data/lib/charming/generators/app_generator.rb +37 -11
  24. data/lib/charming/generators/component_generator.rb +1 -1
  25. data/lib/charming/generators/controller_generator.rb +1 -4
  26. data/lib/charming/generators/model_generator.rb +119 -0
  27. data/lib/charming/generators/name.rb +0 -4
  28. data/lib/charming/generators/screen_generator.rb +14 -28
  29. data/lib/charming/generators/view_generator.rb +11 -14
  30. data/lib/charming/internal/renderer/differential.rb +2 -3
  31. data/lib/charming/internal/terminal/tty_backend.rb +25 -8
  32. data/lib/charming/presentation/component.rb +10 -0
  33. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  34. data/lib/charming/presentation/components/command_palette.rb +120 -0
  35. data/lib/charming/presentation/components/empty_state.rb +43 -0
  36. data/lib/charming/presentation/components/form/builder.rb +48 -0
  37. data/lib/charming/presentation/components/form/confirm.rb +56 -0
  38. data/lib/charming/presentation/components/form/field.rb +96 -0
  39. data/lib/charming/presentation/components/form/input.rb +57 -0
  40. data/lib/charming/presentation/components/form/note.rb +32 -0
  41. data/lib/charming/presentation/components/form/select.rb +89 -0
  42. data/lib/charming/presentation/components/form/textarea.rb +70 -0
  43. data/lib/charming/presentation/components/form.rb +127 -0
  44. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  45. data/lib/charming/presentation/components/list.rb +104 -0
  46. data/lib/charming/presentation/components/markdown.rb +25 -0
  47. data/lib/charming/presentation/components/modal.rb +50 -0
  48. data/lib/charming/presentation/components/progressbar.rb +57 -0
  49. data/lib/charming/presentation/components/spinner.rb +39 -0
  50. data/lib/charming/presentation/components/table.rb +118 -0
  51. data/lib/charming/presentation/components/text_area.rb +219 -0
  52. data/lib/charming/presentation/components/text_input.rb +105 -0
  53. data/lib/charming/presentation/components/viewport.rb +220 -0
  54. data/lib/charming/presentation/layout.rb +43 -0
  55. data/lib/charming/presentation/markdown/renderer.rb +203 -0
  56. data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
  57. data/lib/charming/presentation/markdown.rb +8 -0
  58. data/lib/charming/presentation/template_view.rb +27 -0
  59. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  60. data/lib/charming/presentation/templates.rb +51 -0
  61. data/lib/charming/presentation/ui/border.rb +35 -0
  62. data/lib/charming/presentation/ui/style.rb +246 -0
  63. data/lib/charming/presentation/ui/theme.rb +180 -0
  64. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  65. data/lib/charming/presentation/ui/width.rb +26 -0
  66. data/lib/charming/presentation/ui.rb +232 -0
  67. data/lib/charming/presentation/view.rb +118 -0
  68. data/lib/charming/runtime.rb +7 -7
  69. data/lib/charming/screen.rb +5 -1
  70. data/lib/charming/tasks/inline_executor.rb +28 -0
  71. data/lib/charming/tasks/task.rb +9 -0
  72. data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
  73. data/lib/charming/version.rb +1 -1
  74. data/lib/charming.rb +4 -0
  75. metadata +114 -29
  76. data/lib/charming/component.rb +0 -8
  77. data/lib/charming/components/activity_indicator.rb +0 -158
  78. data/lib/charming/components/command_palette.rb +0 -118
  79. data/lib/charming/components/keyboard_handler.rb +0 -22
  80. data/lib/charming/components/list.rb +0 -105
  81. data/lib/charming/components/modal.rb +0 -48
  82. data/lib/charming/components/progressbar.rb +0 -55
  83. data/lib/charming/components/spinner.rb +0 -37
  84. data/lib/charming/components/table.rb +0 -115
  85. data/lib/charming/components/text_input.rb +0 -103
  86. data/lib/charming/components/viewport.rb +0 -191
  87. data/lib/charming/key_event.rb +0 -13
  88. data/lib/charming/mouse_event.rb +0 -40
  89. data/lib/charming/resize_event.rb +0 -7
  90. data/lib/charming/task.rb +0 -7
  91. data/lib/charming/task_event.rb +0 -17
  92. data/lib/charming/timer_event.rb +0 -7
  93. data/lib/charming/ui/border.rb +0 -33
  94. data/lib/charming/ui/style.rb +0 -244
  95. data/lib/charming/ui/theme.rb +0 -178
  96. data/lib/charming/ui/width.rb +0 -24
  97. data/lib/charming/ui.rb +0 -230
  98. data/lib/charming/view.rb +0 -116
  99. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module UI
6
+ class Style
7
+ ATTRIBUTES = {
8
+ bold: 1,
9
+ faint: 2,
10
+ italic: 3,
11
+ underline: 4,
12
+ reverse: 7,
13
+ strikethrough: 9
14
+ }.freeze
15
+
16
+ COLORS = {
17
+ black: 30,
18
+ red: 31,
19
+ green: 32,
20
+ yellow: 33,
21
+ blue: 34,
22
+ magenta: 35,
23
+ cyan: 36,
24
+ white: 37,
25
+ bright_black: 90,
26
+ bright_red: 91,
27
+ bright_green: 92,
28
+ bright_yellow: 93,
29
+ bright_blue: 94,
30
+ bright_magenta: 95,
31
+ bright_cyan: 96,
32
+ bright_white: 97
33
+ }.freeze
34
+
35
+ def initialize(options = {})
36
+ @options = {
37
+ attributes: [],
38
+ padding: [0, 0, 0, 0],
39
+ align: :left
40
+ }.merge(options)
41
+ end
42
+
43
+ def foreground(color)
44
+ with(foreground: color)
45
+ end
46
+ alias_method :fg, :foreground
47
+
48
+ def background(color)
49
+ with(background: color)
50
+ end
51
+ alias_method :bg, :background
52
+
53
+ ATTRIBUTES.each_key do |attribute|
54
+ define_method(attribute) do
55
+ with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
56
+ end
57
+ end
58
+
59
+ def padding(*values)
60
+ with(padding: expand_box_values(values))
61
+ end
62
+
63
+ def border(style = :normal, sides: nil, foreground: nil)
64
+ with(border: style, border_sides: sides, border_foreground: foreground)
65
+ end
66
+
67
+ def width(value)
68
+ with(width: value)
69
+ end
70
+
71
+ def height(value)
72
+ with(height: value)
73
+ end
74
+
75
+ def align(value)
76
+ with(align: value)
77
+ end
78
+
79
+ def render(value)
80
+ lines = apply_dimensions(value.to_s.lines(chomp: true))
81
+ lines = apply_padding(lines)
82
+ lines = apply_border(lines)
83
+ apply_ansi(lines.join("\n"))
84
+ end
85
+
86
+ private
87
+
88
+ def with(changes)
89
+ self.class.new(@options.merge(changes))
90
+ end
91
+
92
+ def apply_dimensions(lines)
93
+ content_width = target_content_width(lines)
94
+ dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
95
+ apply_height(dimensioned, content_width)
96
+ end
97
+
98
+ def target_content_width(lines)
99
+ explicit_width = @options[:width]
100
+ natural_width = lines.map { |line| Width.measure(line) }.max || 0
101
+ explicit_width || natural_width
102
+ end
103
+
104
+ def fit_line(line, width)
105
+ return line if Width.measure(line) <= width
106
+
107
+ UI.visible_slice(line, 0, width)
108
+ end
109
+
110
+ def apply_height(lines, width)
111
+ height = @options[:height]
112
+ return lines unless height
113
+
114
+ visible = lines.first(height)
115
+ visible + Array.new([height - visible.length, 0].max) { " " * width }
116
+ end
117
+
118
+ def apply_padding(lines)
119
+ top, right, bottom, left = @options.fetch(:padding)
120
+ inner_width = lines.map { |line| Width.measure(line) }.max || 0
121
+ empty = " " * (left + inner_width + right)
122
+ padded = lines.map do |line|
123
+ pad_line(line, inner_width, left, right)
124
+ end
125
+
126
+ Array.new(top, empty) + padded + Array.new(bottom, empty)
127
+ end
128
+
129
+ def apply_border(lines)
130
+ border_name = @options[:border]
131
+ return lines unless border_name
132
+
133
+ border = Border.fetch(border_name)
134
+ sides = Array(@options[:border_sides] || %i[top right bottom left]).map(&:to_sym)
135
+ width = lines.map { |line| Width.measure(line) }.max || 0
136
+ horizontal = border.horizontal * width
137
+ body = lines.map { |line| border_line(line, width, border, sides) }
138
+
139
+ [top_border(border, horizontal, sides), *body, bottom_border(border, horizontal, sides)].compact
140
+ end
141
+
142
+ def pad_line(line, inner_width, left, right)
143
+ (" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
144
+ end
145
+
146
+ def border_line(line, width, border, sides)
147
+ left = sides.include?(:left) ? render_border(border.vertical) : ""
148
+ right = sides.include?(:right) ? render_border(border.vertical) : ""
149
+
150
+ "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
151
+ end
152
+
153
+ def top_border(border, horizontal, sides)
154
+ return unless sides.include?(:top)
155
+ return render_border(horizontal) unless full_horizontal_border?(sides)
156
+
157
+ render_border("#{border.top_left}#{horizontal}#{border.top_right}")
158
+ end
159
+
160
+ def bottom_border(border, horizontal, sides)
161
+ return unless sides.include?(:bottom)
162
+ return render_border(horizontal) unless full_horizontal_border?(sides)
163
+
164
+ render_border("#{border.bottom_left}#{horizontal}#{border.bottom_right}")
165
+ end
166
+
167
+ def full_horizontal_border?(sides)
168
+ sides.include?(:left) && sides.include?(:right)
169
+ end
170
+
171
+ def render_border(value)
172
+ border_foreground = @options[:border_foreground]
173
+ return value unless border_foreground
174
+
175
+ Style.new(foreground: border_foreground, background: @options[:background]).render(value)
176
+ end
177
+
178
+ def apply_ansi(value)
179
+ codes = ansi_codes
180
+ return value if codes.empty?
181
+
182
+ start = "\e[#{codes.join(";")}m"
183
+ value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
184
+ end
185
+
186
+ def ansi_codes
187
+ @options.fetch(:attributes).map { |attribute| ATTRIBUTES.fetch(attribute) } +
188
+ color_codes(@options[:foreground], foreground: true) +
189
+ color_codes(@options[:background], foreground: false)
190
+ end
191
+
192
+ def color_codes(color, foreground:)
193
+ return [] unless color
194
+ return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
195
+ return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
196
+ return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
197
+
198
+ raise ArgumentError, "unknown color: #{color.inspect}"
199
+ end
200
+
201
+ def named_color_code(color, foreground:)
202
+ code = COLORS.fetch(color.to_sym)
203
+ [foreground ? code : code + 10]
204
+ end
205
+
206
+ def indexed_color_code(color, foreground:)
207
+ raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
208
+
209
+ [foreground ? 38 : 48, 5, color]
210
+ end
211
+
212
+ def truecolor_codes(color, foreground:)
213
+ hex = color.to_s.delete_prefix("#")
214
+ raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
215
+
216
+ [foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
217
+ end
218
+
219
+ def align_line(line, width)
220
+ remaining = width - Width.measure(line)
221
+ return line if remaining <= 0
222
+
223
+ case @options.fetch(:align)
224
+ when :right
225
+ (" " * remaining) + line
226
+ when :center
227
+ left = remaining / 2
228
+ (" " * left) + line + (" " * (remaining - left))
229
+ else
230
+ line + (" " * remaining)
231
+ end
232
+ end
233
+
234
+ def expand_box_values(values)
235
+ case values.length
236
+ when 1 then [values[0], values[0], values[0], values[0]]
237
+ when 2 then [values[0], values[1], values[0], values[1]]
238
+ when 4 then values
239
+ else
240
+ raise ArgumentError, "padding expects 1, 2, or 4 values"
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
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
28
+
29
+ def self.load_builtin(name)
30
+ load_file(built_in_path(name))
31
+ end
32
+
33
+ def self.built_in_names
34
+ Dir.glob(File.join(BUILT_IN_ROOT, "*.json")).map { |path| File.basename(path, ".json") }.sort
35
+ end
36
+
37
+ def self.from_hash(value)
38
+ raise ArgumentError, "theme file must contain an object" unless value.is_a?(Hash)
39
+
40
+ styles = value.fetch("styles") do
41
+ raise ArgumentError, "theme file must contain styles"
42
+ end
43
+
44
+ palette = value.fetch("palette", {})
45
+ new(
46
+ resolve_palette_references(styles, palette),
47
+ background: resolve_background(value["background"], palette)
48
+ )
49
+ end
50
+
51
+ def self.resolve_background(value, palette)
52
+ return unless value
53
+
54
+ deep_resolve_colors(value, normalize_colors(palette))
55
+ end
56
+
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)
60
+
61
+ File.join(BUILT_IN_ROOT, "#{slug}.json")
62
+ end
63
+
64
+ def self.resolve_palette_references(styles, palette)
65
+ palette = normalize_colors(palette)
66
+ deep_resolve_colors(styles, palette)
67
+ end
68
+
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
81
+
82
+ def self.normalize_colors(values)
83
+ values.transform_values { |value| normalize_color(value) }.compact
84
+ end
85
+
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
96
+
97
+ attr_reader :background
98
+
99
+ def initialize(tokens = {}, background: nil)
100
+ @tokens = symbolize_keys(tokens)
101
+ @background = background
102
+ end
103
+
104
+ def style(name)
105
+ spec = @tokens.fetch(name.to_sym) do
106
+ raise ArgumentError, "unknown theme token: #{name.inspect}"
107
+ end
108
+
109
+ build_style(spec)
110
+ end
111
+ alias_method :[], :style
112
+
113
+ def method_missing(name, ...)
114
+ return style(name) if @tokens.key?(name)
115
+
116
+ super
117
+ end
118
+
119
+ def respond_to_missing?(name, include_private = false)
120
+ @tokens.key?(name) || super
121
+ end
122
+
123
+ private
124
+
125
+ def build_style(spec)
126
+ return spec if spec.is_a?(Style)
127
+ return UI.style.foreground(spec) unless spec.is_a?(Hash)
128
+
129
+ apply_options(UI.style, symbolize_keys(spec))
130
+ end
131
+
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
137
+
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
144
+
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
150
+
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
159
+ end
160
+
161
+ def apply_border(base_style, border_spec)
162
+ return base_style.border(border_spec) unless border_spec.is_a?(Hash)
163
+
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
171
+
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
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -4,11 +4,11 @@
4
4
  "background": "background",
5
5
  "palette": {
6
6
  "bright": "#9FE8B0",
7
- "muted": "#5A8A68",
7
+ "muted": "#7FB98C",
8
8
  "subtle": "#788E80",
9
9
  "background": "#111A2C",
10
10
  "selected": "#18233D",
11
- "divider": "#2A3752",
11
+ "divider": "#536B91",
12
12
  "amber": "#FFB347",
13
13
  "cyan": "#6FD0E3"
14
14
  },
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
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/
13
+
14
+ module_function
15
+
16
+ def measure(value)
17
+ Unicode::DisplayWidth.of(strip_ansi(value.to_s))
18
+ end
19
+
20
+ def strip_ansi(value)
21
+ value.to_s.gsub(ANSI_PATTERN, "")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
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
11
+
12
+ # Builds a new {Style} instance for chaining color, padding, alignment, and other visual properties.
13
+ def style
14
+ Style.new
15
+ end
16
+
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
23
+
24
+ Array.new(block_height(normalized)) do |index|
25
+ horizontal_line(normalized, widths, index).join(separator)
26
+ end.join("\n")
27
+ end
28
+
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
34
+
35
+ # Centers a *block* within a canvas of the given *width* and *height*, then returns the result.
36
+ def center(block, width:, height:, background: nil)
37
+ place(block, width: width, height: height, top: :center, left: :center, background: background)
38
+ end
39
+
40
+ # Draws *overlay* on top of a base at the specified *top* (row) and *left* (column) coordinates,
41
+ # defaulting to center in both directions. ANSI styling on the base content is preserved underneath.
42
+ def overlay(base, overlay, top: :center, left: :center)
43
+ base_lines = base.to_s.lines(chomp: true)
44
+ overlay_lines = overlay.to_s.lines(chomp: true)
45
+ width = block_width(base_lines)
46
+ row = offset(top, base_lines.length, overlay_lines.length)
47
+ column = offset(left, width, block_width(overlay_lines))
48
+
49
+ draw_lines(base_lines, overlay_lines, row: row, column: column, width: width)
50
+ end
51
+
52
+ # Places a *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
53
+ # and *left* (column). Non-:center values are treated as absolute positions. When *background* is
54
+ # given, the assembled frame is wrapped so the theme bg paints the entire canvas — overlay content
55
+ # with its own bg overrides per-cell; resets re-apply the canvas bg.
56
+ def place(block, width:, height:, top: 0, left: 0, background: nil)
57
+ lines = block.to_s.lines(chomp: true)
58
+ row = offset(top, height, lines.length)
59
+ column = offset(left, width, block_width(lines))
60
+ canvas = Array.new(height) { " " * width }
61
+ composed = draw_lines(canvas, lines, row: row, column: column, width: width)
62
+ return composed unless background
63
+
64
+ Style.new.background(background).render(composed)
65
+ end
66
+
67
+ # Normalizes an array of mixed objects into arrays of lines by calling `#to_s` on each element.
68
+ def normalize_blocks(blocks)
69
+ blocks.map { |block| block.to_s.lines(chomp: true) }
70
+ end
71
+
72
+ # Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
73
+ def block_widths(blocks)
74
+ blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
75
+ end
76
+
77
+ # Returns the maximum visual character width across all *lines*, accounting for multi-column characters
78
+ # (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
79
+ def block_width(lines)
80
+ lines.map { |line| Width.measure(line) }.max || 0
81
+ end
82
+
83
+ # Returns the height in rows of each normalised block, taking the maximum across all blocks.
84
+ def block_height(blocks)
85
+ blocks.map(&:length).max || 0
86
+ end
87
+
88
+ # Builds a single horizontal row by concatenating one line from each *block* at index *index*, padding
89
+ # every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
90
+ def horizontal_line(blocks, widths, index)
91
+ blocks.each_with_index.map do |lines, block_index|
92
+ line = lines[index] || ""
93
+ line + (" " * (widths[block_index] - Width.measure(line)))
94
+ end
95
+ end
96
+
97
+ # Computes a placement coordinate: if *value* is `:center` the result centres the *size* within *available*;
98
+ # otherwise *value* is returned verbatim as an absolute integer position.
99
+ def offset(value, available, size)
100
+ return [(available - size) / 2, 0].max if value == :center
101
+
102
+ value
103
+ end
104
+
105
+ # Merges an *overlay_line* into a *base_line* at the given *column*, returning the combined string. The
106
+ # overlay replaces (covers) underlying characters; anything to the right that exceeds *width* is truncated.
107
+ def composed_overlay_line(base_line, overlay_line, column, width)
108
+ return visible_slice(base_line, 0, width) if column >= width
109
+ return visible_slice(base_line, 0, width) if column + Width.measure(overlay_line) <= 0
110
+
111
+ target_column = [column, 0].max
112
+ overlay_start = [0 - column, 0].max
113
+ overlay = visible_slice(overlay_line, overlay_start, width - target_column)
114
+ overlay_width = Width.measure(overlay)
115
+ return visible_slice(base_line, 0, width) if overlay_width.zero?
116
+
117
+ right_column = target_column + overlay_width
118
+
119
+ visible_slice(base_line, 0, target_column) +
120
+ overlay +
121
+ visible_slice(base_line, right_column, [width - right_column, 0].max)
122
+ end
123
+
124
+ # Returns a visible-slice of *line* starting at *start_column* spanning *width* characters, preserving any
125
+ # ANSI escape sequences that were active at the start of the slice. Non-positive widths return `""`.
126
+ def visible_slice(line, start_column, width)
127
+ return "" unless width.positive?
128
+
129
+ slice_visible_text(line.to_s, start_column, start_column + width)
130
+ end
131
+
132
+ # Slices a string by visible terminal columns while preserving ANSI style state.
133
+ def slice_visible_text(line, start_column, end_column)
134
+ state = {column: 0, output: +"", active: [], started: false, styled: false}
135
+
136
+ each_ansi_or_char(line) do |token, ansi|
137
+ if ansi
138
+ slice_ansi(token, state, start_column, end_column)
139
+ else
140
+ slice_char(token, state, start_column, end_column)
141
+ end
142
+ end
143
+
144
+ terminate_slice(state)
145
+ end
146
+
147
+ # Splits a *line* into token-range pieces bounded by *start_column* and *end_column*, preserving ANSI escapes
148
+ # that fall within the visible range. Yields each character or escape sequence along with whether it is ANSI.
149
+ def each_ansi_or_char(line)
150
+ index = 0
151
+ while index < line.length
152
+ match = line.match(Width::ANSI_PATTERN, index)
153
+ if match&.begin(0) == index
154
+ yield match[0], true
155
+ index = match.end(0)
156
+ else
157
+ char = line[index]
158
+ yield char, false
159
+ index += 1
160
+ end
161
+ end
162
+ end
163
+
164
+ # Slices an ANSI *token* (escape sequence) into *state*, writing active markers to the output if the current
165
+ # *column* falls within the [start_column, end_column) range. Resets styles on `[0m` sequences.
166
+ def slice_ansi(token, state, start_column, end_column)
167
+ started = state[:started]
168
+ update_active_styles(state[:active], token)
169
+ return unless state[:column].between?(start_column, end_column - 1)
170
+
171
+ start_slice(state)
172
+ if started
173
+ state[:output] << token
174
+ state[:styled] = !token.include?("[0m")
175
+ end
176
+ end
177
+
178
+ # Slices a plain *char* into *state*, advancing the column tracker by the character's visual width. If the
179
+ # character overlaps with the [start_column, end_column) range it is appended to the output.
180
+ def slice_char(char, state, start_column, end_column)
181
+ char_width = Width.measure(char)
182
+ char_start = state[:column]
183
+ char_end = char_start + char_width
184
+ state[:column] = char_end
185
+ return unless char_end > start_column && char_start < end_column
186
+
187
+ start_slice(state)
188
+ state[:output] << char
189
+ end
190
+
191
+ # Starts writing to the output buffer, flushing any active ANSI markers if this is the first character placed.
192
+ def start_slice(state)
193
+ return if state[:started]
194
+
195
+ state[:output] << state[:active].join
196
+ state[:styled] = true unless state[:active].empty?
197
+ state[:started] = true
198
+ end
199
+
200
+ # Closes the slice by appending a final `[0m` reset escape to the output unless no active styling exists or
201
+ # nothing was written. Returns the fully constructed output string with trailing reset applied.
202
+ def terminate_slice(state)
203
+ return state[:output] if !state[:styled] || state[:output].empty?
204
+
205
+ "#{state[:output]}\e[0m"
206
+ end
207
+
208
+ # Updates *state*[:active] with an ANSI *token*: resets all active styles on `[0m` or appends the token as a
209
+ # new active marker otherwise. Called during each_ansi_or_char iteration.
210
+ def update_active_styles(active, token)
211
+ if token.include?("[0m")
212
+ active.clear
213
+ else
214
+ active << token
215
+ end
216
+ end
217
+
218
+ # Overlays *lines* onto a *canvas* starting at (*row*, *column*), writing each overlaid line into the canvas
219
+ # via `composed_overlay_line`. Returns the final canvas joined by newlines.
220
+ def draw_lines(canvas, lines, row:, column:, width:)
221
+ lines.each_with_index do |line, index|
222
+ line_index = row + index
223
+ next if line_index.negative? || line_index >= canvas.length
224
+
225
+ canvas[line_index] = composed_overlay_line(canvas[line_index], line, column, width)
226
+ end
227
+
228
+ canvas.join("\n")
229
+ end
230
+ end
231
+ end
232
+ end