charming 0.3.0 → 0.4.0

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/lib/charming/application.rb +91 -14
  4. data/lib/charming/application_state.rb +23 -0
  5. data/lib/charming/cli.rb +2 -2
  6. data/lib/charming/controller/action_hooks.rb +5 -1
  7. data/lib/charming/controller/class_methods.rb +66 -15
  8. data/lib/charming/controller/component_dispatch.rb +163 -0
  9. data/lib/charming/controller/dispatching.rb +1 -2
  10. data/lib/charming/controller/focus_management.rb +67 -1
  11. data/lib/charming/controller/key_dispatch.rb +7 -7
  12. data/lib/charming/controller/rendering.rb +22 -6
  13. data/lib/charming/controller/session_state.rb +63 -22
  14. data/lib/charming/controller.rb +247 -59
  15. data/lib/charming/cross_thread_access.rb +9 -0
  16. data/lib/charming/double_render_error.rb +8 -0
  17. data/lib/charming/generators/layout_generator.rb +4 -1
  18. data/lib/charming/generators/migration_generator.rb +1 -1
  19. data/lib/charming/generators/model_generator.rb +2 -2
  20. data/lib/charming/generators/name.rb +1 -1
  21. data/lib/charming/generators/screen_generator.rb +2 -2
  22. data/lib/charming/generators/view_generator.rb +1 -1
  23. data/lib/charming/internal/deep_freeze.rb +23 -0
  24. data/lib/charming/internal/env_inquirer.rb +22 -0
  25. data/lib/charming/internal/inflections.rb +93 -0
  26. data/lib/charming/internal/session_guard.rb +27 -0
  27. data/lib/charming/internal/terminal/cursor.rb +29 -0
  28. data/lib/charming/internal/terminal/size.rb +47 -0
  29. data/lib/charming/internal/terminal/tty_backend.rb +10 -8
  30. data/lib/charming/presentation/components/autocomplete.rb +14 -6
  31. data/lib/charming/presentation/components/command_palette.rb +11 -9
  32. data/lib/charming/presentation/components/filepicker.rb +4 -4
  33. data/lib/charming/presentation/components/form/confirm.rb +2 -1
  34. data/lib/charming/presentation/components/form/field.rb +1 -1
  35. data/lib/charming/presentation/components/form/input.rb +3 -3
  36. data/lib/charming/presentation/components/form/multiselect.rb +4 -5
  37. data/lib/charming/presentation/components/form/select.rb +3 -3
  38. data/lib/charming/presentation/components/form/textarea.rb +3 -3
  39. data/lib/charming/presentation/components/form.rb +6 -6
  40. data/lib/charming/presentation/components/help_overlay.rb +2 -2
  41. data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
  42. data/lib/charming/presentation/components/list.rb +14 -5
  43. data/lib/charming/presentation/components/modal.rb +3 -2
  44. data/lib/charming/presentation/components/multi_select_list.rb +14 -7
  45. data/lib/charming/presentation/components/result.rb +61 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +14 -6
  47. data/lib/charming/presentation/components/table.rb +64 -32
  48. data/lib/charming/presentation/components/text_area.rb +7 -7
  49. data/lib/charming/presentation/components/text_input.rb +7 -7
  50. data/lib/charming/presentation/components/tree.rb +15 -6
  51. data/lib/charming/presentation/components/viewport.rb +3 -3
  52. data/lib/charming/presentation/layout/pane.rb +6 -2
  53. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  54. data/lib/charming/presentation/view.rb +35 -29
  55. data/lib/charming/render_artifacts.rb +24 -0
  56. data/lib/charming/response.rb +19 -8
  57. data/lib/charming/router.rb +50 -68
  58. data/lib/charming/runtime.rb +42 -26
  59. data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
  60. data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
  61. data/lib/charming/tasks/context.rb +35 -0
  62. data/lib/charming/test_helper.rb +42 -22
  63. data/lib/charming/unhandled_component_event.rb +9 -0
  64. data/lib/charming/unknown_slot.rb +9 -0
  65. data/lib/charming/version.rb +1 -1
  66. data/lib/charming/welcome.rb +1 -1
  67. data/lib/charming.rb +14 -6
  68. metadata +21 -70
  69. data/lib/charming/controller/component_dispatching.rb +0 -125
@@ -15,13 +15,29 @@ module Charming
15
15
  end
16
16
 
17
17
  # Wraps *body* (a string) in the controller's configured layout, if any. When no layout is set
18
- # the body is returned as-is.
18
+ # the body is returned as-is. Render artifacts from every view rendered (body first, layout
19
+ # last) accumulate on the controller for the dispatch to commit.
19
20
  def render_with_layout(body)
20
21
  rendered = render_body(body)
22
+ collect_render_artifacts(body)
21
23
  layout = self.class.layout
22
24
  return rendered unless layout
23
25
 
24
- render_body(layout_body(layout, body, rendered))
26
+ layout_view = layout_body(layout, body, rendered)
27
+ render_body(layout_view).tap { collect_render_artifacts(layout_view) }
28
+ end
29
+
30
+ # Accumulates the render artifacts *body* stashed during its render, if any. Strings
31
+ # and template-less bodies carry none.
32
+ def collect_render_artifacts(body)
33
+ return unless body.respond_to?(:render_artifacts)
34
+
35
+ dispatch_render_artifacts.concat(body.render_artifacts)
36
+ end
37
+
38
+ # The render artifacts accumulated during this dispatch, in render order.
39
+ def dispatch_render_artifacts
40
+ @dispatch_render_artifacts ||= []
25
41
  end
26
42
 
27
43
  # Builds the layout wrapper for *body* / *rendered* content. String/Symbol layouts are
@@ -96,9 +112,9 @@ module Charming
96
112
  def conventional_view_constant_path(name)
97
113
  parts = name.to_s.split("/")
98
114
  action = parts.pop
99
- view_name = "#{ActiveSupport::Inflector.camelize(action.to_s)}View"
115
+ view_name = "#{Internal::Inflections.camelize(action.to_s)}View"
100
116
 
101
- parts.map { |part| ActiveSupport::Inflector.camelize(part) } + [view_name]
117
+ parts.map { |part| Internal::Inflections.camelize(part) } + [view_name]
102
118
  end
103
119
 
104
120
  # Returns the default template path for a given *action* (e.g., "home/show" for HomeController#show).
@@ -108,8 +124,8 @@ module Charming
108
124
 
109
125
  # Returns the underscored controller path (e.g., "home" for HomeController) used for view lookup.
110
126
  def controller_template_path
111
- controller_name = ActiveSupport::Inflector.demodulize(self.class.name).delete_suffix("Controller")
112
- ActiveSupport::Inflector.underscore(controller_name)
127
+ controller_name = Internal::Inflections.demodulize(self.class.name).delete_suffix("Controller")
128
+ Internal::Inflections.underscore(controller_name)
113
129
  end
114
130
  end
115
131
  end
@@ -4,22 +4,32 @@ module Charming
4
4
  class Controller
5
5
  # Session-state helpers mixed into Controller: accessing the application session hash, lazy
6
6
  # state-object lookup by name/class, form builder invocation, and async task submission.
7
+ #
8
+ # State-lifetime rule of thumb: controller ivars for screen-lifetime, `state` objects for
9
+ # app-lifetime, session-persisted values (`persist_session`) for restart-lifetime.
7
10
  module SessionState
8
- # Returns the application session hash for this controller. All persistent state (focus,
9
- # sidebar index, command palette, user state objects) lives here.
11
+ # Returns the application session hash for this controller. The session holds state that
12
+ # must outlive a screen: `state` objects, app-global UI state (focus rings, sidebar index,
13
+ # command palette), and values persisted across restarts via `persist_session`.
14
+ # In development and test the hash is wrapped in an Internal::SessionGuard so access
15
+ # from a task executor thread raises CrossThreadAccess; production returns the raw hash.
10
16
  def session
11
- application.session
17
+ return application.session if Charming.env.production?
18
+
19
+ @session_guard ||= Internal::SessionGuard.new(application.session, self)
12
20
  end
13
21
 
14
22
  # Stores the named layout panes from the latest render so mouse events can be hit-tested
15
- # against the same focus slots used by Tab traversal.
23
+ # against the same focus slots used by Tab traversal. Kept on the controller instance:
24
+ # mouse targets describe the latest render, not persistent state. Internal — called only
25
+ # from the dispatch pipeline's artifact commit; app code should not call this.
16
26
  def register_mouse_targets(targets)
17
- session[:mouse_targets] = targets
27
+ @mouse_targets = targets
18
28
  end
19
29
 
20
30
  # Returns the named layout panes from the latest render.
21
31
  def mouse_targets
22
- session.fetch(:mouse_targets, [])
32
+ @mouse_targets || []
23
33
  end
24
34
 
25
35
  # Returns the named session-backed state object, creating it on first access. *name* is a
@@ -30,36 +40,54 @@ module Charming
30
40
  session[:states][name.to_sym] ||= state_class.new(**attributes)
31
41
  end
32
42
 
33
- # Returns the named mutable widget-state hash stored under `session[:component_state]`,
34
- # seeding it from *defaults* on first access. This is the blessed way to keep an interactive
35
- # component's state across ephemeral controller instances: store only JSON-safe primitives,
36
- # rebuild the component from the hash each event, and write changed values back after
37
- # `handle_key`. (Live component objects don't belong in the session — `save_session` drops
38
- # anything that can't survive a JSON round-trip.)
43
+ # Deprecated. Returns the named mutable widget-state hash stored under
44
+ # `session[:component_state]`, seeding it from *defaults* on first access.
45
+ # Persistent controllers make this unnecessary: memoize the component in an ivar
46
+ # (`@query ||= Components::TextInput.new(...)`) for screen-lifetime state instead.
39
47
  def component_state(name, **defaults)
48
+ Charming.deprecate(
49
+ "component_state is deprecated. Persistent controllers keep components for the screen's " \
50
+ "lifetime — memoize them in ivars (`@query ||= Components::TextInput.new(...)`).",
51
+ category: :component_state
52
+ )
40
53
  session[:component_state] ||= {}
41
54
  session[:component_state][name.to_sym] ||= defaults
42
55
  end
43
56
 
44
- # Builds a Form component scoped to the named form slot in `session[:forms]`. The block is
45
- # evaluated against a Form::Builder (or invoked with the builder as its argument for arity-1 blocks)
46
- # and returns a Form component pre-bound to the per-form mutable state hash.
57
+ # Builds a Form component scoped to the named form slot. The form's mutable state hash
58
+ # lives on the controller instance: it survives events on this screen and is discarded
59
+ # on navigation. Clear it after a successful submit with `reset_form`.
47
60
  def form(name, &block)
48
- session[:forms] ||= {}
49
- form_state = session[:forms][name.to_sym] ||= {}
50
61
  builder = Components::Form::Builder.new(theme: theme)
51
62
  block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
52
- builder.build(state: form_state, theme: theme)
63
+ builder.build(state: form_states[name.to_sym] ||= {}, theme: theme)
64
+ end
65
+
66
+ # Clears the named form's stored state (e.g. after a successful submit, or when the
67
+ # form should re-seed from fresh defaults).
68
+ def reset_form(name)
69
+ form_states.delete(name.to_sym)
70
+ end
71
+
72
+ # The per-controller store of form state hashes, keyed by form name.
73
+ def form_states
74
+ @form_states ||= {}
53
75
  end
54
76
 
55
77
  # Submits a background task with the given *name*. The block is executed by the configured
56
78
  # task executor; its return value (or any raised exception) is delivered to the controller
57
79
  # as a TaskEvent dispatched to the matching `on_task` handler.
58
80
  #
59
- # Blocks that accept an argument receive a Tasks::Progress reporter whose `report`
60
- # calls dispatch the matching `on_task_progress` handler. *timeout:* (seconds)
61
- # cancels the task with Tasks::Cancelled when exceeded.
62
- def run_task(name, timeout: nil, &block)
81
+ # Blocks that accept an argument receive a Tasks::Context: `ctx[key]` reads inputs from
82
+ # the *with:* hash (deep-frozen at submit time) and `ctx.report(...)` dispatches the
83
+ # matching `on_task_progress` handler. *timeout:* (seconds) cancels the task with
84
+ # Tasks::Cancelled when exceeded.
85
+ #
86
+ # Task blocks receive data in via *with:* and return data out as the block value; they
87
+ # touch nothing else. The `on_task` handler on the loop thread is the only place task
88
+ # results become state.
89
+ def run_task(name, timeout: nil, with: {}, &block)
90
+ block = task_block_wrapper(block, Internal::DeepFreeze.call(with))
63
91
  return application.task_executor.submit(name, timeout: timeout, &block) if timeout
64
92
 
65
93
  # Without a timeout, use the plain signature so simple custom executors
@@ -73,6 +101,19 @@ module Charming
73
101
  executor = application.task_executor
74
102
  executor.cancel(name) if executor.respond_to?(:cancel)
75
103
  end
104
+
105
+ private
106
+
107
+ # Wraps the task block so it receives a Tasks::Context carrying the *with* data
108
+ # plus the executor's progress reporter. The wrapper's arity is always 1, so the
109
+ # executor hands it the reporter; the original block's arity decides whether the
110
+ # context is passed on (lambdas stay strict).
111
+ def task_block_wrapper(block, data)
112
+ proc do |progress|
113
+ context = Tasks::Context.new(data, progress)
114
+ block.arity.zero? ? block.call : block.call(context)
115
+ end
116
+ end
76
117
  end
77
118
  end
78
119
  end
@@ -2,8 +2,14 @@
2
2
 
3
3
  module Charming
4
4
  # Controller is the base class for all controller implementations in a Charming application.
5
- # It provides the action dispatch pipeline, key/command/timer/task bindings, sidebar navigation,
6
- # command palette management, and view rendering with layout composition.
5
+ # It provides the action dispatch pipeline, key/timer/task bindings, component-event
6
+ # declarations, and view rendering with layout composition. The sidebar and command
7
+ # palette are opt-in: see Charming::Shell::Sidebar and Charming::Shell::Palette.
8
+ #
9
+ # Controllers are persistent per screen: the Runtime constructs one instance when a route is
10
+ # entered and dispatches every event for that screen at it, so instance variables live for the
11
+ # screen's lifetime. Use ivars for screen-lifetime state, `state(name, Klass)` objects for
12
+ # app-lifetime state, and session persistence for restart-lifetime state.
7
13
  class Controller
8
14
  TimerBinding = Data.define(:name, :interval, :action, :autostart) do
9
15
  def initialize(name:, interval:, action:, autostart: true)
@@ -17,107 +23,136 @@ module Charming
17
23
  include Rendering
18
24
  include SessionState
19
25
  include FocusManagement
20
- include SidebarNavigation
21
- include CommandPalette
22
- include ComponentDispatching
23
26
  include Dispatching
24
27
  include Terminal
25
28
  include Timers
26
29
 
27
30
  attr_reader :application, :event, :params, :screen, :route
28
31
 
29
- # Initializes the controller with its parent application and optional event.
30
- # Defaults to an 80x24 screen when no backend size is available.
31
- def initialize(application:, event: nil, params: {}, screen: nil, route: nil)
32
+ # Initializes the controller with its parent application. The Runtime constructs one
33
+ # instance per route entry; *event:* is deprecated pass events to the dispatch
34
+ # methods instead. Defaults to an 80x24 screen when no backend size is available.
35
+ def initialize(application:, params: {}, screen: nil, route: nil, event: nil)
36
+ if event
37
+ Charming.deprecate(
38
+ "Controller.new(event:) is deprecated. Construct the controller once and pass events " \
39
+ "to the dispatch methods (e.g. dispatch_key(event)).",
40
+ category: :controller_new_event
41
+ )
42
+ end
32
43
  @application = application
33
44
  @event = event
34
45
  @params = params
35
46
  @screen = screen || Screen.new(width: 80, height: 24)
36
47
  @route = route
37
48
  @response = nil
49
+ @loop_thread = Thread.current
50
+ end
51
+
52
+ # Records the loop thread that owns this controller. Construction captures the
53
+ # constructing thread; the Runtime re-captures when the event loop starts, covering
54
+ # runtimes built on one thread and run on another. Internal — called by Runtime.
55
+ def capture_loop_thread!
56
+ @loop_thread = Thread.current
57
+ end
58
+
59
+ # Asserts the current thread is this controller's loop thread. The mutation funnels
60
+ # (session, render/navigate/quit, focus, component_for) call this so task-block
61
+ # access from an executor thread trips immediately. Raises CrossThreadAccess in
62
+ # development and test; logs a warning in production. Internal — called by the
63
+ # funnels and the session guard.
64
+ def assert_loop_thread!(operation)
65
+ return if Thread.current.equal?(@loop_thread)
66
+
67
+ message = "A task thread called #{self.class.name || "an anonymous controller"}##{operation}. " \
68
+ "Task blocks receive data in via `with:` and return data out as the block value. " \
69
+ "Touch the controller, session, and components only on the loop thread — in the `on_task` handler."
70
+ raise CrossThreadAccess, message unless Charming.env.production?
71
+
72
+ logger.warn(message)
73
+ end
74
+
75
+ # Lifecycle hook called once after this controller becomes the active screen's
76
+ # controller, before the first action dispatch. Start per-screen resources here.
77
+ def screen_entered
78
+ end
79
+
80
+ # Lifecycle hook called before this controller is discarded — on navigation away or
81
+ # at quit. Stop per-screen resources here.
82
+ def screen_exited
83
+ end
84
+
85
+ # Replaces the screen dimensions after a terminal resize, keeping the live controller
86
+ # instance (and its ivars) across the resize.
87
+ def update_screen(screen)
88
+ @screen = screen
38
89
  end
39
90
 
40
91
  # Dispatches a named action on this controller (e.g. :show), running all
41
92
  # before/around/after hooks and rescue_from handlers.
42
- def dispatch(action)
43
- run_action_with_hooks(action)
44
- render_default_action if response.nil? && auto_render_after?(action)
45
- response || render("")
93
+ def dispatch(action, event: nil)
94
+ with_dispatch_state(event) do
95
+ run_action_with_hooks(action)
96
+ render_default_action if response.nil? && auto_render_after?(action)
97
+ response || render("")
98
+ end
46
99
  end
47
100
 
48
101
  # Key event dispatch. The precedence ladder (palette → focused text capture →
49
102
  # global bindings → overlay → sidebar/content/component) lives in KeyDispatch.
50
- def dispatch_key
51
- KeyDispatch.new(self).call
103
+ def dispatch_key(event = nil)
104
+ with_dispatch_state(event) { KeyDispatch.new(self).call }
52
105
  end
53
106
 
54
107
  # Timer event dispatcher: looks up the named action in timer bindings and runs it
55
108
  # with the full hook chain. Unlike #dispatch there is no render("") fallback — a
56
109
  # timer action that renders nothing yields a nil response, so silent ticks skip
57
110
  # the repaint instead of blanking the screen.
58
- def dispatch_timer
59
- b = self.class.timer_bindings[event.name.to_sym]
60
- return nil unless b
61
-
62
- run_action_with_hooks(b.action)
63
- response
111
+ def dispatch_timer(event = nil)
112
+ with_dispatch_state(event) { timer_response }
64
113
  end
65
114
 
66
115
  # Task event dispatcher: looks up the handler in task bindings.
67
- def dispatch_task
68
- b = self.class.task_bindings[event.name.to_sym]
69
- b ? dispatch(b.action) : nil
116
+ def dispatch_task(event = nil)
117
+ with_dispatch_state(event) { task_response(self.class.task_bindings) }
70
118
  end
71
119
 
72
120
  # Task progress dispatcher: looks up the handler in task progress bindings.
73
- def dispatch_task_progress
74
- b = self.class.task_progress_bindings[event.name.to_sym]
75
- b ? dispatch(b.action) : nil
121
+ def dispatch_task_progress(event = nil)
122
+ with_dispatch_state(event) { task_response(self.class.task_progress_bindings) }
76
123
  end
77
124
 
78
125
  # Paste event dispatcher: forwards pasted text to the focused component's
79
126
  # `handle_paste` (TextInput, TextArea, Form text fields, and Autocomplete support it).
80
- def dispatch_paste
81
- slot = focus.current
82
- return nil unless slot && respond_to?(slot, true)
83
-
84
- component = send(slot)
85
- return nil unless component.respond_to?(:handle_paste)
86
-
87
- result = component.handle_paste(event)
88
- return nil if result.nil?
89
-
90
- dispatch_component_result(slot, result)
91
- response
127
+ def dispatch_paste(event = nil)
128
+ with_dispatch_state(event) { paste_response }
92
129
  end
93
130
 
94
131
  # Mouse event dispatcher: command palette (if open) wins, then sidebar clicks
95
132
  # (route rows navigate directly), then named layout panes/components.
96
- def dispatch_mouse
97
- return dispatch_command_palette_mouse if command_palette_open?
98
-
99
- sidebar_response = dispatch_sidebar_mouse
100
- return sidebar_response if sidebar_response
101
-
102
- dispatch_component_mouse
133
+ def dispatch_mouse(event = nil)
134
+ with_dispatch_state(event) { mouse_response }
103
135
  end
104
136
 
105
137
  # Renders a body or template wrapped in the controller's layout. Out-of-band escape sequences
106
138
  # registered while rendering (e.g. image transmissions) are collected by the Runtime around the
107
139
  # whole dispatch and attached to the response.
108
140
  def render(body = "", **assigns)
141
+ assert_loop_thread!(:render)
109
142
  body = view_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
110
- @response = Response.render(render_with_layout(body))
143
+ assign_response(Response.render(render_with_layout(body)), "render")
111
144
  end
112
145
 
113
146
  def render_view(view_class, **assigns)
114
- @response = Response.render(render_with_layout(view_class.new(**template_assigns(assigns))))
147
+ assert_loop_thread!(:render_view)
148
+ assign_response(Response.render(render_with_layout(view_class.new(**template_assigns(assigns)))), "render_view(#{view_class})")
115
149
  end
116
150
 
117
151
  # Renders a template from `app/views` by name, applying the controller's layout. *name* is the
118
152
  # template path (e.g., "home/show") and additional keyword *assigns* are forwarded to the view.
119
153
  def render_template(name, **assigns)
120
- @response = Response.render(render_with_layout(template_body(name, **assigns)))
154
+ assert_loop_thread!(:render_template)
155
+ assign_response(Response.render(render_with_layout(template_body(name, **assigns))), "render_template(#{name.inspect})")
121
156
  end
122
157
 
123
158
  # Returns the active theme for this request, delegated to the application.
@@ -136,25 +171,178 @@ module Charming
136
171
  application.logger
137
172
  end
138
173
 
139
- # Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
140
- def open_theme_palette
141
- session[:command_palette] = command_palette_state(:themes)
142
- focus.push_scope([:command_palette], origin: :command_palette)
143
- render_default_action
144
- end
145
-
146
- # Navigates to the given URL path.
147
- def navigate_to(path)
148
- @response = Response.navigate(path)
174
+ # Navigates to the screen registered under *name* in config/routes.rb, passing
175
+ # *params* through to the target controller (e.g. `navigate :project, id: project.id`).
176
+ def navigate(name, **params)
177
+ assert_loop_thread!(:navigate)
178
+ assign_response(Response.navigate(name, **params), "navigate to :#{name}")
149
179
  end
150
180
 
151
181
  # Exits the application — sets a quit response that terminates the event loop.
152
182
  def quit
153
- @response = Response.quit
183
+ assert_loop_thread!(:quit)
184
+ assign_response(Response.quit, "quit")
185
+ end
186
+
187
+ # The component-dispatch collaborator: forwards key/mouse/paste events to focused
188
+ # components and translates their results into controller action calls.
189
+ def component_dispatch
190
+ @component_dispatch ||= ComponentDispatch.new(self)
191
+ end
192
+
193
+ # Returns the component registered for the focus *slot*, or nil. Declared slots
194
+ # (`slot :name { ... }`) resolve first, memoized per controller; undeclared slots
195
+ # fall back to the same-named method convention. Every dispatch path fetches
196
+ # components through here, so the slot convention is greppable in one place.
197
+ def component_for(slot)
198
+ assert_loop_thread!(:component_for)
199
+ return declared_slot(slot.to_sym) if self.class.slot_definitions.key?(slot.to_sym)
200
+
201
+ legacy_slot_component(slot)
154
202
  end
155
203
 
156
204
  private
157
205
 
158
206
  attr_reader :response
207
+
208
+ # Returns the memoized component for a declared slot, instance_exec'ing the factory
209
+ # on first access so it can read params and state. Factories that return nil are
210
+ # memoized as nil (the key? check distinguishes "not built" from "built as nil").
211
+ def declared_slot(name)
212
+ @slots ||= {}
213
+ return @slots[name] if @slots.key?(name)
214
+
215
+ @slots[name] = instance_exec(&self.class.slot_definitions[name])
216
+ end
217
+
218
+ # Resolves an undeclared slot via the legacy same-named-method convention, warning
219
+ # once per controller class and slot. Returns nil (without warning) when no such
220
+ # method exists — pane names like :sidebar are slots without components.
221
+ def legacy_slot_component(name)
222
+ return nil unless respond_to?(name, true)
223
+
224
+ Charming.deprecate(
225
+ "#{self.class.name || "An anonymous controller"} resolves slot :#{name} via a private method. " \
226
+ "Declare it instead: `slot :#{name} { ... }`. The convention is removed at 1.0.",
227
+ category: :"undeclared_slot_#{self.class.name}_#{name}"
228
+ )
229
+ send(name)
230
+ end
231
+
232
+ # The single funnel through which every response is assigned. Raises
233
+ # DoubleRenderError when a response was already set during this dispatch —
234
+ # a second assignment would silently discard the first. Render responses carry
235
+ # the dispatch's merged render artifacts (focus slots, mouse targets) for the
236
+ # commit at dispatch exit.
237
+ def assign_response(value, attempted)
238
+ raise DoubleRenderError, double_render_message(attempted) if response
239
+
240
+ @response = attach_render_artifacts(value)
241
+ end
242
+
243
+ # Attaches the merged render artifacts to a render response when any view rendered
244
+ # a layout this dispatch. Non-render responses and layout-less renders carry none.
245
+ def attach_render_artifacts(value)
246
+ return value unless value.kind == :render
247
+ return value if dispatch_render_artifacts.empty?
248
+
249
+ value.with(artifacts: RenderArtifacts.merge(dispatch_render_artifacts))
250
+ end
251
+
252
+ # Commits the response's render artifacts: validates focus slots against the slot
253
+ # registry, defines the layout focus scope, and registers mouse targets. Runs once
254
+ # per dispatch, at the outermost exit — a dispatch that raises mid-render commits
255
+ # nothing, so the previous frame's registrations stay live with what's on screen.
256
+ def commit_render_artifacts
257
+ artifacts = response&.artifacts
258
+ return unless artifacts
259
+
260
+ register_layout_focus(artifacts.focus_slots)
261
+ register_mouse_targets(artifacts.mouse_targets)
262
+ end
263
+
264
+ # Builds the DoubleRenderError message, naming the action, the response
265
+ # already set, the one attempted, and the fix.
266
+ def double_render_message(attempted)
267
+ "#{dispatch_context} set the response twice in one dispatch: " \
268
+ "first #{describe_response(response)}, then #{attempted}. " \
269
+ "Set the response once per dispatch: remove the first call or restructure the action."
270
+ end
271
+
272
+ # The controller and action for error messages (e.g. "HomeController#show").
273
+ def dispatch_context
274
+ name = self.class.name || "Anonymous controller"
275
+ @current_action ? "#{name}##{@current_action}" : name
276
+ end
277
+
278
+ # A one-word description of a response for error messages.
279
+ def describe_response(response)
280
+ (response.kind == :navigate) ? "navigate to :#{response.name}" : response.kind.to_s
281
+ end
282
+
283
+ # Sets per-dispatch state (the event) around the block, then clears @response and
284
+ # @event when the outermost dispatch exits, so per-dispatch state cannot leak
285
+ # between events. Nested dispatches (a key binding calling #dispatch) keep it.
286
+ # The outermost dispatch also clears @response on entry, so a response set outside
287
+ # any dispatch (e.g. in screen_entered) is discarded instead of tripping the
288
+ # DoubleRenderError guard on the next dispatch's first render. At the outermost
289
+ # exit, a rendered response's artifacts commit; an exception skips the commit.
290
+ def with_dispatch_state(event)
291
+ @event = event if event
292
+ @dispatch_depth = @dispatch_depth.to_i + 1
293
+ if @dispatch_depth == 1
294
+ @response = nil
295
+ dispatch_render_artifacts.clear
296
+ validate_slot_registrations_once
297
+ end
298
+ yield.tap { commit_render_artifacts if @dispatch_depth == 1 }
299
+ ensure
300
+ @dispatch_depth -= 1
301
+ if @dispatch_depth.zero?
302
+ @response = nil
303
+ @event = nil
304
+ end
305
+ end
306
+
307
+ # The timer binding's response, or nil when the ticked timer has no binding.
308
+ def timer_response
309
+ binding = self.class.timer_bindings[event.name.to_sym]
310
+ return nil unless binding
311
+
312
+ run_action_with_hooks(binding.action)
313
+ response
314
+ end
315
+
316
+ # The task binding's response, or nil when the event name has no binding.
317
+ def task_response(bindings)
318
+ binding = bindings[event.name.to_sym]
319
+ binding ? dispatch(binding.action) : nil
320
+ end
321
+
322
+ # Forwards the paste event to the focused component and dispatches its result.
323
+ def paste_response
324
+ slot = focus.current
325
+ component = slot && component_for(slot)
326
+ return nil unless component&.respond_to?(:handle_paste)
327
+
328
+ result = component.handle_paste(event)
329
+ return nil if result.nil?
330
+
331
+ component_dispatch.dispatch_component_result(slot, result)
332
+ response
333
+ end
334
+
335
+ # Routes the mouse event: palette first (when the app includes the shell palette),
336
+ # then sidebar (likewise), then named panes/components.
337
+ def mouse_response
338
+ return dispatch_command_palette_mouse if respond_to?(:command_palette_open?) && command_palette_open?
339
+
340
+ if respond_to?(:dispatch_sidebar_mouse, true)
341
+ sidebar_response = dispatch_sidebar_mouse
342
+ return sidebar_response if sidebar_response
343
+ end
344
+
345
+ component_dispatch.dispatch_component_mouse
346
+ end
159
347
  end
160
348
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Raised when controller machinery is called from a task executor thread. Task blocks
5
+ # receive data in via `run_task`'s `with:` and return data out as the block value; the
6
+ # controller, session, and components belong to the loop thread. Raised in development
7
+ # and test; production logs a warning instead.
8
+ class CrossThreadAccess < Error; end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Raised when a controller sets the response twice in one dispatch — render, navigate,
5
+ # and quit each assign the response, and a second assignment would silently discard the
6
+ # first. The message names the action, the response already set, and the one attempted.
7
+ class DoubleRenderError < Error; end
8
+ end
@@ -99,12 +99,15 @@ module Charming
99
99
 
100
100
  def controller_chrome
101
101
  class_body_block(<<~RUBY)
102
+ include Charming::Shell::Sidebar
103
+ include Charming::Shell::Palette
104
+
102
105
  focus_ring :sidebar, :content
103
106
 
104
107
  key "ctrl+p", :open_command_palette, scope: :global
105
108
 
106
109
  command "Home" do
107
- navigate_to "/"
110
+ navigate :root
108
111
  end
109
112
 
110
113
  command "Theme", :open_theme_palette
@@ -60,7 +60,7 @@ module Charming
60
60
 
61
61
  # The CamelCase migration class name derived from the snake_case migration name.
62
62
  def migration_class_name
63
- ActiveSupport::Inflector.camelize(name.snake_name)
63
+ Internal::Inflections.camelize(name.snake_name)
64
64
  end
65
65
 
66
66
  # Builds the `change` method body based on the migration name convention.
@@ -103,12 +103,12 @@ module Charming
103
103
 
104
104
  # The pluralized table name (e.g., "user" → "users", "category" → "categories").
105
105
  def table_name
106
- ActiveSupport::Inflector.pluralize(name.snake_name)
106
+ Internal::Inflections.pluralize(name.snake_name)
107
107
  end
108
108
 
109
109
  # The CamelCase migration class name (e.g., "users" → "Users").
110
110
  def table_class_name
111
- ActiveSupport::Inflector.camelize(table_name)
111
+ Internal::Inflections.camelize(table_name)
112
112
  end
113
113
 
114
114
  # A migration timestamp in ActiveRecord's filename format, bumped past any
@@ -21,7 +21,7 @@ module Charming
21
21
 
22
22
  # The CamelCase class name (e.g., "user" → "User").
23
23
  def class_name
24
- ActiveSupport::Inflector.camelize(snake_name)
24
+ Internal::Inflections.camelize(snake_name)
25
25
  end
26
26
 
27
27
  # The controller class name (e.g., "user" → "UserController").
@@ -141,14 +141,14 @@ module Charming
141
141
 
142
142
  # Inserts a `screen` route into `config/routes.rb`, idempotently.
143
143
  def insert_route
144
- route = %( screen "/#{name.snake_name}", to: "#{name.snake_name}#show", title: "#{name.class_name}")
144
+ route = %( screen :#{name.snake_name}, "#{name.snake_name}#show", title: "#{name.class_name}")
145
145
  insert_before_end(route_path, route, "route", "end")
146
146
  end
147
147
 
148
148
  # Inserts a `command` block into `ApplicationController`, idempotently.
149
149
  def insert_command
150
150
  command = %( command "#{name.class_name}" do
151
- navigate_to "/#{name.snake_name}"
151
+ navigate :#{name.snake_name}
152
152
  end)
153
153
  insert_before_end(application_controller_path, command, "command", " end")
154
154
  end