charming 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. metadata +36 -49
@@ -83,6 +83,15 @@ module Charming
83
83
  current == slot
84
84
  end
85
85
 
86
+ # True when the topmost scope is an overlay (modal, command palette, or any
87
+ # pushed scope) rather than the structural ring/layout. While an overlay scope
88
+ # is active, the controller routes keys to the focused component instead of
89
+ # content/sidebar key bindings.
90
+ def overlay?
91
+ scope = top
92
+ !!scope && !%i[ring layout].include?(scope[:origin])
93
+ end
94
+
86
95
  private
87
96
 
88
97
  # Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
@@ -18,13 +18,6 @@ module Charming
18
18
  def focused?(slot)
19
19
  focus.focused?(slot)
20
20
  end
21
-
22
- private
23
-
24
- # True when the controller class declared *slot* as part of its focus_ring DSL.
25
- def focus_ring_slot?(slot)
26
- self.class.focus_ring_slots.include?(slot)
27
- end
28
21
  end
29
22
  end
30
23
  end
@@ -44,9 +44,24 @@ module Charming
44
44
  # Submits a background task with the given *name*. The block is executed by the configured
45
45
  # task executor; its return value (or any raised exception) is delivered to the controller
46
46
  # as a TaskEvent dispatched to the matching `on_task` handler.
47
- def run_task(name, &block)
47
+ #
48
+ # Blocks that accept an argument receive a Tasks::Progress reporter whose `report`
49
+ # calls dispatch the matching `on_task_progress` handler. *timeout:* (seconds)
50
+ # cancels the task with Tasks::Cancelled when exceeded.
51
+ def run_task(name, timeout: nil, &block)
52
+ return application.task_executor.submit(name, timeout: timeout, &block) if timeout
53
+
54
+ # Without a timeout, use the plain signature so simple custom executors
55
+ # (`def submit(name, &block)`) remain compatible.
48
56
  application.task_executor.submit(name, &block)
49
57
  end
58
+
59
+ # Cancels the named in-flight background task (raises Tasks::Cancelled inside it).
60
+ # No-op when the task already finished or the executor doesn't support cancellation.
61
+ def cancel_task(name)
62
+ executor = application.task_executor
63
+ executor.cancel(name) if executor.respond_to?(:cancel)
64
+ end
50
65
  end
51
66
  end
52
67
  end
@@ -4,41 +4,36 @@ module Charming
4
4
  class Controller
5
5
  # Sidebar-navigation helpers mixed into Controller. Tracks the sidebar's current route index,
6
6
  # routes j/k/enter/tab keys when the sidebar is focused, and exposes `sidebar_focused?` for views.
7
+ #
8
+ # Sidebar/content focus is driven entirely by the controller's Focus object. Controllers
9
+ # that want Tab-driven sidebar navigation declare `focus_ring :sidebar, :content` (generated
10
+ # apps do); without those slots in the ring, `focus_sidebar`/`focus_content` are no-ops.
7
11
  module SidebarNavigation
8
- # Moves focus to the sidebar. When the controller declared a focus ring, the focus object
9
- # is updated; otherwise a fallback session key tracks focus.
12
+ # Moves focus to the sidebar slot and remembers the highlighted route.
10
13
  def focus_sidebar
11
- if focus_ring_slot?(:sidebar)
12
- focus.focus(:sidebar)
13
- else
14
- session[:focus] = :sidebar
15
- end
14
+ focus.focus(:sidebar)
16
15
  session[:sidebar_index] ||= current_route_index
17
16
  render_default_action
18
17
  end
19
18
 
20
- # Moves focus to the content pane (the inverse of `focus_sidebar`).
19
+ # Moves focus to the content side (the inverse of `focus_sidebar`). "Content" is
20
+ # the :content slot when the ring declares one, otherwise the first non-sidebar
21
+ # slot — so `focus_ring :sidebar, :entries` works without a literal :content.
21
22
  def focus_content
22
- if focus_ring_slot?(:content)
23
- focus.focus(:content)
24
- else
25
- session[:focus] = :content
26
- end
23
+ slot = content_slot
24
+ focus.focus(slot) if slot
27
25
  render_default_action
28
26
  end
29
27
 
30
- # True when the sidebar is the current focus target. Uses the focus ring when defined.
28
+ # True when the sidebar slot is the current focus target.
31
29
  def sidebar_focused?
32
- return focused?(:sidebar) if focus_ring_slot?(:sidebar)
33
-
34
- session[:focus] == :sidebar
30
+ focused?(:sidebar)
35
31
  end
36
32
 
37
- # True when the content pane is the current focus target. Uses the focus ring when defined.
33
+ # True when focus is on the content side: any current slot other than :sidebar.
38
34
  def content_focused?
39
- return focused?(:content) if focus_ring_slot?(:content)
40
-
41
- session[:focus] == :content
35
+ current = focus.current
36
+ !current.nil? && current != :sidebar
42
37
  end
43
38
 
44
39
  # Returns the index of the currently selected route in `sidebar_routes`, defaulting to the
@@ -82,9 +77,43 @@ module Charming
82
77
  response
83
78
  end
84
79
 
85
- # Mouse dispatch for the sidebar. Reserved for future use; returns nil.
80
+ # Mouse dispatch for the sidebar: a click on a route row selects it and navigates
81
+ # immediately; a click elsewhere in the sidebar pane focuses the sidebar. Uses the
82
+ # :sidebar pane's inner rect from the latest render to translate screen coordinates
83
+ # to nav rows. Returns nil when the click missed the sidebar entirely.
86
84
  def dispatch_sidebar_mouse
87
- nil
85
+ return nil unless event.respond_to?(:click?) && event.click?
86
+
87
+ row = sidebar_row_at(event.x, event.y)
88
+ return nil unless row
89
+
90
+ if row.between?(0, sidebar_routes.length - 1)
91
+ session[:sidebar_index] = row
92
+ sidebar_select
93
+ else
94
+ focus.focus(:sidebar)
95
+ render_default_action
96
+ end
97
+ response
98
+ end
99
+
100
+ # The number of rows above the first nav item inside the sidebar pane's content
101
+ # area (the generated layout renders the app title plus a blank gap line).
102
+ # Override in controllers whose sidebar layout differs.
103
+ def sidebar_nav_offset
104
+ 2
105
+ end
106
+
107
+ # Maps screen coordinates to a nav-row index inside the :sidebar pane's inner
108
+ # rect, or nil when the click missed the sidebar.
109
+ def sidebar_row_at(x, y)
110
+ target = mouse_targets.find { |candidate| candidate[:name] == :sidebar }
111
+ return nil unless target
112
+
113
+ inner = target.fetch(:inner_rect)
114
+ return nil unless inner.cover?(x, y)
115
+
116
+ y - inner.y - sidebar_nav_offset
88
117
  end
89
118
 
90
119
  # Moves the sidebar cursor by *delta* positions, clamped to the route list bounds.
@@ -96,14 +125,20 @@ module Charming
96
125
  render_default_action
97
126
  end
98
127
 
128
+ # The slot focus_content targets: :content when declared, else the first
129
+ # non-sidebar slot in the active ring.
130
+ def content_slot
131
+ ring = focus.ring
132
+ return :content if ring.include?(:content)
133
+
134
+ ring.find { |slot| slot != :sidebar }
135
+ end
136
+
99
137
  # Selects the route currently highlighted in the sidebar and navigates to it.
100
138
  def sidebar_select
101
139
  route = sidebar_routes[sidebar_index]
102
- if focus_ring_slot?(:content)
103
- focus.focus(:content)
104
- else
105
- session[:focus] = :content
106
- end
140
+ slot = content_slot
141
+ focus.focus(slot) if slot
107
142
  route ? navigate_to(route.path) : render_default_action
108
143
  end
109
144
  end
@@ -9,6 +9,7 @@ module Charming
9
9
  TaskBinding = Data.define(:name, :action)
10
10
 
11
11
  extend ClassMethods
12
+ include ActionHooks
12
13
  include Rendering
13
14
  include SessionState
14
15
  include FocusManagement
@@ -30,22 +31,50 @@ module Charming
30
31
  @response = nil
31
32
  end
32
33
 
33
- # Dispatches a named action on this controller (e.g. :show).
34
+ # Dispatches a named action on this controller (e.g. :show), running all
35
+ # before/around/after hooks and rescue_from handlers.
34
36
  def dispatch(action)
35
- public_send(action)
37
+ run_action_with_hooks(action)
36
38
  render_default_action if response.nil? && auto_render_after?(action)
37
39
  response || render("")
38
40
  end
39
41
 
40
- # Key event dispatch: checks command palette first, then global bindings,
41
- # sidebar (if focused), content bindings, tab traversal, and focused component.
42
+ # Key event dispatch, in priority order:
43
+ # 1. Command palette (when open) consumes everything.
44
+ # 2. A focused text-capturing component (TextInput, TextArea, Form, …) gets
45
+ # printable characters BEFORE key bindings — typing "q" into a field must
46
+ # insert a q, not quit the app.
47
+ # 3. Global key bindings.
48
+ # 4. An overlay focus scope (a pushed modal) captures all remaining keys.
49
+ # 5. Sidebar keys (when focused), content bindings, then the focused component —
50
+ # which sees Tab before ring traversal so forms can do field navigation.
42
51
  def dispatch_key
43
52
  return dispatch_command_palette_key if command_palette_open?
53
+
54
+ if printable_text_event? && focused_component_captures_text?
55
+ return response if dispatch_to_focused_component == :handled
56
+ end
57
+
44
58
  return dispatch(global_key_action) if global_key_action
59
+
60
+ if focus.overlay?
61
+ dispatch_to_focused_component
62
+ return response
63
+ end
64
+
45
65
  return dispatch_sidebar_key if sidebar_focused?
46
66
  return dispatch(content_key_action) if content_key_action
47
- return response if dispatch_tab_traversal == :handled
48
- return response if dispatch_to_focused_component == :handled
67
+
68
+ # Text-capturing components (forms, editors) own their remaining keys — Tab
69
+ # included, so forms do field navigation. Everything else keeps ring traversal
70
+ # ahead of the component.
71
+ if focused_component_captures_text?
72
+ return response if dispatch_to_focused_component == :handled
73
+ return response if dispatch_tab_traversal == :handled
74
+ else
75
+ return response if dispatch_tab_traversal == :handled
76
+ return response if dispatch_to_focused_component == :handled
77
+ end
49
78
  nil
50
79
  end
51
80
 
@@ -64,14 +93,37 @@ module Charming
64
93
  b ? dispatch(b.action) : nil
65
94
  end
66
95
 
67
- # Mouse event dispatcher: checks command palette (if open), sidebar (if focused).
96
+ # Task progress dispatcher: looks up the handler in task progress bindings.
97
+ def dispatch_task_progress
98
+ b = self.class.task_progress_bindings[event.name.to_sym]
99
+ b ? dispatch(b.action) : nil
100
+ end
101
+
102
+ # Paste event dispatcher: forwards pasted text to the focused component's
103
+ # `handle_paste` (TextInput, TextArea, and form fields support it).
104
+ def dispatch_paste
105
+ slot = focus.current
106
+ return nil unless slot && respond_to?(slot, true)
107
+
108
+ component = send(slot)
109
+ return nil unless component.respond_to?(:handle_paste)
110
+
111
+ result = component.handle_paste(event)
112
+ return nil if result.nil?
113
+
114
+ dispatch_component_result(slot, result)
115
+ response
116
+ end
117
+
118
+ # Mouse event dispatcher: command palette (if open) wins, then sidebar clicks
119
+ # (route rows navigate directly), then named layout panes/components.
68
120
  def dispatch_mouse
69
121
  return dispatch_command_palette_mouse if command_palette_open?
70
122
 
71
- mouse_response = dispatch_component_mouse
72
- return mouse_response if mouse_response
123
+ sidebar_response = dispatch_sidebar_mouse
124
+ return sidebar_response if sidebar_response
73
125
 
74
- dispatch_sidebar_mouse if sidebar_focused?
126
+ dispatch_component_mouse
75
127
  end
76
128
 
77
129
  # Renders a body or template wrapped in the controller's layout.
@@ -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,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
@@ -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
@@ -98,10 +98,47 @@ module Charming
98
98
  controller_actions: controller_actions,
99
99
  controller_helpers: controller_helpers,
100
100
  database_require: database_require,
101
- model_loader: model_loader
101
+ model_loader: model_loader,
102
+ env_setup: env_setup,
103
+ database_spec_setup: database_spec_setup
102
104
  }
103
105
  end
104
106
 
107
+ # Pins CHARMING_ENV to "test" before the app (and its database config) loads, so
108
+ # specs hit db/test.sqlite3. Empty for non-database apps.
109
+ def env_setup
110
+ return "" unless database?
111
+
112
+ "ENV[\"CHARMING_ENV\"] ||= \"test\"\n\n"
113
+ end
114
+
115
+ # Prepares the test database schema before the suite and rolls back each example's
116
+ # writes in a transaction. Empty for non-database apps.
117
+ def database_spec_setup
118
+ return "" unless database?
119
+
120
+ <<~RUBY
121
+
122
+ # Prepare the test database, preferring the dumped schema over replaying migrations.
123
+ schema = File.expand_path("../db/schema.rb", __dir__)
124
+ if File.exist?(schema)
125
+ load schema
126
+ else
127
+ ActiveRecord::MigrationContext.new(File.expand_path("../db/migrate", __dir__)).migrate
128
+ end
129
+
130
+ RSpec.configure do |config|
131
+ # Roll back database writes after each example so tests stay isolated.
132
+ config.around(:each) do |example|
133
+ ActiveRecord::Base.transaction(requires_new: true) do
134
+ example.run
135
+ raise ActiveRecord::Rollback
136
+ end
137
+ end
138
+ end
139
+ RUBY
140
+ end
141
+
105
142
  # The `Gem::Specification` attributes block (indented two spaces to match the wrapping
106
143
  # `Gem::Specification.new do |spec|`).
107
144
  def gemspec_attributes
@@ -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).