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
@@ -2,74 +2,176 @@
2
2
 
3
3
  module Charming
4
4
  module Generators
5
+ # AppGenerator implements `charming new NAME`. Writes a complete Bundler-gem-style
6
+ # Charming app skeleton: Gemfile, Rakefile, gemspec, exe, lib root + application +
7
+ # version, config/routes.rb, app/state, app/controllers, app/views/layouts + home view,
8
+ # and a baseline spec/ tree. Optionally also creates the database files when
9
+ # `database:` is set.
5
10
  class AppGenerator < Base
6
- include BasicTemplates
7
- include ComponentTemplates
8
- include ControllerTemplate
9
- include LayoutTemplate
10
- include ModelTemplates
11
- include ScreenSpecTemplates
12
- include ViewTemplate
13
- include AppSpecTemplates
14
-
15
- FILE_TEMPLATES = [
16
- ["Gemfile", :gemfile],
17
- ["Rakefile", :rakefile],
18
- ["README.md", :readme],
19
- ["%<name>s.gemspec", :gemspec],
20
- ["exe/%<name>s", :executable],
21
- ["lib/%<name>s.rb", :root_file],
22
- ["lib/%<name>s/application.rb", :application],
23
- ["lib/%<name>s/version.rb", :version],
24
- ["config/routes.rb", :routes],
25
- ["app/models/application_model.rb", :application_model],
26
- ["app/models/home_model.rb", :home_model],
27
- ["app/controllers/application_controller.rb", :application_controller],
28
- ["app/controllers/home_controller.rb", :controller],
29
- ["app/views/layouts/application.rb", :layout],
30
- ["app/views/home_view.rb", :view],
31
- ["app/components/app_frame_component.rb", :component],
32
- ["spec/spec_helper.rb", :spec_helper],
33
- ["spec/models/home_model_spec.rb", :spec_model],
34
- ["spec/controllers/home_controller_spec.rb", :spec_controller],
35
- ["spec/views/home_view_spec.rb", :spec_view],
36
- ["spec/components/app_frame_component_spec.rb", :spec_component]
11
+ # The list of [relative-path, template-path, executable-flag] triples to render
12
+ # for a non-database app.
13
+ BASE_FILE_TEMPLATES = [
14
+ ["Gemfile", "app/Gemfile.template", false],
15
+ ["Rakefile", "app/Rakefile.template", false],
16
+ ["README.md", "app/README.md.template", false],
17
+ ["%<name>s.gemspec", "app/gemspec.template", false],
18
+ ["exe/%<name>s", "app/executable.template", true],
19
+ ["lib/%<name>s.rb", "app/root_file.template", false],
20
+ ["lib/%<name>s/application.rb", "app/application.template", false],
21
+ ["lib/%<name>s/version.rb", "app/version.template", false],
22
+ ["config/routes.rb", "app/routes.template", false],
23
+ ["app/state/application_state.rb", "app/application_state.template", false],
24
+ ["app/state/home_state.rb", "app/home_state.template", false],
25
+ ["app/controllers/application_controller.rb", "app/application_controller.template", false],
26
+ ["app/controllers/home_controller.rb", "app/home_controller.template", false],
27
+ ["app/views/layouts/application_layout.rb", "app/layout.template", false],
28
+ ["app/views/home/show_view.rb", "app/view.template", false],
29
+ ["app/components/.keep", "app/keep.template", false],
30
+ ["spec/spec_helper.rb", "app/spec_helper.template", false],
31
+ ["spec/state/home_state_spec.rb", "app/spec_state.template", false],
32
+ ["spec/controllers/home_controller_spec.rb", "app/spec_controller.template", false],
33
+ ["spec/views/home/show_view_spec.rb", "app/spec_view.template", false]
37
34
  ].freeze
38
35
 
39
- def initialize(name, out:, destination:, force: false)
36
+ # The list of [relative-path, template-path, executable-flag] triples to render in
37
+ # addition to the base list when `database:` is set on the generator.
38
+ DATABASE_FILE_TEMPLATES = [
39
+ ["config/database.rb", "app/database_config.template", false],
40
+ ["app/models/application_record.rb", "app/application_record.template", false],
41
+ ["db/migrate/.keep", "app/keep.template", false],
42
+ ["db/seeds.rb", "app/seeds.template", false]
43
+ ].freeze
44
+
45
+ # *name* is the new app's name. *out* is the status stream. *destination* is the
46
+ # parent directory under which `<name>/` will be created. *force* allows overwriting
47
+ # existing files. *database* optionally enables the database template set.
48
+ def initialize(name, out:, destination:, force: false, database: nil)
40
49
  super(out: out, destination: File.join(destination, name), force: force)
41
50
  @name = Name.new(name)
51
+ @database = database
42
52
  end
43
53
 
54
+ # Renders every template in the chosen template list (base + optional database)
55
+ # and writes the files, then initializes a git repository in the new app directory.
44
56
  def generate
45
- FILE_TEMPLATES.each do |path, template|
46
- create_file(file_path(path), send(template), executable: template == :executable)
57
+ file_templates.each do |path, template_path, executable|
58
+ create_file(file_path(path), render_app_template(template_path), executable: executable)
47
59
  end
60
+ initialize_git_repository
48
61
  end
49
62
 
50
63
  private
51
64
 
52
- attr_reader :name
65
+ # The resource name and the database adapter name (or nil).
66
+ attr_reader :name, :database
53
67
  alias_method :app_name, :name
54
68
 
69
+ # True when the database template set should be rendered.
70
+ def database?
71
+ !!database
72
+ end
73
+
74
+ # Returns the template list: base only, or base + database extras.
75
+ def file_templates
76
+ database? ? BASE_FILE_TEMPLATES + DATABASE_FILE_TEMPLATES : BASE_FILE_TEMPLATES
77
+ end
78
+
79
+ # Substitutes `name.snake_name` into a relative-path template (paths use `%<name>s`).
55
80
  def file_path(path)
56
81
  format(path, name: name.snake_name)
57
82
  end
58
83
 
59
- def routes
60
- %(# frozen_string_literal: true
84
+ # Renders an app template file by replacing `__TOKEN__` placeholders with the
85
+ # appropriate values derived from the current *name* and *database* setting.
86
+ def render_app_template(relative_path)
87
+ render_template(relative_path, **app_template_tokens)
88
+ end
89
+
90
+ # Returns the token map used to render every app template.
91
+ def app_template_tokens
92
+ {
93
+ app_name: name.class_name,
94
+ app_snake: name.snake_name,
95
+ app_class: name.class_name,
96
+ gemspec_attributes: gemspec_attributes,
97
+ gemspec_dependencies: gemspec_dependencies,
98
+ controller_actions: controller_actions,
99
+ controller_helpers: controller_helpers,
100
+ database_require: database_require,
101
+ model_loader: model_loader
102
+ }
103
+ end
61
104
 
62
- #{name.class_name}::Application.routes do
63
- root "home#show"
64
- end
65
- )
105
+ # The `Gem::Specification` attributes block (indented two spaces to match the wrapping
106
+ # `Gem::Specification.new do |spec|`).
107
+ def gemspec_attributes
108
+ " spec.name = \"#{name.snake_name}\"\n" \
109
+ " spec.version = #{name.class_name}::VERSION\n" \
110
+ " spec.summary = \"A Charming terminal user interface.\"\n" \
111
+ " spec.authors = [\"TODO: Your name\"]\n" \
112
+ " spec.email = [\"TODO: Your email\"]\n" \
113
+ " spec.files = Dir.glob(\"#{gemspec_file_glob}/**/*\") + %w[README.md]\n" \
114
+ " spec.bindir = \"exe\"\n" \
115
+ " spec.executables = [\"#{name.snake_name}\"]\n" \
116
+ " spec.require_paths = [\"lib\"]\n" \
117
+ " spec.required_ruby_version = \">= 4.0.0\"\n" \
118
+ " spec.metadata[\"rubygems_mfa_required\"] = \"true\""
119
+ end
120
+
121
+ # The `Gem::Specification` `add_dependency` lines (trailing newline).
122
+ def gemspec_dependencies
123
+ "\n spec.add_dependency \"charming\"#{database_dependencies}\n"
124
+ end
125
+
126
+ # The file glob used by the gemspec to enumerate packaged files.
127
+ def gemspec_file_glob
128
+ database? ? "{app,config,db,exe,lib}" : "{app,config,exe,lib}"
66
129
  end
67
130
 
68
- def spec_helper
69
- %(# frozen_string_literal: true
131
+ # The optional `activerecord`/`sqlite3` dependency lines (with leading newlines and
132
+ # trailing newline) when the app is database-configured; otherwise an empty string.
133
+ def database_dependencies
134
+ return "" unless database?
135
+
136
+ "\n spec.add_dependency \"activerecord\", \"~> 8.1\"\n" \
137
+ " spec.add_dependency \"sqlite3\", \"~> 2.0\""
138
+ end
139
+
140
+ # The body of the home controller's `show` action.
141
+ def controller_actions
142
+ "\n def show\n" \
143
+ " render :show, home: home, palette: command_palette\n" \
144
+ " end"
145
+ end
146
+
147
+ # The body of the home controller's private `home` helper, prefixed by a blank line.
148
+ def controller_helpers
149
+ "\n\n private\n" \
150
+ " def home\n" \
151
+ " state(:home, HomeState)\n" \
152
+ " end"
153
+ end
154
+
155
+ # The `require_relative "../config/database"` line when the app is database-configured.
156
+ def database_require
157
+ database? ? "require_relative \"../config/database\"" : ""
158
+ end
159
+
160
+ # The model loader `push_dir` line (with trailing newline) when the app is
161
+ # database-configured; otherwise an empty string.
162
+ def model_loader
163
+ return "" unless database?
164
+
165
+ "loader.push_dir(File.expand_path(\"../app/models\", __dir__), namespace: #{name.class_name})\n"
166
+ end
167
+
168
+ # Initializes a git repository in the new app's directory. Raises Error on failure.
169
+ def initialize_git_repository
170
+ unless system("git", "init", chdir: destination, out: File::NULL, err: File::NULL)
171
+ raise Error, "Could not initialize git repository"
172
+ end
70
173
 
71
- require "#{name.snake_name}"
72
- )
174
+ out.puts "init git"
73
175
  end
74
176
  end
75
177
  end
@@ -4,7 +4,13 @@ require "fileutils"
4
4
 
5
5
  module Charming
6
6
  module Generators
7
+ # Base is the parent class for all Charming file generators. Subclasses implement
8
+ # `generate` to write the appropriate files. The base class provides `create_file`,
9
+ # which writes content to a path under the configured *destination* and refuses to
10
+ # overwrite existing files unless *force* was set.
7
11
  class Base
12
+ # *out* is the status-output stream. *destination* is the app root for generated files.
13
+ # *force* (default false) allows overwriting existing files.
8
14
  def initialize(out:, destination:, force: false)
9
15
  @out = out
10
16
  @destination = destination
@@ -13,8 +19,12 @@ module Charming
13
19
 
14
20
  private
15
21
 
22
+ # Status output stream and destination directory accessor (subclasses use these).
16
23
  attr_reader :out, :destination
17
24
 
25
+ # Writes *content* to *path* (relative to the destination), creating intermediate
26
+ # directories as needed. Raises Generators::Error when the file already exists and
27
+ # *force* is false. Marks the file as executable when *executable:* is true.
18
28
  def create_file(path, content, executable: false)
19
29
  absolute_path = File.join(destination, path)
20
30
  raise Error, "File already exists: #{path}" if File.exist?(absolute_path) && !@force
@@ -24,6 +34,22 @@ module Charming
24
34
  FileUtils.chmod("u+x,go+rx", absolute_path) if executable
25
35
  out.puts "create #{path}"
26
36
  end
37
+
38
+ # Renders a template file by replacing `__TOKEN__` placeholders with values from
39
+ # *tokens*. *template_path* is relative to the generators templates directory.
40
+ def render_template(template_path, **tokens)
41
+ body = File.read(template_file(template_path))
42
+ tokens.each do |key, value|
43
+ body = body.gsub("__#{key.to_s.upcase}__", value.to_s)
44
+ end
45
+ body
46
+ end
47
+
48
+ # Absolute path to a generator template file. *relative_path* is relative to the
49
+ # generators templates directory.
50
+ def template_file(relative_path)
51
+ File.join(__dir__, "templates", relative_path)
52
+ end
27
53
  end
28
54
  end
29
55
  end
@@ -2,28 +2,28 @@
2
2
 
3
3
  module Charming
4
4
  module Generators
5
+ # ComponentGenerator implements `charming generate component NAME`. Writes a
6
+ # `Charming::Presentation::Component` subclass to `app/components/<name>_component.rb`.
5
7
  class ComponentGenerator < AppFileGenerator
8
+ # Writes the component file to the standard `app/components` path.
6
9
  def generate
7
10
  create_file(app_path("app", "components"), component)
8
11
  end
9
12
 
10
13
  private
11
14
 
15
+ # The file-name suffix used by `app_path` (sets "component" so the file is
16
+ # `<name>_component.rb`).
12
17
  def suffix
13
18
  "component"
14
19
  end
15
20
 
21
+ # The full source of the generated component class.
16
22
  def component
17
- %(# frozen_string_literal: true
18
-
19
- module #{app_name.class_name}
20
- class #{name.component_class_name} < Charming::Component
21
- def render
22
- text "#{name.class_name}"
23
- end
24
- end
25
- end
26
- )
23
+ render_template("component/component.rb.template",
24
+ app_class: app_name.class_name,
25
+ component_class: name.component_class_name,
26
+ resource_name: name.class_name)
27
27
  end
28
28
  end
29
29
  end
@@ -2,48 +2,56 @@
2
2
 
3
3
  module Charming
4
4
  module Generators
5
+ # ControllerGenerator implements `charming generate controller NAME [ACTION ...]`.
6
+ # Writes `app/controllers/<name>_controller.rb` containing a class that inherits
7
+ # from the app's `ApplicationController` and a `show` (or named) action that renders
8
+ # the conventional view with the command palette passed as an assign.
5
9
  class ControllerGenerator < AppFileGenerator
10
+ # *name* is the resource name. *args* is the list of action names (defaults to `show`).
11
+ # *out*, *destination*, and *force* are forwarded to the parent.
6
12
  def initialize(name, args, out:, destination:, force: false)
7
13
  super
8
14
  @actions = args
9
15
  end
10
16
 
17
+ # Writes the controller file to the standard app/controllers path.
11
18
  def generate
12
19
  create_file(app_path("app", "controllers"), controller)
13
20
  end
14
21
 
15
22
  private
16
23
 
24
+ # The list of action names supplied on the command line.
17
25
  attr_reader :actions
18
26
 
27
+ # The file-name suffix used by `app_path` (sets "controller" so the file is
28
+ # `<name>_controller.rb`).
19
29
  def suffix
20
30
  "controller"
21
31
  end
22
32
 
33
+ # The full source of the generated controller file.
23
34
  def controller
24
- %(# frozen_string_literal: true
25
-
26
- module #{app_name.class_name}
27
- class #{name.controller_class_name} < ApplicationController
28
- #{action_methods} end
29
- end
30
- )
35
+ render_template("controller/controller.rb.template",
36
+ app_class: app_name.class_name,
37
+ controller_class: name.controller_class_name,
38
+ action_methods: action_methods)
31
39
  end
32
40
 
41
+ # Renders one action method per action name; falls back to a single `show` action
42
+ # when no actions were specified.
33
43
  def action_methods
34
44
  return action_method("show") if actions.empty?
35
45
 
36
46
  actions.map { |action| action_method(action) }.join("\n")
37
47
  end
38
48
 
49
+ # Source for a single action method that renders the matching conventional view and
50
+ # passes the command palette as an assign.
39
51
  def action_method(action)
40
- %( def #{action}
41
- render #{name.view_class_name}.new(
42
- palette: command_palette,
43
- screen: screen
44
- )
45
- end
46
- )
52
+ " def #{action}\n" \
53
+ " render :#{action}, palette: command_palette\n" \
54
+ " end\n"
47
55
  end
48
56
  end
49
57
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ # ModelGenerator implements `charming generate model NAME [name:type ...]`. Writes an
6
+ # ActiveRecord model class, a `Create<Table>` migration (with one column per supplied
7
+ # field), and a baseline spec. Requires the app to have been generated with
8
+ # `--database sqlite3`.
9
+ class ModelGenerator < AppFileGenerator
10
+ # A single model field: column *name* and ActiveRecord *type* (e.g., "string").
11
+ Field = Data.define(:name, :type)
12
+
13
+ # The set of ActiveRecord column types accepted on the command line.
14
+ VALID_TYPES = %w[string text integer float decimal boolean date datetime time].freeze
15
+
16
+ # *name* is the resource name. *args* is the list of `name:type` field specifications.
17
+ def initialize(name, args, out:, destination:, force: false)
18
+ super
19
+ @fields = args.map { |arg| parse_field(arg) }
20
+ end
21
+
22
+ # Validates that the app is database-configured, then writes the model, migration,
23
+ # and spec files.
24
+ def generate
25
+ raise Error, "Database support is not configured. Generate the app with --database sqlite3 first." unless database_configured?
26
+
27
+ create_file(model_path, model)
28
+ create_file(migration_path, migration)
29
+ create_file(spec_path, spec)
30
+ end
31
+
32
+ private
33
+
34
+ # The list of parsed Field entries supplied on the command line.
35
+ attr_reader :fields
36
+
37
+ # No file-name suffix; ModelGenerator writes files to explicit paths.
38
+ def suffix
39
+ nil
40
+ end
41
+
42
+ # Path to the generated `app/models/<name>.rb` file.
43
+ def model_path
44
+ File.join("app", "models", "#{name.snake_name}.rb")
45
+ end
46
+
47
+ # Path to the generated `db/migrate/<timestamp>_create_<table>.rb` file.
48
+ def migration_path
49
+ File.join("db", "migrate", "#{timestamp}_create_#{table_name}.rb")
50
+ end
51
+
52
+ # Path to the generated `spec/models/<name>_spec.rb` file.
53
+ def spec_path
54
+ File.join("spec", "models", "#{name.snake_name}_spec.rb")
55
+ end
56
+
57
+ # The full source of the generated ActiveRecord model class.
58
+ def model
59
+ render_template("model/model.rb.template",
60
+ app_class: app_name.class_name,
61
+ model_class: name.class_name)
62
+ end
63
+
64
+ # The full source of the generated migration, with one `t.<type> :<name>` line per field.
65
+ def migration
66
+ render_template("model/migration.rb.template",
67
+ table_class: table_class_name,
68
+ table_name: table_name,
69
+ field_lines: field_lines)
70
+ end
71
+
72
+ # The full source of the generated model spec (asserts the model inherits from
73
+ # `ApplicationRecord`).
74
+ def spec
75
+ render_template("model/spec.rb.template",
76
+ app_snake: app_name.snake_name,
77
+ app_class: app_name.class_name,
78
+ model_class: name.class_name)
79
+ end
80
+
81
+ # Renders one `t.<type> :<name>` line per field, joined together.
82
+ def field_lines
83
+ fields.map { |field|
84
+ " t.#{field.type} :#{field.name}\n"
85
+ }.join
86
+ end
87
+
88
+ # Parses a single `name:type` argument. Raises Error on invalid names or unsupported types.
89
+ def parse_field(value)
90
+ field_name, type = value.split(":", 2)
91
+ raise Error, "Invalid field: #{value.inspect}" unless field_name && type
92
+ raise Error, "Invalid field name: #{field_name.inspect}" unless Name::VALID_NAME.match?(field_name)
93
+ raise Error, "Unsupported field type: #{type.inspect}" unless VALID_TYPES.include?(type)
94
+
95
+ Field.new(name: field_name, type: type)
96
+ end
97
+
98
+ # True when `config/database.rb` and `app/models/application_record.rb` both exist.
99
+ def database_configured?
100
+ File.exist?(File.join(destination, "config", "database.rb")) &&
101
+ File.exist?(File.join(destination, "app", "models", "application_record.rb"))
102
+ end
103
+
104
+ # The pluralized table name (e.g., "user" → "users", "category" → "categories").
105
+ def table_name
106
+ pluralize(name.snake_name)
107
+ end
108
+
109
+ # The CamelCase migration class name (e.g., "users" → "Users").
110
+ def table_class_name
111
+ table_name.split("_").map(&:capitalize).join
112
+ end
113
+
114
+ # Minimal English pluralization for the model name (covers the common -y, -s/x/z/ch/sh cases).
115
+ def pluralize(value)
116
+ return value.sub(/y\z/, "ies") if value.end_with?("y")
117
+ return "#{value}es" if value.match?(/(?:s|x|z|ch|sh)\z/)
118
+
119
+ "#{value}s"
120
+ end
121
+
122
+ # The current UTC timestamp in the format ActiveRecord uses for migration filenames.
123
+ def timestamp
124
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
125
+ end
126
+ end
127
+ end
128
+ end
@@ -2,28 +2,34 @@
2
2
 
3
3
  module Charming
4
4
  module Generators
5
+ # Name validates a generator resource name and exposes the conventional Ruby class-name
6
+ # variants (singular class, controller, component) derived from it. The original input
7
+ # must match `VALID_NAME` (lowercase, snake_case, must start with a letter).
5
8
  class Name
9
+ # Regex matching a valid snake_case resource name: lowercase letter, then any
10
+ # combination of lowercase letters, digits, and underscores.
6
11
  VALID_NAME = /\A[a-z][a-z0-9_]*\z/
7
12
 
13
+ # The original snake_case name as supplied.
8
14
  attr_reader :snake_name
9
15
 
16
+ # Raises Error when *value* doesn't match `VALID_NAME`.
10
17
  def initialize(value)
11
18
  @snake_name = value.to_s
12
19
  raise Error, "Invalid name: #{value}" unless VALID_NAME.match?(@snake_name)
13
20
  end
14
21
 
22
+ # The CamelCase class name (e.g., "user" → "User").
15
23
  def class_name
16
24
  snake_name.split("_").map(&:capitalize).join
17
25
  end
18
26
 
27
+ # The controller class name (e.g., "user" → "UserController").
19
28
  def controller_class_name
20
29
  "#{class_name}Controller"
21
30
  end
22
31
 
23
- def view_class_name
24
- "#{class_name}View"
25
- end
26
-
32
+ # The component class name (e.g., "user" → "UserComponent").
27
33
  def component_class_name
28
34
  "#{class_name}Component"
29
35
  end