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.
- checksums.yaml +4 -4
- data/README.md +38 -378
- data/lib/charming/application.rb +14 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +62 -3
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +46 -448
- data/lib/charming/database_commands.rb +103 -0
- data/lib/charming/database_installer.rb +152 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +147 -45
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -14
- data/lib/charming/generators/model_generator.rb +128 -0
- data/lib/charming/generators/name.rb +10 -4
- data/lib/charming/generators/screen_generator.rb +84 -52
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +26 -13
- data/lib/charming/internal/renderer/differential.rb +17 -3
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +62 -115
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +56 -0
- data/lib/charming/presentation/components/form/builder.rb +62 -0
- data/lib/charming/presentation/components/form/confirm.rb +69 -0
- data/lib/charming/presentation/components/form/field.rb +121 -0
- data/lib/charming/presentation/components/form/input.rb +71 -0
- data/lib/charming/presentation/components/form/note.rb +41 -0
- data/lib/charming/presentation/components/form/select.rb +112 -0
- data/lib/charming/presentation/components/form/textarea.rb +86 -0
- data/lib/charming/presentation/components/form.rb +156 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +132 -0
- data/lib/charming/presentation/components/markdown.rb +31 -0
- data/lib/charming/presentation/components/modal.rb +64 -0
- data/lib/charming/presentation/components/progressbar.rb +70 -0
- data/lib/charming/presentation/components/spinner.rb +49 -0
- data/lib/charming/presentation/components/table.rb +143 -0
- data/lib/charming/presentation/components/text_area.rb +267 -0
- data/lib/charming/presentation/components/text_input.rb +129 -0
- data/lib/charming/presentation/components/viewport.rb +272 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +113 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
- data/lib/charming/presentation/markdown.rb +11 -0
- data/lib/charming/presentation/template_view.rb +34 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +68 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +213 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +91 -0
- data/lib/charming/presentation/view.rb +135 -0
- data/lib/charming/runtime.rb +9 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +37 -0
- data/lib/charming/tasks/task.rb +12 -0
- data/lib/charming/tasks/threaded_executor.rb +51 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +17 -0
- metadata +170 -36
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -69
- data/lib/charming/generators/app_generator/layout_template.rb +0 -160
- data/lib/charming/generators/app_generator/model_templates.rb +0 -30
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
- data/lib/charming/generators/app_generator/view_template.rb +0 -90
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/task_executor.rb +0 -62
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /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
|
data/lib/charming/controller.rb
CHANGED
|
@@ -8,178 +8,103 @@ module Charming
|
|
|
8
8
|
TimerBinding = Data.define(:name, :interval, :action)
|
|
9
9
|
TaskBinding = Data.define(:name, :action)
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
123
|
-
#
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
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),
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
#
|
|
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
|