charming 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/cli.rb +16 -4
  5. data/lib/charming/controller/key_dispatch.rb +113 -0
  6. data/lib/charming/controller/session_state.rb +11 -0
  7. data/lib/charming/controller/terminal.rb +33 -0
  8. data/lib/charming/controller.rb +13 -40
  9. data/lib/charming/escape.rb +81 -0
  10. data/lib/charming/events/mouse_event.rb +22 -9
  11. data/lib/charming/generators/app_file_generator.rb +29 -0
  12. data/lib/charming/generators/app_generator.rb +13 -25
  13. data/lib/charming/generators/base.rb +5 -0
  14. data/lib/charming/generators/layout_generator.rb +155 -0
  15. data/lib/charming/generators/screen_generator.rb +0 -29
  16. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  17. data/lib/charming/generators/templates/app/README.md.template +15 -1
  18. data/lib/charming/generators/templates/app/application.template +0 -6
  19. data/lib/charming/generators/templates/app/application_controller.template +0 -10
  20. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  21. data/lib/charming/generators/templates/app/layout.template +4 -93
  22. data/lib/charming/generators/templates/app/routes.template +3 -1
  23. data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
  24. data/lib/charming/image/protocol/kitty.rb +133 -0
  25. data/lib/charming/image/protocol.rb +18 -0
  26. data/lib/charming/image/source.rb +95 -0
  27. data/lib/charming/image/terminal.rb +52 -0
  28. data/lib/charming/image/transmit.rb +11 -0
  29. data/lib/charming/image.rb +21 -0
  30. data/lib/charming/internal/event_loop.rb +155 -0
  31. data/lib/charming/internal/terminal/adapter.rb +14 -0
  32. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  33. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  34. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  35. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  36. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  37. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  38. data/lib/charming/presentation/components/autocomplete.rb +7 -0
  39. data/lib/charming/presentation/components/chart.rb +80 -0
  40. data/lib/charming/presentation/components/error_screen.rb +1 -1
  41. data/lib/charming/presentation/components/filepicker.rb +101 -0
  42. data/lib/charming/presentation/components/form/builder.rb +5 -0
  43. data/lib/charming/presentation/components/form/field.rb +5 -0
  44. data/lib/charming/presentation/components/form/input.rb +14 -4
  45. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  46. data/lib/charming/presentation/components/form/textarea.rb +18 -8
  47. data/lib/charming/presentation/components/form.rb +6 -0
  48. data/lib/charming/presentation/components/image.rb +38 -0
  49. data/lib/charming/presentation/components/list.rb +22 -4
  50. data/lib/charming/presentation/components/modal.rb +4 -4
  51. data/lib/charming/presentation/components/paginator.rb +54 -0
  52. data/lib/charming/presentation/components/progressbar.rb +26 -4
  53. data/lib/charming/presentation/components/sparkline.rb +38 -0
  54. data/lib/charming/presentation/components/spinner.rb +22 -3
  55. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  56. data/lib/charming/presentation/components/table.rb +42 -1
  57. data/lib/charming/presentation/components/text_area.rb +3 -4
  58. data/lib/charming/presentation/components/text_input.rb +11 -6
  59. data/lib/charming/presentation/components/time_display.rb +20 -0
  60. data/lib/charming/presentation/components/timer.rb +43 -0
  61. data/lib/charming/presentation/components/toast.rb +4 -3
  62. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  63. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  64. data/lib/charming/presentation/components/viewport.rb +11 -1
  65. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  66. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  67. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  68. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  69. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  70. data/lib/charming/presentation/ui/background.rb +58 -0
  71. data/lib/charming/presentation/ui/border.rb +14 -1
  72. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  73. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  74. data/lib/charming/presentation/ui/canvas.rb +1 -0
  75. data/lib/charming/presentation/ui/gradient.rb +47 -0
  76. data/lib/charming/presentation/ui/style.rb +119 -19
  77. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  78. data/lib/charming/presentation/ui/truncate.rb +29 -0
  79. data/lib/charming/presentation/ui/width.rb +11 -0
  80. data/lib/charming/presentation/ui.rb +52 -11
  81. data/lib/charming/presentation/view.rb +8 -6
  82. data/lib/charming/response.rb +11 -6
  83. data/lib/charming/runtime.rb +167 -90
  84. data/lib/charming/version.rb +1 -1
  85. data/lib/charming/welcome/controller.rb +23 -0
  86. data/lib/charming/welcome/show_view.rb +113 -0
  87. data/lib/charming/welcome.rb +13 -0
  88. metadata +34 -9
  89. data/lib/charming/generators/templates/app/home_controller.template +0 -6
  90. data/lib/charming/generators/templates/app/home_state.template +0 -7
  91. data/lib/charming/generators/templates/app/spec_controller.template +0 -17
  92. data/lib/charming/generators/templates/app/spec_state.template +0 -17
  93. data/lib/charming/generators/templates/app/spec_view.template +0 -16
  94. data/lib/charming/generators/templates/app/view.template +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6b9e1f0689d7ebe1c6ab12b622339c8826bc3e1f44013e8b3c3d7e6e07be98a
4
- data.tar.gz: d1aecf5b432f95241693509d597ca92c39b29549e3a9209be3e49421906fe60c
3
+ metadata.gz: 651009e81e5bb2eb02b2df432b5e399773150691413f2519a6888160b60bd1ca
4
+ data.tar.gz: 8aa3bf4aebc1abab7890eec9b68791d4ed0fd354cffb982cc40f2cf4f261f70c
5
5
  SHA512:
6
- metadata.gz: 3440e877894c5a6146582165c7a8dd68c9d409799f8e99b517077c753f6f29ba5905346088bb5594d1a140bae1f5ba8f861f76b2f45c11ade91f0658b0108932
7
- data.tar.gz: 0cede340d3d193c12acd7441868097987174dc96e85a0c9248a18b922dd0df341ebd7f8d305ee901df703b1553a5966aab611ddde9aa3e07e8ac5db2cca52cd7
6
+ metadata.gz: d4276dd6ef588077877b4d4d44758a9c176161fccddd22a5d74161f8615ee38f3c8078872a0ccbca6c8d73e83c994f7e0221f45f75348b9b81bef507a35ff491
7
+ data.tar.gz: 595008aad5caa6c524e872593b8554b588c8018592aca1ead16820200c31dec67cb1189e338658e902abafa9033612780441ed71bb57bcdb8f296b8f03bbfecc
data/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Charming
2
2
 
3
+ [![CI](https://github.com/pandorocks/charming/actions/workflows/main.yml/badge.svg)](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/home/show_view.rb # screen view classes
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 include a sidebar/content layout, command palette, focus management, theme switching, and default key bindings for commands (`ctrl+p`) and quit (`q`).
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
  ```
@@ -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
- name = args.shift || raise(Generators::Error, "Usage: charming generate #{type} NAME")
64
- generator_class(type).new(name, args, out: out, destination: pwd, force: force)
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
@@ -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
 
@@ -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, and form fields support it).
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 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
@@ -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