charming 0.1.0 → 0.1.2

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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Sidebar-navigation helpers mixed into Controller. Tracks the sidebar's current route index,
6
+ # routes j/k/enter/tab keys when the sidebar is focused, and exposes `sidebar_focused?` for views.
7
+ module SidebarNavigation
8
+ # Moves focus to the sidebar. When the controller declared a focus ring, the focus object
9
+ # is updated; otherwise a fallback session key tracks focus.
10
+ def focus_sidebar
11
+ if focus_ring_slot?(:sidebar)
12
+ focus.focus(:sidebar)
13
+ else
14
+ session[:focus] = :sidebar
15
+ end
16
+ session[:sidebar_index] ||= current_route_index
17
+ render_default_action
18
+ end
19
+
20
+ # Moves focus to the content pane (the inverse of `focus_sidebar`).
21
+ def focus_content
22
+ if focus_ring_slot?(:content)
23
+ focus.focus(:content)
24
+ else
25
+ session[:focus] = :content
26
+ end
27
+ render_default_action
28
+ end
29
+
30
+ # True when the sidebar is the current focus target. Uses the focus ring when defined.
31
+ def sidebar_focused?
32
+ return focused?(:sidebar) if focus_ring_slot?(:sidebar)
33
+
34
+ session[:focus] == :sidebar
35
+ end
36
+
37
+ # True when the content pane is the current focus target. Uses the focus ring when defined.
38
+ def content_focused?
39
+ return focused?(:content) if focus_ring_slot?(:content)
40
+
41
+ session[:focus] == :content
42
+ end
43
+
44
+ # Returns the index of the currently selected route in `sidebar_routes`, defaulting to the
45
+ # active route when the session index is unset.
46
+ def sidebar_index
47
+ session[:sidebar_index] || current_route_index
48
+ end
49
+
50
+ # Returns all routes from the application's router, in registration order.
51
+ def sidebar_routes
52
+ application.routes.all
53
+ end
54
+
55
+ # True when *candidate* route matches the controller's currently active route (used to
56
+ # highlight the current row in the sidebar).
57
+ def current_route?(candidate)
58
+ return candidate.controller_class == self.class && candidate.action == :show unless route
59
+
60
+ candidate.path == route.path &&
61
+ candidate.controller_class == route.controller_class &&
62
+ candidate.action == route.action
63
+ end
64
+
65
+ private
66
+
67
+ # Returns the index of the route that matches `current_route?`, defaulting to 0.
68
+ def current_route_index
69
+ sidebar_routes.index { |candidate| current_route?(candidate) } || 0
70
+ end
71
+
72
+ # Dispatches j/k/enter/tab/escape to sidebar movement and selection; falls through to
73
+ # a default render for any other key.
74
+ def dispatch_sidebar_key
75
+ case key_name
76
+ when :j, :down then sidebar_move(+1)
77
+ when :k, :up then sidebar_move(-1)
78
+ when :enter then sidebar_select
79
+ when :escape, :tab then focus_content
80
+ else render_default_action
81
+ end
82
+ response
83
+ end
84
+
85
+ # Mouse dispatch for the sidebar. Reserved for future use; returns nil.
86
+ def dispatch_sidebar_mouse
87
+ nil
88
+ end
89
+
90
+ # Moves the sidebar cursor by *delta* positions, clamped to the route list bounds.
91
+ def sidebar_move(delta)
92
+ count = sidebar_routes.length
93
+ return render_default_action if count.zero?
94
+
95
+ session[:sidebar_index] = (sidebar_index + delta).clamp(0, count - 1)
96
+ render_default_action
97
+ end
98
+
99
+ # Selects the route currently highlighted in the sidebar and navigates to it.
100
+ def sidebar_select
101
+ route = sidebar_routes[sidebar_index]
102
+ if focus_ring_slot?(:content)
103
+ focus.focus(:content)
104
+ else
105
+ session[:focus] = :content
106
+ end
107
+ route ? navigate_to(route.path) : render_default_action
108
+ end
109
+ end
110
+ end
111
+ end
@@ -8,178 +8,103 @@ module Charming
8
8
  TimerBinding = Data.define(:name, :interval, :action)
9
9
  TaskBinding = Data.define(:name, :action)
10
10
 
11
- class << self
12
- # Registers a key binding (string or symbol key name → method symbol).
13
- # Content-scoped bindings run from the main content pane; global bindings run from any pane.
14
- def key(name, action, scope: :content)
15
- normalized_scope = validate_key_scope(scope)
16
- key_name = name.to_sym
17
- key_bindings[key_name] = action
18
- key_binding_scopes[key_name] = normalized_scope
19
- end
20
-
21
- # Registers a command palette entry — visible in fuzzy search when Ctrl+K is pressed.
22
- # Accepts either a method symbol or an inline callable block.
23
- def command(label, action = nil, &block)
24
- command_bindings << Components::CommandPalette::Command.new(label: label, value: block || action)
25
- end
26
-
27
- # Registers a periodic timer that fires at `every`-second intervals.
28
- # The named action is dispatched on the current route's controller each time.
29
- def timer(name, every:, action:)
30
- timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
31
- end
32
-
33
- # Registers an async task handler that runs when a TaskEvent arrives from the task executor.
34
- def on_task(name, action:)
35
- task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
36
- end
37
-
38
- # Sets the layout class to wrap this controller's rendered output (e.g., for sidebar + main content).
39
- # Accepts a special `:__charming_layout_reader__` sentinel to query — without setting — the current layout.
40
- def layout(layout_class = :__charming_layout_reader__)
41
- return resolved_layout if layout_class == :__charming_layout_reader__
42
-
43
- @layout = layout_class
44
- end
45
-
46
- # Returns inherited key bindings merged from the class hierarchy.
47
- # Each subclass gets a fresh copy of its parent's key bindings to avoid cross-controller pollution.
48
- def key_bindings
49
- @key_bindings ||= superclass.respond_to?(:key_bindings) ? superclass.key_bindings.dup : {}
50
- end
51
-
52
- # Returns inherited key binding scopes merged from the class hierarchy.
53
- # Each subclass gets a fresh copy of its parent's scopes to match key binding inheritance.
54
- def key_binding_scopes
55
- @key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
56
- end
57
-
58
- # Registers a focus ring slot for this controller — slots participate in Tab/Shift+Tab traversal.
59
- # Example: `focus_ring :sidebar, :content` makes sidebar and content tabbable.
60
- def focus_ring(*slots)
61
- @focus_ring_slots = slots
62
- end
63
-
64
- # Returns inherited focus ring slots merged from the class hierarchy.
65
- def focus_ring_slots
66
- @focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
67
- end
68
-
69
- # Returns inherited command bindings (command palette entries) from this controller and its ancestors.
70
- def command_bindings
71
- @command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
72
- end
73
-
74
- # Returns inherited timer bindings from this controller and its ancestors.
75
- def timer_bindings
76
- @timer_bindings ||= superclass.respond_to?(:timer_bindings) ? superclass.timer_bindings.dup : {}
77
- end
78
-
79
- # Returns inherited task bindings (async task handlers) from this controller and its ancestors.
80
- def task_bindings
81
- @task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {}
82
- end
83
-
84
- private
85
-
86
- def validate_key_scope(scope)
87
- normalized_scope = scope.to_sym
88
- return normalized_scope if %i[content global].include?(normalized_scope)
89
-
90
- raise ArgumentError, "unknown key scope: #{scope.inspect}"
91
- end
92
-
93
- # Returns the layout class for this controller, walking up the inheritance chain until one is found or nil.
94
- # Used internally by `layout` when called without args (getter mode).
95
- def resolved_layout
96
- return @layout if instance_variable_defined?(:@layout)
97
- return superclass.layout if superclass.respond_to?(:layout)
98
-
99
- nil
100
- end
101
- end
102
-
103
- attr_reader :application, :event, :params, :screen
104
-
105
- # Initializes the controller with its parent application and an optional event (key/mouse/timer/task data).
106
- # Defaults to a 80x24 screen when no backend size is available.
107
- def initialize(application:, event: nil, params: {}, screen: nil)
11
+ extend ClassMethods
12
+ include Rendering
13
+ include SessionState
14
+ include FocusManagement
15
+ include SidebarNavigation
16
+ include CommandPalette
17
+ include ComponentDispatching
18
+ include Dispatching
19
+
20
+ attr_reader :application, :event, :params, :screen, :route
21
+
22
+ # Initializes the controller with its parent application and optional event.
23
+ # Defaults to an 80x24 screen when no backend size is available.
24
+ def initialize(application:, event: nil, params: {}, screen: nil, route: nil)
108
25
  @application = application
109
26
  @event = event
110
27
  @params = params
111
28
  @screen = screen || Screen.new(width: 80, height: 24)
29
+ @route = route
112
30
  @response = nil
113
31
  end
114
32
 
115
- # Dispatches a named action on this controller (e.g., :show). Calls the method via public_send,
116
- # returning a default empty render if the action produces no response.
33
+ # Dispatches a named action on this controller (e.g. :show).
117
34
  def dispatch(action)
118
35
  public_send(action)
36
+ render_default_action if response.nil? && auto_render_after?(action)
119
37
  response || render("")
120
38
  end
121
39
 
122
- # Key event dispatch pipeline for controllers: checks command palette first (if open),
123
- # then global key bindings, then sidebar (if focused), then content-scoped key bindings,
124
- # then tab traversal, then focused component handling. Returns nil if no handler consumed the event.
40
+ # Key event dispatch: checks command palette first, then global bindings,
41
+ # sidebar (if focused), content bindings, tab traversal, and focused component.
125
42
  def dispatch_key
126
43
  return dispatch_command_palette_key if command_palette_open?
127
44
  return dispatch(global_key_action) if global_key_action
128
45
  return dispatch_sidebar_key if sidebar_focused?
129
-
130
46
  return dispatch(content_key_action) if content_key_action
131
47
  return response if dispatch_tab_traversal == :handled
132
48
  return response if dispatch_to_focused_component == :handled
133
-
134
49
  nil
135
50
  end
136
51
 
137
- # Timer event dispatcher: looks up the event's named action in this controller's timer bindings
138
- # and dispatches it. Returns nil if no binding exists for this timer name.
52
+ # Timer event dispatcher: looks up the named action in timer bindings.
139
53
  def dispatch_timer
140
- binding = self.class.timer_bindings[event.name.to_sym]
141
- binding ? dispatch(binding.action) : nil
54
+ b = self.class.timer_bindings[event.name.to_sym]
55
+ return nil unless b
56
+
57
+ public_send(b.action)
58
+ response
142
59
  end
143
60
 
144
- # Task event dispatcher: looks up the event's named handler in this controller's task bindings
145
- # and dispatches it. Used by async tasks submitted via `run_task`.
61
+ # Task event dispatcher: looks up the handler in task bindings.
146
62
  def dispatch_task
147
- binding = self.class.task_bindings[event.name.to_sym]
148
- binding ? dispatch(binding.action) : nil
63
+ b = self.class.task_bindings[event.name.to_sym]
64
+ b ? dispatch(b.action) : nil
149
65
  end
150
66
 
151
- # Mouse event dispatcher: checks command palette (if open), then sidebar (if focused),
152
- # then falls through to component mouse dispatch. Always returns nil in the base controller —
153
- # subclasses override as needed.
67
+ # Mouse event dispatcher: checks command palette (if open), sidebar (if focused).
154
68
  def dispatch_mouse
155
69
  return dispatch_command_palette_mouse if command_palette_open?
156
70
  return dispatch_sidebar_mouse if sidebar_focused?
157
-
158
71
  dispatch_component_mouse
159
72
  end
160
73
 
161
- # Renders `body` wrapped in this controller's layout (if one is defined) and stores the response.
162
- # If no layout is set, renders body bare. Called by controllers after rendering a view.
163
- def render(body = "")
74
+ # Renders a body or template wrapped in the controller's layout.
75
+ def render(body = "", **assigns)
76
+ body = view_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
164
77
  @response = Response.render(render_with_layout(body))
165
78
  end
166
79
 
80
+ def render_view(view_class, **assigns)
81
+ @response = Response.render(render_with_layout(view_class.new(**template_assigns(assigns))))
82
+ end
83
+
84
+ # Renders a template from `app/views` by name, applying the controller's layout. *name* is the
85
+ # template path (e.g., "home/show") and additional keyword *assigns* are forwarded to the view.
86
+ def render_template(name, **assigns)
87
+ @response = Response.render(render_with_layout(template_body(name, **assigns)))
88
+ end
89
+
90
+ # Returns the active theme for this request, delegated to the application.
167
91
  def theme
168
92
  application.theme
169
93
  end
170
94
 
95
+ # Switches the active theme to *name* and persists the choice in the application session.
171
96
  def use_theme(name)
172
97
  application.use_theme(name)
173
98
  end
174
99
 
100
+ # Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
175
101
  def open_theme_palette
176
102
  session[:command_palette] = command_palette_state(:themes)
177
103
  focus.push_scope([:command_palette], origin: :command_palette)
178
104
  render_default_action
179
105
  end
180
106
 
181
- # Responds with a navigation redirect to the given URL path.
182
- # Used for route transitions triggered from controllers (e.g., sidebar selection).
107
+ # Navigates to the given URL path.
183
108
  def navigate_to(path)
184
109
  @response = Response.navigate(path)
185
110
  end
@@ -189,335 +114,8 @@ module Charming
189
114
  @response = Response.quit
190
115
  end
191
116
 
192
- # Returns the parent application's session hash for per-request state storage (e.g., form data, flags).
193
- def session
194
- application.session
195
- end
196
-
197
- # Lazily instantiates a model class and caches it in the session under `:models`.
198
- # Subsequent calls with the same name return the cached instance. Used like: model(:user, UserModel)
199
- def model(name, model_class, **attributes)
200
- session[:models] ||= {}
201
- session[:models][name.to_sym] ||= model_class.new(**attributes)
202
- end
203
-
204
- # Submits an async task to the application's task executor (threaded or inline).
205
- # The task runs in a background thread; results arrive as TaskEvents in `dispatch_task`.
206
- def run_task(name, &block)
207
- application.task_executor.submit(name, &block)
208
- end
209
-
210
- # Opens the command palette (fuzzy search UI): stores primitive palette state and pushes a palette
211
- # scope onto the focus ring so input is captured inside it. Renders the default action afterward.
212
- def open_command_palette
213
- session[:command_palette] = command_palette_state(:commands)
214
- focus.push_scope([:command_palette], origin: :command_palette)
215
- render_default_action
216
- end
217
-
218
- # Closes the command palette: removes its state from the session, pops its scope from the focus ring,
219
- # and re-renders the default action. Pops all nested scopes until only the palette remains.
220
- def close_command_palette
221
- session.delete(:command_palette)
222
- pop_command_palette_scope
223
- render_default_action
224
- end
225
-
226
- # Returns or lazily initializes the Focus instance for this controller, which manages
227
- # keyboard-driven focus traversal between components (sidebar, content, etc.).
228
- # Defines focus ring slots from class-level declarations on first access.
229
- def focus
230
- @focus ||= Focus.for(session, self.class).tap do |f|
231
- f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
232
- end
233
- end
234
-
235
- # Checks whether the given focus slot (e.g., :sidebar, :content) is currently focused.
236
- def focused?(slot)
237
- focus.focused?(slot)
238
- end
239
-
240
- # Returns whether the command palette is active in the current session.
241
- def command_palette_open?
242
- session.key?(:command_palette)
243
- end
244
-
245
- # Returns a command palette component rebuilt from the current primitive session state, if open.
246
- def command_palette
247
- build_command_palette_from_state(session[:command_palette]) if command_palette_open?
248
- end
249
-
250
- # Shifts focus to the sidebar: moves the focus ring cursor or sets `session[:focus]` to :sidebar,
251
- # highlights the current route index, and re-renders. Sidebar selection uses j/k keys.
252
- def focus_sidebar
253
- if focus_ring_slot?(:sidebar)
254
- focus.focus(:sidebar)
255
- else
256
- session[:focus] = :sidebar
257
- end
258
- session[:sidebar_index] ||= current_route_index
259
- render_default_action
260
- end
261
-
262
- # Shifts focus back to the main content area: moves the focus ring cursor or sets `session[:focus]` to :content,
263
- # and re-renders. Used by Escape key from sidebar and other navigation transitions.
264
- def focus_content
265
- if focus_ring_slot?(:content)
266
- focus.focus(:content)
267
- else
268
- session[:focus] = :content
269
- end
270
- render_default_action
271
- end
272
-
273
- # Returns whether the sidebar currently has focus (from focus ring or session state).
274
- def sidebar_focused?
275
- return focused?(:sidebar) if focus_ring_slot?(:sidebar)
276
-
277
- session[:focus] == :sidebar
278
- end
279
-
280
- # Returns whether the main content area currently has focus (from focus ring or session state).
281
- def content_focused?
282
- return focused?(:content) if focus_ring_slot?(:content)
283
-
284
- session[:focus] == :content
285
- end
286
-
287
- # Returns the currently highlighted sidebar item index, falling back to the current route's position
288
- # when no explicit sidebar selection has been made yet.
289
- def sidebar_index
290
- session[:sidebar_index] || current_route_index
291
- end
292
-
293
117
  private
294
118
 
295
- # Finds the position of this controller among all registered routes (for sidebar highlighting).
296
- # Returns 0 if no matching route is found.
297
- def current_route_index
298
- application.routes.all.index { |route| route.controller_class == self.class && route.action == :show } || 0
299
- end
300
-
301
- # Checks whether the given slot is registered as a focus ring slot for this controller.
302
- def focus_ring_slot?(slot)
303
- self.class.focus_ring_slots.include?(slot)
304
- end
305
-
306
119
  attr_reader :response
307
-
308
- # Renders `body` as a string: calls `#render` if body responds to it (component), otherwise `#to_s`.
309
- def render_body(body)
310
- body.respond_to?(:render) ? body.render.to_s : body.to_s
311
- end
312
-
313
- # Wraps `body` rendering in this controller's layout class (if one is defined).
314
- # If no layout is set, returns body as-is. Provides content, screen, and controller to the layout for composition.
315
- def render_with_layout(body)
316
- rendered = render_body(body)
317
- layout_class = self.class.layout
318
- return rendered unless layout_class
319
-
320
- render_body(layout_class.new(**layout_assigns(body, rendered)))
321
- end
322
-
323
- # Provides view assigns for layout rendering: merges body-specific assigns with standard `content`, `screen`, and `controller`.
324
- def layout_assigns(body, rendered)
325
- view_assigns(body).merge(content: rendered, screen: screen, controller: self, theme: theme)
326
- end
327
-
328
- # Extracts layout assigns from the body if it responds to `#layout_assigns` (e.g., a component),
329
- # otherwise returns an empty hash. Used by layout rendering for composition.
330
- def view_assigns(body)
331
- body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
332
- end
333
-
334
- # Extracts the normalized key from the current event, handling both KeyEvent objects and raw key strings.
335
- # Delegates to `Charming.key_of(event)` for event-to-key resolution.
336
- def key_name
337
- Charming.key_of(event)
338
- end
339
-
340
- # Dispatches a key event to the currently focused component (e.g., text input, list) by calling `#handle_key` on it.
341
- # Returns nil if no focused component or no handler consumed the key, otherwise :handled.
342
- def dispatch_to_focused_component
343
- slot = focus.current
344
- return nil unless slot && respond_to?(slot, true)
345
-
346
- component = send(slot)
347
- return nil unless component.respond_to?(:handle_key)
348
-
349
- result = component.handle_key(event)
350
- return nil if result.nil?
351
-
352
- render_default_action
353
- :handled
354
- end
355
-
356
- # Handles Tab/Shift-Tab traversal: moves focus forward or backward through the focus ring.
357
- # Only processes events that are actually Tab keypresses on an empty focus ring. Returns :handled when consumed.
358
- def dispatch_tab_traversal
359
- return nil unless key_name == :tab
360
- return nil if focus.ring.empty?
361
-
362
- focus.cycle(event.shift ? -1 : +1)
363
- render_default_action
364
- :handled
365
- end
366
-
367
- # Dispatches key events to an open command palette (fuzzy search). Handles cancellation (Escape),
368
- # command execution when a selection is made, and renders the default action if no response was produced.
369
- def dispatch_command_palette_key
370
- palette = command_palette
371
- result = palette.handle_key(event)
372
-
373
- if result == :cancelled
374
- close_command_palette
375
- elsif selected_command?(result)
376
- perform_command(result.last)
377
- else
378
- save_command_palette_state(palette)
379
- render_default_action unless response
380
- end
381
-
382
- response
383
- end
384
-
385
- # Mouse event handler for an open command palette. No-op — the command palette handles mouse internally.
386
- def dispatch_command_palette_mouse
387
- nil
388
- end
389
-
390
- # Dispatches keys within sidebar navigation: j/k or down/up move selection, Enter selects and navigates,
391
- # Escape/Tab shifts focus to content. Other keys are ignored while sidebar owns focus.
392
- def dispatch_sidebar_key
393
- case key_name
394
- when :j, :down then sidebar_move(+1)
395
- when :k, :up then sidebar_move(-1)
396
- when :enter then sidebar_select
397
- when :escape, :tab then focus_content
398
- else render_default_action
399
- end
400
- response
401
- end
402
-
403
- # Mouse event handler for the base controller. No-op — mouse events bubble through to focused components instead.
404
- def dispatch_component_mouse
405
- nil
406
- end
407
-
408
- # Moves sidebar selection up or down by `delta`. Does nothing if there are no routes.
409
- def sidebar_move(delta)
410
- count = application.routes.all.length
411
- return render_default_action if count.zero?
412
-
413
- session[:sidebar_index] = (sidebar_index + delta).clamp(0, count - 1)
414
- render_default_action
415
- end
416
-
417
- # Selects the currently highlighted sidebar route and navigates to it — shifting focus to content area.
418
- # If no route is found at the current index, falls back to default action.
419
- def sidebar_select
420
- route = application.routes.all[sidebar_index]
421
- session[:focus] = :content
422
- route ? navigate_to(route.path) : render_default_action
423
- end
424
-
425
- def build_command_palette_from_state(state)
426
- case state.fetch(:type)
427
- when :commands
428
- build_command_palette_with_state(self.class.command_bindings, state, height: 6)
429
- when :themes
430
- build_command_palette_with_state(theme_commands, state, placeholder: "Search themes", height: 10)
431
- end
432
- end
433
-
434
- def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
435
- Components::CommandPalette.new(
436
- commands: commands,
437
- placeholder: placeholder,
438
- height: height,
439
- value: state.fetch(:value),
440
- cursor: state.fetch(:cursor),
441
- selected_index: state.fetch(:selected_index),
442
- theme: theme
443
- )
444
- end
445
-
446
- def command_palette_state(type)
447
- {type: type, value: "", cursor: 0, selected_index: 0}
448
- end
449
-
450
- def save_command_palette_state(palette)
451
- session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
452
- end
453
-
454
- # Checks if a command palette result indicates a selected command (as opposed to cancel or no-op).
455
- def selected_command?(result)
456
- result.is_a?(Array) && result.first == :selected
457
- end
458
-
459
- # Executes a command value — either a callable block (inline command) or a method name (bound action).
460
- # Pops the command palette scope and re-renders unless the result is navigation or quit.
461
- def perform_command(command)
462
- current_palette_state = session[:command_palette]
463
- pop_command_palette_scope
464
- perform_command_value(command.value)
465
- if command.value != :quit && session[:command_palette].equal?(current_palette_state)
466
- session.delete(:command_palette)
467
- end
468
- render_default_action unless response&.navigate? || response&.quit?
469
- end
470
-
471
- def theme_commands
472
- application.class.themes.keys.map do |name|
473
- Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
474
- end
475
- end
476
-
477
- def theme_label(name)
478
- name.to_s.tr("_", "-").split("-").map(&:capitalize).join(" ")
479
- end
480
-
481
- # Pops the command palette scope from the focus ring until it is no longer the topmost scope.
482
- # Called when a command executes or is cancelled.
483
- def pop_command_palette_scope
484
- focus.pop_scope while focus.ring == [:command_palette]
485
- end
486
-
487
- # Executes a command value — either calling a callable (inline block) or sending a method name to self.
488
- # Used for both inline commands and bound action methods from the palette.
489
- def perform_command_value(value)
490
- value.respond_to?(:call) ? instance_exec(&value) : send(value)
491
- end
492
-
493
- # Renders the default `:show` action if this controller defines it. Called after navigation, command execution,
494
- # or key handling when no explicit response was produced — ensures the view stays rendered.
495
- def render_default_action
496
- show if respond_to?(:show)
497
- end
498
-
499
- def global_key_action
500
- key_action_for_scope(:global)
501
- end
502
-
503
- def content_key_action
504
- return nil unless content_key_scope_active?
505
-
506
- key_action_for_scope(:content)
507
- end
508
-
509
- def content_key_scope_active?
510
- return content_focused? if focus_ring_slot?(:content)
511
-
512
- true
513
- end
514
-
515
- def key_action_for_scope(scope)
516
- action = self.class.key_bindings[key_name]
517
- return nil unless action
518
- return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
519
-
520
- action
521
- end
522
120
  end
523
121
  end