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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +421 -0
  4. data/exe/charming +6 -0
  5. data/lib/charming/application.rb +90 -0
  6. data/lib/charming/application_model.rb +13 -0
  7. data/lib/charming/cli.rb +60 -0
  8. data/lib/charming/component.rb +8 -0
  9. data/lib/charming/components/activity_indicator.rb +158 -0
  10. data/lib/charming/components/command_palette.rb +118 -0
  11. data/lib/charming/components/keyboard_handler.rb +22 -0
  12. data/lib/charming/components/list.rb +105 -0
  13. data/lib/charming/components/modal.rb +48 -0
  14. data/lib/charming/components/progressbar.rb +55 -0
  15. data/lib/charming/components/spinner.rb +37 -0
  16. data/lib/charming/components/table.rb +115 -0
  17. data/lib/charming/components/text_input.rb +103 -0
  18. data/lib/charming/components/viewport.rb +191 -0
  19. data/lib/charming/controller.rb +523 -0
  20. data/lib/charming/focus.rb +65 -0
  21. data/lib/charming/generators/app_file_generator.rb +28 -0
  22. data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
  23. data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
  24. data/lib/charming/generators/app_generator/component_templates.rb +36 -0
  25. data/lib/charming/generators/app_generator/controller_template.rb +69 -0
  26. data/lib/charming/generators/app_generator/layout_template.rb +160 -0
  27. data/lib/charming/generators/app_generator/model_templates.rb +30 -0
  28. data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
  29. data/lib/charming/generators/app_generator/view_template.rb +90 -0
  30. data/lib/charming/generators/app_generator.rb +76 -0
  31. data/lib/charming/generators/base.rb +29 -0
  32. data/lib/charming/generators/component_generator.rb +30 -0
  33. data/lib/charming/generators/controller_generator.rb +50 -0
  34. data/lib/charming/generators/name.rb +32 -0
  35. data/lib/charming/generators/screen_generator.rb +154 -0
  36. data/lib/charming/generators/view_generator.rb +34 -0
  37. data/lib/charming/generators.rb +7 -0
  38. data/lib/charming/internal/renderer/differential.rb +53 -0
  39. data/lib/charming/internal/renderer/full_repaint.rb +19 -0
  40. data/lib/charming/internal/terminal/adapter.rb +52 -0
  41. data/lib/charming/internal/terminal/memory_backend.rb +91 -0
  42. data/lib/charming/internal/terminal/tty_backend.rb +250 -0
  43. data/lib/charming/key_event.rb +13 -0
  44. data/lib/charming/mouse_event.rb +40 -0
  45. data/lib/charming/resize_event.rb +7 -0
  46. data/lib/charming/response.rb +33 -0
  47. data/lib/charming/router.rb +137 -0
  48. data/lib/charming/runtime.rb +192 -0
  49. data/lib/charming/screen.rb +8 -0
  50. data/lib/charming/task.rb +7 -0
  51. data/lib/charming/task_event.rb +17 -0
  52. data/lib/charming/task_executor.rb +62 -0
  53. data/lib/charming/timer_event.rb +7 -0
  54. data/lib/charming/ui/border.rb +33 -0
  55. data/lib/charming/ui/style.rb +244 -0
  56. data/lib/charming/ui/theme.rb +178 -0
  57. data/lib/charming/ui/themes/phosphor.json +100 -0
  58. data/lib/charming/ui/width.rb +24 -0
  59. data/lib/charming/ui.rb +230 -0
  60. data/lib/charming/version.rb +5 -0
  61. data/lib/charming/view.rb +116 -0
  62. data/lib/charming.rb +24 -0
  63. data/sig/charming.rbs +3 -0
  64. 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