charming 0.1.0 → 0.1.1

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +3 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +39 -3
  6. data/lib/charming/controller.rb +146 -24
  7. data/lib/charming/database_commands.rb +87 -0
  8. data/lib/charming/database_installer.rb +125 -0
  9. data/lib/charming/events/key_event.rb +15 -0
  10. data/lib/charming/events/mouse_event.rb +42 -0
  11. data/lib/charming/events/resize_event.rb +9 -0
  12. data/lib/charming/events/task_event.rb +19 -0
  13. data/lib/charming/events/timer_event.rb +9 -0
  14. data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
  15. data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
  16. data/lib/charming/generators/app_generator/component_templates.rb +1 -1
  17. data/lib/charming/generators/app_generator/controller_template.rb +3 -12
  18. data/lib/charming/generators/app_generator/database_templates.rb +45 -0
  19. data/lib/charming/generators/app_generator/layout_template.rb +51 -145
  20. data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
  21. data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
  22. data/lib/charming/generators/app_generator/view_template.rb +12 -18
  23. data/lib/charming/generators/app_generator.rb +37 -11
  24. data/lib/charming/generators/component_generator.rb +1 -1
  25. data/lib/charming/generators/controller_generator.rb +1 -4
  26. data/lib/charming/generators/model_generator.rb +119 -0
  27. data/lib/charming/generators/name.rb +0 -4
  28. data/lib/charming/generators/screen_generator.rb +14 -28
  29. data/lib/charming/generators/view_generator.rb +11 -14
  30. data/lib/charming/internal/renderer/differential.rb +2 -3
  31. data/lib/charming/internal/terminal/tty_backend.rb +25 -8
  32. data/lib/charming/presentation/component.rb +10 -0
  33. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  34. data/lib/charming/presentation/components/command_palette.rb +120 -0
  35. data/lib/charming/presentation/components/empty_state.rb +43 -0
  36. data/lib/charming/presentation/components/form/builder.rb +48 -0
  37. data/lib/charming/presentation/components/form/confirm.rb +56 -0
  38. data/lib/charming/presentation/components/form/field.rb +96 -0
  39. data/lib/charming/presentation/components/form/input.rb +57 -0
  40. data/lib/charming/presentation/components/form/note.rb +32 -0
  41. data/lib/charming/presentation/components/form/select.rb +89 -0
  42. data/lib/charming/presentation/components/form/textarea.rb +70 -0
  43. data/lib/charming/presentation/components/form.rb +127 -0
  44. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  45. data/lib/charming/presentation/components/list.rb +104 -0
  46. data/lib/charming/presentation/components/markdown.rb +25 -0
  47. data/lib/charming/presentation/components/modal.rb +50 -0
  48. data/lib/charming/presentation/components/progressbar.rb +57 -0
  49. data/lib/charming/presentation/components/spinner.rb +39 -0
  50. data/lib/charming/presentation/components/table.rb +118 -0
  51. data/lib/charming/presentation/components/text_area.rb +219 -0
  52. data/lib/charming/presentation/components/text_input.rb +105 -0
  53. data/lib/charming/presentation/components/viewport.rb +220 -0
  54. data/lib/charming/presentation/layout.rb +43 -0
  55. data/lib/charming/presentation/markdown/renderer.rb +203 -0
  56. data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
  57. data/lib/charming/presentation/markdown.rb +8 -0
  58. data/lib/charming/presentation/template_view.rb +27 -0
  59. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  60. data/lib/charming/presentation/templates.rb +51 -0
  61. data/lib/charming/presentation/ui/border.rb +35 -0
  62. data/lib/charming/presentation/ui/style.rb +246 -0
  63. data/lib/charming/presentation/ui/theme.rb +180 -0
  64. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  65. data/lib/charming/presentation/ui/width.rb +26 -0
  66. data/lib/charming/presentation/ui.rb +232 -0
  67. data/lib/charming/presentation/view.rb +118 -0
  68. data/lib/charming/runtime.rb +7 -7
  69. data/lib/charming/screen.rb +5 -1
  70. data/lib/charming/tasks/inline_executor.rb +28 -0
  71. data/lib/charming/tasks/task.rb +9 -0
  72. data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
  73. data/lib/charming/version.rb +1 -1
  74. data/lib/charming.rb +4 -0
  75. metadata +114 -29
  76. data/lib/charming/component.rb +0 -8
  77. data/lib/charming/components/activity_indicator.rb +0 -158
  78. data/lib/charming/components/command_palette.rb +0 -118
  79. data/lib/charming/components/keyboard_handler.rb +0 -22
  80. data/lib/charming/components/list.rb +0 -105
  81. data/lib/charming/components/modal.rb +0 -48
  82. data/lib/charming/components/progressbar.rb +0 -55
  83. data/lib/charming/components/spinner.rb +0 -37
  84. data/lib/charming/components/table.rb +0 -115
  85. data/lib/charming/components/text_input.rb +0 -103
  86. data/lib/charming/components/viewport.rb +0 -191
  87. data/lib/charming/key_event.rb +0 -13
  88. data/lib/charming/mouse_event.rb +0 -40
  89. data/lib/charming/resize_event.rb +0 -7
  90. data/lib/charming/task.rb +0 -7
  91. data/lib/charming/task_event.rb +0 -17
  92. data/lib/charming/timer_event.rb +0 -7
  93. data/lib/charming/ui/border.rb +0 -33
  94. data/lib/charming/ui/style.rb +0 -244
  95. data/lib/charming/ui/theme.rb +0 -178
  96. data/lib/charming/ui/width.rb +0 -24
  97. data/lib/charming/ui.rb +0 -230
  98. data/lib/charming/view.rb +0 -116
  99. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -21,7 +21,7 @@ module Charming
21
21
  # Registers a command palette entry — visible in fuzzy search when Ctrl+K is pressed.
22
22
  # Accepts either a method symbol or an inline callable block.
23
23
  def command(label, action = nil, &block)
24
- command_bindings << Components::CommandPalette::Command.new(label: label, value: block || action)
24
+ command_bindings << Presentation::Components::CommandPalette::Command.new(label: label, value: block || action)
25
25
  end
26
26
 
27
27
  # Registers a periodic timer that fires at `every`-second intervals.
@@ -35,6 +35,19 @@ module Charming
35
35
  task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
36
36
  end
37
37
 
38
+ # Re-renders the given action after dispatched actions that do not set a response.
39
+ # This is opt-in so existing controllers keep explicit render semantics.
40
+ def auto_render(action = :show)
41
+ @auto_render_action = action.to_sym
42
+ end
43
+
44
+ def auto_render_action
45
+ return @auto_render_action if instance_variable_defined?(:@auto_render_action)
46
+ return superclass.auto_render_action if superclass.respond_to?(:auto_render_action)
47
+
48
+ nil
49
+ end
50
+
38
51
  # Sets the layout class to wrap this controller's rendered output (e.g., for sidebar + main content).
39
52
  # Accepts a special `:__charming_layout_reader__` sentinel to query — without setting — the current layout.
40
53
  def layout(layout_class = :__charming_layout_reader__)
@@ -100,15 +113,16 @@ module Charming
100
113
  end
101
114
  end
102
115
 
103
- attr_reader :application, :event, :params, :screen
116
+ attr_reader :application, :event, :params, :screen, :route
104
117
 
105
118
  # Initializes the controller with its parent application and an optional event (key/mouse/timer/task data).
106
119
  # Defaults to a 80x24 screen when no backend size is available.
107
- def initialize(application:, event: nil, params: {}, screen: nil)
120
+ def initialize(application:, event: nil, params: {}, screen: nil, route: nil)
108
121
  @application = application
109
122
  @event = event
110
123
  @params = params
111
124
  @screen = screen || Screen.new(width: 80, height: 24)
125
+ @route = route
112
126
  @response = nil
113
127
  end
114
128
 
@@ -116,6 +130,7 @@ module Charming
116
130
  # returning a default empty render if the action produces no response.
117
131
  def dispatch(action)
118
132
  public_send(action)
133
+ render_default_action if response.nil? && auto_render_after?(action)
119
134
  response || render("")
120
135
  end
121
136
 
@@ -158,12 +173,17 @@ module Charming
158
173
  dispatch_component_mouse
159
174
  end
160
175
 
161
- # Renders `body` wrapped in this controller's layout (if one is defined) and stores the response.
162
- # If no layout is set, renders body bare. Called by controllers after rendering a view.
163
- def render(body = "")
176
+ # Renders a body or template wrapped in this controller's layout (if one is defined) and stores the response.
177
+ # Symbols render `app/views/<controller>/<symbol>.tui.erb` (or `.txt.erb`); strings render as literal bodies.
178
+ def render(body = "", **assigns)
179
+ body = template_body(default_template_name(body), **assigns) if body.is_a?(Symbol)
164
180
  @response = Response.render(render_with_layout(body))
165
181
  end
166
182
 
183
+ def render_template(name, **assigns)
184
+ @response = Response.render(render_with_layout(template_body(name, **assigns)))
185
+ end
186
+
167
187
  def theme
168
188
  application.theme
169
189
  end
@@ -194,11 +214,19 @@ module Charming
194
214
  application.session
195
215
  end
196
216
 
197
- # Lazily instantiates a model class and caches it in the session under `:models`.
198
- # Subsequent calls with the same name return the cached instance. Used like: model(:user, UserModel)
199
- def model(name, model_class, **attributes)
200
- session[:models] ||= {}
201
- session[:models][name.to_sym] ||= model_class.new(**attributes)
217
+ # Lazily instantiates a state class and caches it in the session under `:states`.
218
+ # Subsequent calls with the same name return the cached instance. Used like: state(:home, HomeState)
219
+ def state(name, state_class, **attributes)
220
+ session[:states] ||= {}
221
+ session[:states][name.to_sym] ||= state_class.new(**attributes)
222
+ end
223
+
224
+ def form(name, &block)
225
+ session[:forms] ||= {}
226
+ form_state = session[:forms][name.to_sym] ||= {}
227
+ builder = Presentation::Components::Form::Builder.new(theme: theme)
228
+ block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
229
+ builder.build(state: form_state, theme: theme)
202
230
  end
203
231
 
204
232
  # Submits an async task to the application's task executor (threaded or inline).
@@ -290,12 +318,24 @@ module Charming
290
318
  session[:sidebar_index] || current_route_index
291
319
  end
292
320
 
321
+ def sidebar_routes
322
+ application.routes.all
323
+ end
324
+
325
+ def current_route?(candidate)
326
+ return candidate.controller_class == self.class && candidate.action == :show unless route
327
+
328
+ candidate.path == route.path &&
329
+ candidate.controller_class == route.controller_class &&
330
+ candidate.action == route.action
331
+ end
332
+
293
333
  private
294
334
 
295
335
  # Finds the position of this controller among all registered routes (for sidebar highlighting).
296
336
  # Returns 0 if no matching route is found.
297
337
  def current_route_index
298
- application.routes.all.index { |route| route.controller_class == self.class && route.action == :show } || 0
338
+ sidebar_routes.index { |candidate| current_route?(candidate) } || 0
299
339
  end
300
340
 
301
341
  # Checks whether the given slot is registered as a focus ring slot for this controller.
@@ -310,14 +350,21 @@ module Charming
310
350
  body.respond_to?(:render) ? body.render.to_s : body.to_s
311
351
  end
312
352
 
313
- # Wraps `body` rendering in this controller's layout class (if one is defined).
353
+ # Wraps `body` rendering in this controller's layout (if one is defined).
314
354
  # If no layout is set, returns body as-is. Provides content, screen, and controller to the layout for composition.
315
355
  def render_with_layout(body)
316
356
  rendered = render_body(body)
317
- layout_class = self.class.layout
318
- return rendered unless layout_class
357
+ layout = self.class.layout
358
+ return rendered unless layout
359
+
360
+ render_body(layout_body(layout, body, rendered))
361
+ end
362
+
363
+ def layout_body(layout, body, rendered)
364
+ assigns = layout_assigns(body, rendered)
365
+ return template_body(layout, **assigns) if layout.is_a?(String) || layout.is_a?(Symbol)
319
366
 
320
- render_body(layout_class.new(**layout_assigns(body, rendered)))
367
+ layout.new(**assigns)
321
368
  end
322
369
 
323
370
  # Provides view assigns for layout rendering: merges body-specific assigns with standard `content`, `screen`, and `controller`.
@@ -331,6 +378,41 @@ module Charming
331
378
  body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
332
379
  end
333
380
 
381
+ def template_body(name, **assigns)
382
+ Presentation::TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
383
+ end
384
+
385
+ def resolve_template(name)
386
+ Presentation::Templates.resolve(name, root: application.class.root)
387
+ end
388
+
389
+ def template_assigns(assigns)
390
+ {screen: screen, controller: self, theme: theme}.merge(assigns)
391
+ end
392
+
393
+ def template_namespace
394
+ namespace_name = application.class.namespace
395
+ return nil if namespace_name.to_s.empty?
396
+
397
+ Object.const_get(namespace_name)
398
+ end
399
+
400
+ def default_template_name(action)
401
+ "#{controller_template_path}/#{action}"
402
+ end
403
+
404
+ def controller_template_path
405
+ underscore(self.class.name.split("::").last.delete_suffix("Controller"))
406
+ end
407
+
408
+ def underscore(value)
409
+ value
410
+ .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
411
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
412
+ .tr("-", "_")
413
+ .downcase
414
+ end
415
+
334
416
  # Extracts the normalized key from the current event, handling both KeyEvent objects and raw key strings.
335
417
  # Delegates to `Charming.key_of(event)` for event-to-key resolution.
336
418
  def key_name
@@ -349,10 +431,40 @@ module Charming
349
431
  result = component.handle_key(event)
350
432
  return nil if result.nil?
351
433
 
352
- render_default_action
434
+ dispatch_component_result(slot, result)
353
435
  :handled
354
436
  end
355
437
 
438
+ def dispatch_component_result(slot, result)
439
+ action, arguments = component_result_action(slot, result)
440
+ action ? send(action, *arguments) : render_default_action
441
+ render_default_action unless response
442
+ end
443
+
444
+ def component_result_action(slot, result)
445
+ case result
446
+ when :cancelled
447
+ component_action(slot, :cancelled)
448
+ when Array
449
+ component_array_action(slot, result)
450
+ end
451
+ end
452
+
453
+ def component_array_action(slot, result)
454
+ event_name, value = result
455
+ return component_action(slot, :submitted, value) if event_name == :submitted
456
+ return component_action(slot, :selected, value) if event_name == :selected
457
+
458
+ nil
459
+ end
460
+
461
+ def component_action(slot, suffix, *arguments)
462
+ action = :"#{slot}_#{suffix}"
463
+ return unless respond_to?(action, true)
464
+
465
+ [action, arguments]
466
+ end
467
+
356
468
  # Handles Tab/Shift-Tab traversal: moves focus forward or backward through the focus ring.
357
469
  # Only processes events that are actually Tab keypresses on an empty focus ring. Returns :handled when consumed.
358
470
  def dispatch_tab_traversal
@@ -407,7 +519,7 @@ module Charming
407
519
 
408
520
  # Moves sidebar selection up or down by `delta`. Does nothing if there are no routes.
409
521
  def sidebar_move(delta)
410
- count = application.routes.all.length
522
+ count = sidebar_routes.length
411
523
  return render_default_action if count.zero?
412
524
 
413
525
  session[:sidebar_index] = (sidebar_index + delta).clamp(0, count - 1)
@@ -417,8 +529,12 @@ module Charming
417
529
  # Selects the currently highlighted sidebar route and navigates to it — shifting focus to content area.
418
530
  # If no route is found at the current index, falls back to default action.
419
531
  def sidebar_select
420
- route = application.routes.all[sidebar_index]
421
- session[:focus] = :content
532
+ route = sidebar_routes[sidebar_index]
533
+ if focus_ring_slot?(:content)
534
+ focus.focus(:content)
535
+ else
536
+ session[:focus] = :content
537
+ end
422
538
  route ? navigate_to(route.path) : render_default_action
423
539
  end
424
540
 
@@ -432,7 +548,7 @@ module Charming
432
548
  end
433
549
 
434
550
  def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
435
- Components::CommandPalette.new(
551
+ Presentation::Components::CommandPalette.new(
436
552
  commands: commands,
437
553
  placeholder: placeholder,
438
554
  height: height,
@@ -470,7 +586,7 @@ module Charming
470
586
 
471
587
  def theme_commands
472
588
  application.class.themes.keys.map do |name|
473
- Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
589
+ Presentation::Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
474
590
  end
475
591
  end
476
592
 
@@ -490,10 +606,16 @@ module Charming
490
606
  value.respond_to?(:call) ? instance_exec(&value) : send(value)
491
607
  end
492
608
 
493
- # Renders the default `:show` action if this controller defines it. Called after navigation, command execution,
609
+ # Renders the default action if this controller defines it. Called after navigation, command execution,
494
610
  # or key handling when no explicit response was produced — ensures the view stays rendered.
495
611
  def render_default_action
496
- show if respond_to?(:show)
612
+ action = self.class.auto_render_action || :show
613
+ public_send(action) if respond_to?(action)
614
+ end
615
+
616
+ def auto_render_after?(action)
617
+ auto_render_action = self.class.auto_render_action
618
+ auto_render_action && action.to_sym != auto_render_action
497
619
  end
498
620
 
499
621
  def global_key_action
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Charming
6
+ class DatabaseCommands
7
+ def initialize(command, out:, destination:)
8
+ @command = command
9
+ @out = out
10
+ @destination = destination
11
+ end
12
+
13
+ def run
14
+ case command
15
+ when "db:create" then create
16
+ when "db:migrate" then migrate
17
+ when "db:rollback" then rollback
18
+ when "db:drop" then drop
19
+ when "db:seed" then seed
20
+ else raise Generators::Error, "Unknown database command: #{command}"
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :command, :out, :destination
27
+
28
+ def create
29
+ load_database
30
+ FileUtils.mkdir_p(File.dirname(database_path)) if database_path
31
+ FileUtils.touch(database_path) if database_path
32
+ ActiveRecord::Base.connection
33
+ out.puts "create #{relative_database_path}"
34
+ end
35
+
36
+ def migrate
37
+ load_database
38
+ migration_context.migrate
39
+ out.puts "migrate db/migrate"
40
+ end
41
+
42
+ def rollback
43
+ load_database
44
+ migration_context.rollback(1)
45
+ out.puts "rollback db/migrate"
46
+ end
47
+
48
+ def drop
49
+ load_database
50
+ ActiveRecord::Base.connection.disconnect!
51
+ File.delete(database_path) if database_path && File.exist?(database_path)
52
+ out.puts "drop #{relative_database_path}"
53
+ end
54
+
55
+ def seed
56
+ load_database
57
+ seed_path = File.join(destination, "db", "seeds.rb")
58
+ raise Generators::Error, "Missing file: db/seeds.rb" unless File.exist?(seed_path)
59
+
60
+ load seed_path
61
+ out.puts "seed db/seeds.rb"
62
+ end
63
+
64
+ def load_database
65
+ database_config = File.join(destination, "config", "database.rb")
66
+ raise Generators::Error, "Database support is not configured. Missing config/database.rb." unless File.exist?(database_config)
67
+
68
+ require database_config
69
+ end
70
+
71
+ def migration_context
72
+ ActiveRecord::MigrationContext.new(File.join(destination, "db", "migrate"))
73
+ end
74
+
75
+ def database_path
76
+ ActiveRecord::Base.connection_db_config.database
77
+ end
78
+
79
+ def relative_database_path
80
+ return "database" unless database_path
81
+
82
+ base = File.realpath(destination)
83
+ path = File.expand_path(database_path)
84
+ path.start_with?("#{base}/") ? path.delete_prefix("#{base}/") : path
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Charming
6
+ class DatabaseInstaller
7
+ def initialize(database, out:, destination:)
8
+ @database = database
9
+ @out = out
10
+ @destination = destination
11
+ @app_name = Generators::Name.new(app_name_from_gemspec)
12
+ end
13
+
14
+ def install
15
+ raise Generators::Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
16
+
17
+ create_file("config/database.rb", database_config)
18
+ create_file("app/models/application_record.rb", application_record)
19
+ create_file("db/migrate/.keep", "")
20
+ create_file("db/seeds.rb", %(# frozen_string_literal: true
21
+ ))
22
+ update_gemspec
23
+ update_root_file
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :database, :out, :destination, :app_name
29
+
30
+ def create_file(path, content)
31
+ absolute_path = File.join(destination, path)
32
+ if File.exist?(absolute_path)
33
+ out.puts "exist #{path}"
34
+ return
35
+ end
36
+
37
+ FileUtils.mkdir_p(File.dirname(absolute_path))
38
+ File.write(absolute_path, content)
39
+ out.puts "create #{path}"
40
+ end
41
+
42
+ def update_gemspec
43
+ update_file(gemspec_path) do |current|
44
+ updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
45
+ updated = insert_dependency(updated, "activerecord", "~> 8.1")
46
+ insert_dependency(updated, "sqlite3", "~> 2.0")
47
+ end
48
+ end
49
+
50
+ def update_root_file
51
+ update_file(root_file_path) do |current|
52
+ updated = current
53
+ updated = updated.sub(%(require "zeitwerk"\n), %(require "zeitwerk"\nrequire_relative "../config/database"\n)) unless updated.include?(%(require_relative "../config/database"))
54
+ unless updated.include?(%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})])
55
+ updated = updated.sub(
56
+ %[loader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n],
57
+ %[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]
58
+ )
59
+ end
60
+ updated
61
+ end
62
+ end
63
+
64
+ def update_file(path)
65
+ raise Generators::Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
66
+
67
+ current = File.read(path)
68
+ updated = yield current
69
+ return if updated == current
70
+
71
+ File.write(path, updated)
72
+ out.puts "update #{relative_path(path)}"
73
+ end
74
+
75
+ def insert_dependency(content, gem_name, version)
76
+ return content if content.include?(%(spec.add_dependency "#{gem_name}"))
77
+
78
+ dependency = %( spec.add_dependency "#{gem_name}", "#{version}")
79
+ content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
80
+ end
81
+
82
+ def database_config
83
+ %(# frozen_string_literal: true
84
+
85
+ require "active_record"
86
+ require "fileutils"
87
+
88
+ database_path = File.expand_path("../db/development.sqlite3", __dir__)
89
+ FileUtils.mkdir_p(File.dirname(database_path))
90
+
91
+ ActiveRecord::Base.establish_connection(
92
+ adapter: "sqlite3",
93
+ database: database_path
94
+ )
95
+ )
96
+ end
97
+
98
+ def application_record
99
+ %(# frozen_string_literal: true
100
+
101
+ module #{app_name.class_name}
102
+ class ApplicationRecord < ActiveRecord::Base
103
+ self.abstract_class = true
104
+ end
105
+ end
106
+ )
107
+ end
108
+
109
+ def app_name_from_gemspec
110
+ File.basename(gemspec_path, ".gemspec")
111
+ end
112
+
113
+ def gemspec_path
114
+ @gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Generators::Error, "Run this command from a Charming app root")
115
+ end
116
+
117
+ def root_file_path
118
+ File.join(destination, "lib", "#{app_name.snake_name}.rb")
119
+ end
120
+
121
+ def relative_path(path)
122
+ path.delete_prefix("#{destination}/")
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # KeyEvent represents a terminal key press parsed by the backend. *key* is the normalized semantic
6
+ # action name (e.g., `:up`, `:down`, `:q`), while *char*, *ctrl*, *alt*, and *shift* capture raw
7
+ # input details for custom bindings.
8
+ KeyEvent = Data.define(:key, :char, :ctrl, :alt, :shift) do
9
+ # Constructs a key event with the required *key* symbol, plus optional *char* string and modifier booleans.
10
+ def initialize(key:, char: nil, ctrl: false, alt: false, shift: false)
11
+ super(key: key.to_sym, char: char, ctrl: ctrl, alt: alt, shift: shift)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # MOUSE_BUTTON_MAP encodes terminal mouse button codes to semantic symbols. The constant is frozen and private.
6
+ MOUSE_BUTTON_MAP = {
7
+ 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
8
+ 64 => :scroll_up, 65 => :scroll_down,
9
+ 66 => :scroll_up, 67 => :scroll_down
10
+ }.freeze
11
+ private_constant :MOUSE_BUTTON_MAP
12
+
13
+ # MouseEvent represents a mouse input event. *button* encodes which button or action was triggered (left,
14
+ # right, scroll), while *x* and *y* provide the cursor position. Modifier booleans (*ctrl*, *alt*, *shift*)
15
+ # capture key state at the time of the event.
16
+ MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift) do
17
+ def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false)
18
+ super
19
+ end
20
+
21
+ # Returns the semantic symbol for *button* — one of `left`, `right`, `scroll_up`, etc. or `:unknown`.
22
+ def button_name
23
+ MOUSE_BUTTON_MAP.fetch(button, :unknown)
24
+ end
25
+
26
+ # Returns `true` when the current event is a click (left, middle, or right button).
27
+ def click?
28
+ %i[left middle right].include?(button_name)
29
+ end
30
+
31
+ # Returns `true` when the button name maps to either direction of scroll.
32
+ def scroll?
33
+ %i[scroll_up scroll_down].include?(button_name)
34
+ end
35
+
36
+ # Returns `true` when the current event is a mouse release action.
37
+ def release?
38
+ button_name == :release
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # ResizeEvent represents a terminal window resize. *width* and *height* carry the new terminal dimensions
6
+ # in screen cells, replacing the previous Screen dimensions for all subsequent rendering.
7
+ ResizeEvent = Data.define(:width, :height)
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # TaskEvent represents background task completion. *name* is the declared task identifier, *value* carries
6
+ # the return result and *error* captures any exception raised during execution. The `error?` predicate
7
+ # simplifies error handling in controller handlers.
8
+ TaskEvent = Data.define(:name, :value, :error) do
9
+ def initialize(name:, value: nil, error: nil)
10
+ super
11
+ end
12
+
13
+ # Returns `true` when the task finished with a non-nil exception.
14
+ def error?
15
+ !error.nil?
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Events
5
+ # TimerEvent represents a timed dispatch from the runtime loop. *name* is the declared timer identifier;
6
+ # *now* is the monotonically rising clock value at emission for throttle comparisons.
7
+ TimerEvent = Data.define(:name, :now)
8
+ end
9
+ end
@@ -4,12 +4,12 @@ module Charming
4
4
  module Generators
5
5
  class AppGenerator
6
6
  module AppSpecTemplates
7
- def spec_model
7
+ def spec_state
8
8
  %(# frozen_string_literal: true
9
9
 
10
10
  require "#{app_name.snake_name}"
11
11
 
12
- RSpec.describe #{app_name.class_name}::HomeModel do
12
+ RSpec.describe #{app_name.class_name}::HomeState do
13
13
  describe "#title" do
14
14
  it "has the correct default string value" do
15
15
  instance = described_class.new
@@ -36,7 +36,7 @@ RSpec.describe #{app_name.class_name}::HomeController do
36
36
  subject(:controller) { described_class.new(application: application) }
37
37
 
38
38
  describe "#show" do
39
- it "renders the view with the model" do
39
+ it "renders the view with the state" do
40
40
  response = controller.dispatch(:show)
41
41
 
42
42
  expect(response).to respond_to(:body)
@@ -51,12 +51,16 @@ end
51
51
 
52
52
  require "#{app_name.snake_name}"
53
53
 
54
- RSpec.describe #{app_name.class_name}::HomeView do
54
+ RSpec.describe "home/show template" do
55
55
  describe "#render" do
56
- it "renders the model title" do
57
- view = described_class.new(
58
- home: double(title: "#{app_name.class_name}")
59
- )
56
+ it "renders the state title" do
57
+ template = Charming::Presentation::Templates.resolve("home/show", root: #{app_name.class_name}::Application.root)
58
+ view = Charming::Presentation::TemplateView.new(
59
+ template: template,
60
+ namespace: #{app_name.class_name},
61
+ home: double(title: "#{app_name.class_name}"),
62
+ theme: #{app_name.class_name}::Application.new.theme
63
+ )
60
64
 
61
65
  expect(view.render).to include("#{app_name.class_name}")
62
66
  end
@@ -51,7 +51,7 @@ end
51
51
  spec.summary = "A Charming terminal user interface."
52
52
  spec.authors = ["TODO: Your name"]
53
53
  spec.email = ["TODO: Your email"]
54
- spec.files = Dir.glob("{app,config,exe,lib}/**/*") + %w[README.md]
54
+ spec.files = Dir.glob("#{gemspec_file_glob}/**/*") + %w[README.md]
55
55
  spec.bindir = "exe"
56
56
  spec.executables = ["#{name.snake_name}"]
57
57
  spec.require_paths = ["lib"]
@@ -61,7 +61,19 @@ end
61
61
 
62
62
  def gemspec_dependencies
63
63
  %(
64
- spec.add_dependency "charming")
64
+ spec.add_dependency "charming"#{database_dependencies})
65
+ end
66
+
67
+ def gemspec_file_glob
68
+ database? ? "{app,config,db,exe,lib}" : "{app,config,exe,lib}"
69
+ end
70
+
71
+ def database_dependencies
72
+ return "" unless database?
73
+
74
+ %(
75
+ spec.add_dependency "activerecord", "~> 8.1"
76
+ spec.add_dependency "sqlite3", "~> 2.0")
65
77
  end
66
78
  end
67
79
  end