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
@@ -8,115 +8,19 @@ module Charming
8
8
  TimerBinding = Data.define(:name, :interval, :action)
9
9
  TaskBinding = Data.define(:name, :action)
10
10
 
11
- class << self
12
- # Registers a key binding (string or symbol key name → method symbol).
13
- # Content-scoped bindings run from the main content pane; global bindings run from any pane.
14
- def key(name, action, scope: :content)
15
- normalized_scope = validate_key_scope(scope)
16
- key_name = name.to_sym
17
- key_bindings[key_name] = action
18
- key_binding_scopes[key_name] = normalized_scope
19
- end
20
-
21
- # Registers a command palette entry — visible in fuzzy search when Ctrl+K is pressed.
22
- # Accepts either a method symbol or an inline callable block.
23
- def command(label, action = nil, &block)
24
- command_bindings << Presentation::Components::CommandPalette::Command.new(label: label, value: block || action)
25
- end
26
-
27
- # Registers a periodic timer that fires at `every`-second intervals.
28
- # The named action is dispatched on the current route's controller each time.
29
- def timer(name, every:, action:)
30
- timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
31
- end
32
-
33
- # Registers an async task handler that runs when a TaskEvent arrives from the task executor.
34
- def on_task(name, action:)
35
- task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
36
- end
37
-
38
- # Re-renders the given action after dispatched actions that do not set a response.
39
- # This is opt-in so existing controllers keep explicit render semantics.
40
- def auto_render(action = :show)
41
- @auto_render_action = action.to_sym
42
- end
43
-
44
- def auto_render_action
45
- return @auto_render_action if instance_variable_defined?(:@auto_render_action)
46
- return superclass.auto_render_action if superclass.respond_to?(:auto_render_action)
47
-
48
- nil
49
- end
50
-
51
- # Sets the layout class to wrap this controller's rendered output (e.g., for sidebar + main content).
52
- # Accepts a special `:__charming_layout_reader__` sentinel to query — without setting — the current layout.
53
- def layout(layout_class = :__charming_layout_reader__)
54
- return resolved_layout if layout_class == :__charming_layout_reader__
55
-
56
- @layout = layout_class
57
- end
58
-
59
- # Returns inherited key bindings merged from the class hierarchy.
60
- # Each subclass gets a fresh copy of its parent's key bindings to avoid cross-controller pollution.
61
- def key_bindings
62
- @key_bindings ||= superclass.respond_to?(:key_bindings) ? superclass.key_bindings.dup : {}
63
- end
64
-
65
- # Returns inherited key binding scopes merged from the class hierarchy.
66
- # Each subclass gets a fresh copy of its parent's scopes to match key binding inheritance.
67
- def key_binding_scopes
68
- @key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
69
- end
70
-
71
- # Registers a focus ring slot for this controller — slots participate in Tab/Shift+Tab traversal.
72
- # Example: `focus_ring :sidebar, :content` makes sidebar and content tabbable.
73
- def focus_ring(*slots)
74
- @focus_ring_slots = slots
75
- end
76
-
77
- # Returns inherited focus ring slots merged from the class hierarchy.
78
- def focus_ring_slots
79
- @focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
80
- end
81
-
82
- # Returns inherited command bindings (command palette entries) from this controller and its ancestors.
83
- def command_bindings
84
- @command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
85
- end
86
-
87
- # Returns inherited timer bindings from this controller and its ancestors.
88
- def timer_bindings
89
- @timer_bindings ||= superclass.respond_to?(:timer_bindings) ? superclass.timer_bindings.dup : {}
90
- end
91
-
92
- # Returns inherited task bindings (async task handlers) from this controller and its ancestors.
93
- def task_bindings
94
- @task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {}
95
- end
96
-
97
- private
98
-
99
- def validate_key_scope(scope)
100
- normalized_scope = scope.to_sym
101
- return normalized_scope if %i[content global].include?(normalized_scope)
102
-
103
- raise ArgumentError, "unknown key scope: #{scope.inspect}"
104
- end
105
-
106
- # Returns the layout class for this controller, walking up the inheritance chain until one is found or nil.
107
- # Used internally by `layout` when called without args (getter mode).
108
- def resolved_layout
109
- return @layout if instance_variable_defined?(:@layout)
110
- return superclass.layout if superclass.respond_to?(:layout)
111
-
112
- nil
113
- end
114
- end
11
+ extend ClassMethods
12
+ include Rendering
13
+ include SessionState
14
+ include FocusManagement
15
+ include SidebarNavigation
16
+ include CommandPalette
17
+ include ComponentDispatching
18
+ include Dispatching
115
19
 
116
20
  attr_reader :application, :event, :params, :screen, :route
117
21
 
118
- # Initializes the controller with its parent application and an optional event (key/mouse/timer/task data).
119
- # Defaults to a 80x24 screen when no backend size is available.
22
+ # Initializes the controller with its parent application and optional event.
23
+ # Defaults to an 80x24 screen when no backend size is available.
120
24
  def initialize(application:, event: nil, params: {}, screen: nil, route: nil)
121
25
  @application = application
122
26
  @event = event
@@ -126,80 +30,81 @@ module Charming
126
30
  @response = nil
127
31
  end
128
32
 
129
- # Dispatches a named action on this controller (e.g., :show). Calls the method via public_send,
130
- # returning a default empty render if the action produces no response.
33
+ # Dispatches a named action on this controller (e.g. :show).
131
34
  def dispatch(action)
132
35
  public_send(action)
133
36
  render_default_action if response.nil? && auto_render_after?(action)
134
37
  response || render("")
135
38
  end
136
39
 
137
- # Key event dispatch pipeline for controllers: checks command palette first (if open),
138
- # then global key bindings, then sidebar (if focused), then content-scoped key bindings,
139
- # then tab traversal, then focused component handling. Returns nil if no handler consumed the event.
40
+ # Key event dispatch: checks command palette first, then global bindings,
41
+ # sidebar (if focused), content bindings, tab traversal, and focused component.
140
42
  def dispatch_key
141
43
  return dispatch_command_palette_key if command_palette_open?
142
44
  return dispatch(global_key_action) if global_key_action
143
45
  return dispatch_sidebar_key if sidebar_focused?
144
-
145
46
  return dispatch(content_key_action) if content_key_action
146
47
  return response if dispatch_tab_traversal == :handled
147
48
  return response if dispatch_to_focused_component == :handled
148
-
149
49
  nil
150
50
  end
151
51
 
152
- # Timer event dispatcher: looks up the event's named action in this controller's timer bindings
153
- # and dispatches it. Returns nil if no binding exists for this timer name.
52
+ # Timer event dispatcher: looks up the named action in timer bindings.
154
53
  def dispatch_timer
155
- binding = self.class.timer_bindings[event.name.to_sym]
156
- binding ? dispatch(binding.action) : nil
54
+ b = self.class.timer_bindings[event.name.to_sym]
55
+ return nil unless b
56
+
57
+ public_send(b.action)
58
+ response
157
59
  end
158
60
 
159
- # Task event dispatcher: looks up the event's named handler in this controller's task bindings
160
- # and dispatches it. Used by async tasks submitted via `run_task`.
61
+ # Task event dispatcher: looks up the handler in task bindings.
161
62
  def dispatch_task
162
- binding = self.class.task_bindings[event.name.to_sym]
163
- binding ? dispatch(binding.action) : nil
63
+ b = self.class.task_bindings[event.name.to_sym]
64
+ b ? dispatch(b.action) : nil
164
65
  end
165
66
 
166
- # Mouse event dispatcher: checks command palette (if open), then sidebar (if focused),
167
- # then falls through to component mouse dispatch. Always returns nil in the base controller —
168
- # subclasses override as needed.
67
+ # Mouse event dispatcher: checks command palette (if open), sidebar (if focused).
169
68
  def dispatch_mouse
170
69
  return dispatch_command_palette_mouse if command_palette_open?
171
70
  return dispatch_sidebar_mouse if sidebar_focused?
172
-
173
71
  dispatch_component_mouse
174
72
  end
175
73
 
176
- # Renders a body or template wrapped in this controller's layout (if one is defined) and stores the response.
177
- # Symbols render `app/views/<controller>/<symbol>.tui.erb` (or `.txt.erb`); strings render as literal bodies.
74
+ # Renders a body or template wrapped in the controller's layout.
178
75
  def render(body = "", **assigns)
179
- body = template_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
76
+ body = view_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
180
77
  @response = Response.render(render_with_layout(body))
181
78
  end
182
79
 
80
+ def render_view(view_class, **assigns)
81
+ @response = Response.render(render_with_layout(view_class.new(**template_assigns(assigns))))
82
+ end
83
+
84
+ # Renders a template from `app/views` by name, applying the controller's layout. *name* is the
85
+ # template path (e.g., "home/show") and additional keyword *assigns* are forwarded to the view.
183
86
  def render_template(name, **assigns)
184
87
  @response = Response.render(render_with_layout(template_body(name, **assigns)))
185
88
  end
186
89
 
90
+ # Returns the active theme for this request, delegated to the application.
187
91
  def theme
188
92
  application.theme
189
93
  end
190
94
 
95
+ # Switches the active theme to *name* and persists the choice in the application session.
191
96
  def use_theme(name)
192
97
  application.use_theme(name)
193
98
  end
194
99
 
100
+ # Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
195
101
  def open_theme_palette
196
102
  session[:command_palette] = command_palette_state(:themes)
197
103
  focus.push_scope([:command_palette], origin: :command_palette)
198
104
  render_default_action
199
105
  end
200
106
 
201
- # Responds with a navigation redirect to the given URL path.
202
- # Used for route transitions triggered from controllers (e.g., sidebar selection).
107
+ # Navigates to the given URL path.
203
108
  def navigate_to(path)
204
109
  @response = Response.navigate(path)
205
110
  end
@@ -209,437 +114,8 @@ module Charming
209
114
  @response = Response.quit
210
115
  end
211
116
 
212
- # Returns the parent application's session hash for per-request state storage (e.g., form data, flags).
213
- def session
214
- application.session
215
- end
216
-
217
- # Lazily instantiates a state class and caches it in the session under `:states`.
218
- # Subsequent calls with the same name return the cached instance. Used like: state(:home, HomeState)
219
- def state(name, state_class, **attributes)
220
- session[:states] ||= {}
221
- session[:states][name.to_sym] ||= state_class.new(**attributes)
222
- end
223
-
224
- def form(name, &block)
225
- session[:forms] ||= {}
226
- form_state = session[:forms][name.to_sym] ||= {}
227
- builder = Presentation::Components::Form::Builder.new(theme: theme)
228
- block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
229
- builder.build(state: form_state, theme: theme)
230
- end
231
-
232
- # Submits an async task to the application's task executor (threaded or inline).
233
- # The task runs in a background thread; results arrive as TaskEvents in `dispatch_task`.
234
- def run_task(name, &block)
235
- application.task_executor.submit(name, &block)
236
- end
237
-
238
- # Opens the command palette (fuzzy search UI): stores primitive palette state and pushes a palette
239
- # scope onto the focus ring so input is captured inside it. Renders the default action afterward.
240
- def open_command_palette
241
- session[:command_palette] = command_palette_state(:commands)
242
- focus.push_scope([:command_palette], origin: :command_palette)
243
- render_default_action
244
- end
245
-
246
- # Closes the command palette: removes its state from the session, pops its scope from the focus ring,
247
- # and re-renders the default action. Pops all nested scopes until only the palette remains.
248
- def close_command_palette
249
- session.delete(:command_palette)
250
- pop_command_palette_scope
251
- render_default_action
252
- end
253
-
254
- # Returns or lazily initializes the Focus instance for this controller, which manages
255
- # keyboard-driven focus traversal between components (sidebar, content, etc.).
256
- # Defines focus ring slots from class-level declarations on first access.
257
- def focus
258
- @focus ||= Focus.for(session, self.class).tap do |f|
259
- f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
260
- end
261
- end
262
-
263
- # Checks whether the given focus slot (e.g., :sidebar, :content) is currently focused.
264
- def focused?(slot)
265
- focus.focused?(slot)
266
- end
267
-
268
- # Returns whether the command palette is active in the current session.
269
- def command_palette_open?
270
- session.key?(:command_palette)
271
- end
272
-
273
- # Returns a command palette component rebuilt from the current primitive session state, if open.
274
- def command_palette
275
- build_command_palette_from_state(session[:command_palette]) if command_palette_open?
276
- end
277
-
278
- # Shifts focus to the sidebar: moves the focus ring cursor or sets `session[:focus]` to :sidebar,
279
- # highlights the current route index, and re-renders. Sidebar selection uses j/k keys.
280
- def focus_sidebar
281
- if focus_ring_slot?(:sidebar)
282
- focus.focus(:sidebar)
283
- else
284
- session[:focus] = :sidebar
285
- end
286
- session[:sidebar_index] ||= current_route_index
287
- render_default_action
288
- end
289
-
290
- # Shifts focus back to the main content area: moves the focus ring cursor or sets `session[:focus]` to :content,
291
- # and re-renders. Used by Escape key from sidebar and other navigation transitions.
292
- def focus_content
293
- if focus_ring_slot?(:content)
294
- focus.focus(:content)
295
- else
296
- session[:focus] = :content
297
- end
298
- render_default_action
299
- end
300
-
301
- # Returns whether the sidebar currently has focus (from focus ring or session state).
302
- def sidebar_focused?
303
- return focused?(:sidebar) if focus_ring_slot?(:sidebar)
304
-
305
- session[:focus] == :sidebar
306
- end
307
-
308
- # Returns whether the main content area currently has focus (from focus ring or session state).
309
- def content_focused?
310
- return focused?(:content) if focus_ring_slot?(:content)
311
-
312
- session[:focus] == :content
313
- end
314
-
315
- # Returns the currently highlighted sidebar item index, falling back to the current route's position
316
- # when no explicit sidebar selection has been made yet.
317
- def sidebar_index
318
- session[:sidebar_index] || current_route_index
319
- end
320
-
321
- def sidebar_routes
322
- application.routes.all
323
- end
324
-
325
- def current_route?(candidate)
326
- return candidate.controller_class == self.class && candidate.action == :show unless route
327
-
328
- candidate.path == route.path &&
329
- candidate.controller_class == route.controller_class &&
330
- candidate.action == route.action
331
- end
332
-
333
117
  private
334
118
 
335
- # Finds the position of this controller among all registered routes (for sidebar highlighting).
336
- # Returns 0 if no matching route is found.
337
- def current_route_index
338
- sidebar_routes.index { |candidate| current_route?(candidate) } || 0
339
- end
340
-
341
- # Checks whether the given slot is registered as a focus ring slot for this controller.
342
- def focus_ring_slot?(slot)
343
- self.class.focus_ring_slots.include?(slot)
344
- end
345
-
346
119
  attr_reader :response
347
-
348
- # Renders `body` as a string: calls `#render` if body responds to it (component), otherwise `#to_s`.
349
- def render_body(body)
350
- body.respond_to?(:render) ? body.render.to_s : body.to_s
351
- end
352
-
353
- # Wraps `body` rendering in this controller's layout (if one is defined).
354
- # If no layout is set, returns body as-is. Provides content, screen, and controller to the layout for composition.
355
- def render_with_layout(body)
356
- rendered = render_body(body)
357
- layout = self.class.layout
358
- return rendered unless layout
359
-
360
- render_body(layout_body(layout, body, rendered))
361
- end
362
-
363
- def layout_body(layout, body, rendered)
364
- assigns = layout_assigns(body, rendered)
365
- return template_body(layout, **assigns) if layout.is_a?(String) || layout.is_a?(Symbol)
366
-
367
- layout.new(**assigns)
368
- end
369
-
370
- # Provides view assigns for layout rendering: merges body-specific assigns with standard `content`, `screen`, and `controller`.
371
- def layout_assigns(body, rendered)
372
- view_assigns(body).merge(content: rendered, screen: screen, controller: self, theme: theme)
373
- end
374
-
375
- # Extracts layout assigns from the body if it responds to `#layout_assigns` (e.g., a component),
376
- # otherwise returns an empty hash. Used by layout rendering for composition.
377
- def view_assigns(body)
378
- body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
379
- end
380
-
381
- def template_body(name, **assigns)
382
- Presentation::TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
383
- end
384
-
385
- def resolve_template(name)
386
- Presentation::Templates.resolve(name, root: application.class.root)
387
- end
388
-
389
- def template_assigns(assigns)
390
- {screen: screen, controller: self, theme: theme}.merge(assigns)
391
- end
392
-
393
- def template_namespace
394
- namespace_name = application.class.namespace
395
- return nil if namespace_name.to_s.empty?
396
-
397
- Object.const_get(namespace_name)
398
- end
399
-
400
- def default_template_name(action)
401
- "#{controller_template_path}/#{action}"
402
- end
403
-
404
- def controller_template_path
405
- underscore(self.class.name.split("::").last.delete_suffix("Controller"))
406
- end
407
-
408
- def underscore(value)
409
- value
410
- .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
411
- .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
412
- .tr("-", "_")
413
- .downcase
414
- end
415
-
416
- # Extracts the normalized key from the current event, handling both KeyEvent objects and raw key strings.
417
- # Delegates to `Charming.key_of(event)` for event-to-key resolution.
418
- def key_name
419
- Charming.key_of(event)
420
- end
421
-
422
- # Dispatches a key event to the currently focused component (e.g., text input, list) by calling `#handle_key` on it.
423
- # Returns nil if no focused component or no handler consumed the key, otherwise :handled.
424
- def dispatch_to_focused_component
425
- slot = focus.current
426
- return nil unless slot && respond_to?(slot, true)
427
-
428
- component = send(slot)
429
- return nil unless component.respond_to?(:handle_key)
430
-
431
- result = component.handle_key(event)
432
- return nil if result.nil?
433
-
434
- dispatch_component_result(slot, result)
435
- :handled
436
- end
437
-
438
- def dispatch_component_result(slot, result)
439
- action, arguments = component_result_action(slot, result)
440
- action ? send(action, *arguments) : render_default_action
441
- render_default_action unless response
442
- end
443
-
444
- def component_result_action(slot, result)
445
- case result
446
- when :cancelled
447
- component_action(slot, :cancelled)
448
- when Array
449
- component_array_action(slot, result)
450
- end
451
- end
452
-
453
- def component_array_action(slot, result)
454
- event_name, value = result
455
- return component_action(slot, :submitted, value) if event_name == :submitted
456
- return component_action(slot, :selected, value) if event_name == :selected
457
-
458
- nil
459
- end
460
-
461
- def component_action(slot, suffix, *arguments)
462
- action = :"#{slot}_#{suffix}"
463
- return unless respond_to?(action, true)
464
-
465
- [action, arguments]
466
- end
467
-
468
- # Handles Tab/Shift-Tab traversal: moves focus forward or backward through the focus ring.
469
- # Only processes events that are actually Tab keypresses on an empty focus ring. Returns :handled when consumed.
470
- def dispatch_tab_traversal
471
- return nil unless key_name == :tab
472
- return nil if focus.ring.empty?
473
-
474
- focus.cycle(event.shift ? -1 : +1)
475
- render_default_action
476
- :handled
477
- end
478
-
479
- # Dispatches key events to an open command palette (fuzzy search). Handles cancellation (Escape),
480
- # command execution when a selection is made, and renders the default action if no response was produced.
481
- def dispatch_command_palette_key
482
- palette = command_palette
483
- result = palette.handle_key(event)
484
-
485
- if result == :cancelled
486
- close_command_palette
487
- elsif selected_command?(result)
488
- perform_command(result.last)
489
- else
490
- save_command_palette_state(palette)
491
- render_default_action unless response
492
- end
493
-
494
- response
495
- end
496
-
497
- # Mouse event handler for an open command palette. No-op — the command palette handles mouse internally.
498
- def dispatch_command_palette_mouse
499
- nil
500
- end
501
-
502
- # Dispatches keys within sidebar navigation: j/k or down/up move selection, Enter selects and navigates,
503
- # Escape/Tab shifts focus to content. Other keys are ignored while sidebar owns focus.
504
- def dispatch_sidebar_key
505
- case key_name
506
- when :j, :down then sidebar_move(+1)
507
- when :k, :up then sidebar_move(-1)
508
- when :enter then sidebar_select
509
- when :escape, :tab then focus_content
510
- else render_default_action
511
- end
512
- response
513
- end
514
-
515
- # Mouse event handler for the base controller. No-op — mouse events bubble through to focused components instead.
516
- def dispatch_component_mouse
517
- nil
518
- end
519
-
520
- # Moves sidebar selection up or down by `delta`. Does nothing if there are no routes.
521
- def sidebar_move(delta)
522
- count = sidebar_routes.length
523
- return render_default_action if count.zero?
524
-
525
- session[:sidebar_index] = (sidebar_index + delta).clamp(0, count - 1)
526
- render_default_action
527
- end
528
-
529
- # Selects the currently highlighted sidebar route and navigates to it — shifting focus to content area.
530
- # If no route is found at the current index, falls back to default action.
531
- def sidebar_select
532
- route = sidebar_routes[sidebar_index]
533
- if focus_ring_slot?(:content)
534
- focus.focus(:content)
535
- else
536
- session[:focus] = :content
537
- end
538
- route ? navigate_to(route.path) : render_default_action
539
- end
540
-
541
- def build_command_palette_from_state(state)
542
- case state.fetch(:type)
543
- when :commands
544
- build_command_palette_with_state(self.class.command_bindings, state, height: 6)
545
- when :themes
546
- build_command_palette_with_state(theme_commands, state, placeholder: "Search themes", height: 10)
547
- end
548
- end
549
-
550
- def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
551
- Presentation::Components::CommandPalette.new(
552
- commands: commands,
553
- placeholder: placeholder,
554
- height: height,
555
- value: state.fetch(:value),
556
- cursor: state.fetch(:cursor),
557
- selected_index: state.fetch(:selected_index),
558
- theme: theme
559
- )
560
- end
561
-
562
- def command_palette_state(type)
563
- {type: type, value: "", cursor: 0, selected_index: 0}
564
- end
565
-
566
- def save_command_palette_state(palette)
567
- session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
568
- end
569
-
570
- # Checks if a command palette result indicates a selected command (as opposed to cancel or no-op).
571
- def selected_command?(result)
572
- result.is_a?(Array) && result.first == :selected
573
- end
574
-
575
- # Executes a command value — either a callable block (inline command) or a method name (bound action).
576
- # Pops the command palette scope and re-renders unless the result is navigation or quit.
577
- def perform_command(command)
578
- current_palette_state = session[:command_palette]
579
- pop_command_palette_scope
580
- perform_command_value(command.value)
581
- if command.value != :quit && session[:command_palette].equal?(current_palette_state)
582
- session.delete(:command_palette)
583
- end
584
- render_default_action unless response&.navigate? || response&.quit?
585
- end
586
-
587
- def theme_commands
588
- application.class.themes.keys.map do |name|
589
- Presentation::Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
590
- end
591
- end
592
-
593
- def theme_label(name)
594
- name.to_s.tr("_", "-").split("-").map(&:capitalize).join(" ")
595
- end
596
-
597
- # Pops the command palette scope from the focus ring until it is no longer the topmost scope.
598
- # Called when a command executes or is cancelled.
599
- def pop_command_palette_scope
600
- focus.pop_scope while focus.ring == [:command_palette]
601
- end
602
-
603
- # Executes a command value — either calling a callable (inline block) or sending a method name to self.
604
- # Used for both inline commands and bound action methods from the palette.
605
- def perform_command_value(value)
606
- value.respond_to?(:call) ? instance_exec(&value) : send(value)
607
- end
608
-
609
- # Renders the default action if this controller defines it. Called after navigation, command execution,
610
- # or key handling when no explicit response was produced — ensures the view stays rendered.
611
- def render_default_action
612
- action = self.class.auto_render_action || :show
613
- public_send(action) if respond_to?(action)
614
- end
615
-
616
- def auto_render_after?(action)
617
- auto_render_action = self.class.auto_render_action
618
- auto_render_action && action.to_sym != auto_render_action
619
- end
620
-
621
- def global_key_action
622
- key_action_for_scope(:global)
623
- end
624
-
625
- def content_key_action
626
- return nil unless content_key_scope_active?
627
-
628
- key_action_for_scope(:content)
629
- end
630
-
631
- def content_key_scope_active?
632
- return content_focused? if focus_ring_slot?(:content)
633
-
634
- true
635
- end
636
-
637
- def key_action_for_scope(scope)
638
- action = self.class.key_bindings[key_name]
639
- return nil unless action
640
- return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
641
-
642
- action
643
- end
644
120
  end
645
121
  end