charming 0.2.1 → 0.2.3

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/cli.rb +16 -4
  5. data/lib/charming/controller/key_dispatch.rb +113 -0
  6. data/lib/charming/controller/session_state.rb +11 -0
  7. data/lib/charming/controller/terminal.rb +33 -0
  8. data/lib/charming/controller.rb +13 -40
  9. data/lib/charming/escape.rb +81 -0
  10. data/lib/charming/events/mouse_event.rb +22 -9
  11. data/lib/charming/generators/app_file_generator.rb +29 -0
  12. data/lib/charming/generators/app_generator.rb +13 -25
  13. data/lib/charming/generators/base.rb +5 -0
  14. data/lib/charming/generators/layout_generator.rb +155 -0
  15. data/lib/charming/generators/screen_generator.rb +0 -29
  16. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  17. data/lib/charming/generators/templates/app/README.md.template +15 -1
  18. data/lib/charming/generators/templates/app/application.template +0 -6
  19. data/lib/charming/generators/templates/app/application_controller.template +0 -10
  20. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  21. data/lib/charming/generators/templates/app/layout.template +4 -93
  22. data/lib/charming/generators/templates/app/routes.template +3 -1
  23. data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
  24. data/lib/charming/image/protocol/kitty.rb +133 -0
  25. data/lib/charming/image/protocol.rb +18 -0
  26. data/lib/charming/image/source.rb +95 -0
  27. data/lib/charming/image/terminal.rb +52 -0
  28. data/lib/charming/image/transmit.rb +11 -0
  29. data/lib/charming/image.rb +21 -0
  30. data/lib/charming/internal/event_loop.rb +155 -0
  31. data/lib/charming/internal/terminal/adapter.rb +14 -0
  32. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  33. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  34. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  35. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  36. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  37. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  38. data/lib/charming/presentation/components/autocomplete.rb +7 -0
  39. data/lib/charming/presentation/components/chart.rb +80 -0
  40. data/lib/charming/presentation/components/error_screen.rb +1 -1
  41. data/lib/charming/presentation/components/filepicker.rb +101 -0
  42. data/lib/charming/presentation/components/form/builder.rb +5 -0
  43. data/lib/charming/presentation/components/form/field.rb +5 -0
  44. data/lib/charming/presentation/components/form/input.rb +14 -4
  45. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  46. data/lib/charming/presentation/components/form/textarea.rb +18 -8
  47. data/lib/charming/presentation/components/form.rb +6 -0
  48. data/lib/charming/presentation/components/image.rb +38 -0
  49. data/lib/charming/presentation/components/list.rb +22 -4
  50. data/lib/charming/presentation/components/modal.rb +4 -4
  51. data/lib/charming/presentation/components/paginator.rb +54 -0
  52. data/lib/charming/presentation/components/progressbar.rb +26 -4
  53. data/lib/charming/presentation/components/sparkline.rb +38 -0
  54. data/lib/charming/presentation/components/spinner.rb +22 -3
  55. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  56. data/lib/charming/presentation/components/table.rb +42 -1
  57. data/lib/charming/presentation/components/text_area.rb +3 -4
  58. data/lib/charming/presentation/components/text_input.rb +11 -6
  59. data/lib/charming/presentation/components/time_display.rb +20 -0
  60. data/lib/charming/presentation/components/timer.rb +43 -0
  61. data/lib/charming/presentation/components/toast.rb +4 -3
  62. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  63. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  64. data/lib/charming/presentation/components/viewport.rb +11 -1
  65. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  66. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  67. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  68. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  69. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  70. data/lib/charming/presentation/ui/background.rb +58 -0
  71. data/lib/charming/presentation/ui/border.rb +14 -1
  72. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  73. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  74. data/lib/charming/presentation/ui/canvas.rb +1 -0
  75. data/lib/charming/presentation/ui/gradient.rb +47 -0
  76. data/lib/charming/presentation/ui/style.rb +119 -19
  77. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  78. data/lib/charming/presentation/ui/truncate.rb +29 -0
  79. data/lib/charming/presentation/ui/width.rb +11 -0
  80. data/lib/charming/presentation/ui.rb +52 -11
  81. data/lib/charming/presentation/view.rb +8 -6
  82. data/lib/charming/response.rb +11 -6
  83. data/lib/charming/runtime.rb +167 -90
  84. data/lib/charming/version.rb +1 -1
  85. data/lib/charming/welcome/controller.rb +23 -0
  86. data/lib/charming/welcome/show_view.rb +113 -0
  87. data/lib/charming/welcome.rb +13 -0
  88. metadata +34 -9
  89. data/lib/charming/generators/templates/app/home_controller.template +0 -6
  90. data/lib/charming/generators/templates/app/home_state.template +0 -7
  91. data/lib/charming/generators/templates/app/spec_controller.template +0 -17
  92. data/lib/charming/generators/templates/app/spec_state.template +0 -17
  93. data/lib/charming/generators/templates/app/spec_view.template +0 -16
  94. data/lib/charming/generators/templates/app/view.template +0 -21
@@ -13,22 +13,38 @@ module Charming
13
13
  Style.new
14
14
  end
15
15
 
16
+ # Builds a color that resolves to *light* or *dark* at render time based on
17
+ # the terminal background. Usable anywhere a color is accepted.
18
+ def adaptive(light:, dark:)
19
+ AdaptiveColor.new(light: light, dark: dark)
20
+ end
21
+
16
22
  # Horizontally concatenates *blocks* into a single multi-line string, padding each block's
17
23
  # rows to match the widest row. A *gap* argument (in spaces) can separate adjacent columns.
18
- def join_horizontal(*blocks, gap: 0)
24
+ # *align* positions shorter blocks along the cross axis: `:top` (default), `:center`,
25
+ # `:bottom`, or a fraction between 0.0 and 1.0.
26
+ def join_horizontal(*blocks, gap: 0, align: :top)
19
27
  normalized = normalize_blocks(blocks)
28
+ height = block_height(normalized)
29
+ aligned = normalized.map { |lines| offset_rows(lines, height, align) }
20
30
  widths = block_widths(normalized)
21
31
  separator = " " * gap
22
32
 
23
- Array.new(block_height(normalized)) do |index|
24
- horizontal_line(normalized, widths, index).join(separator)
33
+ Array.new(height) do |index|
34
+ horizontal_line(aligned, widths, index).join(separator)
25
35
  end.join("\n")
26
36
  end
27
37
 
28
- # Stacks *blocks* vertically separated by one or more blank lines. A *gap* of N inserts N
29
- # extra newline characters between blocks (1 gap = 1 blank line, 2 gaps = 2 blank lines, etc.).
30
- def join_vertical(*blocks, gap: 0)
31
- blocks.join("\n" * (gap + 1))
38
+ # Stacks *blocks* vertically separated by one or more blank lines, padding narrower
39
+ # blocks' lines to the widest block. A *gap* of N inserts N extra newline characters
40
+ # between blocks. *align* positions narrower lines along the cross axis: `:left`
41
+ # (default), `:center`, `:right`, or a fraction between 0.0 and 1.0.
42
+ def join_vertical(*blocks, gap: 0, align: :left)
43
+ normalized = normalize_blocks(blocks)
44
+ width = block_widths(normalized).max || 0
45
+
46
+ normalized.map { |lines| lines.map { |line| align_to(line, width, align) }.join("\n") }
47
+ .join("\n" * (gap + 1))
32
48
  end
33
49
 
34
50
  # Places *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
@@ -63,13 +79,13 @@ module Charming
63
79
 
64
80
  # Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
65
81
  def block_widths(blocks)
66
- blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
82
+ blocks.map { |lines| Width.widest(lines) }
67
83
  end
68
84
 
69
85
  # Returns the maximum visual character width across all *lines*, accounting for multi-column characters
70
86
  # (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
71
87
  def block_width(lines)
72
- lines.map { |line| Width.measure(line) }.max || 0
88
+ Width.widest(lines)
73
89
  end
74
90
 
75
91
  # Returns the height in rows of each normalised block, taking the maximum across all blocks.
@@ -81,9 +97,34 @@ module Charming
81
97
  # every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
82
98
  def horizontal_line(blocks, widths, index)
83
99
  blocks.each_with_index.map do |lines, block_index|
84
- line = lines[index] || ""
85
- line + (" " * (widths[block_index] - Width.measure(line)))
100
+ Width.pad_to(lines[index] || "", widths[block_index])
86
101
  end
87
102
  end
103
+
104
+ # Prepends blank rows to *lines* so the block sits at the cross-axis position
105
+ # given by *align* within *height* total rows.
106
+ def offset_rows(lines, height, align)
107
+ Array.new(cross_offset(align, height - lines.length), "") + lines
108
+ end
109
+
110
+ # Pads *line* to *width*, splitting the slack per the cross-axis *align* position.
111
+ def align_to(line, width, align)
112
+ slack = width - Width.measure(line)
113
+ return line if slack <= 0
114
+
115
+ leading = cross_offset(align, slack)
116
+ (" " * leading) + line + (" " * (slack - leading))
117
+ end
118
+
119
+ # Resolves an alignment (`:top`/`:left` → 0.0, `:center` → 0.5, `:bottom`/`:right` → 1.0,
120
+ # or a fraction) into a whole-cell offset within *slack* spare cells.
121
+ CROSS_POSITIONS = {top: 0.0, left: 0.0, center: 0.5, middle: 0.5, bottom: 1.0, right: 1.0}.freeze
122
+
123
+ def cross_offset(align, slack)
124
+ return 0 if slack <= 0
125
+
126
+ position = CROSS_POSITIONS.fetch(align, align)
127
+ (slack * position.to_f).round.clamp(0, slack)
128
+ end
88
129
  end
89
130
  end
@@ -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.
@@ -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
@@ -16,9 +16,10 @@ 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 = @application.routes.resolve("/")
19
+ @route = resolve_route("/")
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
@@ -27,59 +28,87 @@ module Charming
27
28
  # actions render an ErrorScreen instead of crashing the terminal.
28
29
  def run
29
30
  setup_terminal
30
- install_interrupt_handler
31
+ install_signal_handlers
32
+ install_exit_hook
31
33
  with_raw_input do
32
34
  render(initial_response)
33
- loop do
34
- break if @interrupted
35
-
36
- event = next_task_event || next_timer_event || @backend.read_event(timeout: read_timeout)
37
- unless event
38
- break if backend_exhausted?
39
- next
40
- end
41
- break if process(event) == :quit
42
- end
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
43
41
  end
44
- ensure
45
- restore_interrupt_handler
46
- @task_executor&.shutdown(timeout: 2.0)
47
- @application.save_session if @application.respond_to?(:save_session)
48
- restore_terminal
49
42
  end
50
43
 
51
44
  private
52
45
 
53
46
  attr_reader :screen
54
47
 
55
- # The first frame's response the root route's action, with errors caught.
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
60
+
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.
56
63
  def initial_response
57
- resolve_response(dispatch(@route.action))
64
+ response = nil
65
+ escapes = Escape.collecting { response = resolve_response(dispatch(@route.action)) }
66
+ attach_escapes(response, escapes)
58
67
  rescue => e
59
68
  error_response(e)
60
69
  end
61
70
 
62
71
  # Handles a single event. Returns :quit to stop the loop, nil otherwise.
63
72
  # While an error screen is showing, only key events are honored: q quits,
64
- # any other key dismisses and re-renders the current route.
65
- def process(event)
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)
66
78
  return process_error_event(event) if @error
67
79
  return :quit if unbound_interrupt?(event)
68
80
 
69
- response = dispatch_event(event)
70
- return unless response
71
- return :quit if response.quit?
72
-
73
- response = resolve_response(response)
81
+ response = nil
82
+ escapes = Escape.collecting do
83
+ response = dispatch_event(event)
84
+ response = resolve_response(response) if response
85
+ end
86
+ return flush_pending if response.nil?
74
87
  return :quit if response.quit?
75
88
 
76
- render(response)
77
- nil
89
+ hold_response(response, escapes)
90
+ flush ? flush_pending : nil
78
91
  rescue => e
92
+ @pending_response = nil
79
93
  render(error_response(e))
80
94
  nil
81
95
  end
82
96
 
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
103
+
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
+
83
112
  # Error-mode event handling: q quits, any other key dismisses the error
84
113
  # and re-dispatches the current route's action. Timer/task events are ignored.
85
114
  def process_error_event(event)
@@ -91,13 +120,6 @@ module Charming
91
120
  nil
92
121
  end
93
122
 
94
- # True when the backend reports it has no more events to deliver (test backends
95
- # only — the TTY backend never exhausts). Prevents the loop from spinning forever
96
- # in tests that forget a trailing quit event.
97
- def backend_exhausted?
98
- @backend.respond_to?(:exhausted?) && @backend.exhausted?
99
- end
100
-
101
123
  # True for a Ctrl+C key press that the current controller has no binding for —
102
124
  # the runtime treats it as quit so apps always have an escape hatch. Controllers
103
125
  # can take over by binding "ctrl+c" themselves.
@@ -108,20 +130,66 @@ module Charming
108
130
  @route.controller_class.key_bindings[:"ctrl+c"].nil?
109
131
  end
110
132
 
111
- # Traps SIGINT so Ctrl+C (when delivered as a signal rather than a key) exits the
112
- # loop cleanly through the ensure block instead of crashing mid-frame.
113
- def install_interrupt_handler
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
114
140
  @interrupted = false
115
- @previous_int_handler = Signal.trap("INT") { @interrupted = true }
116
- rescue ArgumentError
117
- @previous_int_handler = nil
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 }
118
151
  end
119
152
 
120
- # Restores the previous SIGINT handler installed before the runtime started.
121
- def restore_interrupt_handler
122
- Signal.trap("INT", @previous_int_handler || "DEFAULT")
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)
123
157
  rescue ArgumentError
124
- nil
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 }
125
193
  end
126
194
 
127
195
  # Records *error*, logs its backtrace, and builds a centered ErrorScreen response.
@@ -208,66 +276,54 @@ module Charming
208
276
  end
209
277
 
210
278
  # Follows navigation responses: resolves the new route from the router,
211
- # 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.
212
281
  def resolve_response(response)
213
282
  return response unless response.navigate?
214
283
 
215
- @route = @application.routes.resolve(response.path)
216
- @timers = build_timers
284
+ @route = resolve_route(response.path)
285
+ @event_loop.reset_timers(@route.controller_class.timer_bindings.values)
217
286
  dispatch(@route.action)
218
287
  end
219
288
 
289
+ # Resolves *path* from the app's router. An unrouted "/" falls back to the app's
290
+ # first route, or to the built-in welcome screen when no routes are defined yet
291
+ # (like Rails' welcome page); other unrouted paths still raise.
292
+ def resolve_route(path)
293
+ @application.routes.resolve(path)
294
+ rescue KeyError
295
+ raise unless path == "/"
296
+
297
+ @application.routes.all.first || Welcome.route
298
+ end
299
+
220
300
  # Derives Screen dimensions (width, height) from the terminal backend.
221
301
  def backend_screen
222
302
  width, height = @backend.size
223
303
  Screen.new(width: width, height: height)
224
304
  end
225
305
 
226
- # Renders the body portion of a response through the renderer.
306
+ # Renders a response: first flushes any out-of-band escape sequences (image transmissions,
307
+ # clipboard writes, notifications, title changes) straight to the backend — ahead of the frame so
308
+ # image data is registered before its placeholder cells reference it — then renders the body.
227
309
  def render(response)
310
+ flush_escapes(response)
228
311
  @renderer.render(response.body)
229
312
  end
230
313
 
231
- # Builds the initial set of timer states from controller bindings and the current clock time.
232
- def build_timers
233
- now = clock_now
234
- @route.controller_class.timer_bindings.values.map do |binding|
235
- {binding: binding, next_at: now + binding.interval}
236
- end
237
- end
238
-
239
- # Returns a TimerEvent for the first due timer and advances its next fire time.
240
- # Returns nil if no timers are ready or registered.
241
- def next_timer_event
242
- timer = due_timer
243
- return unless timer
314
+ # Writes a response's out-of-band escape sequences to the backend, ahead of the frame. No-op for
315
+ # backends that don't support them or responses that carry none.
316
+ def flush_escapes(response)
317
+ return unless @backend.respond_to?(:write_escape)
244
318
 
245
- now = clock_now
246
- timer[:next_at] = now + timer.fetch(:binding).interval
247
- Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
319
+ response.escapes&.each { |sequence| @backend.write_escape(sequence) }
248
320
  end
249
321
 
250
- # Pops a task event from the thread-safe queue if one is available.
251
- # Non-blocking — returns nil immediately when the queue is empty.
252
- def next_task_event
253
- @task_queue.pop(true)
254
- rescue ThreadError
255
- nil
256
- end
257
-
258
- # Returns timer values due at or before `now`, sorted by next fire time.
259
- def due_timer
260
- now = clock_now
261
- @timers.select { |timer| timer.fetch(:next_at) <= now }.min_by { |timer| timer.fetch(:next_at) }
262
- end
263
-
264
- # Computes how long to block waiting for input based on when the next timer is due,
265
- # clamped between 0 and DEFAULT_READ_TIMEOUT (0.05s). Returns DEFAULT when no timers exist.
266
- def read_timeout
267
- next_at = @timers.map { |timer| timer.fetch(:next_at) }.min
268
- return DEFAULT_READ_TIMEOUT unless next_at
322
+ # Returns *response* with *escapes* appended, or unchanged when none were collected.
323
+ def attach_escapes(response, escapes)
324
+ return response if escapes.nil? || escapes.empty?
269
325
 
270
- (next_at - clock_now).clamp(0, DEFAULT_READ_TIMEOUT)
326
+ response.with(escapes: response.escapes + escapes)
271
327
  end
272
328
 
273
329
  # Constructs a task executor: supports explicit instances, callable factories, or the default Threaded executor.
@@ -285,16 +341,33 @@ module Charming
285
341
  end
286
342
 
287
343
  # Enters an alternative screen buffer, hides the cursor, and installs
288
- # a terminal resize signal handler if supported by the backend.
344
+ # a terminal resize signal handler if supported by the backend. Also asks
345
+ # the terminal for its background color so adaptive colors resolve correctly.
289
346
  def setup_terminal
347
+ detect_background
290
348
  @backend.enter_alt_screen
291
349
  @backend.hide_cursor
292
- @backend.enable_mouse_tracking if @backend.respond_to?(:enable_mouse_tracking)
350
+ @backend.enable_mouse_tracking(motion: mouse_motion) if @backend.respond_to?(:enable_mouse_tracking)
293
351
  @backend.enable_bracketed_paste if @backend.respond_to?(:enable_bracketed_paste)
294
352
  @backend.enable_focus_reporting if @backend.respond_to?(:enable_focus_reporting)
295
353
  @backend.install_resize_handler if @backend.respond_to?(:install_resize_handler)
296
354
  end
297
355
 
356
+ # The app's configured mouse motion mode (:drag unless the app opts into :all hover).
357
+ def mouse_motion
358
+ @application.respond_to?(:mouse_motion) ? @application.mouse_motion : :drag
359
+ end
360
+
361
+ # Feeds the terminal's OSC 11 background reply (when the backend can obtain
362
+ # one) into UI::Background so adaptive colors resolve against reality rather
363
+ # than the dark-background default.
364
+ def detect_background
365
+ return unless @backend.respond_to?(:query_background_color)
366
+
367
+ background = @backend.query_background_color
368
+ UI::Background.assume = background if background
369
+ end
370
+
298
371
  # Keeps input raw/no-echo across rendering and dispatch, not just during reads.
299
372
  def with_raw_input(&block)
300
373
  return yield unless @backend.respond_to?(:with_raw_input)
@@ -303,8 +376,12 @@ module Charming
303
376
  end
304
377
 
305
378
  # Restores terminal state: reinstalls any previous resize handler, shows
306
- # the cursor, and leaves the alternative screen buffer.
379
+ # the cursor, and leaves the alternative screen buffer. Idempotent — runs
380
+ # once whether reached from run's ensure or the at_exit fallback.
307
381
  def restore_terminal
382
+ return if @restored
383
+
384
+ @restored = true
308
385
  @backend.restore_resize_handler if @backend.respond_to?(:restore_resize_handler)
309
386
  @backend.disable_focus_reporting if @backend.respond_to?(:disable_focus_reporting)
310
387
  @backend.disable_bracketed_paste if @backend.respond_to?(:disable_bracketed_paste)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.3"
5
5
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Welcome
5
+ # Controller for the built-in welcome screen. Renders without an app layout and
6
+ # binds only `q` to quit.
7
+ class Controller < Charming::Controller
8
+ key "q", :quit, scope: :global
9
+
10
+ def show
11
+ render_view ShowView, app_name: app_display_name
12
+ end
13
+
14
+ private
15
+
16
+ # The app's namespace for the welcome heading, or "Charming" for anonymous apps.
17
+ def app_display_name
18
+ name = application.class.namespace
19
+ name.to_s.empty? ? "Charming" : name
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Welcome
5
+ # The welcome screen body: a pastel Miami-deco skyline over the app name, tagline,
6
+ # Charming version, and project link, centered on the terminal.
7
+ class ShowView < Charming::View
8
+ SKYLINE_COLORS = {
9
+ "F" => "#f0437c", # flamingo
10
+ "B" => "#f9cfd8", # blush
11
+ "M" => "#bfe8dc", # mint
12
+ "C" => "#f3e6c9", # cream
13
+ "P" => "#fdfaf3", # plaster
14
+ "G" => "#1d4b44" # palm green
15
+ }.freeze
16
+
17
+ # Rows of [art, color mask] pairs; each mask letter colors the glyph above it.
18
+ SKYLINE = [
19
+ [" ▲ ",
20
+ " F "],
21
+ [" █ ",
22
+ " F "],
23
+ [" ▄▄▄ ▄█▄ ▄▄▄ ",
24
+ " BBB PFP MMM "],
25
+ [" ███ ▐███▌ ███ ",
26
+ " BBB PPPPP MMM "],
27
+ [" █▀▀▀█ ▐███▌ █▀▀▀█ ",
28
+ " BBBBB PPPPP MMMMM "],
29
+ [" ▄█▄ █████ ▐█████▌ █████ ▄█▄ ",
30
+ " GGG BBBBB PPPPPPP MMMMM GGG "],
31
+ [" █ ▄▄▄ ██▀▀▀██ ▐█████▌ ██▀▀▀██ ▄▄▄ █ ",
32
+ " G CCC BBBBBBB PPPPPPP MMMMMMM CCC G "],
33
+ [" █ ███ ███████ ▐███████▌ ███████ ███ █ ",
34
+ " G CCC BBBBBBB PPPPPPPPP MMMMMMM CCC G "],
35
+ [" █ ███ ███████ ▐███████▌ ███████ ███ █ ",
36
+ " G CCC BBBBBBB PPPPPPPPP MMMMMMM CCC G "],
37
+ ["▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁",
38
+ "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"]
39
+ ].freeze
40
+ SKYLINE_WIDTH = SKYLINE.map { |art, _mask| art.length }.max
41
+ SKYLINE_HEIGHT = SKYLINE.length
42
+
43
+ def render
44
+ screen_layout(background: theme.background) do
45
+ pane(style: centered) do
46
+ welcome_content
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def centered
54
+ style.align(:center).align_vertical(:middle)
55
+ end
56
+
57
+ def welcome_content
58
+ column(*blocks, gap: 1, align: :center)
59
+ end
60
+
61
+ def blocks
62
+ [skyline, heading, tagline, footer, hint].compact
63
+ end
64
+
65
+ def skyline
66
+ return unless skyline_fits?
67
+
68
+ column(*SKYLINE.map { |art, mask| paint_row(art, mask) })
69
+ end
70
+
71
+ # Leave room below the art for the heading, tagline, footer, and hint lines.
72
+ def skyline_fits?
73
+ layout_screen.width >= SKYLINE_WIDTH && layout_screen.height >= SKYLINE_HEIGHT + 8
74
+ end
75
+
76
+ def paint_row(art, mask)
77
+ color_runs(art, mask).map { |run, key| paint(run, key) }.join
78
+ end
79
+
80
+ # Groups adjacent glyphs sharing a mask letter into [run, letter] pairs.
81
+ def color_runs(art, mask)
82
+ art.chars.zip(mask.chars)
83
+ .chunk { |_glyph, key| key || " " }
84
+ .map { |key, pairs| [pairs.map(&:first).join, key] }
85
+ end
86
+
87
+ def paint(run, key)
88
+ color = SKYLINE_COLORS[key]
89
+ color ? style.foreground(color).render(run) : run
90
+ end
91
+
92
+ def heading
93
+ text app_name, style: theme.title
94
+ end
95
+
96
+ def tagline
97
+ text "A Rails-inspired Ruby TUI framework", style: theme.text
98
+ end
99
+
100
+ def footer
101
+ column(
102
+ text("Charming v#{Charming::VERSION}", style: theme.muted),
103
+ text("https://github.com/pandorocks/charming", style: theme.muted),
104
+ align: :center
105
+ )
106
+ end
107
+
108
+ def hint
109
+ text "Define a root route in config/routes.rb to replace this screen.", style: theme.muted
110
+ end
111
+ end
112
+ end
113
+ end