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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/lib/charming/application.rb +91 -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 +66 -15
  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.rb +247 -59
  15. data/lib/charming/cross_thread_access.rb +9 -0
  16. data/lib/charming/double_render_error.rb +8 -0
  17. data/lib/charming/generators/layout_generator.rb +4 -1
  18. data/lib/charming/generators/migration_generator.rb +1 -1
  19. data/lib/charming/generators/model_generator.rb +2 -2
  20. data/lib/charming/generators/name.rb +1 -1
  21. data/lib/charming/generators/screen_generator.rb +2 -2
  22. data/lib/charming/generators/view_generator.rb +1 -1
  23. data/lib/charming/internal/deep_freeze.rb +23 -0
  24. data/lib/charming/internal/env_inquirer.rb +22 -0
  25. data/lib/charming/internal/inflections.rb +93 -0
  26. data/lib/charming/internal/session_guard.rb +27 -0
  27. data/lib/charming/internal/terminal/cursor.rb +29 -0
  28. data/lib/charming/internal/terminal/size.rb +47 -0
  29. data/lib/charming/internal/terminal/tty_backend.rb +10 -8
  30. data/lib/charming/presentation/components/autocomplete.rb +14 -6
  31. data/lib/charming/presentation/components/command_palette.rb +11 -9
  32. data/lib/charming/presentation/components/filepicker.rb +4 -4
  33. data/lib/charming/presentation/components/form/confirm.rb +2 -1
  34. data/lib/charming/presentation/components/form/field.rb +1 -1
  35. data/lib/charming/presentation/components/form/input.rb +3 -3
  36. data/lib/charming/presentation/components/form/multiselect.rb +4 -5
  37. data/lib/charming/presentation/components/form/select.rb +3 -3
  38. data/lib/charming/presentation/components/form/textarea.rb +3 -3
  39. data/lib/charming/presentation/components/form.rb +6 -6
  40. data/lib/charming/presentation/components/help_overlay.rb +2 -2
  41. data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
  42. data/lib/charming/presentation/components/list.rb +14 -5
  43. data/lib/charming/presentation/components/modal.rb +3 -2
  44. data/lib/charming/presentation/components/multi_select_list.rb +14 -7
  45. data/lib/charming/presentation/components/result.rb +61 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +14 -6
  47. data/lib/charming/presentation/components/table.rb +64 -32
  48. data/lib/charming/presentation/components/text_area.rb +7 -7
  49. data/lib/charming/presentation/components/text_input.rb +7 -7
  50. data/lib/charming/presentation/components/tree.rb +15 -6
  51. data/lib/charming/presentation/components/viewport.rb +3 -3
  52. data/lib/charming/presentation/layout/pane.rb +6 -2
  53. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  54. data/lib/charming/presentation/view.rb +35 -29
  55. data/lib/charming/render_artifacts.rb +24 -0
  56. data/lib/charming/response.rb +19 -8
  57. data/lib/charming/router.rb +50 -68
  58. data/lib/charming/runtime.rb +42 -26
  59. data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
  60. data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
  61. data/lib/charming/tasks/context.rb +35 -0
  62. data/lib/charming/test_helper.rb +42 -22
  63. data/lib/charming/unhandled_component_event.rb +9 -0
  64. data/lib/charming/unknown_slot.rb +9 -0
  65. data/lib/charming/version.rb +1 -1
  66. data/lib/charming/welcome.rb +1 -1
  67. data/lib/charming.rb +14 -6
  68. metadata +21 -70
  69. 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 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,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
@@ -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).dispatch(action)
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: event).dispatch_key
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: event).dispatch_timer
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: event).dispatch_task
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: event).dispatch_task_progress
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: event).dispatch_mouse
244
+ @controller.dispatch_mouse(event)
242
245
  end
243
246
 
244
- # Instantiates a fresh controller for the active route, passing the application, current *event*,
245
- # route params, screen dimensions, and route object. Called by every dispatch path.
246
- def controller(event: nil)
247
- @route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen, route: @route)
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
- ctrl = controller(event: event)
268
- return nil unless ctrl.respond_to?(:focus_changed)
280
+ return nil unless @controller.respond_to?(:focus_changed)
269
281
 
270
- ctrl.dispatch(:focus_changed)
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: event).dispatch_paste
287
+ @controller.dispatch_paste(event)
276
288
  end
277
289
 
278
- # Dispatches a resize event: updates screen dimensions and re-renders the current action.
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: resolves the new route from the router,
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
- @route = resolve_route(response.path)
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 *path* from the app's router. An unrouted "/" falls back to the app's
300
- # first route, or to the built-in welcome screen when no routes are defined yet
301
- # (like Rails' welcome page); other unrouted paths still raise.
302
- def resolve_route(path)
303
- @application.routes.resolve(path)
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 path == "/"
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 Controller
5
- # Command palette helpers mixed into Controller. Opens/closes the palette, builds the
6
- # palette from registered command bindings or theme list, and routes key/mouse events
7
- # through it. Supports both the standard command palette (:commands) and the theme picker
8
- # (:themes) via a discriminated `session[:command_palette]` state hash.
9
- module CommandPalette
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 == :cancelled
73
+ if result&.cancelled?
44
74
  close_command_palette
45
75
  elsif selected_command?(result)
46
- perform_command(result.last)
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 is the `[:selected, command]` array shape.
125
+ # True when a component result carries a selected command (Result.selected).
96
126
  def selected_command?(result)
97
- result.is_a?(Array) && result.first == :selected
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&.navigate? || response&.quit?
141
+ render_default_action unless response
110
142
  end
111
143
 
112
144
  # Returns the theme-switching commands used by the theme picker palette.