charming 0.1.1 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +113 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +15 -0
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -3,13 +3,19 @@
3
3
  require "fileutils"
4
4
 
5
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.
6
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/`.
7
12
  def initialize(command, out:, destination:)
8
13
  @command = command
9
14
  @out = out
10
15
  @destination = destination
11
16
  end
12
17
 
18
+ # Dispatches the configured command. Raises Generators::Error for unknown commands.
13
19
  def run
14
20
  case command
15
21
  when "db:create" then create
@@ -23,8 +29,10 @@ module Charming
23
29
 
24
30
  private
25
31
 
32
+ # The subcommand, output stream, and app destination.
26
33
  attr_reader :command, :out, :destination
27
34
 
35
+ # Creates the SQLite database file (touch) and establishes the connection.
28
36
  def create
29
37
  load_database
30
38
  FileUtils.mkdir_p(File.dirname(database_path)) if database_path
@@ -33,18 +41,21 @@ module Charming
33
41
  out.puts "create #{relative_database_path}"
34
42
  end
35
43
 
44
+ # Runs all pending migrations from `db/migrate`.
36
45
  def migrate
37
46
  load_database
38
47
  migration_context.migrate
39
48
  out.puts "migrate db/migrate"
40
49
  end
41
50
 
51
+ # Rolls back the most recent migration.
42
52
  def rollback
43
53
  load_database
44
54
  migration_context.rollback(1)
45
55
  out.puts "rollback db/migrate"
46
56
  end
47
57
 
58
+ # Disconnects ActiveRecord, then deletes the database file.
48
59
  def drop
49
60
  load_database
50
61
  ActiveRecord::Base.connection.disconnect!
@@ -52,6 +63,7 @@ module Charming
52
63
  out.puts "drop #{relative_database_path}"
53
64
  end
54
65
 
66
+ # Loads `db/seeds.rb` (raises if missing).
55
67
  def seed
56
68
  load_database
57
69
  seed_path = File.join(destination, "db", "seeds.rb")
@@ -61,6 +73,7 @@ module Charming
61
73
  out.puts "seed db/seeds.rb"
62
74
  end
63
75
 
76
+ # Loads the app's `config/database.rb` (raises if missing) which establishes the connection.
64
77
  def load_database
65
78
  database_config = File.join(destination, "config", "database.rb")
66
79
  raise Generators::Error, "Database support is not configured. Missing config/database.rb." unless File.exist?(database_config)
@@ -68,14 +81,17 @@ module Charming
68
81
  require database_config
69
82
  end
70
83
 
84
+ # The ActiveRecord migration context rooted at `db/migrate` inside the app.
71
85
  def migration_context
72
86
  ActiveRecord::MigrationContext.new(File.join(destination, "db", "migrate"))
73
87
  end
74
88
 
89
+ # The configured database file path (nil when ActiveRecord isn't connected to a file).
75
90
  def database_path
76
91
  ActiveRecord::Base.connection_db_config.database
77
92
  end
78
93
 
94
+ # The database path relative to the app root, used for human-friendly status output.
79
95
  def relative_database_path
80
96
  return "database" unless database_path
81
97
 
@@ -3,7 +3,13 @@
3
3
  require "fileutils"
4
4
 
5
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.
6
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.
7
13
  def initialize(database, out:, destination:)
8
14
  @database = database
9
15
  @out = out
@@ -11,6 +17,9 @@ module Charming
11
17
  @app_name = Generators::Name.new(app_name_from_gemspec)
12
18
  end
13
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.
14
23
  def install
15
24
  raise Generators::Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
16
25
 
@@ -25,8 +34,11 @@ module Charming
25
34
 
26
35
  private
27
36
 
37
+ # The database adapter, status stream, app destination, and derived app name.
28
38
  attr_reader :database, :out, :destination, :app_name
29
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.
30
42
  def create_file(path, content)
31
43
  absolute_path = File.join(destination, path)
32
44
  if File.exist?(absolute_path)
@@ -39,6 +51,8 @@ module Charming
39
51
  out.puts "create #{path}"
40
52
  end
41
53
 
54
+ # Patches the gemspec to include the `db` directory in the gem files glob and to add
55
+ # activerecord + sqlite3 dependencies.
42
56
  def update_gemspec
43
57
  update_file(gemspec_path) do |current|
44
58
  updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
@@ -47,6 +61,8 @@ module Charming
47
61
  end
48
62
  end
49
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.
50
66
  def update_root_file
51
67
  update_file(root_file_path) do |current|
52
68
  updated = current
@@ -61,6 +77,8 @@ module Charming
61
77
  end
62
78
  end
63
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.
64
82
  def update_file(path)
65
83
  raise Generators::Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
66
84
 
@@ -72,6 +90,8 @@ module Charming
72
90
  out.puts "update #{relative_path(path)}"
73
91
  end
74
92
 
93
+ # Inserts a `spec.add_dependency "name", "version"` line after the `charming` dependency
94
+ # when it's not already present.
75
95
  def insert_dependency(content, gem_name, version)
76
96
  return content if content.include?(%(spec.add_dependency "#{gem_name}"))
77
97
 
@@ -79,6 +99,8 @@ module Charming
79
99
  content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
80
100
  end
81
101
 
102
+ # The contents of the new `config/database.rb` (establishes an SQLite connection to
103
+ # `db/development.sqlite3`).
82
104
  def database_config
83
105
  %(# frozen_string_literal: true
84
106
 
@@ -95,6 +117,7 @@ ActiveRecord::Base.establish_connection(
95
117
  )
96
118
  end
97
119
 
120
+ # The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
98
121
  def application_record
99
122
  %(# frozen_string_literal: true
100
123
 
@@ -106,18 +129,22 @@ end
106
129
  )
107
130
  end
108
131
 
132
+ # Reads the app's gemspec filename to derive the app name.
109
133
  def app_name_from_gemspec
110
134
  File.basename(gemspec_path, ".gemspec")
111
135
  end
112
136
 
137
+ # The path to the app's gemspec (raises when not found).
113
138
  def gemspec_path
114
139
  @gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Generators::Error, "Run this command from a Charming app root")
115
140
  end
116
141
 
142
+ # The path to the app's root loader file (`lib/<app_name>.rb`).
117
143
  def root_file_path
118
144
  File.join(destination, "lib", "#{app_name.snake_name}.rb")
119
145
  end
120
146
 
147
+ # Strips the app destination prefix from *path* for human-friendly status output.
121
148
  def relative_path(path)
122
149
  path.delete_prefix("#{destination}/")
123
150
  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
@@ -2,94 +2,170 @@
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 DatabaseTemplates
10
- include LayoutTemplate
11
- include StateTemplates
12
- include ScreenSpecTemplates
13
- include ViewTemplate
14
- include AppSpecTemplates
15
-
11
+ # The list of [relative-path, template-path, executable-flag] triples to render
12
+ # for a non-database app.
16
13
  BASE_FILE_TEMPLATES = [
17
- ["Gemfile", :gemfile],
18
- ["Rakefile", :rakefile],
19
- ["README.md", :readme],
20
- ["%<name>s.gemspec", :gemspec],
21
- ["exe/%<name>s", :executable],
22
- ["lib/%<name>s.rb", :root_file],
23
- ["lib/%<name>s/application.rb", :application],
24
- ["lib/%<name>s/version.rb", :version],
25
- ["config/routes.rb", :routes],
26
- ["app/state/application_state.rb", :application_state],
27
- ["app/state/home_state.rb", :home_state],
28
- ["app/controllers/application_controller.rb", :application_controller],
29
- ["app/controllers/home_controller.rb", :controller],
30
- ["app/views/layouts/application.tui.erb", :layout],
31
- ["app/views/home/show.tui.erb", :view],
32
- ["app/components/app_frame_component.rb", :component],
33
- ["spec/spec_helper.rb", :spec_helper],
34
- ["spec/state/home_state_spec.rb", :spec_state],
35
- ["spec/controllers/home_controller_spec.rb", :spec_controller],
36
- ["spec/views/home/show_template_spec.rb", :spec_view],
37
- ["spec/components/app_frame_component_spec.rb", :spec_component]
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]
38
34
  ].freeze
39
35
 
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.
40
38
  DATABASE_FILE_TEMPLATES = [
41
- ["config/database.rb", :database_config],
42
- ["app/models/application_record.rb", :application_record],
43
- ["db/migrate/.keep", :keep],
44
- ["db/seeds.rb", :seeds]
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]
45
43
  ].freeze
46
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.
47
48
  def initialize(name, out:, destination:, force: false, database: nil)
48
49
  super(out: out, destination: File.join(destination, name), force: force)
49
50
  @name = Name.new(name)
50
51
  @database = database
51
52
  end
52
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.
53
56
  def generate
54
- file_templates.each do |path, template|
55
- 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)
56
59
  end
57
60
  initialize_git_repository
58
61
  end
59
62
 
60
63
  private
61
64
 
65
+ # The resource name and the database adapter name (or nil).
62
66
  attr_reader :name, :database
63
67
  alias_method :app_name, :name
64
68
 
69
+ # True when the database template set should be rendered.
65
70
  def database?
66
71
  !!database
67
72
  end
68
73
 
74
+ # Returns the template list: base only, or base + database extras.
69
75
  def file_templates
70
76
  database? ? BASE_FILE_TEMPLATES + DATABASE_FILE_TEMPLATES : BASE_FILE_TEMPLATES
71
77
  end
72
78
 
79
+ # Substitutes `name.snake_name` into a relative-path template (paths use `%<name>s`).
73
80
  def file_path(path)
74
81
  format(path, name: name.snake_name)
75
82
  end
76
83
 
77
- def routes
78
- %(# 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
79
89
 
80
- #{name.class_name}::Application.routes do
81
- root "home#show"
82
- end
83
- )
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
104
+
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}"
129
+ end
130
+
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\"" : ""
84
158
  end
85
159
 
86
- def spec_helper
87
- %(# frozen_string_literal: true
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?
88
164
 
89
- require "#{name.snake_name}"
90
- )
165
+ "loader.push_dir(File.expand_path(\"../app/models\", __dir__), namespace: #{name.class_name})\n"
91
166
  end
92
167
 
168
+ # Initializes a git repository in the new app's directory. Raises Error on failure.
93
169
  def initialize_git_repository
94
170
  unless system("git", "init", chdir: destination, out: File::NULL, err: File::NULL)
95
171
  raise Error, "Could not initialize git repository"
@@ -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::Presentation::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