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,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# DSL for declaring controller-level event bindings and configuration: keys, commands,
|
|
6
|
+
# timers, task handlers, the auto-rendered action, layout wrapper, and focus ring.
|
|
7
|
+
# Mixed into Controller as class methods; also exposed for tests and shared base controllers.
|
|
8
|
+
module ClassMethods
|
|
9
|
+
# Binds a key press to a controller action. *name* is the normalized key symbol (e.g., "up",
|
|
10
|
+
# "q", "ctrl+c"). *scope* is :content (default) for content-pane keys or :global for app-wide
|
|
11
|
+
# shortcuts that fire regardless of focus. Raises ArgumentError for any other scope.
|
|
12
|
+
def key(name, action, scope: :content)
|
|
13
|
+
normalized_scope = validate_key_scope(scope)
|
|
14
|
+
key_name = name.to_sym
|
|
15
|
+
key_bindings[key_name] = action
|
|
16
|
+
key_binding_scopes[key_name] = normalized_scope
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Adds a CommandPalette entry with the given *label*. *action* is a method name to send on
|
|
20
|
+
# the controller, or a block to instance_exec when selected.
|
|
21
|
+
def command(label, action = nil, &block)
|
|
22
|
+
command_bindings << Presentation::Components::CommandPalette::Command.new(label: label, value: block || action)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Declares a timer that fires every *every* seconds and dispatches *action* on the controller.
|
|
26
|
+
# The runtime builds a TimerEvent and routes it to the active controller's dispatch_timer.
|
|
27
|
+
def timer(name, every:, action:)
|
|
28
|
+
timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Declares a task handler for async work submitted via `run_task(:name)`. When the task emits
|
|
32
|
+
# a TaskEvent with the matching name, the runtime dispatches *action* on the controller.
|
|
33
|
+
def on_task(name, action:)
|
|
34
|
+
task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sets the action that the controller should auto-render after a non-rendering action runs.
|
|
38
|
+
# Defaults to :show when unset.
|
|
39
|
+
def auto_render(action = :show)
|
|
40
|
+
@auto_render_action = action.to_sym
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns the configured auto-render action, walking the superclass chain when undefined locally.
|
|
44
|
+
def auto_render_action
|
|
45
|
+
return @auto_render_action if instance_variable_defined?(:@auto_render_action)
|
|
46
|
+
return superclass.auto_render_action if superclass.respond_to?(:auto_render_action)
|
|
47
|
+
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Sets or returns the controller's layout. Pass a layout class (instantiated per request),
|
|
52
|
+
# a String/Symbol template name (resolved through Presentation::Templates), or `false` to
|
|
53
|
+
# disable inherited layout wrapping. Called with no arguments returns the resolved layout.
|
|
54
|
+
def layout(layout_class = :__charming_layout_reader__)
|
|
55
|
+
return resolved_layout if layout_class == :__charming_layout_reader__
|
|
56
|
+
|
|
57
|
+
@layout = layout_class
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Hash of registered key bindings (symbol key name => action method name), inherited from
|
|
61
|
+
# superclass controllers.
|
|
62
|
+
def key_bindings
|
|
63
|
+
@key_bindings ||= superclass.respond_to?(:key_bindings) ? superclass.key_bindings.dup : {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Hash of key scopes paralleling `key_bindings` (symbol key name => :content or :global).
|
|
67
|
+
def key_binding_scopes
|
|
68
|
+
@key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Defines the named focus slots cycled by Tab/Shift+Tab traversal.
|
|
72
|
+
def focus_ring(*slots)
|
|
73
|
+
@focus_ring_slots = slots
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the focus ring slots, inherited from superclass when undefined.
|
|
77
|
+
def focus_ring_slots
|
|
78
|
+
@focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Array of registered command palette entries, inherited from superclass when undefined.
|
|
82
|
+
def command_bindings
|
|
83
|
+
@command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Hash of timer name => TimerBinding, inherited from superclass when undefined.
|
|
87
|
+
def timer_bindings
|
|
88
|
+
@timer_bindings ||= superclass.respond_to?(:timer_bindings) ? superclass.timer_bindings.dup : {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Hash of task name => TaskBinding, inherited from superclass when undefined.
|
|
92
|
+
def task_bindings
|
|
93
|
+
@task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Validates that *scope* is :content or :global; otherwise raises ArgumentError.
|
|
99
|
+
def validate_key_scope(scope)
|
|
100
|
+
normalized_scope = scope.to_sym
|
|
101
|
+
return normalized_scope if %i[content global].include?(normalized_scope)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "unknown key scope: #{scope.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Walks the superclass chain to find a configured layout, returning nil if none is set.
|
|
107
|
+
def resolved_layout
|
|
108
|
+
return @layout if instance_variable_defined?(:@layout)
|
|
109
|
+
return superclass.layout if superclass.respond_to?(:layout)
|
|
110
|
+
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Command palette helpers mixed into Controller. Opens/closes the palette, builds the
|
|
6
|
+
# palette from registered command bindings or theme list, and routes key/mouse events
|
|
7
|
+
# through it. Supports both the standard command palette (:commands) and the theme picker
|
|
8
|
+
# (:themes) via a discriminated `session[:command_palette]` state hash.
|
|
9
|
+
module CommandPalette
|
|
10
|
+
# Opens the command palette populated with the controller's `command_bindings`. Pushes
|
|
11
|
+
# a focus scope so subsequent keys are routed to the palette.
|
|
12
|
+
def open_command_palette
|
|
13
|
+
session[:command_palette] = command_palette_state(:commands)
|
|
14
|
+
focus.push_scope([:command_palette], origin: :command_palette)
|
|
15
|
+
render_default_action
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Closes the command palette, pops its focus scope, and renders the current action.
|
|
19
|
+
def close_command_palette
|
|
20
|
+
session.delete(:command_palette)
|
|
21
|
+
pop_command_palette_scope
|
|
22
|
+
render_default_action
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True when either the command palette or theme picker is currently open.
|
|
26
|
+
def command_palette_open?
|
|
27
|
+
session.key?(:command_palette)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the active CommandPalette component, or nil when the palette is closed.
|
|
31
|
+
def command_palette
|
|
32
|
+
build_command_palette_from_state(session[:command_palette]) if command_palette_open?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Routes the current key event to the open palette. Cancels on Escape, performs the
|
|
38
|
+
# selected command on Enter, otherwise persists the palette's state and re-renders.
|
|
39
|
+
def dispatch_command_palette_key
|
|
40
|
+
palette = command_palette
|
|
41
|
+
result = palette.handle_key(event)
|
|
42
|
+
|
|
43
|
+
if result == :cancelled
|
|
44
|
+
close_command_palette
|
|
45
|
+
elsif selected_command?(result)
|
|
46
|
+
perform_command(result.last)
|
|
47
|
+
else
|
|
48
|
+
save_command_palette_state(palette)
|
|
49
|
+
render_default_action unless response
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Mouse dispatch for the command palette. Reserved for future use; returns nil.
|
|
56
|
+
def dispatch_command_palette_mouse
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds a CommandPalette component from the persisted palette *state* hash, dispatching
|
|
61
|
+
# to command-list or theme-list construction based on the state's `:type`.
|
|
62
|
+
def build_command_palette_from_state(state)
|
|
63
|
+
case state.fetch(:type)
|
|
64
|
+
when :commands
|
|
65
|
+
build_command_palette_with_state(self.class.command_bindings, state, height: 6)
|
|
66
|
+
when :themes
|
|
67
|
+
build_command_palette_with_state(theme_commands, state, placeholder: "Search themes", height: 10)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Constructs the CommandPalette widget with a *commands* list and persisted *state* hash.
|
|
72
|
+
def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
|
|
73
|
+
Presentation::Components::CommandPalette.new(
|
|
74
|
+
commands: commands,
|
|
75
|
+
placeholder: placeholder,
|
|
76
|
+
height: height,
|
|
77
|
+
value: state.fetch(:value),
|
|
78
|
+
cursor: state.fetch(:cursor),
|
|
79
|
+
selected_index: state.fetch(:selected_index),
|
|
80
|
+
theme: theme
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Initial palette state hash used when opening either palette type.
|
|
85
|
+
def command_palette_state(type)
|
|
86
|
+
{type: type, value: "", cursor: 0, selected_index: 0}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Merges the in-memory palette's state back into the session hash so the search query,
|
|
90
|
+
# cursor, and selected index survive across renders.
|
|
91
|
+
def save_command_palette_state(palette)
|
|
92
|
+
session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# True when a component result is the `[:selected, command]` array shape.
|
|
96
|
+
def selected_command?(result)
|
|
97
|
+
result.is_a?(Array) && result.first == :selected
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Invokes the value (proc, lambda, or method symbol) of the selected *command*, then
|
|
101
|
+
# closes the palette unless the command was :quit or the user has re-opened it.
|
|
102
|
+
def perform_command(command)
|
|
103
|
+
current_palette_state = session[:command_palette]
|
|
104
|
+
pop_command_palette_scope
|
|
105
|
+
perform_command_value(command.value)
|
|
106
|
+
if command.value != :quit && session[:command_palette].equal?(current_palette_state)
|
|
107
|
+
session.delete(:command_palette)
|
|
108
|
+
end
|
|
109
|
+
render_default_action unless response&.navigate? || response&.quit?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the theme-switching commands used by the theme picker palette.
|
|
113
|
+
def theme_commands
|
|
114
|
+
application.class.themes.keys.map do |name|
|
|
115
|
+
Presentation::Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Converts a theme name symbol (e.g., :dracula_dark) to a human-readable label ("Dracula Dark").
|
|
120
|
+
def theme_label(name)
|
|
121
|
+
name.to_s.tr("_", "-").split("-").map(&:capitalize).join(" ")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Pops focus scopes while the top of the stack is the command palette.
|
|
125
|
+
def pop_command_palette_scope
|
|
126
|
+
focus.pop_scope while focus.ring == [:command_palette]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Invokes a palette command *value* — a proc gets instance_exec'd on self, a symbol gets sent.
|
|
130
|
+
def perform_command_value(value)
|
|
131
|
+
value.respond_to?(:call) ? instance_exec(&value) : send(value)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Component-dispatch helpers mixed into Controller. Forwards key events to the currently
|
|
6
|
+
# focused component (the slot returned by `focus.current`) and translates component return
|
|
7
|
+
# values into controller hook calls (e.g., `slot_submitted`, `slot_selected`, `slot_cancelled`).
|
|
8
|
+
module ComponentDispatching
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Sends the current key event to the focused component (if it responds to `handle_key`).
|
|
12
|
+
# Returns `:handled` after dispatching, or nil when no component is focused.
|
|
13
|
+
def dispatch_to_focused_component
|
|
14
|
+
slot = focus.current
|
|
15
|
+
return nil unless slot && respond_to?(slot, true)
|
|
16
|
+
|
|
17
|
+
component = send(slot)
|
|
18
|
+
return nil unless component.respond_to?(:handle_key)
|
|
19
|
+
|
|
20
|
+
result = component.handle_key(event)
|
|
21
|
+
return nil if result.nil?
|
|
22
|
+
|
|
23
|
+
dispatch_component_result(slot, result)
|
|
24
|
+
:handled
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Translates a component `handle_key` *result* into a controller hook call. `:cancelled`
|
|
28
|
+
# triggers `<slot>_cancelled`, `[:submitted, value]` triggers `<slot>_submitted(value)`,
|
|
29
|
+
# `[:selected, value]` triggers `<slot>_selected(value)`. Falls back to a default render
|
|
30
|
+
# when no matching hook exists.
|
|
31
|
+
def dispatch_component_result(slot, result)
|
|
32
|
+
action, arguments = component_result_action(slot, result)
|
|
33
|
+
action ? send(action, *arguments) : render_default_action
|
|
34
|
+
render_default_action unless response
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolves which controller hook (if any) corresponds to the *result* from a component.
|
|
38
|
+
def component_result_action(slot, result)
|
|
39
|
+
case result
|
|
40
|
+
when :cancelled
|
|
41
|
+
component_action(slot, :cancelled)
|
|
42
|
+
when Array
|
|
43
|
+
component_array_action(slot, result)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Handles array-shaped component results, currently `[:submitted, value]` and `[:selected, value]`.
|
|
48
|
+
def component_array_action(slot, result)
|
|
49
|
+
event_name, value = result
|
|
50
|
+
return component_action(slot, :submitted, value) if event_name == :submitted
|
|
51
|
+
return component_action(slot, :selected, value) if event_name == :selected
|
|
52
|
+
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns `[action, arguments]` for the `<slot>_<suffix>` controller hook if defined, or nil.
|
|
57
|
+
def component_action(slot, suffix, *arguments)
|
|
58
|
+
action = :"#{slot}_#{suffix}"
|
|
59
|
+
return unless respond_to?(action, true)
|
|
60
|
+
|
|
61
|
+
[action, arguments]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Handles Tab/Shift+Tab by cycling through the focus ring. Returns :handled after rendering.
|
|
65
|
+
def dispatch_tab_traversal
|
|
66
|
+
return nil unless key_name == :tab
|
|
67
|
+
return nil if focus.ring.empty?
|
|
68
|
+
|
|
69
|
+
focus.cycle(event.shift ? -1 : +1)
|
|
70
|
+
render_default_action
|
|
71
|
+
:handled
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Default mouse dispatch hook: subclasses/components may override by including their own
|
|
75
|
+
# mouse logic via the controller's `dispatch_component_mouse` override.
|
|
76
|
+
def dispatch_component_mouse
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Key-dispatch helpers mixed into Controller. Resolves the current event's key symbol and
|
|
6
|
+
# looks up bindings by scope (content vs. global) before they are sent to controller actions.
|
|
7
|
+
module Dispatching
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# Returns the normalized key symbol for the current controller event.
|
|
11
|
+
def key_name
|
|
12
|
+
Charming.key_of(event)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Calls the auto-render action if one is configured. No-op when the action method is undefined.
|
|
16
|
+
def render_default_action
|
|
17
|
+
action = self.class.auto_render_action || :show
|
|
18
|
+
public_send(action) if respond_to?(action)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# True when an explicit auto-render action is configured and the just-completed *action* is
|
|
22
|
+
# not itself the auto-render action (to avoid infinite loops).
|
|
23
|
+
def auto_render_after?(action)
|
|
24
|
+
auto_render_action = self.class.auto_render_action
|
|
25
|
+
auto_render_action && action.to_sym != auto_render_action
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the action method bound to the current key at :global scope, or nil if none.
|
|
29
|
+
def global_key_action
|
|
30
|
+
key_action_for_scope(:global)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the action method bound to the current key at :content scope, or nil if the
|
|
34
|
+
# content scope is not active (e.g., sidebar has focus).
|
|
35
|
+
def content_key_action
|
|
36
|
+
return nil unless content_key_scope_active?
|
|
37
|
+
|
|
38
|
+
key_action_for_scope(:content)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns false when the controller declared a content focus ring slot and the sidebar
|
|
42
|
+
# is currently focused. Otherwise true (the default behavior for non-declarative controllers).
|
|
43
|
+
def content_key_scope_active?
|
|
44
|
+
return content_focused? if focus_ring_slot?(:content)
|
|
45
|
+
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Looks up the current key in the class bindings and returns the action only if its
|
|
50
|
+
# registered scope matches *scope*. Returns nil otherwise.
|
|
51
|
+
def key_action_for_scope(scope)
|
|
52
|
+
action = self.class.key_bindings[key_name]
|
|
53
|
+
return nil unless action
|
|
54
|
+
return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
|
|
55
|
+
|
|
56
|
+
action
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Focus helpers mixed into Controller: lazily-allocated per-controller Focus object and
|
|
6
|
+
# predicates for `focused?(:slot)` checks from views. The Focus object is keyed by controller
|
|
7
|
+
# class name in the session, so it survives across controller dispatches for the same class.
|
|
8
|
+
module FocusManagement
|
|
9
|
+
# Returns the per-controller Focus object, defining the focus ring from class-level DSL
|
|
10
|
+
# declarations on first access.
|
|
11
|
+
def focus
|
|
12
|
+
@focus ||= Focus.for(session, self.class).tap do |f|
|
|
13
|
+
f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns true when the named *slot* is the currently focused slot in this controller's focus ring.
|
|
18
|
+
def focused?(slot)
|
|
19
|
+
focus.focused?(slot)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# True when the controller class declared *slot* as part of its focus_ring DSL.
|
|
25
|
+
def focus_ring_slot?(slot)
|
|
26
|
+
self.class.focus_ring_slots.include?(slot)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Rendering pipeline mixed into Controller. Resolves view classes, template paths, layouts,
|
|
6
|
+
# and assigns. Most helpers are private — only `render`/`render_view`/`render_template` are
|
|
7
|
+
# part of the public controller API and live in `controller.rb` itself.
|
|
8
|
+
module Rendering
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Returns the string body for *body* — if it responds to `render` (e.g., a View or Component),
|
|
12
|
+
# delegates to that; otherwise calls `to_s`.
|
|
13
|
+
def render_body(body)
|
|
14
|
+
body.respond_to?(:render) ? body.render.to_s : body.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Wraps *body* (a string) in the controller's configured layout, if any. When no layout is set
|
|
18
|
+
# the body is returned as-is.
|
|
19
|
+
def render_with_layout(body)
|
|
20
|
+
rendered = render_body(body)
|
|
21
|
+
layout = self.class.layout
|
|
22
|
+
return rendered unless layout
|
|
23
|
+
|
|
24
|
+
render_body(layout_body(layout, body, rendered))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Builds the layout wrapper for *body* / *rendered* content. String/Symbol layouts are
|
|
28
|
+
# resolved as templates; other values are treated as layout view classes.
|
|
29
|
+
def layout_body(layout, body, rendered)
|
|
30
|
+
assigns = layout_assigns(body, rendered)
|
|
31
|
+
return template_body(layout, **assigns) if layout.is_a?(String) || layout.is_a?(Symbol)
|
|
32
|
+
|
|
33
|
+
layout.new(**assigns)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns a view object for *name* — a conventional view class when one exists under the
|
|
37
|
+
# application namespace, otherwise a TemplateView rendered from `app/views`.
|
|
38
|
+
def view_body(name, **assigns)
|
|
39
|
+
view_class = conventional_view_class(name)
|
|
40
|
+
return view_class.new(**template_assigns(assigns)) if view_class
|
|
41
|
+
|
|
42
|
+
template_body(name, **assigns)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Builds the assigns hash passed to layout view constructors: view's own assigns, plus
|
|
46
|
+
# `content:`, `screen:`, `controller:`, and `theme:`.
|
|
47
|
+
def layout_assigns(body, rendered)
|
|
48
|
+
view_assigns(body).merge(content: rendered, screen: screen, controller: self, theme: theme)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the assigns hash from *body* (a View), or an empty hash when *body* doesn't expose them.
|
|
52
|
+
def view_assigns(body)
|
|
53
|
+
body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resolves a template by *name* and returns a TemplateView bound to the application's namespace.
|
|
57
|
+
def template_body(name, **assigns)
|
|
58
|
+
Presentation::TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Looks up the template file under `app/views` relative to the application root.
|
|
62
|
+
def resolve_template(name)
|
|
63
|
+
Presentation::Templates.resolve(name, root: application.class.root)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the assigns hash passed to templates: `screen:`, `controller:`, `theme:` plus user *assigns*.
|
|
67
|
+
def template_assigns(assigns)
|
|
68
|
+
{screen: screen, controller: self, theme: theme}.merge(assigns)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the application namespace constant (e.g., `MyApp`) used for view-class lookup,
|
|
72
|
+
# or nil when the application has no namespace.
|
|
73
|
+
def template_namespace
|
|
74
|
+
namespace_name = application.class.namespace
|
|
75
|
+
return nil if namespace_name.to_s.empty?
|
|
76
|
+
|
|
77
|
+
Object.const_get(namespace_name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the conventional view class for *name* (e.g., `MyApp::Home::ShowView`) when defined
|
|
81
|
+
# under the application namespace. Returns nil when no such class exists.
|
|
82
|
+
def conventional_view_class(name)
|
|
83
|
+
namespace = template_namespace
|
|
84
|
+
return unless namespace
|
|
85
|
+
|
|
86
|
+
constant_path = conventional_view_constant_path(name)
|
|
87
|
+
constant_path.reduce(namespace) do |scope, constant_name|
|
|
88
|
+
break unless scope.const_defined?(constant_name, false)
|
|
89
|
+
|
|
90
|
+
scope.const_get(constant_name, false)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Builds the constant lookup path (array of strings) for a conventional view class from *name*.
|
|
95
|
+
# Splits "home/show" → ["Home", "ShowView"].
|
|
96
|
+
def conventional_view_constant_path(name)
|
|
97
|
+
parts = name.to_s.split("/")
|
|
98
|
+
action = parts.pop
|
|
99
|
+
parts.map { |part| camelize(part) } + ["#{camelize(action)}View"]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Converts a snake_case string to CamelCase. Used to build conventional view constant names.
|
|
103
|
+
def camelize(value)
|
|
104
|
+
value.to_s.split("_").map(&:capitalize).join
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns the default template path for a given *action* (e.g., "home/show" for HomeController#show).
|
|
108
|
+
def default_template_name(action)
|
|
109
|
+
"#{controller_template_path}/#{action}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the underscored controller path (e.g., "home" for HomeController) used for view lookup.
|
|
113
|
+
def controller_template_path
|
|
114
|
+
underscore(self.class.name.split("::").last.delete_suffix("Controller"))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Converts CamelCase to snake_case.
|
|
118
|
+
def underscore(value)
|
|
119
|
+
value
|
|
120
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
|
|
121
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
122
|
+
.tr("-", "_")
|
|
123
|
+
.downcase
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Session-state helpers mixed into Controller: accessing the application session hash, lazy
|
|
6
|
+
# state-object lookup by name/class, form builder invocation, and async task submission.
|
|
7
|
+
module SessionState
|
|
8
|
+
# Returns the application session hash for this controller. All persistent state (focus,
|
|
9
|
+
# sidebar index, command palette, user state objects) lives here.
|
|
10
|
+
def session
|
|
11
|
+
application.session
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns the named session-backed state object, creating it on first access. *name* is a
|
|
15
|
+
# symbol key under `session[:states]`. *state_class* is an ApplicationState subclass whose
|
|
16
|
+
# constructor receives *attributes* on first creation. Subsequent calls return the same object.
|
|
17
|
+
def state(name, state_class, **attributes)
|
|
18
|
+
session[:states] ||= {}
|
|
19
|
+
session[:states][name.to_sym] ||= state_class.new(**attributes)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Builds a Form component scoped to the named form slot in `session[:forms]`. The block is
|
|
23
|
+
# evaluated against a Form::Builder (or invoked with the builder as its argument for arity-1 blocks)
|
|
24
|
+
# and returns a Form component pre-bound to the per-form mutable state hash.
|
|
25
|
+
def form(name, &block)
|
|
26
|
+
session[:forms] ||= {}
|
|
27
|
+
form_state = session[:forms][name.to_sym] ||= {}
|
|
28
|
+
builder = Presentation::Components::Form::Builder.new(theme: theme)
|
|
29
|
+
block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
|
|
30
|
+
builder.build(state: form_state, theme: theme)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Submits a background task with the given *name*. The block is executed by the configured
|
|
34
|
+
# task executor; its return value (or any raised exception) is delivered to the controller
|
|
35
|
+
# as a TaskEvent dispatched to the matching `on_task` handler.
|
|
36
|
+
def run_task(name, &block)
|
|
37
|
+
application.task_executor.submit(name, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|