charming 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/charming/application.rb +48 -0
- 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 +12 -39
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_generator.rb +3 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +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/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/multiselect.rb +105 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -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 +1 -2
- data/lib/charming/presentation/components/text_input.rb +9 -4
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- 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/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 +154 -88
- data/lib/charming/version.rb +1 -1
- metadata +29 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71b370eac77c67126921d7347a0d23f834f23774626c111445e9abdef73555d9
|
|
4
|
+
data.tar.gz: c8dd983f46183c2fbdb22f9c30753b4f1443d8de2989a4e7f90016f397aa12e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ee80f1c214001c8f91b36ba308f0e2f67aecbc258e7ff3ce63de2eb04093a405a0a23c3174cac6c3a1c6a98746b5353e66dd8179aebab4fe60ad35132603f19
|
|
7
|
+
data.tar.gz: 5242fbc9ad41ca1c0dbebbb070bb33cbdd1279955f81aa2f067954a1b11ffdd1b9cb7b90321285fa17e795c31d3fff32c7e052e95dee7e7c90318e19d252fba1
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A Rails-inspired terminal user interface framework for **Ruby 4+**.
|
|
4
4
|
|
|
5
|
-
Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, background tasks, cross-platform audio playback, and testable terminal backends.
|
|
5
|
+
Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, background tasks, cross-platform audio playback, inline image display (Kitty graphics protocol), system clipboard / desktop notifications / window title, braille charts and sparklines, and testable terminal backends.
|
|
6
6
|
|
|
7
7
|
## Project Status
|
|
8
8
|
|
data/lib/charming/application.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Charming
|
|
|
10
10
|
class Application
|
|
11
11
|
LOGGER_READER = Object.new.freeze
|
|
12
12
|
THEME_READER = Object.new.freeze
|
|
13
|
+
COALESCE_READER = Object.new.freeze
|
|
13
14
|
|
|
14
15
|
class << self
|
|
15
16
|
# Registers or returns the app's Router. Accepts an optional block to define
|
|
@@ -108,6 +109,27 @@ module Charming
|
|
|
108
109
|
nil
|
|
109
110
|
end
|
|
110
111
|
|
|
112
|
+
# When true, the runtime collapses bursts of identical key events — the flood a terminal
|
|
113
|
+
# emits while a key is held (OS auto-repeat) — into a single dispatch, so holding a key
|
|
114
|
+
# can't queue a backlog that keeps acting after release. Off by default: it also merges
|
|
115
|
+
# intentional fast repeats of the same key (e.g. tab tab), so enable it only for
|
|
116
|
+
# movement-style apps. Pass a boolean to set; call without args to read (inherited).
|
|
117
|
+
def coalesce_input(value = COALESCE_READER)
|
|
118
|
+
return configured_coalesce_input if value == COALESCE_READER
|
|
119
|
+
|
|
120
|
+
@coalesce_input = value
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The mouse motion reporting mode: :drag (default) reports movement only while
|
|
124
|
+
# a button is held; :all also reports buttonless hover movement. Pass a symbol
|
|
125
|
+
# to set; call without args to read (inherited).
|
|
126
|
+
def mouse_motion(value = COALESCE_READER)
|
|
127
|
+
return configured_mouse_motion if value == COALESCE_READER
|
|
128
|
+
raise ArgumentError, "mouse_motion must be :drag or :all" unless %i[drag all].include?(value)
|
|
129
|
+
|
|
130
|
+
@mouse_motion = value
|
|
131
|
+
end
|
|
132
|
+
|
|
111
133
|
private
|
|
112
134
|
|
|
113
135
|
def configured_logger
|
|
@@ -117,6 +139,20 @@ module Charming
|
|
|
117
139
|
@logger = Logger.new(File::NULL)
|
|
118
140
|
end
|
|
119
141
|
|
|
142
|
+
def configured_coalesce_input
|
|
143
|
+
return @coalesce_input if instance_variable_defined?(:@coalesce_input)
|
|
144
|
+
return superclass.coalesce_input if superclass.respond_to?(:coalesce_input)
|
|
145
|
+
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def configured_mouse_motion
|
|
150
|
+
return @mouse_motion if instance_variable_defined?(:@mouse_motion)
|
|
151
|
+
return superclass.mouse_motion if superclass.respond_to?(:mouse_motion)
|
|
152
|
+
|
|
153
|
+
:drag
|
|
154
|
+
end
|
|
155
|
+
|
|
120
156
|
# Expands a relative theme path against the app root (or the current working directory
|
|
121
157
|
# when no root is configured). Returns *path* unchanged when it is already absolute.
|
|
122
158
|
def resolve_theme_path(path)
|
|
@@ -163,6 +199,18 @@ module Charming
|
|
|
163
199
|
session[:theme] = name.to_sym
|
|
164
200
|
end
|
|
165
201
|
|
|
202
|
+
# Whether the runtime should coalesce held-key auto-repeat for this app (see the class-level
|
|
203
|
+
# `coalesce_input` DSL). Read by the Runtime at startup.
|
|
204
|
+
def coalesce_input?
|
|
205
|
+
self.class.coalesce_input == true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The mouse motion reporting mode for this app (see the class-level `mouse_motion`
|
|
209
|
+
# DSL). Read by the Runtime at startup.
|
|
210
|
+
def mouse_motion
|
|
211
|
+
self.class.mouse_motion
|
|
212
|
+
end
|
|
213
|
+
|
|
166
214
|
private
|
|
167
215
|
|
|
168
216
|
# Loads the persisted session JSON (symbolizing keys), or {} when absent/invalid.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# KeyDispatch resolves which handler claims a key event — the capture/bubble
|
|
6
|
+
# ladder a browser would own in a web MVC app. Priority order:
|
|
7
|
+
#
|
|
8
|
+
# 1. Command palette (when open) consumes everything.
|
|
9
|
+
# 2. A focused text-capturing component (TextInput, TextArea, Form, …) gets
|
|
10
|
+
# printable characters BEFORE key bindings — typing "q" into a field must
|
|
11
|
+
# insert a q, not quit the app.
|
|
12
|
+
# 3. Global key bindings.
|
|
13
|
+
# 4. An overlay focus scope (a pushed modal) captures all remaining keys.
|
|
14
|
+
# 5. Sidebar keys (when focused), content bindings, then the focused component —
|
|
15
|
+
# which sees Tab before ring traversal so forms can do field navigation.
|
|
16
|
+
#
|
|
17
|
+
# Tiers differ in how they terminate: the palette, overlay, sidebar, and binding
|
|
18
|
+
# tiers consume the key outright once their condition holds, while the component
|
|
19
|
+
# tiers only consume it when the component reports :handled and otherwise let it
|
|
20
|
+
# fall through to the next tier.
|
|
21
|
+
class KeyDispatch
|
|
22
|
+
def initialize(controller)
|
|
23
|
+
@controller = controller
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs the ladder for the controller's current event. Returns the winning
|
|
27
|
+
# tier's response, or nil when no tier claims the key.
|
|
28
|
+
def call
|
|
29
|
+
return palette_response if palette_open?
|
|
30
|
+
return response if typed_text_claimed?
|
|
31
|
+
return binding_response(global_action) if global_action
|
|
32
|
+
return overlay_response if overlay?
|
|
33
|
+
return sidebar_response if sidebar_focused?
|
|
34
|
+
return binding_response(content_action) if content_action
|
|
35
|
+
return response if component_or_ring_claimed?
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :controller
|
|
43
|
+
|
|
44
|
+
def palette_open?
|
|
45
|
+
controller.command_palette_open?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def palette_response
|
|
49
|
+
controller.send(:dispatch_command_palette_key)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# True when a printable character was typed into a focused text-capturing
|
|
53
|
+
# component and the component consumed it.
|
|
54
|
+
def typed_text_claimed?
|
|
55
|
+
controller.send(:printable_text_event?) &&
|
|
56
|
+
controller.send(:focused_component_captures_text?) &&
|
|
57
|
+
component_claimed?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def global_action
|
|
61
|
+
controller.send(:global_key_action)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def content_action
|
|
65
|
+
controller.send(:content_key_action)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def binding_response(action)
|
|
69
|
+
controller.dispatch(action)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def overlay?
|
|
73
|
+
controller.focus.overlay?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# An overlay consumes the key whether or not the component handled it.
|
|
77
|
+
def overlay_response
|
|
78
|
+
controller.send(:dispatch_to_focused_component)
|
|
79
|
+
response
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def sidebar_focused?
|
|
83
|
+
controller.sidebar_focused?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def sidebar_response
|
|
87
|
+
controller.send(:dispatch_sidebar_key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Text-capturing components own their remaining keys — Tab included, so forms
|
|
91
|
+
# do field navigation. Everything else keeps ring traversal ahead of the component.
|
|
92
|
+
def component_or_ring_claimed?
|
|
93
|
+
if controller.send(:focused_component_captures_text?)
|
|
94
|
+
component_claimed? || ring_claimed?
|
|
95
|
+
else
|
|
96
|
+
ring_claimed? || component_claimed?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def component_claimed?
|
|
101
|
+
controller.send(:dispatch_to_focused_component) == :handled
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ring_claimed?
|
|
105
|
+
controller.send(:dispatch_tab_traversal) == :handled
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def response
|
|
109
|
+
controller.send(:response)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -30,6 +30,17 @@ module Charming
|
|
|
30
30
|
session[:states][name.to_sym] ||= state_class.new(**attributes)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Returns the named mutable widget-state hash stored under `session[:component_state]`,
|
|
34
|
+
# seeding it from *defaults* on first access. This is the blessed way to keep an interactive
|
|
35
|
+
# component's state across ephemeral controller instances: store only JSON-safe primitives,
|
|
36
|
+
# rebuild the component from the hash each event, and write changed values back after
|
|
37
|
+
# `handle_key`. (Live component objects don't belong in the session — `save_session` drops
|
|
38
|
+
# anything that can't survive a JSON round-trip.)
|
|
39
|
+
def component_state(name, **defaults)
|
|
40
|
+
session[:component_state] ||= {}
|
|
41
|
+
session[:component_state][name.to_sym] ||= defaults
|
|
42
|
+
end
|
|
43
|
+
|
|
33
44
|
# Builds a Form component scoped to the named form slot in `session[:forms]`. The block is
|
|
34
45
|
# evaluated against a Form::Builder (or invoked with the builder as its argument for arity-1 blocks)
|
|
35
46
|
# and returns a Form component pre-bound to the per-form mutable state hash.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Terminal mixes imperative out-of-band terminal effects into Controller: writing the system
|
|
6
|
+
# clipboard, raising desktop notifications, ringing the bell, and setting the window title. Each
|
|
7
|
+
# registers an {Charming::Escape} sequence onto the dispatch's collection; the Runtime flushes it
|
|
8
|
+
# to the backend before the next frame. They compose freely with a normal `render` — e.g.
|
|
9
|
+
# `def copy_url; copy(state.url); notify("Copied!"); render(:show); end`.
|
|
10
|
+
module Terminal
|
|
11
|
+
# Writes *text* to the system clipboard (OSC 52). *target* selects the selection (`"c"`
|
|
12
|
+
# clipboard, `"p"` primary). Works across Ghostty/Kitty/iTerm2 (and tmux with passthrough).
|
|
13
|
+
def copy(text, target: "c")
|
|
14
|
+
Escape.register(Escape.clipboard(text, target: target))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raises a desktop notification showing *body* (and *title* when given).
|
|
18
|
+
def notify(body, title: nil)
|
|
19
|
+
Escape.register(Escape.notification(body, title: title))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Rings the terminal bell.
|
|
23
|
+
def bell
|
|
24
|
+
Escape.register(Escape.bell)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Sets the terminal window/tab title to *text* (OSC 0).
|
|
28
|
+
def set_title(text)
|
|
29
|
+
Escape.register(Escape.title(text))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/charming/controller.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Charming
|
|
|
17
17
|
include CommandPalette
|
|
18
18
|
include ComponentDispatching
|
|
19
19
|
include Dispatching
|
|
20
|
+
include Terminal
|
|
20
21
|
|
|
21
22
|
attr_reader :application, :event, :params, :screen, :route
|
|
22
23
|
|
|
@@ -39,51 +40,21 @@ module Charming
|
|
|
39
40
|
response || render("")
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Key event dispatch
|
|
43
|
-
#
|
|
44
|
-
# 2. A focused text-capturing component (TextInput, TextArea, Form, …) gets
|
|
45
|
-
# printable characters BEFORE key bindings — typing "q" into a field must
|
|
46
|
-
# insert a q, not quit the app.
|
|
47
|
-
# 3. Global key bindings.
|
|
48
|
-
# 4. An overlay focus scope (a pushed modal) captures all remaining keys.
|
|
49
|
-
# 5. Sidebar keys (when focused), content bindings, then the focused component —
|
|
50
|
-
# which sees Tab before ring traversal so forms can do field navigation.
|
|
43
|
+
# Key event dispatch. The precedence ladder (palette → focused text capture →
|
|
44
|
+
# global bindings → overlay → sidebar/content/component) lives in KeyDispatch.
|
|
51
45
|
def dispatch_key
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if printable_text_event? && focused_component_captures_text?
|
|
55
|
-
return response if dispatch_to_focused_component == :handled
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
return dispatch(global_key_action) if global_key_action
|
|
59
|
-
|
|
60
|
-
if focus.overlay?
|
|
61
|
-
dispatch_to_focused_component
|
|
62
|
-
return response
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
return dispatch_sidebar_key if sidebar_focused?
|
|
66
|
-
return dispatch(content_key_action) if content_key_action
|
|
67
|
-
|
|
68
|
-
# Text-capturing components (forms, editors) own their remaining keys — Tab
|
|
69
|
-
# included, so forms do field navigation. Everything else keeps ring traversal
|
|
70
|
-
# ahead of the component.
|
|
71
|
-
if focused_component_captures_text?
|
|
72
|
-
return response if dispatch_to_focused_component == :handled
|
|
73
|
-
return response if dispatch_tab_traversal == :handled
|
|
74
|
-
else
|
|
75
|
-
return response if dispatch_tab_traversal == :handled
|
|
76
|
-
return response if dispatch_to_focused_component == :handled
|
|
77
|
-
end
|
|
78
|
-
nil
|
|
46
|
+
KeyDispatch.new(self).call
|
|
79
47
|
end
|
|
80
48
|
|
|
81
|
-
# Timer event dispatcher: looks up the named action in timer bindings
|
|
49
|
+
# Timer event dispatcher: looks up the named action in timer bindings and runs it
|
|
50
|
+
# with the full hook chain. Unlike #dispatch there is no render("") fallback — a
|
|
51
|
+
# timer action that renders nothing yields a nil response, so silent ticks skip
|
|
52
|
+
# the repaint instead of blanking the screen.
|
|
82
53
|
def dispatch_timer
|
|
83
54
|
b = self.class.timer_bindings[event.name.to_sym]
|
|
84
55
|
return nil unless b
|
|
85
56
|
|
|
86
|
-
|
|
57
|
+
run_action_with_hooks(b.action)
|
|
87
58
|
response
|
|
88
59
|
end
|
|
89
60
|
|
|
@@ -126,7 +97,9 @@ module Charming
|
|
|
126
97
|
dispatch_component_mouse
|
|
127
98
|
end
|
|
128
99
|
|
|
129
|
-
# Renders a body or template wrapped in the controller's layout.
|
|
100
|
+
# Renders a body or template wrapped in the controller's layout. Out-of-band escape sequences
|
|
101
|
+
# registered while rendering (e.g. image transmissions) are collected by the Runtime around the
|
|
102
|
+
# whole dispatch and attached to the response.
|
|
130
103
|
def render(body = "", **assigns)
|
|
131
104
|
body = view_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
|
|
132
105
|
@response = Response.render(render_with_layout(body))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Escape is the out-of-band terminal channel: escape sequences written straight to the terminal,
|
|
5
|
+
# *before* each frame, bypassing the line-based renderer (which measures width and would shred raw
|
|
6
|
+
# control sequences). It is the shared substrate for several primitives — image transmissions
|
|
7
|
+
# ({Charming::Image::Transmit}), clipboard writes, desktop notifications, and the window title.
|
|
8
|
+
#
|
|
9
|
+
# Sequences are gathered during an event's dispatch via a thread-local collector ({collecting} /
|
|
10
|
+
# {register}) and attached to the {Response}; the {Runtime} flushes them through
|
|
11
|
+
# `backend.write_escape`. Any object that responds to `#payload` (a string) can ride the channel;
|
|
12
|
+
# the builders here return {Sequence} value objects and sanitize interpolated text so user content
|
|
13
|
+
# can't break out of the sequence.
|
|
14
|
+
module Escape
|
|
15
|
+
# A single out-of-band sequence. *payload* is the literal escape string written to the terminal.
|
|
16
|
+
Sequence = Data.define(:payload)
|
|
17
|
+
|
|
18
|
+
# Thread-local key under which the active collection bucket is stored.
|
|
19
|
+
BUCKET_KEY = :charming_escape_bucket
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Runs *block* with a fresh collection bucket active and returns the bucket — the {Sequence}s
|
|
23
|
+
# registered via {register} during the block. The block's own return value is ignored (capture
|
|
24
|
+
# it via a closure). Nesting is supported: an inner collection shadows the outer.
|
|
25
|
+
def collecting
|
|
26
|
+
previous = Thread.current[BUCKET_KEY]
|
|
27
|
+
bucket = []
|
|
28
|
+
Thread.current[BUCKET_KEY] = bucket
|
|
29
|
+
yield
|
|
30
|
+
bucket
|
|
31
|
+
ensure
|
|
32
|
+
Thread.current[BUCKET_KEY] = previous
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Registers *sequence* with the active collection bucket so it is flushed to the backend. A
|
|
36
|
+
# no-op (outside a collection, or when *sequence* is nil) so callers can register freely.
|
|
37
|
+
def register(sequence)
|
|
38
|
+
return sequence unless sequence
|
|
39
|
+
|
|
40
|
+
Thread.current[BUCKET_KEY]&.push(sequence)
|
|
41
|
+
sequence
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds an OSC 52 clipboard-write sequence setting the *target* selection (`"c"` clipboard,
|
|
45
|
+
# `"p"` primary) to *text*. The text is base64-encoded, so any bytes are safe.
|
|
46
|
+
def clipboard(text, target: "c")
|
|
47
|
+
Sequence.new(payload: "\e]52;#{target};#{[text.to_s].pack("m0")}\a")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds a desktop-notification sequence: OSC 777 (`title` + `body`) when a *title* is given,
|
|
51
|
+
# else OSC 9 (body only) — the broadly supported baseline (Ghostty, iTerm2, Kitty).
|
|
52
|
+
def notification(body, title: nil)
|
|
53
|
+
payload =
|
|
54
|
+
if title
|
|
55
|
+
"\e]777;notify;#{sanitize(title)};#{sanitize(body)}\e\\"
|
|
56
|
+
else
|
|
57
|
+
"\e]9;#{sanitize(body)}\a"
|
|
58
|
+
end
|
|
59
|
+
Sequence.new(payload: payload)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Builds a terminal-bell sequence (BEL).
|
|
63
|
+
def bell
|
|
64
|
+
Sequence.new(payload: "\a")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Builds an OSC 0 sequence setting the terminal window (and icon) title to *text*.
|
|
68
|
+
def title(text)
|
|
69
|
+
Sequence.new(payload: "\e]0;#{sanitize(text)}\a")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Strips C0 control characters (incl. ESC and BEL) so interpolated user text can't terminate
|
|
75
|
+
# or inject into the surrounding escape sequence.
|
|
76
|
+
def sanitize(text)
|
|
77
|
+
text.to_s.gsub(/[\x00-\x1f\x7f]/, "")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -10,11 +10,12 @@ module Charming
|
|
|
10
10
|
}.freeze
|
|
11
11
|
private_constant :MOUSE_BUTTON_MAP
|
|
12
12
|
|
|
13
|
-
# MouseEvent represents a mouse input event. *button* encodes which button or action was
|
|
14
|
-
# right, scroll), while *x* and *y* provide the cursor position. Modifier
|
|
15
|
-
# capture key state at the time of the event.
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
# MouseEvent represents a mouse input event. *button* encodes which button or action was
|
|
14
|
+
# triggered (left, right, scroll), while *x* and *y* provide the cursor position. Modifier
|
|
15
|
+
# booleans (*ctrl*, *alt*, *shift*) capture key state at the time of the event. *motion*
|
|
16
|
+
# marks drag/hover movement and *release* marks an SGR button release.
|
|
17
|
+
MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift, :motion, :release) do
|
|
18
|
+
def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false, motion: false, release: false)
|
|
18
19
|
super
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -23,9 +24,9 @@ module Charming
|
|
|
23
24
|
MOUSE_BUTTON_MAP.fetch(button, :unknown)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
# Returns `true`
|
|
27
|
+
# Returns `true` for a button press (left, middle, or right) — not a release, drag, or hover.
|
|
27
28
|
def click?
|
|
28
|
-
%i[left middle right].include?(button_name)
|
|
29
|
+
!motion && !release? && %i[left middle right].include?(button_name)
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
# Returns `true` when the button name maps to either direction of scroll.
|
|
@@ -33,9 +34,21 @@ module Charming
|
|
|
33
34
|
%i[scroll_up scroll_down].include?(button_name)
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
# Returns `true`
|
|
37
|
+
# Returns `true` for a button release: the SGR release marker, or the legacy
|
|
38
|
+
# button-3 release code on a non-motion event. (During motion, button 3
|
|
39
|
+
# means "no button held", not a release.)
|
|
37
40
|
def release?
|
|
38
|
-
button_name == :release
|
|
41
|
+
release || (!motion && button_name == :release)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns `true` for any mouse movement report (drag or hover).
|
|
45
|
+
def motion?
|
|
46
|
+
motion
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns `true` when the mouse moved with a button held down.
|
|
50
|
+
def drag?
|
|
51
|
+
motion && %i[left middle right].include?(button_name)
|
|
39
52
|
end
|
|
40
53
|
end
|
|
41
54
|
end
|
|
@@ -13,6 +13,7 @@ module Charming
|
|
|
13
13
|
BASE_FILE_TEMPLATES = [
|
|
14
14
|
["Gemfile", "app/Gemfile.template", false],
|
|
15
15
|
["Rakefile", "app/Rakefile.template", false],
|
|
16
|
+
[".rspec", "app/dot_rspec.template", false],
|
|
16
17
|
["README.md", "app/README.md.template", false],
|
|
17
18
|
["%<name>s.gemspec", "app/gemspec.template", false],
|
|
18
19
|
["exe/%<name>s", "app/executable.template", true],
|
|
@@ -183,10 +184,10 @@ module Charming
|
|
|
183
184
|
|
|
184
185
|
# The body of the home controller's private `home` helper, prefixed by a blank line.
|
|
185
186
|
def controller_helpers
|
|
186
|
-
"\n\n private\n" \
|
|
187
|
+
"\n\n private\n\n" \
|
|
187
188
|
" def home\n" \
|
|
188
189
|
" state(:home, HomeState)\n" \
|
|
189
|
-
" end"
|
|
190
|
+
" end\n"
|
|
190
191
|
end
|
|
191
192
|
|
|
192
193
|
# The `require_relative "../config/database"` line when the app is database-configured.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Image
|
|
5
|
+
module Protocol
|
|
6
|
+
# Kitty encodes images for the Kitty graphics protocol using *Unicode placeholders* (virtual
|
|
7
|
+
# placement) — the technique that lets a cell-based TUI display images without the raw image
|
|
8
|
+
# escapes ever entering the width-measured, line-diffed frame.
|
|
9
|
+
#
|
|
10
|
+
# Two halves:
|
|
11
|
+
#
|
|
12
|
+
# * {transmit} builds the out-of-band escape (APC `\e_G…\e\`) that ships the PNG bytes and
|
|
13
|
+
# creates a virtual placement sized to rows×cols. It is base64-encoded and chunked to
|
|
14
|
+
# ≤4096 bytes per the protocol, and uses `q=2` to suppress terminal responses (which would
|
|
15
|
+
# otherwise leak into the input stream). This must NOT go through the frame pipeline — the
|
|
16
|
+
# width layer's Fe-escape rule strips the `\e_` introducer and would corrupt it.
|
|
17
|
+
#
|
|
18
|
+
# * {placeholder_block} builds the in-frame cells: each is the placeholder code point
|
|
19
|
+
# {PLACEHOLDER} plus a row and a column diacritic ({DIACRITICS}); the image id is carried in
|
|
20
|
+
# the cells' foreground colour. The colour is a hand-built, *exact* truecolour SGR — it must
|
|
21
|
+
# never be routed through {Charming::UI} styling, whose colour downconversion would mangle
|
|
22
|
+
# the id on non-truecolour terminals.
|
|
23
|
+
module Kitty
|
|
24
|
+
# The Kitty image placeholder code point. Renders as a width-1 cell that the terminal
|
|
25
|
+
# replaces with image pixels; combining diacritics on it encode the cell's row/column.
|
|
26
|
+
PLACEHOLDER = [0x10EEEE].pack("U")
|
|
27
|
+
|
|
28
|
+
# Maximum base64 payload bytes per APC chunk, per the Kitty graphics protocol.
|
|
29
|
+
CHUNK_SIZE = 4096
|
|
30
|
+
|
|
31
|
+
# The ordered combining diacritics that encode a cell's row/column index (the value at
|
|
32
|
+
# index N marks position N). Sourced verbatim from Kitty's `rowcolumn-diacritics.txt`.
|
|
33
|
+
DIACRITIC_CODEPOINTS = [
|
|
34
|
+
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C,
|
|
35
|
+
0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
|
|
36
|
+
0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592,
|
|
37
|
+
0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
|
|
38
|
+
0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
|
|
39
|
+
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
|
|
40
|
+
0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB,
|
|
41
|
+
0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
|
|
42
|
+
0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3,
|
|
43
|
+
0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
|
44
|
+
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954,
|
|
45
|
+
0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
|
|
46
|
+
0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71,
|
|
47
|
+
0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
|
|
48
|
+
0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
|
|
49
|
+
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
|
|
50
|
+
0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7,
|
|
51
|
+
0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
|
|
52
|
+
0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE,
|
|
53
|
+
0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
|
54
|
+
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1,
|
|
55
|
+
0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
|
|
56
|
+
0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1,
|
|
57
|
+
0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187,
|
|
58
|
+
0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# The diacritics as encoded UTF-8 strings, indexed by row/column number.
|
|
62
|
+
DIACRITICS = DIACRITIC_CODEPOINTS.map { |cp| [cp].pack("U") }.freeze
|
|
63
|
+
|
|
64
|
+
module_function
|
|
65
|
+
|
|
66
|
+
# Builds the out-of-band transmit payload for *png_bytes* under *image_id*, creating a virtual
|
|
67
|
+
# placement sized *rows*×*cols* cells. Returns a single string: a chunked `a=t` transmit
|
|
68
|
+
# followed by an `a=p,U=1` virtual placement, ready to write straight to the terminal.
|
|
69
|
+
def transmit(image_id:, png_bytes:, rows:, cols:)
|
|
70
|
+
transmit_image(image_id, png_bytes) + create_placement(image_id, rows: rows, cols: cols)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Builds the in-frame placeholder block: *rows* lines of *cols* cells each, every cell a
|
|
74
|
+
# {PLACEHOLDER} plus its row/column diacritics, with the *image_id* carried in an exact
|
|
75
|
+
# truecolour foreground SGR. Each line measures exactly *cols* display columns.
|
|
76
|
+
def placeholder_block(image_id:, rows:, cols:)
|
|
77
|
+
ensure_in_range!(rows, cols)
|
|
78
|
+
prefix = foreground(image_id)
|
|
79
|
+
Array.new(rows) do |row|
|
|
80
|
+
cells = Array.new(cols) { |col| PLACEHOLDER + DIACRITICS[row] + DIACRITICS[col] }.join
|
|
81
|
+
"#{prefix}#{cells}\e[0m"
|
|
82
|
+
end.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The chunked `a=t` (transmit) escapes carrying the base64 PNG data directly (`f=100,t=d`).
|
|
86
|
+
# Control keys ride only the first chunk; `m=1` flags more-to-come, `m=0` the last.
|
|
87
|
+
def transmit_image(image_id, png_bytes)
|
|
88
|
+
chunks = chunk(encode(png_bytes))
|
|
89
|
+
chunks.each_with_index.map do |chunk, index|
|
|
90
|
+
last = index == chunks.length - 1
|
|
91
|
+
control = (index == 0) ? "a=t,f=100,t=d,i=#{image_id},q=2," : ""
|
|
92
|
+
"\e_G#{control}m=#{last ? 0 : 1};#{chunk}\e\\"
|
|
93
|
+
end.join
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The `a=p,U=1` escape creating a virtual placement for *image_id* sized *cols*×*rows* cells.
|
|
97
|
+
def create_placement(image_id, rows:, cols:)
|
|
98
|
+
"\e_Ga=p,U=1,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The exact truecolour foreground SGR encoding *image_id*'s low 24 bits (no downconversion).
|
|
102
|
+
def foreground(image_id)
|
|
103
|
+
"\e[38;2;#{(image_id >> 16) & 0xFF};#{(image_id >> 8) & 0xFF};#{image_id & 0xFF}m"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Strict base64 (no newlines) of *bytes*, via String#pack so no library require is needed.
|
|
107
|
+
def encode(bytes)
|
|
108
|
+
[bytes].pack("m0")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Splits *data* into ≤{CHUNK_SIZE}-byte chunks (always at least one, even when empty).
|
|
112
|
+
def chunk(data)
|
|
113
|
+
data.scan(/.{1,#{CHUNK_SIZE}}/mo).then { |parts| parts.empty? ? [""] : parts }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Raises when *rows*/*cols* exceed the available diacritics (the encodable cell range).
|
|
117
|
+
def ensure_in_range!(rows, cols)
|
|
118
|
+
max = DIACRITICS.length
|
|
119
|
+
return if rows <= max && cols <= max
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "image is at most #{max} cells per dimension (got #{rows}x#{cols})"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Image
|
|
5
|
+
# Protocol namespaces the terminal-graphics encoders and dispatches to one by name. Each encoder
|
|
6
|
+
# (e.g. {Protocol::Kitty}) exposes the same surface — `transmit(...)` for the out-of-band payload
|
|
7
|
+
# and `placeholder_block(...)` for the in-frame cells — so {Source} stays protocol-agnostic.
|
|
8
|
+
module Protocol
|
|
9
|
+
# Returns the encoder module for *name* (a {Terminal#protocol} symbol), or nil when no encoder
|
|
10
|
+
# applies (e.g. `:none`).
|
|
11
|
+
def self.for(name)
|
|
12
|
+
case name
|
|
13
|
+
when :kitty then Kitty
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|