charming 0.2.0 → 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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. metadata +63 -50
@@ -12,6 +12,11 @@ module Charming
12
12
  Charming.key_of(event)
13
13
  end
14
14
 
15
+ # Returns the normalized key signature for controller-declared bindings.
16
+ def binding_key_name
17
+ Charming.key_signature(event)
18
+ end
19
+
15
20
  # Calls the auto-render action if one is configured. No-op when the action method is undefined.
16
21
  def render_default_action
17
22
  action = self.class.auto_render_action || :show
@@ -38,10 +43,11 @@ module Charming
38
43
  key_action_for_scope(:content)
39
44
  end
40
45
 
41
- # Returns false when the controller declared a content focus ring slot and the sidebar
42
- # is currently focused. Otherwise true (the default behavior for non-declarative controllers).
46
+ # Returns false when the focus ring includes a content slot that isn't currently
47
+ # focused (e.g., the sidebar has focus). Controllers whose ring has no :content slot
48
+ # always have content keys active.
43
49
  def content_key_scope_active?
44
- return content_focused? if focus_ring_slot?(:content)
50
+ return content_focused? if focus.ring.include?(:content)
45
51
 
46
52
  true
47
53
  end
@@ -49,12 +55,32 @@ module Charming
49
55
  # Looks up the current key in the class bindings and returns the action only if its
50
56
  # registered scope matches *scope*. Returns nil otherwise.
51
57
  def key_action_for_scope(scope)
52
- action = self.class.key_bindings[key_name]
58
+ action = self.class.key_bindings[binding_key_name]
53
59
  return nil unless action
54
- return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
60
+ return nil unless self.class.key_binding_scopes.fetch(binding_key_name, :content) == scope
55
61
 
56
62
  action
57
63
  end
64
+
65
+ # True when the current event is a plain printable character: a single
66
+ # non-control char with no ctrl/alt modifier (ctrl+p etc. stay shortcuts).
67
+ def printable_text_event?
68
+ return false unless event.respond_to?(:char) && event.char
69
+ return false if event.respond_to?(:ctrl) && event.ctrl
70
+ return false if event.respond_to?(:alt) && event.alt
71
+
72
+ event.char.length == 1 && !event.char.match?(/[[:cntrl:]]/)
73
+ end
74
+
75
+ # True when the focus ring's current slot resolves to a component that accepts
76
+ # free-typed text (see Component#captures_text?).
77
+ def focused_component_captures_text?
78
+ slot = focus.current
79
+ return false unless slot && respond_to?(slot, true)
80
+
81
+ component = send(slot)
82
+ component.respond_to?(:captures_text?) && component.captures_text?
83
+ end
58
84
  end
59
85
  end
60
86
  end
@@ -83,6 +83,15 @@ module Charming
83
83
  current == slot
84
84
  end
85
85
 
86
+ # True when the topmost scope is an overlay (modal, command palette, or any
87
+ # pushed scope) rather than the structural ring/layout. While an overlay scope
88
+ # is active, the controller routes keys to the focused component instead of
89
+ # content/sidebar key bindings.
90
+ def overlay?
91
+ scope = top
92
+ !!scope && !%i[ring layout].include?(scope[:origin])
93
+ end
94
+
86
95
  private
87
96
 
88
97
  # Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
@@ -18,13 +18,6 @@ module Charming
18
18
  def focused?(slot)
19
19
  focus.focused?(slot)
20
20
  end
21
-
22
- private
23
-
24
- # True when the controller class declared *slot* as part of its focus_ring DSL.
25
- def focus_ring_slot?(slot)
26
- self.class.focus_ring_slots.include?(slot)
27
- end
28
21
  end
29
22
  end
30
23
  end
@@ -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.
@@ -44,9 +55,24 @@ module Charming
44
55
  # Submits a background task with the given *name*. The block is executed by the configured
45
56
  # task executor; its return value (or any raised exception) is delivered to the controller
46
57
  # as a TaskEvent dispatched to the matching `on_task` handler.
47
- def run_task(name, &block)
58
+ #
59
+ # Blocks that accept an argument receive a Tasks::Progress reporter whose `report`
60
+ # calls dispatch the matching `on_task_progress` handler. *timeout:* (seconds)
61
+ # cancels the task with Tasks::Cancelled when exceeded.
62
+ def run_task(name, timeout: nil, &block)
63
+ return application.task_executor.submit(name, timeout: timeout, &block) if timeout
64
+
65
+ # Without a timeout, use the plain signature so simple custom executors
66
+ # (`def submit(name, &block)`) remain compatible.
48
67
  application.task_executor.submit(name, &block)
49
68
  end
69
+
70
+ # Cancels the named in-flight background task (raises Tasks::Cancelled inside it).
71
+ # No-op when the task already finished or the executor doesn't support cancellation.
72
+ def cancel_task(name)
73
+ executor = application.task_executor
74
+ executor.cancel(name) if executor.respond_to?(:cancel)
75
+ end
50
76
  end
51
77
  end
52
78
  end
@@ -4,41 +4,36 @@ module Charming
4
4
  class Controller
5
5
  # Sidebar-navigation helpers mixed into Controller. Tracks the sidebar's current route index,
6
6
  # routes j/k/enter/tab keys when the sidebar is focused, and exposes `sidebar_focused?` for views.
7
+ #
8
+ # Sidebar/content focus is driven entirely by the controller's Focus object. Controllers
9
+ # that want Tab-driven sidebar navigation declare `focus_ring :sidebar, :content` (generated
10
+ # apps do); without those slots in the ring, `focus_sidebar`/`focus_content` are no-ops.
7
11
  module SidebarNavigation
8
- # Moves focus to the sidebar. When the controller declared a focus ring, the focus object
9
- # is updated; otherwise a fallback session key tracks focus.
12
+ # Moves focus to the sidebar slot and remembers the highlighted route.
10
13
  def focus_sidebar
11
- if focus_ring_slot?(:sidebar)
12
- focus.focus(:sidebar)
13
- else
14
- session[:focus] = :sidebar
15
- end
14
+ focus.focus(:sidebar)
16
15
  session[:sidebar_index] ||= current_route_index
17
16
  render_default_action
18
17
  end
19
18
 
20
- # Moves focus to the content pane (the inverse of `focus_sidebar`).
19
+ # Moves focus to the content side (the inverse of `focus_sidebar`). "Content" is
20
+ # the :content slot when the ring declares one, otherwise the first non-sidebar
21
+ # slot — so `focus_ring :sidebar, :entries` works without a literal :content.
21
22
  def focus_content
22
- if focus_ring_slot?(:content)
23
- focus.focus(:content)
24
- else
25
- session[:focus] = :content
26
- end
23
+ slot = content_slot
24
+ focus.focus(slot) if slot
27
25
  render_default_action
28
26
  end
29
27
 
30
- # True when the sidebar is the current focus target. Uses the focus ring when defined.
28
+ # True when the sidebar slot is the current focus target.
31
29
  def sidebar_focused?
32
- return focused?(:sidebar) if focus_ring_slot?(:sidebar)
33
-
34
- session[:focus] == :sidebar
30
+ focused?(:sidebar)
35
31
  end
36
32
 
37
- # True when the content pane is the current focus target. Uses the focus ring when defined.
33
+ # True when focus is on the content side: any current slot other than :sidebar.
38
34
  def content_focused?
39
- return focused?(:content) if focus_ring_slot?(:content)
40
-
41
- session[:focus] == :content
35
+ current = focus.current
36
+ !current.nil? && current != :sidebar
42
37
  end
43
38
 
44
39
  # Returns the index of the currently selected route in `sidebar_routes`, defaulting to the
@@ -82,9 +77,43 @@ module Charming
82
77
  response
83
78
  end
84
79
 
85
- # Mouse dispatch for the sidebar. Reserved for future use; returns nil.
80
+ # Mouse dispatch for the sidebar: a click on a route row selects it and navigates
81
+ # immediately; a click elsewhere in the sidebar pane focuses the sidebar. Uses the
82
+ # :sidebar pane's inner rect from the latest render to translate screen coordinates
83
+ # to nav rows. Returns nil when the click missed the sidebar entirely.
86
84
  def dispatch_sidebar_mouse
87
- nil
85
+ return nil unless event.respond_to?(:click?) && event.click?
86
+
87
+ row = sidebar_row_at(event.x, event.y)
88
+ return nil unless row
89
+
90
+ if row.between?(0, sidebar_routes.length - 1)
91
+ session[:sidebar_index] = row
92
+ sidebar_select
93
+ else
94
+ focus.focus(:sidebar)
95
+ render_default_action
96
+ end
97
+ response
98
+ end
99
+
100
+ # The number of rows above the first nav item inside the sidebar pane's content
101
+ # area (the generated layout renders the app title plus a blank gap line).
102
+ # Override in controllers whose sidebar layout differs.
103
+ def sidebar_nav_offset
104
+ 2
105
+ end
106
+
107
+ # Maps screen coordinates to a nav-row index inside the :sidebar pane's inner
108
+ # rect, or nil when the click missed the sidebar.
109
+ def sidebar_row_at(x, y)
110
+ target = mouse_targets.find { |candidate| candidate[:name] == :sidebar }
111
+ return nil unless target
112
+
113
+ inner = target.fetch(:inner_rect)
114
+ return nil unless inner.cover?(x, y)
115
+
116
+ y - inner.y - sidebar_nav_offset
88
117
  end
89
118
 
90
119
  # Moves the sidebar cursor by *delta* positions, clamped to the route list bounds.
@@ -96,14 +125,20 @@ module Charming
96
125
  render_default_action
97
126
  end
98
127
 
128
+ # The slot focus_content targets: :content when declared, else the first
129
+ # non-sidebar slot in the active ring.
130
+ def content_slot
131
+ ring = focus.ring
132
+ return :content if ring.include?(:content)
133
+
134
+ ring.find { |slot| slot != :sidebar }
135
+ end
136
+
99
137
  # Selects the route currently highlighted in the sidebar and navigates to it.
100
138
  def sidebar_select
101
139
  route = sidebar_routes[sidebar_index]
102
- if focus_ring_slot?(:content)
103
- focus.focus(:content)
104
- else
105
- session[:focus] = :content
106
- end
140
+ slot = content_slot
141
+ focus.focus(slot) if slot
107
142
  route ? navigate_to(route.path) : render_default_action
108
143
  end
109
144
  end
@@ -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
@@ -9,6 +9,7 @@ module Charming
9
9
  TaskBinding = Data.define(:name, :action)
10
10
 
11
11
  extend ClassMethods
12
+ include ActionHooks
12
13
  include Rendering
13
14
  include SessionState
14
15
  include FocusManagement
@@ -16,6 +17,7 @@ module Charming
16
17
  include CommandPalette
17
18
  include ComponentDispatching
18
19
  include Dispatching
20
+ include Terminal
19
21
 
20
22
  attr_reader :application, :event, :params, :screen, :route
21
23
 
@@ -30,31 +32,29 @@ module Charming
30
32
  @response = nil
31
33
  end
32
34
 
33
- # Dispatches a named action on this controller (e.g. :show).
35
+ # Dispatches a named action on this controller (e.g. :show), running all
36
+ # before/around/after hooks and rescue_from handlers.
34
37
  def dispatch(action)
35
- public_send(action)
38
+ run_action_with_hooks(action)
36
39
  render_default_action if response.nil? && auto_render_after?(action)
37
40
  response || render("")
38
41
  end
39
42
 
40
- # Key event dispatch: checks command palette first, then global bindings,
41
- # sidebar (if focused), content bindings, tab traversal, and focused component.
43
+ # Key event dispatch. The precedence ladder (palette focused text capture →
44
+ # global bindings overlay sidebar/content/component) lives in KeyDispatch.
42
45
  def dispatch_key
43
- return dispatch_command_palette_key if command_palette_open?
44
- return dispatch(global_key_action) if global_key_action
45
- return dispatch_sidebar_key if sidebar_focused?
46
- return dispatch(content_key_action) if content_key_action
47
- return response if dispatch_tab_traversal == :handled
48
- return response if dispatch_to_focused_component == :handled
49
- nil
46
+ KeyDispatch.new(self).call
50
47
  end
51
48
 
52
- # 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.
53
53
  def dispatch_timer
54
54
  b = self.class.timer_bindings[event.name.to_sym]
55
55
  return nil unless b
56
56
 
57
- public_send(b.action)
57
+ run_action_with_hooks(b.action)
58
58
  response
59
59
  end
60
60
 
@@ -64,17 +64,42 @@ module Charming
64
64
  b ? dispatch(b.action) : nil
65
65
  end
66
66
 
67
- # Mouse event dispatcher: checks command palette (if open), sidebar (if focused).
67
+ # Task progress dispatcher: looks up the handler in task progress bindings.
68
+ def dispatch_task_progress
69
+ b = self.class.task_progress_bindings[event.name.to_sym]
70
+ b ? dispatch(b.action) : nil
71
+ end
72
+
73
+ # Paste event dispatcher: forwards pasted text to the focused component's
74
+ # `handle_paste` (TextInput, TextArea, and form fields support it).
75
+ def dispatch_paste
76
+ slot = focus.current
77
+ return nil unless slot && respond_to?(slot, true)
78
+
79
+ component = send(slot)
80
+ return nil unless component.respond_to?(:handle_paste)
81
+
82
+ result = component.handle_paste(event)
83
+ return nil if result.nil?
84
+
85
+ dispatch_component_result(slot, result)
86
+ response
87
+ end
88
+
89
+ # Mouse event dispatcher: command palette (if open) wins, then sidebar clicks
90
+ # (route rows navigate directly), then named layout panes/components.
68
91
  def dispatch_mouse
69
92
  return dispatch_command_palette_mouse if command_palette_open?
70
93
 
71
- mouse_response = dispatch_component_mouse
72
- return mouse_response if mouse_response
94
+ sidebar_response = dispatch_sidebar_mouse
95
+ return sidebar_response if sidebar_response
73
96
 
74
- dispatch_sidebar_mouse if sidebar_focused?
97
+ dispatch_component_mouse
75
98
  end
76
99
 
77
- # 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.
78
103
  def render(body = "", **assigns)
79
104
  body = view_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
80
105
  @response = Response.render(render_with_layout(body))