charming 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +3 -1
- data/lib/charming/application.rb +98 -14
- data/lib/charming/application_state.rb +23 -0
- data/lib/charming/cli.rb +2 -2
- data/lib/charming/controller/action_hooks.rb +5 -1
- data/lib/charming/controller/class_methods.rb +79 -17
- data/lib/charming/controller/component_dispatch.rb +163 -0
- data/lib/charming/controller/dispatching.rb +1 -2
- data/lib/charming/controller/focus_management.rb +67 -1
- data/lib/charming/controller/key_dispatch.rb +7 -7
- data/lib/charming/controller/rendering.rb +22 -6
- data/lib/charming/controller/session_state.rb +63 -22
- data/lib/charming/controller/timers.rb +25 -0
- data/lib/charming/controller.rb +253 -60
- data/lib/charming/cross_thread_access.rb +9 -0
- data/lib/charming/double_render_error.rb +8 -0
- data/lib/charming/generators/layout_generator.rb +4 -1
- data/lib/charming/generators/migration_generator.rb +1 -1
- data/lib/charming/generators/model_generator.rb +2 -2
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/screen_generator.rb +2 -2
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/internal/deep_freeze.rb +23 -0
- data/lib/charming/internal/env_inquirer.rb +22 -0
- data/lib/charming/internal/event_loop.rb +25 -2
- data/lib/charming/internal/inflections.rb +93 -0
- data/lib/charming/internal/session_guard.rb +27 -0
- data/lib/charming/internal/terminal/cursor.rb +29 -0
- data/lib/charming/internal/terminal/size.rb +47 -0
- data/lib/charming/internal/terminal/tty_backend.rb +10 -8
- data/lib/charming/internal/timer_control.rb +48 -0
- data/lib/charming/presentation/components/autocomplete.rb +14 -6
- data/lib/charming/presentation/components/command_palette.rb +11 -9
- data/lib/charming/presentation/components/filepicker.rb +4 -4
- data/lib/charming/presentation/components/form/confirm.rb +2 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/form/input.rb +3 -3
- data/lib/charming/presentation/components/form/multiselect.rb +4 -5
- data/lib/charming/presentation/components/form/select.rb +3 -3
- data/lib/charming/presentation/components/form/textarea.rb +3 -3
- data/lib/charming/presentation/components/form.rb +6 -6
- data/lib/charming/presentation/components/help_overlay.rb +2 -2
- data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
- data/lib/charming/presentation/components/list.rb +14 -5
- data/lib/charming/presentation/components/modal.rb +3 -2
- data/lib/charming/presentation/components/multi_select_list.rb +14 -7
- data/lib/charming/presentation/components/result.rb +61 -0
- data/lib/charming/presentation/components/tab_bar.rb +14 -6
- data/lib/charming/presentation/components/table.rb +64 -32
- data/lib/charming/presentation/components/text_area.rb +7 -7
- data/lib/charming/presentation/components/text_input.rb +7 -7
- data/lib/charming/presentation/components/tree.rb +15 -6
- data/lib/charming/presentation/components/viewport.rb +3 -3
- data/lib/charming/presentation/layout/pane.rb +6 -2
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/view.rb +35 -29
- data/lib/charming/projectile.rb +64 -0
- data/lib/charming/render_artifacts.rb +24 -0
- data/lib/charming/response.rb +19 -8
- data/lib/charming/router.rb +50 -68
- data/lib/charming/runtime.rb +54 -28
- data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
- data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
- data/lib/charming/spring.rb +126 -0
- data/lib/charming/tasks/context.rb +35 -0
- data/lib/charming/test_helper.rb +42 -22
- data/lib/charming/unhandled_component_event.rb +9 -0
- data/lib/charming/unknown_slot.rb +9 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome.rb +1 -1
- data/lib/charming.rb +20 -6
- metadata +25 -70
- data/lib/charming/controller/component_dispatching.rb +0 -125
data/lib/charming/runtime.rb
CHANGED
|
@@ -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:
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
@
|
|
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
|
-
|
|
258
|
-
return nil unless ctrl.respond_to?(:focus_changed)
|
|
280
|
+
return nil unless @controller.respond_to?(:focus_changed)
|
|
259
281
|
|
|
260
|
-
|
|
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
|
|
287
|
+
@controller.dispatch_paste(event)
|
|
266
288
|
end
|
|
267
289
|
|
|
268
|
-
# Dispatches a resize event: updates screen dimensions
|
|
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:
|
|
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
|
-
|
|
285
|
-
@
|
|
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 *
|
|
290
|
-
# first route, or to the built-in welcome screen when no
|
|
291
|
-
# (like Rails' welcome page); other unrouted
|
|
292
|
-
def resolve_route(
|
|
293
|
-
@application.routes.resolve(
|
|
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
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
73
|
+
if result&.cancelled?
|
|
44
74
|
close_command_palette
|
|
45
75
|
elsif selected_command?(result)
|
|
46
|
-
perform_command(result.
|
|
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
|
|
125
|
+
# True when a component result carries a selected command (Result.selected).
|
|
96
126
|
def selected_command?(result)
|
|
97
|
-
result.
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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 ?
|
|
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
|
data/lib/charming/test_helper.rb
CHANGED
|
@@ -18,27 +18,28 @@ module Charming
|
|
|
18
18
|
# end
|
|
19
19
|
#
|
|
20
20
|
# it "quits on q" do
|
|
21
|
-
# expect(press(
|
|
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(
|
|
29
|
-
# - `press_sequence(
|
|
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(
|
|
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,
|
|
38
|
-
|
|
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,
|
|
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
|
|
61
|
-
#
|
|
62
|
-
def press(
|
|
63
|
-
controller
|
|
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
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
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 |
|
|
128
|
+
RSpec::Matchers.define :navigate_to do |expected_name, **expected_params|
|
|
112
129
|
match do |response|
|
|
113
|
-
response.respond_to?(:navigate?) && response.navigate?
|
|
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 #{
|
|
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
|
data/lib/charming/version.rb
CHANGED
data/lib/charming/welcome.rb
CHANGED
|
@@ -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(
|
|
10
|
+
Router::Route.new(name: :root, controller_class: Controller, action: :show, title: "Welcome", params: {})
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|