Class: Charming::Controller

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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

#applicationObject (readonly)

Returns the value of attribute application.



116
117
118
# File 'lib/charming/controller.rb', line 116

def application
  @application
end

#eventObject (readonly)

Returns the value of attribute event.



116
117
118
# File 'lib/charming/controller.rb', line 116

def event
  @event
end

#paramsObject (readonly)

Returns the value of attribute params.



116
117
118
# File 'lib/charming/controller.rb', line 116

def params
  @params
end

#routeObject (readonly)

Returns the value of attribute route.



116
117
118
# File 'lib/charming/controller.rb', line 116

def route
  @route
end

#screenObject (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_actionObject



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_bindingsObject

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_slotsObject

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_scopesObject

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_bindingsObject

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_bindingsObject

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_bindingsObject

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_paletteObject

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_paletteObject

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.

Returns:

  • (Boolean)


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).

Returns:

  • (Boolean)


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

Returns:

  • (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_keyObject

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 dispatch_sidebar_key if sidebar_focused?

  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_mouseObject

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 dispatch_sidebar_mouse if sidebar_focused?

  dispatch_component_mouse
end

#dispatch_taskObject

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_timerObject

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

#focusObject

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_contentObject

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_sidebarObject

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 focus_sidebar
  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.

Returns:

  • (Boolean)


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

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_paletteObject

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_paletteObject



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

#quitObject

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

#sessionObject

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

Returns whether the sidebar currently has focus (from focus ring or session state).

Returns:

  • (Boolean)


302
303
304
305
306
# File 'lib/charming/controller.rb', line 302

def sidebar_focused?
  return focused?(:sidebar) if focus_ring_slot?(:sidebar)

  session[:focus] == :sidebar
end

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 sidebar_index
  session[:sidebar_index] || current_route_index
end


321
322
323
# File 'lib/charming/controller.rb', line 321

def sidebar_routes
  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

#themeObject



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