charming 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Components
8
+ # Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
9
+ # (up/down/left/right, page up/down, home/end) and mouse interactions (scroll wheel and
10
+ # click-to-position). Lines are clipped with ANSI awareness via `UI::ANSISlicer` so styled
11
+ # text is preserved across horizontal scrolls. When `wrap:` is true, long lines are wrapped
12
+ # to the configured *width* before scrolling.
13
+ class Viewport < Component
14
+ include KeyboardHandler
15
+
16
+ # Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
17
+ ANSI_PATTERN = /\e\[[0-9;]*m/
18
+
19
+ # Maps scroll keys to the instance methods that perform them via KeyboardHandler.
20
+ KEY_ACTIONS = {
21
+ up: :scroll_up,
22
+ down: :scroll_down,
23
+ page_up: :page_up,
24
+ page_down: :page_down,
25
+ home: :scroll_home,
26
+ end: :scroll_end,
27
+ left: :scroll_left,
28
+ right: :scroll_right
29
+ }.freeze
30
+
31
+ # The current top-visible row and left-visible column, respectively.
32
+ attr_reader :offset, :column
33
+
34
+ # *content* may be a string, an array of lines, or any object responding to `render`.
35
+ # *width* and *height* constrain the visible window; *offset* is the top-visible row
36
+ # and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
37
+ def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
38
+ super()
39
+ @content = content
40
+ @width = width
41
+ @height = height
42
+ @offset = offset
43
+ @column = column
44
+ @wrap = wrap
45
+ @keymap = keymap
46
+ clamp_position
47
+ end
48
+
49
+ # Renders the visible window of content as a multi-line string.
50
+ def render
51
+ visible_lines.map { |line| render_line(line) }.join("\n")
52
+ end
53
+
54
+ # Handles mouse events: scroll wheel adjusts the row offset, click moves the top
55
+ # visible row to the clicked position. Returns :handled on success.
56
+ def handle_mouse(event)
57
+ return nil unless height
58
+
59
+ if event.scroll?
60
+ scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
61
+ @offset += scroll_delta
62
+ clamp_position
63
+ return :handled
64
+ end
65
+
66
+ return nil unless event.click?
67
+
68
+ clicked_row = event.y
69
+ return nil if clicked_row < offset || clicked_row >= offset + viewport_height
70
+
71
+ @offset = clicked_row
72
+ clamp_position
73
+ :handled
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :content, :width, :height
79
+
80
+ # Scrolls the viewport up by one row.
81
+ def scroll_up
82
+ @offset -= 1
83
+ clamp_position
84
+ end
85
+
86
+ # Scrolls the viewport down by one row.
87
+ def scroll_down
88
+ @offset += 1
89
+ clamp_position
90
+ end
91
+
92
+ # Scrolls up by one viewport page.
93
+ def page_up
94
+ @offset -= page_size
95
+ clamp_position
96
+ end
97
+
98
+ # Scrolls down by one viewport page.
99
+ def page_down
100
+ @offset += page_size
101
+ clamp_position
102
+ end
103
+
104
+ # Scrolls to the top-left of the content.
105
+ def scroll_home
106
+ @offset = 0
107
+ @column = 0
108
+ end
109
+
110
+ # Scrolls to the bottom-right of the content.
111
+ def scroll_end
112
+ @offset = max_offset
113
+ @column = max_column
114
+ end
115
+
116
+ # Scrolls one column left.
117
+ def scroll_left
118
+ @column -= 1
119
+ clamp_position
120
+ end
121
+
122
+ # Scrolls one column right.
123
+ def scroll_right
124
+ @column += 1
125
+ clamp_position
126
+ end
127
+
128
+ # Clamps both the row offset and the column to their valid ranges.
129
+ def clamp_position
130
+ @offset = offset.clamp(0, max_offset)
131
+ @column = column.clamp(0, max_column)
132
+ end
133
+
134
+ # Returns the slice of content lines visible in the current viewport, padded to *height*.
135
+ def visible_lines
136
+ lines = content_lines.slice(offset, viewport_height) || []
137
+ return lines unless height
138
+
139
+ lines + Array.new([height - lines.length, 0].max, "")
140
+ end
141
+
142
+ # Renders a single line according to the configured width and wrap mode: clips to the
143
+ # visible column window when not wrapping, otherwise wraps the line to the width.
144
+ def render_line(line)
145
+ return line unless width
146
+ return pad_line(line, width) if wrap?
147
+
148
+ pad_line(clip_line(line), width)
149
+ end
150
+
151
+ # Clips *line* to the visible column window while preserving active ANSI styling.
152
+ def clip_line(line)
153
+ clipped = clip_tokens(line.to_s)
154
+ needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
155
+ end
156
+
157
+ # Walks *line* token-by-token, copying ANSI escapes through and emitting only the
158
+ # characters that fall inside the visible column window.
159
+ def clip_tokens(line)
160
+ state = {cursor: 0, output: +""}
161
+ line.scan(/#{ANSI_PATTERN}|./mo) do |token|
162
+ ansi?(token) ? append_ansi(state, token) : append_character(state, token)
163
+ end
164
+ state.fetch(:output)
165
+ end
166
+
167
+ # Appends an ANSI escape token to the output buffer unchanged.
168
+ def append_ansi(state, token)
169
+ state.fetch(:output) << token
170
+ end
171
+
172
+ # Appends a single character token to the output buffer when it falls inside the
173
+ # visible column window, advancing the visual cursor.
174
+ def append_character(state, char)
175
+ char_width = Unicode::DisplayWidth.of(char)
176
+ cursor = state.fetch(:cursor)
177
+ state.fetch(:output) << char if visible?(cursor, char_width)
178
+ state[:cursor] = cursor + char_width
179
+ end
180
+
181
+ # True when the character at *cursor* (with the given display *char_width*) is within
182
+ # the visible column window.
183
+ def visible?(cursor, char_width)
184
+ cursor >= column && cursor + char_width <= column + width
185
+ end
186
+
187
+ # True when *value* contains ANSI codes but does not end with a reset — needed because
188
+ # the clip may truncate styling in the middle of a styled run.
189
+ def needs_reset?(value)
190
+ value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
191
+ end
192
+
193
+ # Pads *line* to *target_width* with trailing spaces, leaving the line itself unchanged.
194
+ def pad_line(line, target_width)
195
+ line + (" " * [target_width - UI::Width.measure(line), 0].max)
196
+ end
197
+
198
+ # Returns the content lines, wrapped to *width* when wrap is enabled.
199
+ def content_lines
200
+ return wrapped_content_lines if wrap?
201
+
202
+ rendered_content.lines(chomp: true)
203
+ end
204
+
205
+ # Wraps the content to *width* via UI::visible_slice, returning an array of wrapped lines.
206
+ def wrapped_content_lines
207
+ rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
208
+ end
209
+
210
+ # Wraps a single *line* into chunks of *width* display columns.
211
+ def wrap_line(line)
212
+ line_width = UI::Width.measure(line)
213
+ return [""] if line_width.zero?
214
+
215
+ start_column = 0
216
+ out = []
217
+ while start_column < line_width
218
+ out << UI.visible_slice(line, start_column, width)
219
+ start_column += width
220
+ end
221
+ out
222
+ end
223
+
224
+ # Returns the rendered content string, calling `render.to_s` on the content object when
225
+ # it responds to render.
226
+ def rendered_content
227
+ content.respond_to?(:render) ? content.render.to_s : content.to_s
228
+ end
229
+
230
+ # Returns the visible row count (the configured *height* or the content's line count).
231
+ def viewport_height
232
+ height || content_lines.length
233
+ end
234
+
235
+ # Returns the number of rows to advance on a page up/down: at least 1, otherwise the
236
+ # viewport height.
237
+ def page_size
238
+ [viewport_height, 1].max
239
+ end
240
+
241
+ # Returns the maximum allowed row offset (so the bottom of the content is reachable).
242
+ def max_offset
243
+ [content_lines.length - viewport_height, 0].max
244
+ end
245
+
246
+ # Returns the maximum allowed column offset. Returns 0 when wrapping is enabled or
247
+ # when no width is configured.
248
+ def max_column
249
+ return 0 if wrap?
250
+ return 0 unless width
251
+
252
+ [content_width - width, 0].max
253
+ end
254
+
255
+ # Returns the maximum display width across all content lines.
256
+ def content_width
257
+ content_lines.map { |line| UI::Width.measure(line) }.max || 0
258
+ end
259
+
260
+ # True when *token* is an ANSI escape sequence.
261
+ def ansi?(token)
262
+ token.match?(ANSI_PATTERN)
263
+ end
264
+
265
+ # True when soft-wrapping is enabled and a positive width is configured.
266
+ def wrap?
267
+ @wrap && width&.positive?
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Layout
6
+ # Builder turns a declarative `screen_layout { ... }` block into a layout tree of
7
+ # ScreenLayout → Split → Pane nodes. The block DSL is `split(direction) { ... }`,
8
+ # `pane(name) { ... }`, and `overlay { ... }`. Unknown method calls in the block are
9
+ # forwarded to the underlying view so view helpers (e.g., `text`) work inside layout blocks.
10
+ class Builder
11
+ # Builds the layout tree by evaluating the *block* in the builder's context.
12
+ # Returns the root ScreenLayout node.
13
+ def self.build(screen:, view:, background: nil, &)
14
+ new(screen: screen, view: view, background: background).build(&)
15
+ end
16
+
17
+ def initialize(screen:, view:, background: nil)
18
+ @view = view
19
+ @root = ScreenLayout.new(screen: screen, background: background)
20
+ @stack = [@root]
21
+ end
22
+
23
+ # Evaluates *block* in the builder's context, then returns the root ScreenLayout node.
24
+ def build(&)
25
+ instance_eval(&) if block_given?
26
+ root
27
+ end
28
+
29
+ # Adds a Split node to the current scope. *direction* is `:horizontal` or `:vertical`.
30
+ # *gap* (in cells) is inserted between children. Additional *options* are forwarded
31
+ # to Split. The block, if given, is evaluated in the split's scope (for nested children).
32
+ def split(direction, gap: 0, **options, &)
33
+ node = Split.new(direction: direction, gap: gap, **options)
34
+ append(node)
35
+ within(node, &)
36
+ node
37
+ end
38
+
39
+ # Adds a Pane leaf node to the current scope. *name* (optional) is the focus slot name;
40
+ # *content* (or a *block*) is the body. *options* are forwarded to Pane.
41
+ def pane(name = nil, content = nil, **options, &block)
42
+ node = Pane.new(name: name, content: content, block: block, view: view, **options)
43
+ append(node)
44
+ node
45
+ end
46
+
47
+ # Adds an Overlay node to the root ScreenLayout. *top* and *left* default to :center.
48
+ # The block, if given, is evaluated in the view's context.
49
+ def overlay(content = nil, top: :center, left: :center, **options, &block)
50
+ root.add_overlay(Overlay.new(content: content, block: block, view: view, top: top, left: left, **options))
51
+ end
52
+
53
+ # Forwards unknown method calls to the underlying view so helpers like `text` work
54
+ # inside layout blocks.
55
+ def respond_to_missing?(name, include_private = false)
56
+ view.respond_to?(name, include_private) || super
57
+ end
58
+
59
+ def method_missing(name, ...)
60
+ return view.__send__(name, ...) if view.respond_to?(name, true)
61
+
62
+ super
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :root, :stack, :view
68
+
69
+ # Appends *node* to the topmost scope on the stack.
70
+ def append(node)
71
+ stack.last.add_child(node)
72
+ end
73
+
74
+ # Pushes *node* onto the stack, evaluates *block* in the builder's context, then pops it.
75
+ def within(node, &)
76
+ return unless block_given?
77
+
78
+ stack.push(node)
79
+ instance_eval(&)
80
+ ensure
81
+ stack.pop
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Layout
6
+ # Overlay is a compositing node used by ScreenLayout for floating elements (modals,
7
+ # dialogs, command palettes). It positions its content at *top*/*left* (each may be
8
+ # `:center` or an absolute cell offset) and optionally sizes it via *width*/*height*
9
+ # with an outer *style*.
10
+ class Overlay
11
+ # The vertical and horizontal offset (cell count or `:center`) of the overlay
12
+ # within the parent canvas.
13
+ attr_reader :top, :left
14
+
15
+ # *content* (or a *block*) provides the body. *top*/*left* default to :center.
16
+ # *width*/*height* fix the overlay's dimensions; when unset, the content's natural
17
+ # size is used. *style* wraps the rendered content in a UI::Style.
18
+ def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil)
19
+ @content = content
20
+ @block = block
21
+ @view = view
22
+ @top = top
23
+ @left = left
24
+ @width = width
25
+ @height = height
26
+ @style = style
27
+ end
28
+
29
+ # Renders the overlay's content; when *width* or *height* is set, places the rendered
30
+ # content into a sized canvas before returning.
31
+ def render
32
+ return styled_content unless width || height
33
+
34
+ UI.place(styled_content, width: width || UI.block_width(styled_content.lines(chomp: true)), height: height || styled_content.lines.count)
35
+ end
36
+
37
+ private
38
+
39
+ # The raw content, body block, view, and sizing/style options.
40
+ attr_reader :content, :block, :view, :width, :height, :style
41
+
42
+ # Returns the rendered content wrapped in the configured *style* (when present).
43
+ def styled_content
44
+ return rendered_content unless style
45
+
46
+ style.render(rendered_content)
47
+ end
48
+
49
+ # Evaluates the content (block or constant) and returns its rendered string.
50
+ def rendered_content
51
+ value = block ? view.instance_exec(&block) : content
52
+ value.respond_to?(:render) ? value.render.to_s : value.to_s
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Layout
6
+ # Pane is a leaf layout node: a single rectangle with optional border, padding, and
7
+ # styling, containing a piece of content (a string, a View, or a block evaluated in the
8
+ # view's context). Panes with a `name` and `focus: true` are registered as focusable
9
+ # slots in the controller's focus ring.
10
+ class Pane
11
+ # The pane's focus slot name, fixed width, fixed height, and grow weight.
12
+ attr_reader :name, :width, :height, :grow
13
+
14
+ # *name* is the focus slot identifier (optional). *content* or *block* provides the body.
15
+ # *width*/*height*/*grow* control sizing. *border* may be `true` (normal border) or a
16
+ # border name symbol. *padding* may be 1, 2, or 4 values (CSS-style shorthand).
17
+ # *style* sets the base style; *focused_style* overrides it when the pane is focused.
18
+ # *focus: true* marks the pane as focusable. *scroll*/*clip*/*wrap* control how
19
+ # overflow content is rendered (via the embedded Viewport).
20
+ def initialize(name: nil, content: nil, block: nil, view: nil, width: nil, height: nil, grow: nil, border: nil, padding: nil, style: nil, focused_style: nil, focus: false, scroll: false, clip: true, wrap: false)
21
+ @name = name
22
+ @content = content
23
+ @block = block
24
+ @view = view
25
+ @width = width
26
+ @height = height
27
+ @grow = grow
28
+ @border = border
29
+ @padding = padding
30
+ @style = style
31
+ @focused_style = focused_style
32
+ @focus = focus
33
+ @scroll = scroll
34
+ @clip = clip
35
+ @wrap = wrap
36
+ end
37
+
38
+ # Raises ArgumentError — panes are leaves and cannot contain layout children.
39
+ def add_child(_node)
40
+ raise ArgumentError, "pane cannot contain layout children"
41
+ end
42
+
43
+ # Returns [name] when the pane is marked focusable and has a name, otherwise [].
44
+ def focusable_names
45
+ (focus && name) ? [name] : []
46
+ end
47
+
48
+ # Renders the pane into *rect*, applying the configured style, border, and padding
49
+ # around the evaluated content.
50
+ def render(rect)
51
+ outer_style(rect).render(rendered_content(rect))
52
+ end
53
+
54
+ private
55
+
56
+ # The raw content, the body block, the view used for instance_exec, and styling options.
57
+ attr_reader :content, :block, :view, :border, :padding, :style, :focused_style, :focus, :scroll, :clip, :wrap
58
+
59
+ # Returns the content string for *rect*, optionally clipped/scrolled by an embedded Viewport.
60
+ def rendered_content(rect)
61
+ value = evaluate_content
62
+ return value unless clip || scroll
63
+
64
+ Components::Viewport.new(content: value, width: inner_rect(rect).width, height: inner_rect(rect).height, wrap: wrap).render
65
+ end
66
+
67
+ # Evaluates the configured content (block or constant) and renders it to a string.
68
+ def evaluate_content
69
+ value = block ? view.instance_exec(&block) : content
70
+ value.respond_to?(:render) ? value.render.to_s : value.to_s
71
+ end
72
+
73
+ # Builds the outer style object with optional border and padding, sized to the
74
+ # inner rect of the pane.
75
+ def outer_style(rect)
76
+ styled = current_style
77
+ styled = styled.border(border_style) if border
78
+ styled = styled.padding(*padding_values) if padding
79
+ styled.width(inner_rect(rect).width).height(inner_rect(rect).height)
80
+ end
81
+
82
+ # Returns the active style: the focused variant when the pane is focused, otherwise
83
+ # the configured style or a default UI::Style.
84
+ def current_style
85
+ return focused_pane_style if focused?
86
+
87
+ style || UI.style
88
+ end
89
+
90
+ # Returns the focused-pane style: the focused_style override, or the theme's title style.
91
+ def focused_pane_style
92
+ focused_style || view.__send__(:theme).title
93
+ end
94
+
95
+ # True when the pane is configured for focus and the view reports it as currently focused.
96
+ def focused?
97
+ focus && name && view.focused?(name)
98
+ end
99
+
100
+ # Returns the inner Rect after border and padding insets are applied.
101
+ def inner_rect(rect)
102
+ rect.inset(
103
+ top: border_top + padding_top,
104
+ right: border_right + padding_right,
105
+ bottom: border_bottom + padding_bottom,
106
+ left: border_left + padding_left
107
+ )
108
+ end
109
+
110
+ # Resolves the border style symbol: :normal when border is `true`, otherwise the configured value.
111
+ def border_style
112
+ (border == true) ? :normal : border
113
+ end
114
+
115
+ # Border thickness on each side (1 when a border is configured, 0 otherwise).
116
+ def border_top = border ? 1 : 0
117
+ def border_right = border ? 1 : 0
118
+ def border_bottom = border ? 1 : 0
119
+ def border_left = border ? 1 : 0
120
+
121
+ # The padding values normalized to [top, right, bottom, left] form.
122
+ def padding_values
123
+ @padding_values ||= expand_padding(Array(padding))
124
+ end
125
+
126
+ # Per-side padding values (0 when no padding is configured).
127
+ def padding_top = padding ? padding_values[0] : 0
128
+ def padding_right = padding ? padding_values[1] : 0
129
+ def padding_bottom = padding ? padding_values[2] : 0
130
+ def padding_left = padding ? padding_values[3] : 0
131
+
132
+ # Normalizes 1/2/4 padding arguments to [top, right, bottom, left].
133
+ def expand_padding(values)
134
+ case values.length
135
+ when 1 then [values[0], values[0], values[0], values[0]]
136
+ when 2 then [values[0], values[1], values[0], values[1]]
137
+ when 4 then values
138
+ else
139
+ raise ArgumentError, "padding expects 1, 2, or 4 values"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Layout
6
+ # Rect is an immutable rectangle with a top-left position (x, y) and dimensions
7
+ # (width, height). Layout operations produce new Rect instances rather than mutating
8
+ # existing ones.
9
+ Rect = Data.define(:x, :y, :width, :height) do
10
+ # Returns a new Rect inset by *top*/*right*/*bottom*/*left* cells. The result is
11
+ # clamped to a minimum width/height of 0.
12
+ def inset(top: 0, right: 0, bottom: 0, left: 0)
13
+ Rect.new(
14
+ x: x + left,
15
+ y: y + top,
16
+ width: [width - left - right, 0].max,
17
+ height: [height - top - bottom, 0].max
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Layout
6
+ # ScreenLayout is the root of a layout tree. It owns a single child (typically a Split
7
+ # or Pane) rendered into the full terminal screen, and an ordered list of Overlays
8
+ # composited on top of the rendered body.
9
+ class ScreenLayout
10
+ # *screen* is the Charming::Screen whose dimensions define the layout area.
11
+ # *background* (optional) is a UI::Style applied to the empty canvas behind the body.
12
+ def initialize(screen:, background: nil)
13
+ @screen = screen
14
+ @background = background
15
+ @child = nil
16
+ @overlays = []
17
+ end
18
+
19
+ # Sets the single root child. Raises ArgumentError when a child is already present.
20
+ def add_child(node)
21
+ raise ArgumentError, "screen_layout accepts one root layout node" if child
22
+
23
+ @child = node
24
+ end
25
+
26
+ # Appends an overlay to be composited on top of the body, in registration order.
27
+ def add_overlay(node)
28
+ overlays << node
29
+ end
30
+
31
+ # Returns the focusable names from the child, or [] when no child has been added.
32
+ def focusable_names
33
+ child ? child.focusable_names : []
34
+ end
35
+
36
+ # Renders the child into the full-screen rect, then overlays each registered overlay
37
+ # on top in order.
38
+ def render
39
+ body = UI.place(render_child, width: screen.width, height: screen.height, background: background)
40
+
41
+ overlays.reduce(body) do |current, overlay|
42
+ UI.overlay(current, overlay.render, top: overlay.top, left: overlay.left)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # The screen, background style, the single child, and the list of overlays.
49
+ attr_reader :screen, :background, :child, :overlays
50
+
51
+ # Renders the child into a full-screen Rect, or returns an empty string when no child.
52
+ def render_child
53
+ return "" unless child
54
+
55
+ child.render(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end