charming 0.2.0 → 0.2.1

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. metadata +36 -49
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3edeedc6b7d09bb964b5f3e6ba7306ced50355bfffb93c049c9cc7415cab4d2
4
- data.tar.gz: 3944a4cef79f57035ac08d5dbeba1fef7e96504c5ea34c3237e2dad08dcdde31
3
+ metadata.gz: d6b9e1f0689d7ebe1c6ab12b622339c8826bc3e1f44013e8b3c3d7e6e07be98a
4
+ data.tar.gz: d1aecf5b432f95241693509d597ca92c39b29549e3a9209be3e49421906fe60c
5
5
  SHA512:
6
- metadata.gz: ea1881f3bf0d3517cc2a0f20b8ae15fcd452c091d71f6535bc632a7dd66be6abaf97fa3ca76aef567be068be41a374c11e263529cc43dad931ac7fe344847061
7
- data.tar.gz: 6b396f225bdb25bf3cd1f5ff569d4907a9e2e0be8bbfc4ad500021251f1bc39ba65b270b80d28b7dd9fc087294fc9a91d4fb4da5dfbdfbdced19040074093c0a
6
+ metadata.gz: 3440e877894c5a6146582165c7a8dd68c9d409799f8e99b517077c753f6f29ba5905346088bb5594d1a140bae1f5ba8f861f76b2f45c11ade91f0658b0108932
7
+ data.tar.gz: 0cede340d3d193c12acd7441868097987174dc96e85a0c9248a18b922dd0df341ebd7f8d305ee901df703b1553a5966aab611ddde9aa3e07e8ac5db2cca52cd7
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A Rails-inspired terminal user interface framework for **Ruby 4+**.
4
4
 
5
- Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, background tasks, and testable terminal backends.
5
+ Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, background tasks, cross-platform audio playback, and testable terminal backends.
6
6
 
7
7
  ## Project Status
8
8
 
@@ -47,7 +47,7 @@ lib/my_app.rb # namespace loader (Zeitwerk)
47
47
  exe/my_app # executable entry point
48
48
  ```
49
49
 
50
- Generated apps include a sidebar/content layout, command palette, focus management, theme switching, and default key bindings for commands (`p`) and quit (`q`).
50
+ Generated apps include a sidebar/content layout, command palette, focus management, theme switching, and default key bindings for commands (`ctrl+p`) and quit (`q`).
51
51
 
52
52
  ## Development
53
53
 
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
5
+
3
6
  module Charming
4
7
  # Application is a lightweight, Rails-inspired application base for building
5
8
  # terminal-based apps. It provides routing (via a DSL), session storage, and
@@ -39,13 +42,27 @@ module Charming
39
42
  @root = File.expand_path(path)
40
43
  end
41
44
 
42
- # Registers a named theme. Provide either *from:* (path to a JSON file relative to the app root)
43
- # or *built_in:* (name of a bundled theme such as "phosphor"). Raises when neither or both are given.
44
- def theme(name, from: nil, built_in: nil)
45
- raise ArgumentError, "theme expects from: or built_in:" unless from || built_in
46
- raise ArgumentError, "theme expects either from: or built_in:, not both" if from && built_in
47
-
48
- themes[name.to_sym] = if built_in
45
+ # Registers a named theme. Provide one of:
46
+ # - *from:* path to a JSON theme file relative to the app root
47
+ # - *built_in:* — name of a bundled theme ("phosphor", "catppuccin-mocha",
48
+ # "catppuccin-latte", "gruvbox-dark", "nord", "tokyonight")
49
+ # - *extends:* name of an already-registered theme to derive from, with
50
+ # *overrides:* (token name → style spec) merged on top:
51
+ #
52
+ # theme :dark, built_in: "tokyonight"
53
+ # theme :high_contrast, extends: :dark, overrides: {text: {foreground: "#ffffff"}}
54
+ def theme(name, from: nil, built_in: nil, extends: nil, overrides: nil)
55
+ sources = [from, built_in, extends].compact
56
+ raise ArgumentError, "theme expects from:, built_in:, or extends:" if sources.empty?
57
+ raise ArgumentError, "theme expects only one of from:, built_in:, or extends:" if sources.length > 1
58
+ raise ArgumentError, "overrides: requires extends:" if overrides && !extends
59
+
60
+ themes[name.to_sym] = if extends
61
+ parent = themes.fetch(extends.to_sym) do
62
+ raise ArgumentError, "unknown parent theme: #{extends.inspect} (register it before extending)"
63
+ end
64
+ parent.merge(overrides || {})
65
+ elsif built_in
49
66
  UI::Theme.load_builtin(built_in)
50
67
  else
51
68
  UI::Theme.load_file(resolve_theme_path(from))
@@ -74,6 +91,23 @@ module Charming
74
91
  themes.fetch(theme_name.to_sym)
75
92
  end
76
93
 
94
+ # Opts into session persistence: the session hash is serialized as JSON to *to*
95
+ # when the app quits and reloaded on boot. Only JSON-safe values survive the
96
+ # round-trip (hash keys come back as symbols); non-serializable entries (state
97
+ # objects, procs) are skipped with a warning in the log.
98
+ def persist_session(to:)
99
+ @session_path = to
100
+ end
101
+
102
+ # The configured session file path, walking the superclass chain. Nil when
103
+ # persistence is not enabled.
104
+ def session_path
105
+ return @session_path if instance_variable_defined?(:@session_path)
106
+ return superclass.session_path if superclass.respond_to?(:session_path)
107
+
108
+ nil
109
+ end
110
+
77
111
  private
78
112
 
79
113
  def configured_logger
@@ -95,10 +129,24 @@ module Charming
95
129
  attr_accessor :logger, :task_executor
96
130
  attr_reader :session
97
131
 
98
- # Initializes an empty session hash for per-request state storage.
132
+ # Initializes the session hash for per-request state storage, restoring a
133
+ # previously persisted session when `persist_session` is configured.
99
134
  def initialize
100
135
  @logger = self.class.logger
101
- @session = {}
136
+ @session = load_session
137
+ end
138
+
139
+ # Serializes the session to the configured `persist_session` path. Entries that
140
+ # don't survive a JSON round-trip (state objects, procs, focus scopes) are skipped.
141
+ # No-op when persistence isn't configured. Called by the Runtime on exit.
142
+ def save_session
143
+ path = self.class.session_path
144
+ return unless path
145
+
146
+ FileUtils.mkdir_p(File.dirname(path))
147
+ File.write(path, JSON.generate(serializable_session))
148
+ rescue => e
149
+ logger.warn("session not saved: #{e.class}: #{e.message}")
102
150
  end
103
151
 
104
152
  # Delegates to the class-level Router, providing instance access to route definitions.
@@ -114,5 +162,44 @@ module Charming
114
162
  self.class.theme_for(name)
115
163
  session[:theme] = name.to_sym
116
164
  end
165
+
166
+ private
167
+
168
+ # Loads the persisted session JSON (symbolizing keys), or {} when absent/invalid.
169
+ def load_session
170
+ path = self.class.session_path
171
+ return {} unless path && File.exist?(path)
172
+
173
+ JSON.parse(File.read(path), symbolize_names: true)
174
+ rescue JSON::ParserError => e
175
+ logger.warn("session not restored: #{e.message}")
176
+ {}
177
+ end
178
+
179
+ # Framework-internal session keys that must not be persisted: their values carry
180
+ # symbols in *values* (which JSON round-trips into strings, corrupting focus rings
181
+ # and palette state) and they describe transient UI state anyway.
182
+ INTERNAL_SESSION_KEYS = %i[focus_state mouse_targets command_palette].freeze
183
+
184
+ # The subset of session entries that survive a JSON round-trip: nil, booleans,
185
+ # numbers, strings, symbols, and arrays/hashes of those. State objects, procs,
186
+ # framework-internal keys, and other rich values are skipped (hash keys come back
187
+ # as symbols via symbolize_names; symbol *values* come back as strings).
188
+ def serializable_session
189
+ session.except(*INTERNAL_SESSION_KEYS).select { |_key, value| json_safe?(value) }
190
+ end
191
+
192
+ def json_safe?(value)
193
+ case value
194
+ when nil, true, false, String, Symbol, Integer, Float
195
+ true
196
+ when Array
197
+ value.all? { |item| json_safe?(item) }
198
+ when Hash
199
+ value.all? { |key, item| (key.is_a?(String) || key.is_a?(Symbol)) && json_safe?(item) }
200
+ else
201
+ false
202
+ end
203
+ end
117
204
  end
118
205
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Audio provides simple, cross-platform sound playback by shelling out to a system
5
+ # audio binary. The engine lives in {Player}; {System} is the swappable OS adapter.
6
+ module Audio
7
+ # Player plays a single sound file by spawning a system audio binary, and exposes
8
+ # `stop`/`playing?`/`wait` to manage the child process. It never blocks the event
9
+ # loop on its own — call {play} for fire-and-forget playback, or drive it from a
10
+ # controller `run_task` (spawn + {wait}, with an `ensure player.stop`) to get a
11
+ # completion event and reliable teardown when the app quits.
12
+ #
13
+ # A backend binary is resolved on first use, in priority order: `ffplay` (from
14
+ # ffmpeg) on every platform, then OS-native players (`afplay` on macOS; `paplay`,
15
+ # `mpg123`, `aplay` on Linux). {Unavailable} is raised when none are installed.
16
+ class Player
17
+ # Raised by {play} when no supported audio backend is found on `PATH`.
18
+ class Unavailable < Charming::Error; end
19
+
20
+ # Candidate backends in resolution order. `:os` is `:any`, `:macos`, or `:linux`;
21
+ # `:args` are inserted before the file path in the spawned command.
22
+ BACKENDS = [
23
+ {command: "ffplay", os: :any, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"]},
24
+ {command: "afplay", os: :macos, args: []},
25
+ {command: "paplay", os: :linux, args: []},
26
+ {command: "mpg123", os: :linux, args: ["-q"]},
27
+ {command: "aplay", os: :linux, args: ["-q"]}
28
+ ].freeze
29
+
30
+ # *system* is the OS adapter used to probe `PATH` and spawn/track the player
31
+ # process. The default talks to the real OS; specs inject a fake.
32
+ def initialize(system: System.new)
33
+ @system = system
34
+ @pid = nil
35
+ end
36
+
37
+ # Plays the sound file at *path*, stopping any sound already in progress first.
38
+ # Spawns the resolved backend and returns the child PID. Raises {Unavailable}
39
+ # when no backend binary is installed for this platform.
40
+ def play(path)
41
+ backend = resolve_backend!
42
+ stop if playing?
43
+ @pid = @system.spawn([backend[:command], *backend[:args], path.to_s])
44
+ end
45
+
46
+ # Stops the current sound (if any), terminating and reaping the child process.
47
+ # Safe to call when nothing is playing.
48
+ def stop
49
+ return unless @pid
50
+
51
+ @system.terminate(@pid)
52
+ @system.wait(@pid)
53
+ @pid = nil
54
+ end
55
+
56
+ # True while a spawned sound is still playing.
57
+ def playing?
58
+ !@pid.nil? && @system.alive?(@pid)
59
+ end
60
+
61
+ # Blocks until the current sound finishes, then clears it. Intended for use inside
62
+ # a background `run_task`. If the task thread is killed mid-wait (e.g. on app
63
+ # shutdown), `@pid` is left intact so an `ensure player.stop` can reap the child.
64
+ def wait
65
+ return unless @pid
66
+
67
+ @system.wait(@pid)
68
+ @pid = nil
69
+ end
70
+
71
+ # True when a backend binary is installed for this platform. Lets callers degrade
72
+ # gracefully (e.g. skip a chime) instead of rescuing {Unavailable}.
73
+ def available?
74
+ !backend.nil?
75
+ end
76
+
77
+ private
78
+
79
+ # Returns the resolved backend or raises {Unavailable} listing what was searched.
80
+ def resolve_backend!
81
+ backend || raise(Unavailable, "no audio player found on PATH (looked for: #{searched.join(", ")})")
82
+ end
83
+
84
+ # The first supported, installed backend for this platform, or nil. Memoized once found.
85
+ def backend
86
+ @backend ||= BACKENDS.find { |candidate| supported?(candidate) && @system.which?(candidate[:command]) }
87
+ end
88
+
89
+ # The command names that apply to this platform, in order (for error messages).
90
+ def searched
91
+ BACKENDS.select { |candidate| supported?(candidate) }.map { |candidate| candidate[:command] }
92
+ end
93
+
94
+ # True when *candidate* targets this platform.
95
+ def supported?(candidate)
96
+ case candidate[:os]
97
+ when :any then true
98
+ when :macos then @system.macos?
99
+ when :linux then @system.linux?
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Audio
5
+ # System is the OS adapter the {Player} uses to locate and control audio-player
6
+ # processes. It wraps Ruby's `Process`/`ENV`/`RbConfig` so specs can substitute a
7
+ # fake collaborator and never shell out, touch the real process table, or play sound.
8
+ class System
9
+ # *host_os* identifies the platform (defaults to the running Ruby's). *path* is the
10
+ # `PATH` string searched by {which?} (defaults to the process environment).
11
+ def initialize(host_os: RbConfig::CONFIG["host_os"], path: ENV["PATH"])
12
+ @host_os = host_os.to_s
13
+ @path = path.to_s
14
+ end
15
+
16
+ # True on macOS.
17
+ def macos?
18
+ @host_os.match?(/darwin/i)
19
+ end
20
+
21
+ # True on Linux.
22
+ def linux?
23
+ @host_os.match?(/linux/i)
24
+ end
25
+
26
+ # True when *command* resolves to an executable file on `PATH`.
27
+ def which?(command)
28
+ path_dirs.any? do |dir|
29
+ candidate = File.join(dir, command)
30
+ File.file?(candidate) && File.executable?(candidate)
31
+ end
32
+ end
33
+
34
+ # Spawns *argv* (an array) detached from the terminal, discarding the child's
35
+ # stdout/stderr, and returns the child PID.
36
+ def spawn(argv)
37
+ Process.spawn(*argv, out: File::NULL, err: File::NULL)
38
+ end
39
+
40
+ # Sends `SIGTERM` to *pid*, ignoring a process that has already exited.
41
+ def terminate(pid)
42
+ Process.kill("TERM", pid)
43
+ rescue Errno::ESRCH
44
+ nil
45
+ end
46
+
47
+ # True while *pid* is still running. Reaps the child (non-blocking) once it exits.
48
+ def alive?(pid)
49
+ Process.waitpid(pid, Process::WNOHANG).nil?
50
+ rescue Errno::ECHILD, Errno::ESRCH
51
+ false
52
+ end
53
+
54
+ # Blocks until *pid* exits, then reaps it. No-op when the child is already gone.
55
+ def wait(pid)
56
+ Process.waitpid(pid)
57
+ rescue Errno::ECHILD, Errno::ESRCH
58
+ nil
59
+ end
60
+
61
+ private
62
+
63
+ # Returns the directories on `PATH`.
64
+ def path_dirs
65
+ @path.split(File::PATH_SEPARATOR)
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/charming/cli.rb CHANGED
@@ -23,6 +23,7 @@ module Charming
23
23
  case command
24
24
  when "new" then new_app(args)
25
25
  when "generate", "g" then generate(args)
26
+ when "console", "c" then console(args)
26
27
  when /^db:/ then database(command, args)
27
28
  else usage(1)
28
29
  end
@@ -63,19 +64,76 @@ module Charming
63
64
  generator_class(type).new(name, args, out: out, destination: pwd, force: force)
64
65
  end
65
66
 
66
- # Returns the generator class for a *type* string (controller, model, screen, view, component).
67
+ # Returns the generator class for a *type* string (controller, model, screen, view,
68
+ # component, migration).
67
69
  def generator_class(type)
68
70
  {
69
71
  "controller" => Generators::ControllerGenerator,
70
72
  "model" => Generators::ModelGenerator,
71
73
  "screen" => Generators::ScreenGenerator,
72
74
  "view" => Generators::ViewGenerator,
73
- "component" => Generators::ComponentGenerator
75
+ "component" => Generators::ComponentGenerator,
76
+ "migration" => Generators::MigrationGenerator
74
77
  }.fetch(type) { raise Generators::Error, "Unknown generator: #{type}" }
75
78
  end
76
79
 
80
+ # Handles `charming console`: loads the app (root file, which sets up Zeitwerk and the
81
+ # database when configured), prints a banner, and opens IRB with `app` available.
82
+ def console(args)
83
+ raise Generators::Error, "Usage: charming console" if args.any?
84
+
85
+ root_file = app_root_file
86
+ raise Generators::Error, "Run this command from a Charming app root" unless root_file
87
+
88
+ require "irb"
89
+ require root_file
90
+ out.puts "Loading #{Charming.env} environment (Charming #{Charming::VERSION})"
91
+ app_class = console_application_class(root_file)
92
+ ConsoleContext.start(app_class)
93
+ 0
94
+ end
95
+
96
+ # The app's root loader (`lib/<gemspec name>.rb`), or nil when not in an app root.
97
+ def app_root_file
98
+ gemspec = Dir.glob(File.join(pwd, "*.gemspec")).first
99
+ return nil unless gemspec
100
+
101
+ path = File.join(pwd, "lib", "#{File.basename(gemspec, ".gemspec")}.rb")
102
+ File.exist?(path) ? path : nil
103
+ end
104
+
105
+ # Resolves `<AppModule>::Application` from the root file name, or nil.
106
+ def console_application_class(root_file)
107
+ module_name = ActiveSupport::Inflector.camelize(File.basename(root_file, ".rb"))
108
+ ActiveSupport::Inflector.constantize("#{module_name}::Application")
109
+ rescue NameError
110
+ nil
111
+ end
112
+
113
+ # ConsoleContext is the binding IRB starts in: `app` returns a memoized application
114
+ # instance when the app class was resolvable.
115
+ class ConsoleContext
116
+ def self.start(app_class)
117
+ new(app_class).start
118
+ end
119
+
120
+ def initialize(app_class)
121
+ @app_class = app_class
122
+ end
123
+
124
+ def app
125
+ @app ||= @app_class&.new
126
+ end
127
+
128
+ def start
129
+ IRB.setup(nil)
130
+ workspace = IRB::WorkSpace.new(binding)
131
+ IRB::Irb.new(workspace).run(IRB.conf)
132
+ end
133
+ end
134
+
77
135
  # Routes `db:*` commands to either the install path (db:install) or the generic
78
- # Database::Commands dispatcher.
136
+ # Database::Commands dispatcher. Extra arguments (e.g., `STEP=2`) are passed through.
79
137
  def database(command, args)
80
138
  if command == "db:install"
81
139
  database = args.shift || raise(Generators::Error, "Usage: charming db:install sqlite3")
@@ -83,9 +141,7 @@ module Charming
83
141
 
84
142
  Generators::DatabaseInstaller.new(database, out: out, destination: pwd).install
85
143
  else
86
- raise Generators::Error, "Usage: charming #{command}" if args.any?
87
-
88
- Database::Commands.new(command, out: out, destination: pwd).run
144
+ Database::Commands.new(command, args: args, out: out, destination: pwd).run
89
145
  end
90
146
  0
91
147
  end
@@ -112,7 +168,7 @@ module Charming
112
168
 
113
169
  # Prints a usage banner to stderr and returns *status* (1 for unknown commands).
114
170
  def usage(status)
115
- err.puts "Usage: charming new NAME | charming generate TYPE NAME [args] | charming db:COMMAND"
171
+ err.puts "Usage: charming new NAME | charming generate TYPE NAME [args] | charming console | charming db:COMMAND"
116
172
  status
117
173
  end
118
174
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # ActionHooks provides Rails-style before/after/around action hooks and rescue_from.
6
+ # Class-level DSL: before_action, after_action, around_action, rescue_from.
7
+ # Hook arrays are inherited by subclasses via dup.
8
+ module ActionHooks
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ base.include(InstanceMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ # Registers a before hook that runs before the given *actions* (or all actions when
16
+ # *only:* is omitted). *except:* excludes specific actions.
17
+ def before_action(method_name, only: nil, except: nil)
18
+ action_hooks << {type: :before, method: method_name, only: normalize_filter(only), except: normalize_filter(except)}
19
+ end
20
+
21
+ # Registers an after hook. Runs after the action even if the action rendered early.
22
+ def after_action(method_name, only: nil, except: nil)
23
+ action_hooks << {type: :after, method: method_name, only: normalize_filter(only), except: normalize_filter(except)}
24
+ end
25
+
26
+ # Registers an around hook. The hook method must yield to invoke the action.
27
+ def around_action(method_name, only: nil, except: nil)
28
+ action_hooks << {type: :around, method: method_name, only: normalize_filter(only), except: normalize_filter(except)}
29
+ end
30
+
31
+ # Registers an exception handler. When an action raises an exception matching *klass*
32
+ # (or any of *classes*), the controller calls *with:* instead of propagating.
33
+ def rescue_from(*classes, with:)
34
+ rescue_handlers << {classes: classes.flatten, with: with}
35
+ end
36
+
37
+ # All registered hooks, inherited from superclass.
38
+ def action_hooks
39
+ @action_hooks ||= superclass.respond_to?(:action_hooks) ? superclass.action_hooks.dup : []
40
+ end
41
+
42
+ # All registered rescue handlers, inherited from superclass.
43
+ def rescue_handlers
44
+ @rescue_handlers ||= superclass.respond_to?(:rescue_handlers) ? superclass.rescue_handlers.dup : []
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_filter(value)
50
+ return nil if value.nil?
51
+
52
+ Array(value).map(&:to_sym)
53
+ end
54
+ end
55
+
56
+ module InstanceMethods
57
+ private
58
+
59
+ # Wraps an action call in the full before/around/after hook chain and rescue handlers.
60
+ # Replaces the plain `public_send(action)` in Controller#dispatch.
61
+ def run_action_with_hooks(action)
62
+ run_with_rescue(action) { run_around_hooks(action) { run_action(action) } }
63
+ end
64
+
65
+ def run_action(action)
66
+ run_before_hooks(action)
67
+ public_send(action)
68
+ run_after_hooks(action)
69
+ end
70
+
71
+ def run_before_hooks(action)
72
+ hooks_for(action, :before).each { |hook| send(hook[:method]) }
73
+ end
74
+
75
+ def run_after_hooks(action)
76
+ hooks_for(action, :after).each { |hook| send(hook[:method]) }
77
+ end
78
+
79
+ def run_around_hooks(action, &block)
80
+ around = hooks_for(action, :around)
81
+ wrap_around(around, 0, &block)
82
+ end
83
+
84
+ def wrap_around(hooks, index, &block)
85
+ return yield if index >= hooks.length
86
+
87
+ send(hooks[index][:method]) { wrap_around(hooks, index + 1, &block) }
88
+ end
89
+
90
+ def run_with_rescue(action)
91
+ yield
92
+ rescue => e
93
+ handler = rescue_handler_for(e)
94
+ raise unless handler
95
+
96
+ send(handler[:with], e)
97
+ render_default_action unless response
98
+ end
99
+
100
+ # Finds the handler whose rescued class is most specific for *exception* (closest in its
101
+ # ancestor chain). Ties go to the last-registered handler. Note: this deliberately differs
102
+ # from Rails, where declaration order alone decides — specificity is less surprising.
103
+ def rescue_handler_for(exception)
104
+ ancestors = exception.class.ancestors
105
+ best = self.class.rescue_handlers.reverse.filter_map { |handler|
106
+ specificity = handler[:classes].filter_map { |klass| ancestors.index(klass) }.min
107
+ [specificity, handler] if specificity
108
+ }.min_by(&:first)
109
+ best&.last
110
+ end
111
+
112
+ def hooks_for(action, type)
113
+ self.class.action_hooks.select do |hook|
114
+ next false unless hook[:type] == type
115
+ next false if hook[:only] && !hook[:only].include?(action.to_sym)
116
+ next false if hook[:except]&.include?(action.to_sym)
117
+
118
+ true
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -11,7 +11,7 @@ module Charming
11
11
  # shortcuts that fire regardless of focus. Raises ArgumentError for any other scope.
12
12
  def key(name, action, scope: :content)
13
13
  normalized_scope = validate_key_scope(scope)
14
- key_name = name.to_sym
14
+ key_name = Charming.key_binding_name(name)
15
15
  key_bindings[key_name] = action
16
16
  key_binding_scopes[key_name] = normalized_scope
17
17
  end
@@ -25,6 +25,8 @@ module Charming
25
25
  # Declares a timer that fires every *every* seconds and dispatches *action* on the controller.
26
26
  # The runtime builds a TimerEvent and routes it to the active controller's dispatch_timer.
27
27
  def timer(name, every:, action:)
28
+ raise ArgumentError, "timer interval must be positive (got #{every.inspect})" unless every.is_a?(Numeric) && every.positive?
29
+
28
30
  timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
29
31
  end
30
32
 
@@ -34,6 +36,13 @@ module Charming
34
36
  task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
35
37
  end
36
38
 
39
+ # Declares a progress handler for a task: while `run_task(:name)` runs, each
40
+ # `progress.report(...)` dispatches *action* on the controller (the event is
41
+ # available as `event` — a TaskProgressEvent with current/total/message).
42
+ def on_task_progress(name, action:)
43
+ task_progress_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
44
+ end
45
+
37
46
  # Sets the action that the controller should auto-render after a non-rendering action runs.
38
47
  # Defaults to :show when unset.
39
48
  def auto_render(action = :show)
@@ -93,6 +102,11 @@ module Charming
93
102
  @task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {}
94
103
  end
95
104
 
105
+ # Hash of task name => TaskBinding for progress handlers, inherited from superclass.
106
+ def task_progress_bindings
107
+ @task_progress_bindings ||= superclass.respond_to?(:task_progress_bindings) ? superclass.task_progress_bindings.dup : {}
108
+ end
109
+
96
110
  private
97
111
 
98
112
  # Validates that *scope* is :content or :global; otherwise raises ArgumentError.
@@ -12,6 +12,11 @@ module Charming
12
12
  Charming.key_of(event)
13
13
  end
14
14
 
15
+ # Returns the normalized key signature for controller-declared bindings.
16
+ def binding_key_name
17
+ Charming.key_signature(event)
18
+ end
19
+
15
20
  # Calls the auto-render action if one is configured. No-op when the action method is undefined.
16
21
  def render_default_action
17
22
  action = self.class.auto_render_action || :show
@@ -38,10 +43,11 @@ module Charming
38
43
  key_action_for_scope(:content)
39
44
  end
40
45
 
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).
46
+ # Returns false when the focus ring includes a content slot that isn't currently
47
+ # focused (e.g., the sidebar has focus). Controllers whose ring has no :content slot
48
+ # always have content keys active.
43
49
  def content_key_scope_active?
44
- return content_focused? if focus_ring_slot?(:content)
50
+ return content_focused? if focus.ring.include?(:content)
45
51
 
46
52
  true
47
53
  end
@@ -49,12 +55,32 @@ module Charming
49
55
  # Looks up the current key in the class bindings and returns the action only if its
50
56
  # registered scope matches *scope*. Returns nil otherwise.
51
57
  def key_action_for_scope(scope)
52
- action = self.class.key_bindings[key_name]
58
+ action = self.class.key_bindings[binding_key_name]
53
59
  return nil unless action
54
- return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
60
+ return nil unless self.class.key_binding_scopes.fetch(binding_key_name, :content) == scope
55
61
 
56
62
  action
57
63
  end
64
+
65
+ # True when the current event is a plain printable character: a single
66
+ # non-control char with no ctrl/alt modifier (ctrl+p etc. stay shortcuts).
67
+ def printable_text_event?
68
+ return false unless event.respond_to?(:char) && event.char
69
+ return false if event.respond_to?(:ctrl) && event.ctrl
70
+ return false if event.respond_to?(:alt) && event.alt
71
+
72
+ event.char.length == 1 && !event.char.match?(/[[:cntrl:]]/)
73
+ end
74
+
75
+ # True when the focus ring's current slot resolves to a component that accepts
76
+ # free-typed text (see Component#captures_text?).
77
+ def focused_component_captures_text?
78
+ slot = focus.current
79
+ return false unless slot && respond_to?(slot, true)
80
+
81
+ component = send(slot)
82
+ component.respond_to?(:captures_text?) && component.captures_text?
83
+ end
58
84
  end
59
85
  end
60
86
  end