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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +113 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +15 -0
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. 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: 739b00d7bbbe867e98ec93bc614157e59650eb5ecd45a48127139fb5ea11adb1
4
- data.tar.gz: 8c7008f8bcd6eba1464d01e317c23a37d93a47cf272cc617295c5a4f6f50e379
3
+ metadata.gz: 2bc5c3942786d07631d42361391e76f4f42275cc472c8ff7e37669880263b978
4
+ data.tar.gz: b6dc9eef28cf8eb6849a9ae34a4af8e6902a0e263529762cce9a3e591ed06396
5
5
  SHA512:
6
- metadata.gz: 69179596724fc972425aa5c47cd449d31278ea51671c6afc25543d5db58ea2b2edcb035843cc76f53c663f65bbda24e9990cc5cab90142036a228bb68e9cf9e9
7
- data.tar.gz: 55b113fa7a8a0208559716a30d18ec070e6c0b070032f16a9d66daa67273b9c3d81ab5aefe231b36240b783c200d9eff4321f7df9596a845b43f2343f4754f8e
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/show.tui.erb # screen templates
56
- app/views/layouts/application.tui.erb # layout template
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)
@@ -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