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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/lib/charming/application.rb +98 -14
  4. data/lib/charming/application_state.rb +23 -0
  5. data/lib/charming/cli.rb +2 -2
  6. data/lib/charming/controller/action_hooks.rb +5 -1
  7. data/lib/charming/controller/class_methods.rb +79 -17
  8. data/lib/charming/controller/component_dispatch.rb +163 -0
  9. data/lib/charming/controller/dispatching.rb +1 -2
  10. data/lib/charming/controller/focus_management.rb +67 -1
  11. data/lib/charming/controller/key_dispatch.rb +7 -7
  12. data/lib/charming/controller/rendering.rb +22 -6
  13. data/lib/charming/controller/session_state.rb +63 -22
  14. data/lib/charming/controller/timers.rb +25 -0
  15. data/lib/charming/controller.rb +253 -60
  16. data/lib/charming/cross_thread_access.rb +9 -0
  17. data/lib/charming/double_render_error.rb +8 -0
  18. data/lib/charming/generators/layout_generator.rb +4 -1
  19. data/lib/charming/generators/migration_generator.rb +1 -1
  20. data/lib/charming/generators/model_generator.rb +2 -2
  21. data/lib/charming/generators/name.rb +1 -1
  22. data/lib/charming/generators/screen_generator.rb +2 -2
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/deep_freeze.rb +23 -0
  25. data/lib/charming/internal/env_inquirer.rb +22 -0
  26. data/lib/charming/internal/event_loop.rb +25 -2
  27. data/lib/charming/internal/inflections.rb +93 -0
  28. data/lib/charming/internal/session_guard.rb +27 -0
  29. data/lib/charming/internal/terminal/cursor.rb +29 -0
  30. data/lib/charming/internal/terminal/size.rb +47 -0
  31. data/lib/charming/internal/terminal/tty_backend.rb +10 -8
  32. data/lib/charming/internal/timer_control.rb +48 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +14 -6
  34. data/lib/charming/presentation/components/command_palette.rb +11 -9
  35. data/lib/charming/presentation/components/filepicker.rb +4 -4
  36. data/lib/charming/presentation/components/form/confirm.rb +2 -1
  37. data/lib/charming/presentation/components/form/field.rb +1 -1
  38. data/lib/charming/presentation/components/form/input.rb +3 -3
  39. data/lib/charming/presentation/components/form/multiselect.rb +4 -5
  40. data/lib/charming/presentation/components/form/select.rb +3 -3
  41. data/lib/charming/presentation/components/form/textarea.rb +3 -3
  42. data/lib/charming/presentation/components/form.rb +6 -6
  43. data/lib/charming/presentation/components/help_overlay.rb +2 -2
  44. data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
  45. data/lib/charming/presentation/components/list.rb +14 -5
  46. data/lib/charming/presentation/components/modal.rb +3 -2
  47. data/lib/charming/presentation/components/multi_select_list.rb +14 -7
  48. data/lib/charming/presentation/components/result.rb +61 -0
  49. data/lib/charming/presentation/components/tab_bar.rb +14 -6
  50. data/lib/charming/presentation/components/table.rb +64 -32
  51. data/lib/charming/presentation/components/text_area.rb +7 -7
  52. data/lib/charming/presentation/components/text_input.rb +7 -7
  53. data/lib/charming/presentation/components/tree.rb +15 -6
  54. data/lib/charming/presentation/components/viewport.rb +3 -3
  55. data/lib/charming/presentation/layout/pane.rb +6 -2
  56. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  57. data/lib/charming/presentation/view.rb +35 -29
  58. data/lib/charming/projectile.rb +64 -0
  59. data/lib/charming/render_artifacts.rb +24 -0
  60. data/lib/charming/response.rb +19 -8
  61. data/lib/charming/router.rb +50 -68
  62. data/lib/charming/runtime.rb +54 -28
  63. data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
  64. data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
  65. data/lib/charming/spring.rb +126 -0
  66. data/lib/charming/tasks/context.rb +35 -0
  67. data/lib/charming/test_helper.rb +42 -22
  68. data/lib/charming/unhandled_component_event.rb +9 -0
  69. data/lib/charming/unknown_slot.rb +9 -0
  70. data/lib/charming/version.rb +1 -1
  71. data/lib/charming/welcome.rb +1 -1
  72. data/lib/charming.rb +20 -6
  73. metadata +25 -70
  74. 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 :handled on success.
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 :handled
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
- :handled
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
- delegate :width, :height, :grow,
12
- :min_width, :max_width, :min_height, :max_height, to: :geometry
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 injected as instance-local accessor methods via
9
- # `define_singleton_method`. Called when a controller instantiates a view for rendering.
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
- # Returns the shared UI style configuration used by components and views for visual rendering (colors, borders).
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.style
45
+ UI::Style.new
38
46
  end
39
47
 
40
- # Returns the active theme: uses `theme` from assigns or controller, falling back to `UI::Theme.default`.
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 || UI::Theme.default
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
- register_layout_focus(layout)
86
- register_layout_mouse_targets(layout)
87
- layout.render
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
- # Dynamically defines read-only accessor methods for each assign key as singleton methods on self.
117
- # Skips keys where the view already responds (controller methods take precedence).
118
- def define_assign_readers
119
- assigns.each_key do |name|
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
- define_singleton_method(name) { assigns.fetch(name) }
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
- def register_layout_focus(layout)
131
- return unless assigns[:controller]
132
-
133
- assigns[:controller].focus.define_layout(layout.focusable_names)
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 register_layout_mouse_targets(layout)
137
- return unless assigns[:controller]
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
@@ -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 route, or quit.
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
- Response = Data.define(:kind, :body, :path, :escapes) do
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, path: nil, escapes: escapes)
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 named *path* (string).
19
- def self.navigate(path)
20
- new(kind: :navigate, body: "", path: path, escapes: [])
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: "", path: nil, escapes: [])
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 or route.
39
+ # Returns `true` when this response is navigating to another screen.
29
40
  def navigate?
30
41
  kind == :navigate
31
42
  end
@@ -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 route table and provides a Rails-inspired DSL for defining routes.
7
- # Each route maps a URL path to a controller, action (implicitly :show), and title (for sidebar display).
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 route's path template, target controller/action, title, and resolved params.
10
- Route = Data.define(:path, :controller_class, :action, :title, :params) do
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
- path: path,
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 calls like screen and root to register routes.
32
- # This is how `routes.draw { screen "/", to: "HomeController", title: "Home" }` works.
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 at "/" with a given title. Shorthand for `screen path, to: target`.
38
- # Example: `root "HomeController"` maps `/` → HomeController#show with title "Home".
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("/", to: target, title: title)
39
+ screen(:root, target, title: title)
41
40
  end
42
41
 
43
- # Maps a URL path to a controller and action (e.g. "HomeController" for HomeController#show).
44
- # Builds a Route object from the path, resolved controller constant, parsed action, and an optional or derived title.
45
- def screen(path, to:, title: nil)
46
- controller_name, action = to.split("#", 2)
47
- route = Route.new(
48
- path: path,
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(path),
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 route by path from the router's table. Exact routes win over dynamic routes.
60
- # Raises KeyError if no route matches.
61
- # Used at runtime to look up the controller class and action for incoming requests.
62
- def resolve(path = "/")
63
- @routes[path] || resolve_dynamic(path) || raise(KeyError, "key not found: #{path.inspect}")
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
- def dynamic_path?(path)
79
- path.split("/").any? { |segment| segment.start_with?(":") && segment.length > 1 }
80
- end
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
- def compile_dynamic_route(route)
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
- ActiveSupport::Inflector.constantize(name)
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 = "#{ActiveSupport::Inflector.camelize(controller_name)}Controller"
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 URL path by stripping the leading slash,
124
- # splitting on underscores/hyphens/slashes, capitalizing each segment, and joining with spaces.
125
- # Examples: "/projects" → "Projects", "/projects/list" → "Projects List".
126
- def derive_title(path)
127
- return "Home" if path == "/"
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
- path.delete_prefix("/").split(%r{[_\-/]}).map(&:capitalize).join(" ")
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