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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 651009e81e5bb2eb02b2df432b5e399773150691413f2519a6888160b60bd1ca
4
- data.tar.gz: 8aa3bf4aebc1abab7890eec9b68791d4ed0fd354cffb982cc40f2cf4f261f70c
3
+ metadata.gz: 523be6b8c824b1f82ce8eb6151f276a90a81f111d9811f0339fec63ce64ad98b
4
+ data.tar.gz: 72009cdace9af96c2295ea34ceaa1dfbaa78a2654399ca7bc62773b4343ceed5
5
5
  SHA512:
6
- metadata.gz: d4276dd6ef588077877b4d4d44758a9c176161fccddd22a5d74161f8615ee38f3c8078872a0ccbca6c8d73e83c994f7e0221f45f75348b9b81bef507a35ff491
7
- data.tar.gz: 595008aad5caa6c524e872593b8554b588c8018592aca1ead16820200c31dec67cb1189e338658e902abafa9033612780441ed71bb57bcdb8f296b8f03bbfecc
6
+ metadata.gz: 67c08a96c480b1f3e4b1ae903d406e84e02d4a7086dd6584d23d32c3fbe63bf06be3e94181287ed100491eff1dd083753df8888e50b20cc8666adc58a45fbd56
7
+ data.tar.gz: '0699c84e711f47fa6b44ea2e3290e12927e2e1d6429c5f9855970050b214e62d5ae37ffb0922993f8007b6d87e1bd0d4f9237e8ca8a618272dc08274f2153689'
data/README.md CHANGED
@@ -4,12 +4,14 @@
4
4
 
5
5
  A Rails-inspired terminal user interface framework for **Ruby 4+**.
6
6
 
7
- Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, background tasks, cross-platform audio playback, inline image display (Kitty graphics protocol), system clipboard / desktop notifications / window title, braille charts and sparklines, and testable terminal backends.
7
+ Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, physics-based animation (springs and projectiles, ported from charmbracelet/harmonica), background tasks, cross-platform audio playback, inline image display (Kitty graphics protocol), system clipboard / desktop notifications / window title, braille charts and sparklines, and testable terminal backends.
8
8
 
9
9
  ## Project Status
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
@@ -163,6 +164,13 @@ module Charming
163
164
  end
164
165
 
165
166
  attr_accessor :logger, :task_executor
167
+ attr_writer :timer_control
168
+
169
+ # The runtime-injected timer control used by `Controller#start_timer` and
170
+ # friends. Defaults to a null object so controllers work outside a Runtime.
171
+ def timer_control
172
+ @timer_control ||= Internal::TimerControl::Null.new
173
+ end
166
174
  attr_reader :session
167
175
 
168
176
  # Initializes the session hash for per-request state storage, restoring a
@@ -172,9 +180,10 @@ module Charming
172
180
  @session = load_session
173
181
  end
174
182
 
175
- # Serializes the session to the configured `persist_session` path. Entries that
176
- # don't survive a JSON round-trip (state objects, procs, focus scopes) are skipped.
177
- # 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.
178
187
  def save_session
179
188
  path = self.class.session_path
180
189
  return unless path
@@ -214,27 +223,102 @@ module Charming
214
223
  private
215
224
 
216
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.
217
229
  def load_session
218
230
  path = self.class.session_path
219
231
  return {} unless path && File.exist?(path)
220
232
 
221
- 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
222
236
  rescue JSON::ParserError => e
223
237
  logger.warn("session not restored: #{e.message}")
224
238
  {}
225
239
  end
226
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
+
227
256
  # Framework-internal session keys that must not be persisted: their values carry
228
257
  # symbols in *values* (which JSON round-trips into strings, corrupting focus rings
229
- # and palette state) and they describe transient UI state anyway.
230
- 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
231
261
 
232
262
  # The subset of session entries that survive a JSON round-trip: nil, booleans,
233
- # numbers, strings, symbols, and arrays/hashes of those. State objects, procs,
234
- # framework-internal keys, and other rich values are skipped (hash keys come back
235
- # 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).
236
268
  def serializable_session
237
- 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}")
238
322
  end
239
323
 
240
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,18 +18,23 @@ 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
- def timer(name, every:, action:)
23
+ # Timers run from boot by default; declare `autostart: false` to schedule one only when an
24
+ # action calls `start_timer`.
25
+ def timer(name, every:, action:, autostart: true)
28
26
  raise ArgumentError, "timer interval must be positive (got #{every.inspect})" unless every.is_a?(Numeric) && every.positive?
29
27
 
30
- timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
28
+ timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action, autostart: autostart)
29
+ end
30
+
31
+ # Declares an animation: a stopped timer ticking *action* at *fps* frames per second.
32
+ # Begin motion with `start_timer(name)`; the action calls `stop_timer(name)` once the
33
+ # motion settles, returning the app to zero idle cost.
34
+ def animate(name, action:, fps: 30)
35
+ raise ArgumentError, "fps must be positive (got #{fps.inspect})" unless fps.is_a?(Numeric) && fps.positive?
36
+
37
+ timer(name, every: 1.0 / fps, action: action, autostart: false)
31
38
  end
32
39
 
33
40
  # Declares a task handler for async work submitted via `run_task(:name)`. When the task emits
@@ -43,6 +50,24 @@ module Charming
43
50
  task_progress_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
44
51
  end
45
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
+
46
71
  # Sets the action that the controller should auto-render after a non-rendering action runs.
47
72
  # Defaults to :show when unset.
48
73
  def auto_render(action = :show)
@@ -66,6 +91,22 @@ module Charming
66
91
  @layout = layout_class
67
92
  end
68
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
+
69
110
  # Hash of registered key bindings (symbol key name => action method name), inherited from
70
111
  # superclass controllers.
71
112
  def key_bindings
@@ -77,19 +118,26 @@ module Charming
77
118
  @key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
78
119
  end
79
120
 
80
- # 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.
81
125
  def focus_ring(*slots)
82
- @focus_ring_slots = slots
126
+ @focus_ring_slots = slots unless slots.empty?
83
127
  end
84
128
 
85
- # 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.
86
132
  def focus_ring_slots
87
- @focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
133
+ (explicit_focus_ring || slot_definitions.keys).dup
88
134
  end
89
135
 
90
- # Array of registered command palette entries, inherited from superclass when undefined.
91
- def command_bindings
92
- @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?
93
141
  end
94
142
 
95
143
  # Hash of timer name => TimerBinding, inherited from superclass when undefined.
@@ -107,8 +155,22 @@ module Charming
107
155
  @task_progress_bindings ||= superclass.respond_to?(:task_progress_bindings) ? superclass.task_progress_bindings.dup : {}
108
156
  end
109
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
+
110
164
  private
111
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
+
112
174
  # Validates that *scope* is :content or :global; otherwise raises ArgumentError.
113
175
  def validate_key_scope(scope)
114
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