charming 0.2.3 → 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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/lib/charming/application.rb +98 -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 +79 -17
  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/timers.rb +25 -0
  15. data/lib/charming/controller.rb +253 -60
  16. data/lib/charming/cross_thread_access.rb +9 -0
  17. data/lib/charming/double_render_error.rb +8 -0
  18. data/lib/charming/generators/layout_generator.rb +4 -1
  19. data/lib/charming/generators/migration_generator.rb +1 -1
  20. data/lib/charming/generators/model_generator.rb +2 -2
  21. data/lib/charming/generators/name.rb +1 -1
  22. data/lib/charming/generators/screen_generator.rb +2 -2
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/deep_freeze.rb +23 -0
  25. data/lib/charming/internal/env_inquirer.rb +22 -0
  26. data/lib/charming/internal/event_loop.rb +25 -2
  27. data/lib/charming/internal/inflections.rb +93 -0
  28. data/lib/charming/internal/session_guard.rb +27 -0
  29. data/lib/charming/internal/terminal/cursor.rb +29 -0
  30. data/lib/charming/internal/terminal/size.rb +47 -0
  31. data/lib/charming/internal/terminal/tty_backend.rb +10 -8
  32. data/lib/charming/internal/timer_control.rb +48 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +14 -6
  34. data/lib/charming/presentation/components/command_palette.rb +11 -9
  35. data/lib/charming/presentation/components/filepicker.rb +4 -4
  36. data/lib/charming/presentation/components/form/confirm.rb +2 -1
  37. data/lib/charming/presentation/components/form/field.rb +1 -1
  38. data/lib/charming/presentation/components/form/input.rb +3 -3
  39. data/lib/charming/presentation/components/form/multiselect.rb +4 -5
  40. data/lib/charming/presentation/components/form/select.rb +3 -3
  41. data/lib/charming/presentation/components/form/textarea.rb +3 -3
  42. data/lib/charming/presentation/components/form.rb +6 -6
  43. data/lib/charming/presentation/components/help_overlay.rb +2 -2
  44. data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
  45. data/lib/charming/presentation/components/list.rb +14 -5
  46. data/lib/charming/presentation/components/modal.rb +3 -2
  47. data/lib/charming/presentation/components/multi_select_list.rb +14 -7
  48. data/lib/charming/presentation/components/result.rb +61 -0
  49. data/lib/charming/presentation/components/tab_bar.rb +14 -6
  50. data/lib/charming/presentation/components/table.rb +64 -32
  51. data/lib/charming/presentation/components/text_area.rb +7 -7
  52. data/lib/charming/presentation/components/text_input.rb +7 -7
  53. data/lib/charming/presentation/components/tree.rb +15 -6
  54. data/lib/charming/presentation/components/viewport.rb +3 -3
  55. data/lib/charming/presentation/layout/pane.rb +6 -2
  56. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  57. data/lib/charming/presentation/view.rb +35 -29
  58. data/lib/charming/projectile.rb +64 -0
  59. data/lib/charming/render_artifacts.rb +24 -0
  60. data/lib/charming/response.rb +19 -8
  61. data/lib/charming/router.rb +50 -68
  62. data/lib/charming/runtime.rb +54 -28
  63. data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
  64. data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
  65. data/lib/charming/spring.rb +126 -0
  66. data/lib/charming/tasks/context.rb +35 -0
  67. data/lib/charming/test_helper.rb +42 -22
  68. data/lib/charming/unhandled_component_event.rb +9 -0
  69. data/lib/charming/unknown_slot.rb +9 -0
  70. data/lib/charming/version.rb +1 -1
  71. data/lib/charming/welcome.rb +1 -1
  72. data/lib/charming.rb +20 -6
  73. metadata +25 -70
  74. data/lib/charming/controller/component_dispatching.rb +0 -125
@@ -16,8 +16,8 @@ module Charming
16
16
  #
17
17
  # Tiers differ in how they terminate: the palette, overlay, sidebar, and binding
18
18
  # tiers consume the key outright once their condition holds, while the component
19
- # tiers only consume it when the component reports :handled and otherwise let it
20
- # fall through to the next tier.
19
+ # tiers only consume it when the component reports a handled result and otherwise
20
+ # let it fall through to the next tier.
21
21
  class KeyDispatch
22
22
  def initialize(controller)
23
23
  @controller = controller
@@ -42,7 +42,7 @@ module Charming
42
42
  attr_reader :controller
43
43
 
44
44
  def palette_open?
45
- controller.command_palette_open?
45
+ controller.respond_to?(:command_palette_open?) && controller.command_palette_open?
46
46
  end
47
47
 
48
48
  def palette_response
@@ -75,12 +75,12 @@ module Charming
75
75
 
76
76
  # An overlay consumes the key whether or not the component handled it.
77
77
  def overlay_response
78
- controller.send(:dispatch_to_focused_component)
78
+ controller.component_dispatch.dispatch_to_focused_component
79
79
  response
80
80
  end
81
81
 
82
82
  def sidebar_focused?
83
- controller.sidebar_focused?
83
+ controller.respond_to?(:sidebar_focused?) && controller.sidebar_focused?
84
84
  end
85
85
 
86
86
  def sidebar_response
@@ -98,11 +98,11 @@ module Charming
98
98
  end
99
99
 
100
100
  def component_claimed?
101
- controller.send(:dispatch_to_focused_component) == :handled
101
+ controller.component_dispatch.dispatch_to_focused_component == :handled
102
102
  end
103
103
 
104
104
  def ring_claimed?
105
- controller.send(:dispatch_tab_traversal) == :handled
105
+ controller.component_dispatch.dispatch_tab_traversal == :handled
106
106
  end
107
107
 
108
108
  def response
@@ -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
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Timer control helpers mixed into Controller. Controllers are ephemeral, so
6
+ # starting and stopping named timers is delegated to the runtime-owned
7
+ # TimerControl exposed on the application (a null object outside a runtime).
8
+ module Timers
9
+ # Schedules the named declared timer. Idempotent while the timer is running.
10
+ def start_timer(name)
11
+ application.timer_control.start(name)
12
+ end
13
+
14
+ # Unschedules the named timer. Idempotent when it is not running.
15
+ def stop_timer(name)
16
+ application.timer_control.stop(name)
17
+ end
18
+
19
+ # True while the named timer is scheduled.
20
+ def timer_running?(name)
21
+ application.timer_control.running?(name)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -2,10 +2,20 @@
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
- TimerBinding = Data.define(:name, :interval, :action)
14
+ TimerBinding = Data.define(:name, :interval, :action, :autostart) do
15
+ def initialize(name:, interval:, action:, autostart: true)
16
+ super
17
+ end
18
+ end
9
19
  TaskBinding = Data.define(:name, :action)
10
20
 
11
21
  extend ClassMethods
@@ -13,106 +23,136 @@ module Charming
13
23
  include Rendering
14
24
  include SessionState
15
25
  include FocusManagement
16
- include SidebarNavigation
17
- include CommandPalette
18
- include ComponentDispatching
19
26
  include Dispatching
20
27
  include Terminal
28
+ include Timers
21
29
 
22
30
  attr_reader :application, :event, :params, :screen, :route
23
31
 
24
- # Initializes the controller with its parent application and optional event.
25
- # Defaults to an 80x24 screen when no backend size is available.
26
- 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
27
43
  @application = application
28
44
  @event = event
29
45
  @params = params
30
46
  @screen = screen || Screen.new(width: 80, height: 24)
31
47
  @route = route
32
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
33
89
  end
34
90
 
35
91
  # Dispatches a named action on this controller (e.g. :show), running all
36
92
  # before/around/after hooks and rescue_from handlers.
37
- def dispatch(action)
38
- run_action_with_hooks(action)
39
- render_default_action if response.nil? && auto_render_after?(action)
40
- 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
41
99
  end
42
100
 
43
101
  # Key event dispatch. The precedence ladder (palette → focused text capture →
44
102
  # global bindings → overlay → sidebar/content/component) lives in KeyDispatch.
45
- def dispatch_key
46
- KeyDispatch.new(self).call
103
+ def dispatch_key(event = nil)
104
+ with_dispatch_state(event) { KeyDispatch.new(self).call }
47
105
  end
48
106
 
49
107
  # Timer event dispatcher: looks up the named action in timer bindings and runs it
50
108
  # with the full hook chain. Unlike #dispatch there is no render("") fallback — a
51
109
  # timer action that renders nothing yields a nil response, so silent ticks skip
52
110
  # the repaint instead of blanking the screen.
53
- def dispatch_timer
54
- b = self.class.timer_bindings[event.name.to_sym]
55
- return nil unless b
56
-
57
- run_action_with_hooks(b.action)
58
- response
111
+ def dispatch_timer(event = nil)
112
+ with_dispatch_state(event) { timer_response }
59
113
  end
60
114
 
61
115
  # Task event dispatcher: looks up the handler in task bindings.
62
- def dispatch_task
63
- b = self.class.task_bindings[event.name.to_sym]
64
- b ? dispatch(b.action) : nil
116
+ def dispatch_task(event = nil)
117
+ with_dispatch_state(event) { task_response(self.class.task_bindings) }
65
118
  end
66
119
 
67
120
  # Task progress dispatcher: looks up the handler in task progress bindings.
68
- def dispatch_task_progress
69
- b = self.class.task_progress_bindings[event.name.to_sym]
70
- b ? dispatch(b.action) : nil
121
+ def dispatch_task_progress(event = nil)
122
+ with_dispatch_state(event) { task_response(self.class.task_progress_bindings) }
71
123
  end
72
124
 
73
125
  # Paste event dispatcher: forwards pasted text to the focused component's
74
126
  # `handle_paste` (TextInput, TextArea, Form text fields, and Autocomplete support it).
75
- def dispatch_paste
76
- slot = focus.current
77
- return nil unless slot && respond_to?(slot, true)
78
-
79
- component = send(slot)
80
- return nil unless component.respond_to?(:handle_paste)
81
-
82
- result = component.handle_paste(event)
83
- return nil if result.nil?
84
-
85
- dispatch_component_result(slot, result)
86
- response
127
+ def dispatch_paste(event = nil)
128
+ with_dispatch_state(event) { paste_response }
87
129
  end
88
130
 
89
131
  # Mouse event dispatcher: command palette (if open) wins, then sidebar clicks
90
132
  # (route rows navigate directly), then named layout panes/components.
91
- def dispatch_mouse
92
- return dispatch_command_palette_mouse if command_palette_open?
93
-
94
- sidebar_response = dispatch_sidebar_mouse
95
- return sidebar_response if sidebar_response
96
-
97
- dispatch_component_mouse
133
+ def dispatch_mouse(event = nil)
134
+ with_dispatch_state(event) { mouse_response }
98
135
  end
99
136
 
100
137
  # Renders a body or template wrapped in the controller's layout. Out-of-band escape sequences
101
138
  # registered while rendering (e.g. image transmissions) are collected by the Runtime around the
102
139
  # whole dispatch and attached to the response.
103
140
  def render(body = "", **assigns)
141
+ assert_loop_thread!(:render)
104
142
  body = view_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
105
- @response = Response.render(render_with_layout(body))
143
+ assign_response(Response.render(render_with_layout(body)), "render")
106
144
  end
107
145
 
108
146
  def render_view(view_class, **assigns)
109
- @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})")
110
149
  end
111
150
 
112
151
  # Renders a template from `app/views` by name, applying the controller's layout. *name* is the
113
152
  # template path (e.g., "home/show") and additional keyword *assigns* are forwarded to the view.
114
153
  def render_template(name, **assigns)
115
- @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})")
116
156
  end
117
157
 
118
158
  # Returns the active theme for this request, delegated to the application.
@@ -131,25 +171,178 @@ module Charming
131
171
  application.logger
132
172
  end
133
173
 
134
- # Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
135
- def open_theme_palette
136
- session[:command_palette] = command_palette_state(:themes)
137
- focus.push_scope([:command_palette], origin: :command_palette)
138
- render_default_action
139
- end
140
-
141
- # Navigates to the given URL path.
142
- def navigate_to(path)
143
- @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}")
144
179
  end
145
180
 
146
181
  # Exits the application — sets a quit response that terminates the event loop.
147
182
  def quit
148
- @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)
149
202
  end
150
203
 
151
204
  private
152
205
 
153
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
154
347
  end
155
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