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
@@ -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"). *out* is the status-output
13
- # stream. *destination* is the app root for resolving `config/database.rb` and `db/`.
14
- def initialize(command, out:, destination:)
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(1)
57
- out.puts "rollback db/migrate"
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
- seed_path = File.join(destination, "db", "seeds.rb")
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 triggered (left,
14
- # right, scroll), while *x* and *y* provide the cursor position. Modifier booleans (*ctrl*, *alt*, *shift*)
15
- # capture key state at the time of the event.
16
- MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift) do
17
- def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false)
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` when the current event is a click (left, middle, or right button).
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` when the current event is a mouse release action.
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` (establishes an SQLite connection to
104
- # `db/development.sqlite3`).
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
- %(# frozen_string_literal: true
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
- # The current UTC timestamp in the format ActiveRecord uses for migration filenames.
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
- Time.now.utc.strftime("%Y%m%d%H%M%S")
118
+ MigrationTimestamp.next(File.join(destination, "db", "migrate"))
117
119
  end
118
120
  end
119
121
  end
@@ -3,3 +3,9 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
+
7
+ group :development, :test do
8
+ gem "irb" # `charming console` (not a default gem on Ruby 4.0+)
9
+ gem "rake", "~> 13.0"
10
+ gem "rspec", "~> 3.13"
11
+ end
@@ -5,7 +5,7 @@ module __APP_CLASS__
5
5
  layout Layouts::ApplicationLayout
6
6
  focus_ring :sidebar, :content
7
7
 
8
- key "p", :open_command_palette, scope: :global
8
+ key "ctrl+p", :open_command_palette, scope: :global
9
9
  key "q", :quit, scope: :global
10
10
 
11
11
  command "Home" do
@@ -3,7 +3,9 @@
3
3
  require "active_record"
4
4
  require "fileutils"
5
5
 
6
- database_path = File.expand_path("../db/development.sqlite3", __dir__)
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(