tui_tui 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/.github/workflows/ci.yml +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +8 -0
- data/examples/clock.rb +112 -0
- data/examples/counter.rb +48 -0
- data/examples/csv_viewer.rb +233 -0
- data/examples/file_browser.rb +665 -0
- data/examples/form.rb +633 -0
- data/examples/life.rb +144 -0
- data/examples/paint.rb +246 -0
- data/examples/todo.rb +250 -0
- data/examples/widgets.rb +101 -0
- data/lib/tui_tui/ansi.rb +34 -0
- data/lib/tui_tui/canvas.rb +187 -0
- data/lib/tui_tui/canvas_compositor.rb +45 -0
- data/lib/tui_tui/cell.rb +11 -0
- data/lib/tui_tui/color_depth.rb +39 -0
- data/lib/tui_tui/confirm.rb +74 -0
- data/lib/tui_tui/display_text.rb +73 -0
- data/lib/tui_tui/event.rb +10 -0
- data/lib/tui_tui/event_stream.rb +39 -0
- data/lib/tui_tui/focus_ring.rb +25 -0
- data/lib/tui_tui/fuzzy.rb +56 -0
- data/lib/tui_tui/help.rb +44 -0
- data/lib/tui_tui/key_code.rb +9 -0
- data/lib/tui_tui/key_intent.rb +29 -0
- data/lib/tui_tui/key_reader.rb +175 -0
- data/lib/tui_tui/line.rb +59 -0
- data/lib/tui_tui/list.rb +45 -0
- data/lib/tui_tui/modal.rb +30 -0
- data/lib/tui_tui/pager.rb +94 -0
- data/lib/tui_tui/palette.rb +49 -0
- data/lib/tui_tui/prompt.rb +111 -0
- data/lib/tui_tui/rect.rb +48 -0
- data/lib/tui_tui/runtime.rb +53 -0
- data/lib/tui_tui/screen.rb +85 -0
- data/lib/tui_tui/scroll_list.rb +57 -0
- data/lib/tui_tui/scrollbar.rb +40 -0
- data/lib/tui_tui/select.rb +104 -0
- data/lib/tui_tui/size.rb +5 -0
- data/lib/tui_tui/span.rb +14 -0
- data/lib/tui_tui/status_bar.rb +23 -0
- data/lib/tui_tui/style.rb +101 -0
- data/lib/tui_tui/terminal_session.rb +65 -0
- data/lib/tui_tui/terminal_size.rb +24 -0
- data/lib/tui_tui/text_sanitizer.rb +13 -0
- data/lib/tui_tui/text_view.rb +52 -0
- data/lib/tui_tui/theme.rb +127 -0
- data/lib/tui_tui/toast.rb +82 -0
- data/lib/tui_tui/version.rb +5 -0
- data/lib/tui_tui/width.rb +101 -0
- data/lib/tui_tui.rb +51 -0
- metadata +98 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# A sample TUI built on TuiTui ALONE — it never touches the trace viewer.
|
|
5
|
+
# Its point is to show the framework is domain-agnostic: a two-pane app (a
|
|
6
|
+
# directory list + a file preview) in ~150 lines, reusing Canvas, Style, Width
|
|
7
|
+
# (so Japanese file names align), Screen, Runtime, and Keys.
|
|
8
|
+
#
|
|
9
|
+
# ruby examples/file_browser.rb [DIR]
|
|
10
|
+
#
|
|
11
|
+
# Keys: j/k (or ↑/↓) move, l/Enter/→ open dir, h/←/Backspace up, g/G top/bottom,
|
|
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.
|
|
14
|
+
#
|
|
15
|
+
# `/` is an incremental fuzzy finder built on TuiTui::Fuzzy (type to narrow,
|
|
16
|
+
# matched characters highlighted, ↑↓ to navigate, Enter to open, Esc to cancel).
|
|
17
|
+
# The m / ? / q modals are TuiTui widgets (Select, Help, Confirm).
|
|
18
|
+
|
|
19
|
+
require "strscan"
|
|
20
|
+
require_relative "../lib/tui_tui"
|
|
21
|
+
|
|
22
|
+
module FileBrowserSample
|
|
23
|
+
S = TuiTui::Style
|
|
24
|
+
# The chrome palette is derived from a TuiTui::Theme, so changing the theme
|
|
25
|
+
# (the "t" key cycles the hues) recolours both this app and the modals it opens.
|
|
26
|
+
# Every hue follows the detected terminal background (light/dark) via Theme.auto.
|
|
27
|
+
THEMES = %i[cool warm mono].freeze
|
|
28
|
+
|
|
29
|
+
def self.theme_for(hue) = TuiTui::Theme.auto(hue: hue)
|
|
30
|
+
|
|
31
|
+
def self.palette(theme)
|
|
32
|
+
{
|
|
33
|
+
# Directories: the theme's structural-emphasis role (so they follow the
|
|
34
|
+
# theme and stay distinct from fuzzy matches, which use :accent).
|
|
35
|
+
dir: theme.title,
|
|
36
|
+
file: theme.text,
|
|
37
|
+
dim: theme.muted, # subdued text (preview, hints)
|
|
38
|
+
divider: theme.frame, # the rule between panes
|
|
39
|
+
select: theme.selection, # focused selection
|
|
40
|
+
select_blur: theme.selection_dim, # unfocused selection (theme role)
|
|
41
|
+
bar: theme.bar, # footer bar (theme role)
|
|
42
|
+
match: theme.accent, # fuzzy-matched characters
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Token colours for the preview's tiny highlighter. Syntax colours are kept
|
|
47
|
+
# independent of the (switchable) UI theme — like an editor's fixed code theme.
|
|
48
|
+
BASE = TuiTui::Theme::DEFAULT
|
|
49
|
+
CODE = {
|
|
50
|
+
text: BASE.text,
|
|
51
|
+
comment: BASE.muted, # grey
|
|
52
|
+
string: S.new(fg: 108), # sage
|
|
53
|
+
number: S.new(fg: 173), # dusty orange
|
|
54
|
+
keyword: S.new(fg: 109), # slate
|
|
55
|
+
symbol: S.new(fg: 139), # mauve
|
|
56
|
+
constant: S.new(fg: 144), # khaki
|
|
57
|
+
heading: BASE.title, # markdown heading (slate bold)
|
|
58
|
+
bold: S.new(attrs: [:bold]), # markdown **bold**
|
|
59
|
+
italic: S.new(attrs: [:italic]), # markdown *italic*
|
|
60
|
+
link: BASE.accent, # markdown [text](url)
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# A minimal, dependency-free syntax highlighter for the preview. It is
|
|
64
|
+
# line-based (regex per token), so multi-line strings/heredocs may mis-colour
|
|
65
|
+
# — enough to make code readable, not a real parser. Ruby gets keywords/
|
|
66
|
+
# symbols/constants; other source files get strings/numbers/line-comments.
|
|
67
|
+
module Code
|
|
68
|
+
KEYWORDS = %w[
|
|
69
|
+
def end if elsif else unless while until for in do begin rescue ensure retry
|
|
70
|
+
class module self nil true false and or not return yield then case when
|
|
71
|
+
require require_relative attr_reader attr_accessor attr_writer
|
|
72
|
+
raise next break super lambda proc new
|
|
73
|
+
].freeze
|
|
74
|
+
KEYWORD = /(?:#{KEYWORDS.join("|")})\b/.freeze
|
|
75
|
+
|
|
76
|
+
RUBY = [
|
|
77
|
+
[/#.*/, :comment],
|
|
78
|
+
[/"(?:\\.|[^"\\])*"/, :string],
|
|
79
|
+
[/'(?:\\.|[^'\\])*'/, :string],
|
|
80
|
+
[/::/, :text], # namespace separator, so "Foo::Bar" isn't read as a :symbol
|
|
81
|
+
[/:[A-Za-z_]\w*[?!]?/, :symbol],
|
|
82
|
+
[/\d[\d_]*(?:\.\d+)?/, :number],
|
|
83
|
+
[KEYWORD, :keyword],
|
|
84
|
+
[/[A-Z]\w*/, :constant],
|
|
85
|
+
[/[a-z_]\w*[?!]?/, :text], # whole identifiers, so keywords inside words don't split
|
|
86
|
+
].freeze
|
|
87
|
+
|
|
88
|
+
GENERIC = [
|
|
89
|
+
[%r{//.*}, :comment],
|
|
90
|
+
[/#.*/, :comment],
|
|
91
|
+
[/"(?:\\.|[^"\\])*"/, :string],
|
|
92
|
+
[/'(?:\\.|[^'\\])*'/, :string],
|
|
93
|
+
[/\d[\d_]*(?:\.\d+)?/, :number],
|
|
94
|
+
].freeze
|
|
95
|
+
|
|
96
|
+
# Line-based Markdown: whole-line constructs (heading/quote/hr/list marker)
|
|
97
|
+
# via ^-anchored rules, then inline code/bold/italic/links. Underscore
|
|
98
|
+
# emphasis is intentionally unsupported (it false-matches words like a_b).
|
|
99
|
+
MARKDOWN = [
|
|
100
|
+
[/^\s{0,3}#+\s.*/, :heading], # # Heading (whole line)
|
|
101
|
+
[/^\s*(?:-{3,}|\*{3,})\s*$/, :comment], # --- horizontal rule
|
|
102
|
+
[/^\s{0,3}>.*/, :comment], # > blockquote
|
|
103
|
+
[/^\s*(?:[-*+]|\d+\.)\s/, :symbol], # list marker (then inline rules continue)
|
|
104
|
+
[/`[^`]*`/, :string], # `inline code`
|
|
105
|
+
[/\*\*[^*]+\*\*/, :bold], # **bold**
|
|
106
|
+
[/\*[^*]+\*/, :italic], # *italic*
|
|
107
|
+
[/\[[^\]]*\]\([^)]*\)/, :link], # [text](url)
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
EXT = {
|
|
111
|
+
".rb" => :ruby, ".rake" => :ruby, ".gemspec" => :ruby, ".ru" => :ruby,
|
|
112
|
+
".js" => :generic, ".ts" => :generic, ".py" => :generic, ".c" => :generic,
|
|
113
|
+
".h" => :generic, ".cpp" => :generic, ".go" => :generic, ".rs" => :generic,
|
|
114
|
+
".java" => :generic, ".sh" => :generic, ".json" => :generic,
|
|
115
|
+
".yml" => :generic, ".yaml" => :generic, ".css" => :generic, ".scss" => :generic,
|
|
116
|
+
".md" => :markdown, ".markdown" => :markdown,
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
119
|
+
RULESETS = { ruby: RUBY, markdown: MARKDOWN }.freeze
|
|
120
|
+
|
|
121
|
+
module_function
|
|
122
|
+
|
|
123
|
+
def lang_for(ext) = EXT[ext.to_s.downcase]
|
|
124
|
+
|
|
125
|
+
def rules(lang) = RULESETS.fetch(lang, GENERIC)
|
|
126
|
+
|
|
127
|
+
# Tokenise one line into a styled Line.
|
|
128
|
+
def line(text, lang)
|
|
129
|
+
scanner = StringScanner.new(text)
|
|
130
|
+
spans = []
|
|
131
|
+
pending = +""
|
|
132
|
+
until scanner.eos?
|
|
133
|
+
role, token = first_match(scanner, rules(lang))
|
|
134
|
+
if token
|
|
135
|
+
flush(spans, pending)
|
|
136
|
+
pending = +""
|
|
137
|
+
spans << TuiTui::Span[token, CODE[role]]
|
|
138
|
+
else
|
|
139
|
+
pending << scanner.getch
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
flush(spans, pending)
|
|
143
|
+
TuiTui::Line.new(spans.empty? ? [TuiTui::Span[text, CODE[:text]]] : spans)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def first_match(scanner, ruleset)
|
|
147
|
+
ruleset.each do |regex, role|
|
|
148
|
+
token = scanner.scan(regex)
|
|
149
|
+
return [role, token] if token
|
|
150
|
+
end
|
|
151
|
+
[nil, nil]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def flush(spans, pending)
|
|
155
|
+
spans << TuiTui::Span[pending, CODE[:text]] unless pending.empty?
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
MIN_TWO_PANE = 60
|
|
160
|
+
MIN_PANE = 12 # smallest either pane may shrink to when dragging the divider
|
|
161
|
+
SPLIT_STEP = 0.04 # how far </> nudge the divider per press
|
|
162
|
+
WHEEL_STEP = 3 # rows scrolled per mouse-wheel notch
|
|
163
|
+
DOUBLE_CLICK = 0.4 # seconds; a second click on the same entry within this opens it
|
|
164
|
+
PREVIEW_BYTES = 64 * 1024
|
|
165
|
+
PREVIEW_LINES = 1000
|
|
166
|
+
|
|
167
|
+
HELP = [
|
|
168
|
+
["j / k ↑ / ↓", "move"],
|
|
169
|
+
["Space / b", "page down / up"],
|
|
170
|
+
["g / G", "top / bottom"],
|
|
171
|
+
["l Enter →", "open directory"],
|
|
172
|
+
["h ← Backspace", "up to parent"],
|
|
173
|
+
["< / >", "move the divider"],
|
|
174
|
+
["Tab", "focus list / preview (j k g G page-keys follow focus)"],
|
|
175
|
+
["J / K", "scroll preview (from either pane)"],
|
|
176
|
+
["w", "toggle preview wrap"],
|
|
177
|
+
["t", "cycle theme (cool / warm / mono, follows light/dark)"],
|
|
178
|
+
["/", "fuzzy find (↑↓ navigate, Enter open, Esc cancel)"],
|
|
179
|
+
["y", "copy path to clipboard"],
|
|
180
|
+
["m", "actions menu"],
|
|
181
|
+
["?", "this help"],
|
|
182
|
+
["q", "quit"],
|
|
183
|
+
].freeze
|
|
184
|
+
|
|
185
|
+
ACTIONS = [["Up to parent", :parent], ["Refresh", :refresh], ["Quit", :quit]].freeze
|
|
186
|
+
|
|
187
|
+
# The app: responds to view(size) -> Canvas and update(event) -> self | :quit,
|
|
188
|
+
# which is all TuiTui::Runtime asks of it.
|
|
189
|
+
class Browser
|
|
190
|
+
def initialize(path)
|
|
191
|
+
@dir = File.expand_path(path)
|
|
192
|
+
@list = TuiTui::ScrollList.new
|
|
193
|
+
@preview_scroll = 0
|
|
194
|
+
@preview_wrap = false # toggle with "w": wrap long lines vs. clip them
|
|
195
|
+
@hl_path = nil # cache key for syntax-highlighted preview lines
|
|
196
|
+
@list_rect = nil # last list pane rect, for click hit-testing
|
|
197
|
+
@last_click = nil # [index, time] of the last list click, for double-click
|
|
198
|
+
@toast = nil # transient notification (e.g. after copying a path)
|
|
199
|
+
@preview_rect = nil # last preview pane rect, for wheel hit-testing
|
|
200
|
+
@theme_i = 0 # index into THEMES; "t" cycles
|
|
201
|
+
@theme = FileBrowserSample.theme_for(THEMES[@theme_i])
|
|
202
|
+
@styles = FileBrowserSample.palette(@theme)
|
|
203
|
+
@focus = TuiTui::FocusRing.new(:list, :preview)
|
|
204
|
+
@page = 1
|
|
205
|
+
@preview_page = 1
|
|
206
|
+
@split = 0.5 # divider position as a fraction of the body width (resize-safe)
|
|
207
|
+
@finder = nil # the fuzzy query while finding (a String), or nil when off
|
|
208
|
+
@matches = {} # entry name => matched char positions, for highlighting
|
|
209
|
+
@modal = nil
|
|
210
|
+
@on_result = nil
|
|
211
|
+
@clipboard = nil # a path queued for the clipboard; the Runtime drains it
|
|
212
|
+
load_entries
|
|
213
|
+
end
|
|
214
|
+
|
|
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
|
+
# Keep ticking only while a toast is showing, so it auto-dismisses.
|
|
224
|
+
def wants_tick? = !@toast.nil?
|
|
225
|
+
|
|
226
|
+
def update(event)
|
|
227
|
+
@toast = nil if @toast&.expired?
|
|
228
|
+
case event
|
|
229
|
+
when TuiTui::MouseEvent then @modal ? route_modal_mouse(event) : handle_mouse(event)
|
|
230
|
+
when TuiTui::KeyEvent
|
|
231
|
+
return route_modal(event.key) if @modal
|
|
232
|
+
return finder_key(event.key) if @finder
|
|
233
|
+
|
|
234
|
+
handle_key(event.key)
|
|
235
|
+
else self
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Wheel scrolls whichever pane the pointer is over (the list moves its cursor;
|
|
240
|
+
# the preview scrolls its text). A click/drag in the list selects that entry.
|
|
241
|
+
def handle_mouse(event)
|
|
242
|
+
case event.action
|
|
243
|
+
when :wheel
|
|
244
|
+
delta = event.button == :wheel_up ? -WHEEL_STEP : WHEEL_STEP
|
|
245
|
+
in_preview?(event.col) ? scroll_preview(delta) : move(delta)
|
|
246
|
+
when :press then click_list(event)
|
|
247
|
+
when :drag then drag_select(event)
|
|
248
|
+
end
|
|
249
|
+
self
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def in_preview?(col) = @preview_rect && col >= @preview_rect.col
|
|
253
|
+
|
|
254
|
+
# A click selects the entry under the pointer; a second click on the same
|
|
255
|
+
# entry within DOUBLE_CLICK seconds opens it (a directory enters it).
|
|
256
|
+
def click_list(event)
|
|
257
|
+
index = entry_at(event) or return
|
|
258
|
+
|
|
259
|
+
go_to(index)
|
|
260
|
+
if double_click?(index)
|
|
261
|
+
@last_click = nil # avoid a triple-click re-opening
|
|
262
|
+
open_entry
|
|
263
|
+
else
|
|
264
|
+
@last_click = [index, monotonic]
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Drag scrubs the selection (no double-click semantics).
|
|
269
|
+
def drag_select(event)
|
|
270
|
+
index = entry_at(event)
|
|
271
|
+
go_to(index) if index
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The list index under the pointer, or nil (preview pane, out of bounds, or
|
|
275
|
+
# below the last entry). Index 0 is valid, so use an explicit nil check.
|
|
276
|
+
def entry_at(event)
|
|
277
|
+
rect = @list_rect
|
|
278
|
+
return nil if in_preview?(event.col) || rect.nil?
|
|
279
|
+
return nil unless event.row.between?(rect.row, rect.row + rect.rows - 1)
|
|
280
|
+
|
|
281
|
+
index = @list.top + (event.row - rect.row)
|
|
282
|
+
index unless index > @list.last
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def double_click?(index)
|
|
286
|
+
@last_click && @last_click[0] == index && (monotonic - @last_click[1]) <= DOUBLE_CLICK
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
290
|
+
|
|
291
|
+
def view(size)
|
|
292
|
+
canvas = TuiTui::Canvas.blank(size)
|
|
293
|
+
body, status = split_status(size)
|
|
294
|
+
list_rect, preview_rect = split_panes(body)
|
|
295
|
+
@list_rect = list_rect # remembered so a click can hit-test the list
|
|
296
|
+
@preview_rect = preview_rect # and tell which pane the pointer is in
|
|
297
|
+
|
|
298
|
+
@page = [list_rect.rows, 1].max
|
|
299
|
+
@preview_page = [preview_rect&.rows || 1, 1].max # for paging the preview when it is focused
|
|
300
|
+
@list.ensure_visible(list_rect.rows)
|
|
301
|
+
|
|
302
|
+
draw_list(canvas, list_rect)
|
|
303
|
+
draw_divider(canvas, list_rect) if preview_rect
|
|
304
|
+
draw_preview(canvas, preview_rect) if preview_rect
|
|
305
|
+
draw_status(canvas, status) if status
|
|
306
|
+
@toast&.draw(canvas, size, style: @theme.selection)
|
|
307
|
+
@modal&.draw(canvas, size) # modal overlay on top of everything
|
|
308
|
+
canvas
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private
|
|
312
|
+
|
|
313
|
+
# --- modals ---
|
|
314
|
+
|
|
315
|
+
# Show a modal widget; `on_result` interprets its resolved value (and may
|
|
316
|
+
# return :quit). A widget returns nil from `handle` while still open.
|
|
317
|
+
def open_modal(widget, &on_result)
|
|
318
|
+
@modal = widget
|
|
319
|
+
@on_result = on_result
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def route_modal(key) = resolve_modal(@modal.handle(key))
|
|
323
|
+
def route_modal_mouse(event) = resolve_modal(@modal.handle_mouse(event))
|
|
324
|
+
|
|
325
|
+
def resolve_modal(result)
|
|
326
|
+
return self if result.nil? # still open
|
|
327
|
+
|
|
328
|
+
@modal = nil
|
|
329
|
+
@on_result.call(result) == :quit ? :quit : self
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def confirm_quit
|
|
333
|
+
open_modal(TuiTui::Confirm.new("Quit file browser?", theme: @theme)) { |r| :quit if r == :ok }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def open_actions
|
|
337
|
+
open_modal(TuiTui::Select.new("Actions", ACTIONS.map(&:first), theme: @theme)) do |result|
|
|
338
|
+
run_action(result) if result.is_a?(Integer)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def run_action(index)
|
|
343
|
+
case ACTIONS[index][1]
|
|
344
|
+
when :parent then up_dir
|
|
345
|
+
when :refresh then load_entries
|
|
346
|
+
when :quit then :quit
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# --- input ---
|
|
351
|
+
|
|
352
|
+
def handle_key(key)
|
|
353
|
+
case key
|
|
354
|
+
when "q", TuiTui::KeyCode::CTRL_C then confirm_quit
|
|
355
|
+
when "?" then open_modal(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
|
|
356
|
+
when "/" then enter_finder
|
|
357
|
+
when "m" then open_actions
|
|
358
|
+
when "l", "\r", :right then open_entry
|
|
359
|
+
when "h", :left, TuiTui::KeyCode::BACKSPACE then up_dir # h / ← / Backspace
|
|
360
|
+
when "\t" then @focus = @focus.next
|
|
361
|
+
when "<" then @split = [@split - SPLIT_STEP, 0.1].max
|
|
362
|
+
when ">" then @split = [@split + SPLIT_STEP, 0.9].min
|
|
363
|
+
when "J" then scroll_preview(1) # always works, whichever pane is focused
|
|
364
|
+
when "K" then scroll_preview(-1)
|
|
365
|
+
when "w" then toggle_preview_wrap
|
|
366
|
+
when "t" then cycle_theme
|
|
367
|
+
when "y" then copy_path
|
|
368
|
+
else navigate(key) # j/k, arrows, paging and g/G follow the focused pane
|
|
369
|
+
end
|
|
370
|
+
self
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Cycle the UI theme (default -> warm -> mono -> ...), rebuilding the chrome
|
|
374
|
+
# palette. Open modals read @theme when created, so the next one matches too.
|
|
375
|
+
# Queue the selected path for the clipboard (drained by the Runtime) and show
|
|
376
|
+
# a toast so the copy is visibly confirmed.
|
|
377
|
+
def copy_path
|
|
378
|
+
@clipboard = File.expand_path(File.join(@dir, selected.to_s))
|
|
379
|
+
@toast = TuiTui::Toast.new("copied path to clipboard")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def cycle_theme
|
|
383
|
+
@theme_i = (@theme_i + 1) % THEMES.size
|
|
384
|
+
@theme = FileBrowserSample.theme_for(THEMES[@theme_i])
|
|
385
|
+
@styles = FileBrowserSample.palette(@theme)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Move/page keys act on whichever pane Tab has focused: the directory list,
|
|
389
|
+
# or the file preview (scrolling its text back and forth).
|
|
390
|
+
def navigate(key)
|
|
391
|
+
@focus.focused?(:preview) ? navigate_preview(key) : navigate_list(key)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def navigate_list(key)
|
|
395
|
+
case key
|
|
396
|
+
when "j", :down then move(1)
|
|
397
|
+
when "k", :up then move(-1)
|
|
398
|
+
when " ", :pgdn then move(@page)
|
|
399
|
+
when "b", :pgup then move(-@page)
|
|
400
|
+
when "g", :home then go_to(0)
|
|
401
|
+
when "G", :end then go_to(@list.last)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def navigate_preview(key)
|
|
406
|
+
case key
|
|
407
|
+
when "j", :down then scroll_preview(1)
|
|
408
|
+
when "k", :up then scroll_preview(-1)
|
|
409
|
+
when " ", :pgdn then scroll_preview(@preview_page)
|
|
410
|
+
when "b", :pgup then scroll_preview(-@preview_page)
|
|
411
|
+
when "g", :home then @preview_scroll = 0
|
|
412
|
+
when "G", :end then @preview_scroll = 1 << 30 # draw clamps to the last line
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def scroll_preview(delta)
|
|
417
|
+
@preview_scroll = [@preview_scroll + delta, 0].max # upper bound clamped in draw_preview
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def toggle_preview_wrap
|
|
421
|
+
@preview_wrap = !@preview_wrap
|
|
422
|
+
@preview_scroll = 0 # the display-line count changes, so start from the top
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def move(delta)
|
|
426
|
+
@list.move(delta)
|
|
427
|
+
@preview_scroll = 0
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def go_to(index)
|
|
431
|
+
@list.go_to(index)
|
|
432
|
+
@preview_scroll = 0
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def open_entry
|
|
436
|
+
name = selected
|
|
437
|
+
return up_dir if name == ".."
|
|
438
|
+
return unless directory?(name)
|
|
439
|
+
|
|
440
|
+
@dir = File.join(@dir, name)
|
|
441
|
+
load_entries
|
|
442
|
+
go_to(0)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def up_dir
|
|
446
|
+
parent = File.dirname(@dir)
|
|
447
|
+
return if parent == @dir # already at the filesystem root
|
|
448
|
+
|
|
449
|
+
came_from = File.basename(@dir)
|
|
450
|
+
@dir = parent
|
|
451
|
+
load_entries
|
|
452
|
+
go_to(@entries.index(came_from) || 0)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def selected = @entries[@list.cursor]
|
|
456
|
+
|
|
457
|
+
# --- fuzzy finder (incremental; built on TuiTui::Fuzzy) ---
|
|
458
|
+
|
|
459
|
+
def enter_finder
|
|
460
|
+
@finder = ""
|
|
461
|
+
refilter
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def exit_finder
|
|
465
|
+
@finder = nil
|
|
466
|
+
refilter
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# While finding: arrows navigate, printable keys narrow the query, Enter opens
|
|
470
|
+
# the top/selected match, Esc cancels. Letters type into the query (not move),
|
|
471
|
+
# so navigation is the arrow keys.
|
|
472
|
+
def finder_key(key)
|
|
473
|
+
case key
|
|
474
|
+
when :escape, TuiTui::KeyCode::CTRL_C then exit_finder
|
|
475
|
+
when "\r" then choose_finding
|
|
476
|
+
when :up then move(-1)
|
|
477
|
+
when :down then move(1)
|
|
478
|
+
when TuiTui::KeyCode::BACKSPACE, :backspace then backspace_finder
|
|
479
|
+
when String then type_finder(key)
|
|
480
|
+
end
|
|
481
|
+
self
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def type_finder(key)
|
|
485
|
+
return unless key.bytes.all? { |b| b >= 0x20 && b != 0x7F } # printable only
|
|
486
|
+
|
|
487
|
+
@finder += key
|
|
488
|
+
refilter
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def backspace_finder
|
|
492
|
+
return if @finder.empty?
|
|
493
|
+
|
|
494
|
+
@finder = @finder[0...-1]
|
|
495
|
+
refilter
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Open the highlighted match (and leave finder mode).
|
|
499
|
+
def choose_finding
|
|
500
|
+
target = selected
|
|
501
|
+
exit_finder
|
|
502
|
+
go_to(@entries.index(target) || 0)
|
|
503
|
+
open_entry
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# --- directory model ---
|
|
507
|
+
|
|
508
|
+
# Directories first, then files, each alphabetical; ".." unless at the root.
|
|
509
|
+
# `@all` is the full list; `@entries` is what's shown (fuzzy-ranked when finding).
|
|
510
|
+
def load_entries
|
|
511
|
+
names = (Dir.children(@dir) rescue []).sort_by do |name|
|
|
512
|
+
[directory?(name) ? 0 : 1, name.downcase]
|
|
513
|
+
end
|
|
514
|
+
@all = (@dir == File.dirname(@dir) ? [] : [".."]) + names
|
|
515
|
+
@cache_path = nil
|
|
516
|
+
refilter
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Recompute the visible entries: fuzzy-ranked while finding (best match first,
|
|
520
|
+
# with matched positions for highlighting), otherwise the full dir-first list.
|
|
521
|
+
def refilter
|
|
522
|
+
if @finder && !@finder.empty?
|
|
523
|
+
ranked = TuiTui::Fuzzy.new(@finder).rank(@all)
|
|
524
|
+
@entries = ranked.map(&:first)
|
|
525
|
+
@matches = ranked.to_h { |name, found| [name, found.positions] }
|
|
526
|
+
else
|
|
527
|
+
@entries = @all
|
|
528
|
+
@matches = {}
|
|
529
|
+
end
|
|
530
|
+
@list.count = @entries.size
|
|
531
|
+
go_to(0)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def directory?(name) = File.directory?(File.join(@dir, name))
|
|
535
|
+
|
|
536
|
+
# --- layout ---
|
|
537
|
+
|
|
538
|
+
def split_status(size)
|
|
539
|
+
return [whole(size), nil] if size.rows < 2
|
|
540
|
+
|
|
541
|
+
whole(size).split_h(size.rows - 1)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def split_panes(body)
|
|
545
|
+
return [body, nil] if body.cols < MIN_TWO_PANE
|
|
546
|
+
|
|
547
|
+
body.split_ratio(@split, min: MIN_PANE, gutter: 1)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def whole(size) = TuiTui::Rect.new(row: 1, col: 1, rows: size.rows, cols: size.cols)
|
|
551
|
+
|
|
552
|
+
# --- drawing ---
|
|
553
|
+
|
|
554
|
+
# A dim vertical rule in the 1-column gutter between the panes (the column
|
|
555
|
+
# split_ratio left between list and preview). ASCII "|" (N7), dim like frames.
|
|
556
|
+
def draw_divider(canvas, list_rect)
|
|
557
|
+
col = list_rect.col + list_rect.cols
|
|
558
|
+
canvas.fill(TuiTui::Rect.new(row: list_rect.row, col: col, rows: list_rect.rows, cols: 1), @styles[:divider], "|")
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def draw_list(canvas, rect)
|
|
562
|
+
highlight = @focus.focused?(:list) ? @styles[:select] : @styles[:select_blur]
|
|
563
|
+
TuiTui::List.new(@list).draw(canvas, rect, highlight: highlight, scrollbar: @theme) do |index, selected|
|
|
564
|
+
name = @entries[index]
|
|
565
|
+
label = directory?(name) && name != ".." ? "#{name}/" : name
|
|
566
|
+
base = selected ? highlight : (directory?(name) ? @styles[:dir] : @styles[:file])
|
|
567
|
+
entry_line(label, name, base) # List reserves the gutter, truncates, and draws the scrollbar
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# The label as a Line: fuzzy-matched characters get the match style, the rest
|
|
572
|
+
# the base style. Adjacent same-style characters coalesce into one span.
|
|
573
|
+
def entry_line(label, name, base)
|
|
574
|
+
positions = @finder ? @matches[name] : nil
|
|
575
|
+
return TuiTui::Line[TuiTui::Span[label, base]] unless positions
|
|
576
|
+
|
|
577
|
+
spans = []
|
|
578
|
+
run = +""
|
|
579
|
+
run_style = nil
|
|
580
|
+
label.grapheme_clusters.each_with_index do |grapheme, i| # grapheme indices to match Fuzzy#positions
|
|
581
|
+
style = positions.include?(i) ? @styles[:match] : base
|
|
582
|
+
spans << TuiTui::Span[run, run_style] if style != run_style && !run.empty?
|
|
583
|
+
run = +"" if style != run_style
|
|
584
|
+
run_style = style
|
|
585
|
+
run << grapheme
|
|
586
|
+
end
|
|
587
|
+
spans << TuiTui::Span[run, run_style] unless run.empty?
|
|
588
|
+
TuiTui::Line.new(spans)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def draw_preview(canvas, rect)
|
|
592
|
+
width = rect.split_gutter.first.cols # leave room for the scrollbar gutter when wrapping/truncating
|
|
593
|
+
lines = preview_display(width)
|
|
594
|
+
@preview_scroll = @preview_scroll.clamp(0, [lines.length - 1, 0].max)
|
|
595
|
+
TuiTui::TextView.draw(canvas, rect, lines, top: @preview_scroll, scrollbar: @theme)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Preview as styled display lines (Line). Wrap mode wraps to the width (plain,
|
|
599
|
+
# since wrapping styled spans is out of scope); a code file is syntax-
|
|
600
|
+
# highlighted; anything else is plain subdued text.
|
|
601
|
+
def preview_display(width)
|
|
602
|
+
if @preview_wrap
|
|
603
|
+
preview_lines.flat_map { |line| TuiTui::DisplayText.new(line).wrap(width) }
|
|
604
|
+
.map { |dt| plain_line(dt.to_s) }
|
|
605
|
+
elsif (lang = preview_lang)
|
|
606
|
+
highlighted(lang)
|
|
607
|
+
else
|
|
608
|
+
preview_lines.map { |line| plain_line(line) }
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def plain_line(text) = TuiTui::Line[TuiTui::Span[text, @styles[:dim]]]
|
|
613
|
+
|
|
614
|
+
# The highlighter language for the selected file, or nil (no highlighting).
|
|
615
|
+
def preview_lang
|
|
616
|
+
return nil unless selected && selected != ".." && !directory?(selected)
|
|
617
|
+
|
|
618
|
+
Code.lang_for(File.extname(selected.to_s))
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Syntax-highlighted preview Lines, cached per selection (like preview_lines).
|
|
622
|
+
def highlighted(lang)
|
|
623
|
+
path = File.join(@dir, selected.to_s)
|
|
624
|
+
return @hl_lines if @hl_path == path
|
|
625
|
+
|
|
626
|
+
@hl_path = path
|
|
627
|
+
@hl_lines = preview_lines.map { |line| Code.line(line, lang) }
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def draw_status(canvas, rect)
|
|
631
|
+
left = @finder ? " > #{@finder}" : " #{@dir}"
|
|
632
|
+
hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find m=menu t=#{THEMES[@theme_i]} q=quit"
|
|
633
|
+
right = "#{@list.cursor + 1}/#{@entries.size} #{hints} "
|
|
634
|
+
TuiTui::StatusBar.draw(canvas, rect, left: left, right: right, style: @styles[:bar])
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Lines of the selected file's preview, cached per selection so we do not
|
|
638
|
+
# re-read the file on every idle tick.
|
|
639
|
+
def preview_lines
|
|
640
|
+
path = File.join(@dir, selected.to_s)
|
|
641
|
+
return @cache_lines if @cache_path == path
|
|
642
|
+
|
|
643
|
+
@cache_path = path
|
|
644
|
+
@cache_lines = build_preview(path)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def build_preview(path)
|
|
648
|
+
name = selected
|
|
649
|
+
return ["(parent directory)"] if name == ".."
|
|
650
|
+
return ["(directory)"] if directory?(name)
|
|
651
|
+
|
|
652
|
+
data = File.binread(path, PREVIEW_BYTES)
|
|
653
|
+
return ["(empty file)"] if data.nil? || data.empty?
|
|
654
|
+
return ["(binary file)"] if data.include?("\u0000")
|
|
655
|
+
|
|
656
|
+
data.force_encoding("UTF-8").lines.first(PREVIEW_LINES).map { |line| line.chomp.gsub("\t", " ") }
|
|
657
|
+
rescue SystemCallError
|
|
658
|
+
["(unreadable)"]
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
if $PROGRAM_NAME == __FILE__
|
|
664
|
+
TuiTui::Runtime.new(FileBrowserSample::Browser.new(ARGV[0] || ".")).run
|
|
665
|
+
end
|