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.
- checksums.yaml +4 -4
- data/README.md +38 -378
- data/lib/charming/application.rb +3 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +39 -3
- data/lib/charming/controller.rb +146 -24
- data/lib/charming/database_commands.rb +87 -0
- data/lib/charming/database_installer.rb +125 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
- data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
- data/lib/charming/generators/app_generator/component_templates.rb +1 -1
- data/lib/charming/generators/app_generator/controller_template.rb +3 -12
- data/lib/charming/generators/app_generator/database_templates.rb +45 -0
- data/lib/charming/generators/app_generator/layout_template.rb +51 -145
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
- data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
- data/lib/charming/generators/app_generator/view_template.rb +12 -18
- data/lib/charming/generators/app_generator.rb +37 -11
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/controller_generator.rb +1 -4
- data/lib/charming/generators/model_generator.rb +119 -0
- data/lib/charming/generators/name.rb +0 -4
- data/lib/charming/generators/screen_generator.rb +14 -28
- data/lib/charming/generators/view_generator.rb +11 -14
- data/lib/charming/internal/renderer/differential.rb +2 -3
- data/lib/charming/internal/terminal/tty_backend.rb +25 -8
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +43 -0
- data/lib/charming/presentation/components/form/builder.rb +48 -0
- data/lib/charming/presentation/components/form/confirm.rb +56 -0
- data/lib/charming/presentation/components/form/field.rb +96 -0
- data/lib/charming/presentation/components/form/input.rb +57 -0
- data/lib/charming/presentation/components/form/note.rb +32 -0
- data/lib/charming/presentation/components/form/select.rb +89 -0
- data/lib/charming/presentation/components/form/textarea.rb +70 -0
- data/lib/charming/presentation/components/form.rb +127 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +104 -0
- data/lib/charming/presentation/components/markdown.rb +25 -0
- data/lib/charming/presentation/components/modal.rb +50 -0
- data/lib/charming/presentation/components/progressbar.rb +57 -0
- data/lib/charming/presentation/components/spinner.rb +39 -0
- data/lib/charming/presentation/components/table.rb +118 -0
- data/lib/charming/presentation/components/text_area.rb +219 -0
- data/lib/charming/presentation/components/text_input.rb +105 -0
- data/lib/charming/presentation/components/viewport.rb +220 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/renderer.rb +203 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
- data/lib/charming/presentation/markdown.rb +8 -0
- data/lib/charming/presentation/template_view.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +51 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/style.rb +246 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +232 -0
- data/lib/charming/presentation/view.rb +118 -0
- data/lib/charming/runtime.rb +7 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +28 -0
- data/lib/charming/tasks/task.rb +9 -0
- data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +4 -0
- metadata +114 -29
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
data/lib/charming/controller.rb
CHANGED
|
@@ -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
|
|
162
|
-
#
|
|
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
|
|
198
|
-
# Subsequent calls with the same name return the cached instance. Used like:
|
|
199
|
-
def
|
|
200
|
-
session[:
|
|
201
|
-
session[:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
318
|
-
return rendered unless
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
421
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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}::
|
|
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
|
|
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
|
|
54
|
+
RSpec.describe "home/show template" do
|
|
55
55
|
describe "#render" do
|
|
56
|
-
it "renders the
|
|
57
|
-
|
|
58
|
-
|
|
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("{
|
|
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
|