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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +96 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/session_state.rb +16 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller.rb +62 -10
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +38 -1
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/internal/terminal/memory_backend.rb +6 -0
- data/lib/charming/internal/terminal/tty_backend.rb +64 -2
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/progressbar.rb +0 -1
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +40 -9
- data/lib/charming/presentation/components/text_area.rb +47 -10
- data/lib/charming/presentation/components/text_input.rb +79 -4
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +10 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
- data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/width.rb +27 -2
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +122 -15
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- metadata +36 -49
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6b9e1f0689d7ebe1c6ab12b622339c8826bc3e1f44013e8b3c3d7e6e07be98a
|
|
4
|
+
data.tar.gz: d1aecf5b432f95241693509d597ca92c39b29549e3a9209be3e49421906fe60c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/charming/application.rb
CHANGED
|
@@ -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
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
42
|
-
#
|
|
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
|
|
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[
|
|
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(
|
|
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
|