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