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.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/charming/application.rb +91 -14
- data/lib/charming/application_state.rb +23 -0
- data/lib/charming/cli.rb +2 -2
- data/lib/charming/controller/action_hooks.rb +5 -1
- data/lib/charming/controller/class_methods.rb +66 -15
- data/lib/charming/controller/component_dispatch.rb +163 -0
- data/lib/charming/controller/dispatching.rb +1 -2
- data/lib/charming/controller/focus_management.rb +67 -1
- data/lib/charming/controller/key_dispatch.rb +7 -7
- data/lib/charming/controller/rendering.rb +22 -6
- data/lib/charming/controller/session_state.rb +63 -22
- data/lib/charming/controller.rb +247 -59
- data/lib/charming/cross_thread_access.rb +9 -0
- data/lib/charming/double_render_error.rb +8 -0
- data/lib/charming/generators/layout_generator.rb +4 -1
- data/lib/charming/generators/migration_generator.rb +1 -1
- data/lib/charming/generators/model_generator.rb +2 -2
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/screen_generator.rb +2 -2
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/internal/deep_freeze.rb +23 -0
- data/lib/charming/internal/env_inquirer.rb +22 -0
- data/lib/charming/internal/inflections.rb +93 -0
- data/lib/charming/internal/session_guard.rb +27 -0
- data/lib/charming/internal/terminal/cursor.rb +29 -0
- data/lib/charming/internal/terminal/size.rb +47 -0
- data/lib/charming/internal/terminal/tty_backend.rb +10 -8
- data/lib/charming/presentation/components/autocomplete.rb +14 -6
- data/lib/charming/presentation/components/command_palette.rb +11 -9
- data/lib/charming/presentation/components/filepicker.rb +4 -4
- data/lib/charming/presentation/components/form/confirm.rb +2 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/form/input.rb +3 -3
- data/lib/charming/presentation/components/form/multiselect.rb +4 -5
- data/lib/charming/presentation/components/form/select.rb +3 -3
- data/lib/charming/presentation/components/form/textarea.rb +3 -3
- data/lib/charming/presentation/components/form.rb +6 -6
- data/lib/charming/presentation/components/help_overlay.rb +2 -2
- data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
- data/lib/charming/presentation/components/list.rb +14 -5
- data/lib/charming/presentation/components/modal.rb +3 -2
- data/lib/charming/presentation/components/multi_select_list.rb +14 -7
- data/lib/charming/presentation/components/result.rb +61 -0
- data/lib/charming/presentation/components/tab_bar.rb +14 -6
- data/lib/charming/presentation/components/table.rb +64 -32
- data/lib/charming/presentation/components/text_area.rb +7 -7
- data/lib/charming/presentation/components/text_input.rb +7 -7
- data/lib/charming/presentation/components/tree.rb +15 -6
- data/lib/charming/presentation/components/viewport.rb +3 -3
- data/lib/charming/presentation/layout/pane.rb +6 -2
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/view.rb +35 -29
- data/lib/charming/render_artifacts.rb +24 -0
- data/lib/charming/response.rb +19 -8
- data/lib/charming/router.rb +50 -68
- data/lib/charming/runtime.rb +42 -26
- data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
- data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
- data/lib/charming/tasks/context.rb +35 -0
- data/lib/charming/test_helper.rb +42 -22
- data/lib/charming/unhandled_component_event.rb +9 -0
- data/lib/charming/unknown_slot.rb +9 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome.rb +1 -1
- data/lib/charming.rb +14 -6
- metadata +21 -70
- data/lib/charming/controller/component_dispatching.rb +0 -125
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 523be6b8c824b1f82ce8eb6151f276a90a81f111d9811f0339fec63ce64ad98b
|
|
4
|
+
data.tar.gz: 72009cdace9af96c2295ea34ceaa1dfbaa78a2654399ca7bc62773b4343ceed5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
data/lib/charming/application.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
97
|
-
#
|
|
98
|
-
#
|
|
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.
|
|
183
|
-
#
|
|
184
|
-
#
|
|
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
|
-
|
|
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
|
|
241
|
-
#
|
|
242
|
-
#
|
|
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)
|
|
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 =
|
|
120
|
-
|
|
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,
|
|
6
|
-
# timers, task handlers, the auto-rendered action,
|
|
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
|
|
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
|
-
|
|
133
|
+
(explicit_focus_ring || slot_definitions.keys).dup
|
|
99
134
|
end
|
|
100
135
|
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
101
|
+
controller.component_dispatch.dispatch_to_focused_component == :handled
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def ring_claimed?
|
|
105
|
-
controller.
|
|
105
|
+
controller.component_dispatch.dispatch_tab_traversal == :handled
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
def response
|