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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +132 -0
  5. data/Rakefile +8 -0
  6. data/examples/clock.rb +112 -0
  7. data/examples/counter.rb +48 -0
  8. data/examples/csv_viewer.rb +233 -0
  9. data/examples/file_browser.rb +665 -0
  10. data/examples/form.rb +633 -0
  11. data/examples/life.rb +144 -0
  12. data/examples/paint.rb +246 -0
  13. data/examples/todo.rb +250 -0
  14. data/examples/widgets.rb +101 -0
  15. data/lib/tui_tui/ansi.rb +34 -0
  16. data/lib/tui_tui/canvas.rb +187 -0
  17. data/lib/tui_tui/canvas_compositor.rb +45 -0
  18. data/lib/tui_tui/cell.rb +11 -0
  19. data/lib/tui_tui/color_depth.rb +39 -0
  20. data/lib/tui_tui/confirm.rb +74 -0
  21. data/lib/tui_tui/display_text.rb +73 -0
  22. data/lib/tui_tui/event.rb +10 -0
  23. data/lib/tui_tui/event_stream.rb +39 -0
  24. data/lib/tui_tui/focus_ring.rb +25 -0
  25. data/lib/tui_tui/fuzzy.rb +56 -0
  26. data/lib/tui_tui/help.rb +44 -0
  27. data/lib/tui_tui/key_code.rb +9 -0
  28. data/lib/tui_tui/key_intent.rb +29 -0
  29. data/lib/tui_tui/key_reader.rb +175 -0
  30. data/lib/tui_tui/line.rb +59 -0
  31. data/lib/tui_tui/list.rb +45 -0
  32. data/lib/tui_tui/modal.rb +30 -0
  33. data/lib/tui_tui/pager.rb +94 -0
  34. data/lib/tui_tui/palette.rb +49 -0
  35. data/lib/tui_tui/prompt.rb +111 -0
  36. data/lib/tui_tui/rect.rb +48 -0
  37. data/lib/tui_tui/runtime.rb +53 -0
  38. data/lib/tui_tui/screen.rb +85 -0
  39. data/lib/tui_tui/scroll_list.rb +57 -0
  40. data/lib/tui_tui/scrollbar.rb +40 -0
  41. data/lib/tui_tui/select.rb +104 -0
  42. data/lib/tui_tui/size.rb +5 -0
  43. data/lib/tui_tui/span.rb +14 -0
  44. data/lib/tui_tui/status_bar.rb +23 -0
  45. data/lib/tui_tui/style.rb +101 -0
  46. data/lib/tui_tui/terminal_session.rb +65 -0
  47. data/lib/tui_tui/terminal_size.rb +24 -0
  48. data/lib/tui_tui/text_sanitizer.rb +13 -0
  49. data/lib/tui_tui/text_view.rb +52 -0
  50. data/lib/tui_tui/theme.rb +127 -0
  51. data/lib/tui_tui/toast.rb +82 -0
  52. data/lib/tui_tui/version.rb +5 -0
  53. data/lib/tui_tui/width.rb +101 -0
  54. data/lib/tui_tui.rb +51 -0
  55. 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