charming 0.1.1 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +113 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +15 -0
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -5,10 +5,18 @@ require "unicode/display_width"
5
5
  module Charming
6
6
  module Presentation
7
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.
8
13
  class Viewport < Component
9
14
  include KeyboardHandler
10
15
 
16
+ # Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
11
17
  ANSI_PATTERN = /\e\[[0-9;]*m/
18
+
19
+ # Maps scroll keys to the instance methods that perform them via KeyboardHandler.
12
20
  KEY_ACTIONS = {
13
21
  up: :scroll_up,
14
22
  down: :scroll_down,
@@ -20,8 +28,12 @@ module Charming
20
28
  right: :scroll_right
21
29
  }.freeze
22
30
 
31
+ # The current top-visible row and left-visible column, respectively.
23
32
  attr_reader :offset, :column
24
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.
25
37
  def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
26
38
  super()
27
39
  @content = content
@@ -34,10 +46,13 @@ module Charming
34
46
  clamp_position
35
47
  end
36
48
 
49
+ # Renders the visible window of content as a multi-line string.
37
50
  def render
38
51
  visible_lines.map { |line| render_line(line) }.join("\n")
39
52
  end
40
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.
41
56
  def handle_mouse(event)
42
57
  return nil unless height
43
58
 
@@ -62,51 +77,61 @@ module Charming
62
77
 
63
78
  attr_reader :content, :width, :height
64
79
 
80
+ # Scrolls the viewport up by one row.
65
81
  def scroll_up
66
82
  @offset -= 1
67
83
  clamp_position
68
84
  end
69
85
 
86
+ # Scrolls the viewport down by one row.
70
87
  def scroll_down
71
88
  @offset += 1
72
89
  clamp_position
73
90
  end
74
91
 
92
+ # Scrolls up by one viewport page.
75
93
  def page_up
76
94
  @offset -= page_size
77
95
  clamp_position
78
96
  end
79
97
 
98
+ # Scrolls down by one viewport page.
80
99
  def page_down
81
100
  @offset += page_size
82
101
  clamp_position
83
102
  end
84
103
 
104
+ # Scrolls to the top-left of the content.
85
105
  def scroll_home
86
106
  @offset = 0
87
107
  @column = 0
88
108
  end
89
109
 
110
+ # Scrolls to the bottom-right of the content.
90
111
  def scroll_end
91
112
  @offset = max_offset
92
113
  @column = max_column
93
114
  end
94
115
 
116
+ # Scrolls one column left.
95
117
  def scroll_left
96
118
  @column -= 1
97
119
  clamp_position
98
120
  end
99
121
 
122
+ # Scrolls one column right.
100
123
  def scroll_right
101
124
  @column += 1
102
125
  clamp_position
103
126
  end
104
127
 
128
+ # Clamps both the row offset and the column to their valid ranges.
105
129
  def clamp_position
106
130
  @offset = offset.clamp(0, max_offset)
107
131
  @column = column.clamp(0, max_column)
108
132
  end
109
133
 
134
+ # Returns the slice of content lines visible in the current viewport, padded to *height*.
110
135
  def visible_lines
111
136
  lines = content_lines.slice(offset, viewport_height) || []
112
137
  return lines unless height
@@ -114,6 +139,8 @@ module Charming
114
139
  lines + Array.new([height - lines.length, 0].max, "")
115
140
  end
116
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.
117
144
  def render_line(line)
118
145
  return line unless width
119
146
  return pad_line(line, width) if wrap?
@@ -121,11 +148,14 @@ module Charming
121
148
  pad_line(clip_line(line), width)
122
149
  end
123
150
 
151
+ # Clips *line* to the visible column window while preserving active ANSI styling.
124
152
  def clip_line(line)
125
153
  clipped = clip_tokens(line.to_s)
126
154
  needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
127
155
  end
128
156
 
157
+ # Walks *line* token-by-token, copying ANSI escapes through and emitting only the
158
+ # characters that fall inside the visible column window.
129
159
  def clip_tokens(line)
130
160
  state = {cursor: 0, output: +""}
131
161
  line.scan(/#{ANSI_PATTERN}|./mo) do |token|
@@ -134,10 +164,13 @@ module Charming
134
164
  state.fetch(:output)
135
165
  end
136
166
 
167
+ # Appends an ANSI escape token to the output buffer unchanged.
137
168
  def append_ansi(state, token)
138
169
  state.fetch(:output) << token
139
170
  end
140
171
 
172
+ # Appends a single character token to the output buffer when it falls inside the
173
+ # visible column window, advancing the visual cursor.
141
174
  def append_character(state, char)
142
175
  char_width = Unicode::DisplayWidth.of(char)
143
176
  cursor = state.fetch(:cursor)
@@ -145,28 +178,36 @@ module Charming
145
178
  state[:cursor] = cursor + char_width
146
179
  end
147
180
 
181
+ # True when the character at *cursor* (with the given display *char_width*) is within
182
+ # the visible column window.
148
183
  def visible?(cursor, char_width)
149
184
  cursor >= column && cursor + char_width <= column + width
150
185
  end
151
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.
152
189
  def needs_reset?(value)
153
190
  value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
154
191
  end
155
192
 
193
+ # Pads *line* to *target_width* with trailing spaces, leaving the line itself unchanged.
156
194
  def pad_line(line, target_width)
157
195
  line + (" " * [target_width - UI::Width.measure(line), 0].max)
158
196
  end
159
197
 
198
+ # Returns the content lines, wrapped to *width* when wrap is enabled.
160
199
  def content_lines
161
200
  return wrapped_content_lines if wrap?
162
201
 
163
202
  rendered_content.lines(chomp: true)
164
203
  end
165
204
 
205
+ # Wraps the content to *width* via UI::visible_slice, returning an array of wrapped lines.
166
206
  def wrapped_content_lines
167
207
  rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
168
208
  end
169
209
 
210
+ # Wraps a single *line* into chunks of *width* display columns.
170
211
  def wrap_line(line)
171
212
  line_width = UI::Width.measure(line)
172
213
  return [""] if line_width.zero?
@@ -180,22 +221,30 @@ module Charming
180
221
  out
181
222
  end
182
223
 
224
+ # Returns the rendered content string, calling `render.to_s` on the content object when
225
+ # it responds to render.
183
226
  def rendered_content
184
227
  content.respond_to?(:render) ? content.render.to_s : content.to_s
185
228
  end
186
229
 
230
+ # Returns the visible row count (the configured *height* or the content's line count).
187
231
  def viewport_height
188
232
  height || content_lines.length
189
233
  end
190
234
 
235
+ # Returns the number of rows to advance on a page up/down: at least 1, otherwise the
236
+ # viewport height.
191
237
  def page_size
192
238
  [viewport_height, 1].max
193
239
  end
194
240
 
241
+ # Returns the maximum allowed row offset (so the bottom of the content is reachable).
195
242
  def max_offset
196
243
  [content_lines.length - viewport_height, 0].max
197
244
  end
198
245
 
246
+ # Returns the maximum allowed column offset. Returns 0 when wrapping is enabled or
247
+ # when no width is configured.
199
248
  def max_column
200
249
  return 0 if wrap?
201
250
  return 0 unless width
@@ -203,14 +252,17 @@ module Charming
203
252
  [content_width - width, 0].max
204
253
  end
205
254
 
255
+ # Returns the maximum display width across all content lines.
206
256
  def content_width
207
257
  content_lines.map { |line| UI::Width.measure(line) }.max || 0
208
258
  end
209
259
 
260
+ # True when *token* is an ANSI escape sequence.
210
261
  def ansi?(token)
211
262
  token.match?(ANSI_PATTERN)
212
263
  end
213
264
 
265
+ # True when soft-wrapping is enabled and a positive width is configured.
214
266
  def wrap?
215
267
  @wrap && width&.positive?
216
268
  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