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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-05-02
4
+
5
+ - Initial release
@@ -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