charming 0.1.0 → 0.1.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Charming
6
+ # DatabaseCommands implements the runtime side of `charming db:COMMAND` (other than
7
+ # `db:install`, which lives in DatabaseInstaller). It loads the app's `config/database.rb`,
8
+ # delegates the actual work to ActiveRecord, and prints a short status line on success.
9
+ class DatabaseCommands
10
+ # *command* is the subcommand string (e.g., "db:create"). *out* is the status-output
11
+ # stream. *destination* is the app root for resolving `config/database.rb` and `db/`.
12
+ def initialize(command, out:, destination:)
13
+ @command = command
14
+ @out = out
15
+ @destination = destination
16
+ end
17
+
18
+ # Dispatches the configured command. Raises Generators::Error for unknown commands.
19
+ def run
20
+ case command
21
+ when "db:create" then create
22
+ when "db:migrate" then migrate
23
+ when "db:rollback" then rollback
24
+ when "db:drop" then drop
25
+ when "db:seed" then seed
26
+ else raise Generators::Error, "Unknown database command: #{command}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # The subcommand, output stream, and app destination.
33
+ attr_reader :command, :out, :destination
34
+
35
+ # Creates the SQLite database file (touch) and establishes the connection.
36
+ def create
37
+ load_database
38
+ FileUtils.mkdir_p(File.dirname(database_path)) if database_path
39
+ FileUtils.touch(database_path) if database_path
40
+ ActiveRecord::Base.connection
41
+ out.puts "create #{relative_database_path}"
42
+ end
43
+
44
+ # Runs all pending migrations from `db/migrate`.
45
+ def migrate
46
+ load_database
47
+ migration_context.migrate
48
+ out.puts "migrate db/migrate"
49
+ end
50
+
51
+ # Rolls back the most recent migration.
52
+ def rollback
53
+ load_database
54
+ migration_context.rollback(1)
55
+ out.puts "rollback db/migrate"
56
+ end
57
+
58
+ # Disconnects ActiveRecord, then deletes the database file.
59
+ def drop
60
+ load_database
61
+ ActiveRecord::Base.connection.disconnect!
62
+ File.delete(database_path) if database_path && File.exist?(database_path)
63
+ out.puts "drop #{relative_database_path}"
64
+ end
65
+
66
+ # Loads `db/seeds.rb` (raises if missing).
67
+ def seed
68
+ load_database
69
+ seed_path = File.join(destination, "db", "seeds.rb")
70
+ raise Generators::Error, "Missing file: db/seeds.rb" unless File.exist?(seed_path)
71
+
72
+ load seed_path
73
+ out.puts "seed db/seeds.rb"
74
+ end
75
+
76
+ # Loads the app's `config/database.rb` (raises if missing) which establishes the connection.
77
+ def load_database
78
+ database_config = File.join(destination, "config", "database.rb")
79
+ raise Generators::Error, "Database support is not configured. Missing config/database.rb." unless File.exist?(database_config)
80
+
81
+ require database_config
82
+ end
83
+
84
+ # The ActiveRecord migration context rooted at `db/migrate` inside the app.
85
+ def migration_context
86
+ ActiveRecord::MigrationContext.new(File.join(destination, "db", "migrate"))
87
+ end
88
+
89
+ # The configured database file path (nil when ActiveRecord isn't connected to a file).
90
+ def database_path
91
+ ActiveRecord::Base.connection_db_config.database
92
+ end
93
+
94
+ # The database path relative to the app root, used for human-friendly status output.
95
+ def relative_database_path
96
+ return "database" unless database_path
97
+
98
+ base = File.realpath(destination)
99
+ path = File.expand_path(database_path)
100
+ path.start_with?("#{base}/") ? path.delete_prefix("#{base}/") : path
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Charming
6
+ # DatabaseInstaller implements `charming db:install sqlite3`. It adds database support
7
+ # to an existing Charming app by creating `config/database.rb`, `app/models/application_record.rb`,
8
+ # `db/migrate/`, and `db/seeds.rb`, and patching the gemspec and root loader to include
9
+ # the new dependencies and the `app/models` autoload directory.
10
+ class DatabaseInstaller
11
+ # *database* is the adapter name (only "sqlite3" is currently supported). *out* is the
12
+ # status-output stream. *destination* is the app root.
13
+ def initialize(database, out:, destination:)
14
+ @database = database
15
+ @out = out
16
+ @destination = destination
17
+ @app_name = Generators::Name.new(app_name_from_gemspec)
18
+ end
19
+
20
+ # Performs the install: writes the database config, application record, migrate directory,
21
+ # seeds file, and patches the gemspec + root loader. Idempotent: existing files are
22
+ # reported with "exist <path>" instead of being overwritten.
23
+ def install
24
+ raise Generators::Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
25
+
26
+ create_file("config/database.rb", database_config)
27
+ create_file("app/models/application_record.rb", application_record)
28
+ create_file("db/migrate/.keep", "")
29
+ create_file("db/seeds.rb", %(# frozen_string_literal: true
30
+ ))
31
+ update_gemspec
32
+ update_root_file
33
+ end
34
+
35
+ private
36
+
37
+ # The database adapter, status stream, app destination, and derived app name.
38
+ attr_reader :database, :out, :destination, :app_name
39
+
40
+ # Writes *content* to *path* (relative to the app root), creating intermediate directories.
41
+ # Reports "exist <path>" without overwriting when the file already exists.
42
+ def create_file(path, content)
43
+ absolute_path = File.join(destination, path)
44
+ if File.exist?(absolute_path)
45
+ out.puts "exist #{path}"
46
+ return
47
+ end
48
+
49
+ FileUtils.mkdir_p(File.dirname(absolute_path))
50
+ File.write(absolute_path, content)
51
+ out.puts "create #{path}"
52
+ end
53
+
54
+ # Patches the gemspec to include the `db` directory in the gem files glob and to add
55
+ # activerecord + sqlite3 dependencies.
56
+ def update_gemspec
57
+ update_file(gemspec_path) do |current|
58
+ updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
59
+ updated = insert_dependency(updated, "activerecord", "~> 8.1")
60
+ insert_dependency(updated, "sqlite3", "~> 2.0")
61
+ end
62
+ end
63
+
64
+ # Patches the root loader file (`lib/<app>.rb`) to require `config/database` and to push
65
+ # the `app/models` autoload directory. Both edits are no-ops when already applied.
66
+ def update_root_file
67
+ update_file(root_file_path) do |current|
68
+ updated = current
69
+ updated = updated.sub(%(require "zeitwerk"\n), %(require "zeitwerk"\nrequire_relative "../config/database"\n)) unless updated.include?(%(require_relative "../config/database"))
70
+ unless updated.include?(%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})])
71
+ updated = updated.sub(
72
+ %[loader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n],
73
+ %[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})\nloader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n]
74
+ )
75
+ end
76
+ updated
77
+ end
78
+ end
79
+
80
+ # Reads *path*, yields its contents to the block, and writes the result back when it
81
+ # differs. Raises Generators::Error when the file is missing.
82
+ def update_file(path)
83
+ raise Generators::Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
84
+
85
+ current = File.read(path)
86
+ updated = yield current
87
+ return if updated == current
88
+
89
+ File.write(path, updated)
90
+ out.puts "update #{relative_path(path)}"
91
+ end
92
+
93
+ # Inserts a `spec.add_dependency "name", "version"` line after the `charming` dependency
94
+ # when it's not already present.
95
+ def insert_dependency(content, gem_name, version)
96
+ return content if content.include?(%(spec.add_dependency "#{gem_name}"))
97
+
98
+ dependency = %( spec.add_dependency "#{gem_name}", "#{version}")
99
+ content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
100
+ end
101
+
102
+ # The contents of the new `config/database.rb` (establishes an SQLite connection to
103
+ # `db/development.sqlite3`).
104
+ def database_config
105
+ %(# frozen_string_literal: true
106
+
107
+ require "active_record"
108
+ require "fileutils"
109
+
110
+ database_path = File.expand_path("../db/development.sqlite3", __dir__)
111
+ FileUtils.mkdir_p(File.dirname(database_path))
112
+
113
+ ActiveRecord::Base.establish_connection(
114
+ adapter: "sqlite3",
115
+ database: database_path
116
+ )
117
+ )
118
+ end
119
+
120
+ # The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
121
+ def application_record
122
+ %(# frozen_string_literal: true
123
+
124
+ module #{app_name.class_name}
125
+ class ApplicationRecord < ActiveRecord::Base
126
+ self.abstract_class = true
127
+ end
128
+ end
129
+ )
130
+ end
131
+
132
+ # Reads the app's gemspec filename to derive the app name.
133
+ def app_name_from_gemspec
134
+ File.basename(gemspec_path, ".gemspec")
135
+ end
136
+
137
+ # The path to the app's gemspec (raises when not found).
138
+ def gemspec_path
139
+ @gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Generators::Error, "Run this command from a Charming app root")
140
+ end
141
+
142
+ # The path to the app's root loader file (`lib/<app_name>.rb`).
143
+ def root_file_path
144
+ File.join(destination, "lib", "#{app_name.snake_name}.rb")
145
+ end
146
+
147
+ # Strips the app destination prefix from *path* for human-friendly status output.
148
+ def relative_path(path)
149
+ path.delete_prefix("#{destination}/")
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # KeyEvent represents a terminal key press parsed by the backend. *key* is the normalized semantic
6
+ # action name (e.g., `:up`, `:down`, `:q`), while *char*, *ctrl*, *alt*, and *shift* capture raw
7
+ # input details for custom bindings.
8
+ KeyEvent = Data.define(:key, :char, :ctrl, :alt, :shift) do
9
+ # Constructs a key event with the required *key* symbol, plus optional *char* string and modifier booleans.
10
+ def initialize(key:, char: nil, ctrl: false, alt: false, shift: false)
11
+ super(key: key.to_sym, char: char, ctrl: ctrl, alt: alt, shift: shift)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # MOUSE_BUTTON_MAP encodes terminal mouse button codes to semantic symbols. The constant is frozen and private.
6
+ MOUSE_BUTTON_MAP = {
7
+ 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
8
+ 64 => :scroll_up, 65 => :scroll_down,
9
+ 66 => :scroll_up, 67 => :scroll_down
10
+ }.freeze
11
+ private_constant :MOUSE_BUTTON_MAP
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)
18
+ super
19
+ end
20
+
21
+ # Returns the semantic symbol for *button* — one of `left`, `right`, `scroll_up`, etc. or `:unknown`.
22
+ def button_name
23
+ MOUSE_BUTTON_MAP.fetch(button, :unknown)
24
+ end
25
+
26
+ # Returns `true` when the current event is a click (left, middle, or right button).
27
+ def click?
28
+ %i[left middle right].include?(button_name)
29
+ end
30
+
31
+ # Returns `true` when the button name maps to either direction of scroll.
32
+ def scroll?
33
+ %i[scroll_up scroll_down].include?(button_name)
34
+ end
35
+
36
+ # Returns `true` when the current event is a mouse release action.
37
+ def release?
38
+ button_name == :release
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # ResizeEvent represents a terminal window resize. *width* and *height* carry the new terminal dimensions
6
+ # in screen cells, replacing the previous Screen dimensions for all subsequent rendering.
7
+ ResizeEvent = Data.define(:width, :height)
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # TaskEvent represents background task completion. *name* is the declared task identifier, *value* carries
6
+ # the return result and *error* captures any exception raised during execution. The `error?` predicate
7
+ # simplifies error handling in controller handlers.
8
+ TaskEvent = Data.define(:name, :value, :error) do
9
+ def initialize(name:, value: nil, error: nil)
10
+ super
11
+ end
12
+
13
+ # Returns `true` when the task finished with a non-nil exception.
14
+ def error?
15
+ !error.nil?
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # TimerEvent represents a timed dispatch from the runtime loop. *name* is the declared timer identifier;
6
+ # *now* is the monotonically rising clock value at emission for throttle comparisons.
7
+ TimerEvent = Data.define(:name, :now)
8
+ end
9
+ end
@@ -1,7 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
+ # Focus manages a stack of focus scopes (rings) for a single controller class. Each scope has
5
+ # a slot ring (a fixed list of named slots) and a current slot within that ring. Multiple
6
+ # scopes can be stacked so the command palette, modals, and layouts can each have their own
7
+ # focus contexts without interfering with one another.
8
+ #
9
+ # State lives under `session[:focus_state][controller_class_name]` so focus persists across
10
+ # controller dispatches within the same session.
4
11
  class Focus
12
+ # Returns the Focus object for *controller_class* under the given *session*, creating the
13
+ # underlying session hash if absent.
5
14
  def self.for(session, controller_class)
6
15
  session[:focus_state] ||= {}
7
16
  key = controller_class.name
@@ -13,34 +22,54 @@ module Charming
13
22
  @state = state
14
23
  end
15
24
 
25
+ # Defines the primary focus ring for the controller with the given *slots*. Only effective
26
+ # the first time it is called; subsequent calls are no-ops.
16
27
  def define(slots)
17
28
  return if @state[:scopes].any? { |scope| scope[:origin] == :ring }
18
29
 
19
30
  @state[:scopes] << build_scope(slots, :ring)
20
31
  end
21
32
 
33
+ # Defines a layout scope (inserted after the primary ring and before modal scopes). *slots*
34
+ # is the list of pane names; the previously-focused layout slot is preserved when it is still
35
+ # part of the new ring.
36
+ def define_layout(slots)
37
+ current = current_layout_slot(slots)
38
+ remove_scope(:layout)
39
+ return if slots.empty?
40
+
41
+ @state[:scopes].insert(layout_scope_index, build_scope(slots, :layout, current))
42
+ end
43
+
44
+ # Pushes a new focus scope with the given *slots* onto the stack. Used by modals, palettes,
45
+ # and other overlays. *origin* is a label for the scope kind.
22
46
  def push_scope(slots, origin: :modal)
23
47
  @state[:scopes] << build_scope(slots, origin)
24
48
  end
25
49
 
50
+ # Pops the topmost focus scope from the stack.
26
51
  def pop_scope
27
52
  @state[:scopes].pop
28
53
  end
29
54
 
55
+ # Returns the currently focused slot, or nil when no scope is active.
30
56
  def current
31
57
  top && top[:current]
32
58
  end
33
59
 
60
+ # Returns the slot ring of the topmost scope (an array of slot names). Empty when no scope.
34
61
  def ring
35
62
  top ? top[:ring] : []
36
63
  end
37
64
 
65
+ # Sets the current slot within the topmost scope to *slot*. No-op when *slot* is not in the ring.
38
66
  def focus(slot)
39
67
  return unless ring.include?(slot)
40
68
 
41
69
  top[:current] = slot
42
70
  end
43
71
 
72
+ # Cycles focus by *direction* (default +1 forward) within the topmost ring. No-op on an empty ring.
44
73
  def cycle(direction = +1)
45
74
  return if ring.empty?
46
75
 
@@ -48,18 +77,45 @@ module Charming
48
77
  top[:current] = ring[(index + direction) % ring.length]
49
78
  end
50
79
 
80
+ # True when *slot* is the current focus slot.
51
81
  def focused?(slot)
52
82
  current == slot
53
83
  end
54
84
 
55
85
  private
56
86
 
87
+ # Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
57
88
  def top
58
89
  @state[:scopes].last
59
90
  end
60
91
 
61
- def build_scope(slots, origin)
62
- {ring: slots.dup.freeze, current: slots.first, origin: origin}
92
+ # Removes every scope whose origin equals *origin* (in place).
93
+ def remove_scope(origin)
94
+ @state[:scopes].reject! { |scope| scope[:origin] == origin }
95
+ end
96
+
97
+ # Returns the index in the scope stack where a layout scope belongs: just before the first
98
+ # non-ring, non-layout scope (i.e., at the end of the "structural" stack).
99
+ def layout_scope_index
100
+ index = @state[:scopes].index { |scope| !%i[ring layout].include?(scope[:origin]) }
101
+ index || @state[:scopes].length
102
+ end
103
+
104
+ # Returns the current layout scope's current slot, but only when it is still part of *slots*.
105
+ # Otherwise returns the first slot in *slots* (so a new layout reverts to its first pane).
106
+ def current_layout_slot(slots)
107
+ current_slot = current_layout_scope&.fetch(:current)
108
+ slots.include?(current_slot) ? current_slot : slots.first
109
+ end
110
+
111
+ # Returns the layout scope, or nil when no layout scope is present.
112
+ def current_layout_scope
113
+ @state[:scopes].find { |scope| scope[:origin] == :layout }
114
+ end
115
+
116
+ # Builds an immutable scope hash with the given *slots*, *origin*, and starting *current* slot.
117
+ def build_scope(slots, origin, current = slots.first)
118
+ {ring: slots.dup.freeze, current: current, origin: origin}
63
119
  end
64
120
  end
65
121
  end
@@ -2,7 +2,14 @@
2
2
 
3
3
  module Charming
4
4
  module Generators
5
+ # AppFileGenerator is the parent class for "in-app" sub-generators (controller, model,
6
+ # screen, view, component) that run inside an existing Charming app. It derives the
7
+ # app's namespace from the local gemspec and exposes path-building helpers that put
8
+ # files under the right `app/...` subdirectory.
5
9
  class AppFileGenerator < Base
10
+ # *name* is the singular resource name (e.g., "user"). *_args* are subcommand-specific
11
+ # (e.g., controller actions or model fields). *out*, *destination*, and *force* are
12
+ # forwarded to Base.
6
13
  def initialize(name, _args, out:, destination:, force: false)
7
14
  super(out: out, destination: destination, force: force)
8
15
  @name = Name.new(name)
@@ -11,12 +18,18 @@ module Charming
11
18
 
12
19
  private
13
20
 
21
+ # The resource name and the parent app name (both wrapped in Generators::Name).
14
22
  attr_reader :name, :app_name
15
23
 
24
+ # Builds the full file path under `app/<dir>/<resource>_<suffix>.rb` for the
25
+ # configured *parts* (the immediate directory chain). The suffix is supplied by
26
+ # the subclass (controller, model, view, etc.).
16
27
  def app_path(*parts)
17
28
  File.join(*parts, "#{name.snake_name}_#{suffix}.rb")
18
29
  end
19
30
 
31
+ # Reads the gemspec filename from the destination directory to derive the app name.
32
+ # Raises Error when no gemspec is found.
20
33
  def app_name_from_gemspec
21
34
  gemspec = Dir.glob(File.join(destination, "*.gemspec")).first
22
35
  raise Error, "Run this generator from a Charming app root" unless gemspec