tuile 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/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/examples/file_commander.rb +196 -0
- data/examples/hello_world.rb +29 -0
- data/lib/tuile/component/has_content.rb +69 -0
- data/lib/tuile/component/info_window.rb +30 -0
- data/lib/tuile/component/label.rb +63 -0
- data/lib/tuile/component/layout.rb +98 -0
- data/lib/tuile/component/list.rb +583 -0
- data/lib/tuile/component/log_window.rb +59 -0
- data/lib/tuile/component/picker_window.rb +97 -0
- data/lib/tuile/component/popup.rb +127 -0
- data/lib/tuile/component/text_field.rb +209 -0
- data/lib/tuile/component/window.rb +215 -0
- data/lib/tuile/component.rb +236 -0
- data/lib/tuile/event_queue.rb +192 -0
- data/lib/tuile/fake_event_queue.rb +31 -0
- data/lib/tuile/fake_screen.rb +58 -0
- data/lib/tuile/keys.rb +63 -0
- data/lib/tuile/mouse_event.rb +49 -0
- data/lib/tuile/point.rb +14 -0
- data/lib/tuile/rect.rb +58 -0
- data/lib/tuile/screen.rb +377 -0
- data/lib/tuile/screen_pane.rb +174 -0
- data/lib/tuile/size.rb +42 -0
- data/lib/tuile/version.rb +6 -0
- data/lib/tuile/vertical_scroll_bar.rb +46 -0
- data/lib/tuile.rb +37 -0
- data/sig/tuile.rbs +1502 -0
- metadata +197 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 246774aae8809b045b95adef86263642f561d726cb907162101cef1c94135142
|
|
4
|
+
data.tar.gz: d6858c0ece1e461a5ff767572ac1fd6255eb338a84caff1b12d0a0db66aec6b0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 97ecd56ae772b96c8944d78245ee628f56dbcf4a4c0a955319bc8d1bf630132dd6b23ec6bb23899a97f45b6b4b92b3686fd548c056bc9eb107caa06bcaf97a89
|
|
7
|
+
data.tar.gz: 6b0f6c0f887ede7a2e7aa3309241b3b39844f5a2819f92fafdee52b1be4e88a2c9fc5a32f18ddcc3e0a671c0dedb4724de833da75d4c391a62619b8d4be58136
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"tuile" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please open a bug ticket.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Vysny
|
|
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,378 @@
|
|
|
1
|
+
# Tuile
|
|
2
|
+
|
|
3
|
+
Tuile is a small component-oriented terminal-UI framework for Ruby. You build
|
|
4
|
+
your interface as a tree of components — windows, lists, text fields, popups —
|
|
5
|
+
and Tuile runs a single-threaded event loop that dispatches keys and mouse
|
|
6
|
+
events, then repaints everything that was invalidated since the last tick. The
|
|
7
|
+
name is French for "roof tile": small pieces that compose into a larger whole.
|
|
8
|
+
|
|
9
|
+
The design philosophy — "boxes within boxes" that talk via listeners and data
|
|
10
|
+
providers — is described in
|
|
11
|
+
[component-oriented programming](https://mvysny.github.io/component-oriented-programming/).
|
|
12
|
+
Tuile is that approach applied to a terminal.
|
|
13
|
+
|
|
14
|
+
If you have looked at the alternatives:
|
|
15
|
+
|
|
16
|
+
- [tty-toolkit](https://ttytoolkit.org/) (`tty-prompt`, `tty-cursor`, …) is a
|
|
17
|
+
set of low-level building blocks rather than a framework: there is no
|
|
18
|
+
component tree, no event loop, no invalidation. Tuile sits on top of
|
|
19
|
+
`tty-cursor`/`tty-screen` and adds the framework layer.
|
|
20
|
+
- [vedeu](https://github.com/gavinlaking/vedeu) is the closest Ruby comparable
|
|
21
|
+
but is no longer maintained (last release 2017).
|
|
22
|
+
- [ratatui](https://github.com/ratatui/ratatui) is the popular TUI framework
|
|
23
|
+
in the Rust ecosystem; its immediate-mode API is closer to `tty-prompt` than
|
|
24
|
+
to Tuile's retained component tree.
|
|
25
|
+
|
|
26
|
+
Tuile is the only actively maintained component-oriented TUI framework for
|
|
27
|
+
Ruby that we are aware of.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
> **Note:** the gem name on RubyGems is being finalised. Until release, install
|
|
32
|
+
> from git (see below). Replace
|
|
33
|
+
> `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with
|
|
34
|
+
> the gem name once published.
|
|
35
|
+
|
|
36
|
+
Install the gem and add it to the application's Gemfile by executing:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If bundler is not being used to manage dependencies, install the gem by
|
|
43
|
+
executing:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Until then, point your `Gemfile` at git:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
gem "tuile", git: "https://github.com/mvysny/tuile.git"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Tuile requires Ruby 4.0+.
|
|
56
|
+
|
|
57
|
+
## Hello world
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
require "tuile"
|
|
61
|
+
|
|
62
|
+
# Screen must exist before any Component is built — components reach for
|
|
63
|
+
# Tuile::Screen.instance during invalidate/repaint hooks.
|
|
64
|
+
screen = Tuile::Screen.new
|
|
65
|
+
|
|
66
|
+
label = Tuile::Component::Label.new
|
|
67
|
+
label.text = "Hello, world!"
|
|
68
|
+
|
|
69
|
+
window = Tuile::Component::Window.new("Tuile")
|
|
70
|
+
window.content = label
|
|
71
|
+
|
|
72
|
+
screen.content = window
|
|
73
|
+
window.focus
|
|
74
|
+
begin
|
|
75
|
+
screen.run_event_loop
|
|
76
|
+
ensure
|
|
77
|
+
screen.close
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Save it as `hello.rb` and run `ruby hello.rb`. Press `q` or `ESC` to exit.
|
|
82
|
+
|
|
83
|
+
A larger demo lives in [`examples/file_commander.rb`](examples/file_commander.rb):
|
|
84
|
+
a two-pane file browser with cursor navigation, header label, and a layout
|
|
85
|
+
that follows terminal resize.
|
|
86
|
+
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
### Component tree
|
|
90
|
+
|
|
91
|
+
Everything on screen is a `Tuile::Component`. Components have a `parent`,
|
|
92
|
+
`children`, a `rect` (absolute position), an `active?` flag (true for every
|
|
93
|
+
component on the focus chain root → focused), and an optional `key_shortcut`
|
|
94
|
+
that the framework will route keys to from anywhere in the tree.
|
|
95
|
+
|
|
96
|
+
A single `Tuile::Screen` (process singleton) owns the tree. Under it sits a
|
|
97
|
+
structural `ScreenPane` with three slots: tiled `content` (your app's main
|
|
98
|
+
layout), a `popups` stack (modal overlays), and a one-row `status_bar`.
|
|
99
|
+
Putting popups under the same parent as content means focus traversal,
|
|
100
|
+
attachment checks and child-removed callbacks all work uniformly.
|
|
101
|
+
|
|
102
|
+
### Layout and repaint
|
|
103
|
+
|
|
104
|
+
Tuile uses the simplest possible repaint model — no damage tracking, no
|
|
105
|
+
clipping, no diffing:
|
|
106
|
+
|
|
107
|
+
1. A component that needs to redraw calls `invalidate`. This just records the
|
|
108
|
+
component in a set on the screen.
|
|
109
|
+
2. After the event loop drains the current batch of keyboard/mouse/posted
|
|
110
|
+
events, the screen runs a single `repaint` pass:
|
|
111
|
+
- Invalidated **tiled** components are sorted by tree depth (parents first)
|
|
112
|
+
and each one fully redraws its `rect`.
|
|
113
|
+
- If anything tiled was redrawn, **all popups** are drawn on top in
|
|
114
|
+
stacking order. Popups deliberately overdraw content; there is no
|
|
115
|
+
clipping.
|
|
116
|
+
- The hardware cursor is moved to the focused component's
|
|
117
|
+
`cursor_position` (e.g. into a focused text field).
|
|
118
|
+
|
|
119
|
+
This means a component is responsible for fully covering its own `rect` —
|
|
120
|
+
parents do not paint behind their children. `Layout` enforces this by simply
|
|
121
|
+
not drawing anything itself; its children must tile the entire layout area.
|
|
122
|
+
The trade-off is that if you leave gaps, they will show stale characters; the
|
|
123
|
+
upside is that the repaint code is tiny and predictable, and there is no
|
|
124
|
+
flicker because the terminal is written to in a single batched pass per tick.
|
|
125
|
+
|
|
126
|
+
### Single-threaded event loop
|
|
127
|
+
|
|
128
|
+
`Tuile::Screen#run_event_loop` reads keys and mouse events on a worker thread,
|
|
129
|
+
funnels them through `Tuile::EventQueue`, and processes them on the main
|
|
130
|
+
thread. **All** UI mutations — `rect=`, `content=`, `add_line`, `invalidate`,
|
|
131
|
+
`screen.focused=` — must run on that thread. Most UI methods will raise
|
|
132
|
+
`"UI lock not held"` if you violate this.
|
|
133
|
+
|
|
134
|
+
If you need to mutate the UI from a background thread (an HTTP poll, a file
|
|
135
|
+
watcher, a worker), marshal the work back via the queue:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
Thread.new do
|
|
139
|
+
result = some_long_call
|
|
140
|
+
screen.event_queue.submit { log_window.content.add_line(result) }
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`SIGWINCH` (terminal resize) is plumbed through the same queue: the framework
|
|
145
|
+
posts a size event, runs layout, and invalidates the entire tree. Components
|
|
146
|
+
react by reassigning their child rectangles inside `rect=` — do not install
|
|
147
|
+
your own WINCH handler.
|
|
148
|
+
|
|
149
|
+
### Focus and shortcuts
|
|
150
|
+
|
|
151
|
+
`screen.focused = component` walks parent pointers up to the root, marks the
|
|
152
|
+
whole chain `active?`, and deactivates everything else. Click-to-focus and
|
|
153
|
+
`Layout#on_focus` only ever forward focus to components whose `focusable?`
|
|
154
|
+
returns true, so clicking a `Label` inside a `Window` does not pull focus
|
|
155
|
+
away from the window's content.
|
|
156
|
+
|
|
157
|
+
`key_shortcut` is matched against the focused component's whole subtree
|
|
158
|
+
*unless* the focused component owns the hardware cursor (e.g. a `TextField`
|
|
159
|
+
the user is typing into) — that suppression is what lets text fields swallow
|
|
160
|
+
printable keys without sibling shortcuts hijacking them.
|
|
161
|
+
|
|
162
|
+
## Components
|
|
163
|
+
|
|
164
|
+
All components live under `Tuile::Component::*`. Each one is documented below
|
|
165
|
+
with the methods you are most likely to reach for; full API docs are in the
|
|
166
|
+
YARD output (`bundle exec rake yard`).
|
|
167
|
+
|
|
168
|
+
### `Component::Label`
|
|
169
|
+
|
|
170
|
+
Static text. No word-wrapping; long lines are clipped to `rect.width`. Lines
|
|
171
|
+
may contain Rainbow ANSI formatting.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
label = Tuile::Component::Label.new
|
|
175
|
+
label.text = "Hello, #{Rainbow('world').green}!"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Key API: `text=`, `content_size`.
|
|
179
|
+
|
|
180
|
+
### `Component::Layout`
|
|
181
|
+
|
|
182
|
+
Positions children but paints nothing of its own — children must completely
|
|
183
|
+
cover the layout's `rect`. Use `add(child)` and `remove(child)`. By default,
|
|
184
|
+
focus forwards to the first focusable child.
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
class Header < Tuile::Component::Layout::Absolute
|
|
188
|
+
def initialize
|
|
189
|
+
super
|
|
190
|
+
@left = Tuile::Component::Label.new
|
|
191
|
+
@right = Tuile::Component::Label.new
|
|
192
|
+
add(@left)
|
|
193
|
+
add(@right)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def rect=(new_rect)
|
|
197
|
+
super
|
|
198
|
+
@left.rect = Tuile::Rect.new(rect.left, rect.top, rect.width / 2, 1)
|
|
199
|
+
@right.rect = Tuile::Rect.new(rect.left + rect.width / 2, rect.top,
|
|
200
|
+
rect.width - rect.width / 2, 1)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`Layout::Absolute` is the recommended base when you want to position children
|
|
206
|
+
manually; it inherits all the focus / key dispatch wiring and only asks you
|
|
207
|
+
to override `rect=` to reposition children whenever the parent resizes.
|
|
208
|
+
|
|
209
|
+
### `Component::Window`
|
|
210
|
+
|
|
211
|
+
A bordered frame with a caption and a single content slot. Optionally has a
|
|
212
|
+
`footer` (a component that overlays the bottom border row, e.g. a search
|
|
213
|
+
field) and a built-in scrollbar when the content is a `List`.
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
window = Tuile::Component::Window.new("Settings")
|
|
217
|
+
window.content = some_list
|
|
218
|
+
window.scrollbar = true # only valid when content is a Component::List
|
|
219
|
+
window.footer = search_field
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Key API: `content=`, `footer=`, `caption=`, `scrollbar=`. Windows are
|
|
223
|
+
focusable; focus delegates to content (or footer when active).
|
|
224
|
+
|
|
225
|
+
### `Component::List`
|
|
226
|
+
|
|
227
|
+
A scrollable list of strings with optional cursor and scrollbar.
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
list = Tuile::Component::List.new
|
|
231
|
+
list.lines = ["alpha", "beta", "gamma"]
|
|
232
|
+
list.cursor = Tuile::Component::List::Cursor.new
|
|
233
|
+
list.on_item_chosen = ->(index, line) { Tuile.logger.info("picked #{line}") }
|
|
234
|
+
list.auto_scroll = true # auto-scroll to bottom on add_line
|
|
235
|
+
list.add_line("delta")
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Cursor variants:
|
|
239
|
+
|
|
240
|
+
- `List::Cursor::None` — no cursor (default).
|
|
241
|
+
- `List::Cursor` — lands on every line; arrows / `jk` / Home / End / Ctrl+U /
|
|
242
|
+
Ctrl+D move it.
|
|
243
|
+
- `List::Cursor::Limited` — restricts the cursor to a fixed set of line
|
|
244
|
+
positions (useful for menus where only some rows are selectable).
|
|
245
|
+
|
|
246
|
+
Pressing Enter or left-clicking an item fires `on_item_chosen(index, line)`.
|
|
247
|
+
|
|
248
|
+
Key API: `lines=`, `add_line`, `add_lines`, `cursor=`, `top_line=`,
|
|
249
|
+
`auto_scroll=`, `scrollbar_visibility=`, `on_item_chosen`,
|
|
250
|
+
`select_next` / `select_prev` (search).
|
|
251
|
+
|
|
252
|
+
### `Component::TextField`
|
|
253
|
+
|
|
254
|
+
A single-line input with a real terminal caret. The field does not scroll —
|
|
255
|
+
keystrokes that would overflow `rect.width - 1` are rejected.
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
field = Tuile::Component::TextField.new
|
|
259
|
+
field.text = "initial"
|
|
260
|
+
field.on_change = ->(text) { filter_results(text) }
|
|
261
|
+
field.on_enter = -> { submit(field.text) }
|
|
262
|
+
field.on_escape = -> { popup.close }
|
|
263
|
+
field.on_key_up = -> { results.cursor.go_up_by(1) }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Optional callbacks: `on_change`, `on_enter`, `on_escape`, `on_key_up`,
|
|
267
|
+
`on_key_down`. When set, the corresponding key is consumed by the field; when
|
|
268
|
+
nil, the key falls through to the parent (e.g. ESC closes the surrounding
|
|
269
|
+
popup by default).
|
|
270
|
+
|
|
271
|
+
### `Component::Popup`
|
|
272
|
+
|
|
273
|
+
A modal overlay. It paints nothing itself: it wraps any component as
|
|
274
|
+
`content`, centres itself on the screen, auto-sizes to the wrapped content,
|
|
275
|
+
and consumes `q` / `ESC` to close. Popups are drawn on top of the tiled
|
|
276
|
+
content; multiple popups stack.
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
window = Tuile::Component::Window.new("Help")
|
|
280
|
+
window.content = help_list
|
|
281
|
+
Tuile::Component::Popup.open(content: window)
|
|
282
|
+
# or, equivalently:
|
|
283
|
+
popup = Tuile::Component::Popup.new(content: window)
|
|
284
|
+
popup.open
|
|
285
|
+
# popup.close, popup.open?
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Bare content also works (a `Label`, a `List`…) and yields a borderless popup;
|
|
289
|
+
wrap in a `Window` if you want a frame.
|
|
290
|
+
|
|
291
|
+
### `Component::InfoWindow`
|
|
292
|
+
|
|
293
|
+
A `Window` preconfigured with a `List` of static lines. Convenient for
|
|
294
|
+
read-only information.
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
Tuile::Component::InfoWindow.open("Cannot open", [path, error.message])
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Usable tiled too — just `add` it to a layout.
|
|
301
|
+
|
|
302
|
+
### `Component::PickerWindow`
|
|
303
|
+
|
|
304
|
+
A `Window` that lists single-keystroke options and fires a callback when one
|
|
305
|
+
is picked. ESC / `q` cancel without firing.
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
Tuile::Component::PickerWindow.open("Choose action", [
|
|
309
|
+
["e", "Edit"],
|
|
310
|
+
["d", "Delete"],
|
|
311
|
+
["c", "Copy"]
|
|
312
|
+
]) do |key|
|
|
313
|
+
perform(key)
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The callback receives the picked option's key. The popup variant closes
|
|
318
|
+
itself after the pick.
|
|
319
|
+
|
|
320
|
+
### `Component::LogWindow`
|
|
321
|
+
|
|
322
|
+
A `Window` whose content is an auto-scrolling `List`. Wire your logger at it
|
|
323
|
+
through `LogWindow::IO`:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
log_window = Tuile::Component::LogWindow.new("Log")
|
|
327
|
+
Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
|
|
328
|
+
Tuile.logger.info("started up")
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`LogWindow::IO` implements both `write` (stdlib `Logger`) and `puts`
|
|
332
|
+
(`TTY::Logger` and similar), and marshals lines back through the event queue,
|
|
333
|
+
so it is safe to log from any thread. Tuile itself is silent unless the host
|
|
334
|
+
app sets `Tuile.logger`.
|
|
335
|
+
|
|
336
|
+
## Geometry primitives
|
|
337
|
+
|
|
338
|
+
`Tuile::Point`, `Tuile::Size`, `Tuile::Rect` are `Data.define` value types
|
|
339
|
+
(frozen, structural equality). `Rect` uses **half-open** edges:
|
|
340
|
+
`rect.contains?(point)` is true when `x >= left && x < left + width`. `Rect`
|
|
341
|
+
also offers `centered`, `clamp_height`, `top_left`, etc.
|
|
342
|
+
|
|
343
|
+
## Logging
|
|
344
|
+
|
|
345
|
+
Tuile writes to `Tuile.logger`, which defaults to a `Logger.new(IO::NULL)`
|
|
346
|
+
(silent). Set it to any object that quacks like the stdlib `Logger`
|
|
347
|
+
interface:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
Tuile.logger = Logger.new($stderr) # or:
|
|
351
|
+
Tuile.logger = TTY::Logger.new # duck-typed, works directly
|
|
352
|
+
Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Development
|
|
356
|
+
|
|
357
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then,
|
|
358
|
+
run `bundle exec rake spec` to run the tests. You can also run `bin/console`
|
|
359
|
+
for an interactive prompt that will allow you to experiment.
|
|
360
|
+
|
|
361
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
362
|
+
To release a new version, see [`RELEASING.md`](RELEASING.md).
|
|
363
|
+
|
|
364
|
+
## Contributing
|
|
365
|
+
|
|
366
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
367
|
+
<https://github.com/mvysny/tuile>. Please read [`AGENTS.md`](AGENTS.md) before
|
|
368
|
+
opening a PR — it documents the architecture invariants (singleton screen,
|
|
369
|
+
invalidation/repaint contract, threading rule) that the framework relies on.
|
|
370
|
+
This project is intended to be a safe, welcoming space for collaboration, and
|
|
371
|
+
contributors are expected to adhere to the
|
|
372
|
+
[code of conduct](https://github.com/mvysny/tuile/blob/master/CODE_OF_CONDUCT.md).
|
|
373
|
+
|
|
374
|
+
## License
|
|
375
|
+
|
|
376
|
+
The gem is available as open source under the terms of the
|
|
377
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
378
|
+
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Tuile two-pane file commander. Two windows side by side, each showing a
|
|
5
|
+
# directory listing. Tab switches active pane; arrows / jk move the cursor;
|
|
6
|
+
# Enter descends into a directory (no-op on a regular file); Backspace
|
|
7
|
+
# ascends to the parent. The header label shows the active pane's cwd.
|
|
8
|
+
# Unreadable directories surface an InfoWindow. Layout follows the
|
|
9
|
+
# terminal on resize (WINCH) — the framework dispatches a TTYSizeEvent and
|
|
10
|
+
# the layout's `rect=` rebuilds the geometry.
|
|
11
|
+
#
|
|
12
|
+
# Run from the gem root:
|
|
13
|
+
# bundle exec ruby -Ilib examples/file_commander.rb [start_dir]
|
|
14
|
+
#
|
|
15
|
+
# Press q or ESC to exit.
|
|
16
|
+
|
|
17
|
+
require "tuile"
|
|
18
|
+
|
|
19
|
+
module FileCommanderExample
|
|
20
|
+
# Pastel X11 colors chosen to read on a black background.
|
|
21
|
+
TYPE_COLORS = {
|
|
22
|
+
directory: :lightskyblue,
|
|
23
|
+
symlink: :paleturquoise,
|
|
24
|
+
executable: :lightgreen,
|
|
25
|
+
regular: :lightgray
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# A directory listing pane. Owns its `cwd`, repopulates the list on
|
|
29
|
+
# navigation, and notifies a callback so the shared header label can be
|
|
30
|
+
# rebuilt without the panes knowing about each other.
|
|
31
|
+
class DirList < Tuile::Component::List
|
|
32
|
+
def initialize(start_dir)
|
|
33
|
+
super()
|
|
34
|
+
self.cursor = Tuile::Component::List::Cursor.new
|
|
35
|
+
@cwd = File.expand_path(start_dir)
|
|
36
|
+
@on_cwd_changed = nil
|
|
37
|
+
load_entries
|
|
38
|
+
self.on_item_chosen = method(:descend)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :cwd
|
|
42
|
+
attr_accessor :on_cwd_changed
|
|
43
|
+
|
|
44
|
+
def handle_key(key)
|
|
45
|
+
return false unless active?
|
|
46
|
+
|
|
47
|
+
if Tuile::Keys::BACKSPACES.include?(key)
|
|
48
|
+
ascend
|
|
49
|
+
true
|
|
50
|
+
else
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_focus
|
|
56
|
+
super
|
|
57
|
+
@on_cwd_changed&.call
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def descend(_index, line)
|
|
63
|
+
target = File.expand_path(File.join(@cwd, Rainbow.uncolor(line).chomp("/")))
|
|
64
|
+
change_to(target) if File.directory?(target)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ascend
|
|
68
|
+
parent = File.dirname(@cwd)
|
|
69
|
+
change_to(parent) if parent != @cwd
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def change_to(path)
|
|
73
|
+
previous = @cwd
|
|
74
|
+
@cwd = path
|
|
75
|
+
load_entries
|
|
76
|
+
self.cursor = Tuile::Component::List::Cursor.new
|
|
77
|
+
self.top_line = 0
|
|
78
|
+
@on_cwd_changed&.call
|
|
79
|
+
rescue SystemCallError => e
|
|
80
|
+
@cwd = previous
|
|
81
|
+
Tuile::Component::InfoWindow.open("Cannot open", [path, e.message])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def load_entries
|
|
85
|
+
entries = Dir.children(@cwd).map do |name|
|
|
86
|
+
path = File.join(@cwd, name)
|
|
87
|
+
is_dir = File.directory?(path)
|
|
88
|
+
{ name: name, type: classify(path), display: is_dir ? "#{name}/" : name, dir_first: is_dir ? 0 : 1 }
|
|
89
|
+
end
|
|
90
|
+
entries.sort_by! { |e| [e[:dir_first], e[:name].downcase] }
|
|
91
|
+
self.lines = entries.map { |e| Rainbow(e[:display]).color(TYPE_COLORS[e[:type]]) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Classify by symlink first so a symlink-to-dir still reads as a link.
|
|
95
|
+
def classify(path)
|
|
96
|
+
if File.symlink?(path)
|
|
97
|
+
:symlink
|
|
98
|
+
elsif File.directory?(path)
|
|
99
|
+
:directory
|
|
100
|
+
elsif File.executable?(path)
|
|
101
|
+
:executable
|
|
102
|
+
else
|
|
103
|
+
:regular
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A pane window that advertises navigation shortcuts in the status bar.
|
|
109
|
+
# The active window's `keyboard_hint` is rendered by {Tuile::Screen}
|
|
110
|
+
# alongside the global `q` quit hint, so all the user-facing controls
|
|
111
|
+
# land in one place.
|
|
112
|
+
class PaneWindow < Tuile::Component::Window
|
|
113
|
+
def keyboard_hint
|
|
114
|
+
"Tab #{Rainbow("Switch").cadetblue} " \
|
|
115
|
+
"Enter #{Rainbow("Open").cadetblue} " \
|
|
116
|
+
"Bksp #{Rainbow("Up").cadetblue}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Top-level layout. Header label on the first row, two side-by-side
|
|
121
|
+
# windows below. `rect=` re-runs on the initial mount and on every WINCH,
|
|
122
|
+
# so the split tracks the terminal size automatically.
|
|
123
|
+
class FileCommander < Tuile::Component::Layout::Absolute
|
|
124
|
+
def initialize(left_dir, right_dir)
|
|
125
|
+
super()
|
|
126
|
+
@header = Tuile::Component::Label.new
|
|
127
|
+
add(@header)
|
|
128
|
+
|
|
129
|
+
@left_window = PaneWindow.new
|
|
130
|
+
@left_list = DirList.new(left_dir)
|
|
131
|
+
@left_list.on_cwd_changed = method(:refresh_header)
|
|
132
|
+
@left_window.content = @left_list
|
|
133
|
+
@left_window.scrollbar = true
|
|
134
|
+
add(@left_window)
|
|
135
|
+
|
|
136
|
+
@right_window = PaneWindow.new
|
|
137
|
+
@right_list = DirList.new(right_dir)
|
|
138
|
+
@right_list.on_cwd_changed = method(:refresh_header)
|
|
139
|
+
@right_window.content = @right_list
|
|
140
|
+
@right_window.scrollbar = true
|
|
141
|
+
add(@right_window)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
attr_reader :left_window
|
|
145
|
+
|
|
146
|
+
def rect=(new_rect)
|
|
147
|
+
super
|
|
148
|
+
return if rect.empty?
|
|
149
|
+
|
|
150
|
+
@header.rect = Tuile::Rect.new(rect.left, rect.top, rect.width, 1)
|
|
151
|
+
body_top = rect.top + 1
|
|
152
|
+
body_height = [rect.height - 1, 0].max
|
|
153
|
+
half = rect.width / 2
|
|
154
|
+
@left_window.rect = Tuile::Rect.new(rect.left, body_top, half, body_height)
|
|
155
|
+
@right_window.rect = Tuile::Rect.new(rect.left + half, body_top,
|
|
156
|
+
rect.width - half, body_height)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def handle_key(key)
|
|
160
|
+
if key == "\t"
|
|
161
|
+
toggle_focus
|
|
162
|
+
true
|
|
163
|
+
else
|
|
164
|
+
super
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def toggle_focus
|
|
171
|
+
target = @left_window.active? ? @right_window : @left_window
|
|
172
|
+
screen.focused = target
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def refresh_header
|
|
176
|
+
active_list = @left_list.active? ? @left_list : @right_list
|
|
177
|
+
@header.text = " #{active_list.cwd}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
start_dir = ARGV[0] || Dir.pwd
|
|
183
|
+
unless File.directory?(start_dir)
|
|
184
|
+
warn "#{start_dir}: not a directory"
|
|
185
|
+
exit 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
screen = Tuile::Screen.new
|
|
189
|
+
commander = FileCommanderExample::FileCommander.new(start_dir, start_dir)
|
|
190
|
+
screen.content = commander
|
|
191
|
+
commander.left_window.focus
|
|
192
|
+
begin
|
|
193
|
+
screen.run_event_loop
|
|
194
|
+
ensure
|
|
195
|
+
screen.close
|
|
196
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Tuile hello-world. A Window wrapping a Label.
|
|
5
|
+
#
|
|
6
|
+
# Run from the gem root:
|
|
7
|
+
# bundle exec ruby -Ilib examples/hello_world.rb
|
|
8
|
+
#
|
|
9
|
+
# Press q or ESC to exit.
|
|
10
|
+
|
|
11
|
+
require "tuile"
|
|
12
|
+
|
|
13
|
+
# Screen must exist before any Component is built: components reach for
|
|
14
|
+
# Tuile::Screen.instance during invalidate/repaint hooks.
|
|
15
|
+
screen = Tuile::Screen.new
|
|
16
|
+
|
|
17
|
+
label = Tuile::Component::Label.new
|
|
18
|
+
label.text = "Hello, world!"
|
|
19
|
+
|
|
20
|
+
window = Tuile::Component::Window.new("Tuile")
|
|
21
|
+
window.content = label
|
|
22
|
+
|
|
23
|
+
screen.content = window
|
|
24
|
+
window.focus
|
|
25
|
+
begin
|
|
26
|
+
screen.run_event_loop
|
|
27
|
+
ensure
|
|
28
|
+
screen.close
|
|
29
|
+
end
|