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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/controller/key_dispatch.rb +113 -0
  5. data/lib/charming/controller/session_state.rb +11 -0
  6. data/lib/charming/controller/terminal.rb +33 -0
  7. data/lib/charming/controller.rb +12 -39
  8. data/lib/charming/escape.rb +81 -0
  9. data/lib/charming/events/mouse_event.rb +22 -9
  10. data/lib/charming/generators/app_generator.rb +3 -2
  11. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  12. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  13. data/lib/charming/image/protocol/kitty.rb +126 -0
  14. data/lib/charming/image/protocol.rb +18 -0
  15. data/lib/charming/image/source.rb +85 -0
  16. data/lib/charming/image/terminal.rb +52 -0
  17. data/lib/charming/image/transmit.rb +11 -0
  18. data/lib/charming/image.rb +21 -0
  19. data/lib/charming/internal/event_loop.rb +155 -0
  20. data/lib/charming/internal/terminal/adapter.rb +14 -0
  21. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  22. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  23. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  24. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  25. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  26. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  27. data/lib/charming/presentation/components/chart.rb +80 -0
  28. data/lib/charming/presentation/components/error_screen.rb +1 -1
  29. data/lib/charming/presentation/components/filepicker.rb +101 -0
  30. data/lib/charming/presentation/components/form/builder.rb +5 -0
  31. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  32. data/lib/charming/presentation/components/image.rb +38 -0
  33. data/lib/charming/presentation/components/list.rb +22 -4
  34. data/lib/charming/presentation/components/paginator.rb +54 -0
  35. data/lib/charming/presentation/components/progressbar.rb +26 -4
  36. data/lib/charming/presentation/components/sparkline.rb +38 -0
  37. data/lib/charming/presentation/components/spinner.rb +22 -3
  38. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  39. data/lib/charming/presentation/components/table.rb +42 -1
  40. data/lib/charming/presentation/components/text_area.rb +1 -2
  41. data/lib/charming/presentation/components/text_input.rb +9 -4
  42. data/lib/charming/presentation/components/time_display.rb +20 -0
  43. data/lib/charming/presentation/components/timer.rb +43 -0
  44. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  45. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  46. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  47. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  48. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  49. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  50. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  51. data/lib/charming/presentation/ui/background.rb +58 -0
  52. data/lib/charming/presentation/ui/border.rb +14 -1
  53. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  54. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  55. data/lib/charming/presentation/ui/canvas.rb +1 -0
  56. data/lib/charming/presentation/ui/gradient.rb +47 -0
  57. data/lib/charming/presentation/ui/style.rb +119 -19
  58. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  59. data/lib/charming/presentation/ui/truncate.rb +29 -0
  60. data/lib/charming/presentation/ui/width.rb +11 -0
  61. data/lib/charming/presentation/ui.rb +52 -11
  62. data/lib/charming/presentation/view.rb +8 -6
  63. data/lib/charming/response.rb +11 -6
  64. data/lib/charming/runtime.rb +154 -88
  65. data/lib/charming/version.rb +1 -1
  66. metadata +29 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6b9e1f0689d7ebe1c6ab12b622339c8826bc3e1f44013e8b3c3d7e6e07be98a
4
- data.tar.gz: d1aecf5b432f95241693509d597ca92c39b29549e3a9209be3e49421906fe60c
3
+ metadata.gz: 71b370eac77c67126921d7347a0d23f834f23774626c111445e9abdef73555d9
4
+ data.tar.gz: c8dd983f46183c2fbdb22f9c30753b4f1443d8de2989a4e7f90016f397aa12e4
5
5
  SHA512:
6
- metadata.gz: 3440e877894c5a6146582165c7a8dd68c9d409799f8e99b517077c753f6f29ba5905346088bb5594d1a140bae1f5ba8f861f76b2f45c11ade91f0658b0108932
7
- data.tar.gz: 0cede340d3d193c12acd7441868097987174dc96e85a0c9248a18b922dd0df341ebd7f8d305ee901df703b1553a5966aab611ddde9aa3e07e8ac5db2cca52cd7
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
 
@@ -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
@@ -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, in priority order:
43
- # 1. Command palette (when open) consumes everything.
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
- return dispatch_command_palette_key if command_palette_open?
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
- public_send(b.action)
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 triggered (left,
14
- # right, scroll), while *x* and *y* provide the cursor position. Modifier booleans (*ctrl*, *alt*, *shift*)
15
- # capture key state at the time of the event.
16
- MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift) do
17
- def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false)
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` when the current event is a click (left, middle, or right button).
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` when the current event is a mouse release action.
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.
@@ -3,3 +3,9 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
+
7
+ group :development, :test do
8
+ gem "irb" # `charming console` (not a default gem on Ruby 4.0+)
9
+ gem "rake", "~> 13.0"
10
+ gem "rspec", "~> 3.13"
11
+ end
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
@@ -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