charming 0.1.2 → 0.1.4
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/lib/charming/application.rb +22 -5
- data/lib/charming/cli.rb +3 -3
- data/lib/charming/controller/class_methods.rb +2 -2
- data/lib/charming/controller/command_palette.rb +2 -2
- data/lib/charming/controller/component_dispatching.rb +47 -3
- data/lib/charming/controller/focus.rb +123 -0
- data/lib/charming/controller/focus_management.rb +1 -1
- data/lib/charming/controller/rendering.rb +6 -17
- data/lib/charming/controller/session_state.rb +12 -1
- data/lib/charming/controller.rb +11 -2
- data/lib/charming/database/commands.rb +106 -0
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/database_installer.rb +154 -0
- data/lib/charming/generators/model_generator.rb +2 -10
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/templates/app/application.template +1 -1
- data/lib/charming/generators/templates/app/layout.template +3 -6
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/generators/templates/component/component.rb.template +1 -1
- data/lib/charming/generators/templates/screen/view.rb.template +1 -1
- data/lib/charming/generators/templates/view/view.rb.template +1 -1
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/internal/renderer/differential.rb +13 -5
- data/lib/charming/internal/terminal/tty_backend.rb +22 -2
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -49
- data/lib/charming/presentation/components/form/builder.rb +52 -54
- data/lib/charming/presentation/components/form/confirm.rb +49 -51
- data/lib/charming/presentation/components/form/field.rb +94 -96
- data/lib/charming/presentation/components/form/input.rb +53 -55
- data/lib/charming/presentation/components/form/note.rb +27 -29
- data/lib/charming/presentation/components/form/select.rb +84 -86
- data/lib/charming/presentation/components/form/textarea.rb +67 -69
- data/lib/charming/presentation/components/form.rb +120 -122
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -125
- data/lib/charming/presentation/components/markdown.rb +21 -23
- data/lib/charming/presentation/components/modal.rb +46 -48
- data/lib/charming/presentation/components/progressbar.rb +51 -53
- data/lib/charming/presentation/components/spinner.rb +40 -42
- data/lib/charming/presentation/components/table.rb +109 -111
- data/lib/charming/presentation/components/text_area.rb +219 -221
- data/lib/charming/presentation/components/text_input.rb +120 -122
- data/lib/charming/presentation/components/viewport.rb +218 -220
- data/lib/charming/presentation/layout/builder.rb +64 -66
- data/lib/charming/presentation/layout/overlay.rb +48 -50
- data/lib/charming/presentation/layout/pane.rb +129 -118
- data/lib/charming/presentation/layout/rect.rb +19 -16
- data/lib/charming/presentation/layout/screen_layout.rb +47 -42
- data/lib/charming/presentation/layout/split.rb +107 -102
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/render_context.rb +30 -14
- data/lib/charming/presentation/markdown/renderer.rb +302 -79
- data/lib/charming/presentation/markdown/style_config.rb +215 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -59
- data/lib/charming/presentation/markdown.rb +4 -6
- data/lib/charming/presentation/template_view.rb +22 -24
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -49
- data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
- data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +37 -39
- data/lib/charming/presentation/ui/canvas.rb +59 -61
- data/lib/charming/presentation/ui/style.rb +173 -175
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +69 -71
- data/lib/charming/presentation/view.rb +110 -105
- data/lib/charming/router.rb +3 -8
- data/lib/charming/runtime.rb +25 -10
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +5 -4
- metadata +43 -9
- data/lib/charming/database_commands.rb +0 -103
- data/lib/charming/database_installer.rb +0 -152
- data/lib/charming/focus.rb +0 -121
- data/lib/charming/presentation/markdown/block_renderers.rb +0 -120
- data/lib/charming/presentation/markdown/inline_renderers.rb +0 -68
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Generators
|
|
7
|
+
# DatabaseInstaller implements `charming db:install sqlite3`. It adds database support
|
|
8
|
+
# to an existing Charming app by creating `config/database.rb`, `app/models/application_record.rb`,
|
|
9
|
+
# `db/migrate/`, and `db/seeds.rb`, and patching the gemspec and root loader to include
|
|
10
|
+
# the new dependencies and the `app/models` autoload directory.
|
|
11
|
+
class DatabaseInstaller
|
|
12
|
+
# *database* is the adapter name (only "sqlite3" is currently supported). *out* is the
|
|
13
|
+
# status-output stream. *destination* is the app root.
|
|
14
|
+
def initialize(database, out:, destination:)
|
|
15
|
+
@database = database
|
|
16
|
+
@out = out
|
|
17
|
+
@destination = destination
|
|
18
|
+
@app_name = Name.new(app_name_from_gemspec)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Performs the install: writes the database config, application record, migrate directory,
|
|
22
|
+
# seeds file, and patches the gemspec + root loader. Idempotent: existing files are
|
|
23
|
+
# reported with "exist <path>" instead of being overwritten.
|
|
24
|
+
def install
|
|
25
|
+
raise Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
|
|
26
|
+
|
|
27
|
+
create_file("config/database.rb", database_config)
|
|
28
|
+
create_file("app/models/application_record.rb", application_record)
|
|
29
|
+
create_file("db/migrate/.keep", "")
|
|
30
|
+
create_file("db/seeds.rb", %(# frozen_string_literal: true
|
|
31
|
+
))
|
|
32
|
+
update_gemspec
|
|
33
|
+
update_root_file
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The database adapter, status stream, app destination, and derived app name.
|
|
39
|
+
attr_reader :database, :out, :destination, :app_name
|
|
40
|
+
|
|
41
|
+
# Writes *content* to *path* (relative to the app root), creating intermediate directories.
|
|
42
|
+
# Reports "exist <path>" without overwriting when the file already exists.
|
|
43
|
+
def create_file(path, content)
|
|
44
|
+
absolute_path = File.join(destination, path)
|
|
45
|
+
if File.exist?(absolute_path)
|
|
46
|
+
out.puts "exist #{path}"
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(absolute_path))
|
|
51
|
+
File.write(absolute_path, content)
|
|
52
|
+
out.puts "create #{path}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Patches the gemspec to include the `db` directory in the gem files glob and to add
|
|
56
|
+
# activerecord + sqlite3 dependencies.
|
|
57
|
+
def update_gemspec
|
|
58
|
+
update_file(gemspec_path) do |current|
|
|
59
|
+
updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
|
|
60
|
+
updated = insert_dependency(updated, "activerecord", "~> 8.1")
|
|
61
|
+
insert_dependency(updated, "sqlite3", "~> 2.0")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Patches the root loader file (`lib/<app>.rb`) to require `config/database` and to push
|
|
66
|
+
# the `app/models` autoload directory. Both edits are no-ops when already applied.
|
|
67
|
+
def update_root_file
|
|
68
|
+
update_file(root_file_path) do |current|
|
|
69
|
+
updated = current
|
|
70
|
+
updated = updated.sub(%(require "zeitwerk"\n), %(require "zeitwerk"\nrequire_relative "../config/database"\n)) unless updated.include?(%(require_relative "../config/database"))
|
|
71
|
+
unless updated.include?(%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})])
|
|
72
|
+
updated = updated.sub(
|
|
73
|
+
%[loader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n],
|
|
74
|
+
%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})\nloader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
updated
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reads *path*, yields its contents to the block, and writes the result back when it
|
|
82
|
+
# differs. Raises Error when the file is missing.
|
|
83
|
+
def update_file(path)
|
|
84
|
+
raise Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
|
|
85
|
+
|
|
86
|
+
current = File.read(path)
|
|
87
|
+
updated = yield current
|
|
88
|
+
return if updated == current
|
|
89
|
+
|
|
90
|
+
File.write(path, updated)
|
|
91
|
+
out.puts "update #{relative_path(path)}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Inserts a `spec.add_dependency "name", "version"` line after the `charming` dependency
|
|
95
|
+
# when it's not already present.
|
|
96
|
+
def insert_dependency(content, gem_name, version)
|
|
97
|
+
return content if content.include?(%(spec.add_dependency "#{gem_name}"))
|
|
98
|
+
|
|
99
|
+
dependency = %( spec.add_dependency "#{gem_name}", "#{version}")
|
|
100
|
+
content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The contents of the new `config/database.rb` (establishes an SQLite connection to
|
|
104
|
+
# `db/development.sqlite3`).
|
|
105
|
+
def database_config
|
|
106
|
+
%(# frozen_string_literal: true
|
|
107
|
+
|
|
108
|
+
require "active_record"
|
|
109
|
+
require "fileutils"
|
|
110
|
+
|
|
111
|
+
database_path = File.expand_path("../db/development.sqlite3", __dir__)
|
|
112
|
+
FileUtils.mkdir_p(File.dirname(database_path))
|
|
113
|
+
|
|
114
|
+
ActiveRecord::Base.establish_connection(
|
|
115
|
+
adapter: "sqlite3",
|
|
116
|
+
database: database_path
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
|
|
122
|
+
def application_record
|
|
123
|
+
%(# frozen_string_literal: true
|
|
124
|
+
|
|
125
|
+
module #{app_name.class_name}
|
|
126
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
127
|
+
self.abstract_class = true
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Reads the app's gemspec filename to derive the app name.
|
|
134
|
+
def app_name_from_gemspec
|
|
135
|
+
File.basename(gemspec_path, ".gemspec")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The path to the app's gemspec (raises when not found).
|
|
139
|
+
def gemspec_path
|
|
140
|
+
@gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Error, "Run this command from a Charming app root")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The path to the app's root loader file (`lib/<app_name>.rb`).
|
|
144
|
+
def root_file_path
|
|
145
|
+
File.join(destination, "lib", "#{app_name.snake_name}.rb")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Strips the app destination prefix from *path* for human-friendly status output.
|
|
149
|
+
def relative_path(path)
|
|
150
|
+
path.delete_prefix("#{destination}/")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -103,20 +103,12 @@ module Charming
|
|
|
103
103
|
|
|
104
104
|
# The pluralized table name (e.g., "user" → "users", "category" → "categories").
|
|
105
105
|
def table_name
|
|
106
|
-
pluralize(name.snake_name)
|
|
106
|
+
ActiveSupport::Inflector.pluralize(name.snake_name)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
# The CamelCase migration class name (e.g., "users" → "Users").
|
|
110
110
|
def table_class_name
|
|
111
|
-
|
|
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"
|
|
111
|
+
ActiveSupport::Inflector.camelize(table_name)
|
|
120
112
|
end
|
|
121
113
|
|
|
122
114
|
# The current UTC timestamp in the format ActiveRecord uses for migration filenames.
|
|
@@ -21,7 +21,7 @@ module Charming
|
|
|
21
21
|
|
|
22
22
|
# The CamelCase class name (e.g., "user" → "User").
|
|
23
23
|
def class_name
|
|
24
|
-
|
|
24
|
+
ActiveSupport::Inflector.camelize(snake_name)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# The controller class name (e.g., "user" → "UserController").
|
|
@@ -4,7 +4,7 @@ module __APP_CLASS__
|
|
|
4
4
|
class Application < Charming::Application
|
|
5
5
|
root File.expand_path("../..", __dir__)
|
|
6
6
|
|
|
7
|
-
Charming::
|
|
7
|
+
Charming::UI::Theme.built_in_names.each do |theme_name|
|
|
8
8
|
theme theme_name.to_sym, built_in: theme_name
|
|
9
9
|
end
|
|
10
10
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module __APP_CLASS__
|
|
4
4
|
module Layouts
|
|
5
|
-
class ApplicationLayout < Charming::
|
|
5
|
+
class ApplicationLayout < Charming::View
|
|
6
6
|
def render
|
|
7
7
|
screen_layout(background: theme.background) do
|
|
8
8
|
split(narrow? ? :vertical : :horizontal, gap: 1) do
|
|
@@ -54,7 +54,7 @@ module __APP_CLASS__
|
|
|
54
54
|
def nav_item_label(route, index)
|
|
55
55
|
cursor = (sidebar_focused? && index == sidebar_index) ? ">" : " "
|
|
56
56
|
active = current_route?(route) ? "\u{25cf}" : " "
|
|
57
|
-
"
|
|
57
|
+
"#{cursor} #{active} #{route.title}"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def nav_item_style(route, index)
|
|
@@ -84,11 +84,8 @@ module __APP_CLASS__
|
|
|
84
84
|
def command_palette_modal
|
|
85
85
|
return unless palette_component
|
|
86
86
|
|
|
87
|
-
render_component Charming::
|
|
87
|
+
render_component Charming::Components::CommandPaletteModal.new(
|
|
88
88
|
content: palette_component,
|
|
89
|
-
title: "Command palette",
|
|
90
|
-
help: "Type to filter. Enter selects. Escape closes.",
|
|
91
|
-
width: 52,
|
|
92
89
|
theme: theme
|
|
93
90
|
)
|
|
94
91
|
end
|
|
@@ -27,6 +27,12 @@ module Charming
|
|
|
27
27
|
render_changes(frame)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Discards the cached previous frame so the next render performs a full repaint.
|
|
31
|
+
# Call this when the screen contents are no longer trustworthy (e.g. terminal resize).
|
|
32
|
+
def invalidate
|
|
33
|
+
@previous_frame = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
30
36
|
private
|
|
31
37
|
|
|
32
38
|
# Performs the initial full repaint and records the first frame.
|
|
@@ -35,7 +41,7 @@ module Charming
|
|
|
35
41
|
@previous_frame = frame
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
# Computes the per-line diff against the previous frame, writes
|
|
44
|
+
# Computes the per-line diff against the previous frame, writes only changed lines,
|
|
39
45
|
# and records the new frame. Falls back to a full repaint when the output backend
|
|
40
46
|
# doesn't support partial writes.
|
|
41
47
|
def render_changes(frame)
|
|
@@ -50,15 +56,17 @@ module Charming
|
|
|
50
56
|
@previous_frame = frame
|
|
51
57
|
end
|
|
52
58
|
|
|
53
|
-
# Returns an array of [1-based-row, line] tuples
|
|
54
|
-
#
|
|
59
|
+
# Returns an array of [1-based-row, line] tuples for rows whose content changed.
|
|
60
|
+
# Empty strings clear rows that existed in the previous frame but not the new one.
|
|
55
61
|
def changed_lines(previous_frame, frame)
|
|
56
62
|
previous_lines = previous_frame.lines(chomp: true)
|
|
57
63
|
lines = frame.lines(chomp: true)
|
|
58
64
|
line_count = [previous_lines.length, lines.length].max
|
|
59
65
|
|
|
60
|
-
line_count.times.
|
|
61
|
-
|
|
66
|
+
line_count.times.filter_map do |index|
|
|
67
|
+
line = lines[index] || ""
|
|
68
|
+
previous_line = previous_lines[index] || ""
|
|
69
|
+
[index + 1, line] unless line == previous_line
|
|
62
70
|
end
|
|
63
71
|
end
|
|
64
72
|
end
|
|
@@ -51,6 +51,20 @@ module Charming
|
|
|
51
51
|
nil
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
|
|
55
|
+
# single keypress in raw mode is not enough: keys pressed while rendering or dispatching
|
|
56
|
+
# events can otherwise be echoed into the alternate screen before the next read.
|
|
57
|
+
def with_raw_input
|
|
58
|
+
return yield unless @input.respond_to?(:tty?) && @input.tty?
|
|
59
|
+
return yield unless @input.respond_to?(:raw) && @input.respond_to?(:noecho)
|
|
60
|
+
|
|
61
|
+
@input.raw do
|
|
62
|
+
@input.noecho do
|
|
63
|
+
yield
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
# Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
|
|
55
69
|
# the previous handler so it can be restored on teardown.
|
|
56
70
|
def install_resize_handler
|
|
@@ -120,7 +134,7 @@ module Charming
|
|
|
120
134
|
# Writes a partial frame composed of [row, line] tuples (1-based rows).
|
|
121
135
|
def write_lines(line_changes, **)
|
|
122
136
|
without_auto_wrap do
|
|
123
|
-
write_control(line_changes.map { |row, line|
|
|
137
|
+
write_control(line_changes.map { |row, line| positioned_line(row, line) }.join)
|
|
124
138
|
end
|
|
125
139
|
end
|
|
126
140
|
|
|
@@ -180,7 +194,13 @@ module Charming
|
|
|
180
194
|
# Writes *lines* one row at a time, with each line preceded by an ANSI cursor
|
|
181
195
|
# position and a clear-to-end-of-line sequence.
|
|
182
196
|
def write_positioned_lines(lines)
|
|
183
|
-
write_control(lines.each_with_index.map { |line, index|
|
|
197
|
+
write_control(lines.each_with_index.map { |line, index| positioned_line(index + 1, line) }.join)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Resets SGR before and after each row so partial repaint rows cannot inherit
|
|
201
|
+
# colors/backgrounds from the previous physical terminal line.
|
|
202
|
+
def positioned_line(row, line)
|
|
203
|
+
"\e[0m\e[#{row};1H\e[2K#{line}\e[0m"
|
|
184
204
|
end
|
|
185
205
|
|
|
186
206
|
# Disables auto-wrap, yields, then re-enables it and flushes the output.
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Component < View
|
|
8
|
-
end
|
|
4
|
+
# Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
|
|
5
|
+
# helper methods (text, box, row, column, etc.), and rendering via render.
|
|
6
|
+
class Component < View
|
|
9
7
|
end
|
|
10
8
|
end
|