charming 0.3.0 → 0.4.0
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 -0
- data/lib/charming/application.rb +91 -14
- data/lib/charming/application_state.rb +23 -0
- data/lib/charming/cli.rb +2 -2
- data/lib/charming/controller/action_hooks.rb +5 -1
- data/lib/charming/controller/class_methods.rb +66 -15
- data/lib/charming/controller/component_dispatch.rb +163 -0
- data/lib/charming/controller/dispatching.rb +1 -2
- data/lib/charming/controller/focus_management.rb +67 -1
- data/lib/charming/controller/key_dispatch.rb +7 -7
- data/lib/charming/controller/rendering.rb +22 -6
- data/lib/charming/controller/session_state.rb +63 -22
- data/lib/charming/controller.rb +247 -59
- data/lib/charming/cross_thread_access.rb +9 -0
- data/lib/charming/double_render_error.rb +8 -0
- data/lib/charming/generators/layout_generator.rb +4 -1
- data/lib/charming/generators/migration_generator.rb +1 -1
- data/lib/charming/generators/model_generator.rb +2 -2
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/screen_generator.rb +2 -2
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/internal/deep_freeze.rb +23 -0
- data/lib/charming/internal/env_inquirer.rb +22 -0
- data/lib/charming/internal/inflections.rb +93 -0
- data/lib/charming/internal/session_guard.rb +27 -0
- data/lib/charming/internal/terminal/cursor.rb +29 -0
- data/lib/charming/internal/terminal/size.rb +47 -0
- data/lib/charming/internal/terminal/tty_backend.rb +10 -8
- data/lib/charming/presentation/components/autocomplete.rb +14 -6
- data/lib/charming/presentation/components/command_palette.rb +11 -9
- data/lib/charming/presentation/components/filepicker.rb +4 -4
- data/lib/charming/presentation/components/form/confirm.rb +2 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/form/input.rb +3 -3
- data/lib/charming/presentation/components/form/multiselect.rb +4 -5
- data/lib/charming/presentation/components/form/select.rb +3 -3
- data/lib/charming/presentation/components/form/textarea.rb +3 -3
- data/lib/charming/presentation/components/form.rb +6 -6
- data/lib/charming/presentation/components/help_overlay.rb +2 -2
- data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
- data/lib/charming/presentation/components/list.rb +14 -5
- data/lib/charming/presentation/components/modal.rb +3 -2
- data/lib/charming/presentation/components/multi_select_list.rb +14 -7
- data/lib/charming/presentation/components/result.rb +61 -0
- data/lib/charming/presentation/components/tab_bar.rb +14 -6
- data/lib/charming/presentation/components/table.rb +64 -32
- data/lib/charming/presentation/components/text_area.rb +7 -7
- data/lib/charming/presentation/components/text_input.rb +7 -7
- data/lib/charming/presentation/components/tree.rb +15 -6
- data/lib/charming/presentation/components/viewport.rb +3 -3
- data/lib/charming/presentation/layout/pane.rb +6 -2
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/view.rb +35 -29
- data/lib/charming/render_artifacts.rb +24 -0
- data/lib/charming/response.rb +19 -8
- data/lib/charming/router.rb +50 -68
- data/lib/charming/runtime.rb +42 -26
- data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
- data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
- data/lib/charming/tasks/context.rb +35 -0
- data/lib/charming/test_helper.rb +42 -22
- data/lib/charming/unhandled_component_event.rb +9 -0
- data/lib/charming/unknown_slot.rb +9 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome.rb +1 -1
- data/lib/charming.rb +14 -6
- metadata +21 -70
- data/lib/charming/controller/component_dispatching.rb +0 -125
|
@@ -5,11 +5,11 @@ module Charming
|
|
|
5
5
|
# rendering hooks, layout composition helpers (`row`, `column`, `render_component`, `yield_content`),
|
|
6
6
|
# and access to controller theme, style, and focus state from within views.
|
|
7
7
|
class View
|
|
8
|
-
# Initializes the view with named assigns
|
|
9
|
-
#
|
|
8
|
+
# Initializes the view with named assigns. Assign keys become private reader
|
|
9
|
+
# methods via method_missing (see below) — existing methods always win, so a
|
|
10
|
+
# `title:` assign never shadows a `def title` helper.
|
|
10
11
|
def initialize(**assigns)
|
|
11
12
|
@assigns = assigns
|
|
12
|
-
define_assign_readers
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# Returns all view assigns as a hash, used by layouts to compose the full template (content + screen + controller).
|
|
@@ -28,18 +28,28 @@ module Charming
|
|
|
28
28
|
ctrl ? ctrl.focused?(slot) : false
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# The RenderArtifacts from every screen_layout call in this view's render, in render
|
|
32
|
+
# order. Internal — the controller's rendering pipeline and TestHelper#render_view
|
|
33
|
+
# read them; app code should not.
|
|
34
|
+
def render_artifacts
|
|
35
|
+
@render_artifacts ||= []
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
private
|
|
32
39
|
|
|
33
40
|
attr_reader :assigns
|
|
34
41
|
|
|
35
|
-
#
|
|
42
|
+
# Builds a fresh Style for inline visual styling (colors, borders, alignment).
|
|
43
|
+
# Styles are constructed, not read from a shared singleton.
|
|
36
44
|
def style
|
|
37
|
-
UI.
|
|
45
|
+
UI::Style.new
|
|
38
46
|
end
|
|
39
47
|
|
|
40
|
-
# Returns the active theme:
|
|
48
|
+
# Returns the active theme as injected: the `theme` assign (the controller's
|
|
49
|
+
# rendering pipeline always passes one) or the controller's theme. Views and
|
|
50
|
+
# components take what they're given — there is no ambient fallback.
|
|
41
51
|
def theme
|
|
42
|
-
assigns[:theme] || assigns[:controller]&.theme
|
|
52
|
+
assigns[:theme] || assigns[:controller]&.theme
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
# Outputs styled text through the view's rendering pipeline. Accepts a named `style:` for inline formatting.
|
|
@@ -80,11 +90,15 @@ module Charming
|
|
|
80
90
|
end
|
|
81
91
|
|
|
82
92
|
# Builds a declarative layout tree for the current terminal screen and renders it.
|
|
93
|
+
# The layout's registration data (focusable panes, mouse targets) is stashed on the
|
|
94
|
+
# view as RenderArtifacts — the dispatch pipeline commits them when the response
|
|
95
|
+
# paints, so rendering never mutates the controller. Several screen_layout calls in
|
|
96
|
+
# one render accumulate in order.
|
|
83
97
|
def screen_layout(background: nil, &)
|
|
84
98
|
layout = Layout::Builder.build(screen: layout_screen, view: self, background: background, &)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
artifacts = layout.render_with_artifacts
|
|
100
|
+
render_artifacts << artifacts
|
|
101
|
+
artifacts.frame
|
|
88
102
|
end
|
|
89
103
|
|
|
90
104
|
# Yields the layout's `content` slot — used by view templates to inject their body into a layout wrapper (e.g., sidebar).
|
|
@@ -113,30 +127,22 @@ module Charming
|
|
|
113
127
|
style_object ? style_object.render(value) : value
|
|
114
128
|
end
|
|
115
129
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
def
|
|
119
|
-
assigns.
|
|
120
|
-
next if respond_to?(name, true)
|
|
130
|
+
# Resolves assign keys as zero-argument private readers. Real methods take
|
|
131
|
+
# precedence (method_missing only fires when nothing defined the message).
|
|
132
|
+
def method_missing(name, *args, &block)
|
|
133
|
+
return assigns.fetch(name) if args.empty? && block.nil? && assigns.key?(name)
|
|
121
134
|
|
|
122
|
-
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def layout_screen
|
|
127
|
-
assigns[:screen] || assigns[:controller]&.screen || Charming::Screen.new(width: 80, height: 24)
|
|
135
|
+
super
|
|
128
136
|
end
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
assigns
|
|
138
|
+
# Lets `respond_to?` answer true for assign names, matching the readers
|
|
139
|
+
# method_missing provides.
|
|
140
|
+
def respond_to_missing?(name, include_private = false)
|
|
141
|
+
assigns.key?(name) || super
|
|
134
142
|
end
|
|
135
143
|
|
|
136
|
-
def
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
assigns[:controller].register_mouse_targets(layout.mouse_targets)
|
|
144
|
+
def layout_screen
|
|
145
|
+
assigns[:screen] || assigns[:controller]&.screen || Charming::Screen.new(width: 80, height: 24)
|
|
140
146
|
end
|
|
141
147
|
end
|
|
142
148
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# RenderArtifacts is what a pure view render produces: the painted *frame* string plus
|
|
5
|
+
# the registration data the frame implies — *focus_slots* (focusable layout pane
|
|
6
|
+
# names) and *mouse_targets* (named pane hit areas). Views stash them instead of
|
|
7
|
+
# mutating the controller mid-render; the dispatch pipeline commits them when the
|
|
8
|
+
# response actually paints.
|
|
9
|
+
RenderArtifacts = Data.define(:frame, :focus_slots, :mouse_targets) do
|
|
10
|
+
def initialize(frame: "", focus_slots: [], mouse_targets: [])
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Merges several layouts' artifacts from one render: the last-rendered layout wins
|
|
15
|
+
# for focus, and mouse targets concatenate in render order (overlays hit-test
|
|
16
|
+
# last-wins via rfind).
|
|
17
|
+
def self.merge(artifacts)
|
|
18
|
+
new(
|
|
19
|
+
focus_slots: artifacts.last&.focus_slots || [],
|
|
20
|
+
mouse_targets: artifacts.flat_map(&:mouse_targets)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/charming/response.rb
CHANGED
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
# Response encapsulates a controller's dispatch outcome — one of render text, navigate to another
|
|
4
|
+
# Response encapsulates a controller's dispatch outcome — one of render text, navigate to another screen, or quit.
|
|
5
5
|
# Rails-style factories (`render`, `navigate`, `quit`) serve as the public API and map to :kind values
|
|
6
6
|
# that the Runtime interprets at the end of each event loop iteration.
|
|
7
7
|
#
|
|
8
8
|
# *escapes* carries any out-of-band terminal sequences (image transmissions, clipboard writes,
|
|
9
9
|
# notifications, window-title changes) gathered during the dispatch. The Runtime flushes them straight
|
|
10
10
|
# to the backend, bypassing the line-based frame pipeline. It is empty for ordinary responses.
|
|
11
|
-
|
|
11
|
+
#
|
|
12
|
+
# *artifacts* carries the merged RenderArtifacts (focus slots, mouse targets) from the views
|
|
13
|
+
# rendered during the dispatch, attached when the response is assigned. The dispatch pipeline
|
|
14
|
+
# commits them at dispatch exit; nil for navigate/quit responses and renders with no layout.
|
|
15
|
+
Response = Data.define(:kind, :body, :name, :params, :escapes, :artifacts) do
|
|
12
16
|
# Factory constructing a Render response for displaying *body* text on the current screen. *escapes*
|
|
13
17
|
# is the list of out-of-band sequences gathered during the dispatch (defaults to none).
|
|
14
18
|
def self.render(body, escapes: [])
|
|
15
|
-
new(kind: :render, body: body,
|
|
19
|
+
new(kind: :render, body: body, name: nil, params: {}, escapes: escapes, artifacts: nil)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
|
-
# Factory constructing a NavigateResponse routing to the
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
# Factory constructing a NavigateResponse routing to the screen registered under *name*
|
|
23
|
+
# (a Symbol from config/routes.rb), passing *params* through to the controller.
|
|
24
|
+
def self.navigate(name, **params)
|
|
25
|
+
if name.is_a?(String) && name.start_with?("/")
|
|
26
|
+
suggestion = name.split("/")[1].to_s.delete_prefix(":")
|
|
27
|
+
raise ArgumentError,
|
|
28
|
+
"String URL paths were removed. Use `navigate :#{suggestion}` with a screen name from config/routes.rb. See UPGRADING.md."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
new(kind: :navigate, body: "", name: name.to_sym, params: params, escapes: [], artifacts: nil)
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
# Factory constructing a QuitResponse signalling termination of the top-level event loop.
|
|
24
35
|
def self.quit
|
|
25
|
-
new(kind: :quit, body: "",
|
|
36
|
+
new(kind: :quit, body: "", name: nil, params: {}, escapes: [], artifacts: nil)
|
|
26
37
|
end
|
|
27
38
|
|
|
28
|
-
# Returns `true` when this response is navigating to another screen
|
|
39
|
+
# Returns `true` when this response is navigating to another screen.
|
|
29
40
|
def navigate?
|
|
30
41
|
kind == :navigate
|
|
31
42
|
end
|
data/lib/charming/router.rb
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
module Charming
|
|
6
|
-
# Router manages an application's
|
|
7
|
-
# Each
|
|
4
|
+
# Router manages an application's screen table and provides a Rails-inspired DSL for
|
|
5
|
+
# defining screens. Each screen maps a symbolic name to a controller, an action
|
|
6
|
+
# (implicitly :show), and a title (for sidebar display). Navigation passes params
|
|
7
|
+
# directly — there are no URL path templates.
|
|
8
8
|
class Router
|
|
9
|
-
# Route is a Data object holding a
|
|
10
|
-
|
|
9
|
+
# Route is a Data object holding a screen's name, target controller/action, title,
|
|
10
|
+
# and resolved params.
|
|
11
|
+
Route = Data.define(:name, :controller_class, :action, :title, :params) do
|
|
11
12
|
def with_params(params)
|
|
12
13
|
self.class.new(
|
|
13
|
-
|
|
14
|
+
name: name,
|
|
14
15
|
controller_class: controller_class,
|
|
15
16
|
action: action,
|
|
16
17
|
title: title,
|
|
@@ -19,48 +20,48 @@ module Charming
|
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
DynamicRoute = Data.define(:route, :pattern, :param_names)
|
|
23
|
-
|
|
24
23
|
# Initializes a new router with an optional namespace prefix for controller constant lookups.
|
|
25
24
|
def initialize(namespace: nil)
|
|
26
25
|
@namespace = namespace
|
|
27
26
|
@routes = {}
|
|
28
|
-
@dynamic_routes = []
|
|
29
27
|
end
|
|
30
28
|
|
|
31
|
-
# Evaluates a block in the context of this Router instance using instance_eval, allowing DSL
|
|
32
|
-
#
|
|
29
|
+
# Evaluates a block in the context of this Router instance using instance_eval, allowing DSL
|
|
30
|
+
# calls like screen and root to register routes.
|
|
31
|
+
# This is how `routes.draw { root "home#show" }` works.
|
|
33
32
|
def draw(&)
|
|
34
33
|
instance_eval(&)
|
|
35
34
|
end
|
|
36
35
|
|
|
37
|
-
# Registers the home screen
|
|
38
|
-
# Example: `root "
|
|
36
|
+
# Registers the home screen under the reserved name :root.
|
|
37
|
+
# Example: `root "home#show"` maps :root → HomeController#show with title "Home".
|
|
39
38
|
def root(target, title: "Home")
|
|
40
|
-
screen(
|
|
39
|
+
screen(:root, target, title: title)
|
|
41
40
|
end
|
|
42
41
|
|
|
43
|
-
# Maps a
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
# Maps a symbolic *name* to a controller and action (e.g. "home#show" for
|
|
43
|
+
# HomeController#show; the action defaults to :show). *title* defaults to a
|
|
44
|
+
# humanized form of the name.
|
|
45
|
+
def screen(name, target = nil, title: nil, to: nil)
|
|
46
|
+
name = screen_name(name)
|
|
47
|
+
target ||= to or raise ArgumentError, "screen :#{name} needs a target like \"home#show\""
|
|
48
|
+
controller_name, action = target.split("#", 2)
|
|
49
|
+
@routes[name] = Route.new(
|
|
50
|
+
name: name,
|
|
49
51
|
controller_class: constantize(controller_constant_name(controller_name)),
|
|
50
52
|
action: (action || "show").to_sym,
|
|
51
|
-
title: title || derive_title(
|
|
53
|
+
title: title || derive_title(name),
|
|
52
54
|
params: {}
|
|
53
55
|
)
|
|
54
|
-
@routes[path] = route
|
|
55
|
-
@dynamic_routes.reject! { |dynamic_route| dynamic_route.route.path == path }
|
|
56
|
-
@dynamic_routes << compile_dynamic_route(route) if dynamic_path?(path)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
|
-
# Resolves a
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
def resolve(
|
|
63
|
-
@routes
|
|
58
|
+
# Resolves a screen by name, returning the route with *params* attached. Raises
|
|
59
|
+
# KeyError listing the registered names when no screen matches. Used at runtime to
|
|
60
|
+
# look up the controller class and action for navigation.
|
|
61
|
+
def resolve(name = :root, params = {})
|
|
62
|
+
@routes.fetch(name.to_sym) do
|
|
63
|
+
raise KeyError, "unknown screen #{name.inspect} (registered screens: #{@routes.keys.map(&:inspect).join(", ")})"
|
|
64
|
+
end.with_params(params)
|
|
64
65
|
end
|
|
65
66
|
|
|
66
67
|
# Returns all registered routes as Route objects, ordered by insertion.
|
|
@@ -75,58 +76,39 @@ module Charming
|
|
|
75
76
|
# For example, namespace "Admin" means HomeController resolves as Admin::HomeController.
|
|
76
77
|
attr_reader :namespace
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
# Normalizes a screen name to a Symbol, rejecting legacy string URL paths with a
|
|
80
|
+
# migration hint.
|
|
81
|
+
def screen_name(name)
|
|
82
|
+
return name if name.is_a?(Symbol)
|
|
83
|
+
raise ArgumentError, string_path_hint(name, "screen") if name.is_a?(String) && name.start_with?("/")
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
param_names = []
|
|
84
|
-
segments = route.path.split("/", -1).map do |segment|
|
|
85
|
-
if segment.start_with?(":") && segment.length > 1
|
|
86
|
-
param_names << segment.delete_prefix(":").to_sym
|
|
87
|
-
"([^/]+)"
|
|
88
|
-
else
|
|
89
|
-
Regexp.escape(segment)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
DynamicRoute.new(route: route, pattern: /\A#{segments.join("/")}\z/, param_names: param_names)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def resolve_dynamic(path)
|
|
97
|
-
@dynamic_routes.each do |dynamic_route|
|
|
98
|
-
match = dynamic_route.pattern.match(path)
|
|
99
|
-
return dynamic_route.route.with_params(extract_params(dynamic_route.param_names, match.captures)) if match
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
nil
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def extract_params(names, values)
|
|
106
|
-
names.zip(values).to_h do |name, value|
|
|
107
|
-
[name, URI.decode_www_form_component(value)]
|
|
108
|
-
end
|
|
85
|
+
name.to_sym
|
|
109
86
|
end
|
|
110
87
|
|
|
111
88
|
# Looks up a constant by name in Object. Used to resolve controller strings from route definitions.
|
|
112
89
|
def constantize(name)
|
|
113
|
-
|
|
90
|
+
Internal::Inflections.constantize(name)
|
|
114
91
|
end
|
|
115
92
|
|
|
116
93
|
# Builds the full controller constant name, prepending the namespace if present.
|
|
117
94
|
# For example: "home" with namespace "Admin" → "Admin::HomeController".
|
|
118
95
|
def controller_constant_name(controller_name)
|
|
119
|
-
name = "#{
|
|
96
|
+
name = "#{Internal::Inflections.camelize(controller_name)}Controller"
|
|
120
97
|
@namespace.to_s.empty? ? name : "#{@namespace}::#{name}"
|
|
121
98
|
end
|
|
122
99
|
|
|
123
|
-
# Derives a human-readable title from a
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
def derive_title(
|
|
127
|
-
|
|
100
|
+
# Derives a human-readable title from a screen name by splitting on underscores and
|
|
101
|
+
# hyphens, capitalizing each segment, and joining with spaces.
|
|
102
|
+
# Example: :project_list → "Project List".
|
|
103
|
+
def derive_title(name)
|
|
104
|
+
name.to_s.split(/[_-]/).map(&:capitalize).join(" ")
|
|
105
|
+
end
|
|
128
106
|
|
|
129
|
-
|
|
107
|
+
# The error message for callers still passing string URL paths.
|
|
108
|
+
def string_path_hint(path, dsl)
|
|
109
|
+
suggestion = path.split("/")[1].to_s.delete_prefix(":")
|
|
110
|
+
"String URL paths were removed. Register screens by name — `#{dsl} :#{suggestion}, ...` — " \
|
|
111
|
+
"and navigate with `navigate :#{suggestion}`. See UPGRADING.md."
|
|
130
112
|
end
|
|
131
113
|
end
|
|
132
114
|
end
|
data/lib/charming/runtime.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Charming
|
|
|
16
16
|
@task_queue = Thread::Queue.new
|
|
17
17
|
@task_executor = build_task_executor(task_executor)
|
|
18
18
|
@application.task_executor = @task_executor
|
|
19
|
-
@route = resolve_route(
|
|
19
|
+
@route = resolve_route(:root)
|
|
20
20
|
@screen = backend_screen
|
|
21
21
|
@coalesce_input = @application.respond_to?(:coalesce_input?) && @application.coalesce_input?
|
|
22
22
|
@event_loop = build_event_loop
|
|
@@ -24,6 +24,7 @@ module Charming
|
|
|
24
24
|
event_loop: @event_loop,
|
|
25
25
|
bindings: -> { @route.controller_class.timer_bindings }
|
|
26
26
|
)
|
|
27
|
+
enter_controller
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# Runs the event loop: enters alt-screen, dispatches incoming events
|
|
@@ -34,10 +35,12 @@ module Charming
|
|
|
34
35
|
setup_terminal
|
|
35
36
|
install_signal_handlers
|
|
36
37
|
install_exit_hook
|
|
38
|
+
@controller.capture_loop_thread!
|
|
37
39
|
with_raw_input do
|
|
38
40
|
render(initial_response)
|
|
39
41
|
@event_loop.run { |event, more_ready| process(event, flush: !more_ready) }
|
|
40
42
|
ensure
|
|
43
|
+
exit_controller
|
|
41
44
|
restore_signal_handlers
|
|
42
45
|
@task_executor&.shutdown(timeout: 2.0)
|
|
43
46
|
@application.save_session if @application.respond_to?(:save_session)
|
|
@@ -213,38 +216,48 @@ module Charming
|
|
|
213
216
|
# Dispatches an action on the current route's controller with an optional event.
|
|
214
217
|
# Entry point from the event loop into controllers.
|
|
215
218
|
def dispatch(action, event: nil)
|
|
216
|
-
controller(event: event)
|
|
219
|
+
@controller.dispatch(action, event: event)
|
|
217
220
|
end
|
|
218
221
|
|
|
219
222
|
# Dispatches a key press to the current route's controller.
|
|
220
223
|
def dispatch_key(event)
|
|
221
|
-
controller(event
|
|
224
|
+
@controller.dispatch_key(event)
|
|
222
225
|
end
|
|
223
226
|
|
|
224
227
|
# Dispatches a timer tick to the current route's controller.
|
|
225
228
|
def dispatch_timer(event)
|
|
226
|
-
controller(event
|
|
229
|
+
@controller.dispatch_timer(event)
|
|
227
230
|
end
|
|
228
231
|
|
|
229
232
|
# Dispatches an async task result to the current route's controller.
|
|
230
233
|
def dispatch_task(event)
|
|
231
|
-
controller(event
|
|
234
|
+
@controller.dispatch_task(event)
|
|
232
235
|
end
|
|
233
236
|
|
|
234
237
|
# Dispatches a task progress report to the current route's controller.
|
|
235
238
|
def dispatch_task_progress(event)
|
|
236
|
-
controller(event
|
|
239
|
+
@controller.dispatch_task_progress(event)
|
|
237
240
|
end
|
|
238
241
|
|
|
239
242
|
# Dispatches a mouse action (click, drag, scroll) to the current route's controller.
|
|
240
243
|
def dispatch_mouse(event)
|
|
241
|
-
controller(event
|
|
244
|
+
@controller.dispatch_mouse(event)
|
|
242
245
|
end
|
|
243
246
|
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
# Constructs the controller for the current route and runs its screen_entered hook.
|
|
248
|
+
# The instance lives until the next navigation (or quit), so controller ivars hold
|
|
249
|
+
# screen-lifetime state.
|
|
250
|
+
def enter_controller
|
|
251
|
+
@controller = @route.controller_class.new(
|
|
252
|
+
application: @application, params: @route.params, screen: screen, route: @route
|
|
253
|
+
)
|
|
254
|
+
@controller.screen_entered
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Runs the current controller's screen_exited hook and releases it.
|
|
258
|
+
def exit_controller
|
|
259
|
+
@controller&.screen_exited
|
|
260
|
+
@controller = nil
|
|
248
261
|
end
|
|
249
262
|
|
|
250
263
|
# Type-based dispatcher: routes resize, task, progress, timer, mouse, paste, and key
|
|
@@ -264,47 +277,50 @@ module Charming
|
|
|
264
277
|
# Dispatches a terminal focus change to the controller's optional `focus_changed`
|
|
265
278
|
# action. Ignored when the controller doesn't define one.
|
|
266
279
|
def dispatch_focus_change(event)
|
|
267
|
-
|
|
268
|
-
return nil unless ctrl.respond_to?(:focus_changed)
|
|
280
|
+
return nil unless @controller.respond_to?(:focus_changed)
|
|
269
281
|
|
|
270
|
-
|
|
282
|
+
@controller.dispatch(:focus_changed, event: event)
|
|
271
283
|
end
|
|
272
284
|
|
|
273
285
|
# Dispatches pasted text to the current route's controller.
|
|
274
286
|
def dispatch_paste(event)
|
|
275
|
-
controller(event
|
|
287
|
+
@controller.dispatch_paste(event)
|
|
276
288
|
end
|
|
277
289
|
|
|
278
|
-
# Dispatches a resize event: updates screen dimensions
|
|
290
|
+
# Dispatches a resize event: updates screen dimensions on the live controller and
|
|
291
|
+
# re-renders the current action.
|
|
279
292
|
# The renderer's cached previous frame is invalidated and the backend is cleared so the
|
|
280
293
|
# new-dimension frame paints onto a clean alt-screen instead of overlaying stale rows.
|
|
281
294
|
def dispatch_resize(event)
|
|
282
295
|
@screen = Screen.new(width: event.width, height: event.height)
|
|
283
296
|
@renderer.invalidate if @renderer.respond_to?(:invalidate)
|
|
284
297
|
@backend.clear if @backend.respond_to?(:clear)
|
|
298
|
+
@controller.update_screen(@screen)
|
|
285
299
|
dispatch(@route.action, event: event)
|
|
286
300
|
end
|
|
287
301
|
|
|
288
|
-
# Follows navigation responses:
|
|
289
|
-
# reschedules the event loop's timers for the new controller, and
|
|
302
|
+
# Follows navigation responses: discards the current controller, resolves and enters
|
|
303
|
+
# the new route, reschedules the event loop's timers for the new controller, and
|
|
290
304
|
# dispatches that route's action.
|
|
291
305
|
def resolve_response(response)
|
|
292
306
|
return response unless response.navigate?
|
|
293
307
|
|
|
294
|
-
|
|
308
|
+
exit_controller
|
|
309
|
+
@route = resolve_route(response.name, response.params)
|
|
295
310
|
@event_loop.reset_timers(autostart_timer_bindings)
|
|
311
|
+
enter_controller
|
|
296
312
|
dispatch(@route.action)
|
|
297
313
|
end
|
|
298
314
|
|
|
299
|
-
# Resolves *
|
|
300
|
-
# first route, or to the built-in welcome screen when no
|
|
301
|
-
# (like Rails' welcome page); other unrouted
|
|
302
|
-
def resolve_route(
|
|
303
|
-
@application.routes.resolve(
|
|
315
|
+
# Resolves *name* from the app's router, attaching *params*. An unrouted :root falls
|
|
316
|
+
# back to the app's first route, or to the built-in welcome screen when no screens
|
|
317
|
+
# are defined yet (like Rails' welcome page); other unrouted names still raise.
|
|
318
|
+
def resolve_route(name, params = {})
|
|
319
|
+
@application.routes.resolve(name, params)
|
|
304
320
|
rescue KeyError
|
|
305
|
-
raise unless
|
|
321
|
+
raise unless name.to_sym == :root
|
|
306
322
|
|
|
307
|
-
@application.routes.all.first || Welcome.route
|
|
323
|
+
(@application.routes.all.first || Welcome.route).with_params(params)
|
|
308
324
|
end
|
|
309
325
|
|
|
310
326
|
# Derives Screen dimensions (width, height) from the terminal backend.
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
class
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
# Shell::Palette is the opt-in app-shell command palette: the `command` class DSL,
|
|
5
|
+
# open/close helpers, the theme picker, and key/mouse routing while the palette is
|
|
6
|
+
# open. Generated apps include it in ApplicationController when generated with the
|
|
7
|
+
# sidebar layout (`include Charming::Shell::Palette`).
|
|
8
|
+
#
|
|
9
|
+
# Palette state lives in the session, not on the controller: the palette is app-global —
|
|
10
|
+
# a command can navigate to another screen, and the open/closed state must survive the
|
|
11
|
+
# controller swap that navigation causes.
|
|
12
|
+
module Shell
|
|
13
|
+
module Palette
|
|
14
|
+
# Wires the class-level `command` DSL into the including controller.
|
|
15
|
+
def self.included(base)
|
|
16
|
+
base.extend(ClassMethods)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Class-level palette DSL: `command` entries and their inherited registry.
|
|
20
|
+
module ClassMethods
|
|
21
|
+
# Adds a CommandPalette entry with the given *label*. *action* is a method name to send on
|
|
22
|
+
# the controller, or a block to instance_exec when selected.
|
|
23
|
+
def command(label, action = nil, &block)
|
|
24
|
+
command_bindings << Components::CommandPalette::Command.new(label: label, value: block || action)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Array of registered command palette entries, inherited from superclass when undefined.
|
|
28
|
+
def command_bindings
|
|
29
|
+
@command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
10
33
|
# Opens the command palette populated with the controller's `command_bindings`. Pushes
|
|
11
34
|
# a focus scope so subsequent keys are routed to the palette.
|
|
12
35
|
def open_command_palette
|
|
@@ -32,6 +55,13 @@ module Charming
|
|
|
32
55
|
build_command_palette_from_state(session[:command_palette]) if command_palette_open?
|
|
33
56
|
end
|
|
34
57
|
|
|
58
|
+
# Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
|
|
59
|
+
def open_theme_palette
|
|
60
|
+
session[:command_palette] = command_palette_state(:themes)
|
|
61
|
+
focus.push_scope([:command_palette], origin: :command_palette)
|
|
62
|
+
render_default_action
|
|
63
|
+
end
|
|
64
|
+
|
|
35
65
|
private
|
|
36
66
|
|
|
37
67
|
# Routes the current key event to the open palette. Cancels on Escape, performs the
|
|
@@ -40,10 +70,10 @@ module Charming
|
|
|
40
70
|
palette = command_palette
|
|
41
71
|
result = palette.handle_key(event)
|
|
42
72
|
|
|
43
|
-
if result
|
|
73
|
+
if result&.cancelled?
|
|
44
74
|
close_command_palette
|
|
45
75
|
elsif selected_command?(result)
|
|
46
|
-
perform_command(result.
|
|
76
|
+
perform_command(result.value)
|
|
47
77
|
else
|
|
48
78
|
save_command_palette_state(palette)
|
|
49
79
|
render_default_action unless response
|
|
@@ -92,13 +122,15 @@ module Charming
|
|
|
92
122
|
session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
|
|
93
123
|
end
|
|
94
124
|
|
|
95
|
-
# True when a component result
|
|
125
|
+
# True when a component result carries a selected command (Result.selected).
|
|
96
126
|
def selected_command?(result)
|
|
97
|
-
result.
|
|
127
|
+
result.respond_to?(:selected?) && result.selected?
|
|
98
128
|
end
|
|
99
129
|
|
|
100
130
|
# Invokes the value (proc, lambda, or method symbol) of the selected *command*, then
|
|
101
131
|
# closes the palette unless the command was :quit or the user has re-opened it.
|
|
132
|
+
# A command that set a response keeps it; only a command that produced no response
|
|
133
|
+
# falls back to the default render.
|
|
102
134
|
def perform_command(command)
|
|
103
135
|
current_palette_state = session[:command_palette]
|
|
104
136
|
pop_command_palette_scope
|
|
@@ -106,7 +138,7 @@ module Charming
|
|
|
106
138
|
if command.value != :quit && session[:command_palette].equal?(current_palette_state)
|
|
107
139
|
session.delete(:command_palette)
|
|
108
140
|
end
|
|
109
|
-
render_default_action unless response
|
|
141
|
+
render_default_action unless response
|
|
110
142
|
end
|
|
111
143
|
|
|
112
144
|
# Returns the theme-switching commands used by the theme picker palette.
|