charming 0.2.3 → 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 +3 -1
- data/lib/charming/application.rb +98 -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 +79 -17
- 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/timers.rb +25 -0
- data/lib/charming/controller.rb +253 -60
- 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/event_loop.rb +25 -2
- 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/internal/timer_control.rb +48 -0
- 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/projectile.rb +64 -0
- 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 +54 -28
- 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/spring.rb +126 -0
- 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 +20 -6
- metadata +25 -70
- data/lib/charming/controller/component_dispatching.rb +0 -125
|
@@ -62,14 +62,14 @@ module Charming
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# Handles mouse events: scroll wheel adjusts the row offset, click moves the top
|
|
65
|
-
# visible row to the clicked position. Returns
|
|
65
|
+
# visible row to the clicked position. Returns Result.handled on success.
|
|
66
66
|
def handle_mouse(event)
|
|
67
67
|
return nil unless height
|
|
68
68
|
|
|
69
69
|
if event.scroll?
|
|
70
70
|
scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
|
|
71
71
|
position.move_to(offset + scroll_delta, bounds)
|
|
72
|
-
return
|
|
72
|
+
return Result.handled
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
return nil unless event.click?
|
|
@@ -78,7 +78,7 @@ module Charming
|
|
|
78
78
|
return nil if clicked_row < 0 || clicked_row >= viewport_height
|
|
79
79
|
|
|
80
80
|
position.move_to(offset + clicked_row, bounds)
|
|
81
|
-
|
|
81
|
+
Result.handled
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
private
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
3
5
|
module Charming
|
|
4
6
|
module Layout
|
|
5
7
|
# Pane is a leaf layout node: a single rectangle with optional border, padding, and
|
|
@@ -7,9 +9,11 @@ module Charming
|
|
|
7
9
|
# view's context). Panes with a `name` and `behavior.focus: true` are registered as
|
|
8
10
|
# focusable slots in the controller's focus ring.
|
|
9
11
|
class Pane
|
|
12
|
+
extend Forwardable
|
|
13
|
+
|
|
10
14
|
attr_reader :name
|
|
11
|
-
|
|
12
|
-
:min_width, :max_width, :min_height, :max_height
|
|
15
|
+
def_delegators :geometry, :width, :height, :grow,
|
|
16
|
+
:min_width, :max_width, :min_height, :max_height
|
|
13
17
|
|
|
14
18
|
# *name* is the focus slot identifier. *content* (or a *block*) is the body; *view*
|
|
15
19
|
# is the view used for instance_exec when the block is given. *geometry*, *style*, and
|
|
@@ -49,6 +49,13 @@ module Charming
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Returns the rendered frame plus registration data (focusable pane names, mouse
|
|
53
|
+
# targets) as RenderArtifacts — the pure-render counterpart to #render that views
|
|
54
|
+
# use so dispatch, not rendering, commits registrations.
|
|
55
|
+
def render_with_artifacts
|
|
56
|
+
RenderArtifacts.new(frame: render, focus_slots: focusable_names, mouse_targets: mouse_targets)
|
|
57
|
+
end
|
|
58
|
+
|
|
52
59
|
private
|
|
53
60
|
|
|
54
61
|
# The screen, background style, the single child, and the list of overlays.
|
|
@@ -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,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Projectile is simple physics motion for games: a position advanced by a
|
|
5
|
+
# velocity, and a velocity advanced by an acceleration (typically gravity),
|
|
6
|
+
# one fixed time step per frame (semi-implicit Euler, matching
|
|
7
|
+
# charmbracelet/harmonica's projectile):
|
|
8
|
+
#
|
|
9
|
+
# ball = Charming::Projectile.new(
|
|
10
|
+
# delta_time: Charming.fps(60),
|
|
11
|
+
# position: Charming::Projectile::Point.new(x: 0.0, y: 0.0),
|
|
12
|
+
# velocity: Charming::Projectile::Vector.new(x: 20.0, y: 0.0),
|
|
13
|
+
# acceleration: Charming::Projectile::TERMINAL_GRAVITY
|
|
14
|
+
# )
|
|
15
|
+
# position = ball.update # each frame
|
|
16
|
+
class Projectile
|
|
17
|
+
Point = Data.define(:x, :y, :z) do
|
|
18
|
+
def initialize(x:, y:, z: 0.0)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Vector = Data.define(:x, :y, :z) do
|
|
24
|
+
def initialize(x:, y:, z: 0.0)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gravity for a coordinate plane whose origin is bottom-left (y grows upward).
|
|
30
|
+
GRAVITY = Vector.new(x: 0.0, y: -9.81)
|
|
31
|
+
|
|
32
|
+
# Gravity for terminal coordinates, whose origin is top-left (y grows downward).
|
|
33
|
+
TERMINAL_GRAVITY = Vector.new(x: 0.0, y: 9.81)
|
|
34
|
+
|
|
35
|
+
attr_reader :position, :velocity, :acceleration
|
|
36
|
+
|
|
37
|
+
def initialize(delta_time:, position:, velocity: Vector.new(x: 0.0, y: 0.0), acceleration: Vector.new(x: 0.0, y: 0.0))
|
|
38
|
+
@delta_time = delta_time
|
|
39
|
+
@position = position
|
|
40
|
+
@velocity = velocity
|
|
41
|
+
@acceleration = acceleration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Advances one frame and returns the new position. Position moves by the
|
|
45
|
+
# current velocity before the velocity accelerates — keep this order.
|
|
46
|
+
def update
|
|
47
|
+
@position = shift(position, velocity)
|
|
48
|
+
@velocity = shift(velocity, acceleration)
|
|
49
|
+
position
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :delta_time
|
|
55
|
+
|
|
56
|
+
def shift(value, rate)
|
|
57
|
+
value.with(
|
|
58
|
+
x: value.x + rate.x * delta_time,
|
|
59
|
+
y: value.y + rate.y * delta_time,
|
|
60
|
+
z: value.z + rate.z * delta_time
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
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
|