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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +31 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +270 -0
  5. data/bin/potty_demo +128 -0
  6. data/examples/test_view.rb +87 -0
  7. data/lib/potty/animator.rb +127 -0
  8. data/lib/potty/application.rb +136 -0
  9. data/lib/potty/border.rb +51 -0
  10. data/lib/potty/events.rb +46 -0
  11. data/lib/potty/keys.rb +71 -0
  12. data/lib/potty/layout.rb +47 -0
  13. data/lib/potty/sprite.rb +49 -0
  14. data/lib/potty/sprites/sample.rb +36 -0
  15. data/lib/potty/style.rb +14 -0
  16. data/lib/potty/surface.rb +46 -0
  17. data/lib/potty/surfaces/curses_surface.rb +114 -0
  18. data/lib/potty/surfaces/inline_surface.rb +148 -0
  19. data/lib/potty/theme.rb +82 -0
  20. data/lib/potty/version.rb +5 -0
  21. data/lib/potty/view.rb +132 -0
  22. data/lib/potty/widgets/base.rb +114 -0
  23. data/lib/potty/widgets/button.rb +52 -0
  24. data/lib/potty/widgets/checkbox_group.rb +101 -0
  25. data/lib/potty/widgets/colored_fields_item.rb +56 -0
  26. data/lib/potty/widgets/container.rb +113 -0
  27. data/lib/potty/widgets/countdown.rb +81 -0
  28. data/lib/potty/widgets/flash_message.rb +69 -0
  29. data/lib/potty/widgets/label.rb +37 -0
  30. data/lib/potty/widgets/list.rb +192 -0
  31. data/lib/potty/widgets/list_item.rb +120 -0
  32. data/lib/potty/widgets/panel.rb +49 -0
  33. data/lib/potty/widgets/progress_bar.rb +55 -0
  34. data/lib/potty/widgets/radio_group.rb +121 -0
  35. data/lib/potty/widgets/spinner.rb +84 -0
  36. data/lib/potty/widgets/status_bar.rb +56 -0
  37. data/lib/potty/widgets/text_input.rb +138 -0
  38. data/lib/potty/widgets/toggle.rb +65 -0
  39. data/lib/potty/window_manager.rb +55 -0
  40. data/lib/potty.rb +35 -0
  41. 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