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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +144 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +41 -3
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +27 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- 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
|
|
42
|
-
#
|
|
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
|
|
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[
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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.
|
|
28
|
+
# True when the sidebar slot is the current focus target.
|
|
31
29
|
def sidebar_focused?
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
session[:focus] == :sidebar
|
|
30
|
+
focused?(:sidebar)
|
|
35
31
|
end
|
|
36
32
|
|
|
37
|
-
# True when
|
|
33
|
+
# True when focus is on the content side: any current slot other than :sidebar.
|
|
38
34
|
def content_focused?
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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
|
data/lib/charming/controller.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
72
|
-
return
|
|
94
|
+
sidebar_response = dispatch_sidebar_mouse
|
|
95
|
+
return sidebar_response if sidebar_response
|
|
73
96
|
|
|
74
|
-
|
|
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))
|