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
@@ -1,178 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Charming
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
23
-
24
- def self.load_file(path)
25
- from_hash(JSON.parse(File.read(path)))
26
- end
27
-
28
- def self.load_builtin(name)
29
- load_file(built_in_path(name))
30
- end
31
-
32
- def self.built_in_names
33
- Dir.glob(File.join(BUILT_IN_ROOT, "*.json")).map { |path| File.basename(path, ".json") }.sort
34
- end
35
-
36
- def self.from_hash(value)
37
- raise ArgumentError, "theme file must contain an object" unless value.is_a?(Hash)
38
-
39
- styles = value.fetch("styles") do
40
- raise ArgumentError, "theme file must contain styles"
41
- end
42
-
43
- palette = value.fetch("palette", {})
44
- new(
45
- resolve_palette_references(styles, palette),
46
- background: resolve_background(value["background"], palette)
47
- )
48
- end
49
-
50
- def self.resolve_background(value, palette)
51
- return unless value
52
-
53
- deep_resolve_colors(value, normalize_colors(palette))
54
- end
55
-
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)
59
-
60
- File.join(BUILT_IN_ROOT, "#{slug}.json")
61
- end
62
-
63
- def self.resolve_palette_references(styles, palette)
64
- palette = normalize_colors(palette)
65
- deep_resolve_colors(styles, palette)
66
- end
67
-
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
78
- end
79
- end
80
-
81
- def self.normalize_colors(values)
82
- values.transform_values { |value| normalize_color(value) }.compact
83
- end
84
-
85
- def self.normalize_color(value)
86
- return unless value.is_a?(String)
87
-
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]
93
- end
94
- end
95
-
96
- attr_reader :background
97
-
98
- def initialize(tokens = {}, background: nil)
99
- @tokens = symbolize_keys(tokens)
100
- @background = background
101
- end
102
-
103
- def style(name)
104
- spec = @tokens.fetch(name.to_sym) do
105
- raise ArgumentError, "unknown theme token: #{name.inspect}"
106
- end
107
-
108
- build_style(spec)
109
- end
110
- alias_method :[], :style
111
-
112
- def method_missing(name, ...)
113
- return style(name) if @tokens.key?(name)
114
-
115
- super
116
- end
117
-
118
- def respond_to_missing?(name, include_private = false)
119
- @tokens.key?(name) || super
120
- end
121
-
122
- private
123
-
124
- def build_style(spec)
125
- return spec if spec.is_a?(Style)
126
- return UI.style.foreground(spec) unless spec.is_a?(Hash)
127
-
128
- apply_options(UI.style, symbolize_keys(spec))
129
- end
130
-
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
136
-
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
143
-
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
147
- end
148
- end
149
-
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
159
-
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
170
-
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
174
- end
175
- end
176
- end
177
- end
178
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "unicode/display_width"
4
-
5
- module Charming
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/
12
-
13
- module_function
14
-
15
- def measure(value)
16
- Unicode::DisplayWidth.of(strip_ansi(value.to_s))
17
- end
18
-
19
- def strip_ansi(value)
20
- value.to_s.gsub(ANSI_PATTERN, "")
21
- end
22
- end
23
- end
24
- end
data/lib/charming/ui.rb DELETED
@@ -1,230 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
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
10
-
11
- # Builds a new {Style} instance for chaining color, padding, alignment, and other visual properties.
12
- def style
13
- Style.new
14
- end
15
-
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
22
-
23
- Array.new(block_height(normalized)) do |index|
24
- horizontal_line(normalized, widths, index).join(separator)
25
- end.join("\n")
26
- end
27
-
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
33
-
34
- # Centers a *block* within a canvas of the given *width* and *height*, then returns the result.
35
- def center(block, width:, height:, background: nil)
36
- place(block, width: width, height: height, top: :center, left: :center, background: background)
37
- end
38
-
39
- # Draws *overlay* on top of a base at the specified *top* (row) and *left* (column) coordinates,
40
- # defaulting to center in both directions. ANSI styling on the base content is preserved underneath.
41
- def overlay(base, overlay, top: :center, left: :center)
42
- base_lines = base.to_s.lines(chomp: true)
43
- overlay_lines = overlay.to_s.lines(chomp: true)
44
- width = block_width(base_lines)
45
- row = offset(top, base_lines.length, overlay_lines.length)
46
- column = offset(left, width, block_width(overlay_lines))
47
-
48
- draw_lines(base_lines, overlay_lines, row: row, column: column, width: width)
49
- end
50
-
51
- # Places a *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
52
- # and *left* (column). Non-:center values are treated as absolute positions. When *background* is
53
- # given, the assembled frame is wrapped so the theme bg paints the entire canvas — overlay content
54
- # with its own bg overrides per-cell; resets re-apply the canvas bg.
55
- def place(block, width:, height:, top: 0, left: 0, background: nil)
56
- lines = block.to_s.lines(chomp: true)
57
- row = offset(top, height, lines.length)
58
- column = offset(left, width, block_width(lines))
59
- canvas = Array.new(height) { " " * width }
60
- composed = draw_lines(canvas, lines, row: row, column: column, width: width)
61
- return composed unless background
62
-
63
- Style.new.background(background).render(composed)
64
- end
65
-
66
- # Normalizes an array of mixed objects into arrays of lines by calling `#to_s` on each element.
67
- def normalize_blocks(blocks)
68
- blocks.map { |block| block.to_s.lines(chomp: true) }
69
- end
70
-
71
- # Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
72
- def block_widths(blocks)
73
- blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
74
- end
75
-
76
- # Returns the maximum visual character width across all *lines*, accounting for multi-column characters
77
- # (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
78
- def block_width(lines)
79
- lines.map { |line| Width.measure(line) }.max || 0
80
- end
81
-
82
- # Returns the height in rows of each normalised block, taking the maximum across all blocks.
83
- def block_height(blocks)
84
- blocks.map(&:length).max || 0
85
- end
86
-
87
- # Builds a single horizontal row by concatenating one line from each *block* at index *index*, padding
88
- # every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
89
- def horizontal_line(blocks, widths, index)
90
- blocks.each_with_index.map do |lines, block_index|
91
- line = lines[index] || ""
92
- line + (" " * (widths[block_index] - Width.measure(line)))
93
- end
94
- end
95
-
96
- # Computes a placement coordinate: if *value* is `:center` the result centres the *size* within *available*;
97
- # otherwise *value* is returned verbatim as an absolute integer position.
98
- def offset(value, available, size)
99
- return [(available - size) / 2, 0].max if value == :center
100
-
101
- value
102
- end
103
-
104
- # Merges an *overlay_line* into a *base_line* at the given *column*, returning the combined string. The
105
- # overlay replaces (covers) underlying characters; anything to the right that exceeds *width* is truncated.
106
- def composed_overlay_line(base_line, overlay_line, column, width)
107
- return visible_slice(base_line, 0, width) if column >= width
108
- return visible_slice(base_line, 0, width) if column + Width.measure(overlay_line) <= 0
109
-
110
- target_column = [column, 0].max
111
- overlay_start = [0 - column, 0].max
112
- overlay = visible_slice(overlay_line, overlay_start, width - target_column)
113
- overlay_width = Width.measure(overlay)
114
- return visible_slice(base_line, 0, width) if overlay_width.zero?
115
-
116
- right_column = target_column + overlay_width
117
-
118
- visible_slice(base_line, 0, target_column) +
119
- overlay +
120
- visible_slice(base_line, right_column, [width - right_column, 0].max)
121
- end
122
-
123
- # Returns a visible-slice of *line* starting at *start_column* spanning *width* characters, preserving any
124
- # ANSI escape sequences that were active at the start of the slice. Non-positive widths return `""`.
125
- def visible_slice(line, start_column, width)
126
- return "" unless width.positive?
127
-
128
- slice_visible_text(line.to_s, start_column, start_column + width)
129
- end
130
-
131
- # Slices a string by visible terminal columns while preserving ANSI style state.
132
- def slice_visible_text(line, start_column, end_column)
133
- state = {column: 0, output: +"", active: [], started: false, styled: false}
134
-
135
- each_ansi_or_char(line) do |token, ansi|
136
- if ansi
137
- slice_ansi(token, state, start_column, end_column)
138
- else
139
- slice_char(token, state, start_column, end_column)
140
- end
141
- end
142
-
143
- terminate_slice(state)
144
- end
145
-
146
- # Splits a *line* into token-range pieces bounded by *start_column* and *end_column*, preserving ANSI escapes
147
- # that fall within the visible range. Yields each character or escape sequence along with whether it is ANSI.
148
- def each_ansi_or_char(line)
149
- index = 0
150
- while index < line.length
151
- match = line.match(Width::ANSI_PATTERN, index)
152
- if match&.begin(0) == index
153
- yield match[0], true
154
- index = match.end(0)
155
- else
156
- char = line[index]
157
- yield char, false
158
- index += 1
159
- end
160
- end
161
- end
162
-
163
- # Slices an ANSI *token* (escape sequence) into *state*, writing active markers to the output if the current
164
- # *column* falls within the [start_column, end_column) range. Resets styles on `[0m` sequences.
165
- def slice_ansi(token, state, start_column, end_column)
166
- started = state[:started]
167
- update_active_styles(state[:active], token)
168
- return unless state[:column].between?(start_column, end_column - 1)
169
-
170
- start_slice(state)
171
- if started
172
- state[:output] << token
173
- state[:styled] = !token.include?("[0m")
174
- end
175
- end
176
-
177
- # Slices a plain *char* into *state*, advancing the column tracker by the character's visual width. If the
178
- # character overlaps with the [start_column, end_column) range it is appended to the output.
179
- def slice_char(char, state, start_column, end_column)
180
- char_width = Width.measure(char)
181
- char_start = state[:column]
182
- char_end = char_start + char_width
183
- state[:column] = char_end
184
- return unless char_end > start_column && char_start < end_column
185
-
186
- start_slice(state)
187
- state[:output] << char
188
- end
189
-
190
- # Starts writing to the output buffer, flushing any active ANSI markers if this is the first character placed.
191
- def start_slice(state)
192
- return if state[:started]
193
-
194
- state[:output] << state[:active].join
195
- state[:styled] = true unless state[:active].empty?
196
- state[:started] = true
197
- end
198
-
199
- # Closes the slice by appending a final `[0m` reset escape to the output unless no active styling exists or
200
- # nothing was written. Returns the fully constructed output string with trailing reset applied.
201
- def terminate_slice(state)
202
- return state[:output] if !state[:styled] || state[:output].empty?
203
-
204
- "#{state[:output]}\e[0m"
205
- end
206
-
207
- # Updates *state*[:active] with an ANSI *token*: resets all active styles on `[0m` or appends the token as a
208
- # new active marker otherwise. Called during each_ansi_or_char iteration.
209
- def update_active_styles(active, token)
210
- if token.include?("[0m")
211
- active.clear
212
- else
213
- active << token
214
- end
215
- end
216
-
217
- # Overlays *lines* onto a *canvas* starting at (*row*, *column*), writing each overlaid line into the canvas
218
- # via `composed_overlay_line`. Returns the final canvas joined by newlines.
219
- def draw_lines(canvas, lines, row:, column:, width:)
220
- lines.each_with_index do |line, index|
221
- line_index = row + index
222
- next if line_index.negative? || line_index >= canvas.length
223
-
224
- canvas[line_index] = composed_overlay_line(canvas[line_index], line, column, width)
225
- end
226
-
227
- canvas.join("\n")
228
- end
229
- end
230
- end
data/lib/charming/view.rb DELETED
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # View is the base class for all screen view implementations. It provides assign injection (via `initialize`),
5
- # rendering hooks, layout composition helpers (`row`, `column`, `render_component`, `yield_content`),
6
- # and access to controller theme, style, and focus state from within views.
7
- class View
8
- # Initializes the view with named assigns injected as instance-local accessor methods via
9
- # `define_singleton_method`. Called when a controller instantiates a view for rendering.
10
- def initialize(**assigns)
11
- @assigns = assigns
12
- define_assign_readers
13
- end
14
-
15
- # Returns all view assigns as a hash, used by layouts to compose the full template (content + screen + controller).
16
- def layout_assigns
17
- assigns
18
- end
19
-
20
- # Renders the view's body. Default is empty — subclasses override to return visible text.
21
- def render
22
- ""
23
- end
24
-
25
- # Delegates focus checking to the controller in assigns, allowing views to determine which slot (sidebar, content) has focus.
26
- def focused?(slot)
27
- ctrl = assigns[:focus_controller] || assigns[:controller]
28
- ctrl ? ctrl.focused?(slot) : false
29
- end
30
-
31
- private
32
-
33
- attr_reader :assigns
34
-
35
- # Returns the shared UI style configuration used by components and views for visual rendering (colors, borders).
36
- def style
37
- UI.style
38
- end
39
-
40
- # Returns the active theme: uses `theme` from assigns or controller, falling back to `UI::Theme.default`.
41
- def theme
42
- assigns[:theme] || assigns[:controller]&.theme || UI::Theme.default
43
- end
44
-
45
- # Outputs styled text through the view's rendering pipeline. Accepts a named `style:` for inline formatting.
46
- # Appends the rendered value to the output buffer and returns it.
47
- def text(value, style: nil)
48
- rendered = apply_style(value.to_s, style)
49
- append_to_buffer(rendered)
50
- rendered
51
- end
52
-
53
- # Renders a box with optional styling. Accepts an inline block for complex content or a plain value.
54
- # Used for bordered containers and field groups in views.
55
- def box(value = nil, style: nil, &)
56
- content = block_given? ? capture(&) : value.to_s
57
- apply_style(content, style)
58
- end
59
-
60
- # Joins items horizontally (side-by-side) using the UI rendering engine. Supports a `gap:` parameter.
61
- def row(*items, gap: 0)
62
- UI.join_horizontal(*items, gap: gap)
63
- end
64
-
65
- # Stacks items vertically using the UI rendering engine. Supports a `gap:` parameter for spacing.
66
- def column(*items, gap: 0)
67
- UI.join_vertical(*items, gap: gap)
68
- end
69
-
70
- # Renders a component (e.g., a ProgressBar, Spinner, Modal) and returns its string output.
71
- def render_component(component)
72
- component.render.to_s
73
- end
74
-
75
- # Renders a partial view component. An alias for `render_component` used in layout templates.
76
- def render_partial(partial)
77
- render_component(partial)
78
- end
79
-
80
- # Yields the layout's `content` slot — used by view templates to inject their body into a layout wrapper (e.g., sidebar).
81
- def yield_content
82
- assigns.fetch(:content, "")
83
- end
84
-
85
- # Evaluates a block in the view's context with a clean output buffer. Captures text written via `text`/`box`
86
- # and returns joined content. Resets buffer afterward for parent rendering.
87
- def capture(&)
88
- previous_buffer = @output_buffer
89
- @output_buffer = []
90
- result = instance_eval(&)
91
- @output_buffer.empty? ? result.to_s : @output_buffer.join("\n")
92
- ensure
93
- @output_buffer = previous_buffer
94
- end
95
-
96
- # Appends a value to the current output buffer (if one is active). Used by rendering helpers.
97
- def append_to_buffer(value)
98
- @output_buffer << value if @output_buffer
99
- end
100
-
101
- # Applies a style object's `render` method to a string, returning styled output or raw text when style is nil.
102
- def apply_style(value, style_object)
103
- style_object ? style_object.render(value) : value
104
- end
105
-
106
- # Dynamically defines read-only accessor methods for each assign key as singleton methods on self.
107
- # Skips keys where the view already responds (controller methods take precedence).
108
- def define_assign_readers
109
- assigns.each_key do |name|
110
- next if respond_to?(name, true)
111
-
112
- define_singleton_method(name) { assigns.fetch(name) }
113
- end
114
- end
115
- end
116
- end