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.
- checksums.yaml +4 -4
- data/README.md +16 -3
- data/lib/charming/application.rb +48 -0
- data/lib/charming/cli.rb +16 -4
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +13 -40
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_file_generator.rb +29 -0
- data/lib/charming/generators/app_generator.rb +13 -25
- data/lib/charming/generators/base.rb +5 -0
- data/lib/charming/generators/layout_generator.rb +155 -0
- data/lib/charming/generators/screen_generator.rb +0 -29
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/README.md.template +15 -1
- data/lib/charming/generators/templates/app/application.template +0 -6
- data/lib/charming/generators/templates/app/application_controller.template +0 -10
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +4 -93
- data/lib/charming/generators/templates/app/routes.template +3 -1
- data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
- data/lib/charming/image/protocol/kitty.rb +133 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +95 -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 +21 -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 +79 -2
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/autocomplete.rb +7 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/error_screen.rb +1 -1
- 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/field.rb +5 -0
- data/lib/charming/presentation/components/form/input.rb +14 -4
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form/textarea.rb +18 -8
- data/lib/charming/presentation/components/form.rb +6 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/modal.rb +4 -4
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -4
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/table.rb +42 -1
- data/lib/charming/presentation/components/text_area.rb +3 -4
- data/lib/charming/presentation/components/text_input.rb +11 -6
- 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 +4 -3
- data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
- data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
- data/lib/charming/presentation/components/viewport.rb +11 -1
- data/lib/charming/presentation/markdown/renderer.rb +1 -1
- data/lib/charming/presentation/markdown/style_config.rb +1 -0
- data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
- 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/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +11 -0
- 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/runtime.rb +167 -90
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome/controller.rb +23 -0
- data/lib/charming/welcome/show_view.rb +113 -0
- data/lib/charming/welcome.rb +13 -0
- metadata +34 -9
- data/lib/charming/generators/templates/app/home_controller.template +0 -6
- data/lib/charming/generators/templates/app/home_state.template +0 -7
- data/lib/charming/generators/templates/app/spec_controller.template +0 -17
- data/lib/charming/generators/templates/app/spec_state.template +0 -17
- data/lib/charming/generators/templates/app/spec_view.template +0 -16
- 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
|
-
|
|
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(
|
|
24
|
-
horizontal_line(
|
|
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
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/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
|
|
@@ -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 =
|
|
19
|
+
@route = resolve_route("/")
|
|
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
|
|
@@ -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
|
-
|
|
31
|
+
install_signal_handlers
|
|
32
|
+
install_exit_hook
|
|
31
33
|
with_raw_input do
|
|
32
34
|
render(initial_response)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
112
|
-
#
|
|
113
|
-
|
|
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
|
-
@
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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 =
|
|
216
|
-
@
|
|
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
|
|
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
|
-
#
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
@
|
|
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
|
-
|
|
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
|
-
#
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
(
|
|
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)
|
data/lib/charming/version.rb
CHANGED
|
@@ -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
|