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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/charming/application.rb +22 -5
  3. data/lib/charming/cli.rb +3 -3
  4. data/lib/charming/controller/class_methods.rb +2 -2
  5. data/lib/charming/controller/command_palette.rb +2 -2
  6. data/lib/charming/controller/component_dispatching.rb +47 -3
  7. data/lib/charming/controller/focus.rb +123 -0
  8. data/lib/charming/controller/focus_management.rb +1 -1
  9. data/lib/charming/controller/rendering.rb +6 -17
  10. data/lib/charming/controller/session_state.rb +12 -1
  11. data/lib/charming/controller.rb +11 -2
  12. data/lib/charming/database/commands.rb +106 -0
  13. data/lib/charming/generators/component_generator.rb +1 -1
  14. data/lib/charming/generators/database_installer.rb +154 -0
  15. data/lib/charming/generators/model_generator.rb +2 -10
  16. data/lib/charming/generators/name.rb +1 -1
  17. data/lib/charming/generators/templates/app/application.template +1 -1
  18. data/lib/charming/generators/templates/app/layout.template +3 -6
  19. data/lib/charming/generators/templates/app/view.template +1 -1
  20. data/lib/charming/generators/templates/component/component.rb.template +1 -1
  21. data/lib/charming/generators/templates/screen/view.rb.template +1 -1
  22. data/lib/charming/generators/templates/view/view.rb.template +1 -1
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/renderer/differential.rb +13 -5
  25. data/lib/charming/internal/terminal/tty_backend.rb +22 -2
  26. data/lib/charming/presentation/component.rb +3 -5
  27. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  28. data/lib/charming/presentation/components/command_palette.rb +94 -96
  29. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  30. data/lib/charming/presentation/components/empty_state.rb +47 -49
  31. data/lib/charming/presentation/components/form/builder.rb +52 -54
  32. data/lib/charming/presentation/components/form/confirm.rb +49 -51
  33. data/lib/charming/presentation/components/form/field.rb +94 -96
  34. data/lib/charming/presentation/components/form/input.rb +53 -55
  35. data/lib/charming/presentation/components/form/note.rb +27 -29
  36. data/lib/charming/presentation/components/form/select.rb +84 -86
  37. data/lib/charming/presentation/components/form/textarea.rb +67 -69
  38. data/lib/charming/presentation/components/form.rb +120 -122
  39. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  40. data/lib/charming/presentation/components/list.rb +123 -125
  41. data/lib/charming/presentation/components/markdown.rb +21 -23
  42. data/lib/charming/presentation/components/modal.rb +46 -48
  43. data/lib/charming/presentation/components/progressbar.rb +51 -53
  44. data/lib/charming/presentation/components/spinner.rb +40 -42
  45. data/lib/charming/presentation/components/table.rb +109 -111
  46. data/lib/charming/presentation/components/text_area.rb +219 -221
  47. data/lib/charming/presentation/components/text_input.rb +120 -122
  48. data/lib/charming/presentation/components/viewport.rb +218 -220
  49. data/lib/charming/presentation/layout/builder.rb +64 -66
  50. data/lib/charming/presentation/layout/overlay.rb +48 -50
  51. data/lib/charming/presentation/layout/pane.rb +129 -118
  52. data/lib/charming/presentation/layout/rect.rb +19 -16
  53. data/lib/charming/presentation/layout/screen_layout.rb +47 -42
  54. data/lib/charming/presentation/layout/split.rb +107 -102
  55. data/lib/charming/presentation/layout.rb +28 -30
  56. data/lib/charming/presentation/markdown/render_context.rb +30 -14
  57. data/lib/charming/presentation/markdown/renderer.rb +302 -79
  58. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  59. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -59
  60. data/lib/charming/presentation/markdown.rb +4 -6
  61. data/lib/charming/presentation/template_view.rb +22 -24
  62. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  63. data/lib/charming/presentation/templates.rb +47 -49
  64. data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
  65. data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
  66. data/lib/charming/presentation/ui/border.rb +24 -26
  67. data/lib/charming/presentation/ui/border_painter.rb +37 -39
  68. data/lib/charming/presentation/ui/canvas.rb +59 -61
  69. data/lib/charming/presentation/ui/style.rb +173 -175
  70. data/lib/charming/presentation/ui/theme.rb +133 -135
  71. data/lib/charming/presentation/ui/width.rb +12 -14
  72. data/lib/charming/presentation/ui.rb +69 -71
  73. data/lib/charming/presentation/view.rb +110 -105
  74. data/lib/charming/router.rb +3 -8
  75. data/lib/charming/runtime.rb +25 -10
  76. data/lib/charming/version.rb +1 -1
  77. data/lib/charming.rb +5 -4
  78. metadata +43 -9
  79. data/lib/charming/database_commands.rb +0 -103
  80. data/lib/charming/database_installer.rb +0 -152
  81. data/lib/charming/focus.rb +0 -121
  82. data/lib/charming/presentation/markdown/block_renderers.rb +0 -120
  83. 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
- table_name.split("_").map(&:capitalize).join
112
- end
113
-
114
- # Minimal English pluralization for the model name (covers the common -y, -s/x/z/ch/sh cases).
115
- def pluralize(value)
116
- return value.sub(/y\z/, "ies") if value.end_with?("y")
117
- return "#{value}es" if value.match?(/(?:s|x|z|ch|sh)\z/)
118
-
119
- "#{value}s"
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
- snake_name.split("_").map(&:capitalize).join
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::Presentation::UI::Theme.built_in_names.each do |theme_name|
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::Presentation::View
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
- "\#{cursor} \#{active} \#{route.title}"
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::Presentation::Components::Modal.new(
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module __APP_CLASS__
4
4
  module Home
5
- class ShowView < Charming::Presentation::View
5
+ class ShowView < Charming::View
6
6
  def render
7
7
  column(title_line, help_line, gap: 1)
8
8
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module __APP_CLASS__
4
- class __COMPONENT_CLASS__ < Charming::Presentation::Component
4
+ class __COMPONENT_CLASS__ < Charming::Component
5
5
  def render
6
6
  text "__RESOURCE_NAME__"
7
7
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module __APP_CLASS__
4
4
  module __RESOURCE_MODULE__
5
- class ShowView < Charming::Presentation::View
5
+ class ShowView < Charming::View
6
6
  def render
7
7
  __SCREEN_NAME__.title
8
8
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module __APP_CLASS__
4
4
  module __RESOURCE_MODULE__
5
- class __ACTION_VIEW_CLASS__View < Charming::Presentation::View
5
+ class __ACTION_VIEW_CLASS__View < Charming::View
6
6
  def render
7
7
  "__RESOURCE_NAME__"
8
8
  end
@@ -40,7 +40,7 @@ module Charming
40
40
 
41
41
  # CamelCase rendering of the action name (e.g., "user_settings" → "UserSettings").
42
42
  def action_class_name
43
- action.split("_").map(&:capitalize).join
43
+ ActiveSupport::Inflector.camelize(action)
44
44
  end
45
45
  end
46
46
  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 the changed lines,
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 covering the larger of the two
54
- # frames' line counts, with empty strings padding the shorter frame.
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.map do |index|
61
- [index + 1, lines[index] || ""]
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| "\e[#{row};1H\e[2K#{line}" }.join)
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| "\e[#{index + 1};1H\e[2K#{line}" }.join)
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
- module Presentation
5
- # Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
6
- # helper methods (text, box, row, column, etc.), and rendering via render.
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