Class: Charming::Controller
- Inherits:
-
Object
- Object
- Charming::Controller
- Defined in:
- lib/charming/controller.rb
Overview
Controller is the base class for all controller implementations in a Charming application. It provides the action dispatch pipeline, key/command/timer/task bindings, sidebar navigation, command palette management, and view rendering with layout composition.
Defined Under Namespace
Classes: TaskBinding, TimerBinding
Instance Attribute Summary collapse
-
#application ⇒ Object
readonly
Returns the value of attribute application.
-
#event ⇒ Object
readonly
Returns the value of attribute event.
-
#params ⇒ Object
readonly
Returns the value of attribute params.
-
#route ⇒ Object
readonly
Returns the value of attribute route.
-
#screen ⇒ Object
readonly
Returns the value of attribute screen.
Class Method Summary collapse
-
.auto_render(action = :show) ⇒ Object
Re-renders the given action after dispatched actions that do not set a response.
- .auto_render_action ⇒ Object
-
.command(label, action = nil, &block) ⇒ Object
Registers a command palette entry — visible in fuzzy search when Ctrl+K is pressed.
-
.command_bindings ⇒ Object
Returns inherited command bindings (command palette entries) from this controller and its ancestors.
-
.focus_ring(*slots) ⇒ Object
Registers a focus ring slot for this controller — slots participate in Tab/Shift+Tab traversal.
-
.focus_ring_slots ⇒ Object
Returns inherited focus ring slots merged from the class hierarchy.
-
.key(name, action, scope: :content) ⇒ Object
Registers a key binding (string or symbol key name → method symbol).
-
.key_binding_scopes ⇒ Object
Returns inherited key binding scopes merged from the class hierarchy.
-
.key_bindings ⇒ Object
Returns inherited key bindings merged from the class hierarchy.
-
.layout(layout_class = :__charming_layout_reader__) ⇒ Object
Sets the layout class to wrap this controller’s rendered output (e.g., for sidebar + main content).
-
.on_task(name, action:) ⇒ Object
Registers an async task handler that runs when a TaskEvent arrives from the task executor.
-
.task_bindings ⇒ Object
Returns inherited task bindings (async task handlers) from this controller and its ancestors.
-
.timer(name, every:, action:) ⇒ Object
Registers a periodic timer that fires at ‘every`-second intervals.
-
.timer_bindings ⇒ Object
Returns inherited timer bindings from this controller and its ancestors.
Instance Method Summary collapse
-
#close_command_palette ⇒ Object
Closes the command palette: removes its state from the session, pops its scope from the focus ring, and re-renders the default action.
-
#command_palette ⇒ Object
Returns a command palette component rebuilt from the current primitive session state, if open.
-
#command_palette_open? ⇒ Boolean
Returns whether the command palette is active in the current session.
-
#content_focused? ⇒ Boolean
Returns whether the main content area currently has focus (from focus ring or session state).
- #current_route?(candidate) ⇒ Boolean
-
#dispatch(action) ⇒ Object
Dispatches a named action on this controller (e.g., :show).
-
#dispatch_key ⇒ Object
Key event dispatch pipeline for controllers: checks command palette first (if open), then global key bindings, then sidebar (if focused), then content-scoped key bindings, then tab traversal, then focused component handling.
-
#dispatch_mouse ⇒ Object
Mouse event dispatcher: checks command palette (if open), then sidebar (if focused), then falls through to component mouse dispatch.
-
#dispatch_task ⇒ Object
Task event dispatcher: looks up the event’s named handler in this controller’s task bindings and dispatches it.
-
#dispatch_timer ⇒ Object
Timer event dispatcher: looks up the event’s named action in this controller’s timer bindings and dispatches it.
-
#focus ⇒ Object
Returns or lazily initializes the Focus instance for this controller, which manages keyboard-driven focus traversal between components (sidebar, content, etc.).
-
#focus_content ⇒ Object
Shifts focus back to the main content area: moves the focus ring cursor or sets ‘session` to :content, and re-renders.
-
#focus_sidebar ⇒ Object
Shifts focus to the sidebar: moves the focus ring cursor or sets ‘session` to :sidebar, highlights the current route index, and re-renders.
-
#focused?(slot) ⇒ Boolean
Checks whether the given focus slot (e.g., :sidebar, :content) is currently focused.
- #form(name, &block) ⇒ Object
-
#initialize(application:, event: nil, params: {}, screen: nil, route: nil) ⇒ Controller
constructor
Initializes the controller with its parent application and an optional event (key/mouse/timer/task data).
-
#navigate_to(path) ⇒ Object
Responds with a navigation redirect to the given URL path.
-
#open_command_palette ⇒ Object
Opens the command palette (fuzzy search UI): stores primitive palette state and pushes a palette scope onto the focus ring so input is captured inside it.
- #open_theme_palette ⇒ Object
-
#quit ⇒ Object
Exits the application — sets a quit response that terminates the event loop.
-
#render(body = "", **assigns) ⇒ Object
Renders a body or template wrapped in this controller’s layout (if one is defined) and stores the response.
- #render_template(name, **assigns) ⇒ Object
-
#run_task(name, &block) ⇒ Object
Submits an async task to the application’s task executor (threaded or inline).
-
#session ⇒ Object
Returns the parent application’s session hash for per-request state storage (e.g., form data, flags).
-
#sidebar_focused? ⇒ Boolean
Returns whether the sidebar currently has focus (from focus ring or session state).
-
#sidebar_index ⇒ Object
Returns the currently highlighted sidebar item index, falling back to the current route’s position when no explicit sidebar selection has been made yet.
- #sidebar_routes ⇒ Object
-
#state(name, state_class, **attributes) ⇒ Object
Lazily instantiates a state class and caches it in the session under ‘:states`.
- #theme ⇒ Object
- #use_theme(name) ⇒ Object
Constructor Details
#initialize(application:, event: nil, params: {}, screen: nil, route: nil) ⇒ Controller
Initializes the controller with its parent application and an optional event (key/mouse/timer/task data). Defaults to a 80x24 screen when no backend size is available.
120 121 122 123 124 125 126 127 |
# File 'lib/charming/controller.rb', line 120 def initialize(application:, event: nil, params: {}, screen: nil, route: nil) @application = application @event = event @params = params @screen = screen || Screen.new(width: 80, height: 24) @route = route @response = nil end |
Instance Attribute Details
#application ⇒ Object (readonly)
Returns the value of attribute application.
116 117 118 |
# File 'lib/charming/controller.rb', line 116 def application @application end |
#event ⇒ Object (readonly)
Returns the value of attribute event.
116 117 118 |
# File 'lib/charming/controller.rb', line 116 def event @event end |
#params ⇒ Object (readonly)
Returns the value of attribute params.
116 117 118 |
# File 'lib/charming/controller.rb', line 116 def params @params end |
#route ⇒ Object (readonly)
Returns the value of attribute route.
116 117 118 |
# File 'lib/charming/controller.rb', line 116 def route @route end |
#screen ⇒ Object (readonly)
Returns the value of attribute screen.
116 117 118 |
# File 'lib/charming/controller.rb', line 116 def screen @screen end |
Class Method Details
.auto_render(action = :show) ⇒ Object
Re-renders the given action after dispatched actions that do not set a response. This is opt-in so existing controllers keep explicit render semantics.
40 41 42 |
# File 'lib/charming/controller.rb', line 40 def auto_render(action = :show) @auto_render_action = action.to_sym end |
.auto_render_action ⇒ Object
44 45 46 47 48 49 |
# File 'lib/charming/controller.rb', line 44 def auto_render_action return @auto_render_action if instance_variable_defined?(:@auto_render_action) return superclass.auto_render_action if superclass.respond_to?(:auto_render_action) nil end |
.command(label, action = nil, &block) ⇒ Object
Registers a command palette entry — visible in fuzzy search when Ctrl+K is pressed. Accepts either a method symbol or an inline callable block.
23 24 25 |
# File 'lib/charming/controller.rb', line 23 def command(label, action = nil, &block) command_bindings << Presentation::Components::CommandPalette::Command.new(label: label, value: block || action) end |
.command_bindings ⇒ Object
Returns inherited command bindings (command palette entries) from this controller and its ancestors.
83 84 85 |
# File 'lib/charming/controller.rb', line 83 def command_bindings @command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : [] end |
.focus_ring(*slots) ⇒ Object
Registers a focus ring slot for this controller — slots participate in Tab/Shift+Tab traversal. Example: ‘focus_ring :sidebar, :content` makes sidebar and content tabbable.
73 74 75 |
# File 'lib/charming/controller.rb', line 73 def focus_ring(*slots) @focus_ring_slots = slots end |
.focus_ring_slots ⇒ Object
Returns inherited focus ring slots merged from the class hierarchy.
78 79 80 |
# File 'lib/charming/controller.rb', line 78 def focus_ring_slots @focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : [] end |
.key(name, action, scope: :content) ⇒ Object
Registers a key binding (string or symbol key name → method symbol). Content-scoped bindings run from the main content pane; global bindings run from any pane.
14 15 16 17 18 19 |
# File 'lib/charming/controller.rb', line 14 def key(name, action, scope: :content) normalized_scope = validate_key_scope(scope) key_name = name.to_sym key_bindings[key_name] = action key_binding_scopes[key_name] = normalized_scope end |
.key_binding_scopes ⇒ Object
Returns inherited key binding scopes merged from the class hierarchy. Each subclass gets a fresh copy of its parent’s scopes to match key binding inheritance.
67 68 69 |
# File 'lib/charming/controller.rb', line 67 def key_binding_scopes @key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {} end |
.key_bindings ⇒ Object
Returns inherited key bindings merged from the class hierarchy. Each subclass gets a fresh copy of its parent’s key bindings to avoid cross-controller pollution.
61 62 63 |
# File 'lib/charming/controller.rb', line 61 def key_bindings @key_bindings ||= superclass.respond_to?(:key_bindings) ? superclass.key_bindings.dup : {} end |
.layout(layout_class = :__charming_layout_reader__) ⇒ Object
Sets the layout class to wrap this controller’s rendered output (e.g., for sidebar + main content). Accepts a special ‘:charming_layout_reader` sentinel to query — without setting — the current layout.
53 54 55 56 57 |
# File 'lib/charming/controller.rb', line 53 def layout(layout_class = :__charming_layout_reader__) return resolved_layout if layout_class == :__charming_layout_reader__ @layout = layout_class end |
.on_task(name, action:) ⇒ Object
Registers an async task handler that runs when a TaskEvent arrives from the task executor.
34 35 36 |
# File 'lib/charming/controller.rb', line 34 def on_task(name, action:) task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action) end |
.task_bindings ⇒ Object
Returns inherited task bindings (async task handlers) from this controller and its ancestors.
93 94 95 |
# File 'lib/charming/controller.rb', line 93 def task_bindings @task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {} end |
.timer(name, every:, action:) ⇒ Object
Registers a periodic timer that fires at ‘every`-second intervals. The named action is dispatched on the current route’s controller each time.
29 30 31 |
# File 'lib/charming/controller.rb', line 29 def timer(name, every:, action:) timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action) end |
.timer_bindings ⇒ Object
Returns inherited timer bindings from this controller and its ancestors.
88 89 90 |
# File 'lib/charming/controller.rb', line 88 def timer_bindings @timer_bindings ||= superclass.respond_to?(:timer_bindings) ? superclass.timer_bindings.dup : {} end |
Instance Method Details
#close_command_palette ⇒ Object
Closes the command palette: removes its state from the session, pops its scope from the focus ring, and re-renders the default action. Pops all nested scopes until only the palette remains.
248 249 250 251 252 |
# File 'lib/charming/controller.rb', line 248 def close_command_palette session.delete(:command_palette) pop_command_palette_scope render_default_action end |
#command_palette ⇒ Object
Returns a command palette component rebuilt from the current primitive session state, if open.
274 275 276 |
# File 'lib/charming/controller.rb', line 274 def command_palette build_command_palette_from_state(session[:command_palette]) if command_palette_open? end |
#command_palette_open? ⇒ Boolean
Returns whether the command palette is active in the current session.
269 270 271 |
# File 'lib/charming/controller.rb', line 269 def command_palette_open? session.key?(:command_palette) end |
#content_focused? ⇒ Boolean
Returns whether the main content area currently has focus (from focus ring or session state).
309 310 311 312 313 |
# File 'lib/charming/controller.rb', line 309 def content_focused? return focused?(:content) if focus_ring_slot?(:content) session[:focus] == :content end |
#current_route?(candidate) ⇒ Boolean
325 326 327 328 329 330 331 |
# File 'lib/charming/controller.rb', line 325 def current_route?(candidate) return candidate.controller_class == self.class && candidate.action == :show unless route candidate.path == route.path && candidate.controller_class == route.controller_class && candidate.action == route.action end |
#dispatch(action) ⇒ Object
Dispatches a named action on this controller (e.g., :show). Calls the method via public_send, returning a default empty render if the action produces no response.
131 132 133 134 135 |
# File 'lib/charming/controller.rb', line 131 def dispatch(action) public_send(action) render_default_action if response.nil? && auto_render_after?(action) response || render("") end |
#dispatch_key ⇒ Object
Key event dispatch pipeline for controllers: checks command palette first (if open), then global key bindings, then sidebar (if focused), then content-scoped key bindings, then tab traversal, then focused component handling. Returns nil if no handler consumed the event.
140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/charming/controller.rb', line 140 def dispatch_key return dispatch_command_palette_key if command_palette_open? return dispatch(global_key_action) if global_key_action return if return dispatch(content_key_action) if content_key_action return response if dispatch_tab_traversal == :handled return response if dispatch_to_focused_component == :handled nil end |
#dispatch_mouse ⇒ Object
Mouse event dispatcher: checks command palette (if open), then sidebar (if focused), then falls through to component mouse dispatch. Always returns nil in the base controller —subclasses override as needed.
169 170 171 172 173 174 |
# File 'lib/charming/controller.rb', line 169 def dispatch_mouse return dispatch_command_palette_mouse if command_palette_open? return if dispatch_component_mouse end |
#dispatch_task ⇒ Object
Task event dispatcher: looks up the event’s named handler in this controller’s task bindings and dispatches it. Used by async tasks submitted via ‘run_task`.
161 162 163 164 |
# File 'lib/charming/controller.rb', line 161 def dispatch_task binding = self.class.task_bindings[event.name.to_sym] binding ? dispatch(binding.action) : nil end |
#dispatch_timer ⇒ Object
Timer event dispatcher: looks up the event’s named action in this controller’s timer bindings and dispatches it. Returns nil if no binding exists for this timer name.
154 155 156 157 |
# File 'lib/charming/controller.rb', line 154 def dispatch_timer binding = self.class.timer_bindings[event.name.to_sym] binding ? dispatch(binding.action) : nil end |
#focus ⇒ Object
Returns or lazily initializes the Focus instance for this controller, which manages keyboard-driven focus traversal between components (sidebar, content, etc.). Defines focus ring slots from class-level declarations on first access.
257 258 259 260 261 |
# File 'lib/charming/controller.rb', line 257 def focus @focus ||= Focus.for(session, self.class).tap do |f| f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty? end end |
#focus_content ⇒ Object
Shifts focus back to the main content area: moves the focus ring cursor or sets ‘session` to :content, and re-renders. Used by Escape key from sidebar and other navigation transitions.
292 293 294 295 296 297 298 299 |
# File 'lib/charming/controller.rb', line 292 def focus_content if focus_ring_slot?(:content) focus.focus(:content) else session[:focus] = :content end render_default_action end |
#focus_sidebar ⇒ Object
Shifts focus to the sidebar: moves the focus ring cursor or sets ‘session` to :sidebar, highlights the current route index, and re-renders. Sidebar selection uses j/k keys.
280 281 282 283 284 285 286 287 288 |
# File 'lib/charming/controller.rb', line 280 def if focus_ring_slot?(:sidebar) focus.focus(:sidebar) else session[:focus] = :sidebar end session[:sidebar_index] ||= current_route_index render_default_action end |
#focused?(slot) ⇒ Boolean
Checks whether the given focus slot (e.g., :sidebar, :content) is currently focused.
264 265 266 |
# File 'lib/charming/controller.rb', line 264 def focused?(slot) focus.focused?(slot) end |
#form(name, &block) ⇒ Object
224 225 226 227 228 229 230 |
# File 'lib/charming/controller.rb', line 224 def form(name, &block) session[:forms] ||= {} form_state = session[:forms][name.to_sym] ||= {} builder = Presentation::Components::Form::Builder.new(theme: theme) block.arity.zero? ? builder.instance_eval(&block) : block.call(builder) builder.build(state: form_state, theme: theme) end |
#navigate_to(path) ⇒ Object
Responds with a navigation redirect to the given URL path. Used for route transitions triggered from controllers (e.g., sidebar selection).
203 204 205 |
# File 'lib/charming/controller.rb', line 203 def navigate_to(path) @response = Response.navigate(path) end |
#open_command_palette ⇒ Object
Opens the command palette (fuzzy search UI): stores primitive palette state and pushes a palette scope onto the focus ring so input is captured inside it. Renders the default action afterward.
240 241 242 243 244 |
# File 'lib/charming/controller.rb', line 240 def open_command_palette session[:command_palette] = command_palette_state(:commands) focus.push_scope([:command_palette], origin: :command_palette) render_default_action end |
#open_theme_palette ⇒ Object
195 196 197 198 199 |
# File 'lib/charming/controller.rb', line 195 def open_theme_palette session[:command_palette] = command_palette_state(:themes) focus.push_scope([:command_palette], origin: :command_palette) render_default_action end |
#quit ⇒ Object
Exits the application — sets a quit response that terminates the event loop.
208 209 210 |
# File 'lib/charming/controller.rb', line 208 def quit @response = Response.quit end |
#render(body = "", **assigns) ⇒ Object
Renders a body or template wrapped in this controller’s layout (if one is defined) and stores the response. Symbols render ‘app/views/<controller>/<symbol>.tui.erb` (or `.txt.erb`); strings render as literal bodies.
178 179 180 181 |
# File 'lib/charming/controller.rb', line 178 def render(body = "", **assigns) body = template_body(default_template_name(body), **assigns) if body.is_a?(Symbol) @response = Response.render(render_with_layout(body)) end |
#render_template(name, **assigns) ⇒ Object
183 184 185 |
# File 'lib/charming/controller.rb', line 183 def render_template(name, **assigns) @response = Response.render(render_with_layout(template_body(name, **assigns))) end |
#run_task(name, &block) ⇒ Object
Submits an async task to the application’s task executor (threaded or inline). The task runs in a background thread; results arrive as TaskEvents in ‘dispatch_task`.
234 235 236 |
# File 'lib/charming/controller.rb', line 234 def run_task(name, &block) application.task_executor.submit(name, &block) end |
#session ⇒ Object
Returns the parent application’s session hash for per-request state storage (e.g., form data, flags).
213 214 215 |
# File 'lib/charming/controller.rb', line 213 def session application.session end |
#sidebar_focused? ⇒ Boolean
Returns whether the sidebar currently has focus (from focus ring or session state).
302 303 304 305 306 |
# File 'lib/charming/controller.rb', line 302 def return focused?(:sidebar) if focus_ring_slot?(:sidebar) session[:focus] == :sidebar end |
#sidebar_index ⇒ Object
Returns the currently highlighted sidebar item index, falling back to the current route’s position when no explicit sidebar selection has been made yet.
317 318 319 |
# File 'lib/charming/controller.rb', line 317 def session[:sidebar_index] || current_route_index end |
#sidebar_routes ⇒ Object
321 322 323 |
# File 'lib/charming/controller.rb', line 321 def application.routes.all end |
#state(name, state_class, **attributes) ⇒ Object
Lazily instantiates a state class and caches it in the session under ‘:states`. Subsequent calls with the same name return the cached instance. Used like: state(:home, HomeState)
219 220 221 222 |
# File 'lib/charming/controller.rb', line 219 def state(name, state_class, **attributes) session[:states] ||= {} session[:states][name.to_sym] ||= state_class.new(**attributes) end |
#theme ⇒ Object
187 188 189 |
# File 'lib/charming/controller.rb', line 187 def theme application.theme end |
#use_theme(name) ⇒ Object
191 192 193 |
# File 'lib/charming/controller.rb', line 191 def use_theme(name) application.use_theme(name) end |