charming 0.1.1 → 0.1.2
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 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
data/lib/charming/controller.rb
CHANGED
|
@@ -8,115 +8,19 @@ module Charming
|
|
|
8
8
|
TimerBinding = Data.define(:name, :interval, :action)
|
|
9
9
|
TaskBinding = Data.define(:name, :action)
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Registers a command palette entry — visible in fuzzy search when Ctrl+K is pressed.
|
|
22
|
-
# Accepts either a method symbol or an inline callable block.
|
|
23
|
-
def command(label, action = nil, &block)
|
|
24
|
-
command_bindings << Presentation::Components::CommandPalette::Command.new(label: label, value: block || action)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Registers a periodic timer that fires at `every`-second intervals.
|
|
28
|
-
# The named action is dispatched on the current route's controller each time.
|
|
29
|
-
def timer(name, every:, action:)
|
|
30
|
-
timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Registers an async task handler that runs when a TaskEvent arrives from the task executor.
|
|
34
|
-
def on_task(name, action:)
|
|
35
|
-
task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Re-renders the given action after dispatched actions that do not set a response.
|
|
39
|
-
# This is opt-in so existing controllers keep explicit render semantics.
|
|
40
|
-
def auto_render(action = :show)
|
|
41
|
-
@auto_render_action = action.to_sym
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def auto_render_action
|
|
45
|
-
return @auto_render_action if instance_variable_defined?(:@auto_render_action)
|
|
46
|
-
return superclass.auto_render_action if superclass.respond_to?(:auto_render_action)
|
|
47
|
-
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Sets the layout class to wrap this controller's rendered output (e.g., for sidebar + main content).
|
|
52
|
-
# Accepts a special `:__charming_layout_reader__` sentinel to query — without setting — the current layout.
|
|
53
|
-
def layout(layout_class = :__charming_layout_reader__)
|
|
54
|
-
return resolved_layout if layout_class == :__charming_layout_reader__
|
|
55
|
-
|
|
56
|
-
@layout = layout_class
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Returns inherited key bindings merged from the class hierarchy.
|
|
60
|
-
# Each subclass gets a fresh copy of its parent's key bindings to avoid cross-controller pollution.
|
|
61
|
-
def key_bindings
|
|
62
|
-
@key_bindings ||= superclass.respond_to?(:key_bindings) ? superclass.key_bindings.dup : {}
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Returns inherited key binding scopes merged from the class hierarchy.
|
|
66
|
-
# Each subclass gets a fresh copy of its parent's scopes to match key binding inheritance.
|
|
67
|
-
def key_binding_scopes
|
|
68
|
-
@key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Registers a focus ring slot for this controller — slots participate in Tab/Shift+Tab traversal.
|
|
72
|
-
# Example: `focus_ring :sidebar, :content` makes sidebar and content tabbable.
|
|
73
|
-
def focus_ring(*slots)
|
|
74
|
-
@focus_ring_slots = slots
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Returns inherited focus ring slots merged from the class hierarchy.
|
|
78
|
-
def focus_ring_slots
|
|
79
|
-
@focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Returns inherited command bindings (command palette entries) from this controller and its ancestors.
|
|
83
|
-
def command_bindings
|
|
84
|
-
@command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Returns inherited timer bindings from this controller and its ancestors.
|
|
88
|
-
def timer_bindings
|
|
89
|
-
@timer_bindings ||= superclass.respond_to?(:timer_bindings) ? superclass.timer_bindings.dup : {}
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Returns inherited task bindings (async task handlers) from this controller and its ancestors.
|
|
93
|
-
def task_bindings
|
|
94
|
-
@task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {}
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
private
|
|
98
|
-
|
|
99
|
-
def validate_key_scope(scope)
|
|
100
|
-
normalized_scope = scope.to_sym
|
|
101
|
-
return normalized_scope if %i[content global].include?(normalized_scope)
|
|
102
|
-
|
|
103
|
-
raise ArgumentError, "unknown key scope: #{scope.inspect}"
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Returns the layout class for this controller, walking up the inheritance chain until one is found or nil.
|
|
107
|
-
# Used internally by `layout` when called without args (getter mode).
|
|
108
|
-
def resolved_layout
|
|
109
|
-
return @layout if instance_variable_defined?(:@layout)
|
|
110
|
-
return superclass.layout if superclass.respond_to?(:layout)
|
|
111
|
-
|
|
112
|
-
nil
|
|
113
|
-
end
|
|
114
|
-
end
|
|
11
|
+
extend ClassMethods
|
|
12
|
+
include Rendering
|
|
13
|
+
include SessionState
|
|
14
|
+
include FocusManagement
|
|
15
|
+
include SidebarNavigation
|
|
16
|
+
include CommandPalette
|
|
17
|
+
include ComponentDispatching
|
|
18
|
+
include Dispatching
|
|
115
19
|
|
|
116
20
|
attr_reader :application, :event, :params, :screen, :route
|
|
117
21
|
|
|
118
|
-
# Initializes the controller with its parent application and
|
|
119
|
-
# Defaults to
|
|
22
|
+
# Initializes the controller with its parent application and optional event.
|
|
23
|
+
# Defaults to an 80x24 screen when no backend size is available.
|
|
120
24
|
def initialize(application:, event: nil, params: {}, screen: nil, route: nil)
|
|
121
25
|
@application = application
|
|
122
26
|
@event = event
|
|
@@ -126,80 +30,81 @@ module Charming
|
|
|
126
30
|
@response = nil
|
|
127
31
|
end
|
|
128
32
|
|
|
129
|
-
# Dispatches a named action on this controller (e.g
|
|
130
|
-
# returning a default empty render if the action produces no response.
|
|
33
|
+
# Dispatches a named action on this controller (e.g. :show).
|
|
131
34
|
def dispatch(action)
|
|
132
35
|
public_send(action)
|
|
133
36
|
render_default_action if response.nil? && auto_render_after?(action)
|
|
134
37
|
response || render("")
|
|
135
38
|
end
|
|
136
39
|
|
|
137
|
-
# Key event dispatch
|
|
138
|
-
#
|
|
139
|
-
# then tab traversal, then focused component handling. Returns nil if no handler consumed the event.
|
|
40
|
+
# Key event dispatch: checks command palette first, then global bindings,
|
|
41
|
+
# sidebar (if focused), content bindings, tab traversal, and focused component.
|
|
140
42
|
def dispatch_key
|
|
141
43
|
return dispatch_command_palette_key if command_palette_open?
|
|
142
44
|
return dispatch(global_key_action) if global_key_action
|
|
143
45
|
return dispatch_sidebar_key if sidebar_focused?
|
|
144
|
-
|
|
145
46
|
return dispatch(content_key_action) if content_key_action
|
|
146
47
|
return response if dispatch_tab_traversal == :handled
|
|
147
48
|
return response if dispatch_to_focused_component == :handled
|
|
148
|
-
|
|
149
49
|
nil
|
|
150
50
|
end
|
|
151
51
|
|
|
152
|
-
# Timer event dispatcher: looks up the
|
|
153
|
-
# and dispatches it. Returns nil if no binding exists for this timer name.
|
|
52
|
+
# Timer event dispatcher: looks up the named action in timer bindings.
|
|
154
53
|
def dispatch_timer
|
|
155
|
-
|
|
156
|
-
|
|
54
|
+
b = self.class.timer_bindings[event.name.to_sym]
|
|
55
|
+
return nil unless b
|
|
56
|
+
|
|
57
|
+
public_send(b.action)
|
|
58
|
+
response
|
|
157
59
|
end
|
|
158
60
|
|
|
159
|
-
# Task event dispatcher: looks up the
|
|
160
|
-
# and dispatches it. Used by async tasks submitted via `run_task`.
|
|
61
|
+
# Task event dispatcher: looks up the handler in task bindings.
|
|
161
62
|
def dispatch_task
|
|
162
|
-
|
|
163
|
-
|
|
63
|
+
b = self.class.task_bindings[event.name.to_sym]
|
|
64
|
+
b ? dispatch(b.action) : nil
|
|
164
65
|
end
|
|
165
66
|
|
|
166
|
-
# Mouse event dispatcher: checks command palette (if open),
|
|
167
|
-
# then falls through to component mouse dispatch. Always returns nil in the base controller —
|
|
168
|
-
# subclasses override as needed.
|
|
67
|
+
# Mouse event dispatcher: checks command palette (if open), sidebar (if focused).
|
|
169
68
|
def dispatch_mouse
|
|
170
69
|
return dispatch_command_palette_mouse if command_palette_open?
|
|
171
70
|
return dispatch_sidebar_mouse if sidebar_focused?
|
|
172
|
-
|
|
173
71
|
dispatch_component_mouse
|
|
174
72
|
end
|
|
175
73
|
|
|
176
|
-
# Renders a body or template wrapped in
|
|
177
|
-
# Symbols render `app/views/<controller>/<symbol>.tui.erb` (or `.txt.erb`); strings render as literal bodies.
|
|
74
|
+
# Renders a body or template wrapped in the controller's layout.
|
|
178
75
|
def render(body = "", **assigns)
|
|
179
|
-
body =
|
|
76
|
+
body = view_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
|
|
180
77
|
@response = Response.render(render_with_layout(body))
|
|
181
78
|
end
|
|
182
79
|
|
|
80
|
+
def render_view(view_class, **assigns)
|
|
81
|
+
@response = Response.render(render_with_layout(view_class.new(**template_assigns(assigns))))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Renders a template from `app/views` by name, applying the controller's layout. *name* is the
|
|
85
|
+
# template path (e.g., "home/show") and additional keyword *assigns* are forwarded to the view.
|
|
183
86
|
def render_template(name, **assigns)
|
|
184
87
|
@response = Response.render(render_with_layout(template_body(name, **assigns)))
|
|
185
88
|
end
|
|
186
89
|
|
|
90
|
+
# Returns the active theme for this request, delegated to the application.
|
|
187
91
|
def theme
|
|
188
92
|
application.theme
|
|
189
93
|
end
|
|
190
94
|
|
|
95
|
+
# Switches the active theme to *name* and persists the choice in the application session.
|
|
191
96
|
def use_theme(name)
|
|
192
97
|
application.use_theme(name)
|
|
193
98
|
end
|
|
194
99
|
|
|
100
|
+
# Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
|
|
195
101
|
def open_theme_palette
|
|
196
102
|
session[:command_palette] = command_palette_state(:themes)
|
|
197
103
|
focus.push_scope([:command_palette], origin: :command_palette)
|
|
198
104
|
render_default_action
|
|
199
105
|
end
|
|
200
106
|
|
|
201
|
-
#
|
|
202
|
-
# Used for route transitions triggered from controllers (e.g., sidebar selection).
|
|
107
|
+
# Navigates to the given URL path.
|
|
203
108
|
def navigate_to(path)
|
|
204
109
|
@response = Response.navigate(path)
|
|
205
110
|
end
|
|
@@ -209,437 +114,8 @@ module Charming
|
|
|
209
114
|
@response = Response.quit
|
|
210
115
|
end
|
|
211
116
|
|
|
212
|
-
# Returns the parent application's session hash for per-request state storage (e.g., form data, flags).
|
|
213
|
-
def session
|
|
214
|
-
application.session
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
# Lazily instantiates a state class and caches it in the session under `:states`.
|
|
218
|
-
# Subsequent calls with the same name return the cached instance. Used like: state(:home, HomeState)
|
|
219
|
-
def state(name, state_class, **attributes)
|
|
220
|
-
session[:states] ||= {}
|
|
221
|
-
session[:states][name.to_sym] ||= state_class.new(**attributes)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def form(name, &block)
|
|
225
|
-
session[:forms] ||= {}
|
|
226
|
-
form_state = session[:forms][name.to_sym] ||= {}
|
|
227
|
-
builder = Presentation::Components::Form::Builder.new(theme: theme)
|
|
228
|
-
block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
|
|
229
|
-
builder.build(state: form_state, theme: theme)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# Submits an async task to the application's task executor (threaded or inline).
|
|
233
|
-
# The task runs in a background thread; results arrive as TaskEvents in `dispatch_task`.
|
|
234
|
-
def run_task(name, &block)
|
|
235
|
-
application.task_executor.submit(name, &block)
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
# Opens the command palette (fuzzy search UI): stores primitive palette state and pushes a palette
|
|
239
|
-
# scope onto the focus ring so input is captured inside it. Renders the default action afterward.
|
|
240
|
-
def open_command_palette
|
|
241
|
-
session[:command_palette] = command_palette_state(:commands)
|
|
242
|
-
focus.push_scope([:command_palette], origin: :command_palette)
|
|
243
|
-
render_default_action
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
# Closes the command palette: removes its state from the session, pops its scope from the focus ring,
|
|
247
|
-
# and re-renders the default action. Pops all nested scopes until only the palette remains.
|
|
248
|
-
def close_command_palette
|
|
249
|
-
session.delete(:command_palette)
|
|
250
|
-
pop_command_palette_scope
|
|
251
|
-
render_default_action
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# Returns or lazily initializes the Focus instance for this controller, which manages
|
|
255
|
-
# keyboard-driven focus traversal between components (sidebar, content, etc.).
|
|
256
|
-
# Defines focus ring slots from class-level declarations on first access.
|
|
257
|
-
def focus
|
|
258
|
-
@focus ||= Focus.for(session, self.class).tap do |f|
|
|
259
|
-
f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Checks whether the given focus slot (e.g., :sidebar, :content) is currently focused.
|
|
264
|
-
def focused?(slot)
|
|
265
|
-
focus.focused?(slot)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
# Returns whether the command palette is active in the current session.
|
|
269
|
-
def command_palette_open?
|
|
270
|
-
session.key?(:command_palette)
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
# Returns a command palette component rebuilt from the current primitive session state, if open.
|
|
274
|
-
def command_palette
|
|
275
|
-
build_command_palette_from_state(session[:command_palette]) if command_palette_open?
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
# Shifts focus to the sidebar: moves the focus ring cursor or sets `session[:focus]` to :sidebar,
|
|
279
|
-
# highlights the current route index, and re-renders. Sidebar selection uses j/k keys.
|
|
280
|
-
def focus_sidebar
|
|
281
|
-
if focus_ring_slot?(:sidebar)
|
|
282
|
-
focus.focus(:sidebar)
|
|
283
|
-
else
|
|
284
|
-
session[:focus] = :sidebar
|
|
285
|
-
end
|
|
286
|
-
session[:sidebar_index] ||= current_route_index
|
|
287
|
-
render_default_action
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# Shifts focus back to the main content area: moves the focus ring cursor or sets `session[:focus]` to :content,
|
|
291
|
-
# and re-renders. Used by Escape key from sidebar and other navigation transitions.
|
|
292
|
-
def focus_content
|
|
293
|
-
if focus_ring_slot?(:content)
|
|
294
|
-
focus.focus(:content)
|
|
295
|
-
else
|
|
296
|
-
session[:focus] = :content
|
|
297
|
-
end
|
|
298
|
-
render_default_action
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
# Returns whether the sidebar currently has focus (from focus ring or session state).
|
|
302
|
-
def sidebar_focused?
|
|
303
|
-
return focused?(:sidebar) if focus_ring_slot?(:sidebar)
|
|
304
|
-
|
|
305
|
-
session[:focus] == :sidebar
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# Returns whether the main content area currently has focus (from focus ring or session state).
|
|
309
|
-
def content_focused?
|
|
310
|
-
return focused?(:content) if focus_ring_slot?(:content)
|
|
311
|
-
|
|
312
|
-
session[:focus] == :content
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# Returns the currently highlighted sidebar item index, falling back to the current route's position
|
|
316
|
-
# when no explicit sidebar selection has been made yet.
|
|
317
|
-
def sidebar_index
|
|
318
|
-
session[:sidebar_index] || current_route_index
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def sidebar_routes
|
|
322
|
-
application.routes.all
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def current_route?(candidate)
|
|
326
|
-
return candidate.controller_class == self.class && candidate.action == :show unless route
|
|
327
|
-
|
|
328
|
-
candidate.path == route.path &&
|
|
329
|
-
candidate.controller_class == route.controller_class &&
|
|
330
|
-
candidate.action == route.action
|
|
331
|
-
end
|
|
332
|
-
|
|
333
117
|
private
|
|
334
118
|
|
|
335
|
-
# Finds the position of this controller among all registered routes (for sidebar highlighting).
|
|
336
|
-
# Returns 0 if no matching route is found.
|
|
337
|
-
def current_route_index
|
|
338
|
-
sidebar_routes.index { |candidate| current_route?(candidate) } || 0
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Checks whether the given slot is registered as a focus ring slot for this controller.
|
|
342
|
-
def focus_ring_slot?(slot)
|
|
343
|
-
self.class.focus_ring_slots.include?(slot)
|
|
344
|
-
end
|
|
345
|
-
|
|
346
119
|
attr_reader :response
|
|
347
|
-
|
|
348
|
-
# Renders `body` as a string: calls `#render` if body responds to it (component), otherwise `#to_s`.
|
|
349
|
-
def render_body(body)
|
|
350
|
-
body.respond_to?(:render) ? body.render.to_s : body.to_s
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# Wraps `body` rendering in this controller's layout (if one is defined).
|
|
354
|
-
# If no layout is set, returns body as-is. Provides content, screen, and controller to the layout for composition.
|
|
355
|
-
def render_with_layout(body)
|
|
356
|
-
rendered = render_body(body)
|
|
357
|
-
layout = self.class.layout
|
|
358
|
-
return rendered unless layout
|
|
359
|
-
|
|
360
|
-
render_body(layout_body(layout, body, rendered))
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
def layout_body(layout, body, rendered)
|
|
364
|
-
assigns = layout_assigns(body, rendered)
|
|
365
|
-
return template_body(layout, **assigns) if layout.is_a?(String) || layout.is_a?(Symbol)
|
|
366
|
-
|
|
367
|
-
layout.new(**assigns)
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
# Provides view assigns for layout rendering: merges body-specific assigns with standard `content`, `screen`, and `controller`.
|
|
371
|
-
def layout_assigns(body, rendered)
|
|
372
|
-
view_assigns(body).merge(content: rendered, screen: screen, controller: self, theme: theme)
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# Extracts layout assigns from the body if it responds to `#layout_assigns` (e.g., a component),
|
|
376
|
-
# otherwise returns an empty hash. Used by layout rendering for composition.
|
|
377
|
-
def view_assigns(body)
|
|
378
|
-
body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
def template_body(name, **assigns)
|
|
382
|
-
Presentation::TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
def resolve_template(name)
|
|
386
|
-
Presentation::Templates.resolve(name, root: application.class.root)
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
def template_assigns(assigns)
|
|
390
|
-
{screen: screen, controller: self, theme: theme}.merge(assigns)
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
def template_namespace
|
|
394
|
-
namespace_name = application.class.namespace
|
|
395
|
-
return nil if namespace_name.to_s.empty?
|
|
396
|
-
|
|
397
|
-
Object.const_get(namespace_name)
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
def default_template_name(action)
|
|
401
|
-
"#{controller_template_path}/#{action}"
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
def controller_template_path
|
|
405
|
-
underscore(self.class.name.split("::").last.delete_suffix("Controller"))
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
def underscore(value)
|
|
409
|
-
value
|
|
410
|
-
.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
|
|
411
|
-
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
412
|
-
.tr("-", "_")
|
|
413
|
-
.downcase
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
# Extracts the normalized key from the current event, handling both KeyEvent objects and raw key strings.
|
|
417
|
-
# Delegates to `Charming.key_of(event)` for event-to-key resolution.
|
|
418
|
-
def key_name
|
|
419
|
-
Charming.key_of(event)
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# Dispatches a key event to the currently focused component (e.g., text input, list) by calling `#handle_key` on it.
|
|
423
|
-
# Returns nil if no focused component or no handler consumed the key, otherwise :handled.
|
|
424
|
-
def dispatch_to_focused_component
|
|
425
|
-
slot = focus.current
|
|
426
|
-
return nil unless slot && respond_to?(slot, true)
|
|
427
|
-
|
|
428
|
-
component = send(slot)
|
|
429
|
-
return nil unless component.respond_to?(:handle_key)
|
|
430
|
-
|
|
431
|
-
result = component.handle_key(event)
|
|
432
|
-
return nil if result.nil?
|
|
433
|
-
|
|
434
|
-
dispatch_component_result(slot, result)
|
|
435
|
-
:handled
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
def dispatch_component_result(slot, result)
|
|
439
|
-
action, arguments = component_result_action(slot, result)
|
|
440
|
-
action ? send(action, *arguments) : render_default_action
|
|
441
|
-
render_default_action unless response
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
def component_result_action(slot, result)
|
|
445
|
-
case result
|
|
446
|
-
when :cancelled
|
|
447
|
-
component_action(slot, :cancelled)
|
|
448
|
-
when Array
|
|
449
|
-
component_array_action(slot, result)
|
|
450
|
-
end
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def component_array_action(slot, result)
|
|
454
|
-
event_name, value = result
|
|
455
|
-
return component_action(slot, :submitted, value) if event_name == :submitted
|
|
456
|
-
return component_action(slot, :selected, value) if event_name == :selected
|
|
457
|
-
|
|
458
|
-
nil
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
def component_action(slot, suffix, *arguments)
|
|
462
|
-
action = :"#{slot}_#{suffix}"
|
|
463
|
-
return unless respond_to?(action, true)
|
|
464
|
-
|
|
465
|
-
[action, arguments]
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
# Handles Tab/Shift-Tab traversal: moves focus forward or backward through the focus ring.
|
|
469
|
-
# Only processes events that are actually Tab keypresses on an empty focus ring. Returns :handled when consumed.
|
|
470
|
-
def dispatch_tab_traversal
|
|
471
|
-
return nil unless key_name == :tab
|
|
472
|
-
return nil if focus.ring.empty?
|
|
473
|
-
|
|
474
|
-
focus.cycle(event.shift ? -1 : +1)
|
|
475
|
-
render_default_action
|
|
476
|
-
:handled
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
# Dispatches key events to an open command palette (fuzzy search). Handles cancellation (Escape),
|
|
480
|
-
# command execution when a selection is made, and renders the default action if no response was produced.
|
|
481
|
-
def dispatch_command_palette_key
|
|
482
|
-
palette = command_palette
|
|
483
|
-
result = palette.handle_key(event)
|
|
484
|
-
|
|
485
|
-
if result == :cancelled
|
|
486
|
-
close_command_palette
|
|
487
|
-
elsif selected_command?(result)
|
|
488
|
-
perform_command(result.last)
|
|
489
|
-
else
|
|
490
|
-
save_command_palette_state(palette)
|
|
491
|
-
render_default_action unless response
|
|
492
|
-
end
|
|
493
|
-
|
|
494
|
-
response
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
# Mouse event handler for an open command palette. No-op — the command palette handles mouse internally.
|
|
498
|
-
def dispatch_command_palette_mouse
|
|
499
|
-
nil
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
# Dispatches keys within sidebar navigation: j/k or down/up move selection, Enter selects and navigates,
|
|
503
|
-
# Escape/Tab shifts focus to content. Other keys are ignored while sidebar owns focus.
|
|
504
|
-
def dispatch_sidebar_key
|
|
505
|
-
case key_name
|
|
506
|
-
when :j, :down then sidebar_move(+1)
|
|
507
|
-
when :k, :up then sidebar_move(-1)
|
|
508
|
-
when :enter then sidebar_select
|
|
509
|
-
when :escape, :tab then focus_content
|
|
510
|
-
else render_default_action
|
|
511
|
-
end
|
|
512
|
-
response
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
# Mouse event handler for the base controller. No-op — mouse events bubble through to focused components instead.
|
|
516
|
-
def dispatch_component_mouse
|
|
517
|
-
nil
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
# Moves sidebar selection up or down by `delta`. Does nothing if there are no routes.
|
|
521
|
-
def sidebar_move(delta)
|
|
522
|
-
count = sidebar_routes.length
|
|
523
|
-
return render_default_action if count.zero?
|
|
524
|
-
|
|
525
|
-
session[:sidebar_index] = (sidebar_index + delta).clamp(0, count - 1)
|
|
526
|
-
render_default_action
|
|
527
|
-
end
|
|
528
|
-
|
|
529
|
-
# Selects the currently highlighted sidebar route and navigates to it — shifting focus to content area.
|
|
530
|
-
# If no route is found at the current index, falls back to default action.
|
|
531
|
-
def sidebar_select
|
|
532
|
-
route = sidebar_routes[sidebar_index]
|
|
533
|
-
if focus_ring_slot?(:content)
|
|
534
|
-
focus.focus(:content)
|
|
535
|
-
else
|
|
536
|
-
session[:focus] = :content
|
|
537
|
-
end
|
|
538
|
-
route ? navigate_to(route.path) : render_default_action
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
def build_command_palette_from_state(state)
|
|
542
|
-
case state.fetch(:type)
|
|
543
|
-
when :commands
|
|
544
|
-
build_command_palette_with_state(self.class.command_bindings, state, height: 6)
|
|
545
|
-
when :themes
|
|
546
|
-
build_command_palette_with_state(theme_commands, state, placeholder: "Search themes", height: 10)
|
|
547
|
-
end
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
|
|
551
|
-
Presentation::Components::CommandPalette.new(
|
|
552
|
-
commands: commands,
|
|
553
|
-
placeholder: placeholder,
|
|
554
|
-
height: height,
|
|
555
|
-
value: state.fetch(:value),
|
|
556
|
-
cursor: state.fetch(:cursor),
|
|
557
|
-
selected_index: state.fetch(:selected_index),
|
|
558
|
-
theme: theme
|
|
559
|
-
)
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
def command_palette_state(type)
|
|
563
|
-
{type: type, value: "", cursor: 0, selected_index: 0}
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
def save_command_palette_state(palette)
|
|
567
|
-
session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
# Checks if a command palette result indicates a selected command (as opposed to cancel or no-op).
|
|
571
|
-
def selected_command?(result)
|
|
572
|
-
result.is_a?(Array) && result.first == :selected
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
# Executes a command value — either a callable block (inline command) or a method name (bound action).
|
|
576
|
-
# Pops the command palette scope and re-renders unless the result is navigation or quit.
|
|
577
|
-
def perform_command(command)
|
|
578
|
-
current_palette_state = session[:command_palette]
|
|
579
|
-
pop_command_palette_scope
|
|
580
|
-
perform_command_value(command.value)
|
|
581
|
-
if command.value != :quit && session[:command_palette].equal?(current_palette_state)
|
|
582
|
-
session.delete(:command_palette)
|
|
583
|
-
end
|
|
584
|
-
render_default_action unless response&.navigate? || response&.quit?
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
def theme_commands
|
|
588
|
-
application.class.themes.keys.map do |name|
|
|
589
|
-
Presentation::Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
|
|
590
|
-
end
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
def theme_label(name)
|
|
594
|
-
name.to_s.tr("_", "-").split("-").map(&:capitalize).join(" ")
|
|
595
|
-
end
|
|
596
|
-
|
|
597
|
-
# Pops the command palette scope from the focus ring until it is no longer the topmost scope.
|
|
598
|
-
# Called when a command executes or is cancelled.
|
|
599
|
-
def pop_command_palette_scope
|
|
600
|
-
focus.pop_scope while focus.ring == [:command_palette]
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
# Executes a command value — either calling a callable (inline block) or sending a method name to self.
|
|
604
|
-
# Used for both inline commands and bound action methods from the palette.
|
|
605
|
-
def perform_command_value(value)
|
|
606
|
-
value.respond_to?(:call) ? instance_exec(&value) : send(value)
|
|
607
|
-
end
|
|
608
|
-
|
|
609
|
-
# Renders the default action if this controller defines it. Called after navigation, command execution,
|
|
610
|
-
# or key handling when no explicit response was produced — ensures the view stays rendered.
|
|
611
|
-
def render_default_action
|
|
612
|
-
action = self.class.auto_render_action || :show
|
|
613
|
-
public_send(action) if respond_to?(action)
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
def auto_render_after?(action)
|
|
617
|
-
auto_render_action = self.class.auto_render_action
|
|
618
|
-
auto_render_action && action.to_sym != auto_render_action
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
def global_key_action
|
|
622
|
-
key_action_for_scope(:global)
|
|
623
|
-
end
|
|
624
|
-
|
|
625
|
-
def content_key_action
|
|
626
|
-
return nil unless content_key_scope_active?
|
|
627
|
-
|
|
628
|
-
key_action_for_scope(:content)
|
|
629
|
-
end
|
|
630
|
-
|
|
631
|
-
def content_key_scope_active?
|
|
632
|
-
return content_focused? if focus_ring_slot?(:content)
|
|
633
|
-
|
|
634
|
-
true
|
|
635
|
-
end
|
|
636
|
-
|
|
637
|
-
def key_action_for_scope(scope)
|
|
638
|
-
action = self.class.key_bindings[key_name]
|
|
639
|
-
return nil unless action
|
|
640
|
-
return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
|
|
641
|
-
|
|
642
|
-
action
|
|
643
|
-
end
|
|
644
120
|
end
|
|
645
121
|
end
|