charming 0.1.0
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +421 -0
- data/exe/charming +6 -0
- data/lib/charming/application.rb +90 -0
- data/lib/charming/application_model.rb +13 -0
- data/lib/charming/cli.rb +60 -0
- data/lib/charming/component.rb +8 -0
- data/lib/charming/components/activity_indicator.rb +158 -0
- data/lib/charming/components/command_palette.rb +118 -0
- data/lib/charming/components/keyboard_handler.rb +22 -0
- data/lib/charming/components/list.rb +105 -0
- data/lib/charming/components/modal.rb +48 -0
- data/lib/charming/components/progressbar.rb +55 -0
- data/lib/charming/components/spinner.rb +37 -0
- data/lib/charming/components/table.rb +115 -0
- data/lib/charming/components/text_input.rb +103 -0
- data/lib/charming/components/viewport.rb +191 -0
- data/lib/charming/controller.rb +523 -0
- data/lib/charming/focus.rb +65 -0
- data/lib/charming/generators/app_file_generator.rb +28 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
- data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
- data/lib/charming/generators/app_generator/component_templates.rb +36 -0
- data/lib/charming/generators/app_generator/controller_template.rb +69 -0
- data/lib/charming/generators/app_generator/layout_template.rb +160 -0
- data/lib/charming/generators/app_generator/model_templates.rb +30 -0
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
- data/lib/charming/generators/app_generator/view_template.rb +90 -0
- data/lib/charming/generators/app_generator.rb +76 -0
- data/lib/charming/generators/base.rb +29 -0
- data/lib/charming/generators/component_generator.rb +30 -0
- data/lib/charming/generators/controller_generator.rb +50 -0
- data/lib/charming/generators/name.rb +32 -0
- data/lib/charming/generators/screen_generator.rb +154 -0
- data/lib/charming/generators/view_generator.rb +34 -0
- data/lib/charming/generators.rb +7 -0
- data/lib/charming/internal/renderer/differential.rb +53 -0
- data/lib/charming/internal/renderer/full_repaint.rb +19 -0
- data/lib/charming/internal/terminal/adapter.rb +52 -0
- data/lib/charming/internal/terminal/memory_backend.rb +91 -0
- data/lib/charming/internal/terminal/tty_backend.rb +250 -0
- data/lib/charming/key_event.rb +13 -0
- data/lib/charming/mouse_event.rb +40 -0
- data/lib/charming/resize_event.rb +7 -0
- data/lib/charming/response.rb +33 -0
- data/lib/charming/router.rb +137 -0
- data/lib/charming/runtime.rb +192 -0
- data/lib/charming/screen.rb +8 -0
- data/lib/charming/task.rb +7 -0
- data/lib/charming/task_event.rb +17 -0
- data/lib/charming/task_executor.rb +62 -0
- data/lib/charming/timer_event.rb +7 -0
- data/lib/charming/ui/border.rb +33 -0
- data/lib/charming/ui/style.rb +244 -0
- data/lib/charming/ui/theme.rb +178 -0
- data/lib/charming/ui/themes/phosphor.json +100 -0
- data/lib/charming/ui/width.rb +24 -0
- data/lib/charming/ui.rb +230 -0
- data/lib/charming/version.rb +5 -0
- data/lib/charming/view.rb +116 -0
- data/lib/charming.rb +24 -0
- data/sig/charming.rbs +3 -0
- metadata +225 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e0eaad21d88747b525afbae63f84bf610e51efe4fbc56d5e628255efe66e35d8
|
|
4
|
+
data.tar.gz: 2e4b4669da9b3975a8e09b6d248e1b74802b216f8036e52e6df8582cebc289c7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bf4004256b03622484367fa47cdcd5a855f9693f6623e1f3b54fc54c9d952f66477ad3ef6342963f1cdc0fca75eb9b0dd399ca2f9d07f217198c1f02ae139372
|
|
7
|
+
data.tar.gz: 4257cdb45bd0fa51866f4ada6db6a279cc92cec5b23248ee1d7bf467fbebc546564df83c57bd93ca797ebc81a5cee9b562a31ac93a6771483b8f2643e7452cf0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pandorocks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# Charming
|
|
2
|
+
|
|
3
|
+
A Rails-inspired terminal user interface framework for **Ruby 4+**.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class MyApp < Charming::Application
|
|
7
|
+
routes do
|
|
8
|
+
root "counter#show"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class CounterController < Charming::Controller
|
|
13
|
+
key "up", :increment
|
|
14
|
+
key "down", :decrement
|
|
15
|
+
key "q", :quit, scope: :global
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
render "Count: #{counter.count}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def increment
|
|
22
|
+
counter.count += 1
|
|
23
|
+
show
|
|
24
|
+
end
|
|
25
|
+
|
|
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:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bundle install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Generating an App
|
|
58
|
+
|
|
59
|
+
Create a complete, runnable Charming app with the built-in generator:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
charming new my_app
|
|
63
|
+
cd my_app
|
|
64
|
+
bundle exec exe/my_app
|
|
65
|
+
```
|
|
66
|
+
|
|
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`:
|
|
115
|
+
|
|
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
|
|
177
|
+
|
|
178
|
+
Application models inherit from `Charming::ApplicationModel`, which includes `ActiveModel::Model` and `ActiveModel::Attributes`:
|
|
179
|
+
|
|
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
|
+
```
|
|
189
|
+
|
|
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
|
+
```
|
|
213
|
+
|
|
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
|
|
401
|
+
```
|
|
402
|
+
|
|
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.
|
|
404
|
+
|
|
405
|
+
## Development
|
|
406
|
+
|
|
407
|
+
After checking out the repo, run:
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
bundle install
|
|
411
|
+
bin/check # run everything — RSpec + Standard Ruby
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Common binstubs:
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
bin/rspec # run specs only
|
|
418
|
+
bin/format # auto-format with Standard Ruby
|
|
419
|
+
bin/lint # style checks with Standard Ruby
|
|
420
|
+
bin/check # run everything
|
|
421
|
+
```
|
data/exe/charming
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Application is a lightweight, Rails-inspired application base for building
|
|
5
|
+
# terminal-based apps. It provides routing (via a DSL), session storage, and
|
|
6
|
+
# task execution for managing async operations.
|
|
7
|
+
class Application
|
|
8
|
+
THEME_READER = Object.new.freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Registers or returns the app's Router. Accepts an optional block to define
|
|
12
|
+
# routes via DSL (screen, root). Lazily initializes a new Router per namespace.
|
|
13
|
+
def routes(&block)
|
|
14
|
+
@routes ||= Router.new(namespace: namespace)
|
|
15
|
+
@routes.draw(&block) if block
|
|
16
|
+
@routes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Derives the module namespace from the class name — e.g., Admin::HomeController
|
|
20
|
+
# yields "Admin". Mirrors Rails' engine-style namespacing.
|
|
21
|
+
def namespace
|
|
22
|
+
name&.split("::")&.then { |parts| parts[0...-1].join("::") }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def root(path = THEME_READER)
|
|
26
|
+
return @root if path == THEME_READER
|
|
27
|
+
|
|
28
|
+
@root = File.expand_path(path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def theme(name, from: nil, built_in: nil)
|
|
32
|
+
raise ArgumentError, "theme expects from: or built_in:" unless from || built_in
|
|
33
|
+
raise ArgumentError, "theme expects either from: or built_in:, not both" if from && built_in
|
|
34
|
+
|
|
35
|
+
themes[name.to_sym] = if built_in
|
|
36
|
+
UI::Theme.load_builtin(built_in)
|
|
37
|
+
else
|
|
38
|
+
UI::Theme.load_file(resolve_theme_path(from))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def themes
|
|
43
|
+
@themes ||= superclass.respond_to?(:themes) ? superclass.themes.dup : {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_theme(name = THEME_READER)
|
|
47
|
+
return @default_theme || themes.keys.first if name == THEME_READER
|
|
48
|
+
|
|
49
|
+
@default_theme = name.to_sym
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def theme_for(name = nil)
|
|
53
|
+
theme_name = name || default_theme
|
|
54
|
+
return UI::Theme.default unless theme_name
|
|
55
|
+
|
|
56
|
+
themes.fetch(theme_name.to_sym)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def resolve_theme_path(path)
|
|
62
|
+
return path if File.absolute_path?(path)
|
|
63
|
+
|
|
64
|
+
File.expand_path(path, root || Dir.pwd)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
attr_accessor :task_executor
|
|
69
|
+
attr_reader :session
|
|
70
|
+
|
|
71
|
+
# Initializes an empty session hash for per-request state storage.
|
|
72
|
+
def initialize
|
|
73
|
+
@session = {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Delegates to the class-level Router, providing instance access to route definitions.
|
|
77
|
+
def routes
|
|
78
|
+
self.class.routes
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def theme
|
|
82
|
+
self.class.theme_for(session[:theme])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def use_theme(name)
|
|
86
|
+
self.class.theme_for(name)
|
|
87
|
+
session[:theme] = name.to_sym
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
# ApplicationModel is the persistent state base for application data models. It includes
|
|
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
|
|
10
|
+
include ActiveModel::Model
|
|
11
|
+
include ActiveModel::Attributes
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/charming/cli.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class CLI
|
|
5
|
+
def initialize(out: $stdout, err: $stderr, pwd: Dir.pwd)
|
|
6
|
+
@out = out
|
|
7
|
+
@err = err
|
|
8
|
+
@pwd = pwd
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(argv)
|
|
12
|
+
command, *args = argv
|
|
13
|
+
case command
|
|
14
|
+
when "new" then new_app(args)
|
|
15
|
+
when "generate", "g" then generate(args)
|
|
16
|
+
else usage(1)
|
|
17
|
+
end
|
|
18
|
+
rescue Generators::Error => e
|
|
19
|
+
err.puts e.message
|
|
20
|
+
1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :out, :err, :pwd
|
|
26
|
+
|
|
27
|
+
def new_app(args)
|
|
28
|
+
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
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate(args)
|
|
35
|
+
force = args.delete("--force")
|
|
36
|
+
type = args.shift || raise(Generators::Error, "Usage: charming generate TYPE NAME [actions]")
|
|
37
|
+
generator(type, args, force).generate
|
|
38
|
+
0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def generator(type, args, force)
|
|
42
|
+
name = args.shift || raise(Generators::Error, "Usage: charming generate #{type} NAME")
|
|
43
|
+
generator_class(type).new(name, args, out: out, destination: pwd, force: force)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def generator_class(type)
|
|
47
|
+
{
|
|
48
|
+
"controller" => Generators::ControllerGenerator,
|
|
49
|
+
"screen" => Generators::ScreenGenerator,
|
|
50
|
+
"view" => Generators::ViewGenerator,
|
|
51
|
+
"component" => Generators::ComponentGenerator
|
|
52
|
+
}.fetch(type) { raise Generators::Error, "Unknown generator: #{type}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def usage(status)
|
|
56
|
+
err.puts "Usage: charming new NAME | charming generate TYPE NAME [actions]"
|
|
57
|
+
status
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
|
|
5
|
+
# helper methods (text, box, row, column, etc.), and rendering via render.
|
|
6
|
+
class Component < View
|
|
7
|
+
end
|
|
8
|
+
end
|