charming 0.1.1 → 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 +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- 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 +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -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 +123 -47
- 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 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- 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 +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- 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 +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -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/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 +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -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_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2bc5c3942786d07631d42361391e76f4f42275cc472c8ff7e37669880263b978
|
|
4
|
+
data.tar.gz: b6dc9eef28cf8eb6849a9ae34a4af8e6902a0e263529762cce9a3e591ed06396
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e772a624b0f4a51d722ed40863bfae85161ac9bc1b508d7accb6cc7a4fc8f30352a79b66a9d42416992d38477c145f5ecc55c953b77aca1cf787a03a5f2f0e64
|
|
7
|
+
data.tar.gz: 61dc03c8e8ade6e62fbc86c6f76e382063cc13aa1f60cc8afa161f6a646d1e4836483f9ca9a2e8446881f5b5a65d4b8e5107e39300c5844034dbbcb33f37a47f
|
data/README.md
CHANGED
|
@@ -52,8 +52,8 @@ The generator produces a Bundler gem with a Rails-like structure:
|
|
|
52
52
|
app/controllers/ # controller actions and input bindings
|
|
53
53
|
app/state/ # session-backed TUI state
|
|
54
54
|
app/models/ # optional Active Record models
|
|
55
|
-
app/views/home/
|
|
56
|
-
app/views/layouts/
|
|
55
|
+
app/views/home/show_view.rb # screen view classes
|
|
56
|
+
app/views/layouts/application_layout.rb # layout view class
|
|
57
57
|
app/components/ # reusable components
|
|
58
58
|
config/routes.rb # route definitions
|
|
59
59
|
lib/my_app.rb # namespace loader (Zeitwerk)
|
data/lib/charming/application.rb
CHANGED
|
@@ -22,12 +22,16 @@ module Charming
|
|
|
22
22
|
name&.split("::")&.then { |parts| parts[0...-1].join("::") }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Returns the app's filesystem root, used to resolve relative theme and template paths.
|
|
26
|
+
# Pass *path* to set it; without arguments it returns the current value (or nil if unset).
|
|
25
27
|
def root(path = THEME_READER)
|
|
26
28
|
return @root if path == THEME_READER
|
|
27
29
|
|
|
28
30
|
@root = File.expand_path(path)
|
|
29
31
|
end
|
|
30
32
|
|
|
33
|
+
# Registers a named theme. Provide either *from:* (path to a JSON file relative to the app root)
|
|
34
|
+
# or *built_in:* (name of a bundled theme such as "phosphor"). Raises when neither or both are given.
|
|
31
35
|
def theme(name, from: nil, built_in: nil)
|
|
32
36
|
raise ArgumentError, "theme expects from: or built_in:" unless from || built_in
|
|
33
37
|
raise ArgumentError, "theme expects either from: or built_in:, not both" if from && built_in
|
|
@@ -39,16 +43,21 @@ module Charming
|
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
|
|
46
|
+
# Hash of all registered themes keyed by symbol, including those inherited from superclasses.
|
|
42
47
|
def themes
|
|
43
48
|
@themes ||= superclass.respond_to?(:themes) ? superclass.themes.dup : {}
|
|
44
49
|
end
|
|
45
50
|
|
|
51
|
+
# Returns the default theme name, or sets it when *name* is given. When unset, falls back
|
|
52
|
+
# to the first registered theme. Used by `theme_for` when no name is provided.
|
|
46
53
|
def default_theme(name = THEME_READER)
|
|
47
54
|
return @default_theme || themes.keys.first if name == THEME_READER
|
|
48
55
|
|
|
49
56
|
@default_theme = name.to_sym
|
|
50
57
|
end
|
|
51
58
|
|
|
59
|
+
# Resolves a theme by *name* (or the default theme when *name* is nil). Returns the default
|
|
60
|
+
# built-in theme if no name is given and no default is registered.
|
|
52
61
|
def theme_for(name = nil)
|
|
53
62
|
theme_name = name || default_theme
|
|
54
63
|
return Presentation::UI::Theme.default unless theme_name
|
|
@@ -58,6 +67,8 @@ module Charming
|
|
|
58
67
|
|
|
59
68
|
private
|
|
60
69
|
|
|
70
|
+
# Expands a relative theme path against the app root (or the current working directory
|
|
71
|
+
# when no root is configured). Returns *path* unchanged when it is already absolute.
|
|
61
72
|
def resolve_theme_path(path)
|
|
62
73
|
return path if File.absolute_path?(path)
|
|
63
74
|
|
data/lib/charming/cli.rb
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
+
# CLI dispatches the `charming` executable's subcommands to the appropriate generators
|
|
5
|
+
# or database commands. Subcommands:
|
|
6
|
+
# - `charming new NAME [--database sqlite3] [--force]` — scaffolds a new app
|
|
7
|
+
# - `charming generate TYPE NAME [args]` — runs a sub-generator (controller, model, screen, view, component)
|
|
8
|
+
# - `charming db:COMMAND` — runs a database command (db:create, db:migrate, db:rollback, db:drop, db:seed, db:install)
|
|
9
|
+
#
|
|
10
|
+
# Generator errors are caught and printed to stderr; the process exits with status 1.
|
|
4
11
|
class CLI
|
|
12
|
+
# *out* defaults to `$stdout`, *err* to `$stderr`, *pwd* to `Dir.pwd` (overridable for tests).
|
|
5
13
|
def initialize(out: $stdout, err: $stderr, pwd: Dir.pwd)
|
|
6
14
|
@out = out
|
|
7
15
|
@err = err
|
|
8
16
|
@pwd = pwd
|
|
9
17
|
end
|
|
10
18
|
|
|
19
|
+
# Runs the CLI with the given *argv* array. Returns 0 on success, 1 on a generator error,
|
|
20
|
+
# or the status from `usage` for unknown subcommands.
|
|
11
21
|
def call(argv)
|
|
12
22
|
command, *args = argv
|
|
13
23
|
case command
|
|
@@ -23,8 +33,11 @@ module Charming
|
|
|
23
33
|
|
|
24
34
|
private
|
|
25
35
|
|
|
36
|
+
# Standard output, standard error, and working directory used for generator destinations.
|
|
26
37
|
attr_reader :out, :err, :pwd
|
|
27
38
|
|
|
39
|
+
# Handles `charming new`. Validates args, extracts `--database=` and `--force`,
|
|
40
|
+
# and runs AppGenerator. Returns 0 on success, raises Generators::Error on bad input.
|
|
28
41
|
def new_app(args)
|
|
29
42
|
force = args.delete("--force")
|
|
30
43
|
database = extract_database(args)
|
|
@@ -35,6 +48,8 @@ module Charming
|
|
|
35
48
|
0
|
|
36
49
|
end
|
|
37
50
|
|
|
51
|
+
# Handles `charming generate TYPE NAME [args]`. Extracts `--force` and dispatches to
|
|
52
|
+
# the generator class for the requested type.
|
|
38
53
|
def generate(args)
|
|
39
54
|
force = args.delete("--force")
|
|
40
55
|
type = args.shift || raise(Generators::Error, "Usage: charming generate TYPE NAME [actions]")
|
|
@@ -42,11 +57,13 @@ module Charming
|
|
|
42
57
|
0
|
|
43
58
|
end
|
|
44
59
|
|
|
60
|
+
# Builds the generator instance for the given *type*, popping the name from *args*.
|
|
45
61
|
def generator(type, args, force)
|
|
46
62
|
name = args.shift || raise(Generators::Error, "Usage: charming generate #{type} NAME")
|
|
47
63
|
generator_class(type).new(name, args, out: out, destination: pwd, force: force)
|
|
48
64
|
end
|
|
49
65
|
|
|
66
|
+
# Returns the generator class for a *type* string (controller, model, screen, view, component).
|
|
50
67
|
def generator_class(type)
|
|
51
68
|
{
|
|
52
69
|
"controller" => Generators::ControllerGenerator,
|
|
@@ -57,6 +74,8 @@ module Charming
|
|
|
57
74
|
}.fetch(type) { raise Generators::Error, "Unknown generator: #{type}" }
|
|
58
75
|
end
|
|
59
76
|
|
|
77
|
+
# Routes `db:*` commands to either the install path (db:install) or the generic
|
|
78
|
+
# DatabaseCommands dispatcher.
|
|
60
79
|
def database(command, args)
|
|
61
80
|
if command == "db:install"
|
|
62
81
|
database = args.shift || raise(Generators::Error, "Usage: charming db:install sqlite3")
|
|
@@ -71,6 +90,8 @@ module Charming
|
|
|
71
90
|
0
|
|
72
91
|
end
|
|
73
92
|
|
|
93
|
+
# Extracts the optional `--database=<value>` argument from *args*, removing it in place.
|
|
94
|
+
# Returns the validated database name (currently only "sqlite3") or nil when not given.
|
|
74
95
|
def extract_database(args)
|
|
75
96
|
inline = args.find { |arg| arg.start_with?("--database=") }
|
|
76
97
|
return validate_database(args.delete(inline).split("=", 2).last) if inline
|
|
@@ -82,12 +103,14 @@ module Charming
|
|
|
82
103
|
validate_database(args.delete_at(index) || raise(Generators::Error, "Usage: charming new NAME [--database sqlite3] [--force]"))
|
|
83
104
|
end
|
|
84
105
|
|
|
106
|
+
# Validates that *database* is a supported adapter name. Currently only "sqlite3".
|
|
85
107
|
def validate_database(database)
|
|
86
108
|
return database if database == "sqlite3"
|
|
87
109
|
|
|
88
110
|
raise Generators::Error, "Unsupported database: #{database.inspect}"
|
|
89
111
|
end
|
|
90
112
|
|
|
113
|
+
# Prints a usage banner to stderr and returns *status* (1 for unknown commands).
|
|
91
114
|
def usage(status)
|
|
92
115
|
err.puts "Usage: charming new NAME | charming generate TYPE NAME [args] | charming db:COMMAND"
|
|
93
116
|
status
|
|
@@ -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
|