charming 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +144 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +41 -3
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +27 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- metadata +63 -50
|
@@ -57,14 +57,16 @@ module Charming
|
|
|
57
57
|
apply_style(content, style)
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
# Joins items horizontally (side-by-side) using the UI rendering engine. Supports
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
# Joins items horizontally (side-by-side) using the UI rendering engine. Supports `gap:`
|
|
61
|
+
# spacing and cross-axis `align:` (`:top`/`:center`/`:bottom` or a 0.0–1.0 fraction).
|
|
62
|
+
def row(*items, gap: 0, align: :top)
|
|
63
|
+
UI.join_horizontal(*items, gap: gap, align: align)
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
# Stacks items vertically using the UI rendering engine. Supports
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
# Stacks items vertically using the UI rendering engine. Supports `gap:` spacing and
|
|
67
|
+
# cross-axis `align:` (`:left`/`:center`/`:right` or a 0.0–1.0 fraction).
|
|
68
|
+
def column(*items, gap: 0, align: :left)
|
|
69
|
+
UI.join_vertical(*items, gap: gap, align: align)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
# Renders a component (e.g., a ProgressBar, Spinner, Modal) and returns its string output.
|
data/lib/charming/response.rb
CHANGED
|
@@ -4,20 +4,25 @@ module Charming
|
|
|
4
4
|
# Response encapsulates a controller's dispatch outcome — one of render text, navigate to another route, or quit.
|
|
5
5
|
# Rails-style factories (`render`, `navigate`, `quit`) serve as the public API and map to :kind values
|
|
6
6
|
# that the Runtime interprets at the end of each event loop iteration.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
#
|
|
8
|
+
# *escapes* carries any out-of-band terminal sequences (image transmissions, clipboard writes,
|
|
9
|
+
# notifications, window-title changes) gathered during the dispatch. The Runtime flushes them straight
|
|
10
|
+
# to the backend, bypassing the line-based frame pipeline. It is empty for ordinary responses.
|
|
11
|
+
Response = Data.define(:kind, :body, :path, :escapes) do
|
|
12
|
+
# Factory constructing a Render response for displaying *body* text on the current screen. *escapes*
|
|
13
|
+
# is the list of out-of-band sequences gathered during the dispatch (defaults to none).
|
|
14
|
+
def self.render(body, escapes: [])
|
|
15
|
+
new(kind: :render, body: body, path: nil, escapes: escapes)
|
|
11
16
|
end
|
|
12
17
|
|
|
13
18
|
# Factory constructing a NavigateResponse routing to the named *path* (string).
|
|
14
19
|
def self.navigate(path)
|
|
15
|
-
new(kind: :navigate, body: "", path: path)
|
|
20
|
+
new(kind: :navigate, body: "", path: path, escapes: [])
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
# Factory constructing a QuitResponse signalling termination of the top-level event loop.
|
|
19
24
|
def self.quit
|
|
20
|
-
new(kind: :quit, body: "", path: nil)
|
|
25
|
+
new(kind: :quit, body: "", path: nil, escapes: [])
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
# Returns `true` when this response is navigating to another screen or route.
|
data/lib/charming/router.rb
CHANGED
data/lib/charming/runtime.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Charming
|
|
|
6
6
|
# reads keyboard, mouse, timer, and task events, dispatching them to
|
|
7
7
|
# controllers, rendering responses, and tearing down cleanly on exit.
|
|
8
8
|
class Runtime
|
|
9
|
-
DEFAULT_READ_TIMEOUT =
|
|
9
|
+
DEFAULT_READ_TIMEOUT = Internal::EventLoop::DEFAULT_READ_TIMEOUT
|
|
10
10
|
|
|
11
11
|
def initialize(application, backend: nil, renderer: nil, clock: nil, task_executor: nil)
|
|
12
12
|
@application = application
|
|
@@ -18,38 +18,187 @@ module Charming
|
|
|
18
18
|
@application.task_executor = @task_executor
|
|
19
19
|
@route = @application.routes.resolve("/")
|
|
20
20
|
@screen = backend_screen
|
|
21
|
-
@
|
|
21
|
+
@coalesce_input = @application.respond_to?(:coalesce_input?) && @application.coalesce_input?
|
|
22
|
+
@event_loop = build_event_loop
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
# Runs the event loop: enters alt-screen, dispatches incoming events
|
|
25
26
|
# (key, mouse, timer, async task), renders controller responses, and
|
|
26
|
-
# restores terminal state on exit.
|
|
27
|
+
# restores terminal state on exit. Unhandled exceptions from controller
|
|
28
|
+
# actions render an ErrorScreen instead of crashing the terminal.
|
|
27
29
|
def run
|
|
28
30
|
setup_terminal
|
|
31
|
+
install_signal_handlers
|
|
32
|
+
install_exit_hook
|
|
29
33
|
with_raw_input do
|
|
30
|
-
render(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
render(initial_response)
|
|
35
|
+
@event_loop.run { |event, more_ready| process(event, flush: !more_ready) }
|
|
36
|
+
ensure
|
|
37
|
+
restore_signal_handlers
|
|
38
|
+
@task_executor&.shutdown(timeout: 2.0)
|
|
39
|
+
@application.save_session if @application.respond_to?(:save_session)
|
|
40
|
+
restore_terminal
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
attr_reader :screen
|
|
47
|
+
|
|
48
|
+
# Builds the event pump, wiring in the current controller's timer bindings and
|
|
49
|
+
# an interrupt check backed by the SIGINT flag set in install_interrupt_handler.
|
|
50
|
+
def build_event_loop
|
|
51
|
+
Internal::EventLoop.new(
|
|
52
|
+
backend: @backend,
|
|
53
|
+
clock: @clock,
|
|
54
|
+
task_queue: @task_queue,
|
|
55
|
+
timer_bindings: @route.controller_class.timer_bindings.values,
|
|
56
|
+
coalesce_input: @coalesce_input,
|
|
57
|
+
interrupted: -> { @interrupted }
|
|
58
|
+
)
|
|
59
|
+
end
|
|
38
60
|
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
# The first frame's response — the root route's action, with errors caught. Out-of-band escape
|
|
62
|
+
# sequences registered while rendering are collected and attached to the response.
|
|
63
|
+
def initial_response
|
|
64
|
+
response = nil
|
|
65
|
+
escapes = Escape.collecting { response = resolve_response(dispatch(@route.action)) }
|
|
66
|
+
attach_escapes(response, escapes)
|
|
67
|
+
rescue => e
|
|
68
|
+
error_response(e)
|
|
69
|
+
end
|
|
41
70
|
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
# Handles a single event. Returns :quit to stop the loop, nil otherwise.
|
|
72
|
+
# While an error screen is showing, only key events are honored: q quits,
|
|
73
|
+
# any other key dismisses and re-renders the current route. When *flush* is
|
|
74
|
+
# false (more events are already due), the response is held so a burst of
|
|
75
|
+
# task/timer events paints once with the final state; held escapes are
|
|
76
|
+
# carried forward in order so none are lost.
|
|
77
|
+
def process(event, flush: true)
|
|
78
|
+
return process_error_event(event) if @error
|
|
79
|
+
return :quit if unbound_interrupt?(event)
|
|
80
|
+
|
|
81
|
+
response = nil
|
|
82
|
+
escapes = Escape.collecting do
|
|
83
|
+
response = dispatch_event(event)
|
|
84
|
+
response = resolve_response(response) if response
|
|
44
85
|
end
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
86
|
+
return flush_pending if response.nil?
|
|
87
|
+
return :quit if response.quit?
|
|
88
|
+
|
|
89
|
+
hold_response(response, escapes)
|
|
90
|
+
flush ? flush_pending : nil
|
|
91
|
+
rescue => e
|
|
92
|
+
@pending_response = nil
|
|
93
|
+
render(error_response(e))
|
|
94
|
+
nil
|
|
48
95
|
end
|
|
49
96
|
|
|
50
|
-
|
|
97
|
+
# Replaces the held response with *response*, prepending any escapes held
|
|
98
|
+
# from earlier events in the burst so they still flush in order.
|
|
99
|
+
def hold_response(response, escapes)
|
|
100
|
+
held = @pending_response&.escapes || []
|
|
101
|
+
@pending_response = response.with(escapes: held + response.escapes + escapes)
|
|
102
|
+
end
|
|
51
103
|
|
|
52
|
-
|
|
104
|
+
# Renders the held response, if any. Always returns nil (the loop's "keep going").
|
|
105
|
+
def flush_pending
|
|
106
|
+
response = @pending_response
|
|
107
|
+
@pending_response = nil
|
|
108
|
+
render(response) if response
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Error-mode event handling: q quits, any other key dismisses the error
|
|
113
|
+
# and re-dispatches the current route's action. Timer/task events are ignored.
|
|
114
|
+
def process_error_event(event)
|
|
115
|
+
return unless event.is_a?(Events::KeyEvent)
|
|
116
|
+
return :quit if Charming.key_of(event) == :q
|
|
117
|
+
|
|
118
|
+
@error = nil
|
|
119
|
+
render(initial_response)
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# True for a Ctrl+C key press that the current controller has no binding for —
|
|
124
|
+
# the runtime treats it as quit so apps always have an escape hatch. Controllers
|
|
125
|
+
# can take over by binding "ctrl+c" themselves.
|
|
126
|
+
def unbound_interrupt?(event)
|
|
127
|
+
return false unless event.is_a?(Events::KeyEvent)
|
|
128
|
+
return false unless event.ctrl && event.key == :c
|
|
129
|
+
|
|
130
|
+
@route.controller_class.key_bindings[:"ctrl+c"].nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Signals that should exit the loop cleanly through the ensure block (restoring
|
|
134
|
+
# the terminal) instead of killing the process mid-frame.
|
|
135
|
+
QUIT_SIGNALS = %w[INT TERM HUP].freeze
|
|
136
|
+
|
|
137
|
+
# Traps quit signals to set the interrupt flag the event loop checks, and —
|
|
138
|
+
# when the backend supports it — SIGTSTP/SIGCONT for shell suspend/resume.
|
|
139
|
+
def install_signal_handlers
|
|
140
|
+
@interrupted = false
|
|
141
|
+
@previous_handlers = {}
|
|
142
|
+
QUIT_SIGNALS.each { |signal| trap_signal(signal) { @interrupted = true } }
|
|
143
|
+
install_suspend_handlers if @backend.respond_to?(:suspend)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Ctrl+Z: return the terminal to its normal state, stop the process, and on
|
|
147
|
+
# SIGCONT re-enter the TUI and force a repaint via the backend's resize path.
|
|
148
|
+
def install_suspend_handlers
|
|
149
|
+
trap_signal("TSTP") { suspend }
|
|
150
|
+
trap_signal("CONT") { resume }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Traps *signal*, remembering the previous handler for teardown. Platforms
|
|
154
|
+
# without the signal (or restricted environments) are silently skipped.
|
|
155
|
+
def trap_signal(signal, &block)
|
|
156
|
+
@previous_handlers[signal] = Signal.trap(signal, &block)
|
|
157
|
+
rescue ArgumentError
|
|
158
|
+
@previous_handlers.delete(signal)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Restores every signal handler that was replaced when the runtime started.
|
|
162
|
+
def restore_signal_handlers
|
|
163
|
+
(@previous_handlers || {}).each do |signal, previous|
|
|
164
|
+
Signal.trap(signal, previous || "DEFAULT")
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Restores the terminal for the shell and stops the process (the untrappable
|
|
171
|
+
# SIGSTOP), so Ctrl+Z behaves like it does in any well-behaved TUI.
|
|
172
|
+
def suspend
|
|
173
|
+
@backend.suspend
|
|
174
|
+
Process.kill("STOP", Process.pid)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Re-enters the TUI after a foreground `fg`: raw mode and alt screen come back,
|
|
178
|
+
# the renderer cache is dropped, and the backend's resize path triggers a repaint.
|
|
179
|
+
def resume
|
|
180
|
+
@backend.resume
|
|
181
|
+
@renderer.invalidate if @renderer.respond_to?(:invalidate)
|
|
182
|
+
@backend.notify_resize if @backend.respond_to?(:notify_resize)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Registers a process-exit fallback so an unexpected death (an exception that
|
|
186
|
+
# skips run's ensure, or an exit from within a handler) still restores the
|
|
187
|
+
# terminal. restore_terminal is idempotent, so the normal path stays cheap.
|
|
188
|
+
def install_exit_hook
|
|
189
|
+
return if @exit_hook_installed
|
|
190
|
+
|
|
191
|
+
@exit_hook_installed = true
|
|
192
|
+
at_exit { restore_terminal }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Records *error*, logs its backtrace, and builds a centered ErrorScreen response.
|
|
196
|
+
def error_response(error)
|
|
197
|
+
@error = error
|
|
198
|
+
@application.logger.error("#{error.class}: #{error.message}\n#{Array(error.backtrace).join("\n")}")
|
|
199
|
+
panel = Components::ErrorScreen.new(error: error, theme: @application.theme).render
|
|
200
|
+
Response.render(UI.center(panel, width: screen.width, height: screen.height))
|
|
201
|
+
end
|
|
53
202
|
|
|
54
203
|
# Dispatches an action on the current route's controller with an optional event.
|
|
55
204
|
# Entry point from the event loop into controllers.
|
|
@@ -72,6 +221,11 @@ module Charming
|
|
|
72
221
|
controller(event: event).dispatch_task
|
|
73
222
|
end
|
|
74
223
|
|
|
224
|
+
# Dispatches a task progress report to the current route's controller.
|
|
225
|
+
def dispatch_task_progress(event)
|
|
226
|
+
controller(event: event).dispatch_task_progress
|
|
227
|
+
end
|
|
228
|
+
|
|
75
229
|
# Dispatches a mouse action (click, drag, scroll) to the current route's controller.
|
|
76
230
|
def dispatch_mouse(event)
|
|
77
231
|
controller(event: event).dispatch_mouse
|
|
@@ -83,17 +237,34 @@ module Charming
|
|
|
83
237
|
@route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen, route: @route)
|
|
84
238
|
end
|
|
85
239
|
|
|
86
|
-
# Type-based dispatcher: routes resize, task, timer, mouse, and key
|
|
87
|
-
# to the appropriate handler. Falls back to key dispatch for unclassified events.
|
|
240
|
+
# Type-based dispatcher: routes resize, task, progress, timer, mouse, paste, and key
|
|
241
|
+
# events to the appropriate handler. Falls back to key dispatch for unclassified events.
|
|
88
242
|
def dispatch_event(event)
|
|
89
243
|
return dispatch_resize(event) if event.is_a?(Events::ResizeEvent)
|
|
90
244
|
return dispatch_task(event) if event.is_a?(Events::TaskEvent)
|
|
245
|
+
return dispatch_task_progress(event) if event.is_a?(Events::TaskProgressEvent)
|
|
91
246
|
return dispatch_timer(event) if event.is_a?(Events::TimerEvent)
|
|
92
247
|
return dispatch_mouse(event) if event.is_a?(Events::MouseEvent)
|
|
248
|
+
return dispatch_paste(event) if event.is_a?(Events::PasteEvent)
|
|
249
|
+
return dispatch_focus_change(event) if event.is_a?(Events::FocusEvent)
|
|
93
250
|
|
|
94
251
|
dispatch_key(event)
|
|
95
252
|
end
|
|
96
253
|
|
|
254
|
+
# Dispatches a terminal focus change to the controller's optional `focus_changed`
|
|
255
|
+
# action. Ignored when the controller doesn't define one.
|
|
256
|
+
def dispatch_focus_change(event)
|
|
257
|
+
ctrl = controller(event: event)
|
|
258
|
+
return nil unless ctrl.respond_to?(:focus_changed)
|
|
259
|
+
|
|
260
|
+
ctrl.dispatch(:focus_changed)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Dispatches pasted text to the current route's controller.
|
|
264
|
+
def dispatch_paste(event)
|
|
265
|
+
controller(event: event).dispatch_paste
|
|
266
|
+
end
|
|
267
|
+
|
|
97
268
|
# Dispatches a resize event: updates screen dimensions and re-renders the current action.
|
|
98
269
|
# The renderer's cached previous frame is invalidated and the backend is cleared so the
|
|
99
270
|
# new-dimension frame paints onto a clean alt-screen instead of overlaying stale rows.
|
|
@@ -105,12 +276,13 @@ module Charming
|
|
|
105
276
|
end
|
|
106
277
|
|
|
107
278
|
# Follows navigation responses: resolves the new route from the router,
|
|
108
|
-
#
|
|
279
|
+
# reschedules the event loop's timers for the new controller, and
|
|
280
|
+
# dispatches that route's action.
|
|
109
281
|
def resolve_response(response)
|
|
110
282
|
return response unless response.navigate?
|
|
111
283
|
|
|
112
284
|
@route = @application.routes.resolve(response.path)
|
|
113
|
-
@
|
|
285
|
+
@event_loop.reset_timers(@route.controller_class.timer_bindings.values)
|
|
114
286
|
dispatch(@route.action)
|
|
115
287
|
end
|
|
116
288
|
|
|
@@ -120,51 +292,27 @@ module Charming
|
|
|
120
292
|
Screen.new(width: width, height: height)
|
|
121
293
|
end
|
|
122
294
|
|
|
123
|
-
# Renders
|
|
295
|
+
# Renders a response: first flushes any out-of-band escape sequences (image transmissions,
|
|
296
|
+
# clipboard writes, notifications, title changes) straight to the backend — ahead of the frame so
|
|
297
|
+
# image data is registered before its placeholder cells reference it — then renders the body.
|
|
124
298
|
def render(response)
|
|
299
|
+
flush_escapes(response)
|
|
125
300
|
@renderer.render(response.body)
|
|
126
301
|
end
|
|
127
302
|
|
|
128
|
-
#
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
@
|
|
132
|
-
{binding: binding, next_at: now + binding.interval}
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Returns a TimerEvent for the first due timer and advances its next fire time.
|
|
137
|
-
# Returns nil if no timers are ready or registered.
|
|
138
|
-
def next_timer_event
|
|
139
|
-
timer = due_timer
|
|
140
|
-
return unless timer
|
|
303
|
+
# Writes a response's out-of-band escape sequences to the backend, ahead of the frame. No-op for
|
|
304
|
+
# backends that don't support them or responses that carry none.
|
|
305
|
+
def flush_escapes(response)
|
|
306
|
+
return unless @backend.respond_to?(:write_escape)
|
|
141
307
|
|
|
142
|
-
|
|
143
|
-
timer[:next_at] = now + timer.fetch(:binding).interval
|
|
144
|
-
Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
|
|
308
|
+
response.escapes&.each { |sequence| @backend.write_escape(sequence) }
|
|
145
309
|
end
|
|
146
310
|
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
@task_queue.pop(true)
|
|
151
|
-
rescue ThreadError
|
|
152
|
-
nil
|
|
153
|
-
end
|
|
311
|
+
# Returns *response* with *escapes* appended, or unchanged when none were collected.
|
|
312
|
+
def attach_escapes(response, escapes)
|
|
313
|
+
return response if escapes.nil? || escapes.empty?
|
|
154
314
|
|
|
155
|
-
|
|
156
|
-
def due_timer
|
|
157
|
-
now = clock_now
|
|
158
|
-
@timers.select { |timer| timer.fetch(:next_at) <= now }.min_by { |timer| timer.fetch(:next_at) }
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Computes how long to block waiting for input based on when the next timer is due,
|
|
162
|
-
# clamped between 0 and DEFAULT_READ_TIMEOUT (0.05s). Returns DEFAULT when no timers exist.
|
|
163
|
-
def read_timeout
|
|
164
|
-
next_at = @timers.map { |timer| timer.fetch(:next_at) }.min
|
|
165
|
-
return DEFAULT_READ_TIMEOUT unless next_at
|
|
166
|
-
|
|
167
|
-
(next_at - clock_now).clamp(0, DEFAULT_READ_TIMEOUT)
|
|
315
|
+
response.with(escapes: response.escapes + escapes)
|
|
168
316
|
end
|
|
169
317
|
|
|
170
318
|
# Constructs a task executor: supports explicit instances, callable factories, or the default Threaded executor.
|
|
@@ -182,14 +330,33 @@ module Charming
|
|
|
182
330
|
end
|
|
183
331
|
|
|
184
332
|
# Enters an alternative screen buffer, hides the cursor, and installs
|
|
185
|
-
# a terminal resize signal handler if supported by the backend.
|
|
333
|
+
# a terminal resize signal handler if supported by the backend. Also asks
|
|
334
|
+
# the terminal for its background color so adaptive colors resolve correctly.
|
|
186
335
|
def setup_terminal
|
|
336
|
+
detect_background
|
|
187
337
|
@backend.enter_alt_screen
|
|
188
338
|
@backend.hide_cursor
|
|
189
|
-
@backend.enable_mouse_tracking if @backend.respond_to?(:enable_mouse_tracking)
|
|
339
|
+
@backend.enable_mouse_tracking(motion: mouse_motion) if @backend.respond_to?(:enable_mouse_tracking)
|
|
340
|
+
@backend.enable_bracketed_paste if @backend.respond_to?(:enable_bracketed_paste)
|
|
341
|
+
@backend.enable_focus_reporting if @backend.respond_to?(:enable_focus_reporting)
|
|
190
342
|
@backend.install_resize_handler if @backend.respond_to?(:install_resize_handler)
|
|
191
343
|
end
|
|
192
344
|
|
|
345
|
+
# The app's configured mouse motion mode (:drag unless the app opts into :all hover).
|
|
346
|
+
def mouse_motion
|
|
347
|
+
@application.respond_to?(:mouse_motion) ? @application.mouse_motion : :drag
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Feeds the terminal's OSC 11 background reply (when the backend can obtain
|
|
351
|
+
# one) into UI::Background so adaptive colors resolve against reality rather
|
|
352
|
+
# than the dark-background default.
|
|
353
|
+
def detect_background
|
|
354
|
+
return unless @backend.respond_to?(:query_background_color)
|
|
355
|
+
|
|
356
|
+
background = @backend.query_background_color
|
|
357
|
+
UI::Background.assume = background if background
|
|
358
|
+
end
|
|
359
|
+
|
|
193
360
|
# Keeps input raw/no-echo across rendering and dispatch, not just during reads.
|
|
194
361
|
def with_raw_input(&block)
|
|
195
362
|
return yield unless @backend.respond_to?(:with_raw_input)
|
|
@@ -198,9 +365,15 @@ module Charming
|
|
|
198
365
|
end
|
|
199
366
|
|
|
200
367
|
# Restores terminal state: reinstalls any previous resize handler, shows
|
|
201
|
-
# the cursor, and leaves the alternative screen buffer.
|
|
368
|
+
# the cursor, and leaves the alternative screen buffer. Idempotent — runs
|
|
369
|
+
# once whether reached from run's ensure or the at_exit fallback.
|
|
202
370
|
def restore_terminal
|
|
371
|
+
return if @restored
|
|
372
|
+
|
|
373
|
+
@restored = true
|
|
203
374
|
@backend.restore_resize_handler if @backend.respond_to?(:restore_resize_handler)
|
|
375
|
+
@backend.disable_focus_reporting if @backend.respond_to?(:disable_focus_reporting)
|
|
376
|
+
@backend.disable_bracketed_paste if @backend.respond_to?(:disable_bracketed_paste)
|
|
204
377
|
@backend.disable_mouse_tracking if @backend.respond_to?(:disable_mouse_tracking)
|
|
205
378
|
@backend.show_cursor
|
|
206
379
|
@backend.leave_alt_screen
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Tasks
|
|
5
|
+
# Cancelled is raised inside a task thread when the controller calls
|
|
6
|
+
# `cancel_task(name)` or the task exceeds its `timeout:`. The task completes with
|
|
7
|
+
# a TaskEvent whose error is this exception, so `on_task` handlers can detect it
|
|
8
|
+
# via `event.error.is_a?(Charming::Tasks::Cancelled)`.
|
|
9
|
+
class Cancelled < StandardError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -13,13 +13,19 @@ module Charming
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# Wraps *block* in a Task, invokes it immediately, and pushes the resulting
|
|
16
|
-
# TaskEvent (value or error) onto the queue.
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
# TaskEvent (value or error) onto the queue. Blocks that accept an argument
|
|
17
|
+
# receive a Progress reporter (its events are queued before the completion event).
|
|
18
|
+
# Returns nil.
|
|
19
|
+
def submit(name, timeout: nil, &block)
|
|
20
|
+
task = Task.new(name: name.to_sym, block: block, timeout: timeout)
|
|
19
21
|
@queue << run(task)
|
|
20
22
|
nil
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
# No-op: inline tasks have always finished by the time cancel could be called.
|
|
26
|
+
def cancel(name)
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
# No-op stub for the shutdown contract; nothing to join since tasks run on the caller.
|
|
24
30
|
def shutdown(timeout: 0.0)
|
|
25
31
|
end
|
|
@@ -28,7 +34,7 @@ module Charming
|
|
|
28
34
|
|
|
29
35
|
# Invokes the task's block and wraps the result (or raised exception) in a TaskEvent.
|
|
30
36
|
def run(task)
|
|
31
|
-
Events::TaskEvent.new(name: task.name, value: task.call)
|
|
37
|
+
Events::TaskEvent.new(name: task.name, value: task.call(Progress.new(@queue, task.name)))
|
|
32
38
|
rescue StandardError, ScriptError => e
|
|
33
39
|
Events::TaskEvent.new(name: task.name, error: e)
|
|
34
40
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Tasks
|
|
5
|
+
# Progress is the reporter handed to task blocks that accept an argument:
|
|
6
|
+
#
|
|
7
|
+
# run_task(:import) do |progress|
|
|
8
|
+
# rows.each_with_index do |row, i|
|
|
9
|
+
# import(row)
|
|
10
|
+
# progress.report(i + 1, of: rows.length, message: row.name)
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Each `report` pushes a TaskProgressEvent onto the runtime queue, which dispatches
|
|
15
|
+
# it to the controller's matching `on_task_progress` handler.
|
|
16
|
+
class Progress
|
|
17
|
+
def initialize(queue, name)
|
|
18
|
+
@queue = queue
|
|
19
|
+
@name = name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Reports progress: *current* units done, optionally *of:* a total and with a
|
|
23
|
+
# human-readable *message:*.
|
|
24
|
+
def report(current, of: nil, message: nil)
|
|
25
|
+
@queue << Events::TaskProgressEvent.new(name: @name, current: current, total: of, message: message)
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/charming/tasks/task.rb
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
3
5
|
module Charming
|
|
4
6
|
module Tasks
|
|
5
7
|
# Task is the unit of work submitted to a task executor. It pairs a *name* (used by
|
|
6
|
-
# `on_task` handlers to route the result) with a *block* to invoke on the executor
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def
|
|
8
|
+
# `on_task` handlers to route the result) with a *block* to invoke on the executor,
|
|
9
|
+
# plus an optional *timeout* in seconds.
|
|
10
|
+
Task = Data.define(:name, :block, :timeout) do
|
|
11
|
+
def initialize(name:, block:, timeout: nil)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Invokes the task's block, passing *progress* when the block accepts an argument.
|
|
16
|
+
# Enforces the timeout (raising Tasks::Cancelled) when one was configured.
|
|
17
|
+
def call(progress = nil)
|
|
18
|
+
return invoke(progress) unless timeout
|
|
19
|
+
|
|
20
|
+
Timeout.timeout(timeout, Cancelled, "task #{name} timed out after #{timeout}s") do
|
|
21
|
+
invoke(progress)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def invoke(progress)
|
|
28
|
+
block.arity.zero? ? block.call : block.call(progress)
|
|
29
|
+
end
|
|
10
30
|
end
|
|
11
31
|
end
|
|
12
32
|
end
|
|
@@ -5,30 +5,53 @@ module Charming
|
|
|
5
5
|
# ThreadedExecutor runs submitted tasks on background Ruby threads. Each submission
|
|
6
6
|
# creates a new thread that invokes the block and pushes the resulting TaskEvent
|
|
7
7
|
# onto the shared *queue*. Threads are tracked so `shutdown` can wait (or kill)
|
|
8
|
-
# in-flight work.
|
|
8
|
+
# in-flight work, and tracked by name so `cancel` can interrupt a specific task.
|
|
9
9
|
class ThreadedExecutor
|
|
10
10
|
# *queue* is the thread-safe Queue (typically `runtime.@task_queue`) into which
|
|
11
11
|
# completed TaskEvents are pushed.
|
|
12
12
|
def initialize(queue)
|
|
13
13
|
@queue = queue
|
|
14
14
|
@threads = []
|
|
15
|
+
@threads_by_name = {}
|
|
15
16
|
@mutex = Mutex.new
|
|
17
|
+
@shutting_down = false
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# Wraps *block* in a Task and spawns a new thread to invoke it. The thread's
|
|
19
21
|
# return value (or rescued exception) is pushed onto the queue as a TaskEvent.
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
# Blocks that accept an argument receive a Progress reporter. *timeout* (seconds)
|
|
23
|
+
# cancels the task when exceeded. Returns nil immediately. Raises if called after
|
|
24
|
+
# shutdown has begun.
|
|
25
|
+
def submit(name, timeout: nil, &block)
|
|
26
|
+
task = Task.new(name: name.to_sym, block: block, timeout: timeout)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
raise "cannot submit task after shutdown" if @shutting_down
|
|
29
|
+
|
|
30
|
+
thread = Thread.new(task) { |t| @queue << run(t) }
|
|
31
|
+
@threads << thread
|
|
32
|
+
@threads_by_name[task.name] = thread
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Cancels the named in-flight task by raising Tasks::Cancelled in its thread.
|
|
38
|
+
# The task completes with a TaskEvent whose error is the Cancelled exception.
|
|
39
|
+
# No-op when the task isn't running.
|
|
40
|
+
def cancel(name)
|
|
41
|
+
thread = @mutex.synchronize { @threads_by_name[name.to_sym] }
|
|
42
|
+
return unless thread&.alive?
|
|
43
|
+
|
|
44
|
+
thread.raise(Cancelled, "task #{name} cancelled")
|
|
25
45
|
nil
|
|
26
46
|
end
|
|
27
47
|
|
|
28
48
|
# Waits up to *timeout* seconds for in-flight threads to finish, then kills any
|
|
29
|
-
# remaining live threads.
|
|
30
|
-
def shutdown(timeout:
|
|
31
|
-
threads = @mutex.synchronize
|
|
49
|
+
# remaining live threads. Refuses new submissions once called.
|
|
50
|
+
def shutdown(timeout: 2.0)
|
|
51
|
+
threads = @mutex.synchronize do
|
|
52
|
+
@shutting_down = true
|
|
53
|
+
@threads.dup
|
|
54
|
+
end
|
|
32
55
|
threads.each { |thread| thread.join(timeout) }
|
|
33
56
|
threads.each do |thread|
|
|
34
57
|
next unless thread.alive?
|
|
@@ -40,9 +63,10 @@ module Charming
|
|
|
40
63
|
|
|
41
64
|
private
|
|
42
65
|
|
|
43
|
-
# Invokes the task's block and wraps the result
|
|
66
|
+
# Invokes the task's block (passing a Progress reporter) and wraps the result
|
|
67
|
+
# (or rescued exception) in a TaskEvent.
|
|
44
68
|
def run(task)
|
|
45
|
-
Events::TaskEvent.new(name: task.name, value: task.call)
|
|
69
|
+
Events::TaskEvent.new(name: task.name, value: task.call(Progress.new(@queue, task.name)))
|
|
46
70
|
rescue StandardError, ScriptError => e
|
|
47
71
|
Events::TaskEvent.new(name: task.name, error: e)
|
|
48
72
|
end
|