charming 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +421 -0
- data/exe/charming +6 -0
- data/lib/charming/application.rb +90 -0
- data/lib/charming/application_model.rb +13 -0
- data/lib/charming/cli.rb +60 -0
- data/lib/charming/component.rb +8 -0
- data/lib/charming/components/activity_indicator.rb +158 -0
- data/lib/charming/components/command_palette.rb +118 -0
- data/lib/charming/components/keyboard_handler.rb +22 -0
- data/lib/charming/components/list.rb +105 -0
- data/lib/charming/components/modal.rb +48 -0
- data/lib/charming/components/progressbar.rb +55 -0
- data/lib/charming/components/spinner.rb +37 -0
- data/lib/charming/components/table.rb +115 -0
- data/lib/charming/components/text_input.rb +103 -0
- data/lib/charming/components/viewport.rb +191 -0
- data/lib/charming/controller.rb +523 -0
- data/lib/charming/focus.rb +65 -0
- data/lib/charming/generators/app_file_generator.rb +28 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
- data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
- data/lib/charming/generators/app_generator/component_templates.rb +36 -0
- data/lib/charming/generators/app_generator/controller_template.rb +69 -0
- data/lib/charming/generators/app_generator/layout_template.rb +160 -0
- data/lib/charming/generators/app_generator/model_templates.rb +30 -0
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
- data/lib/charming/generators/app_generator/view_template.rb +90 -0
- data/lib/charming/generators/app_generator.rb +76 -0
- data/lib/charming/generators/base.rb +29 -0
- data/lib/charming/generators/component_generator.rb +30 -0
- data/lib/charming/generators/controller_generator.rb +50 -0
- data/lib/charming/generators/name.rb +32 -0
- data/lib/charming/generators/screen_generator.rb +154 -0
- data/lib/charming/generators/view_generator.rb +34 -0
- data/lib/charming/generators.rb +7 -0
- data/lib/charming/internal/renderer/differential.rb +53 -0
- data/lib/charming/internal/renderer/full_repaint.rb +19 -0
- data/lib/charming/internal/terminal/adapter.rb +52 -0
- data/lib/charming/internal/terminal/memory_backend.rb +91 -0
- data/lib/charming/internal/terminal/tty_backend.rb +250 -0
- data/lib/charming/key_event.rb +13 -0
- data/lib/charming/mouse_event.rb +40 -0
- data/lib/charming/resize_event.rb +7 -0
- data/lib/charming/response.rb +33 -0
- data/lib/charming/router.rb +137 -0
- data/lib/charming/runtime.rb +192 -0
- data/lib/charming/screen.rb +8 -0
- data/lib/charming/task.rb +7 -0
- data/lib/charming/task_event.rb +17 -0
- data/lib/charming/task_executor.rb +62 -0
- data/lib/charming/timer_event.rb +7 -0
- data/lib/charming/ui/border.rb +33 -0
- data/lib/charming/ui/style.rb +244 -0
- data/lib/charming/ui/theme.rb +178 -0
- data/lib/charming/ui/themes/phosphor.json +100 -0
- data/lib/charming/ui/width.rb +24 -0
- data/lib/charming/ui.rb +230 -0
- data/lib/charming/version.rb +5 -0
- data/lib/charming/view.rb +116 -0
- data/lib/charming.rb +24 -0
- data/sig/charming.rbs +3 -0
- metadata +225 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Controller is the base class for all controller implementations in a Charming application.
|
|
5
|
+
# It provides the action dispatch pipeline, key/command/timer/task bindings, sidebar navigation,
|
|
6
|
+
# command palette management, and view rendering with layout composition.
|
|
7
|
+
class Controller
|
|
8
|
+
TimerBinding = Data.define(:name, :interval, :action)
|
|
9
|
+
TaskBinding = Data.define(:name, :action)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Registers a key binding (string or symbol key name → method symbol).
|
|
13
|
+
# Content-scoped bindings run from the main content pane; global bindings run from any pane.
|
|
14
|
+
def key(name, action, scope: :content)
|
|
15
|
+
normalized_scope = validate_key_scope(scope)
|
|
16
|
+
key_name = name.to_sym
|
|
17
|
+
key_bindings[key_name] = action
|
|
18
|
+
key_binding_scopes[key_name] = normalized_scope
|
|
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 << 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
|
+
# Sets the layout class to wrap this controller's rendered output (e.g., for sidebar + main content).
|
|
39
|
+
# Accepts a special `:__charming_layout_reader__` sentinel to query — without setting — the current layout.
|
|
40
|
+
def layout(layout_class = :__charming_layout_reader__)
|
|
41
|
+
return resolved_layout if layout_class == :__charming_layout_reader__
|
|
42
|
+
|
|
43
|
+
@layout = layout_class
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns inherited key bindings merged from the class hierarchy.
|
|
47
|
+
# Each subclass gets a fresh copy of its parent's key bindings to avoid cross-controller pollution.
|
|
48
|
+
def key_bindings
|
|
49
|
+
@key_bindings ||= superclass.respond_to?(:key_bindings) ? superclass.key_bindings.dup : {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns inherited key binding scopes merged from the class hierarchy.
|
|
53
|
+
# Each subclass gets a fresh copy of its parent's scopes to match key binding inheritance.
|
|
54
|
+
def key_binding_scopes
|
|
55
|
+
@key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Registers a focus ring slot for this controller — slots participate in Tab/Shift+Tab traversal.
|
|
59
|
+
# Example: `focus_ring :sidebar, :content` makes sidebar and content tabbable.
|
|
60
|
+
def focus_ring(*slots)
|
|
61
|
+
@focus_ring_slots = slots
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns inherited focus ring slots merged from the class hierarchy.
|
|
65
|
+
def focus_ring_slots
|
|
66
|
+
@focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns inherited command bindings (command palette entries) from this controller and its ancestors.
|
|
70
|
+
def command_bindings
|
|
71
|
+
@command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns inherited timer bindings from this controller and its ancestors.
|
|
75
|
+
def timer_bindings
|
|
76
|
+
@timer_bindings ||= superclass.respond_to?(:timer_bindings) ? superclass.timer_bindings.dup : {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns inherited task bindings (async task handlers) from this controller and its ancestors.
|
|
80
|
+
def task_bindings
|
|
81
|
+
@task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_key_scope(scope)
|
|
87
|
+
normalized_scope = scope.to_sym
|
|
88
|
+
return normalized_scope if %i[content global].include?(normalized_scope)
|
|
89
|
+
|
|
90
|
+
raise ArgumentError, "unknown key scope: #{scope.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the layout class for this controller, walking up the inheritance chain until one is found or nil.
|
|
94
|
+
# Used internally by `layout` when called without args (getter mode).
|
|
95
|
+
def resolved_layout
|
|
96
|
+
return @layout if instance_variable_defined?(:@layout)
|
|
97
|
+
return superclass.layout if superclass.respond_to?(:layout)
|
|
98
|
+
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
attr_reader :application, :event, :params, :screen
|
|
104
|
+
|
|
105
|
+
# Initializes the controller with its parent application and an optional event (key/mouse/timer/task data).
|
|
106
|
+
# Defaults to a 80x24 screen when no backend size is available.
|
|
107
|
+
def initialize(application:, event: nil, params: {}, screen: nil)
|
|
108
|
+
@application = application
|
|
109
|
+
@event = event
|
|
110
|
+
@params = params
|
|
111
|
+
@screen = screen || Screen.new(width: 80, height: 24)
|
|
112
|
+
@response = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Dispatches a named action on this controller (e.g., :show). Calls the method via public_send,
|
|
116
|
+
# returning a default empty render if the action produces no response.
|
|
117
|
+
def dispatch(action)
|
|
118
|
+
public_send(action)
|
|
119
|
+
response || render("")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Key event dispatch pipeline for controllers: checks command palette first (if open),
|
|
123
|
+
# then global key bindings, then sidebar (if focused), then content-scoped key bindings,
|
|
124
|
+
# then tab traversal, then focused component handling. Returns nil if no handler consumed the event.
|
|
125
|
+
def dispatch_key
|
|
126
|
+
return dispatch_command_palette_key if command_palette_open?
|
|
127
|
+
return dispatch(global_key_action) if global_key_action
|
|
128
|
+
return dispatch_sidebar_key if sidebar_focused?
|
|
129
|
+
|
|
130
|
+
return dispatch(content_key_action) if content_key_action
|
|
131
|
+
return response if dispatch_tab_traversal == :handled
|
|
132
|
+
return response if dispatch_to_focused_component == :handled
|
|
133
|
+
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Timer event dispatcher: looks up the event's named action in this controller's timer bindings
|
|
138
|
+
# and dispatches it. Returns nil if no binding exists for this timer name.
|
|
139
|
+
def dispatch_timer
|
|
140
|
+
binding = self.class.timer_bindings[event.name.to_sym]
|
|
141
|
+
binding ? dispatch(binding.action) : nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Task event dispatcher: looks up the event's named handler in this controller's task bindings
|
|
145
|
+
# and dispatches it. Used by async tasks submitted via `run_task`.
|
|
146
|
+
def dispatch_task
|
|
147
|
+
binding = self.class.task_bindings[event.name.to_sym]
|
|
148
|
+
binding ? dispatch(binding.action) : nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Mouse event dispatcher: checks command palette (if open), then sidebar (if focused),
|
|
152
|
+
# then falls through to component mouse dispatch. Always returns nil in the base controller —
|
|
153
|
+
# subclasses override as needed.
|
|
154
|
+
def dispatch_mouse
|
|
155
|
+
return dispatch_command_palette_mouse if command_palette_open?
|
|
156
|
+
return dispatch_sidebar_mouse if sidebar_focused?
|
|
157
|
+
|
|
158
|
+
dispatch_component_mouse
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Renders `body` wrapped in this controller's layout (if one is defined) and stores the response.
|
|
162
|
+
# If no layout is set, renders body bare. Called by controllers after rendering a view.
|
|
163
|
+
def render(body = "")
|
|
164
|
+
@response = Response.render(render_with_layout(body))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def theme
|
|
168
|
+
application.theme
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def use_theme(name)
|
|
172
|
+
application.use_theme(name)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def open_theme_palette
|
|
176
|
+
session[:command_palette] = command_palette_state(:themes)
|
|
177
|
+
focus.push_scope([:command_palette], origin: :command_palette)
|
|
178
|
+
render_default_action
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Responds with a navigation redirect to the given URL path.
|
|
182
|
+
# Used for route transitions triggered from controllers (e.g., sidebar selection).
|
|
183
|
+
def navigate_to(path)
|
|
184
|
+
@response = Response.navigate(path)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Exits the application — sets a quit response that terminates the event loop.
|
|
188
|
+
def quit
|
|
189
|
+
@response = Response.quit
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Returns the parent application's session hash for per-request state storage (e.g., form data, flags).
|
|
193
|
+
def session
|
|
194
|
+
application.session
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Lazily instantiates a model class and caches it in the session under `:models`.
|
|
198
|
+
# Subsequent calls with the same name return the cached instance. Used like: model(:user, UserModel)
|
|
199
|
+
def model(name, model_class, **attributes)
|
|
200
|
+
session[:models] ||= {}
|
|
201
|
+
session[:models][name.to_sym] ||= model_class.new(**attributes)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Submits an async task to the application's task executor (threaded or inline).
|
|
205
|
+
# The task runs in a background thread; results arrive as TaskEvents in `dispatch_task`.
|
|
206
|
+
def run_task(name, &block)
|
|
207
|
+
application.task_executor.submit(name, &block)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Opens the command palette (fuzzy search UI): stores primitive palette state and pushes a palette
|
|
211
|
+
# scope onto the focus ring so input is captured inside it. Renders the default action afterward.
|
|
212
|
+
def open_command_palette
|
|
213
|
+
session[:command_palette] = command_palette_state(:commands)
|
|
214
|
+
focus.push_scope([:command_palette], origin: :command_palette)
|
|
215
|
+
render_default_action
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Closes the command palette: removes its state from the session, pops its scope from the focus ring,
|
|
219
|
+
# and re-renders the default action. Pops all nested scopes until only the palette remains.
|
|
220
|
+
def close_command_palette
|
|
221
|
+
session.delete(:command_palette)
|
|
222
|
+
pop_command_palette_scope
|
|
223
|
+
render_default_action
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Returns or lazily initializes the Focus instance for this controller, which manages
|
|
227
|
+
# keyboard-driven focus traversal between components (sidebar, content, etc.).
|
|
228
|
+
# Defines focus ring slots from class-level declarations on first access.
|
|
229
|
+
def focus
|
|
230
|
+
@focus ||= Focus.for(session, self.class).tap do |f|
|
|
231
|
+
f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Checks whether the given focus slot (e.g., :sidebar, :content) is currently focused.
|
|
236
|
+
def focused?(slot)
|
|
237
|
+
focus.focused?(slot)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Returns whether the command palette is active in the current session.
|
|
241
|
+
def command_palette_open?
|
|
242
|
+
session.key?(:command_palette)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Returns a command palette component rebuilt from the current primitive session state, if open.
|
|
246
|
+
def command_palette
|
|
247
|
+
build_command_palette_from_state(session[:command_palette]) if command_palette_open?
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Shifts focus to the sidebar: moves the focus ring cursor or sets `session[:focus]` to :sidebar,
|
|
251
|
+
# highlights the current route index, and re-renders. Sidebar selection uses j/k keys.
|
|
252
|
+
def focus_sidebar
|
|
253
|
+
if focus_ring_slot?(:sidebar)
|
|
254
|
+
focus.focus(:sidebar)
|
|
255
|
+
else
|
|
256
|
+
session[:focus] = :sidebar
|
|
257
|
+
end
|
|
258
|
+
session[:sidebar_index] ||= current_route_index
|
|
259
|
+
render_default_action
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Shifts focus back to the main content area: moves the focus ring cursor or sets `session[:focus]` to :content,
|
|
263
|
+
# and re-renders. Used by Escape key from sidebar and other navigation transitions.
|
|
264
|
+
def focus_content
|
|
265
|
+
if focus_ring_slot?(:content)
|
|
266
|
+
focus.focus(:content)
|
|
267
|
+
else
|
|
268
|
+
session[:focus] = :content
|
|
269
|
+
end
|
|
270
|
+
render_default_action
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Returns whether the sidebar currently has focus (from focus ring or session state).
|
|
274
|
+
def sidebar_focused?
|
|
275
|
+
return focused?(:sidebar) if focus_ring_slot?(:sidebar)
|
|
276
|
+
|
|
277
|
+
session[:focus] == :sidebar
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Returns whether the main content area currently has focus (from focus ring or session state).
|
|
281
|
+
def content_focused?
|
|
282
|
+
return focused?(:content) if focus_ring_slot?(:content)
|
|
283
|
+
|
|
284
|
+
session[:focus] == :content
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Returns the currently highlighted sidebar item index, falling back to the current route's position
|
|
288
|
+
# when no explicit sidebar selection has been made yet.
|
|
289
|
+
def sidebar_index
|
|
290
|
+
session[:sidebar_index] || current_route_index
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
# Finds the position of this controller among all registered routes (for sidebar highlighting).
|
|
296
|
+
# Returns 0 if no matching route is found.
|
|
297
|
+
def current_route_index
|
|
298
|
+
application.routes.all.index { |route| route.controller_class == self.class && route.action == :show } || 0
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Checks whether the given slot is registered as a focus ring slot for this controller.
|
|
302
|
+
def focus_ring_slot?(slot)
|
|
303
|
+
self.class.focus_ring_slots.include?(slot)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
attr_reader :response
|
|
307
|
+
|
|
308
|
+
# Renders `body` as a string: calls `#render` if body responds to it (component), otherwise `#to_s`.
|
|
309
|
+
def render_body(body)
|
|
310
|
+
body.respond_to?(:render) ? body.render.to_s : body.to_s
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Wraps `body` rendering in this controller's layout class (if one is defined).
|
|
314
|
+
# If no layout is set, returns body as-is. Provides content, screen, and controller to the layout for composition.
|
|
315
|
+
def render_with_layout(body)
|
|
316
|
+
rendered = render_body(body)
|
|
317
|
+
layout_class = self.class.layout
|
|
318
|
+
return rendered unless layout_class
|
|
319
|
+
|
|
320
|
+
render_body(layout_class.new(**layout_assigns(body, rendered)))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Provides view assigns for layout rendering: merges body-specific assigns with standard `content`, `screen`, and `controller`.
|
|
324
|
+
def layout_assigns(body, rendered)
|
|
325
|
+
view_assigns(body).merge(content: rendered, screen: screen, controller: self, theme: theme)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Extracts layout assigns from the body if it responds to `#layout_assigns` (e.g., a component),
|
|
329
|
+
# otherwise returns an empty hash. Used by layout rendering for composition.
|
|
330
|
+
def view_assigns(body)
|
|
331
|
+
body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Extracts the normalized key from the current event, handling both KeyEvent objects and raw key strings.
|
|
335
|
+
# Delegates to `Charming.key_of(event)` for event-to-key resolution.
|
|
336
|
+
def key_name
|
|
337
|
+
Charming.key_of(event)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Dispatches a key event to the currently focused component (e.g., text input, list) by calling `#handle_key` on it.
|
|
341
|
+
# Returns nil if no focused component or no handler consumed the key, otherwise :handled.
|
|
342
|
+
def dispatch_to_focused_component
|
|
343
|
+
slot = focus.current
|
|
344
|
+
return nil unless slot && respond_to?(slot, true)
|
|
345
|
+
|
|
346
|
+
component = send(slot)
|
|
347
|
+
return nil unless component.respond_to?(:handle_key)
|
|
348
|
+
|
|
349
|
+
result = component.handle_key(event)
|
|
350
|
+
return nil if result.nil?
|
|
351
|
+
|
|
352
|
+
render_default_action
|
|
353
|
+
:handled
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Handles Tab/Shift-Tab traversal: moves focus forward or backward through the focus ring.
|
|
357
|
+
# Only processes events that are actually Tab keypresses on an empty focus ring. Returns :handled when consumed.
|
|
358
|
+
def dispatch_tab_traversal
|
|
359
|
+
return nil unless key_name == :tab
|
|
360
|
+
return nil if focus.ring.empty?
|
|
361
|
+
|
|
362
|
+
focus.cycle(event.shift ? -1 : +1)
|
|
363
|
+
render_default_action
|
|
364
|
+
:handled
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Dispatches key events to an open command palette (fuzzy search). Handles cancellation (Escape),
|
|
368
|
+
# command execution when a selection is made, and renders the default action if no response was produced.
|
|
369
|
+
def dispatch_command_palette_key
|
|
370
|
+
palette = command_palette
|
|
371
|
+
result = palette.handle_key(event)
|
|
372
|
+
|
|
373
|
+
if result == :cancelled
|
|
374
|
+
close_command_palette
|
|
375
|
+
elsif selected_command?(result)
|
|
376
|
+
perform_command(result.last)
|
|
377
|
+
else
|
|
378
|
+
save_command_palette_state(palette)
|
|
379
|
+
render_default_action unless response
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
response
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Mouse event handler for an open command palette. No-op — the command palette handles mouse internally.
|
|
386
|
+
def dispatch_command_palette_mouse
|
|
387
|
+
nil
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Dispatches keys within sidebar navigation: j/k or down/up move selection, Enter selects and navigates,
|
|
391
|
+
# Escape/Tab shifts focus to content. Other keys are ignored while sidebar owns focus.
|
|
392
|
+
def dispatch_sidebar_key
|
|
393
|
+
case key_name
|
|
394
|
+
when :j, :down then sidebar_move(+1)
|
|
395
|
+
when :k, :up then sidebar_move(-1)
|
|
396
|
+
when :enter then sidebar_select
|
|
397
|
+
when :escape, :tab then focus_content
|
|
398
|
+
else render_default_action
|
|
399
|
+
end
|
|
400
|
+
response
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Mouse event handler for the base controller. No-op — mouse events bubble through to focused components instead.
|
|
404
|
+
def dispatch_component_mouse
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Moves sidebar selection up or down by `delta`. Does nothing if there are no routes.
|
|
409
|
+
def sidebar_move(delta)
|
|
410
|
+
count = application.routes.all.length
|
|
411
|
+
return render_default_action if count.zero?
|
|
412
|
+
|
|
413
|
+
session[:sidebar_index] = (sidebar_index + delta).clamp(0, count - 1)
|
|
414
|
+
render_default_action
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Selects the currently highlighted sidebar route and navigates to it — shifting focus to content area.
|
|
418
|
+
# If no route is found at the current index, falls back to default action.
|
|
419
|
+
def sidebar_select
|
|
420
|
+
route = application.routes.all[sidebar_index]
|
|
421
|
+
session[:focus] = :content
|
|
422
|
+
route ? navigate_to(route.path) : render_default_action
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def build_command_palette_from_state(state)
|
|
426
|
+
case state.fetch(:type)
|
|
427
|
+
when :commands
|
|
428
|
+
build_command_palette_with_state(self.class.command_bindings, state, height: 6)
|
|
429
|
+
when :themes
|
|
430
|
+
build_command_palette_with_state(theme_commands, state, placeholder: "Search themes", height: 10)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
|
|
435
|
+
Components::CommandPalette.new(
|
|
436
|
+
commands: commands,
|
|
437
|
+
placeholder: placeholder,
|
|
438
|
+
height: height,
|
|
439
|
+
value: state.fetch(:value),
|
|
440
|
+
cursor: state.fetch(:cursor),
|
|
441
|
+
selected_index: state.fetch(:selected_index),
|
|
442
|
+
theme: theme
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def command_palette_state(type)
|
|
447
|
+
{type: type, value: "", cursor: 0, selected_index: 0}
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def save_command_palette_state(palette)
|
|
451
|
+
session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Checks if a command palette result indicates a selected command (as opposed to cancel or no-op).
|
|
455
|
+
def selected_command?(result)
|
|
456
|
+
result.is_a?(Array) && result.first == :selected
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Executes a command value — either a callable block (inline command) or a method name (bound action).
|
|
460
|
+
# Pops the command palette scope and re-renders unless the result is navigation or quit.
|
|
461
|
+
def perform_command(command)
|
|
462
|
+
current_palette_state = session[:command_palette]
|
|
463
|
+
pop_command_palette_scope
|
|
464
|
+
perform_command_value(command.value)
|
|
465
|
+
if command.value != :quit && session[:command_palette].equal?(current_palette_state)
|
|
466
|
+
session.delete(:command_palette)
|
|
467
|
+
end
|
|
468
|
+
render_default_action unless response&.navigate? || response&.quit?
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def theme_commands
|
|
472
|
+
application.class.themes.keys.map do |name|
|
|
473
|
+
Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def theme_label(name)
|
|
478
|
+
name.to_s.tr("_", "-").split("-").map(&:capitalize).join(" ")
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Pops the command palette scope from the focus ring until it is no longer the topmost scope.
|
|
482
|
+
# Called when a command executes or is cancelled.
|
|
483
|
+
def pop_command_palette_scope
|
|
484
|
+
focus.pop_scope while focus.ring == [:command_palette]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Executes a command value — either calling a callable (inline block) or sending a method name to self.
|
|
488
|
+
# Used for both inline commands and bound action methods from the palette.
|
|
489
|
+
def perform_command_value(value)
|
|
490
|
+
value.respond_to?(:call) ? instance_exec(&value) : send(value)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Renders the default `:show` action if this controller defines it. Called after navigation, command execution,
|
|
494
|
+
# or key handling when no explicit response was produced — ensures the view stays rendered.
|
|
495
|
+
def render_default_action
|
|
496
|
+
show if respond_to?(:show)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def global_key_action
|
|
500
|
+
key_action_for_scope(:global)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def content_key_action
|
|
504
|
+
return nil unless content_key_scope_active?
|
|
505
|
+
|
|
506
|
+
key_action_for_scope(:content)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def content_key_scope_active?
|
|
510
|
+
return content_focused? if focus_ring_slot?(:content)
|
|
511
|
+
|
|
512
|
+
true
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def key_action_for_scope(scope)
|
|
516
|
+
action = self.class.key_bindings[key_name]
|
|
517
|
+
return nil unless action
|
|
518
|
+
return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
|
|
519
|
+
|
|
520
|
+
action
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Focus
|
|
5
|
+
def self.for(session, controller_class)
|
|
6
|
+
session[:focus_state] ||= {}
|
|
7
|
+
key = controller_class.name
|
|
8
|
+
session[:focus_state][key] ||= {scopes: []}
|
|
9
|
+
new(session[:focus_state][key])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(state)
|
|
13
|
+
@state = state
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def define(slots)
|
|
17
|
+
return if @state[:scopes].any? { |scope| scope[:origin] == :ring }
|
|
18
|
+
|
|
19
|
+
@state[:scopes] << build_scope(slots, :ring)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def push_scope(slots, origin: :modal)
|
|
23
|
+
@state[:scopes] << build_scope(slots, origin)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pop_scope
|
|
27
|
+
@state[:scopes].pop
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def current
|
|
31
|
+
top && top[:current]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ring
|
|
35
|
+
top ? top[:ring] : []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def focus(slot)
|
|
39
|
+
return unless ring.include?(slot)
|
|
40
|
+
|
|
41
|
+
top[:current] = slot
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cycle(direction = +1)
|
|
45
|
+
return if ring.empty?
|
|
46
|
+
|
|
47
|
+
index = ring.index(current) || 0
|
|
48
|
+
top[:current] = ring[(index + direction) % ring.length]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def focused?(slot)
|
|
52
|
+
current == slot
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def top
|
|
58
|
+
@state[:scopes].last
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_scope(slots, origin)
|
|
62
|
+
{ring: slots.dup.freeze, current: slots.first, origin: origin}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Generators
|
|
5
|
+
class AppFileGenerator < Base
|
|
6
|
+
def initialize(name, _args, out:, destination:, force: false)
|
|
7
|
+
super(out: out, destination: destination, force: force)
|
|
8
|
+
@name = Name.new(name)
|
|
9
|
+
@app_name = Name.new(app_name_from_gemspec)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
attr_reader :name, :app_name
|
|
15
|
+
|
|
16
|
+
def app_path(*parts)
|
|
17
|
+
File.join(*parts, "#{name.snake_name}_#{suffix}.rb")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def app_name_from_gemspec
|
|
21
|
+
gemspec = Dir.glob(File.join(destination, "*.gemspec")).first
|
|
22
|
+
raise Error, "Run this generator from a Charming app root" unless gemspec
|
|
23
|
+
|
|
24
|
+
File.basename(gemspec, ".gemspec")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Generators
|
|
5
|
+
class AppGenerator
|
|
6
|
+
module AppSpecTemplates
|
|
7
|
+
def spec_model
|
|
8
|
+
%(# frozen_string_literal: true
|
|
9
|
+
|
|
10
|
+
require "#{app_name.snake_name}"
|
|
11
|
+
|
|
12
|
+
RSpec.describe #{app_name.class_name}::HomeModel do
|
|
13
|
+
describe "#title" do
|
|
14
|
+
it "has the correct default string value" do
|
|
15
|
+
instance = described_class.new
|
|
16
|
+
expect(instance.title).to eq("#{app_name.class_name}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "accepts overridden title values" do
|
|
20
|
+
instance = described_class.new(title: "Alternative")
|
|
21
|
+
expect(instance.title).to eq("Alternative")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def spec_controller
|
|
29
|
+
%(# frozen_string_literal: true
|
|
30
|
+
|
|
31
|
+
require "#{app_name.snake_name}"
|
|
32
|
+
|
|
33
|
+
RSpec.describe #{app_name.class_name}::HomeController do
|
|
34
|
+
let(:application) { #{app_name.class_name}::Application.new }
|
|
35
|
+
|
|
36
|
+
subject(:controller) { described_class.new(application: application) }
|
|
37
|
+
|
|
38
|
+
describe "#show" do
|
|
39
|
+
it "renders the view with the model" do
|
|
40
|
+
response = controller.dispatch(:show)
|
|
41
|
+
|
|
42
|
+
expect(response).to respond_to(:body)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def spec_view
|
|
50
|
+
%(# frozen_string_literal: true
|
|
51
|
+
|
|
52
|
+
require "#{app_name.snake_name}"
|
|
53
|
+
|
|
54
|
+
RSpec.describe #{app_name.class_name}::HomeView do
|
|
55
|
+
describe "#render" do
|
|
56
|
+
it "renders the model title" do
|
|
57
|
+
view = described_class.new(
|
|
58
|
+
home: double(title: "#{app_name.class_name}")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(view.render).to include("#{app_name.class_name}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def spec_component
|
|
69
|
+
%(# frozen_string_literal: true
|
|
70
|
+
|
|
71
|
+
require "#{app_name.snake_name}"
|
|
72
|
+
|
|
73
|
+
RSpec.describe #{app_name.class_name}::AppFrameComponent do
|
|
74
|
+
describe "#render" do
|
|
75
|
+
it "returns a string" do
|
|
76
|
+
component = described_class.new(title: "#{app_name.class_name}")
|
|
77
|
+
expect(component.render).to be_a(String)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|