charming 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0eaad21d88747b525afbae63f84bf610e51efe4fbc56d5e628255efe66e35d8
4
- data.tar.gz: 2e4b4669da9b3975a8e09b6d248e1b74802b216f8036e52e6df8582cebc289c7
3
+ metadata.gz: 2bc5c3942786d07631d42361391e76f4f42275cc472c8ff7e37669880263b978
4
+ data.tar.gz: b6dc9eef28cf8eb6849a9ae34a4af8e6902a0e263529762cce9a3e591ed06396
5
5
  SHA512:
6
- metadata.gz: bf4004256b03622484367fa47cdcd5a855f9693f6623e1f3b54fc54c9d952f66477ad3ef6342963f1cdc0fca75eb9b0dd399ca2f9d07f217198c1f02ae139372
7
- data.tar.gz: 4257cdb45bd0fa51866f4ada6db6a279cc92cec5b23248ee1d7bf467fbebc546564df83c57bd93ca797ebc81a5cee9b562a31ac93a6771483b8f2643e7452cf0
6
+ metadata.gz: e772a624b0f4a51d722ed40863bfae85161ac9bc1b508d7accb6cc7a4fc8f30352a79b66a9d42416992d38477c145f5ecc55c953b77aca1cf787a03a5f2f0e64
7
+ data.tar.gz: 61dc03c8e8ade6e62fbc86c6f76e382063cc13aa1f60cc8afa161f6a646d1e4836483f9ca9a2e8446881f5b5a65d4b8e5107e39300c5844034dbbcb33f37a47f
data/README.md CHANGED
@@ -2,405 +2,65 @@
2
2
 
3
3
  A Rails-inspired terminal user interface framework for **Ruby 4+**.
4
4
 
5
- ```ruby
6
- class MyApp < Charming::Application
7
- routes do
8
- root "counter#show"
9
- end
10
- end
5
+ Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, background tasks, and testable terminal backends.
11
6
 
12
- class CounterController < Charming::Controller
13
- key "up", :increment
14
- key "down", :decrement
15
- key "q", :quit, scope: :global
7
+ ## Project Status
16
8
 
17
- def show
18
- render "Count: #{counter.count}"
19
- end
9
+ Charming is still in its infancy and is under constant development. APIs, behavior, and generated app structure may change until the project reaches a stable `1.0` release.
20
10
 
21
- def increment
22
- counter.count += 1
23
- show
24
- end
11
+ ## Quick Start
25
12
 
26
- def decrement
27
- counter.count -= 1
28
- show
29
- end
30
-
31
- private
32
-
33
- def counter
34
- model(:counter, CounterModel)
35
- end
36
- end
37
-
38
- class CounterModel < Charming::ApplicationModel
39
- attribute :count, :integer, default: 0
40
- end
41
- ```
42
-
43
- ## Installation
44
-
45
- Add this line to your application's Gemfile:
46
-
47
- ```ruby
48
- gem "charming"
49
- ```
50
-
51
- Then execute:
13
+ Install the Charming CLI gem on your machine:
52
14
 
53
15
  ```bash
54
- bundle install
16
+ gem install charming
55
17
  ```
56
18
 
57
- ## Generating an App
58
-
59
- Create a complete, runnable Charming app with the built-in generator:
19
+ Generate and run an app:
60
20
 
61
21
  ```bash
62
22
  charming new my_app
63
23
  cd my_app
24
+ bundle install
64
25
  bundle exec exe/my_app
65
26
  ```
66
27
 
67
- The generator produces a full Bundler gem with conventional Rails-like structure:
68
-
69
- ```text
70
- app/controllers/ # application controllers
71
- app/models/ # persistent state models
72
- app/views/ # screen views
73
- app/components/ # reusable widgets (AppFrame, etc.)
74
- config/routes.rb # route definitions
75
- lib/my_app.rb # namespace loader (Zeitwerk)
76
- exe/my_app # executable entry point
77
- ```
78
-
79
- Inside a generated app, scaffold more code:
80
-
81
- ```bash
82
- charming generate controller users index show
83
- charming generate view details
84
- charming generate component status_badge
85
- charming g controller products # shortcut
86
- ```
87
-
88
- Generated apps ship with a command palette (press `p`) and a sidebar navigation layout with theming and focus management baked in.
89
-
90
- ## Running Without the Generator
91
-
92
- You can also build an app from scratch. Run it with:
93
-
94
- ```ruby
95
- Charming.run(MyApp.new)
96
- # ^ entry point — starts the terminal event loop
97
- ```
98
-
99
- The `Charming::Runtime` manages the terminal lifecycle, reads events from a TTY backend, and dispatches them to controllers. An in-memory backend (`MemoryBackend`) is available for scripting and testing without a real terminal.
100
-
101
- ## Application & Routing
102
-
103
- Define routes with `root` and `screen` — each maps a URL path to a controller and action:
104
-
105
- ```ruby
106
- class MyApp < Charming::Application
107
- routes do
108
- root "home#index"
109
- screen "/cities/:id", to: "cities#show"
110
- end
111
- end
112
- ```
113
-
114
- Dynamic segments (`:id`) are available in controllers through `params`:
28
+ Charming can also be added to an existing Ruby project with Bundler, but the primary workflow is installing the gem globally and using `charming new` to create a complete app.
115
29
 
116
- ```ruby
117
- class CitiesController < Charming::Controller
118
- def show
119
- render "City #{params[:id]}"
120
- end
121
- end
122
- ```
123
-
124
- Exact routes take precedence over dynamic routes. Multiple screens are listed in route order and rendered as sidebar entries (handled automatically by generated app layouts).
125
-
126
- ## Controllers
127
-
128
- The base `Charming::Controller` provides key bindings, command palette entries, timer-driven actions, navigation, and state management:
129
-
130
- **Key bindings** — strings or symbols mapped to action methods, scoped as either content-focused or global:
131
-
132
- ```ruby
133
- class HomeController < Charming::Controller
134
- key "up", :increment
135
- key "j", :navigate_down, scope: :content
136
- key "q", :quit, scope: :global
137
- end
138
- ```
139
-
140
- **Command palette** — entries visible in the fuzzy-search command palette. Accepts a method name or an inline block:
141
-
142
- ```ruby
143
- command "Save changes", :save
144
- command "Clear" do
145
- @model = reset_model
146
- end
147
- ```
148
-
149
- **Timers** — periodic actions that fire at a given interval on the current controller:
150
-
151
- ```ruby
152
- timer :blink, every: 0.5, action: :toggle_spinner
153
- ```
154
-
155
- **Model storage** — models are stored in session and lazily instantiated:
156
-
157
- ```ruby
158
- def counter
159
- model(:counter, CounterModel)
160
- end
161
- # ^ name ^ class
162
- ```
163
-
164
- Subsequent calls with the same name return the cached instance. Use this pattern to define accessors on your controllers.
165
-
166
- **Navigation** — redirect to a new route or quit the application:
167
-
168
- ```ruby
169
- navigate_to "/settings" # redirect
170
- quit # exit the app
171
- open_command_palette # open command palette
172
- close_command_palette # close it
173
- use_theme :phosphor # switch theme (persists in session)
174
- ```
175
-
176
- ## Models
30
+ ## Documentation
177
31
 
178
- Application models inherit from `Charming::ApplicationModel`, which includes `ActiveModel::Model` and `ActiveModel::Attributes`:
32
+ | Guide | Purpose |
33
+ |-------|---------|
34
+ | [Docs Index](docs/README.md) | Suggested reading paths and all documentation links. |
35
+ | [Getting Started](docs/getting_started.md) | Build and run a generated Charming app. |
36
+ | [Core Concepts](docs/core_concepts.md) | App architecture, runtime flow, ephemeral controllers, and state. |
37
+ | [Routing](docs/routing.md) | `root`, `screen`, dynamic params, route titles, and route order. |
38
+ | [Controllers & Templates](docs/controllers_and_templates.md) | Actions, `render :show`, `render_template`, key bindings, commands, timers, and tasks. |
39
+ | [Layouts](docs/layouts.md) | Template layouts, `yield_content`, split panes, overlays, responsive layouts, and styles. |
40
+ | [State](docs/state.md) | `ApplicationState`, typed attributes, validations, and session-backed state. |
41
+ | [Database](docs/database.md) | Optional SQLite persistence with Active Record models and migrations. |
42
+ | [Components](docs/components.md) | Built-in components, custom components, and interaction return values. |
43
+ | [Themes](docs/themes.md) | Theme registration, tokens, and runtime theme switching. |
44
+ | [API Reference](docs/api.md) | Compact public API reference. |
45
+ | [Testing](docs/testing.md) | Controller, template, component, runtime, timer, and task tests. |
179
46
 
180
- ```ruby
181
- class CounterModel < Charming::ApplicationModel
182
- attribute :count, :integer, default: 0
183
-
184
- validate :count_gte_zero do
185
- errors.add(:count, "must be >= 0") if count < 0
186
- end
187
- end
188
- ```
47
+ ## Generated App Structure
189
48
 
190
- Models are the only place persistent state should live. Controllers are created fresh per event — never store state on them.
191
-
192
- ## Views
193
-
194
- Views inherit from `Charming::View` and expose assigns (from `initialize`) as instance-local accessor methods:
195
-
196
- ```ruby
197
- class HomeView < Charming::View
198
- def render
199
- row(title, subtitle, gap: 2)
200
- end
201
-
202
- private
203
-
204
- def title
205
- text "Hello!", style: theme.title
206
- end
207
-
208
- def subtitle
209
- text "World", style: theme.muted
210
- end
211
- end
212
- ```
49
+ The generator produces a Bundler gem with a Rails-like structure:
213
50
 
214
- Views use `row`, `column` for layout, `box` for bordered containers, and `text` for styled output. Assigns passed to `initialize` become reader methods automatically:
215
-
216
- ```ruby
217
- class HomeView < Charming::View
218
- # Pass assigns via initialize; they are accessible as regular methods inside render()
219
- end
220
-
221
- view = HomeView.new(title: "Hello", count: 42)
222
- # view.title "Hello"
223
- # view.count 42
224
- ```
225
-
226
- Defining your own method with the same name will override the auto-generated accessor.
227
-
228
- ### Layouts
229
-
230
- Layouts are views that wrap the current screen with a wrapper (sidebar, header, etc.):
231
-
232
- ```ruby
233
- class ApplicationController < Charming::Controller
234
- layout Layouts::Application
235
- end
236
- ```
237
-
238
- Subclasses inherit their parent's layout. Override with `layout false` to disable wrapping.
239
-
240
- Layouts use `yield_content` to render the primary screen and receive `screen`, `controller`, and `theme` as assigns:
241
-
242
- ```ruby
243
- module MyProject
244
- module Layouts
245
- class Application < Charming::View
246
- def render
247
- body = Charming::UI.place(content, width: screen.width, height: screen.height)
248
- return body unless command_palette_open?
249
-
250
- Charming::UI.overlay(body, command_palette.render)
251
- end
252
- end
253
- end
254
- end
255
- ```
256
-
257
- ### Partials
258
-
259
- Render class-based partial views from other views:
260
-
261
- ```ruby
262
- render_component HeaderView.new(title: "Dashboard")
263
- # ^ component is a View subclass or Component
264
- ```
265
-
266
- Components are just `Charming::View` subclasses — they gain the same assigns, helpers, and rendering behavior.
267
-
268
- ## Themes
269
-
270
- Applications register named themes from bundled JSON files or custom locations:
271
-
272
- ```ruby
273
- class MyApp < Charming::Application
274
- Charming::UI::Theme.built_in_names.each do |theme_name|
275
- theme theme_name.to_sym, built_in: theme_name
276
- end
277
-
278
- default_theme :phosphor
279
-
280
- theme :custom, from: "config/themes/custom.json"
281
- end
282
- ```
283
-
284
- Charming ships with the Phosphor theme by default. Views use semantic tokens — not hardcoded colors — for all styling:
285
-
286
- ```ruby
287
- text "Welcome", style: theme.title # bright cyan + bold
288
- text "Status", style: theme.muted # dim gray
289
- text "Alert", style: theme.info # cyan
290
- ```
291
-
292
- ## Components
293
-
294
- Charming ships with interactive terminal widgets that inherit from `Charming::View` (and thus gain all View helpers):
295
-
296
- | Component | Description |
297
- |-----------|-------------|
298
- | `TextInput` | Editable text field with cursor movement, selection, and character insertion |
299
- | `List` | Selectable list with keyboard navigation (up/down/home/end/enter) and mouse support |
300
- | `Modal` | Overlay dialog with title, content, and help text |
301
- | `CommandPalette` | Fuzzy-search command input used internally by the framework |
302
- | `Viewport` | Scrollable container for tall content lists |
303
- | `Spinner` | Animated progress indicator |
304
- | `ActivityIndicator` | Spinner variant (same underlying widget) |
305
- | `Progressbar` | A text-based progress bar |
306
- | `Table` | Unicode-rendered data table with keyboard navigation and mouse selection |
307
- | `KeyboardHandler` | Key-mapping mixin for custom components |
308
-
309
- All components accept a `theme:` parameter and are rendered from views via `render_component`:
310
-
311
- ```ruby
312
- render_component List.new(
313
- items: ["Alpha", "Beta", "Gamma"],
314
- selected_index: 0,
315
- theme: theme
316
- )
317
- ```
318
-
319
- Components return specific values from their `handle_key(event)` methods — the framework recognizes conventions like `[:selected, item]` and `:cancelled`. They also provide a `handle_mouse(event)` method for mouse-driven interaction.
320
-
321
- ## Async Tasks
322
-
323
- Dispatch background work via `run_task` on controllers. Results arrive as `TaskEvent`s that trigger controller actions:
324
-
325
- ```ruby
326
- class HomeController < Charming::Controller
327
- on_task :fetch_data, action: :data_loaded
328
-
329
- def load_data
330
- run_task :fetch_data do
331
- # runs in a background thread
332
- sleep 2
333
- "done"
334
- end
335
- end
336
-
337
- def data_loaded
338
- render "Task complete!"
339
- end
340
- end
341
- ```
342
-
343
- Register handlers with `on_task` on the controller and dispatch work with `run_task`.
344
-
345
- ## Focus Management
346
-
347
- Multi-screen layouts can define focusable areas using `focus_ring`:
348
-
349
- ```ruby
350
- class ApplicationController < Charming::Controller
351
- focus_ring :sidebar, :content
352
- end
353
- ```
354
-
355
- This enables keyboard-driven focus traversal — `Tab` cycles forward, `Shift+Tab` backward. Use `focused?(slot)`, `focus_sidebar`, and `focus_content` to programmatically control focus:
356
-
357
- ```ruby
358
- def show
359
- focus_sidebar if params[:sidebar]
360
- render HomeView.new(...)
361
- end
362
- ```
363
-
364
- ## Layout Primitives
365
-
366
- The `Charming::UI` module provides layout primitives for building custom screen layouts. These work independently of the runtime and backends:
367
-
368
- | Method | Description |
369
- |--------|-------------|
370
- | `Style.new` | Create a new style for chaining colors, padding, borders, alignment |
371
- | `UI.join_horizontal(*blocks, gap: 0)` | Place blocks side-by-side |
372
- | `UI.join_vertical(*blocks, gap: 0)` | Stack blocks vertically |
373
- | `UI.center(block, width:, height:)` | Center a block in a fixed canvas |
374
- | `UI.place(block, width:, height:, top:, left:, background:)` | Place anywhere on a canvas |
375
- | `UI.overlay(base, overlay, top:, left:)` | Overlay content atop another |
376
-
377
- All methods work with ANSI-styled strings and correctly handle Unicode display widths:
378
-
379
- ```ruby
380
- body = UI.join_horizontal(sidebar, main_content, gap: 1)
381
- canvas = UI.place(body, width: screen.width, height: screen.height)
382
- Charming::UI.overlay(canvas, modal_view.render)
383
- ```
384
-
385
- ## Testing
386
-
387
- Charming uses an in-memory backend (`MemoryBackend`) for testing, so specs run without a real terminal. Pass `backend: MemoryBackend.new(...)` to `Charming::Runtime`:
388
-
389
- ```ruby
390
- backend = Charming::Internal::Terminal::MemoryBackend.new(
391
- events: [
392
- Charming::KeyEvent.new(key: :up),
393
- Charming::KeyEvent.new(key: :q)
394
- ]
395
- )
396
- runtime = described_class.new(app, backend: backend)
397
- runtime.run
398
-
399
- expect(backend.frames).to eq(["Count: 0", "Count: 1"])
400
- # ^ captured terminal output, one frame per render
51
+ ```text
52
+ app/controllers/ # controller actions and input bindings
53
+ app/state/ # session-backed TUI state
54
+ app/models/ # optional Active Record models
55
+ app/views/home/show_view.rb # screen view classes
56
+ app/views/layouts/application_layout.rb # layout view class
57
+ app/components/ # reusable components
58
+ config/routes.rb # route definitions
59
+ lib/my_app.rb # namespace loader (Zeitwerk)
60
+ exe/my_app # executable entry point
401
61
  ```
402
62
 
403
- The `MemoryBackend` constructor accepts `events:` (a series of events to feed the loop), and `width:` / `height:` for screen dimensions. After running, assertions go against `backend.frames` an array capturing each rendered terminal frame passed through `write_frame`. This pattern is used throughout the test suite.
63
+ Generated apps include a sidebar/content layout, command palette, focus management, theme switching, and default key bindings for commands (`p`) and quit (`q`).
404
64
 
405
65
  ## Development
406
66
 
@@ -408,7 +68,7 @@ After checking out the repo, run:
408
68
 
409
69
  ```bash
410
70
  bundle install
411
- bin/check # run everything — RSpec + Standard Ruby
71
+ bin/check
412
72
  ```
413
73
 
414
74
  Common binstubs:
@@ -22,42 +22,53 @@ module Charming
22
22
  name&.split("::")&.then { |parts| parts[0...-1].join("::") }
23
23
  end
24
24
 
25
+ # Returns the app's filesystem root, used to resolve relative theme and template paths.
26
+ # Pass *path* to set it; without arguments it returns the current value (or nil if unset).
25
27
  def root(path = THEME_READER)
26
28
  return @root if path == THEME_READER
27
29
 
28
30
  @root = File.expand_path(path)
29
31
  end
30
32
 
33
+ # Registers a named theme. Provide either *from:* (path to a JSON file relative to the app root)
34
+ # or *built_in:* (name of a bundled theme such as "phosphor"). Raises when neither or both are given.
31
35
  def theme(name, from: nil, built_in: nil)
32
36
  raise ArgumentError, "theme expects from: or built_in:" unless from || built_in
33
37
  raise ArgumentError, "theme expects either from: or built_in:, not both" if from && built_in
34
38
 
35
39
  themes[name.to_sym] = if built_in
36
- UI::Theme.load_builtin(built_in)
40
+ Presentation::UI::Theme.load_builtin(built_in)
37
41
  else
38
- UI::Theme.load_file(resolve_theme_path(from))
42
+ Presentation::UI::Theme.load_file(resolve_theme_path(from))
39
43
  end
40
44
  end
41
45
 
46
+ # Hash of all registered themes keyed by symbol, including those inherited from superclasses.
42
47
  def themes
43
48
  @themes ||= superclass.respond_to?(:themes) ? superclass.themes.dup : {}
44
49
  end
45
50
 
51
+ # Returns the default theme name, or sets it when *name* is given. When unset, falls back
52
+ # to the first registered theme. Used by `theme_for` when no name is provided.
46
53
  def default_theme(name = THEME_READER)
47
54
  return @default_theme || themes.keys.first if name == THEME_READER
48
55
 
49
56
  @default_theme = name.to_sym
50
57
  end
51
58
 
59
+ # Resolves a theme by *name* (or the default theme when *name* is nil). Returns the default
60
+ # built-in theme if no name is given and no default is registered.
52
61
  def theme_for(name = nil)
53
62
  theme_name = name || default_theme
54
- return UI::Theme.default unless theme_name
63
+ return Presentation::UI::Theme.default unless theme_name
55
64
 
56
65
  themes.fetch(theme_name.to_sym)
57
66
  end
58
67
 
59
68
  private
60
69
 
70
+ # Expands a relative theme path against the app root (or the current working directory
71
+ # when no root is configured). Returns *path* unchanged when it is already absolute.
61
72
  def resolve_theme_path(path)
62
73
  return path if File.absolute_path?(path)
63
74
 
@@ -3,10 +3,10 @@
3
3
  require "active_model"
4
4
 
5
5
  module Charming
6
- # ApplicationModel is the persistent state base for application data models. It includes
6
+ # ApplicationState is the base for session-backed TUI state. It includes
7
7
  # `ActiveModel::Model` (validation, initialisation) and `ActiveModel::Attributes` (typed attributes
8
- # with defaults via `attribute :name, :type, default: ...`), making it suitable as session-stored root objects.
9
- class ApplicationModel
8
+ # with defaults via `attribute :name, :type, default: ...`), making it suitable for screen/form state.
9
+ class ApplicationState
10
10
  include ActiveModel::Model
11
11
  include ActiveModel::Attributes
12
12
  end
data/lib/charming/cli.rb CHANGED
@@ -1,18 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
+ # CLI dispatches the `charming` executable's subcommands to the appropriate generators
5
+ # or database commands. Subcommands:
6
+ # - `charming new NAME [--database sqlite3] [--force]` — scaffolds a new app
7
+ # - `charming generate TYPE NAME [args]` — runs a sub-generator (controller, model, screen, view, component)
8
+ # - `charming db:COMMAND` — runs a database command (db:create, db:migrate, db:rollback, db:drop, db:seed, db:install)
9
+ #
10
+ # Generator errors are caught and printed to stderr; the process exits with status 1.
4
11
  class CLI
12
+ # *out* defaults to `$stdout`, *err* to `$stderr`, *pwd* to `Dir.pwd` (overridable for tests).
5
13
  def initialize(out: $stdout, err: $stderr, pwd: Dir.pwd)
6
14
  @out = out
7
15
  @err = err
8
16
  @pwd = pwd
9
17
  end
10
18
 
19
+ # Runs the CLI with the given *argv* array. Returns 0 on success, 1 on a generator error,
20
+ # or the status from `usage` for unknown subcommands.
11
21
  def call(argv)
12
22
  command, *args = argv
13
23
  case command
14
24
  when "new" then new_app(args)
15
25
  when "generate", "g" then generate(args)
26
+ when /^db:/ then database(command, args)
16
27
  else usage(1)
17
28
  end
18
29
  rescue Generators::Error => e
@@ -22,15 +33,23 @@ module Charming
22
33
 
23
34
  private
24
35
 
36
+ # Standard output, standard error, and working directory used for generator destinations.
25
37
  attr_reader :out, :err, :pwd
26
38
 
39
+ # Handles `charming new`. Validates args, extracts `--database=` and `--force`,
40
+ # and runs AppGenerator. Returns 0 on success, raises Generators::Error on bad input.
27
41
  def new_app(args)
28
42
  force = args.delete("--force")
29
- name = args.fetch(0) { raise Generators::Error, "Usage: charming new NAME [--force]" }
30
- Generators::AppGenerator.new(name, out: out, destination: pwd, force: force).generate
43
+ database = extract_database(args)
44
+ name = args.fetch(0) { raise Generators::Error, "Usage: charming new NAME [--database sqlite3] [--force]" }
45
+ raise Generators::Error, "Usage: charming new NAME [--database sqlite3] [--force]" if args.length > 1
46
+
47
+ Generators::AppGenerator.new(name, out: out, destination: pwd, force: force, database: database).generate
31
48
  0
32
49
  end
33
50
 
51
+ # Handles `charming generate TYPE NAME [args]`. Extracts `--force` and dispatches to
52
+ # the generator class for the requested type.
34
53
  def generate(args)
35
54
  force = args.delete("--force")
36
55
  type = args.shift || raise(Generators::Error, "Usage: charming generate TYPE NAME [actions]")
@@ -38,22 +57,62 @@ module Charming
38
57
  0
39
58
  end
40
59
 
60
+ # Builds the generator instance for the given *type*, popping the name from *args*.
41
61
  def generator(type, args, force)
42
62
  name = args.shift || raise(Generators::Error, "Usage: charming generate #{type} NAME")
43
63
  generator_class(type).new(name, args, out: out, destination: pwd, force: force)
44
64
  end
45
65
 
66
+ # Returns the generator class for a *type* string (controller, model, screen, view, component).
46
67
  def generator_class(type)
47
68
  {
48
69
  "controller" => Generators::ControllerGenerator,
70
+ "model" => Generators::ModelGenerator,
49
71
  "screen" => Generators::ScreenGenerator,
50
72
  "view" => Generators::ViewGenerator,
51
73
  "component" => Generators::ComponentGenerator
52
74
  }.fetch(type) { raise Generators::Error, "Unknown generator: #{type}" }
53
75
  end
54
76
 
77
+ # Routes `db:*` commands to either the install path (db:install) or the generic
78
+ # DatabaseCommands dispatcher.
79
+ def database(command, args)
80
+ if command == "db:install"
81
+ database = args.shift || raise(Generators::Error, "Usage: charming db:install sqlite3")
82
+ raise Generators::Error, "Usage: charming db:install sqlite3" if args.any?
83
+
84
+ DatabaseInstaller.new(database, out: out, destination: pwd).install
85
+ else
86
+ raise Generators::Error, "Usage: charming #{command}" if args.any?
87
+
88
+ DatabaseCommands.new(command, out: out, destination: pwd).run
89
+ end
90
+ 0
91
+ end
92
+
93
+ # Extracts the optional `--database=<value>` argument from *args*, removing it in place.
94
+ # Returns the validated database name (currently only "sqlite3") or nil when not given.
95
+ def extract_database(args)
96
+ inline = args.find { |arg| arg.start_with?("--database=") }
97
+ return validate_database(args.delete(inline).split("=", 2).last) if inline
98
+
99
+ index = args.index("--database")
100
+ return nil unless index
101
+
102
+ args.delete_at(index)
103
+ validate_database(args.delete_at(index) || raise(Generators::Error, "Usage: charming new NAME [--database sqlite3] [--force]"))
104
+ end
105
+
106
+ # Validates that *database* is a supported adapter name. Currently only "sqlite3".
107
+ def validate_database(database)
108
+ return database if database == "sqlite3"
109
+
110
+ raise Generators::Error, "Unsupported database: #{database.inspect}"
111
+ end
112
+
113
+ # Prints a usage banner to stderr and returns *status* (1 for unknown commands).
55
114
  def usage(status)
56
- err.puts "Usage: charming new NAME | charming generate TYPE NAME [actions]"
115
+ err.puts "Usage: charming new NAME | charming generate TYPE NAME [args] | charming db:COMMAND"
57
116
  status
58
117
  end
59
118
  end