potty 0.0.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/bin/potty_demo +128 -0
- data/examples/test_view.rb +87 -0
- data/lib/potty/animator.rb +127 -0
- data/lib/potty/application.rb +136 -0
- data/lib/potty/border.rb +51 -0
- data/lib/potty/events.rb +46 -0
- data/lib/potty/keys.rb +71 -0
- data/lib/potty/layout.rb +47 -0
- data/lib/potty/sprite.rb +49 -0
- data/lib/potty/sprites/sample.rb +36 -0
- data/lib/potty/style.rb +14 -0
- data/lib/potty/surface.rb +46 -0
- data/lib/potty/surfaces/curses_surface.rb +114 -0
- data/lib/potty/surfaces/inline_surface.rb +148 -0
- data/lib/potty/theme.rb +82 -0
- data/lib/potty/version.rb +5 -0
- data/lib/potty/view.rb +132 -0
- data/lib/potty/widgets/base.rb +114 -0
- data/lib/potty/widgets/button.rb +52 -0
- data/lib/potty/widgets/checkbox_group.rb +101 -0
- data/lib/potty/widgets/colored_fields_item.rb +56 -0
- data/lib/potty/widgets/container.rb +113 -0
- data/lib/potty/widgets/countdown.rb +81 -0
- data/lib/potty/widgets/flash_message.rb +69 -0
- data/lib/potty/widgets/label.rb +37 -0
- data/lib/potty/widgets/list.rb +192 -0
- data/lib/potty/widgets/list_item.rb +120 -0
- data/lib/potty/widgets/panel.rb +49 -0
- data/lib/potty/widgets/progress_bar.rb +55 -0
- data/lib/potty/widgets/radio_group.rb +121 -0
- data/lib/potty/widgets/spinner.rb +84 -0
- data/lib/potty/widgets/status_bar.rb +56 -0
- data/lib/potty/widgets/text_input.rb +138 -0
- data/lib/potty/widgets/toggle.rb +65 -0
- data/lib/potty/window_manager.rb +55 -0
- data/lib/potty.rb +35 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 025204ee0b4853237fc9ea15a4714261d6583e827077cc38fd34dbe635f186c6
|
|
4
|
+
data.tar.gz: ffc7a323d75589bbf073a70b136aec36f65a1a4ce4126d9c7a7986cabe484672
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b0534b15973e60c6fde7848fd7ff1d93db9e47f7f2b8bb3fa3b6e24cedefa36dfffb1c6c22b6d9fdc8e43a174a50be526c5b82cbb20793dfa9d772ff65d3bbda
|
|
7
|
+
data.tar.gz: 1863a5f74f255b034dcb1f86dd23067c3823be15967f359249cc1d4f6c3b0ab54a959c7b18221c3a82c45f4cf573cf483ef63425cdef0196f734d0a2ce24da59
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to potty are documented here. The format is loosely based
|
|
4
|
+
on [Keep a Changelog](https://keepachangelog.com/), and the project follows
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.0.1] - 2026-05-30
|
|
8
|
+
|
|
9
|
+
Initial public release. (Developed privately under the working name `cursed`,
|
|
10
|
+
which was already taken on RubyGems; renamed to `potty` for release.)
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Application / View / Widget framework** — a view stack with push/pop
|
|
14
|
+
navigation, focus cycling (Tab/Shift+Tab, recursing into containers), a tick
|
|
15
|
+
loop for time-driven widgets, and suspend/resume.
|
|
16
|
+
- **Render-target Surface abstraction** — the same widget tree renders to a
|
|
17
|
+
full-screen curses display (`:curses`, default) or an inline ANSI region
|
|
18
|
+
redrawn in place under the cursor (`:inline`), via `Application.new(mode:)`.
|
|
19
|
+
- **Composition** — `Container`, `VBox`, `HBox`, and bordered `Panel`, with a
|
|
20
|
+
shared `Border` helper (single/rounded/double/heavy).
|
|
21
|
+
- **Widgets** — `List` (+ `ActionItem`/`SeparatorItem`/`InputItem`/
|
|
22
|
+
`ColoredFieldsItem`), `Label`, `Button`, `TextInput`, `Toggle`, `RadioGroup`,
|
|
23
|
+
`CheckboxGroup`, `Spinner`, `Countdown`, `FlashMessage`, `StatusBar`,
|
|
24
|
+
`ProgressBar`.
|
|
25
|
+
- **Animation** — `Sprite` + `Animator` (loop/once, fps, on_complete).
|
|
26
|
+
- **Events** — an `on`/`emit` mixin so widgets emit semantic events
|
|
27
|
+
(`:change`, `:press`, `:select`, `:focus`, `:complete`, `:expire`).
|
|
28
|
+
- **Theming** — a semantic palette (`theme.style`) resolved per surface;
|
|
29
|
+
transparent (terminal-default) backgrounds; injectable custom palette.
|
|
30
|
+
- **`Keys`** — named key codes with `getch` String/Integer normalization.
|
|
31
|
+
- A self-demonstrating `bin/potty_demo`.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TwilightCoders
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# potty
|
|
2
|
+
|
|
3
|
+
A curses-based terminal UI framework for Ruby. Build full-screen TUIs from a
|
|
4
|
+
tree of composable widgets, with view-stack navigation, a focus model, theming,
|
|
5
|
+
and frame-based animation.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────┐
|
|
9
|
+
│ → Say hello │
|
|
10
|
+
│ Configure │
|
|
11
|
+
│ Quit │
|
|
12
|
+
└─────────────────────────────────┘
|
|
13
|
+
↑↓: Navigate HELLO ESC: Quit
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
> **Status:** early release (`0.0.1`). The API is young and evolving under real
|
|
17
|
+
> consumers. Expect additive change.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Requires the `curses` gem (a native extension) and a real terminal. Not yet
|
|
22
|
+
published to RubyGems — depend on it via git or a local path:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Gemfile
|
|
26
|
+
gem 'potty', github: 'TwilightCoders/potty'
|
|
27
|
+
# or, for local development:
|
|
28
|
+
gem 'potty', path: '../potty'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require 'potty'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
A `Potty::Application` runs a stack of `Potty::View`s. A view builds a tree of
|
|
38
|
+
widgets in `build_layout` and reacts to input. Subclass `View`, hand the app a
|
|
39
|
+
root view, and call `run`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require 'potty'
|
|
43
|
+
|
|
44
|
+
class HelloView < Potty::View
|
|
45
|
+
def build_layout
|
|
46
|
+
@flash = Potty::Widgets::FlashMessage.new(app)
|
|
47
|
+
|
|
48
|
+
@list = Potty::Widgets::List.new(app)
|
|
49
|
+
@list.items = [
|
|
50
|
+
Potty::Widgets::ActionItem.new('Say hello') { flash_success('Hello!') },
|
|
51
|
+
Potty::Widgets::ActionItem.new('Quit') { app.quit }
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
@status = Potty::Widgets::StatusBar.new(app)
|
|
55
|
+
@status.left_text = '↑↓: Navigate'
|
|
56
|
+
@status.center_text = 'HELLO'
|
|
57
|
+
@status.right_text = 'ESC: Quit'
|
|
58
|
+
|
|
59
|
+
@widgets = [@flash, @list, @status]
|
|
60
|
+
@list.focus
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def handle_escape
|
|
64
|
+
app.quit
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
app = Potty::Application.new
|
|
70
|
+
app.run(HelloView.new(app))
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For a guided tour of the whole widget set, run the bundled demo in a real
|
|
74
|
+
terminal:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bin/potty_demo # from a checkout
|
|
78
|
+
potty_demo # when the gem is installed
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It's a single self-demonstrating dashboard: one composed layout (so it shows
|
|
82
|
+
off the layout system by *being* it) whose form controls reconfigure the demo
|
|
83
|
+
live — the Border radio restyles the very panels you're looking at, the Title
|
|
84
|
+
field renames the header, the checkboxes show/hide the live animation. See
|
|
85
|
+
[`examples/test_view.rb`](examples/test_view.rb) for a smaller example.
|
|
86
|
+
|
|
87
|
+
## Core concepts
|
|
88
|
+
|
|
89
|
+
### Application
|
|
90
|
+
|
|
91
|
+
`Potty::Application` owns the curses lifecycle and the event loop.
|
|
92
|
+
|
|
93
|
+
- `run(root_view)` — set up curses, push the root view, loop until `quit`.
|
|
94
|
+
- `push_view(view)` / `pop_view` — navigate a stack of views (e.g. drilling
|
|
95
|
+
into a submenu and back). ESC pops by default unless the view's
|
|
96
|
+
`handle_escape` consumes it.
|
|
97
|
+
- `quit` — stop the loop.
|
|
98
|
+
- `suspend` / `resume` — tear down and rebuild curses so you can shell out to an
|
|
99
|
+
external process (an editor, a pager) and come back cleanly.
|
|
100
|
+
- `tick_interval=` — see [Animation & ticking](#animation--ticking).
|
|
101
|
+
|
|
102
|
+
### View
|
|
103
|
+
|
|
104
|
+
Subclass `Potty::View` and override:
|
|
105
|
+
|
|
106
|
+
- `build_layout` — construct widgets into `@widgets` and call `focus` on the
|
|
107
|
+
initial one. Called once at construction.
|
|
108
|
+
- `handle_escape` — return `true` to consume ESC (e.g. `app.quit` or a confirm),
|
|
109
|
+
`false` to let the app pop the view.
|
|
110
|
+
- optionally `on_activate` / `on_deactivate` — run when the view becomes
|
|
111
|
+
(in)active on the stack; a good place to rebuild dynamic lists.
|
|
112
|
+
|
|
113
|
+
The view routes keys to the focused widget first, then cycles focus with
|
|
114
|
+
**Tab / Shift+Tab** across widgets whose `can_focus?` is true (recursing into
|
|
115
|
+
containers). `flash_success`, `flash_error`, and `flash_info` post messages to a
|
|
116
|
+
`FlashMessage` widget in the tree. Widgets are laid out top-to-bottom by
|
|
117
|
+
[`Layout`](#layout), unless you nest them in [containers](#containers--composition).
|
|
118
|
+
|
|
119
|
+
### Events
|
|
120
|
+
|
|
121
|
+
Every widget mixes in `Potty::Events`, so you can wire a UI together
|
|
122
|
+
declaratively instead of subclassing for one-off behavior. Widgets emit semantic
|
|
123
|
+
events; subscribe with `on`:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
name.on(:change) { |text| greeting.text = "Hello, #{text}" }
|
|
127
|
+
notify.on(:change) { |on| features.visible = on }
|
|
128
|
+
save.on(:press) { app.pop_view }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Emitted events: `:focus`/`:blur` (any widget), `:change` (`TextInput`, `Toggle`,
|
|
132
|
+
`RadioGroup`, `CheckboxGroup`), `:select`/`:activate` (`List`), `:press`
|
|
133
|
+
(`Button`), `:expire` (`Countdown`), `:complete` (`Animator`, `Spinner`). `on`
|
|
134
|
+
returns self and supports multiple listeners. Keys are named in `Potty::Keys`
|
|
135
|
+
(`ENTER`, `ESC`, `TAB`, `UP`, …) — no magic integers in your `handle_key`.
|
|
136
|
+
|
|
137
|
+
### Containers & composition
|
|
138
|
+
|
|
139
|
+
A `View`'s `@widgets` is laid out as a vertical stack, but any entry can be a
|
|
140
|
+
container holding more widgets — so you get nesting, columns, and framed panels.
|
|
141
|
+
Render, tick, and focus traversal all recurse.
|
|
142
|
+
|
|
143
|
+
- **`VBox`** / **`HBox`** — vertical stack / equal-width columns (`spacing:`).
|
|
144
|
+
- **`Panel`** — bordered, optionally titled container (`title:`, `style:`,
|
|
145
|
+
`color:`) that insets its children.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
Potty::Widgets::Panel.new(app, title: 'Settings').add(
|
|
149
|
+
Potty::Widgets::Label.new(app, text: 'Name'),
|
|
150
|
+
Potty::Widgets::TextInput.new(app),
|
|
151
|
+
Potty::Widgets::Button.new(app, label: 'Save')
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Widgets
|
|
156
|
+
|
|
157
|
+
Every widget inherits `Potty::Widgets::Base` and implements as much of this
|
|
158
|
+
contract as it needs:
|
|
159
|
+
|
|
160
|
+
| Method | Purpose |
|
|
161
|
+
| --- | --- |
|
|
162
|
+
| `preferred_height(width)` | rows the widget wants (drives stack layout) |
|
|
163
|
+
| `layout(rect)` / `on_layout` | receive assigned position+size |
|
|
164
|
+
| `render(window)` | draw onto the curses window |
|
|
165
|
+
| `handle_key(ch)` | handle input; return `true` if consumed |
|
|
166
|
+
| `tick(now)` | per-frame update (time-driven widgets only) |
|
|
167
|
+
| `can_focus?` / `focus` / `blur` | focus participation |
|
|
168
|
+
| `show` / `hide` | visibility |
|
|
169
|
+
|
|
170
|
+
#### Widget catalog
|
|
171
|
+
|
|
172
|
+
- **`List`** — scrollable list of heterogeneous `ListItem`s. Delegates unhandled
|
|
173
|
+
keys to the selected item (how `InputItem` captures typing). Item types:
|
|
174
|
+
`ActionItem` (callback on Enter), `DisabledItem` / `SeparatorItem` (skipped by
|
|
175
|
+
selection), `InputItem` (inline editable row), and `ColoredFieldsItem`
|
|
176
|
+
(multi-color segments via `render_custom`).
|
|
177
|
+
- **`Label`** — static, non-focusable single-line text. `text:`, `color:`,
|
|
178
|
+
`bold:`.
|
|
179
|
+
- **`Button`** — focusable; Space/Enter emits `:press`. `on_press:` shortcut.
|
|
180
|
+
- **`TextInput`** — single-line editable field. Block cursor when focused, dim
|
|
181
|
+
placeholder, horizontal scroll. `text` / `text=`, `placeholder`,
|
|
182
|
+
`max_length`, emits `:change` (snapshot). ASCII input.
|
|
183
|
+
- **`Toggle`** — boolean `[●]`/`[○]`; Space/Enter flips. `value` / `value=`,
|
|
184
|
+
`label`, emits `:change`.
|
|
185
|
+
- **`RadioGroup`** — N mutually exclusive `{value, label}` options; arrows move a
|
|
186
|
+
cursor, Space/Enter commits. `selected` / `selected=`, emits `:change`.
|
|
187
|
+
- **`CheckboxGroup`** — multi-select sibling of `RadioGroup`; Space/Enter toggles
|
|
188
|
+
the cursor row. `selected`, `selected?`, emits `:change` (selected values).
|
|
189
|
+
- **`Spinner`** — single-line activity indicator: animated braille glyph + live
|
|
190
|
+
`label` + trailing state. `complete!(:success/:failure/:cancelled)` freezes the
|
|
191
|
+
glyph and flips color (idempotent); emits `:complete`. Tick-driven.
|
|
192
|
+
- **`Countdown`** — passive display counting down N seconds, emits `:expire`.
|
|
193
|
+
Tick-driven (see below).
|
|
194
|
+
- **`FlashMessage`** — transient success/error/warning/info banner with timeout.
|
|
195
|
+
- **`StatusBar`** — bottom bar with `left_text` / `center_text` / `right_text`.
|
|
196
|
+
- **`ProgressBar`** — pure-string bar using Unicode eighth-blocks for sub-cell
|
|
197
|
+
resolution; `render(0.0..1.0)` returns a string (usable on a curses window or
|
|
198
|
+
plain stdout).
|
|
199
|
+
|
|
200
|
+
### Layout
|
|
201
|
+
|
|
202
|
+
`Potty::Layout` is pure geometry over a `Rect(x, y, width, height)`:
|
|
203
|
+
|
|
204
|
+
- `Layout.stack(container, widgets, spacing:)` — vertical stack (the default a
|
|
205
|
+
view uses), querying each widget's `preferred_height`.
|
|
206
|
+
- `Layout.split_horizontal(container, ratio:)` — left/right split.
|
|
207
|
+
- `Layout.fill(container)` — full container.
|
|
208
|
+
|
|
209
|
+
### Theme
|
|
210
|
+
|
|
211
|
+
`Potty::Theme` maps semantic names to curses color pairs: `:normal`,
|
|
212
|
+
`:selected`, `:disabled`, `:success`, `:error`, `:warning`, `:info`, `:dim`,
|
|
213
|
+
`:header`, `:status`.
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
theme[:error] # color-pair attr
|
|
217
|
+
theme.attr(:selected, bold: true) # attr with A_BOLD / A_UNDERLINE OR'd in
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Animation & ticking
|
|
221
|
+
|
|
222
|
+
The event loop normally blocks on input. To drive animations and countdowns,
|
|
223
|
+
give the app a tick interval — the loop then wakes every N milliseconds, fans a
|
|
224
|
+
single shared `Time.now` out to every widget's `tick(now)`, and repaints.
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
app = Potty::Application.new
|
|
228
|
+
app.tick_interval = 40 # ms; ≈25fps. nil (default) = blocking, input-only.
|
|
229
|
+
app.run(view)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`Potty::Sprite` is a named sequence of multiline-string frames; `Potty::Animator`
|
|
233
|
+
is a widget that plays sprites by elapsed-time / fps, with `:loop` and `:once`
|
|
234
|
+
modes (`:once` fires `on_complete`).
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class LoaderView < Potty::View
|
|
238
|
+
def build_layout
|
|
239
|
+
@spinner = Potty::Animator.new(app, centered: true, color: :info)
|
|
240
|
+
@spinner << Potty::Sprites::Sample.spinner # first sprite auto-plays
|
|
241
|
+
@label = Potty::Widgets::Label.new(app, text: 'Loading…', color: :info)
|
|
242
|
+
@widgets = [@spinner, @label]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
app = Potty::Application.new
|
|
247
|
+
app.tick_interval = 40
|
|
248
|
+
app.run(LoaderView.new(app))
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`add_sprite` (`<<`) registers more sprites; `play(:name)` swaps and restarts.
|
|
252
|
+
Define your own `Sprite.new(:name, frames: [...], fps:, mode:)`;
|
|
253
|
+
`Potty::Sprites::Sample` (`spinner`, `plane`) is a template to copy.
|
|
254
|
+
|
|
255
|
+
## Development
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
bundle install
|
|
259
|
+
bundle exec rspec # full suite
|
|
260
|
+
bundle exec rspec spec/potty/animator_spec.rb:42 # a single example
|
|
261
|
+
ruby examples/test_view.rb # interactive demo (needs a real TTY)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Tests cover the pure-logic surface — input handling, frame timing (via an
|
|
265
|
+
injected clock), layout, rendering assertions through fake windows — so the
|
|
266
|
+
suite runs without `init_screen` or a real terminal.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT.
|
data/bin/potty_demo
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# potty_demo - a self-demonstrating dashboard. Run it in a real terminal:
|
|
5
|
+
# bin/potty_demo (from a checkout)
|
|
6
|
+
# potty_demo (when the gem is installed)
|
|
7
|
+
#
|
|
8
|
+
# It's meta: the whole screen is one composed layout (so it demonstrates the
|
|
9
|
+
# layout system by *being* it), and the form widgets on the left are wired to
|
|
10
|
+
# reconfigure the demo itself live - the Border radio restyles the very panels
|
|
11
|
+
# you're looking at, the Title field renames the header, the checkboxes show or
|
|
12
|
+
# hide the live widgets on the right.
|
|
13
|
+
|
|
14
|
+
lib = File.expand_path('../lib', __dir__)
|
|
15
|
+
$LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
|
|
16
|
+
|
|
17
|
+
require 'potty'
|
|
18
|
+
|
|
19
|
+
module PottyDemo
|
|
20
|
+
class Dashboard < Potty::View
|
|
21
|
+
BORDER_STYLES = %i[single rounded double heavy].freeze
|
|
22
|
+
|
|
23
|
+
def build_layout
|
|
24
|
+
@flash = Potty::Widgets::FlashMessage.new(app)
|
|
25
|
+
@header = Potty::Widgets::Label.new(app, text: header_for(''), color: :header, bold: true)
|
|
26
|
+
|
|
27
|
+
build_controls
|
|
28
|
+
build_live
|
|
29
|
+
wire_events
|
|
30
|
+
|
|
31
|
+
@controls_panel = Potty::Widgets::Panel.new(app, title: 'Controls', color: :info).add(
|
|
32
|
+
Potty::Widgets::Label.new(app, text: 'Title', color: :dim), @title_in,
|
|
33
|
+
Potty::Widgets::Label.new(app, text: 'Border', color: :dim), @style_rg,
|
|
34
|
+
@animate,
|
|
35
|
+
Potty::Widgets::Label.new(app, text: 'Show', color: :dim), @show,
|
|
36
|
+
@salute
|
|
37
|
+
)
|
|
38
|
+
@live_panel = Potty::Widgets::Panel.new(app, title: 'Live', color: :info).add(@anim, @spin, @clock)
|
|
39
|
+
@columns = Potty::Widgets::HBox.new(app, spacing: 1).add(@controls_panel, @live_panel)
|
|
40
|
+
|
|
41
|
+
@status = Potty::Widgets::StatusBar.new(app)
|
|
42
|
+
@status.left_text = 'Tab: field Space/Enter: act'
|
|
43
|
+
@status.center_text = 'CURSED'
|
|
44
|
+
@status.right_text = 'ESC: quit'
|
|
45
|
+
|
|
46
|
+
@widgets = [@flash, @header, @columns, @status]
|
|
47
|
+
@title_in.focus
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def handle_escape
|
|
51
|
+
app.quit
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def build_controls
|
|
58
|
+
@title_in = Potty::Widgets::TextInput.new(app, placeholder: 'name this demo')
|
|
59
|
+
@style_rg = Potty::Widgets::RadioGroup.new(app,
|
|
60
|
+
options: BORDER_STYLES.map { |s| { value: s, label: s.to_s } })
|
|
61
|
+
@animate = Potty::Widgets::Toggle.new(app, label: 'Fly the plane', value: true)
|
|
62
|
+
@show = Potty::Widgets::CheckboxGroup.new(app,
|
|
63
|
+
options: [{ value: :anim, label: 'Animation' },
|
|
64
|
+
{ value: :spin, label: 'Spinner' },
|
|
65
|
+
{ value: :clock, label: 'Countdown' }],
|
|
66
|
+
selected: %i[anim spin clock])
|
|
67
|
+
@salute = Potty::Widgets::Button.new(app, label: 'Salute', color: :success)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_live
|
|
71
|
+
@anim = Potty::Animator.new(app, color: :info, centered: true)
|
|
72
|
+
@anim << Potty::Sprites::Sample.plane # plays once, then loops the spinner
|
|
73
|
+
@anim << Potty::Sprites::Sample.spinner
|
|
74
|
+
@anim.on(:complete) { @anim.play(:spinner) }
|
|
75
|
+
|
|
76
|
+
@spin = Potty::Widgets::Spinner.new(app, label: 'rendering frames', prefix: '')
|
|
77
|
+
@clock = Potty::Widgets::Countdown.new(app, seconds: 9,
|
|
78
|
+
format: ->(r) { "auto-salute in #{r}s" })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Every control reconfigures the demo you're looking at.
|
|
82
|
+
def wire_events
|
|
83
|
+
@title_in.on(:change) { |t| @header.text = header_for(t) }
|
|
84
|
+
|
|
85
|
+
@style_rg.on(:change) do |style|
|
|
86
|
+
[@controls_panel, @live_panel].each { |p| p.style = style }
|
|
87
|
+
flash_info("border: #{style}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@animate.on(:change) { |on| on ? @anim.resume : @anim.stop }
|
|
91
|
+
|
|
92
|
+
@show.on(:change) do |sel|
|
|
93
|
+
@anim.visible = sel.include?(:anim)
|
|
94
|
+
@spin.visible = sel.include?(:spin)
|
|
95
|
+
@clock.visible = sel.include?(:clock)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@salute.on(:press) { salute! }
|
|
99
|
+
@clock.on(:expire) { salute!; @clock.start } # recurring
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def salute!
|
|
103
|
+
@anim.play(:plane)
|
|
104
|
+
flash_success('o7')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def header_for(title)
|
|
108
|
+
title = title.strip
|
|
109
|
+
base = title.empty? ? 'a TUI kit that demos itself' : title
|
|
110
|
+
"potty - #{base}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if __FILE__ == $PROGRAM_NAME
|
|
116
|
+
begin
|
|
117
|
+
app = Potty::Application.new
|
|
118
|
+
app.tick_interval = 40 # ~25fps so animation/countdown advance
|
|
119
|
+
app.run(PottyDemo::Dashboard.new(app))
|
|
120
|
+
rescue Interrupt
|
|
121
|
+
puts "\nInterrupted."
|
|
122
|
+
exit 130
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
warn "Error: #{e.message}"
|
|
125
|
+
warn e.backtrace if ENV['DEBUG']
|
|
126
|
+
exit 1
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/potty'
|
|
4
|
+
|
|
5
|
+
module Potty
|
|
6
|
+
module Examples
|
|
7
|
+
# Simple test view to verify curses setup
|
|
8
|
+
class TestView < Potty::View
|
|
9
|
+
def build_layout
|
|
10
|
+
@flash = Widgets::FlashMessage.new(app)
|
|
11
|
+
|
|
12
|
+
@list = Widgets::List.new(app)
|
|
13
|
+
@list.items = build_test_items
|
|
14
|
+
@list.on_activate = proc { |item| handle_activation(item) }
|
|
15
|
+
|
|
16
|
+
@status = Widgets::StatusBar.new(app)
|
|
17
|
+
@status.left_text = "\u2191\u2193: Navigate"
|
|
18
|
+
@status.center_text = "CURSES TEST"
|
|
19
|
+
@status.right_text = "ESC: Quit"
|
|
20
|
+
|
|
21
|
+
@widgets = [@flash, @list, @status]
|
|
22
|
+
@list.focus
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_test_items
|
|
26
|
+
items = []
|
|
27
|
+
|
|
28
|
+
items << Widgets::SeparatorItem.new("\u2501\u2501 TEST ITEMS \u2501\u2501")
|
|
29
|
+
|
|
30
|
+
items << Widgets::ActionItem.new("Show success message") do
|
|
31
|
+
flash_success("This is a success message!")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
items << Widgets::ActionItem.new("Show error message") do
|
|
35
|
+
flash_error("This is an error message!")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
items << Widgets::ActionItem.new("Show info message") do
|
|
39
|
+
flash_info("This is an info message!")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
items << Widgets::SeparatorItem.new
|
|
43
|
+
|
|
44
|
+
items << Widgets::DisabledItem.new("This item is disabled")
|
|
45
|
+
|
|
46
|
+
items << Widgets::SeparatorItem.new("\u2501\u2501 INPUT TEST \u2501\u2501")
|
|
47
|
+
|
|
48
|
+
items << Widgets::InputItem.new("Type something", default: "") do |value|
|
|
49
|
+
flash_success("You typed: #{value}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
items << Widgets::SeparatorItem.new
|
|
53
|
+
|
|
54
|
+
items << Widgets::ActionItem.new("Quit application") do
|
|
55
|
+
app.quit
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
items
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_activation(item)
|
|
62
|
+
# Item handles its own activation
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def handle_escape
|
|
66
|
+
app.quit
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Run the test if executed directly
|
|
74
|
+
if __FILE__ == $0
|
|
75
|
+
begin
|
|
76
|
+
app = Potty::Application.new
|
|
77
|
+
root_view = Potty::Examples::TestView.new(app)
|
|
78
|
+
app.run(root_view)
|
|
79
|
+
rescue Interrupt
|
|
80
|
+
puts "\nInterrupted. Exiting..."
|
|
81
|
+
exit 130
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
puts "Error: #{e.message}"
|
|
84
|
+
puts e.backtrace if ENV['DEBUG']
|
|
85
|
+
exit 1
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'widgets/base'
|
|
4
|
+
require_relative 'sprite'
|
|
5
|
+
|
|
6
|
+
module Potty
|
|
7
|
+
# Frame-based animation widget. Holds one or more named Sprites and
|
|
8
|
+
# advances the active one at its fps on each tick. Being a Widget, it
|
|
9
|
+
# composes into a View tree like anything else.
|
|
10
|
+
#
|
|
11
|
+
# Playback is time-driven: tick(now) advances the frame only when enough
|
|
12
|
+
# wall-clock time has elapsed for the active sprite's fps. The Application
|
|
13
|
+
# event loop supplies `now`; tests can supply it too, which makes the whole
|
|
14
|
+
# thing deterministic.
|
|
15
|
+
class Animator < Widgets::Base
|
|
16
|
+
attr_reader :current, :frame_index
|
|
17
|
+
attr_accessor :color, :on_complete, :centered
|
|
18
|
+
|
|
19
|
+
def initialize(app, color: :normal, centered: false)
|
|
20
|
+
super(app)
|
|
21
|
+
@sprites = {}
|
|
22
|
+
@current = nil # active sprite name (Symbol)
|
|
23
|
+
@frame_index = 0
|
|
24
|
+
@last_advance = nil # Time of the last frame advance
|
|
25
|
+
@playing = false
|
|
26
|
+
@color = color
|
|
27
|
+
@centered = centered
|
|
28
|
+
@on_complete = nil # called with self when a :once sprite finishes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Register a sprite. The first one added becomes active and starts playing.
|
|
32
|
+
def add_sprite(sprite)
|
|
33
|
+
@sprites[sprite.name] = sprite
|
|
34
|
+
play(sprite.name) if @current.nil?
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
alias << add_sprite
|
|
38
|
+
|
|
39
|
+
def sprite
|
|
40
|
+
@sprites[@current]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def sprite_names
|
|
44
|
+
@sprites.keys
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Switch the active sprite and (re)start playback from frame 0.
|
|
48
|
+
# Pass reset: false to keep the current frame index (e.g. crossfade).
|
|
49
|
+
def play(name, reset: true)
|
|
50
|
+
name = name.to_sym
|
|
51
|
+
return self unless @sprites.key?(name)
|
|
52
|
+
|
|
53
|
+
@current = name
|
|
54
|
+
if reset
|
|
55
|
+
@frame_index = 0
|
|
56
|
+
@last_advance = nil
|
|
57
|
+
end
|
|
58
|
+
@playing = true
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop
|
|
63
|
+
@playing = false
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resume
|
|
68
|
+
@playing = true
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def playing?
|
|
73
|
+
@playing
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def preferred_height(_width)
|
|
77
|
+
sprite ? sprite.height : 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tick(now)
|
|
81
|
+
return unless @playing && sprite
|
|
82
|
+
|
|
83
|
+
@last_advance ||= now
|
|
84
|
+
frame_duration = 1.0 / sprite.fps
|
|
85
|
+
elapsed = now - @last_advance
|
|
86
|
+
return if elapsed < frame_duration
|
|
87
|
+
|
|
88
|
+
# Catch up if the loop ran slow, but never overshoot a :once endpoint.
|
|
89
|
+
steps = (elapsed / frame_duration).floor
|
|
90
|
+
@last_advance += steps * frame_duration
|
|
91
|
+
advance(steps)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def render(window)
|
|
95
|
+
return unless @visible && @rect && sprite
|
|
96
|
+
|
|
97
|
+
attr = theme[@color]
|
|
98
|
+
sprite.frame_lines(@frame_index).each_with_index do |line, row|
|
|
99
|
+
y = @rect.y + row
|
|
100
|
+
break if y >= @rect.y + @rect.height
|
|
101
|
+
|
|
102
|
+
x = @rect.x
|
|
103
|
+
x += [(@rect.width - line.length) / 2, 0].max if @centered
|
|
104
|
+
clipped = line[0, @rect.width] || ''
|
|
105
|
+
window.setpos(y, x)
|
|
106
|
+
window.attron(attr) { window.addstr(clipped) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def advance(steps)
|
|
113
|
+
case sprite.mode
|
|
114
|
+
when :once
|
|
115
|
+
@frame_index += steps
|
|
116
|
+
if @frame_index >= sprite.frame_count - 1
|
|
117
|
+
@frame_index = sprite.frame_count - 1
|
|
118
|
+
@playing = false
|
|
119
|
+
@on_complete&.call(self)
|
|
120
|
+
emit(:complete, self)
|
|
121
|
+
end
|
|
122
|
+
else # :loop
|
|
123
|
+
@frame_index = (@frame_index + steps) % sprite.frame_count
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|