charming 0.2.0 → 0.2.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -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/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. metadata +63 -50
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3edeedc6b7d09bb964b5f3e6ba7306ced50355bfffb93c049c9cc7415cab4d2
4
- data.tar.gz: 3944a4cef79f57035ac08d5dbeba1fef7e96504c5ea34c3237e2dad08dcdde31
3
+ metadata.gz: 71b370eac77c67126921d7347a0d23f834f23774626c111445e9abdef73555d9
4
+ data.tar.gz: c8dd983f46183c2fbdb22f9c30753b4f1443d8de2989a4e7f90016f397aa12e4
5
5
  SHA512:
6
- metadata.gz: ea1881f3bf0d3517cc2a0f20b8ae15fcd452c091d71f6535bc632a7dd66be6abaf97fa3ca76aef567be068be41a374c11e263529cc43dad931ac7fe344847061
7
- data.tar.gz: 6b396f225bdb25bf3cd1f5ff569d4907a9e2e0be8bbfc4ad500021251f1bc39ba65b270b80d28b7dd9fc087294fc9a91d4fb4da5dfbdfbdced19040074093c0a
6
+ metadata.gz: 2ee80f1c214001c8f91b36ba308f0e2f67aecbc258e7ff3ce63de2eb04093a405a0a23c3174cac6c3a1c6a98746b5353e66dd8179aebab4fe60ad35132603f19
7
+ data.tar.gz: 5242fbc9ad41ca1c0dbebbb070bb33cbdd1279955f81aa2f067954a1b11ffdd1b9cb7b90321285fa17e795c31d3fff32c7e052e95dee7e7c90318e19d252fba1
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, inline image display (Kitty graphics protocol), system clipboard / desktop notifications / window title, braille charts and sparklines, 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
@@ -7,6 +10,7 @@ module Charming
7
10
  class Application
8
11
  LOGGER_READER = Object.new.freeze
9
12
  THEME_READER = Object.new.freeze
13
+ COALESCE_READER = Object.new.freeze
10
14
 
11
15
  class << self
12
16
  # Registers or returns the app's Router. Accepts an optional block to define
@@ -39,13 +43,27 @@ module Charming
39
43
  @root = File.expand_path(path)
40
44
  end
41
45
 
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
46
+ # Registers a named theme. Provide one of:
47
+ # - *from:* path to a JSON theme file relative to the app root
48
+ # - *built_in:* — name of a bundled theme ("phosphor", "catppuccin-mocha",
49
+ # "catppuccin-latte", "gruvbox-dark", "nord", "tokyonight")
50
+ # - *extends:* name of an already-registered theme to derive from, with
51
+ # *overrides:* (token name → style spec) merged on top:
52
+ #
53
+ # theme :dark, built_in: "tokyonight"
54
+ # theme :high_contrast, extends: :dark, overrides: {text: {foreground: "#ffffff"}}
55
+ def theme(name, from: nil, built_in: nil, extends: nil, overrides: nil)
56
+ sources = [from, built_in, extends].compact
57
+ raise ArgumentError, "theme expects from:, built_in:, or extends:" if sources.empty?
58
+ raise ArgumentError, "theme expects only one of from:, built_in:, or extends:" if sources.length > 1
59
+ raise ArgumentError, "overrides: requires extends:" if overrides && !extends
60
+
61
+ themes[name.to_sym] = if extends
62
+ parent = themes.fetch(extends.to_sym) do
63
+ raise ArgumentError, "unknown parent theme: #{extends.inspect} (register it before extending)"
64
+ end
65
+ parent.merge(overrides || {})
66
+ elsif built_in
49
67
  UI::Theme.load_builtin(built_in)
50
68
  else
51
69
  UI::Theme.load_file(resolve_theme_path(from))
@@ -74,6 +92,44 @@ module Charming
74
92
  themes.fetch(theme_name.to_sym)
75
93
  end
76
94
 
95
+ # Opts into session persistence: the session hash is serialized as JSON to *to*
96
+ # when the app quits and reloaded on boot. Only JSON-safe values survive the
97
+ # round-trip (hash keys come back as symbols); non-serializable entries (state
98
+ # objects, procs) are skipped with a warning in the log.
99
+ def persist_session(to:)
100
+ @session_path = to
101
+ end
102
+
103
+ # The configured session file path, walking the superclass chain. Nil when
104
+ # persistence is not enabled.
105
+ def session_path
106
+ return @session_path if instance_variable_defined?(:@session_path)
107
+ return superclass.session_path if superclass.respond_to?(:session_path)
108
+
109
+ nil
110
+ end
111
+
112
+ # When true, the runtime collapses bursts of identical key events — the flood a terminal
113
+ # emits while a key is held (OS auto-repeat) — into a single dispatch, so holding a key
114
+ # can't queue a backlog that keeps acting after release. Off by default: it also merges
115
+ # intentional fast repeats of the same key (e.g. tab tab), so enable it only for
116
+ # movement-style apps. Pass a boolean to set; call without args to read (inherited).
117
+ def coalesce_input(value = COALESCE_READER)
118
+ return configured_coalesce_input if value == COALESCE_READER
119
+
120
+ @coalesce_input = value
121
+ end
122
+
123
+ # The mouse motion reporting mode: :drag (default) reports movement only while
124
+ # a button is held; :all also reports buttonless hover movement. Pass a symbol
125
+ # to set; call without args to read (inherited).
126
+ def mouse_motion(value = COALESCE_READER)
127
+ return configured_mouse_motion if value == COALESCE_READER
128
+ raise ArgumentError, "mouse_motion must be :drag or :all" unless %i[drag all].include?(value)
129
+
130
+ @mouse_motion = value
131
+ end
132
+
77
133
  private
78
134
 
79
135
  def configured_logger
@@ -83,6 +139,20 @@ module Charming
83
139
  @logger = Logger.new(File::NULL)
84
140
  end
85
141
 
142
+ def configured_coalesce_input
143
+ return @coalesce_input if instance_variable_defined?(:@coalesce_input)
144
+ return superclass.coalesce_input if superclass.respond_to?(:coalesce_input)
145
+
146
+ false
147
+ end
148
+
149
+ def configured_mouse_motion
150
+ return @mouse_motion if instance_variable_defined?(:@mouse_motion)
151
+ return superclass.mouse_motion if superclass.respond_to?(:mouse_motion)
152
+
153
+ :drag
154
+ end
155
+
86
156
  # Expands a relative theme path against the app root (or the current working directory
87
157
  # when no root is configured). Returns *path* unchanged when it is already absolute.
88
158
  def resolve_theme_path(path)
@@ -95,10 +165,24 @@ module Charming
95
165
  attr_accessor :logger, :task_executor
96
166
  attr_reader :session
97
167
 
98
- # Initializes an empty session hash for per-request state storage.
168
+ # Initializes the session hash for per-request state storage, restoring a
169
+ # previously persisted session when `persist_session` is configured.
99
170
  def initialize
100
171
  @logger = self.class.logger
101
- @session = {}
172
+ @session = load_session
173
+ end
174
+
175
+ # Serializes the session to the configured `persist_session` path. Entries that
176
+ # don't survive a JSON round-trip (state objects, procs, focus scopes) are skipped.
177
+ # No-op when persistence isn't configured. Called by the Runtime on exit.
178
+ def save_session
179
+ path = self.class.session_path
180
+ return unless path
181
+
182
+ FileUtils.mkdir_p(File.dirname(path))
183
+ File.write(path, JSON.generate(serializable_session))
184
+ rescue => e
185
+ logger.warn("session not saved: #{e.class}: #{e.message}")
102
186
  end
103
187
 
104
188
  # Delegates to the class-level Router, providing instance access to route definitions.
@@ -114,5 +198,56 @@ module Charming
114
198
  self.class.theme_for(name)
115
199
  session[:theme] = name.to_sym
116
200
  end
201
+
202
+ # Whether the runtime should coalesce held-key auto-repeat for this app (see the class-level
203
+ # `coalesce_input` DSL). Read by the Runtime at startup.
204
+ def coalesce_input?
205
+ self.class.coalesce_input == true
206
+ end
207
+
208
+ # The mouse motion reporting mode for this app (see the class-level `mouse_motion`
209
+ # DSL). Read by the Runtime at startup.
210
+ def mouse_motion
211
+ self.class.mouse_motion
212
+ end
213
+
214
+ private
215
+
216
+ # Loads the persisted session JSON (symbolizing keys), or {} when absent/invalid.
217
+ def load_session
218
+ path = self.class.session_path
219
+ return {} unless path && File.exist?(path)
220
+
221
+ JSON.parse(File.read(path), symbolize_names: true)
222
+ rescue JSON::ParserError => e
223
+ logger.warn("session not restored: #{e.message}")
224
+ {}
225
+ end
226
+
227
+ # Framework-internal session keys that must not be persisted: their values carry
228
+ # symbols in *values* (which JSON round-trips into strings, corrupting focus rings
229
+ # and palette state) and they describe transient UI state anyway.
230
+ INTERNAL_SESSION_KEYS = %i[focus_state mouse_targets command_palette].freeze
231
+
232
+ # The subset of session entries that survive a JSON round-trip: nil, booleans,
233
+ # numbers, strings, symbols, and arrays/hashes of those. State objects, procs,
234
+ # framework-internal keys, and other rich values are skipped (hash keys come back
235
+ # as symbols via symbolize_names; symbol *values* come back as strings).
236
+ def serializable_session
237
+ session.except(*INTERNAL_SESSION_KEYS).select { |_key, value| json_safe?(value) }
238
+ end
239
+
240
+ def json_safe?(value)
241
+ case value
242
+ when nil, true, false, String, Symbol, Integer, Float
243
+ true
244
+ when Array
245
+ value.all? { |item| json_safe?(item) }
246
+ when Hash
247
+ value.all? { |key, item| (key.is_a?(String) || key.is_a?(Symbol)) && json_safe?(item) }
248
+ else
249
+ false
250
+ end
251
+ end
117
252
  end
118
253
  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.