charming 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/lib/charming/application.rb +98 -14
  4. data/lib/charming/application_state.rb +23 -0
  5. data/lib/charming/cli.rb +2 -2
  6. data/lib/charming/controller/action_hooks.rb +5 -1
  7. data/lib/charming/controller/class_methods.rb +79 -17
  8. data/lib/charming/controller/component_dispatch.rb +163 -0
  9. data/lib/charming/controller/dispatching.rb +1 -2
  10. data/lib/charming/controller/focus_management.rb +67 -1
  11. data/lib/charming/controller/key_dispatch.rb +7 -7
  12. data/lib/charming/controller/rendering.rb +22 -6
  13. data/lib/charming/controller/session_state.rb +63 -22
  14. data/lib/charming/controller/timers.rb +25 -0
  15. data/lib/charming/controller.rb +253 -60
  16. data/lib/charming/cross_thread_access.rb +9 -0
  17. data/lib/charming/double_render_error.rb +8 -0
  18. data/lib/charming/generators/layout_generator.rb +4 -1
  19. data/lib/charming/generators/migration_generator.rb +1 -1
  20. data/lib/charming/generators/model_generator.rb +2 -2
  21. data/lib/charming/generators/name.rb +1 -1
  22. data/lib/charming/generators/screen_generator.rb +2 -2
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/deep_freeze.rb +23 -0
  25. data/lib/charming/internal/env_inquirer.rb +22 -0
  26. data/lib/charming/internal/event_loop.rb +25 -2
  27. data/lib/charming/internal/inflections.rb +93 -0
  28. data/lib/charming/internal/session_guard.rb +27 -0
  29. data/lib/charming/internal/terminal/cursor.rb +29 -0
  30. data/lib/charming/internal/terminal/size.rb +47 -0
  31. data/lib/charming/internal/terminal/tty_backend.rb +10 -8
  32. data/lib/charming/internal/timer_control.rb +48 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +14 -6
  34. data/lib/charming/presentation/components/command_palette.rb +11 -9
  35. data/lib/charming/presentation/components/filepicker.rb +4 -4
  36. data/lib/charming/presentation/components/form/confirm.rb +2 -1
  37. data/lib/charming/presentation/components/form/field.rb +1 -1
  38. data/lib/charming/presentation/components/form/input.rb +3 -3
  39. data/lib/charming/presentation/components/form/multiselect.rb +4 -5
  40. data/lib/charming/presentation/components/form/select.rb +3 -3
  41. data/lib/charming/presentation/components/form/textarea.rb +3 -3
  42. data/lib/charming/presentation/components/form.rb +6 -6
  43. data/lib/charming/presentation/components/help_overlay.rb +2 -2
  44. data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
  45. data/lib/charming/presentation/components/list.rb +14 -5
  46. data/lib/charming/presentation/components/modal.rb +3 -2
  47. data/lib/charming/presentation/components/multi_select_list.rb +14 -7
  48. data/lib/charming/presentation/components/result.rb +61 -0
  49. data/lib/charming/presentation/components/tab_bar.rb +14 -6
  50. data/lib/charming/presentation/components/table.rb +64 -32
  51. data/lib/charming/presentation/components/text_area.rb +7 -7
  52. data/lib/charming/presentation/components/text_input.rb +7 -7
  53. data/lib/charming/presentation/components/tree.rb +15 -6
  54. data/lib/charming/presentation/components/viewport.rb +3 -3
  55. data/lib/charming/presentation/layout/pane.rb +6 -2
  56. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  57. data/lib/charming/presentation/view.rb +35 -29
  58. data/lib/charming/projectile.rb +64 -0
  59. data/lib/charming/render_artifacts.rb +24 -0
  60. data/lib/charming/response.rb +19 -8
  61. data/lib/charming/router.rb +50 -68
  62. data/lib/charming/runtime.rb +54 -28
  63. data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
  64. data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
  65. data/lib/charming/spring.rb +126 -0
  66. data/lib/charming/tasks/context.rb +35 -0
  67. data/lib/charming/test_helper.rb +42 -22
  68. data/lib/charming/unhandled_component_event.rb +9 -0
  69. data/lib/charming/unknown_slot.rb +9 -0
  70. data/lib/charming/version.rb +1 -1
  71. data/lib/charming/welcome.rb +1 -1
  72. data/lib/charming.rb +20 -6
  73. metadata +25 -70
  74. data/lib/charming/controller/component_dispatching.rb +0 -125
@@ -16,10 +16,15 @@ module Charming
16
16
  @task_queue = Thread::Queue.new
17
17
  @task_executor = build_task_executor(task_executor)
18
18
  @application.task_executor = @task_executor
19
- @route = resolve_route("/")
19
+ @route = resolve_route(:root)
20
20
  @screen = backend_screen
21
21
  @coalesce_input = @application.respond_to?(:coalesce_input?) && @application.coalesce_input?
22
22
  @event_loop = build_event_loop
23
+ @application.timer_control = Internal::TimerControl.new(
24
+ event_loop: @event_loop,
25
+ bindings: -> { @route.controller_class.timer_bindings }
26
+ )
27
+ enter_controller
23
28
  end
24
29
 
25
30
  # Runs the event loop: enters alt-screen, dispatches incoming events
@@ -30,10 +35,12 @@ module Charming
30
35
  setup_terminal
31
36
  install_signal_handlers
32
37
  install_exit_hook
38
+ @controller.capture_loop_thread!
33
39
  with_raw_input do
34
40
  render(initial_response)
35
41
  @event_loop.run { |event, more_ready| process(event, flush: !more_ready) }
36
42
  ensure
43
+ exit_controller
37
44
  restore_signal_handlers
38
45
  @task_executor&.shutdown(timeout: 2.0)
39
46
  @application.save_session if @application.respond_to?(:save_session)
@@ -52,12 +59,18 @@ module Charming
52
59
  backend: @backend,
53
60
  clock: @clock,
54
61
  task_queue: @task_queue,
55
- timer_bindings: @route.controller_class.timer_bindings.values,
62
+ timer_bindings: autostart_timer_bindings,
56
63
  coalesce_input: @coalesce_input,
57
64
  interrupted: -> { @interrupted }
58
65
  )
59
66
  end
60
67
 
68
+ # The current route's timers that run from boot. Timers declared with
69
+ # `autostart: false` (including `animate` ticks) wait for `start_timer`.
70
+ def autostart_timer_bindings
71
+ @route.controller_class.timer_bindings.values.select(&:autostart)
72
+ end
73
+
61
74
  # The first frame's response — the root route's action, with errors caught. Out-of-band escape
62
75
  # sequences registered while rendering are collected and attached to the response.
63
76
  def initial_response
@@ -203,38 +216,48 @@ module Charming
203
216
  # Dispatches an action on the current route's controller with an optional event.
204
217
  # Entry point from the event loop into controllers.
205
218
  def dispatch(action, event: nil)
206
- controller(event: event).dispatch(action)
219
+ @controller.dispatch(action, event: event)
207
220
  end
208
221
 
209
222
  # Dispatches a key press to the current route's controller.
210
223
  def dispatch_key(event)
211
- controller(event: event).dispatch_key
224
+ @controller.dispatch_key(event)
212
225
  end
213
226
 
214
227
  # Dispatches a timer tick to the current route's controller.
215
228
  def dispatch_timer(event)
216
- controller(event: event).dispatch_timer
229
+ @controller.dispatch_timer(event)
217
230
  end
218
231
 
219
232
  # Dispatches an async task result to the current route's controller.
220
233
  def dispatch_task(event)
221
- controller(event: event).dispatch_task
234
+ @controller.dispatch_task(event)
222
235
  end
223
236
 
224
237
  # Dispatches a task progress report to the current route's controller.
225
238
  def dispatch_task_progress(event)
226
- controller(event: event).dispatch_task_progress
239
+ @controller.dispatch_task_progress(event)
227
240
  end
228
241
 
229
242
  # Dispatches a mouse action (click, drag, scroll) to the current route's controller.
230
243
  def dispatch_mouse(event)
231
- controller(event: event).dispatch_mouse
244
+ @controller.dispatch_mouse(event)
245
+ end
246
+
247
+ # Constructs the controller for the current route and runs its screen_entered hook.
248
+ # The instance lives until the next navigation (or quit), so controller ivars hold
249
+ # screen-lifetime state.
250
+ def enter_controller
251
+ @controller = @route.controller_class.new(
252
+ application: @application, params: @route.params, screen: screen, route: @route
253
+ )
254
+ @controller.screen_entered
232
255
  end
233
256
 
234
- # Instantiates a fresh controller for the active route, passing the application, current *event*,
235
- # route params, screen dimensions, and route object. Called by every dispatch path.
236
- def controller(event: nil)
237
- @route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen, route: @route)
257
+ # Runs the current controller's screen_exited hook and releases it.
258
+ def exit_controller
259
+ @controller&.screen_exited
260
+ @controller = nil
238
261
  end
239
262
 
240
263
  # Type-based dispatcher: routes resize, task, progress, timer, mouse, paste, and key
@@ -254,47 +277,50 @@ module Charming
254
277
  # Dispatches a terminal focus change to the controller's optional `focus_changed`
255
278
  # action. Ignored when the controller doesn't define one.
256
279
  def dispatch_focus_change(event)
257
- ctrl = controller(event: event)
258
- return nil unless ctrl.respond_to?(:focus_changed)
280
+ return nil unless @controller.respond_to?(:focus_changed)
259
281
 
260
- ctrl.dispatch(:focus_changed)
282
+ @controller.dispatch(:focus_changed, event: event)
261
283
  end
262
284
 
263
285
  # Dispatches pasted text to the current route's controller.
264
286
  def dispatch_paste(event)
265
- controller(event: event).dispatch_paste
287
+ @controller.dispatch_paste(event)
266
288
  end
267
289
 
268
- # Dispatches a resize event: updates screen dimensions and re-renders the current action.
290
+ # Dispatches a resize event: updates screen dimensions on the live controller and
291
+ # re-renders the current action.
269
292
  # The renderer's cached previous frame is invalidated and the backend is cleared so the
270
293
  # new-dimension frame paints onto a clean alt-screen instead of overlaying stale rows.
271
294
  def dispatch_resize(event)
272
295
  @screen = Screen.new(width: event.width, height: event.height)
273
296
  @renderer.invalidate if @renderer.respond_to?(:invalidate)
274
297
  @backend.clear if @backend.respond_to?(:clear)
298
+ @controller.update_screen(@screen)
275
299
  dispatch(@route.action, event: event)
276
300
  end
277
301
 
278
- # Follows navigation responses: resolves the new route from the router,
279
- # reschedules the event loop's timers for the new controller, and
302
+ # Follows navigation responses: discards the current controller, resolves and enters
303
+ # the new route, reschedules the event loop's timers for the new controller, and
280
304
  # dispatches that route's action.
281
305
  def resolve_response(response)
282
306
  return response unless response.navigate?
283
307
 
284
- @route = resolve_route(response.path)
285
- @event_loop.reset_timers(@route.controller_class.timer_bindings.values)
308
+ exit_controller
309
+ @route = resolve_route(response.name, response.params)
310
+ @event_loop.reset_timers(autostart_timer_bindings)
311
+ enter_controller
286
312
  dispatch(@route.action)
287
313
  end
288
314
 
289
- # Resolves *path* from the app's router. An unrouted "/" falls back to the app's
290
- # first route, or to the built-in welcome screen when no routes are defined yet
291
- # (like Rails' welcome page); other unrouted paths still raise.
292
- def resolve_route(path)
293
- @application.routes.resolve(path)
315
+ # Resolves *name* from the app's router, attaching *params*. An unrouted :root falls
316
+ # back to the app's first route, or to the built-in welcome screen when no screens
317
+ # are defined yet (like Rails' welcome page); other unrouted names still raise.
318
+ def resolve_route(name, params = {})
319
+ @application.routes.resolve(name, params)
294
320
  rescue KeyError
295
- raise unless path == "/"
321
+ raise unless name.to_sym == :root
296
322
 
297
- @application.routes.all.first || Welcome.route
323
+ (@application.routes.all.first || Welcome.route).with_params(params)
298
324
  end
299
325
 
300
326
  # Derives Screen dimensions (width, height) from the terminal backend.
@@ -1,12 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- class Controller
5
- # Command palette helpers mixed into Controller. Opens/closes the palette, builds the
6
- # palette from registered command bindings or theme list, and routes key/mouse events
7
- # through it. Supports both the standard command palette (:commands) and the theme picker
8
- # (:themes) via a discriminated `session[:command_palette]` state hash.
9
- module CommandPalette
4
+ # Shell::Palette is the opt-in app-shell command palette: the `command` class DSL,
5
+ # open/close helpers, the theme picker, and key/mouse routing while the palette is
6
+ # open. Generated apps include it in ApplicationController when generated with the
7
+ # sidebar layout (`include Charming::Shell::Palette`).
8
+ #
9
+ # Palette state lives in the session, not on the controller: the palette is app-global —
10
+ # a command can navigate to another screen, and the open/closed state must survive the
11
+ # controller swap that navigation causes.
12
+ module Shell
13
+ module Palette
14
+ # Wires the class-level `command` DSL into the including controller.
15
+ def self.included(base)
16
+ base.extend(ClassMethods)
17
+ end
18
+
19
+ # Class-level palette DSL: `command` entries and their inherited registry.
20
+ module ClassMethods
21
+ # Adds a CommandPalette entry with the given *label*. *action* is a method name to send on
22
+ # the controller, or a block to instance_exec when selected.
23
+ def command(label, action = nil, &block)
24
+ command_bindings << Components::CommandPalette::Command.new(label: label, value: block || action)
25
+ end
26
+
27
+ # Array of registered command palette entries, inherited from superclass when undefined.
28
+ def command_bindings
29
+ @command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
30
+ end
31
+ end
32
+
10
33
  # Opens the command palette populated with the controller's `command_bindings`. Pushes
11
34
  # a focus scope so subsequent keys are routed to the palette.
12
35
  def open_command_palette
@@ -32,6 +55,13 @@ module Charming
32
55
  build_command_palette_from_state(session[:command_palette]) if command_palette_open?
33
56
  end
34
57
 
58
+ # Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
59
+ def open_theme_palette
60
+ session[:command_palette] = command_palette_state(:themes)
61
+ focus.push_scope([:command_palette], origin: :command_palette)
62
+ render_default_action
63
+ end
64
+
35
65
  private
36
66
 
37
67
  # Routes the current key event to the open palette. Cancels on Escape, performs the
@@ -40,10 +70,10 @@ module Charming
40
70
  palette = command_palette
41
71
  result = palette.handle_key(event)
42
72
 
43
- if result == :cancelled
73
+ if result&.cancelled?
44
74
  close_command_palette
45
75
  elsif selected_command?(result)
46
- perform_command(result.last)
76
+ perform_command(result.value)
47
77
  else
48
78
  save_command_palette_state(palette)
49
79
  render_default_action unless response
@@ -92,13 +122,15 @@ module Charming
92
122
  session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
93
123
  end
94
124
 
95
- # True when a component result is the `[:selected, command]` array shape.
125
+ # True when a component result carries a selected command (Result.selected).
96
126
  def selected_command?(result)
97
- result.is_a?(Array) && result.first == :selected
127
+ result.respond_to?(:selected?) && result.selected?
98
128
  end
99
129
 
100
130
  # Invokes the value (proc, lambda, or method symbol) of the selected *command*, then
101
131
  # closes the palette unless the command was :quit or the user has re-opened it.
132
+ # A command that set a response keeps it; only a command that produced no response
133
+ # falls back to the default render.
102
134
  def perform_command(command)
103
135
  current_palette_state = session[:command_palette]
104
136
  pop_command_palette_scope
@@ -106,7 +138,7 @@ module Charming
106
138
  if command.value != :quit && session[:command_palette].equal?(current_palette_state)
107
139
  session.delete(:command_palette)
108
140
  end
109
- render_default_action unless response&.navigate? || response&.quit?
141
+ render_default_action unless response
110
142
  end
111
143
 
112
144
  # Returns the theme-switching commands used by the theme picker palette.
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- class Controller
5
- # Sidebar-navigation helpers mixed into Controller. Tracks the sidebar's current route index,
6
- # routes j/k/enter/tab keys when the sidebar is focused, and exposes `sidebar_focused?` for views.
7
- #
8
- # Sidebar/content focus is driven entirely by the controller's Focus object. Controllers
9
- # that want Tab-driven sidebar navigation declare `focus_ring :sidebar, :content` (generated
10
- # apps do); without those slots in the ring, `focus_sidebar`/`focus_content` are no-ops.
11
- module SidebarNavigation
4
+ # Shell::Sidebar is the opt-in app-shell sidebar: route listing, sidebar/content focus
5
+ # split, j/k/enter navigation, and mouse clicks on route rows. Generated apps include it
6
+ # in ApplicationController when generated with the sidebar layout
7
+ # (`include Charming::Shell::Sidebar`). Controllers that want Tab-driven sidebar
8
+ # navigation declare `focus_ring :sidebar, :content`.
9
+ module Shell
10
+ module Sidebar
12
11
  # Moves focus to the sidebar slot and remembers the highlighted route.
13
12
  def focus_sidebar
14
13
  focus.focus(:sidebar)
@@ -37,7 +36,8 @@ module Charming
37
36
  end
38
37
 
39
38
  # Returns the index of the currently selected route in `sidebar_routes`, defaulting to the
40
- # active route when the session index is unset.
39
+ # active route when the session index is unset. Stored in the session: the sidebar cursor
40
+ # is app-global — it should keep its position as the user moves between screens.
41
41
  def sidebar_index
42
42
  session[:sidebar_index] || current_route_index
43
43
  end
@@ -52,7 +52,7 @@ module Charming
52
52
  def current_route?(candidate)
53
53
  return candidate.controller_class == self.class && candidate.action == :show unless route
54
54
 
55
- candidate.path == route.path &&
55
+ candidate.name == route.name &&
56
56
  candidate.controller_class == route.controller_class &&
57
57
  candidate.action == route.action
58
58
  end
@@ -139,7 +139,7 @@ module Charming
139
139
  route = sidebar_routes[sidebar_index]
140
140
  slot = content_slot
141
141
  focus.focus(slot) if slot
142
- route ? navigate_to(route.path) : render_default_action
142
+ route ? navigate(route.name) : render_default_action
143
143
  end
144
144
  end
145
145
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ******************************************************************************
4
+ #
5
+ # Copyright (c) 2008-2012 Ryan Juckett
6
+ # http://www.ryanjuckett.com/
7
+ #
8
+ # This software is provided 'as-is', without any express or implied
9
+ # warranty. In no event will the authors be held liable for any damages
10
+ # arising from the use of this software.
11
+ #
12
+ # Permission is granted to anyone to use this software for any purpose,
13
+ # including commercial applications, and to alter it and redistribute it
14
+ # freely, subject to the following restrictions:
15
+ #
16
+ # 1. The origin of this software must not be misrepresented; you must not
17
+ # claim that you wrote the original software. If you use this software
18
+ # in a product, an acknowledgment in the product documentation would be
19
+ # appreciated but is not required.
20
+ #
21
+ # 2. Altered source versions must be plainly marked as such, and must not be
22
+ # misrepresented as being the original software.
23
+ #
24
+ # 3. This notice may not be removed or altered from any source
25
+ # distribution.
26
+ #
27
+ # ******************************************************************************
28
+ #
29
+ # Ported to Go by Charmbracelet, Inc. in 2021 (charmbracelet/harmonica, MIT).
30
+ # Ported to Ruby for Charming in 2026 from charmbracelet/harmonica.
31
+ #
32
+ # For background on the algorithm see:
33
+ # https://www.ryanjuckett.com/damped-springs/
34
+
35
+ module Charming
36
+ # Spring is a damped harmonic oscillator for physics-based animation. It
37
+ # precomputes motion coefficients for a fixed time step so each frame update
38
+ # is four multiply-adds. Instances are immutable; the caller owns position
39
+ # and velocity (typically as Float attributes on an ApplicationState) and
40
+ # feeds them back in every frame:
41
+ #
42
+ # SPRING = Charming::Spring.new(delta_time: Charming.fps(60))
43
+ # position, velocity = SPRING.update(position, velocity, target)
44
+ #
45
+ # The damping ratio shapes the motion: below 1 overshoots and oscillates,
46
+ # exactly 1 reaches the target as fast as possible without overshooting,
47
+ # above 1 approaches slower still. Angular frequency scales the speed.
48
+ class Spring
49
+ def initialize(delta_time:, angular_frequency: 6.0, damping_ratio: 0.5)
50
+ angular_frequency = [0.0, angular_frequency].max
51
+ damping_ratio = [0.0, damping_ratio].max
52
+ @pos_pos, @pos_vel, @vel_pos, @vel_vel =
53
+ coefficients(delta_time, angular_frequency, damping_ratio)
54
+ freeze
55
+ end
56
+
57
+ # Advances one frame toward *target*, returning `[new_position, new_velocity]`.
58
+ def update(position, velocity, target)
59
+ relative = position - target
60
+ [relative * @pos_pos + velocity * @pos_vel + target,
61
+ relative * @vel_pos + velocity * @vel_vel]
62
+ end
63
+
64
+ # True once the motion has effectively come to rest at *target* — the guard
65
+ # for stopping an animation timer.
66
+ def settled?(position, velocity, target, epsilon: 0.01)
67
+ (position - target).abs < epsilon && velocity.abs < epsilon
68
+ end
69
+
70
+ private
71
+
72
+ def coefficients(delta_time, frequency, ratio)
73
+ return [1.0, 0.0, 0.0, 1.0] if frequency < Float::EPSILON
74
+
75
+ if ratio > 1.0 + Float::EPSILON
76
+ over_damped(delta_time, frequency, ratio)
77
+ elsif ratio < 1.0 - Float::EPSILON
78
+ under_damped(delta_time, frequency, ratio)
79
+ else
80
+ critically_damped(delta_time, frequency)
81
+ end
82
+ end
83
+
84
+ def over_damped(delta_time, frequency, ratio)
85
+ za = -frequency * ratio
86
+ zb = frequency * Math.sqrt(ratio * ratio - 1.0)
87
+ z1 = za - zb
88
+ z2 = za + zb
89
+ e2 = Math.exp(z2 * delta_time)
90
+ e1_over_two_zb = Math.exp(z1 * delta_time) / (2.0 * zb)
91
+ e2_over_two_zb = e2 / (2.0 * zb)
92
+ z1e1_over_two_zb = z1 * e1_over_two_zb
93
+ z2e2_over_two_zb = z2 * e2_over_two_zb
94
+
95
+ [e1_over_two_zb * z2 - z2e2_over_two_zb + e2,
96
+ -e1_over_two_zb + e2_over_two_zb,
97
+ (z1e1_over_two_zb - z2e2_over_two_zb + e2) * z2,
98
+ -z1e1_over_two_zb + z2e2_over_two_zb]
99
+ end
100
+
101
+ def under_damped(delta_time, frequency, ratio)
102
+ omega_zeta = frequency * ratio
103
+ alpha = frequency * Math.sqrt(1.0 - ratio * ratio)
104
+ exp_term = Math.exp(-omega_zeta * delta_time)
105
+ exp_sin = exp_term * Math.sin(alpha * delta_time)
106
+ exp_cos = exp_term * Math.cos(alpha * delta_time)
107
+ exp_omega_zeta_sin_over_alpha = omega_zeta * exp_sin / alpha
108
+
109
+ [exp_cos + exp_omega_zeta_sin_over_alpha,
110
+ exp_sin / alpha,
111
+ -exp_sin * alpha - omega_zeta * exp_omega_zeta_sin_over_alpha,
112
+ exp_cos - exp_omega_zeta_sin_over_alpha]
113
+ end
114
+
115
+ def critically_damped(delta_time, frequency)
116
+ exp_term = Math.exp(-frequency * delta_time)
117
+ time_exp = delta_time * exp_term
118
+ time_exp_freq = time_exp * frequency
119
+
120
+ [time_exp_freq + exp_term,
121
+ time_exp,
122
+ -frequency * time_exp_freq,
123
+ -time_exp_freq + exp_term]
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Tasks
5
+ # Context is the object a task block receives as its argument. It carries the
6
+ # task's inputs (the `with:` hash given to `run_task`, deep-frozen at submit time)
7
+ # and the progress reporter:
8
+ #
9
+ # run_task(:search, with: {query: query.value}) do |ctx|
10
+ # results = Api.search(ctx[:query])
11
+ # ctx.report(1, of: 1)
12
+ # results
13
+ # end
14
+ #
15
+ # Task blocks receive data in via `with:` and return data out as the block value.
16
+ # They touch nothing else: the `on_task` handler on the loop thread is the only
17
+ # place task results become state.
18
+ class Context
19
+ def initialize(data, progress)
20
+ @data = data
21
+ @progress = progress
22
+ end
23
+
24
+ # Returns the input value for *key* from the `with:` hash.
25
+ def [](key)
26
+ @data[key]
27
+ end
28
+
29
+ # Reports progress to the matching `on_task_progress` handler on the loop thread.
30
+ def report(current, of: nil, message: nil)
31
+ @progress.report(current, of: of, message: message)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -18,27 +18,28 @@ module Charming
18
18
  # end
19
19
  #
20
20
  # it "quits on q" do
21
- # expect(press(ctrl_class: HomeController, key: "q")).to be_quit
21
+ # expect(press(ctrl, "q")).to be_quit
22
22
  # end
23
23
  # end
24
24
  #
25
25
  # Helpers:
26
- # - `build_controller(klass, app:, screen:, route:)` — controller instance wired to an app
26
+ # - `build_controller(klass, app:, screen:, route:, params:)` — controller instance wired to an app
27
27
  # - `key_event("ctrl+p")` — build a KeyEvent from a human-readable string
28
- # - `press(controller_or_class, "down")` — dispatch a key press, returns the Response
29
- # - `press_sequence(klass, ["down", "down", "enter"], app:)` — dispatch several presses
28
+ # - `press(controller, "down")` — dispatch a key press at the instance, returns the Response
29
+ # - `press_sequence(controller, ["down", "down", "enter"])` — dispatch several presses at one instance
30
30
  #
31
31
  # RSpec matchers (when RSpec is loaded):
32
32
  # - `expect(response).to render_text("...")` / `render_match(/.../)`
33
33
  # - `expect(response).to be_quit` / `be_navigate` (predicate matchers on Response)
34
- # - `expect(response).to navigate_to("/path")`
34
+ # - `expect(response).to navigate_to(:projects)`
35
35
  module TestHelper
36
- # Builds a controller instance with sensible test defaults: a fresh Application,
37
- # an 80x24 screen, and no event.
38
- def build_controller(controller_class, app: nil, screen: nil, route: nil, event: nil)
36
+ # Builds a controller instance with sensible test defaults: a fresh Application and
37
+ # an 80x24 screen. Runs the screen_entered lifecycle hook, mirroring the Runtime's
38
+ # persistent-controller lifecycle.
39
+ def build_controller(controller_class, app: nil, screen: nil, route: nil, params: {})
39
40
  app ||= Charming::Application.new
40
41
  screen ||= Charming::Screen.new(width: 80, height: 24)
41
- controller_class.new(application: app, event: event, screen: screen, route: route)
42
+ controller_class.new(application: app, params: params, screen: screen, route: route).tap(&:screen_entered)
42
43
  end
43
44
 
44
45
  # Builds a KeyEvent from a human-readable string like "q", "down", "ctrl+p",
@@ -57,18 +58,16 @@ module Charming
57
58
  )
58
59
  end
59
60
 
60
- # Dispatches a single key press against *controller_class* and returns the Response.
61
- # Pass `app:` to share session state across presses.
62
- def press(controller_class, key, app:, screen: nil, route: nil)
63
- controller = build_controller(controller_class, app: app, screen: screen, route: route, event: key_event(key))
64
- controller.dispatch_key
61
+ # Dispatches a single key press at *controller* (an instance from build_controller)
62
+ # and returns the Response. Accepts a human-readable string or a ready KeyEvent.
63
+ def press(controller, key)
64
+ controller.dispatch_key(key.is_a?(String) ? key_event(key) : key)
65
65
  end
66
66
 
67
- # Dispatches each key in *keys* in order against fresh controller instances sharing
68
- # *app*'s session (mirroring the runtime's controller-per-event model). Returns the
69
- # last Response.
70
- def press_sequence(controller_class, keys, app:, screen: nil, route: nil)
71
- keys.map { |key| press(controller_class, key, app: app, screen: screen, route: route) }.last
67
+ # Dispatches each key in *keys* in order at the same controller instance (mirroring
68
+ # the runtime's persistent-controller model). Returns the last Response.
69
+ def press_sequence(controller, keys)
70
+ keys.map { |key| press(controller, key) }.last
72
71
  end
73
72
 
74
73
  # Builds a MemoryBackend pre-seeded with KeyEvents parsed from *keys*, ready to be
@@ -77,6 +76,24 @@ module Charming
77
76
  events = keys.map { |key| key.is_a?(String) ? key_event(key) : key }
78
77
  Charming::Internal::Terminal::MemoryBackend.new(events: events, width: width, height: height)
79
78
  end
79
+
80
+ # Renders a view class standalone — no controller needed — and returns a hash with
81
+ # the painted :frame plus the layout's registration data (:focus_slots,
82
+ # :mouse_targets). Rendering is pure: registrations only commit when a response
83
+ # paints, so unit-testing a view means asserting on these artifacts.
84
+ #
85
+ # result = render_view(Home::ShowView, screen: Charming::Screen.new(width: 40, height: 10))
86
+ # expect(result[:focus_slots]).to eq([:entries])
87
+ def render_view(view_class, **assigns)
88
+ view = view_class.new(**assigns)
89
+ frame = view.render
90
+ artifacts = view.render_artifacts
91
+ {
92
+ frame: frame,
93
+ focus_slots: artifacts.last&.focus_slots || [],
94
+ mouse_targets: artifacts.flat_map(&:mouse_targets)
95
+ }
96
+ end
80
97
  end
81
98
  end
82
99
 
@@ -108,13 +125,16 @@ if defined?(RSpec)
108
125
  end
109
126
  end
110
127
 
111
- RSpec::Matchers.define :navigate_to do |expected_path|
128
+ RSpec::Matchers.define :navigate_to do |expected_name, **expected_params|
112
129
  match do |response|
113
- response.respond_to?(:navigate?) && response.navigate? && response.path == expected_path
130
+ next false unless response.respond_to?(:navigate?) && response.navigate?
131
+ next false unless response.name == expected_name.to_sym
132
+
133
+ expected_params.empty? || response.params == expected_params
114
134
  end
115
135
 
116
136
  failure_message do |response|
117
- "expected a navigation response to #{expected_path.inspect}, got: #{response.inspect}"
137
+ "expected a navigation response to #{expected_name.inspect}, got: #{response.inspect}"
118
138
  end
119
139
  end
120
140
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Raised when a component emits a result (`[:submitted, value]`, `[:selected, value]`,
5
+ # or `:cancelled`) and the controller has neither an `on_submit`/`on_select`/`on_cancel`
6
+ # registration nor a legacy `<slot>_<event>` hook for it. Raised in development and test;
7
+ # production logs a warning and falls back to the default render instead.
8
+ class UnhandledComponentEvent < Error; end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Raised when a rendered layout names a focusable pane, or an `on_*` registration
5
+ # references a slot, that nothing declares — not a `slot` declaration, not a
6
+ # `focus_ring` entry, not a same-named method. Raised in development and test;
7
+ # production logs a warning. The message names the fix and lists declared slots.
8
+ class UnknownSlot < Error; end
9
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- VERSION = "0.2.3"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -7,7 +7,7 @@ module Charming
7
7
  module Welcome
8
8
  # The fallback route the Runtime uses when the application has no routes.
9
9
  def self.route
10
- Router::Route.new(path: "/", controller_class: Controller, action: :show, title: "Welcome", params: {})
10
+ Router::Route.new(name: :root, controller_class: Controller, action: :show, title: "Welcome", params: {})
11
11
  end
12
12
  end
13
13
  end