tui_tui 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +51 -14
- data/examples/README.md +28 -0
- data/examples/breakout.rb +225 -0
- data/examples/counter.rb +11 -12
- data/examples/csv_viewer.rb +28 -50
- data/examples/downloads.rb +128 -0
- data/examples/file_browser.rb +106 -56
- data/examples/form.rb +80 -102
- data/examples/life.rb +1 -1
- data/examples/log_viewer.rb +118 -0
- data/examples/todo.rb +43 -64
- data/examples/widgets.rb +4 -4
- data/lib/tui_tui/box_chrome.rb +0 -9
- data/lib/tui_tui/box_chrome_resolver.rb +21 -0
- data/lib/tui_tui/box_prober.rb +3 -4
- data/lib/tui_tui/canvas.rb +6 -55
- data/lib/tui_tui/canvas_compositor.rb +12 -10
- data/lib/tui_tui/canvas_renderer.rb +59 -0
- data/lib/tui_tui/cell.rb +4 -3
- data/lib/tui_tui/clock.rb +11 -0
- data/lib/tui_tui/command.rb +21 -0
- data/lib/tui_tui/event_stream.rb +42 -7
- data/lib/tui_tui/key_code.rb +3 -0
- data/lib/tui_tui/key_reader.rb +1 -1
- data/lib/tui_tui/line.rb +11 -0
- data/lib/tui_tui/modal/command_palette.rb +193 -0
- data/lib/tui_tui/modal/confirm.rb +74 -0
- data/lib/tui_tui/modal/help.rb +44 -0
- data/lib/tui_tui/modal/pager.rb +106 -0
- data/lib/tui_tui/modal/prompt.rb +66 -0
- data/lib/tui_tui/modal/select.rb +103 -0
- data/lib/tui_tui/modal.rb +4 -0
- data/lib/tui_tui/modal_host.rb +2 -2
- data/lib/tui_tui/rect.rb +69 -0
- data/lib/tui_tui/runtime.rb +52 -20
- data/lib/tui_tui/screen.rb +15 -3
- data/lib/tui_tui/terminal_session.rb +16 -1
- data/lib/tui_tui/test_runtime.rb +71 -0
- data/lib/tui_tui/text_input.rb +168 -0
- data/lib/tui_tui/text_sanitizer.rb +3 -2
- data/lib/tui_tui/theme.rb +25 -27
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui/widget/list.rb +59 -0
- data/lib/tui_tui/widget/progress.rb +33 -0
- data/lib/tui_tui/widget/scroll_list.rb +59 -0
- data/lib/tui_tui/widget/scrollbar.rb +43 -0
- data/lib/tui_tui/widget/spinner.rb +37 -0
- data/lib/tui_tui/widget/status_bar.rb +25 -0
- data/lib/tui_tui/widget/table.rb +81 -0
- data/lib/tui_tui/widget/tabs.rb +67 -0
- data/lib/tui_tui/widget/text_view.rb +47 -0
- data/lib/tui_tui/widget/toast.rb +83 -0
- data/lib/tui_tui.rb +24 -14
- metadata +28 -13
- data/lib/tui_tui/confirm.rb +0 -74
- data/lib/tui_tui/help.rb +0 -44
- data/lib/tui_tui/list.rb +0 -59
- data/lib/tui_tui/pager.rb +0 -94
- data/lib/tui_tui/prompt.rb +0 -111
- data/lib/tui_tui/scroll_list.rb +0 -57
- data/lib/tui_tui/scrollbar.rb +0 -41
- data/lib/tui_tui/select.rb +0 -104
- data/lib/tui_tui/status_bar.rb +0 -23
- data/lib/tui_tui/text_view.rb +0 -56
- data/lib/tui_tui/toast.rb +0 -82
- /data/lib/tui_tui/{event.rb → events.rb} +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# A simulated download manager. It demonstrates a ticking dashboard built from
|
|
5
|
+
# Widget::Table (name/size/progress/status columns with Span-colored status
|
|
6
|
+
# cells), Widget::Spinner (animating the active row), Widget::Progress (overall
|
|
7
|
+
# bar), and Widget::StatusBar, laid out with Rect#split_rows. Downloads advance
|
|
8
|
+
# on each tick; finished ones promote the next queued item. Japanese file names
|
|
9
|
+
# show the width-safe rendering. Colors come from TuiTui::Theme.auto.
|
|
10
|
+
#
|
|
11
|
+
# ruby examples/downloads.rb
|
|
12
|
+
#
|
|
13
|
+
# Keys: j/k (or ↑/↓) move, Space pause/resume the selected download,
|
|
14
|
+
# q (or Ctrl-C) quit.
|
|
15
|
+
|
|
16
|
+
require_relative "../lib/tui_tui"
|
|
17
|
+
|
|
18
|
+
module DownloadsSample
|
|
19
|
+
Download = Data.define(:name, :size_mb, :ratio, :state)
|
|
20
|
+
|
|
21
|
+
SEED = [
|
|
22
|
+
Download.new("ruby-3.4.tar.gz", 24.8, 0.0, :active),
|
|
23
|
+
Download.new("開発環境つくりかた.pdf", 8.2, 0.0, :queued),
|
|
24
|
+
Download.new("tui_tui-manual.epub", 3.5, 0.0, :queued),
|
|
25
|
+
Download.new("空の絶景写真集.zip", 412.0, 0.0, :queued),
|
|
26
|
+
Download.new("linux-firmware.img", 96.3, 0.0, :queued),
|
|
27
|
+
Download.new("soundtrack.flac", 58.1, 0.0, :queued),
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
class App
|
|
31
|
+
def initialize(downloads = SEED)
|
|
32
|
+
@downloads = downloads.dup
|
|
33
|
+
@theme = TuiTui::Theme.auto
|
|
34
|
+
@spinner = TuiTui::Widget::Spinner.new
|
|
35
|
+
@list = TuiTui::Widget::ScrollList.new(@downloads.size)
|
|
36
|
+
@table = TuiTui::Widget::Table.new(@list)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Keep ticking only while something is downloading.
|
|
40
|
+
def wants_tick? = @downloads.any? { |d| d.state == :active }
|
|
41
|
+
|
|
42
|
+
def update(event)
|
|
43
|
+
case event
|
|
44
|
+
in TuiTui::KeyEvent(key: "q" | TuiTui::KeyCode::CTRL_C) then return :quit
|
|
45
|
+
in TuiTui::KeyEvent(key: "j" | :down) then @list.move(1)
|
|
46
|
+
in TuiTui::KeyEvent(key: "k" | :up) then @list.move(-1)
|
|
47
|
+
in TuiTui::KeyEvent(key: " ") then toggle_pause
|
|
48
|
+
in TuiTui::TickEvent then step
|
|
49
|
+
else nil
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def view(size)
|
|
55
|
+
canvas = TuiTui::Canvas.blank(size)
|
|
56
|
+
table, bar, status = TuiTui::Rect.of(size).split_rows(:rest, 1, 1)
|
|
57
|
+
|
|
58
|
+
@list.ensure_visible([table.rows - 1, 1].max)
|
|
59
|
+
draw_table(canvas, table)
|
|
60
|
+
TuiTui::Widget::Progress.draw(canvas, bar, overall_ratio,
|
|
61
|
+
bar_style: @theme.selection, track_style: @theme.muted)
|
|
62
|
+
TuiTui::Widget::StatusBar.draw(canvas, status, style: @theme.bar,
|
|
63
|
+
left: " #{done_count}/#{@downloads.size} done",
|
|
64
|
+
right: "j/k move Space pause q quit ")
|
|
65
|
+
canvas
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Advance every active download; a finished one promotes the next queued.
|
|
71
|
+
def step
|
|
72
|
+
@spinner.advance
|
|
73
|
+
@downloads.each_with_index do |item, index|
|
|
74
|
+
next unless item.state == :active
|
|
75
|
+
|
|
76
|
+
ratio = item.ratio + rand(0.02..0.09)
|
|
77
|
+
@downloads[index] = ratio >= 1.0 ? item.with(ratio: 1.0, state: :done) : item.with(ratio: ratio)
|
|
78
|
+
promote_next if ratio >= 1.0
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def promote_next
|
|
83
|
+
index = @downloads.index { |d| d.state == :queued }
|
|
84
|
+
@downloads[index] = @downloads[index].with(state: :active) if index
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def toggle_pause
|
|
88
|
+
item = @downloads[@list.cursor]
|
|
89
|
+
case item.state
|
|
90
|
+
when :active then @downloads[@list.cursor] = item.with(state: :paused)
|
|
91
|
+
when :paused then @downloads[@list.cursor] = item.with(state: :active)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def draw_table(canvas, rect)
|
|
96
|
+
return if rect.rows <= 0 || rect.cols <= 0
|
|
97
|
+
|
|
98
|
+
name_width = [rect.cols - 9 - 6 - 10 - 3, 8].max # fixed columns + 3 gaps
|
|
99
|
+
columns = [["name", name_width], ["size", 9], ["prog", 6], ["status", 10]]
|
|
100
|
+
@table.draw(canvas, rect, columns: columns,
|
|
101
|
+
header_style: @theme.bar, highlight: @theme.selection) do |index, _selected|
|
|
102
|
+
item = @downloads[index]
|
|
103
|
+
[item.name, format("%.1f MB", item.size_mb), "#{(item.ratio * 100).round}%", status_span(item)]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Status cells carry their own Span style, so they stay colored even on
|
|
108
|
+
# the highlighted row.
|
|
109
|
+
def status_span(item)
|
|
110
|
+
case item.state
|
|
111
|
+
when :done then TuiTui::Span["done", @theme.success]
|
|
112
|
+
when :active then TuiTui::Span["#{@spinner.glyph} down", @theme.warning]
|
|
113
|
+
when :paused then TuiTui::Span["paused", @theme.danger]
|
|
114
|
+
else TuiTui::Span["queued", @theme.muted]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def overall_ratio
|
|
119
|
+
@downloads.sum(&:ratio) / @downloads.size
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def done_count = @downloads.count { |d| d.state == :done }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if $PROGRAM_NAME == __FILE__
|
|
127
|
+
TuiTui::Runtime.new(DownloadsSample::App.new).run(tick: 0.1)
|
|
128
|
+
end
|
data/examples/file_browser.rb
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
#
|
|
11
11
|
# Keys: j/k (or ↑/↓) move, l/Enter/→ open dir, h/←/Backspace up, g/G top/bottom,
|
|
12
12
|
# Tab switch pane, J/K (or mouse wheel) scroll the preview, w wrap, t theme, </> divider, / fuzzy find,
|
|
13
|
-
# y copy the path (OSC 52), m actions menu, ? help, q (or Ctrl-C) quit.
|
|
13
|
+
# y copy the path (OSC 52), : command palette, m actions menu, ? help, q (or Ctrl-C) quit.
|
|
14
14
|
#
|
|
15
15
|
# `/` is an incremental fuzzy finder built on TuiTui::Fuzzy (type to narrow,
|
|
16
16
|
# matched characters highlighted, ↑↓ to navigate, Enter to open, Esc to cancel).
|
|
17
|
-
# The m / ? / q modals are TuiTui widgets (Select, Help,
|
|
17
|
+
# The : / m / ? / q modals are TuiTui widgets (CommandPalette, Select, Help,
|
|
18
|
+
# Confirm), opened and dispatched via TuiTui::ModalHost.
|
|
18
19
|
|
|
19
20
|
require "strscan"
|
|
20
21
|
require_relative "../lib/tui_tui"
|
|
@@ -177,6 +178,7 @@ module FileBrowserSample
|
|
|
177
178
|
["t", "cycle theme (cool / warm / mono, follows light/dark)"],
|
|
178
179
|
["/", "fuzzy find (↑↓ navigate, Enter open, Esc cancel)"],
|
|
179
180
|
["y", "copy path to clipboard"],
|
|
181
|
+
[":", "command palette (fuzzy-run any command)"],
|
|
180
182
|
["m", "actions menu"],
|
|
181
183
|
["?", "this help"],
|
|
182
184
|
["q", "quit"],
|
|
@@ -184,12 +186,26 @@ module FileBrowserSample
|
|
|
184
186
|
|
|
185
187
|
ACTIONS = [["Up to parent", :parent], ["Refresh", :refresh], ["Quit", :quit]].freeze
|
|
186
188
|
|
|
189
|
+
# Commands surfaced in the ":" command palette. Each is [label, action]; the
|
|
190
|
+
# palette ranks by the label and resolves to the chosen pair (see run_command).
|
|
191
|
+
COMMANDS = [
|
|
192
|
+
["Open selected entry", :open],
|
|
193
|
+
["Up to parent directory", :parent],
|
|
194
|
+
["Refresh listing", :refresh],
|
|
195
|
+
["Toggle preview wrap", :wrap],
|
|
196
|
+
["Cycle theme", :theme],
|
|
197
|
+
["Copy path to clipboard", :copy],
|
|
198
|
+
["Fuzzy find", :find],
|
|
199
|
+
["Keyboard help", :help],
|
|
200
|
+
["Quit", :quit],
|
|
201
|
+
].freeze
|
|
202
|
+
|
|
187
203
|
# The app: responds to view(size) -> Canvas and update(event) -> self | :quit,
|
|
188
204
|
# which is all TuiTui::Runtime asks of it.
|
|
189
205
|
class Browser
|
|
190
206
|
def initialize(path)
|
|
191
207
|
@dir = File.expand_path(path)
|
|
192
|
-
@list = TuiTui::ScrollList.new
|
|
208
|
+
@list = TuiTui::Widget::ScrollList.new
|
|
193
209
|
@preview_scroll = 0
|
|
194
210
|
@preview_wrap = false # toggle with "w": wrap long lines vs. clip them
|
|
195
211
|
@hl_path = nil # cache key for syntax-highlighted preview lines
|
|
@@ -204,31 +220,21 @@ module FileBrowserSample
|
|
|
204
220
|
@page = 1
|
|
205
221
|
@preview_page = 1
|
|
206
222
|
@split = 0.5 # divider position as a fraction of the body width (resize-safe)
|
|
207
|
-
@finder = nil # the fuzzy query while finding
|
|
223
|
+
@finder = nil # a TuiTui::TextInput holding the fuzzy query while finding, or nil when off
|
|
208
224
|
@matches = {} # entry name => matched char positions, for highlighting
|
|
209
|
-
@
|
|
210
|
-
@on_result = nil
|
|
211
|
-
@clipboard = nil # a path queued for the clipboard; the Runtime drains it
|
|
225
|
+
@host = TuiTui::ModalHost.new
|
|
212
226
|
load_entries
|
|
213
227
|
end
|
|
214
228
|
|
|
215
|
-
# The Runtime calls this after `update` and copies the returned text (OSC 52),
|
|
216
|
-
# then clears it. Keeping the I/O out of `update` leaves the fold pure.
|
|
217
|
-
def take_clipboard
|
|
218
|
-
text = @clipboard
|
|
219
|
-
@clipboard = nil
|
|
220
|
-
text
|
|
221
|
-
end
|
|
222
|
-
|
|
223
229
|
# Keep ticking only while a toast is showing, so it auto-dismisses.
|
|
224
230
|
def wants_tick? = !@toast.nil?
|
|
225
231
|
|
|
226
232
|
def update(event)
|
|
227
233
|
@toast = nil if @toast&.expired?
|
|
228
234
|
case event
|
|
229
|
-
when TuiTui::MouseEvent then @
|
|
235
|
+
when TuiTui::MouseEvent then @host.open? ? route_modal(event) : handle_mouse(event)
|
|
230
236
|
when TuiTui::KeyEvent
|
|
231
|
-
return route_modal(event
|
|
237
|
+
return route_modal(event) if @host.open?
|
|
232
238
|
return finder_key(event.key) if @finder
|
|
233
239
|
|
|
234
240
|
handle_key(event.key)
|
|
@@ -305,7 +311,7 @@ module FileBrowserSample
|
|
|
305
311
|
draw_preview(canvas, preview_rect) if preview_rect
|
|
306
312
|
draw_status(canvas, status) if status
|
|
307
313
|
@toast&.draw(canvas, size, style: @theme.selection)
|
|
308
|
-
@
|
|
314
|
+
@host.draw(canvas, size) # modal overlay on top of everything
|
|
309
315
|
canvas
|
|
310
316
|
end
|
|
311
317
|
|
|
@@ -314,28 +320,30 @@ module FileBrowserSample
|
|
|
314
320
|
# --- modals ---
|
|
315
321
|
|
|
316
322
|
# Show a modal widget; `on_result` interprets its resolved value (and may
|
|
317
|
-
# return :quit).
|
|
323
|
+
# return :quit). ModalHost routes events and runs on_result on resolve; this
|
|
324
|
+
# wrapper returns self so handle_key branches evaluate to the app model.
|
|
318
325
|
def open_modal(widget, &on_result)
|
|
319
|
-
@
|
|
320
|
-
|
|
326
|
+
@host.open(widget, &on_result)
|
|
327
|
+
self
|
|
321
328
|
end
|
|
322
329
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
330
|
+
# Route one event to the open modal and normalize the outcome for update:
|
|
331
|
+
# nil while it stays open (or when on_result had nothing to say) means self;
|
|
332
|
+
# :quit and [app, *commands] (the palette's "Copy path") pass through.
|
|
333
|
+
def route_modal(event)
|
|
334
|
+
case (outcome = @host.handle(event))
|
|
335
|
+
when :quit then :quit
|
|
336
|
+
when Array then outcome
|
|
337
|
+
else self
|
|
338
|
+
end
|
|
331
339
|
end
|
|
332
340
|
|
|
333
341
|
def confirm_quit
|
|
334
|
-
open_modal(TuiTui::Confirm.new("Quit file browser?", theme: @theme)) { |r| :quit if r == :ok }
|
|
342
|
+
open_modal(TuiTui::Modal::Confirm.new("Quit file browser?", theme: @theme)) { |r| :quit if r == :ok }
|
|
335
343
|
end
|
|
336
344
|
|
|
337
345
|
def open_actions
|
|
338
|
-
open_modal(TuiTui::Select.new("Actions", ACTIONS.map(&:first), theme: @theme)) do |result|
|
|
346
|
+
open_modal(TuiTui::Modal::Select.new("Actions", ACTIONS.map(&:first), theme: @theme)) do |result|
|
|
339
347
|
run_action(result) if result.is_a?(Integer)
|
|
340
348
|
end
|
|
341
349
|
end
|
|
@@ -348,48 +356,87 @@ module FileBrowserSample
|
|
|
348
356
|
end
|
|
349
357
|
end
|
|
350
358
|
|
|
359
|
+
def open_help = open_modal(TuiTui::Modal::Help.new("Keys", HELP, theme: @theme)) { nil }
|
|
360
|
+
|
|
361
|
+
# The ":" command palette: a fuzzy-filtered list of every command. The palette
|
|
362
|
+
# ranks by the label and resolves to the chosen [label, action] pair (or
|
|
363
|
+
# :cancel on Esc), which run_command dispatches.
|
|
364
|
+
def open_palette
|
|
365
|
+
open_modal(TuiTui::Modal::CommandPalette.new(COMMANDS, theme: @theme) { |label, _action| label }) do |chosen|
|
|
366
|
+
run_command(chosen.last) if chosen.is_a?(Array)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def run_command(action)
|
|
371
|
+
case action
|
|
372
|
+
when :open then open_entry
|
|
373
|
+
when :parent then up_dir
|
|
374
|
+
when :refresh then load_entries
|
|
375
|
+
when :wrap then toggle_preview_wrap
|
|
376
|
+
when :theme then cycle_theme
|
|
377
|
+
when :copy then copy_path
|
|
378
|
+
when :find then enter_finder
|
|
379
|
+
when :help then open_help # palettes can chain into another modal
|
|
380
|
+
when :quit then :quit
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
351
384
|
# --- input ---
|
|
352
385
|
|
|
386
|
+
# Each branch evaluates to update's return value: self, [self, *commands], or :quit.
|
|
353
387
|
def handle_key(key)
|
|
354
388
|
case key
|
|
355
389
|
when "q", TuiTui::KeyCode::CTRL_C then confirm_quit
|
|
356
|
-
when "?" then
|
|
390
|
+
when "?" then open_help
|
|
357
391
|
when "/" then enter_finder
|
|
392
|
+
when ":" then open_palette
|
|
358
393
|
when "m" then open_actions
|
|
359
394
|
when "l", "\r", :right then open_entry
|
|
360
395
|
when "h", :left, TuiTui::KeyCode::BACKSPACE then up_dir # h / ← / Backspace
|
|
361
|
-
when "\t" then
|
|
362
|
-
when "<" then
|
|
363
|
-
when ">" then
|
|
396
|
+
when "\t" then switch_focus
|
|
397
|
+
when "<" then nudge_split(-SPLIT_STEP)
|
|
398
|
+
when ">" then nudge_split(SPLIT_STEP)
|
|
364
399
|
when "J" then scroll_preview(1) # always works, whichever pane is focused
|
|
365
400
|
when "K" then scroll_preview(-1)
|
|
366
401
|
when "w" then toggle_preview_wrap
|
|
367
402
|
when "t" then cycle_theme
|
|
368
|
-
when "y" then copy_path
|
|
403
|
+
when "y" then copy_path # [self, Command::Copy] propagates out of update
|
|
369
404
|
else navigate(key) # j/k, arrows, paging and g/G follow the focused pane
|
|
370
405
|
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def switch_focus
|
|
409
|
+
@focus = @focus.next
|
|
371
410
|
self
|
|
372
411
|
end
|
|
373
412
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
413
|
+
def nudge_split(delta)
|
|
414
|
+
@split = (@split + delta).clamp(0.1, 0.9)
|
|
415
|
+
self
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Return a Command::Copy for the selected path (the Runtime writes it to the
|
|
419
|
+
# clipboard via OSC 52) and show a toast so the copy is visibly confirmed.
|
|
378
420
|
def copy_path
|
|
379
|
-
|
|
380
|
-
@toast = TuiTui::Toast.new("copied path to clipboard")
|
|
421
|
+
path = File.expand_path(File.join(@dir, selected.to_s))
|
|
422
|
+
@toast = TuiTui::Widget::Toast.new("copied path to clipboard")
|
|
423
|
+
[self, TuiTui::Command::Copy.new(path)]
|
|
381
424
|
end
|
|
382
425
|
|
|
426
|
+
# Cycle the UI theme (default -> warm -> mono -> ...), rebuilding the chrome
|
|
427
|
+
# palette. Open modals read @theme when created, so the next one matches too.
|
|
383
428
|
def cycle_theme
|
|
384
429
|
@theme_i = (@theme_i + 1) % THEMES.size
|
|
385
430
|
@theme = FileBrowserSample.theme_for(THEMES[@theme_i])
|
|
386
431
|
@styles = FileBrowserSample.palette(@theme)
|
|
432
|
+
self
|
|
387
433
|
end
|
|
388
434
|
|
|
389
435
|
# Move/page keys act on whichever pane Tab has focused: the directory list,
|
|
390
436
|
# or the file preview (scrolling its text back and forth).
|
|
391
437
|
def navigate(key)
|
|
392
438
|
@focus.focused?(:preview) ? navigate_preview(key) : navigate_list(key)
|
|
439
|
+
self
|
|
393
440
|
end
|
|
394
441
|
|
|
395
442
|
def navigate_list(key)
|
|
@@ -416,27 +463,31 @@ module FileBrowserSample
|
|
|
416
463
|
|
|
417
464
|
def scroll_preview(delta)
|
|
418
465
|
@preview_scroll = [@preview_scroll + delta, 0].max # upper bound clamped in draw_preview
|
|
466
|
+
self
|
|
419
467
|
end
|
|
420
468
|
|
|
421
469
|
def toggle_preview_wrap
|
|
422
470
|
@preview_wrap = !@preview_wrap
|
|
423
471
|
@preview_scroll = 0 # the display-line count changes, so start from the top
|
|
472
|
+
self
|
|
424
473
|
end
|
|
425
474
|
|
|
426
475
|
def move(delta)
|
|
427
476
|
@list.move(delta)
|
|
428
477
|
@preview_scroll = 0
|
|
478
|
+
self
|
|
429
479
|
end
|
|
430
480
|
|
|
431
481
|
def go_to(index)
|
|
432
482
|
@list.go_to(index)
|
|
433
483
|
@preview_scroll = 0
|
|
484
|
+
self
|
|
434
485
|
end
|
|
435
486
|
|
|
436
487
|
def open_entry
|
|
437
488
|
name = selected
|
|
438
489
|
return up_dir if name == ".."
|
|
439
|
-
return unless directory?(name)
|
|
490
|
+
return self unless directory?(name)
|
|
440
491
|
|
|
441
492
|
@dir = File.join(@dir, name)
|
|
442
493
|
load_entries
|
|
@@ -445,7 +496,7 @@ module FileBrowserSample
|
|
|
445
496
|
|
|
446
497
|
def up_dir
|
|
447
498
|
parent = File.dirname(@dir)
|
|
448
|
-
return if parent == @dir # already at the filesystem root
|
|
499
|
+
return self if parent == @dir # already at the filesystem root
|
|
449
500
|
|
|
450
501
|
came_from = File.basename(@dir)
|
|
451
502
|
@dir = parent
|
|
@@ -458,8 +509,9 @@ module FileBrowserSample
|
|
|
458
509
|
# --- fuzzy finder (incremental; built on TuiTui::Fuzzy) ---
|
|
459
510
|
|
|
460
511
|
def enter_finder
|
|
461
|
-
@finder =
|
|
512
|
+
@finder = TuiTui::TextInput.new
|
|
462
513
|
refilter
|
|
514
|
+
self
|
|
463
515
|
end
|
|
464
516
|
|
|
465
517
|
def exit_finder
|
|
@@ -483,16 +535,14 @@ module FileBrowserSample
|
|
|
483
535
|
end
|
|
484
536
|
|
|
485
537
|
def type_finder(key)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
@finder += key
|
|
489
|
-
refilter
|
|
538
|
+
# #type ignores control keys; the query is append-only (cursor at the end).
|
|
539
|
+
refilter if @finder.type(key)
|
|
490
540
|
end
|
|
491
541
|
|
|
492
542
|
def backspace_finder
|
|
493
543
|
return if @finder.empty?
|
|
494
544
|
|
|
495
|
-
@finder
|
|
545
|
+
@finder.delete_back # by grapheme, so a multi-codepoint cluster deletes whole
|
|
496
546
|
refilter
|
|
497
547
|
end
|
|
498
548
|
|
|
@@ -521,7 +571,7 @@ module FileBrowserSample
|
|
|
521
571
|
# with matched positions for highlighting), otherwise the full dir-first list.
|
|
522
572
|
def refilter
|
|
523
573
|
if @finder && !@finder.empty?
|
|
524
|
-
ranked = TuiTui::Fuzzy.new(@finder).rank(@all)
|
|
574
|
+
ranked = TuiTui::Fuzzy.new(@finder.value).rank(@all)
|
|
525
575
|
@entries = ranked.map(&:first)
|
|
526
576
|
@matches = ranked.to_h { |name, found| [name, found.positions] }
|
|
527
577
|
else
|
|
@@ -562,7 +612,7 @@ module FileBrowserSample
|
|
|
562
612
|
|
|
563
613
|
def draw_list(canvas, rect)
|
|
564
614
|
highlight = @focus.focused?(:list) ? @styles[:select] : @styles[:select_blur]
|
|
565
|
-
TuiTui::List.new(@list).draw(canvas, rect, highlight: highlight, scrollbar: @theme) do |index, selected|
|
|
615
|
+
TuiTui::Widget::List.new(@list).draw(canvas, rect, highlight: highlight, scrollbar: @theme) do |index, selected|
|
|
566
616
|
name = @entries[index]
|
|
567
617
|
label = directory?(name) && name != ".." ? "#{name}/" : name
|
|
568
618
|
base = selected ? highlight : (directory?(name) ? @styles[:dir] : @styles[:file])
|
|
@@ -594,7 +644,7 @@ module FileBrowserSample
|
|
|
594
644
|
width = rect.split_gutter.first.cols # leave room for the scrollbar gutter when wrapping/truncating
|
|
595
645
|
lines = preview_display(width)
|
|
596
646
|
@preview_scroll = @preview_scroll.clamp(0, [lines.length - 1, 0].max)
|
|
597
|
-
TuiTui::TextView.draw(canvas, rect, lines, top: @preview_scroll, scrollbar: @theme)
|
|
647
|
+
TuiTui::Widget::TextView.draw(canvas, rect, lines, top: @preview_scroll, scrollbar: @theme)
|
|
598
648
|
end
|
|
599
649
|
|
|
600
650
|
# Preview as styled display lines (Line). Wrap mode wraps to the width (plain,
|
|
@@ -630,10 +680,10 @@ module FileBrowserSample
|
|
|
630
680
|
end
|
|
631
681
|
|
|
632
682
|
def draw_status(canvas, rect)
|
|
633
|
-
left = @finder ? " > #{@finder}" : " #{@dir}"
|
|
634
|
-
hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find m=menu t=#{THEMES[@theme_i]} q=quit"
|
|
683
|
+
left = @finder ? " > #{@finder.value}" : " #{@dir}"
|
|
684
|
+
hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find :=cmds m=menu t=#{THEMES[@theme_i]} q=quit"
|
|
635
685
|
right = "#{@list.cursor + 1}/#{@entries.size} #{hints} "
|
|
636
|
-
TuiTui::StatusBar.draw(canvas, rect, left: left, right: right, style: @styles[:bar])
|
|
686
|
+
TuiTui::Widget::StatusBar.draw(canvas, rect, left: left, right: right, style: @styles[:bar])
|
|
637
687
|
end
|
|
638
688
|
|
|
639
689
|
# Lines of the selected file's preview, cached per selection so we do not
|