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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. 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 a `gap:` parameter.
61
- def row(*items, gap: 0)
62
- UI.join_horizontal(*items, gap: gap)
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 a `gap:` parameter for spacing.
66
- def column(*items, gap: 0)
67
- UI.join_vertical(*items, gap: gap)
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.
@@ -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
- Response = Data.define(:kind, :body, :path) do
8
- # Factory constructing a Render response for displaying *body* text on the current screen.
9
- def self.render(body)
10
- new(kind: :render, body: body, path: nil)
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.
@@ -47,7 +47,7 @@ module Charming
47
47
  route = Route.new(
48
48
  path: path,
49
49
  controller_class: constantize(controller_constant_name(controller_name)),
50
- action: action.to_sym,
50
+ action: (action || "show").to_sym,
51
51
  title: title || derive_title(path),
52
52
  params: {}
53
53
  )
@@ -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 = 0.05
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
- @timers = build_timers
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(resolve_response(dispatch(@route.action)))
31
- loop do
32
- event = next_task_event || next_timer_event || @backend.read_event(timeout: read_timeout)
33
- next unless event
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
- response = dispatch_event(event)
36
- next unless response
37
- break if response.quit?
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
- response = resolve_response(response)
40
- break if response.quit?
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
- render(response)
43
- end
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
- ensure
46
- @task_executor&.shutdown(timeout: 0.0)
47
- restore_terminal
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
- private
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
- attr_reader :screen
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 events
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
- # resets timers for the new route, and dispatches that route's action.
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
- @timers = build_timers
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 the body portion of a response through the renderer.
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
- # Builds the initial set of timer states from controller bindings and the current clock time.
129
- def build_timers
130
- now = clock_now
131
- @route.controller_class.timer_bindings.values.map do |binding|
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
- now = clock_now
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
- # Pops a task event from the thread-safe queue if one is available.
148
- # Non-blocking — returns nil immediately when the queue is empty.
149
- def next_task_event
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
- # Returns timer values due at or before `now`, sorted by next fire time.
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. Returns nil.
17
- def submit(name, &block)
18
- task = Task.new(name: name.to_sym, block: block)
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
@@ -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
- Task = Data.define(:name, :block) do
8
- # Invokes the task's block in the executor's thread and returns its value (or raises).
9
- def call = block.call
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
- # Returns nil immediately.
21
- def submit(name, &block)
22
- task = Task.new(name: name.to_sym, block: block)
23
- thread = Thread.new(task) { |t| @queue << run(t) }
24
- @mutex.synchronize { @threads << thread }
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. Used by Runtime during teardown.
30
- def shutdown(timeout: 0.0)
31
- threads = @mutex.synchronize { @threads.dup }
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 (or rescued exception) in a TaskEvent.
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