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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +144 -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/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- 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 +41 -3
- 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/Gemfile.template +6 -0
- 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/dot_rspec.template +2 -0
- 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/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +27 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- 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/chart.rb +80 -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/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -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/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- 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/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- 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 +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -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/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -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/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- 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 +63 -50
|
@@ -8,11 +8,17 @@ module Charming
|
|
|
8
8
|
# `db:install`, which lives in Generators::DatabaseInstaller). It loads the app's
|
|
9
9
|
# `config/database.rb`, delegates the actual work to ActiveRecord, and prints a short
|
|
10
10
|
# status line on success.
|
|
11
|
+
#
|
|
12
|
+
# Supported commands: db:create, db:migrate, db:rollback [STEP=n], db:drop, db:seed,
|
|
13
|
+
# db:setup, db:reset, db:prepare, db:status, db:version, db:schema:dump, db:schema:load.
|
|
14
|
+
# The target database file is selected by CHARMING_ENV (development, test, production).
|
|
11
15
|
class Commands
|
|
12
|
-
# *command* is the subcommand string (e.g., "db:create"). *
|
|
13
|
-
#
|
|
14
|
-
|
|
16
|
+
# *command* is the subcommand string (e.g., "db:create"). *args* holds extra CLI
|
|
17
|
+
# arguments such as "STEP=2". *out* is the status-output stream. *destination* is the
|
|
18
|
+
# app root for resolving `config/database.rb` and `db/`.
|
|
19
|
+
def initialize(command, out:, destination:, args: [])
|
|
15
20
|
@command = command
|
|
21
|
+
@args = args
|
|
16
22
|
@out = out
|
|
17
23
|
@destination = destination
|
|
18
24
|
end
|
|
@@ -25,14 +31,21 @@ module Charming
|
|
|
25
31
|
when "db:rollback" then rollback
|
|
26
32
|
when "db:drop" then drop
|
|
27
33
|
when "db:seed" then seed
|
|
34
|
+
when "db:setup" then setup
|
|
35
|
+
when "db:reset" then reset
|
|
36
|
+
when "db:prepare" then prepare
|
|
37
|
+
when "db:status" then status
|
|
38
|
+
when "db:version" then version
|
|
39
|
+
when "db:schema:dump" then schema_dump
|
|
40
|
+
when "db:schema:load" then schema_load
|
|
28
41
|
else raise Generators::Error, "Unknown database command: #{command}"
|
|
29
42
|
end
|
|
30
43
|
end
|
|
31
44
|
|
|
32
45
|
private
|
|
33
46
|
|
|
34
|
-
# The subcommand, output stream, and app destination.
|
|
35
|
-
attr_reader :command, :out, :destination
|
|
47
|
+
# The subcommand, extra arguments, output stream, and app destination.
|
|
48
|
+
attr_reader :command, :args, :out, :destination
|
|
36
49
|
|
|
37
50
|
# Creates the SQLite database file (touch) and establishes the connection.
|
|
38
51
|
def create
|
|
@@ -43,18 +56,21 @@ module Charming
|
|
|
43
56
|
out.puts "create #{relative_database_path}"
|
|
44
57
|
end
|
|
45
58
|
|
|
46
|
-
# Runs all pending migrations from `db/migrate`.
|
|
59
|
+
# Runs all pending migrations from `db/migrate`, then refreshes `db/schema.rb`.
|
|
47
60
|
def migrate
|
|
48
61
|
load_database
|
|
49
62
|
migration_context.migrate
|
|
63
|
+
dump_schema
|
|
50
64
|
out.puts "migrate db/migrate"
|
|
51
65
|
end
|
|
52
66
|
|
|
53
|
-
# Rolls back the most recent migration.
|
|
67
|
+
# Rolls back the most recent migration(s). Accepts `STEP=n` (default 1), then
|
|
68
|
+
# refreshes `db/schema.rb`.
|
|
54
69
|
def rollback
|
|
55
70
|
load_database
|
|
56
|
-
migration_context.rollback(
|
|
57
|
-
|
|
71
|
+
migration_context.rollback(step_argument)
|
|
72
|
+
dump_schema
|
|
73
|
+
out.puts "rollback db/migrate (#{step_argument} step#{"s" if step_argument > 1})"
|
|
58
74
|
end
|
|
59
75
|
|
|
60
76
|
# Disconnects ActiveRecord, then deletes the database file.
|
|
@@ -65,16 +81,92 @@ module Charming
|
|
|
65
81
|
out.puts "drop #{relative_database_path}"
|
|
66
82
|
end
|
|
67
83
|
|
|
68
|
-
# Loads `db/seeds.rb` (raises if missing).
|
|
84
|
+
# Loads `db/seeds.rb` (raises if missing). The full application is loaded first so
|
|
85
|
+
# seeds can reference app models.
|
|
69
86
|
def seed
|
|
70
87
|
load_database
|
|
71
|
-
|
|
88
|
+
load_application
|
|
72
89
|
raise Generators::Error, "Missing file: db/seeds.rb" unless File.exist?(seed_path)
|
|
73
90
|
|
|
74
91
|
load seed_path
|
|
75
92
|
out.puts "seed db/seeds.rb"
|
|
76
93
|
end
|
|
77
94
|
|
|
95
|
+
# Creates the database, loads the schema when one exists (otherwise migrates), and seeds.
|
|
96
|
+
def setup
|
|
97
|
+
create
|
|
98
|
+
File.exist?(schema_path) ? schema_load : migrate
|
|
99
|
+
seed if File.exist?(seed_path)
|
|
100
|
+
out.puts "setup #{relative_database_path}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Drops and re-creates the database from scratch (drop + setup).
|
|
104
|
+
def reset
|
|
105
|
+
load_database
|
|
106
|
+
drop
|
|
107
|
+
setup
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# CI-friendly: sets up the database when it doesn't exist, otherwise just migrates.
|
|
111
|
+
def prepare
|
|
112
|
+
load_database
|
|
113
|
+
if database_path && File.exist?(database_path)
|
|
114
|
+
migrate
|
|
115
|
+
else
|
|
116
|
+
setup
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Prints a Rails-style migration status table (status, version, name).
|
|
121
|
+
def status
|
|
122
|
+
load_database
|
|
123
|
+
out.puts "Status Version Name"
|
|
124
|
+
out.puts "-" * 48
|
|
125
|
+
migration_context.migrations_status.each do |migration_status, version, name|
|
|
126
|
+
out.puts format("%-8s %-16s %s", migration_status, version, name)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Prints the current schema version.
|
|
131
|
+
def version
|
|
132
|
+
load_database
|
|
133
|
+
out.puts "version #{migration_context.current_version}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Writes the current database structure to `db/schema.rb`.
|
|
137
|
+
def schema_dump
|
|
138
|
+
load_database
|
|
139
|
+
dump_schema
|
|
140
|
+
out.puts "dump db/schema.rb"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Loads `db/schema.rb` into the database (fast alternative to replaying migrations).
|
|
144
|
+
def schema_load
|
|
145
|
+
load_database
|
|
146
|
+
raise Generators::Error, "Missing file: db/schema.rb. Run `charming db:schema:dump` first." unless File.exist?(schema_path)
|
|
147
|
+
|
|
148
|
+
load schema_path
|
|
149
|
+
out.puts "load db/schema.rb"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Dumps the connected database's structure to `db/schema.rb` via ActiveRecord.
|
|
153
|
+
def dump_schema
|
|
154
|
+
File.open(schema_path, "w") do |file|
|
|
155
|
+
ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, file)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Parses the `STEP=n` argument for db:rollback (defaults to 1).
|
|
160
|
+
def step_argument
|
|
161
|
+
step = args.find { |arg| arg.start_with?("STEP=") }
|
|
162
|
+
return 1 unless step
|
|
163
|
+
|
|
164
|
+
value = step.delete_prefix("STEP=").to_i
|
|
165
|
+
raise Generators::Error, "STEP must be a positive integer" unless value.positive?
|
|
166
|
+
|
|
167
|
+
value
|
|
168
|
+
end
|
|
169
|
+
|
|
78
170
|
# Loads the app's `config/database.rb` (raises if missing) which establishes the connection.
|
|
79
171
|
def load_database
|
|
80
172
|
database_config = File.join(destination, "config", "database.rb")
|
|
@@ -83,11 +175,31 @@ module Charming
|
|
|
83
175
|
require database_config
|
|
84
176
|
end
|
|
85
177
|
|
|
178
|
+
# Requires the app's root loader (`lib/<app>.rb`, derived from the gemspec name) so
|
|
179
|
+
# app constants — models in particular — are available. No-op when not in an app root.
|
|
180
|
+
def load_application
|
|
181
|
+
gemspec = Dir.glob(File.join(destination, "*.gemspec")).first
|
|
182
|
+
return unless gemspec
|
|
183
|
+
|
|
184
|
+
root_file = File.join(destination, "lib", "#{File.basename(gemspec, ".gemspec")}.rb")
|
|
185
|
+
require root_file if File.exist?(root_file)
|
|
186
|
+
end
|
|
187
|
+
|
|
86
188
|
# The ActiveRecord migration context rooted at `db/migrate` inside the app.
|
|
87
189
|
def migration_context
|
|
88
190
|
ActiveRecord::MigrationContext.new(File.join(destination, "db", "migrate"))
|
|
89
191
|
end
|
|
90
192
|
|
|
193
|
+
# Path to the app's `db/schema.rb`.
|
|
194
|
+
def schema_path
|
|
195
|
+
File.join(destination, "db", "schema.rb")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Path to the app's `db/seeds.rb`.
|
|
199
|
+
def seed_path
|
|
200
|
+
File.join(destination, "db", "seeds.rb")
|
|
201
|
+
end
|
|
202
|
+
|
|
91
203
|
# The configured database file path (nil when ActiveRecord isn't connected to a file).
|
|
92
204
|
def database_path
|
|
93
205
|
ActiveRecord::Base.connection_db_config.database
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Escape is the out-of-band terminal channel: escape sequences written straight to the terminal,
|
|
5
|
+
# *before* each frame, bypassing the line-based renderer (which measures width and would shred raw
|
|
6
|
+
# control sequences). It is the shared substrate for several primitives — image transmissions
|
|
7
|
+
# ({Charming::Image::Transmit}), clipboard writes, desktop notifications, and the window title.
|
|
8
|
+
#
|
|
9
|
+
# Sequences are gathered during an event's dispatch via a thread-local collector ({collecting} /
|
|
10
|
+
# {register}) and attached to the {Response}; the {Runtime} flushes them through
|
|
11
|
+
# `backend.write_escape`. Any object that responds to `#payload` (a string) can ride the channel;
|
|
12
|
+
# the builders here return {Sequence} value objects and sanitize interpolated text so user content
|
|
13
|
+
# can't break out of the sequence.
|
|
14
|
+
module Escape
|
|
15
|
+
# A single out-of-band sequence. *payload* is the literal escape string written to the terminal.
|
|
16
|
+
Sequence = Data.define(:payload)
|
|
17
|
+
|
|
18
|
+
# Thread-local key under which the active collection bucket is stored.
|
|
19
|
+
BUCKET_KEY = :charming_escape_bucket
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Runs *block* with a fresh collection bucket active and returns the bucket — the {Sequence}s
|
|
23
|
+
# registered via {register} during the block. The block's own return value is ignored (capture
|
|
24
|
+
# it via a closure). Nesting is supported: an inner collection shadows the outer.
|
|
25
|
+
def collecting
|
|
26
|
+
previous = Thread.current[BUCKET_KEY]
|
|
27
|
+
bucket = []
|
|
28
|
+
Thread.current[BUCKET_KEY] = bucket
|
|
29
|
+
yield
|
|
30
|
+
bucket
|
|
31
|
+
ensure
|
|
32
|
+
Thread.current[BUCKET_KEY] = previous
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Registers *sequence* with the active collection bucket so it is flushed to the backend. A
|
|
36
|
+
# no-op (outside a collection, or when *sequence* is nil) so callers can register freely.
|
|
37
|
+
def register(sequence)
|
|
38
|
+
return sequence unless sequence
|
|
39
|
+
|
|
40
|
+
Thread.current[BUCKET_KEY]&.push(sequence)
|
|
41
|
+
sequence
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds an OSC 52 clipboard-write sequence setting the *target* selection (`"c"` clipboard,
|
|
45
|
+
# `"p"` primary) to *text*. The text is base64-encoded, so any bytes are safe.
|
|
46
|
+
def clipboard(text, target: "c")
|
|
47
|
+
Sequence.new(payload: "\e]52;#{target};#{[text.to_s].pack("m0")}\a")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds a desktop-notification sequence: OSC 777 (`title` + `body`) when a *title* is given,
|
|
51
|
+
# else OSC 9 (body only) — the broadly supported baseline (Ghostty, iTerm2, Kitty).
|
|
52
|
+
def notification(body, title: nil)
|
|
53
|
+
payload =
|
|
54
|
+
if title
|
|
55
|
+
"\e]777;notify;#{sanitize(title)};#{sanitize(body)}\e\\"
|
|
56
|
+
else
|
|
57
|
+
"\e]9;#{sanitize(body)}\a"
|
|
58
|
+
end
|
|
59
|
+
Sequence.new(payload: payload)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Builds a terminal-bell sequence (BEL).
|
|
63
|
+
def bell
|
|
64
|
+
Sequence.new(payload: "\a")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Builds an OSC 0 sequence setting the terminal window (and icon) title to *text*.
|
|
68
|
+
def title(text)
|
|
69
|
+
Sequence.new(payload: "\e]0;#{sanitize(text)}\a")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Strips C0 control characters (incl. ESC and BEL) so interpolated user text can't terminate
|
|
75
|
+
# or inject into the surrounding escape sequence.
|
|
76
|
+
def sanitize(text)
|
|
77
|
+
text.to_s.gsub(/[\x00-\x1f\x7f]/, "")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Events
|
|
5
|
+
# FocusEvent reports the terminal window gaining or losing focus (focus reporting
|
|
6
|
+
# mode `\e[?1004h`, markers `\e[I` / `\e[O`). Controllers opt in by defining a
|
|
7
|
+
# `focus_changed` action; apps can use it to pause timers or dim the UI.
|
|
8
|
+
FocusEvent = Data.define(:focused) do
|
|
9
|
+
def focused? = focused
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -10,11 +10,12 @@ module Charming
|
|
|
10
10
|
}.freeze
|
|
11
11
|
private_constant :MOUSE_BUTTON_MAP
|
|
12
12
|
|
|
13
|
-
# MouseEvent represents a mouse input event. *button* encodes which button or action was
|
|
14
|
-
# right, scroll), while *x* and *y* provide the cursor position. Modifier
|
|
15
|
-
# capture key state at the time of the event.
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
# MouseEvent represents a mouse input event. *button* encodes which button or action was
|
|
14
|
+
# triggered (left, right, scroll), while *x* and *y* provide the cursor position. Modifier
|
|
15
|
+
# booleans (*ctrl*, *alt*, *shift*) capture key state at the time of the event. *motion*
|
|
16
|
+
# marks drag/hover movement and *release* marks an SGR button release.
|
|
17
|
+
MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift, :motion, :release) do
|
|
18
|
+
def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false, motion: false, release: false)
|
|
18
19
|
super
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -23,9 +24,9 @@ module Charming
|
|
|
23
24
|
MOUSE_BUTTON_MAP.fetch(button, :unknown)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
# Returns `true`
|
|
27
|
+
# Returns `true` for a button press (left, middle, or right) — not a release, drag, or hover.
|
|
27
28
|
def click?
|
|
28
|
-
%i[left middle right].include?(button_name)
|
|
29
|
+
!motion && !release? && %i[left middle right].include?(button_name)
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
# Returns `true` when the button name maps to either direction of scroll.
|
|
@@ -33,9 +34,21 @@ module Charming
|
|
|
33
34
|
%i[scroll_up scroll_down].include?(button_name)
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
# Returns `true`
|
|
37
|
+
# Returns `true` for a button release: the SGR release marker, or the legacy
|
|
38
|
+
# button-3 release code on a non-motion event. (During motion, button 3
|
|
39
|
+
# means "no button held", not a release.)
|
|
37
40
|
def release?
|
|
38
|
-
button_name == :release
|
|
41
|
+
release || (!motion && button_name == :release)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns `true` for any mouse movement report (drag or hover).
|
|
45
|
+
def motion?
|
|
46
|
+
motion
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns `true` when the mouse moved with a button held down.
|
|
50
|
+
def drag?
|
|
51
|
+
motion && %i[left middle right].include?(button_name)
|
|
39
52
|
end
|
|
40
53
|
end
|
|
41
54
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Events
|
|
5
|
+
# PasteEvent carries text pasted via the terminal's bracketed-paste mode
|
|
6
|
+
# (`\e[200~ ... \e[201~`). Without bracketed paste, pasted text arrives as a storm
|
|
7
|
+
# of individual key events; with it, components receive the whole string at once
|
|
8
|
+
# via `handle_paste`.
|
|
9
|
+
PasteEvent = Data.define(:text)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Events
|
|
5
|
+
# TaskProgressEvent reports incremental progress from a running background task.
|
|
6
|
+
# *name* matches the task name, *current*/*total* describe completion (total may be
|
|
7
|
+
# nil for indeterminate work), and *message* is an optional human-readable status.
|
|
8
|
+
TaskProgressEvent = Data.define(:name, :current, :total, :message) do
|
|
9
|
+
def initialize(name:, current:, total: nil, message: nil)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Completion as a 0.0..1.0 fraction, or nil when the total is unknown.
|
|
14
|
+
def fraction
|
|
15
|
+
return nil unless total&.positive?
|
|
16
|
+
|
|
17
|
+
(current.to_f / total).clamp(0.0, 1.0)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -13,6 +13,7 @@ module Charming
|
|
|
13
13
|
BASE_FILE_TEMPLATES = [
|
|
14
14
|
["Gemfile", "app/Gemfile.template", false],
|
|
15
15
|
["Rakefile", "app/Rakefile.template", false],
|
|
16
|
+
[".rspec", "app/dot_rspec.template", false],
|
|
16
17
|
["README.md", "app/README.md.template", false],
|
|
17
18
|
["%<name>s.gemspec", "app/gemspec.template", false],
|
|
18
19
|
["exe/%<name>s", "app/executable.template", true],
|
|
@@ -98,10 +99,47 @@ module Charming
|
|
|
98
99
|
controller_actions: controller_actions,
|
|
99
100
|
controller_helpers: controller_helpers,
|
|
100
101
|
database_require: database_require,
|
|
101
|
-
model_loader: model_loader
|
|
102
|
+
model_loader: model_loader,
|
|
103
|
+
env_setup: env_setup,
|
|
104
|
+
database_spec_setup: database_spec_setup
|
|
102
105
|
}
|
|
103
106
|
end
|
|
104
107
|
|
|
108
|
+
# Pins CHARMING_ENV to "test" before the app (and its database config) loads, so
|
|
109
|
+
# specs hit db/test.sqlite3. Empty for non-database apps.
|
|
110
|
+
def env_setup
|
|
111
|
+
return "" unless database?
|
|
112
|
+
|
|
113
|
+
"ENV[\"CHARMING_ENV\"] ||= \"test\"\n\n"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Prepares the test database schema before the suite and rolls back each example's
|
|
117
|
+
# writes in a transaction. Empty for non-database apps.
|
|
118
|
+
def database_spec_setup
|
|
119
|
+
return "" unless database?
|
|
120
|
+
|
|
121
|
+
<<~RUBY
|
|
122
|
+
|
|
123
|
+
# Prepare the test database, preferring the dumped schema over replaying migrations.
|
|
124
|
+
schema = File.expand_path("../db/schema.rb", __dir__)
|
|
125
|
+
if File.exist?(schema)
|
|
126
|
+
load schema
|
|
127
|
+
else
|
|
128
|
+
ActiveRecord::MigrationContext.new(File.expand_path("../db/migrate", __dir__)).migrate
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
RSpec.configure do |config|
|
|
132
|
+
# Roll back database writes after each example so tests stay isolated.
|
|
133
|
+
config.around(:each) do |example|
|
|
134
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
135
|
+
example.run
|
|
136
|
+
raise ActiveRecord::Rollback
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
RUBY
|
|
141
|
+
end
|
|
142
|
+
|
|
105
143
|
# The `Gem::Specification` attributes block (indented two spaces to match the wrapping
|
|
106
144
|
# `Gem::Specification.new do |spec|`).
|
|
107
145
|
def gemspec_attributes
|
|
@@ -146,10 +184,10 @@ module Charming
|
|
|
146
184
|
|
|
147
185
|
# The body of the home controller's private `home` helper, prefixed by a blank line.
|
|
148
186
|
def controller_helpers
|
|
149
|
-
"\n\n private\n" \
|
|
187
|
+
"\n\n private\n\n" \
|
|
150
188
|
" def home\n" \
|
|
151
189
|
" state(:home, HomeState)\n" \
|
|
152
|
-
" end"
|
|
190
|
+
" end\n"
|
|
153
191
|
end
|
|
154
192
|
|
|
155
193
|
# The `require_relative "../config/database"` line when the app is database-configured.
|
|
@@ -100,22 +100,11 @@ module Charming
|
|
|
100
100
|
content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
# The contents of the new `config/database.rb`
|
|
104
|
-
# `db
|
|
103
|
+
# The contents of the new `config/database.rb` — read from the shared app template so
|
|
104
|
+
# `charming new --database` and `charming db:install` stay in sync. Environment-aware:
|
|
105
|
+
# the database file is `db/<CHARMING_ENV>.sqlite3`.
|
|
105
106
|
def database_config
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
require "active_record"
|
|
109
|
-
require "fileutils"
|
|
110
|
-
|
|
111
|
-
database_path = File.expand_path("../db/development.sqlite3", __dir__)
|
|
112
|
-
FileUtils.mkdir_p(File.dirname(database_path))
|
|
113
|
-
|
|
114
|
-
ActiveRecord::Base.establish_connection(
|
|
115
|
-
adapter: "sqlite3",
|
|
116
|
-
database: database_path
|
|
117
|
-
)
|
|
118
|
-
)
|
|
107
|
+
File.read(File.join(__dir__, "templates", "app", "database_config.template"))
|
|
119
108
|
end
|
|
120
109
|
|
|
121
110
|
# The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Generators
|
|
5
|
+
# MigrationGenerator implements `charming generate migration NAME [field:type ...]`.
|
|
6
|
+
# Follows Rails naming conventions:
|
|
7
|
+
# - `create_<table>` generates a create_table migration (fields become columns)
|
|
8
|
+
# - `add_<x>_to_<table>` generates add_column lines for the supplied fields
|
|
9
|
+
# - `remove_<x>_from_<table>` generates remove_column lines
|
|
10
|
+
# - anything else generates an empty `change` method to fill in
|
|
11
|
+
class MigrationGenerator < AppFileGenerator
|
|
12
|
+
# A single migration field: column *name* and ActiveRecord *type*.
|
|
13
|
+
Field = Data.define(:name, :type)
|
|
14
|
+
|
|
15
|
+
# The set of ActiveRecord column types accepted on the command line.
|
|
16
|
+
VALID_TYPES = ModelGenerator::VALID_TYPES
|
|
17
|
+
|
|
18
|
+
def initialize(name, args, out:, destination:, force: false)
|
|
19
|
+
super
|
|
20
|
+
@fields = args.map { |arg| parse_field(arg) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Validates database support, then writes the timestamped migration file.
|
|
24
|
+
def generate
|
|
25
|
+
raise Error, "Database support is not configured. Run `charming db:install sqlite3` first." unless database_configured?
|
|
26
|
+
|
|
27
|
+
create_file(migration_path, migration)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :fields
|
|
33
|
+
|
|
34
|
+
# No file-name suffix; MigrationGenerator writes to an explicit path.
|
|
35
|
+
def suffix
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Path to the generated `db/migrate/<timestamp>_<name>.rb` file.
|
|
40
|
+
def migration_path
|
|
41
|
+
File.join("db", "migrate", "#{timestamp}_#{name.snake_name}.rb")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The ActiveRecord migration API version stamped into generated migrations. Matches
|
|
45
|
+
# the version used by the model generator's migration template.
|
|
46
|
+
MIGRATION_VERSION = "8.1"
|
|
47
|
+
|
|
48
|
+
# The full source of the generated migration, dispatching on the name convention.
|
|
49
|
+
def migration
|
|
50
|
+
<<~RUBY
|
|
51
|
+
# frozen_string_literal: true
|
|
52
|
+
|
|
53
|
+
class #{migration_class_name} < ActiveRecord::Migration[#{MIGRATION_VERSION}]
|
|
54
|
+
def change
|
|
55
|
+
#{change_body.chomp}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
RUBY
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The CamelCase migration class name derived from the snake_case migration name.
|
|
62
|
+
def migration_class_name
|
|
63
|
+
ActiveSupport::Inflector.camelize(name.snake_name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Builds the `change` method body based on the migration name convention.
|
|
67
|
+
def change_body
|
|
68
|
+
case name.snake_name
|
|
69
|
+
when /\Acreate_(.+)\z/
|
|
70
|
+
create_table_body(Regexp.last_match(1))
|
|
71
|
+
when /\Aadd_.+_to_(.+)\z/
|
|
72
|
+
column_lines(Regexp.last_match(1), :add_column)
|
|
73
|
+
when /\Aremove_.+_from_(.+)\z/
|
|
74
|
+
column_lines(Regexp.last_match(1), :remove_column)
|
|
75
|
+
else
|
|
76
|
+
" # Add your migration steps here.\n"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Generates a `create_table` block with one column line per field plus timestamps.
|
|
81
|
+
def create_table_body(table)
|
|
82
|
+
field_lines = fields.map { |field| " t.#{field.type} :#{field.name}\n" }.join
|
|
83
|
+
" create_table :#{table} do |t|\n#{field_lines} t.timestamps\n end\n"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Generates one add_column/remove_column line per field for the given table.
|
|
87
|
+
def column_lines(table, method)
|
|
88
|
+
return " # No fields given — add #{method} lines here.\n" if fields.empty?
|
|
89
|
+
|
|
90
|
+
fields.map { |field| " #{method} :#{table}, :#{field.name}, :#{field.type}\n" }.join
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parses a single `name:type` argument. Raises Error on invalid names or unsupported types.
|
|
94
|
+
def parse_field(value)
|
|
95
|
+
field_name, type = value.split(":", 2)
|
|
96
|
+
raise Error, "Invalid field: #{value.inspect}" unless field_name && type
|
|
97
|
+
raise Error, "Invalid field name: #{field_name.inspect}" unless Name::VALID_NAME.match?(field_name)
|
|
98
|
+
raise Error, "Unsupported field type: #{type.inspect}" unless VALID_TYPES.include?(type)
|
|
99
|
+
|
|
100
|
+
Field.new(name: field_name, type: type)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# True when `config/database.rb` exists in the app.
|
|
104
|
+
def database_configured?
|
|
105
|
+
File.exist?(File.join(destination, "config", "database.rb"))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A migration timestamp in ActiveRecord's filename format, bumped past any
|
|
109
|
+
# existing migration's version so two generators run in the same second
|
|
110
|
+
# can't produce colliding versions.
|
|
111
|
+
def timestamp
|
|
112
|
+
MigrationTimestamp.next(File.join(destination, "db", "migrate"))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Generators
|
|
5
|
+
# MigrationTimestamp produces ActiveRecord-format migration version numbers
|
|
6
|
+
# (YYYYMMDDHHMMSS) that are guaranteed unique within a `db/migrate` directory:
|
|
7
|
+
# when generators run within the same second, the version is bumped one second
|
|
8
|
+
# past the highest existing migration version.
|
|
9
|
+
module MigrationTimestamp
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Returns the next available version string for *migrate_dir*.
|
|
13
|
+
def next(migrate_dir)
|
|
14
|
+
now = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
15
|
+
highest = highest_existing(migrate_dir)
|
|
16
|
+
return now unless highest && highest >= now
|
|
17
|
+
|
|
18
|
+
(highest.to_i + 1).to_s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The highest version prefix among existing migration files, or nil.
|
|
22
|
+
def highest_existing(migrate_dir)
|
|
23
|
+
Dir.glob(File.join(migrate_dir, "*.rb"))
|
|
24
|
+
.filter_map { |path| File.basename(path)[/\A\d{14}/] }
|
|
25
|
+
.max
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -111,9 +111,11 @@ module Charming
|
|
|
111
111
|
ActiveSupport::Inflector.camelize(table_name)
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
#
|
|
114
|
+
# A migration timestamp in ActiveRecord's filename format, bumped past any
|
|
115
|
+
# existing migration's version so two generators run in the same second
|
|
116
|
+
# can't produce colliding versions.
|
|
115
117
|
def timestamp
|
|
116
|
-
|
|
118
|
+
MigrationTimestamp.next(File.join(destination, "db", "migrate"))
|
|
117
119
|
end
|
|
118
120
|
end
|
|
119
121
|
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# The database file is selected by CHARMING_ENV (development, test, production).
|
|
7
|
+
environment = ENV["CHARMING_ENV"] || "development"
|
|
8
|
+
database_path = File.expand_path("../db/#{environment}.sqlite3", __dir__)
|
|
7
9
|
FileUtils.mkdir_p(File.dirname(database_path))
|
|
8
10
|
|
|
9
11
|
ActiveRecord::Base.establish_connection(
|