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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- 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
|
data/lib/charming/focus.rb
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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",
|
|
18
|
-
["Rakefile",
|
|
19
|
-
["README.md",
|
|
20
|
-
["%<name>s.gemspec",
|
|
21
|
-
["exe/%<name>s",
|
|
22
|
-
["lib/%<name>s.rb",
|
|
23
|
-
["lib/%<name>s/application.rb",
|
|
24
|
-
["lib/%<name>s/version.rb",
|
|
25
|
-
["config/routes.rb",
|
|
26
|
-
["app/state/application_state.rb",
|
|
27
|
-
["app/state/home_state.rb",
|
|
28
|
-
["app/controllers/application_controller.rb",
|
|
29
|
-
["app/controllers/home_controller.rb",
|
|
30
|
-
["app/views/layouts/
|
|
31
|
-
["app/views/home/
|
|
32
|
-
["app/components/
|
|
33
|
-
["spec/spec_helper.rb",
|
|
34
|
-
["spec/state/home_state_spec.rb",
|
|
35
|
-
["spec/controllers/home_controller_spec.rb",
|
|
36
|
-
["spec/views/home/
|
|
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",
|
|
42
|
-
["app/models/application_record.rb",
|
|
43
|
-
["db/migrate/.keep",
|
|
44
|
-
["db/seeds.rb",
|
|
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,
|
|
55
|
-
create_file(file_path(path),
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|