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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 651009e81e5bb2eb02b2df432b5e399773150691413f2519a6888160b60bd1ca
|
|
4
|
+
data.tar.gz: 8aa3bf4aebc1abab7890eec9b68791d4ed0fd354cffb982cc40f2cf4f261f70c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4276dd6ef588077877b4d4d44758a9c176161fccddd22a5d74161f8615ee38f3c8078872a0ccbca6c8d73e83c994f7e0221f45f75348b9b81bef507a35ff491
|
|
7
|
+
data.tar.gz: 595008aad5caa6c524e872593b8554b588c8018592aca1ead16820200c31dec67cb1189e338658e902abafa9033612780441ed71bb57bcdb8f296b8f03bbfecc
|
data/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Charming
|
|
2
2
|
|
|
3
|
+
[](https://github.com/pandorocks/charming/actions/workflows/main.yml)
|
|
4
|
+
|
|
3
5
|
A Rails-inspired terminal user interface framework for **Ruby 4+**.
|
|
4
6
|
|
|
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.
|
|
7
|
+
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
8
|
|
|
7
9
|
## Project Status
|
|
8
10
|
|
|
@@ -39,7 +41,7 @@ The generator produces a Bundler gem with a Rails-like structure:
|
|
|
39
41
|
app/controllers/ # controller actions and input bindings
|
|
40
42
|
app/state/ # session-backed TUI state
|
|
41
43
|
app/models/ # optional Active Record models
|
|
42
|
-
app/views/
|
|
44
|
+
app/views/ # screen view classes
|
|
43
45
|
app/views/layouts/application_layout.rb # layout view class
|
|
44
46
|
app/components/ # reusable components
|
|
45
47
|
config/routes.rb # route definitions
|
|
@@ -47,7 +49,17 @@ lib/my_app.rb # namespace loader (Zeitwerk)
|
|
|
47
49
|
exe/my_app # executable entry point
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
Generated apps
|
|
52
|
+
Generated apps start minimal. Until the app defines a route, it boots to a built-in welcome screen served from the gem itself (like Rails' welcome page) — generate your first screen and it disappears, with nothing to delete:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
bundle exec charming generate screen home
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Restore the full app chrome — sidebar/content layout, command palette (`ctrl+p`), focus management, and theme switching — with one command:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bundle exec charming generate layout --style sidebar
|
|
62
|
+
```
|
|
51
63
|
|
|
52
64
|
## Development
|
|
53
65
|
|
|
@@ -65,4 +77,5 @@ bin/rspec # run specs only
|
|
|
65
77
|
bin/format # auto-format with Standard Ruby
|
|
66
78
|
bin/lint # style checks with Standard Ruby
|
|
67
79
|
bin/check # run everything
|
|
80
|
+
bin/ci # what CI runs: specs, lint, eager-load check, gem build
|
|
68
81
|
```
|
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.
|
data/lib/charming/cli.rb
CHANGED
|
@@ -5,6 +5,7 @@ module Charming
|
|
|
5
5
|
# or database commands. Subcommands:
|
|
6
6
|
# - `charming new NAME [--database sqlite3] [--force]` — scaffolds a new app
|
|
7
7
|
# - `charming generate TYPE NAME [args]` — runs a sub-generator (controller, model, screen, view, component)
|
|
8
|
+
# - `charming generate layout [--style sidebar]` — restores the sidebar/theme/palette app chrome
|
|
8
9
|
# - `charming db:COMMAND` — runs a database command (db:create, db:migrate, db:rollback, db:drop, db:seed, db:install)
|
|
9
10
|
#
|
|
10
11
|
# Generator errors are caught and printed to stderr; the process exits with status 1.
|
|
@@ -58,14 +59,24 @@ module Charming
|
|
|
58
59
|
0
|
|
59
60
|
end
|
|
60
61
|
|
|
62
|
+
# Generator types that take no positional NAME argument.
|
|
63
|
+
NAMELESS_GENERATORS = %w[layout].freeze
|
|
64
|
+
|
|
61
65
|
# Builds the generator instance for the given *type*, popping the name from *args*.
|
|
62
66
|
def generator(type, args, force)
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
generator_class(type).new(generator_name(type, args), args, out: out, destination: pwd, force: force)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Pops the NAME argument for a generator *type*, or supplies a fixed name for
|
|
71
|
+
# nameless generators (e.g., layout targets the application layout).
|
|
72
|
+
def generator_name(type, args)
|
|
73
|
+
return "application" if NAMELESS_GENERATORS.include?(type)
|
|
74
|
+
|
|
75
|
+
args.shift || raise(Generators::Error, "Usage: charming generate #{type} NAME")
|
|
65
76
|
end
|
|
66
77
|
|
|
67
78
|
# Returns the generator class for a *type* string (controller, model, screen, view,
|
|
68
|
-
# component, migration).
|
|
79
|
+
# component, migration, layout).
|
|
69
80
|
def generator_class(type)
|
|
70
81
|
{
|
|
71
82
|
"controller" => Generators::ControllerGenerator,
|
|
@@ -73,7 +84,8 @@ module Charming
|
|
|
73
84
|
"screen" => Generators::ScreenGenerator,
|
|
74
85
|
"view" => Generators::ViewGenerator,
|
|
75
86
|
"component" => Generators::ComponentGenerator,
|
|
76
|
-
"migration" => Generators::MigrationGenerator
|
|
87
|
+
"migration" => Generators::MigrationGenerator,
|
|
88
|
+
"layout" => Generators::LayoutGenerator
|
|
77
89
|
}.fetch(type) { raise Generators::Error, "Unknown generator: #{type}" }
|
|
78
90
|
end
|
|
79
91
|
|
|
@@ -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
|
|
|
@@ -100,7 +71,7 @@ module Charming
|
|
|
100
71
|
end
|
|
101
72
|
|
|
102
73
|
# Paste event dispatcher: forwards pasted text to the focused component's
|
|
103
|
-
# `handle_paste` (TextInput, TextArea,
|
|
74
|
+
# `handle_paste` (TextInput, TextArea, Form text fields, and Autocomplete support it).
|
|
104
75
|
def dispatch_paste
|
|
105
76
|
slot = focus.current
|
|
106
77
|
return nil unless slot && respond_to?(slot, true)
|
|
@@ -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
|
|
@@ -36,6 +36,35 @@ module Charming
|
|
|
36
36
|
|
|
37
37
|
File.basename(gemspec, ".gemspec")
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
# Inserts *content* into *path* just before the line matching *end_line*. No-ops when
|
|
41
|
+
# the content is already present. Raises Error when the file or end-line is missing.
|
|
42
|
+
def insert_before_end(path, content, label, end_line)
|
|
43
|
+
raise Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
current = File.read(path)
|
|
46
|
+
return if current.include?(content)
|
|
47
|
+
|
|
48
|
+
lines = current.lines
|
|
49
|
+
index = insertion_index(lines, path, end_line)
|
|
50
|
+
lines.insert(index, "#{content}\n")
|
|
51
|
+
File.write(path, lines.join)
|
|
52
|
+
out.puts "insert #{label} #{relative_path(path)}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the index of the last line in *lines* that matches *end_line* (the line
|
|
56
|
+
# just before which new content will be inserted). Raises Error when not found.
|
|
57
|
+
def insertion_index(lines, path, end_line)
|
|
58
|
+
index = lines.rindex { |line| line.chomp == end_line }
|
|
59
|
+
raise Error, "Could not update #{relative_path(path)}" unless index
|
|
60
|
+
|
|
61
|
+
index
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Strips the destination prefix from *path* for human-friendly status output.
|
|
65
|
+
def relative_path(path)
|
|
66
|
+
path.delete_prefix("#{destination}/")
|
|
67
|
+
end
|
|
39
68
|
end
|
|
40
69
|
end
|
|
41
70
|
end
|