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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb4da799ae87f28a2495a56dd47dcb049c5b532397993976a5d56fcb2a51a518
4
- data.tar.gz: 070767c2b15058ff97db6d4aec1da299f0f8a7df9e249ac323ef9ff1329764ac
3
+ metadata.gz: 523be6b8c824b1f82ce8eb6151f276a90a81f111d9811f0339fec63ce64ad98b
4
+ data.tar.gz: 72009cdace9af96c2295ea34ceaa1dfbaa78a2654399ca7bc62773b4343ceed5
5
5
  SHA512:
6
- metadata.gz: c4a76d6edd2439e200ea88d1ec13f8be7d4a74974770fd091de041f3f0c03c953edc0ae72b53ca0aed3dd9648f924d3c693d9a696ea5a727d2d351dc6f04e660
7
- data.tar.gz: da98baee0f32faa72560af2b6f1f05f80b61996a272739145774103d42d672d536c5b00634082042ee0a3d4d919eb9f53080f2c143ffb1b7d7563d9b7da86b89
6
+ metadata.gz: 67c08a96c480b1f3e4b1ae903d406e84e02d4a7086dd6584d23d32c3fbe63bf06be3e94181287ed100491eff1dd083753df8888e50b20cc8666adc58a45fbd56
7
+ data.tar.gz: '0699c84e711f47fa6b44ea2e3290e12927e2e1d6429c5f9855970050b214e62d5ae37ffb0922993f8007b6d87e1bd0d4f9237e8ca8a618272dc08274f2153689'
data/README.md CHANGED
@@ -10,6 +10,8 @@ Charming gives terminal apps familiar application structure: routes, controllers
10
10
 
11
11
  Charming is still in its infancy and is under constant development. APIs, behavior, and generated app structure may change until the project reaches a stable `1.0` release.
12
12
 
13
+ [API_POLICY.md](API_POLICY.md) defines what the 1.0 freeze covers: `Charming::Internal` is unversioned, and everything else follows semver from 1.0.
14
+
13
15
  ## Quick Start
14
16
 
15
17
  Install the Charming CLI gem on your machine:
@@ -24,7 +24,7 @@ module Charming
24
24
  # Derives the module namespace from the class name — e.g., Admin::HomeController
25
25
  # yields "Admin". Mirrors Rails' engine-style namespacing.
26
26
  def namespace
27
- ActiveSupport::Inflector.deconstantize(name.to_s)
27
+ Internal::Inflections.deconstantize(name.to_s)
28
28
  end
29
29
 
30
30
  # Returns or sets the app logger. Defaults to a null-device logger so app and framework code
@@ -93,9 +93,10 @@ module Charming
93
93
  end
94
94
 
95
95
  # Opts into session persistence: the session hash is serialized as JSON to *to*
96
- # when the app quits and reloaded on boot. Only JSON-safe values survive the
97
- # round-trip (hash keys come back as symbols); non-serializable entries (state
98
- # objects, procs) are skipped with a warning in the log.
96
+ # when the app quits and reloaded on boot. JSON-safe values survive (hash keys
97
+ # come back as symbols; symbol values come back as strings). State objects
98
+ # persist only their `persist`-marked attributes; anything else dropped warns
99
+ # once per key (category :session_drop).
99
100
  def persist_session(to:)
100
101
  @session_path = to
101
102
  end
@@ -179,9 +180,10 @@ module Charming
179
180
  @session = load_session
180
181
  end
181
182
 
182
- # Serializes the session to the configured `persist_session` path. Entries that
183
- # don't survive a JSON round-trip (state objects, procs, focus scopes) are skipped.
184
- # No-op when persistence isn't configured. Called by the Runtime on exit.
183
+ # Serializes the session to the configured `persist_session` path. State objects
184
+ # persist their `persist`-marked attributes only; other attributes reinitialize to
185
+ # defaults on next boot. Dropped entries warn once per key. No-op when persistence
186
+ # isn't configured. Called by the Runtime on exit.
185
187
  def save_session
186
188
  path = self.class.session_path
187
189
  return unless path
@@ -221,27 +223,102 @@ module Charming
221
223
  private
222
224
 
223
225
  # Loads the persisted session JSON (symbolizing keys), or {} when absent/invalid.
226
+ # Serialized state objects are re-instantiated from their declared class and
227
+ # persisted attributes; unknown classes and stale attribute sets degrade to a
228
+ # logged warning plus fresh state — a stale session file never crashes boot.
224
229
  def load_session
225
230
  path = self.class.session_path
226
231
  return {} unless path && File.exist?(path)
227
232
 
228
- JSON.parse(File.read(path), symbolize_names: true)
233
+ data = JSON.parse(File.read(path), symbolize_names: true)
234
+ data[:states] = restore_states(data[:states]) if data[:states].is_a?(Hash)
235
+ data
229
236
  rescue JSON::ParserError => e
230
237
  logger.warn("session not restored: #{e.message}")
231
238
  {}
232
239
  end
233
240
 
241
+ # Re-instantiates serialized state objects; entries that fail (renamed class,
242
+ # changed attributes) are skipped with a warning.
243
+ def restore_states(serialized)
244
+ serialized.filter_map { |name, payload| restore_state(name, payload) }.to_h
245
+ end
246
+
247
+ # Restores one state object from its serialized payload, or nil on any mismatch.
248
+ def restore_state(name, payload)
249
+ klass = Object.const_get(payload[:class].to_s)
250
+ [name, klass.new(**payload[:attributes].to_h)]
251
+ rescue => e
252
+ logger.warn("session state #{name.inspect} not restored (#{e.class}: #{e.message}); starting fresh")
253
+ nil
254
+ end
255
+
234
256
  # Framework-internal session keys that must not be persisted: their values carry
235
257
  # symbols in *values* (which JSON round-trips into strings, corrupting focus rings
236
- # and palette state) and they describe transient UI state anyway.
237
- INTERNAL_SESSION_KEYS = %i[focus_state mouse_targets command_palette].freeze
258
+ # and palette state) and they describe transient UI state anyway. Both are app-global
259
+ # across screens by design: focus rings and the palette outlive any one controller.
260
+ INTERNAL_SESSION_KEYS = %i[focus_state command_palette].freeze
238
261
 
239
262
  # The subset of session entries that survive a JSON round-trip: nil, booleans,
240
- # numbers, strings, symbols, and arrays/hashes of those. State objects, procs,
241
- # framework-internal keys, and other rich values are skipped (hash keys come back
242
- # as symbols via symbolize_names; symbol *values* come back as strings).
263
+ # numbers, strings, symbols, and arrays/hashes of those plus state objects with
264
+ # `persist`-marked attributes, serialized under :states as
265
+ # {name => {class:, attributes:}}. Procs and other rich values are dropped with a
266
+ # one-time :session_drop warning per key (hash keys come back as symbols via
267
+ # symbolize_names; symbol *values* come back as strings).
243
268
  def serializable_session
244
- session.except(*INTERNAL_SESSION_KEYS).select { |_key, value| json_safe?(value) }
269
+ plain = session.except(:states, *INTERNAL_SESSION_KEYS)
270
+ safe = plain.select { |_key, value| json_safe?(value) }
271
+ (plain.keys - safe.keys).each do |key|
272
+ warn_session_drop(key,
273
+ "session[:#{key}]: the value is not JSON-safe. Keep only strings, numbers, " \
274
+ "booleans, nil, symbols, and arrays/hashes of those in the session.")
275
+ end
276
+
277
+ states = serializable_states
278
+ states.empty? ? safe : safe.merge(states: states)
279
+ end
280
+
281
+ # Serializes session[:states] entries: classes with `persist` declarations keep
282
+ # their marked (JSON-safe) attributes; undeclared classes are dropped with a
283
+ # one-time :session_drop warning naming the `persist` declaration to add.
284
+ def serializable_states
285
+ states = session[:states]
286
+ return {} unless states
287
+
288
+ states.filter_map { |name, object| serialize_state(name, object) }.to_h
289
+ end
290
+
291
+ # Serializes one state object, or nil (with a warning) when its class declares no
292
+ # persisted attributes.
293
+ def serialize_state(name, object)
294
+ klass = object.class
295
+ unless klass.respond_to?(:persisted?) && klass.persisted?
296
+ warn_session_drop(name,
297
+ "#{klass.name || klass.inspect} (session[:states][:#{name}]) declares no persisted attributes. " \
298
+ "Declare what to keep: `persist :attr, ...` on #{klass.name || "the state class"}.")
299
+ return nil
300
+ end
301
+
302
+ [name, {class: klass.name, attributes: persisted_attributes_for(object)}]
303
+ end
304
+
305
+ # The JSON-safe subset of a state object's persisted attributes; non-safe values
306
+ # are dropped with a per-attribute warning.
307
+ def persisted_attributes_for(object)
308
+ object.class.persisted_attribute_names.filter_map do |attribute|
309
+ value = object.public_send(attribute)
310
+ next [attribute, value] if json_safe?(value)
311
+
312
+ warn_session_drop(attribute,
313
+ "#{object.class.name}##{attribute} is not JSON-safe. Keep only strings, numbers, " \
314
+ "booleans, nil, and arrays/hashes of those in persisted attributes.")
315
+ nil
316
+ end.to_h
317
+ end
318
+
319
+ # Warns once per key/class that a session entry was dropped, naming the fix.
320
+ def warn_session_drop(category_key, detail)
321
+ Charming.deprecate("save_session dropped #{detail}", category: :"session_drop_#{category_key}")
245
322
  end
246
323
 
247
324
  def json_safe?(value)
@@ -6,8 +6,31 @@ module Charming
6
6
  # ApplicationState is the base for session-backed TUI state. It includes
7
7
  # `ActiveModel::Model` (validation, initialisation) and `ActiveModel::Attributes` (typed attributes
8
8
  # with defaults via `attribute :name, :type, default: ...`), making it suitable for screen/form state.
9
+ #
10
+ # Persistence across restarts is explicit: `persist :attr_name` marks the attributes that
11
+ # `save_session` serializes. Unmarked attributes reinitialize to their defaults on the
12
+ # next boot — by design, not by silent accident. State classes with no `persist`
13
+ # declarations are dropped from the session file with a deprecation warning this
14
+ # release; at 1.0 they are dropped without warning.
9
15
  class ApplicationState
10
16
  include ActiveModel::Model
11
17
  include ActiveModel::Attributes
18
+
19
+ # Marks attributes that persist across restarts via `persist_session`. Only
20
+ # JSON-safe values (strings, numbers, booleans, nil, arrays/hashes of those)
21
+ # survive the file; others are dropped with a warning.
22
+ def self.persist(*names)
23
+ persisted_attribute_names.concat(names.map(&:to_sym))
24
+ end
25
+
26
+ # The names marked with `persist`, inherited from the superclass.
27
+ def self.persisted_attribute_names
28
+ @persisted_attribute_names ||= superclass.respond_to?(:persisted_attribute_names) ? superclass.persisted_attribute_names.dup : []
29
+ end
30
+
31
+ # True when the class marks at least one attribute with `persist`.
32
+ def self.persisted?
33
+ persisted_attribute_names.any?
34
+ end
12
35
  end
13
36
  end
data/lib/charming/cli.rb CHANGED
@@ -116,8 +116,8 @@ module Charming
116
116
 
117
117
  # Resolves `<AppModule>::Application` from the root file name, or nil.
118
118
  def console_application_class(root_file)
119
- module_name = ActiveSupport::Inflector.camelize(File.basename(root_file, ".rb"))
120
- ActiveSupport::Inflector.constantize("#{module_name}::Application")
119
+ module_name = Internal::Inflections.camelize(File.basename(root_file, ".rb"))
120
+ Internal::Inflections.constantize("#{module_name}::Application")
121
121
  rescue NameError
122
122
  nil
123
123
  end
@@ -57,9 +57,13 @@ module Charming
57
57
  private
58
58
 
59
59
  # Wraps an action call in the full before/around/after hook chain and rescue handlers.
60
- # Replaces the plain `public_send(action)` in Controller#dispatch.
60
+ # Replaces the plain `public_send(action)` in Controller#dispatch. Tracks the running
61
+ # action so the DoubleRenderError message can name it.
61
62
  def run_action_with_hooks(action)
63
+ previous, @current_action = @current_action, action
62
64
  run_with_rescue(action) { run_around_hooks(action) { run_action(action) } }
65
+ ensure
66
+ @current_action = previous
63
67
  end
64
68
 
65
69
  def run_action(action)
@@ -2,8 +2,10 @@
2
2
 
3
3
  module Charming
4
4
  class Controller
5
- # DSL for declaring controller-level event bindings and configuration: keys, commands,
6
- # timers, task handlers, the auto-rendered action, layout wrapper, and focus ring.
5
+ # DSL for declaring controller-level event bindings and configuration: keys,
6
+ # timers, task handlers, component-event handlers, the auto-rendered action,
7
+ # layout wrapper, and focus ring. (The `command` palette DSL lives in the
8
+ # opt-in app shell: Charming::Shell::Palette.)
7
9
  # Mixed into Controller as class methods; also exposed for tests and shared base controllers.
8
10
  module ClassMethods
9
11
  # Binds a key press to a controller action. *name* is the normalized key symbol (e.g., "up",
@@ -16,12 +18,6 @@ module Charming
16
18
  key_binding_scopes[key_name] = normalized_scope
17
19
  end
18
20
 
19
- # Adds a CommandPalette entry with the given *label*. *action* is a method name to send on
20
- # the controller, or a block to instance_exec when selected.
21
- def command(label, action = nil, &block)
22
- command_bindings << Components::CommandPalette::Command.new(label: label, value: block || action)
23
- end
24
-
25
21
  # Declares a timer that fires every *every* seconds and dispatches *action* on the controller.
26
22
  # The runtime builds a TimerEvent and routes it to the active controller's dispatch_timer.
27
23
  # Timers run from boot by default; declare `autostart: false` to schedule one only when an
@@ -54,6 +50,24 @@ module Charming
54
50
  task_progress_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
55
51
  end
56
52
 
53
+ # Declares the action dispatched when the component in *slot* submits a value
54
+ # (`Result.submitted(value)` from its `handle_key`; legacy forms normalize). The action receives the value.
55
+ def on_submit(slot, action)
56
+ component_event_bindings[[slot.to_sym, :submitted]] = action.to_sym
57
+ end
58
+
59
+ # Declares the action dispatched when the component in *slot* selects an item
60
+ # (`Result.selected(value)` from its `handle_key`; legacy forms normalize). The action receives the value.
61
+ def on_select(slot, action)
62
+ component_event_bindings[[slot.to_sym, :selected]] = action.to_sym
63
+ end
64
+
65
+ # Declares the action dispatched when the component in *slot* is cancelled
66
+ # (`Result.cancelled` from its `handle_key`; legacy forms normalize). The action receives no arguments.
67
+ def on_cancel(slot, action)
68
+ component_event_bindings[[slot.to_sym, :cancelled]] = action.to_sym
69
+ end
70
+
57
71
  # Sets the action that the controller should auto-render after a non-rendering action runs.
58
72
  # Defaults to :show when unset.
59
73
  def auto_render(action = :show)
@@ -77,6 +91,22 @@ module Charming
77
91
  @layout = layout_class
78
92
  end
79
93
 
94
+ # Declares the component for the named focus *slot*. The factory block is
95
+ # instance_exec'd against the controller (it can read params and state) and the
96
+ # result is memoized for the controller's lifetime — this replaces the
97
+ # hand-rolled `@query ||= ...` private-method convention. Also defines a private
98
+ # reader with the slot's name returning the memoized component.
99
+ def slot(name, &factory)
100
+ slot_definitions[name.to_sym] = factory
101
+ define_method(name) { component_for(name) }
102
+ private name
103
+ end
104
+
105
+ # Hash of declared slots (name => factory block), inherited from superclass.
106
+ def slot_definitions
107
+ @slot_definitions ||= superclass.respond_to?(:slot_definitions) ? superclass.slot_definitions.dup : {}
108
+ end
109
+
80
110
  # Hash of registered key bindings (symbol key name => action method name), inherited from
81
111
  # superclass controllers.
82
112
  def key_bindings
@@ -88,19 +118,26 @@ module Charming
88
118
  @key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
89
119
  end
90
120
 
91
- # Defines the named focus slots cycled by Tab/Shift+Tab traversal.
121
+ # Defines the named focus slots cycled by Tab/Shift+Tab traversal. Accepts any
122
+ # names, including layout panes without components (e.g. :sidebar). Called with
123
+ # no arguments — or never called — the ring defaults to the declared slots in
124
+ # declaration order.
92
125
  def focus_ring(*slots)
93
- @focus_ring_slots = slots
126
+ @focus_ring_slots = slots unless slots.empty?
94
127
  end
95
128
 
96
- # Returns the focus ring slots, inherited from superclass when undefined.
129
+ # Returns the focus ring slots: the explicit `focus_ring` declaration from the
130
+ # nearest declaring ancestor when one exists, otherwise the declared slots in
131
+ # declaration order.
97
132
  def focus_ring_slots
98
- @focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
133
+ (explicit_focus_ring || slot_definitions.keys).dup
99
134
  end
100
135
 
101
- # Array of registered command palette entries, inherited from superclass when undefined.
102
- def command_bindings
103
- @command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
136
+ # True when the controller (or an ancestor) declared an explicit focus ring —
137
+ # explicit rings may name component-less layout panes, so they are not filtered
138
+ # to components the way the declared-slot default is.
139
+ def explicit_focus_ring?
140
+ !explicit_focus_ring.nil?
104
141
  end
105
142
 
106
143
  # Hash of timer name => TimerBinding, inherited from superclass when undefined.
@@ -118,8 +155,22 @@ module Charming
118
155
  @task_progress_bindings ||= superclass.respond_to?(:task_progress_bindings) ? superclass.task_progress_bindings.dup : {}
119
156
  end
120
157
 
158
+ # Hash of [slot, event] => action for component-event registrations made with
159
+ # `on_submit`/`on_select`/`on_cancel`, inherited from superclass when undefined.
160
+ def component_event_bindings
161
+ @component_event_bindings ||= superclass.respond_to?(:component_event_bindings) ? superclass.component_event_bindings.dup : {}
162
+ end
163
+
121
164
  private
122
165
 
166
+ # Returns the nearest explicit `focus_ring` declaration walking the superclass
167
+ # chain, or nil when none was made.
168
+ def explicit_focus_ring
169
+ return @focus_ring_slots if instance_variable_defined?(:@focus_ring_slots)
170
+
171
+ superclass.respond_to?(:explicit_focus_ring, true) ? superclass.send(:explicit_focus_ring) : nil
172
+ end
173
+
123
174
  # Validates that *scope* is :content or :global; otherwise raises ArgumentError.
124
175
  def validate_key_scope(scope)
125
176
  normalized_scope = scope.to_sym
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # ComponentDispatch forwards events to the currently focused component (the slot
6
+ # returned by `focus.current`) and translates component return values into controller
7
+ # action calls — `[:submitted, value]`, `[:selected, value]`, and `:cancelled` resolve
8
+ # through the class-level `on_submit`/`on_select`/`on_cancel` registry (falling back
9
+ # to the deprecated `<slot>_<event>` convention with a warning).
10
+ #
11
+ # Extracted as a collaborator (like KeyDispatch) so the dispatch rules live in one
12
+ # object instead of a mixin. Controllers access it via #component_dispatch.
13
+ class ComponentDispatch
14
+ def initialize(controller)
15
+ @controller = controller
16
+ end
17
+
18
+ # Sends the current key event to the focused component (if it responds to `handle_key`).
19
+ # Returns `:handled` after dispatching, or nil when no component is focused.
20
+ def dispatch_to_focused_component
21
+ slot = controller.focus.current
22
+ component = slot && controller.component_for(slot)
23
+ return nil unless component&.respond_to?(:handle_key)
24
+
25
+ result = component.handle_key(event)
26
+ return nil if result.nil?
27
+
28
+ dispatch_component_result(slot, result)
29
+ :handled
30
+ end
31
+
32
+ # Translates a component `handle_key` *result* into a controller action call.
33
+ # Legacy return forms (`:handled`, `:cancelled`, `[:submitted, v]`,
34
+ # `[:selected, v]`) normalize to Components::Result here; components in the wild
35
+ # may return either form. Falls back to a default render when no handler exists
36
+ # (production only).
37
+ def dispatch_component_result(slot, result)
38
+ action, arguments = component_result_action(slot, Components::Result.normalize(result))
39
+ action ? controller.send(action, *arguments) : controller.send(:render_default_action)
40
+ controller.send(:render_default_action) unless controller.send(:response)
41
+ end
42
+
43
+ # Handles Tab/Shift+Tab by cycling through the focus ring. Returns :handled after rendering.
44
+ def dispatch_tab_traversal
45
+ return nil unless key_name == :tab
46
+ return nil if controller.focus.ring.empty?
47
+
48
+ controller.focus.cycle(event.shift ? -1 : +1)
49
+ controller.send(:render_default_action)
50
+ :handled
51
+ end
52
+
53
+ # Hit-tests the current mouse event against named layout panes from the latest render.
54
+ # Clicks move focus to matching slots; components in clicked panes receive local coordinates.
55
+ def dispatch_component_mouse
56
+ target = mouse_target_for_event
57
+ return nil unless target
58
+
59
+ slot = target.fetch(:name)
60
+ previous_focus = controller.focus.current
61
+ controller.focus.focus(slot) if focusable_click?(slot)
62
+
63
+ result = dispatch_mouse_to_target_component(slot, target)
64
+ return controller.send(:response) if result.nil? && previous_focus == controller.focus.current
65
+
66
+ result ? dispatch_component_result(slot, result) : controller.send(:render_default_action)
67
+ controller.send(:response)
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :controller
73
+
74
+ def event
75
+ controller.event
76
+ end
77
+
78
+ # Resolves which controller action (if any) corresponds to the normalized *result*.
79
+ def component_result_action(slot, result)
80
+ case result.kind
81
+ when :cancelled
82
+ component_action(slot, :cancelled)
83
+ when :submitted, :selected
84
+ component_action(slot, result.kind, result.value)
85
+ end
86
+ end
87
+
88
+ # Returns `[action, arguments]` for the component event in *slot* with *suffix*
89
+ # (:submitted, :selected, or :cancelled). Resolution order: an explicit
90
+ # `on_submit`/`on_select`/`on_cancel` registration, then the legacy
91
+ # `<slot>_<suffix>` hook (with a one-time deprecation warning). With neither,
92
+ # development/test raise UnhandledComponentEvent; production logs a warning and
93
+ # returns nil, which falls back to the default render.
94
+ def component_action(slot, suffix, *arguments)
95
+ action = controller.class.component_event_bindings[[slot.to_sym, suffix]]
96
+ return [action, arguments] if action
97
+
98
+ legacy_action = :"#{slot}_#{suffix}"
99
+ if controller.respond_to?(legacy_action, true)
100
+ Charming.deprecate(
101
+ "#{controller.class.name || "an anonymous controller"}##{legacy_action} uses the auto-discovered hook. " \
102
+ "Declare it explicitly: `#{component_event_dsl(suffix)} :#{slot}, :#{legacy_action}`.",
103
+ category: :"component_event_#{controller.class.name}_#{slot}_#{suffix}"
104
+ )
105
+ return [legacy_action, arguments]
106
+ end
107
+
108
+ unhandled_component_event(slot, suffix)
109
+ nil
110
+ end
111
+
112
+ # Raises UnhandledComponentEvent outside production; logs a warning in production.
113
+ def unhandled_component_event(slot, suffix)
114
+ message = "Unhandled component event: #{controller.class.name || "anonymous controller"} has no handler for " \
115
+ ":#{slot} :#{suffix}. Declare one: `#{component_event_dsl(suffix)} :#{slot}, :your_action`."
116
+ raise UnhandledComponentEvent, message unless Charming.env.production?
117
+
118
+ controller.logger.warn(message)
119
+ end
120
+
121
+ # The class-level DSL name that declares a handler for *suffix*.
122
+ def component_event_dsl(suffix)
123
+ {submitted: "on_submit", selected: "on_select", cancelled: "on_cancel"}.fetch(suffix)
124
+ end
125
+
126
+ # The normalized key symbol for the current event.
127
+ def key_name
128
+ Charming.key_of(event)
129
+ end
130
+
131
+ def mouse_target_for_event
132
+ controller.mouse_targets.rfind { |target| target.fetch(:rect).cover?(event.x, event.y) }
133
+ end
134
+
135
+ def focusable_click?(slot)
136
+ event.respond_to?(:click?) && event.click? && controller.focus.ring.include?(slot)
137
+ end
138
+
139
+ def dispatch_mouse_to_target_component(slot, target)
140
+ component = controller.component_for(slot)
141
+ return nil unless component&.respond_to?(:handle_mouse)
142
+
143
+ local_event = local_mouse_event(target.fetch(:inner_rect))
144
+ return nil unless local_event
145
+
146
+ component.handle_mouse(local_event)
147
+ end
148
+
149
+ def local_mouse_event(rect)
150
+ return nil unless rect.cover?(event.x, event.y)
151
+
152
+ Events::MouseEvent.new(
153
+ button: event.button,
154
+ x: event.x - rect.x,
155
+ y: event.y - rect.y,
156
+ ctrl: event.ctrl,
157
+ alt: event.alt,
158
+ shift: event.shift
159
+ )
160
+ end
161
+ end
162
+ end
163
+ end
@@ -76,9 +76,8 @@ module Charming
76
76
  # free-typed text (see Component#captures_text?).
77
77
  def focused_component_captures_text?
78
78
  slot = focus.current
79
- return false unless slot && respond_to?(slot, true)
79
+ component = slot && component_for(slot)
80
80
 
81
- component = send(slot)
82
81
  component.respond_to?(:captures_text?) && component.captures_text?
83
82
  end
84
83
  end
@@ -9,8 +9,10 @@ module Charming
9
9
  # Returns the per-controller Focus object, defining the focus ring from class-level DSL
10
10
  # declarations on first access.
11
11
  def focus
12
+ assert_loop_thread!(:focus)
12
13
  @focus ||= Controller::Focus.for(session, self.class).tap do |f|
13
- f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
14
+ slots = resolved_focus_ring
15
+ f.define(slots) unless slots.empty?
14
16
  end
15
17
  end
16
18
 
@@ -18,6 +20,70 @@ module Charming
18
20
  def focused?(slot)
19
21
  focus.focused?(slot)
20
22
  end
23
+
24
+ # Internal: registers the focusable panes from the latest render — validates each
25
+ # name against the slot registry, then defines the layout focus scope. Called by
26
+ # views during render; the single commit point for layout focus.
27
+ def register_layout_focus(names)
28
+ validate_layout_slots(names)
29
+ focus.define_layout(names)
30
+ end
31
+
32
+ private
33
+
34
+ # Raises UnknownSlot for pane names nothing declares (dev/test); logs in production.
35
+ def validate_layout_slots(names)
36
+ unknown = names.reject { |name| known_slot?(name) }
37
+ return if unknown.empty?
38
+
39
+ message = unknown_slot_message(unknown, "rendered as focusable panes")
40
+ raise UnknownSlot, message unless Charming.env.production?
41
+
42
+ logger.warn(message)
43
+ end
44
+
45
+ # Validates on_submit/on_select/on_cancel registrations once per controller
46
+ # instance, at the first dispatch (class-definition-time checks would be
47
+ # order-sensitive: a slot's method may be defined below the registration).
48
+ def validate_slot_registrations_once
49
+ return if @slot_registrations_validated
50
+
51
+ @slot_registrations_validated = true
52
+ unknown = self.class.component_event_bindings.keys.map(&:first).uniq.reject { |name| known_slot?(name) }
53
+ return if unknown.empty?
54
+
55
+ message = unknown_slot_message(unknown, "registered `on_*` handlers for undeclared slots")
56
+ raise UnknownSlot, message unless Charming.env.production?
57
+
58
+ logger.warn(message)
59
+ end
60
+
61
+ # True when *name* resolves as a slot: declared with `slot`, named in the focus
62
+ # ring, or defined as a method (the legacy convention).
63
+ def known_slot?(name)
64
+ self.class.slot_definitions.key?(name.to_sym) ||
65
+ self.class.focus_ring_slots.include?(name.to_sym) ||
66
+ respond_to?(name, true)
67
+ end
68
+
69
+ # Builds the UnknownSlot message, naming the unknown slots, the fix, and the
70
+ # declared slots.
71
+ def unknown_slot_message(unknown, context)
72
+ declared = self.class.slot_definitions.keys
73
+ "#{self.class.name || "An anonymous controller"} #{context}: " \
74
+ "#{unknown.map { |name| name.inspect }.join(", ")} — nothing declares them. " \
75
+ "Declare each with `slot :name { ... }`, add it to `focus_ring`, or define a same-named method. " \
76
+ "Declared slots: #{declared.empty? ? "(none)" : declared.map(&:inspect).join(", ")}."
77
+ end
78
+
79
+ # The ring handed to Focus: the class ring, filtered to actual components when it
80
+ # comes from declared slots (an explicit `focus_ring` keeps component-less panes).
81
+ def resolved_focus_ring
82
+ ring = self.class.focus_ring_slots
83
+ return ring if self.class.explicit_focus_ring?
84
+
85
+ ring.select { |name| component_for(name).is_a?(Component) }
86
+ end
21
87
  end
22
88
  end
23
89
  end
@@ -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