tui_tui 0.3.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +51 -14
  4. data/examples/README.md +28 -0
  5. data/examples/breakout.rb +225 -0
  6. data/examples/counter.rb +11 -12
  7. data/examples/csv_viewer.rb +28 -50
  8. data/examples/downloads.rb +128 -0
  9. data/examples/file_browser.rb +64 -55
  10. data/examples/form.rb +80 -102
  11. data/examples/life.rb +1 -1
  12. data/examples/log_viewer.rb +118 -0
  13. data/examples/todo.rb +43 -64
  14. data/examples/widgets.rb +4 -4
  15. data/lib/tui_tui/box_chrome.rb +0 -9
  16. data/lib/tui_tui/box_chrome_resolver.rb +21 -0
  17. data/lib/tui_tui/canvas.rb +6 -55
  18. data/lib/tui_tui/canvas_compositor.rb +12 -10
  19. data/lib/tui_tui/canvas_renderer.rb +59 -0
  20. data/lib/tui_tui/cell.rb +4 -3
  21. data/lib/tui_tui/command.rb +21 -0
  22. data/lib/tui_tui/event_stream.rb +42 -7
  23. data/lib/tui_tui/key_reader.rb +1 -1
  24. data/lib/tui_tui/modal/command_palette.rb +193 -0
  25. data/lib/tui_tui/modal/confirm.rb +74 -0
  26. data/lib/tui_tui/modal/help.rb +44 -0
  27. data/lib/tui_tui/modal/pager.rb +106 -0
  28. data/lib/tui_tui/modal/prompt.rb +66 -0
  29. data/lib/tui_tui/modal/select.rb +103 -0
  30. data/lib/tui_tui/modal.rb +4 -0
  31. data/lib/tui_tui/modal_host.rb +2 -2
  32. data/lib/tui_tui/rect.rb +69 -0
  33. data/lib/tui_tui/runtime.rb +46 -23
  34. data/lib/tui_tui/screen.rb +7 -3
  35. data/lib/tui_tui/test_runtime.rb +71 -0
  36. data/lib/tui_tui/text_input.rb +168 -0
  37. data/lib/tui_tui/text_sanitizer.rb +3 -10
  38. data/lib/tui_tui/theme.rb +25 -27
  39. data/lib/tui_tui/version.rb +1 -1
  40. data/lib/tui_tui/widget/list.rb +59 -0
  41. data/lib/tui_tui/widget/progress.rb +33 -0
  42. data/lib/tui_tui/widget/scroll_list.rb +59 -0
  43. data/lib/tui_tui/widget/scrollbar.rb +43 -0
  44. data/lib/tui_tui/widget/spinner.rb +37 -0
  45. data/lib/tui_tui/widget/status_bar.rb +25 -0
  46. data/lib/tui_tui/widget/table.rb +81 -0
  47. data/lib/tui_tui/widget/tabs.rb +67 -0
  48. data/lib/tui_tui/widget/text_view.rb +47 -0
  49. data/lib/tui_tui/widget/toast.rb +83 -0
  50. data/lib/tui_tui.rb +23 -15
  51. metadata +28 -15
  52. data/lib/tui_tui/command_palette.rb +0 -190
  53. data/lib/tui_tui/confirm.rb +0 -74
  54. data/lib/tui_tui/help.rb +0 -44
  55. data/lib/tui_tui/list.rb +0 -57
  56. data/lib/tui_tui/pager.rb +0 -106
  57. data/lib/tui_tui/prompt.rb +0 -108
  58. data/lib/tui_tui/scroll_list.rb +0 -57
  59. data/lib/tui_tui/scrollbar.rb +0 -41
  60. data/lib/tui_tui/select.rb +0 -103
  61. data/lib/tui_tui/status_bar.rb +0 -23
  62. data/lib/tui_tui/text_view.rb +0 -45
  63. data/lib/tui_tui/toast.rb +0 -83
  64. /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
@@ -14,7 +14,8 @@
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 (CommandPalette, Select, Help, Confirm).
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"
@@ -204,7 +205,7 @@ module FileBrowserSample
204
205
  class Browser
205
206
  def initialize(path)
206
207
  @dir = File.expand_path(path)
207
- @list = TuiTui::ScrollList.new
208
+ @list = TuiTui::Widget::ScrollList.new
208
209
  @preview_scroll = 0
209
210
  @preview_wrap = false # toggle with "w": wrap long lines vs. clip them
210
211
  @hl_path = nil # cache key for syntax-highlighted preview lines
@@ -219,31 +220,21 @@ module FileBrowserSample
219
220
  @page = 1
220
221
  @preview_page = 1
221
222
  @split = 0.5 # divider position as a fraction of the body width (resize-safe)
222
- @finder = nil # the fuzzy query while finding (a String), or nil when off
223
+ @finder = nil # a TuiTui::TextInput holding the fuzzy query while finding, or nil when off
223
224
  @matches = {} # entry name => matched char positions, for highlighting
224
- @modal = nil
225
- @on_result = nil
226
- @clipboard = nil # a path queued for the clipboard; the Runtime drains it
225
+ @host = TuiTui::ModalHost.new
227
226
  load_entries
228
227
  end
229
228
 
230
- # The Runtime calls this after `update` and copies the returned text (OSC 52),
231
- # then clears it. Keeping the I/O out of `update` leaves the fold pure.
232
- def take_clipboard
233
- text = @clipboard
234
- @clipboard = nil
235
- text
236
- end
237
-
238
229
  # Keep ticking only while a toast is showing, so it auto-dismisses.
239
230
  def wants_tick? = !@toast.nil?
240
231
 
241
232
  def update(event)
242
233
  @toast = nil if @toast&.expired?
243
234
  case event
244
- when TuiTui::MouseEvent then @modal ? route_modal_mouse(event) : handle_mouse(event)
235
+ when TuiTui::MouseEvent then @host.open? ? route_modal(event) : handle_mouse(event)
245
236
  when TuiTui::KeyEvent
246
- return route_modal(event.key) if @modal
237
+ return route_modal(event) if @host.open?
247
238
  return finder_key(event.key) if @finder
248
239
 
249
240
  handle_key(event.key)
@@ -320,7 +311,7 @@ module FileBrowserSample
320
311
  draw_preview(canvas, preview_rect) if preview_rect
321
312
  draw_status(canvas, status) if status
322
313
  @toast&.draw(canvas, size, style: @theme.selection)
323
- @modal&.draw(canvas, size) # modal overlay on top of everything
314
+ @host.draw(canvas, size) # modal overlay on top of everything
324
315
  canvas
325
316
  end
326
317
 
@@ -329,28 +320,30 @@ module FileBrowserSample
329
320
  # --- modals ---
330
321
 
331
322
  # Show a modal widget; `on_result` interprets its resolved value (and may
332
- # return :quit). A widget returns nil from `handle` while still open.
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.
333
325
  def open_modal(widget, &on_result)
334
- @modal = widget
335
- @on_result = on_result
326
+ @host.open(widget, &on_result)
327
+ self
336
328
  end
337
329
 
338
- def route_modal(key) = resolve_modal(@modal.handle(key))
339
- def route_modal_mouse(event) = resolve_modal(@modal.handle_mouse(event))
340
-
341
- def resolve_modal(result)
342
- return self if result.nil? # still open
343
-
344
- @modal = nil
345
- @on_result.call(result) == :quit ? :quit : self
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
346
339
  end
347
340
 
348
341
  def confirm_quit
349
- 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 }
350
343
  end
351
344
 
352
345
  def open_actions
353
- 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|
354
347
  run_action(result) if result.is_a?(Integer)
355
348
  end
356
349
  end
@@ -363,13 +356,13 @@ module FileBrowserSample
363
356
  end
364
357
  end
365
358
 
366
- def open_help = open_modal(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
359
+ def open_help = open_modal(TuiTui::Modal::Help.new("Keys", HELP, theme: @theme)) { nil }
367
360
 
368
361
  # The ":" command palette: a fuzzy-filtered list of every command. The palette
369
362
  # ranks by the label and resolves to the chosen [label, action] pair (or
370
363
  # :cancel on Esc), which run_command dispatches.
371
364
  def open_palette
372
- open_modal(TuiTui::CommandPalette.new(COMMANDS, theme: @theme) { |label, _action| label }) do |chosen|
365
+ open_modal(TuiTui::Modal::CommandPalette.new(COMMANDS, theme: @theme) { |label, _action| label }) do |chosen|
373
366
  run_command(chosen.last) if chosen.is_a?(Array)
374
367
  end
375
368
  end
@@ -390,6 +383,7 @@ module FileBrowserSample
390
383
 
391
384
  # --- input ---
392
385
 
386
+ # Each branch evaluates to update's return value: self, [self, *commands], or :quit.
393
387
  def handle_key(key)
394
388
  case key
395
389
  when "q", TuiTui::KeyCode::CTRL_C then confirm_quit
@@ -399,38 +393,50 @@ module FileBrowserSample
399
393
  when "m" then open_actions
400
394
  when "l", "\r", :right then open_entry
401
395
  when "h", :left, TuiTui::KeyCode::BACKSPACE then up_dir # h / ← / Backspace
402
- when "\t" then @focus = @focus.next
403
- when "<" then @split = [@split - SPLIT_STEP, 0.1].max
404
- when ">" then @split = [@split + SPLIT_STEP, 0.9].min
396
+ when "\t" then switch_focus
397
+ when "<" then nudge_split(-SPLIT_STEP)
398
+ when ">" then nudge_split(SPLIT_STEP)
405
399
  when "J" then scroll_preview(1) # always works, whichever pane is focused
406
400
  when "K" then scroll_preview(-1)
407
401
  when "w" then toggle_preview_wrap
408
402
  when "t" then cycle_theme
409
- when "y" then copy_path
403
+ when "y" then copy_path # [self, Command::Copy] propagates out of update
410
404
  else navigate(key) # j/k, arrows, paging and g/G follow the focused pane
411
405
  end
406
+ end
407
+
408
+ def switch_focus
409
+ @focus = @focus.next
412
410
  self
413
411
  end
414
412
 
415
- # Cycle the UI theme (default -> warm -> mono -> ...), rebuilding the chrome
416
- # palette. Open modals read @theme when created, so the next one matches too.
417
- # Queue the selected path for the clipboard (drained by the Runtime) and show
418
- # a toast so the copy is visibly confirmed.
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.
419
420
  def copy_path
420
- @clipboard = File.expand_path(File.join(@dir, selected.to_s))
421
- @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)]
422
424
  end
423
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.
424
428
  def cycle_theme
425
429
  @theme_i = (@theme_i + 1) % THEMES.size
426
430
  @theme = FileBrowserSample.theme_for(THEMES[@theme_i])
427
431
  @styles = FileBrowserSample.palette(@theme)
432
+ self
428
433
  end
429
434
 
430
435
  # Move/page keys act on whichever pane Tab has focused: the directory list,
431
436
  # or the file preview (scrolling its text back and forth).
432
437
  def navigate(key)
433
438
  @focus.focused?(:preview) ? navigate_preview(key) : navigate_list(key)
439
+ self
434
440
  end
435
441
 
436
442
  def navigate_list(key)
@@ -457,27 +463,31 @@ module FileBrowserSample
457
463
 
458
464
  def scroll_preview(delta)
459
465
  @preview_scroll = [@preview_scroll + delta, 0].max # upper bound clamped in draw_preview
466
+ self
460
467
  end
461
468
 
462
469
  def toggle_preview_wrap
463
470
  @preview_wrap = !@preview_wrap
464
471
  @preview_scroll = 0 # the display-line count changes, so start from the top
472
+ self
465
473
  end
466
474
 
467
475
  def move(delta)
468
476
  @list.move(delta)
469
477
  @preview_scroll = 0
478
+ self
470
479
  end
471
480
 
472
481
  def go_to(index)
473
482
  @list.go_to(index)
474
483
  @preview_scroll = 0
484
+ self
475
485
  end
476
486
 
477
487
  def open_entry
478
488
  name = selected
479
489
  return up_dir if name == ".."
480
- return unless directory?(name)
490
+ return self unless directory?(name)
481
491
 
482
492
  @dir = File.join(@dir, name)
483
493
  load_entries
@@ -486,7 +496,7 @@ module FileBrowserSample
486
496
 
487
497
  def up_dir
488
498
  parent = File.dirname(@dir)
489
- return if parent == @dir # already at the filesystem root
499
+ return self if parent == @dir # already at the filesystem root
490
500
 
491
501
  came_from = File.basename(@dir)
492
502
  @dir = parent
@@ -499,8 +509,9 @@ module FileBrowserSample
499
509
  # --- fuzzy finder (incremental; built on TuiTui::Fuzzy) ---
500
510
 
501
511
  def enter_finder
502
- @finder = ""
512
+ @finder = TuiTui::TextInput.new
503
513
  refilter
514
+ self
504
515
  end
505
516
 
506
517
  def exit_finder
@@ -524,16 +535,14 @@ module FileBrowserSample
524
535
  end
525
536
 
526
537
  def type_finder(key)
527
- return unless key.bytes.all? { |b| b >= 0x20 && b != 0x7F } # printable only
528
-
529
- @finder += key
530
- refilter
538
+ # #type ignores control keys; the query is append-only (cursor at the end).
539
+ refilter if @finder.type(key)
531
540
  end
532
541
 
533
542
  def backspace_finder
534
543
  return if @finder.empty?
535
544
 
536
- @finder = @finder[0...-1]
545
+ @finder.delete_back # by grapheme, so a multi-codepoint cluster deletes whole
537
546
  refilter
538
547
  end
539
548
 
@@ -562,7 +571,7 @@ module FileBrowserSample
562
571
  # with matched positions for highlighting), otherwise the full dir-first list.
563
572
  def refilter
564
573
  if @finder && !@finder.empty?
565
- ranked = TuiTui::Fuzzy.new(@finder).rank(@all)
574
+ ranked = TuiTui::Fuzzy.new(@finder.value).rank(@all)
566
575
  @entries = ranked.map(&:first)
567
576
  @matches = ranked.to_h { |name, found| [name, found.positions] }
568
577
  else
@@ -603,7 +612,7 @@ module FileBrowserSample
603
612
 
604
613
  def draw_list(canvas, rect)
605
614
  highlight = @focus.focused?(:list) ? @styles[:select] : @styles[:select_blur]
606
- 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|
607
616
  name = @entries[index]
608
617
  label = directory?(name) && name != ".." ? "#{name}/" : name
609
618
  base = selected ? highlight : (directory?(name) ? @styles[:dir] : @styles[:file])
@@ -635,7 +644,7 @@ module FileBrowserSample
635
644
  width = rect.split_gutter.first.cols # leave room for the scrollbar gutter when wrapping/truncating
636
645
  lines = preview_display(width)
637
646
  @preview_scroll = @preview_scroll.clamp(0, [lines.length - 1, 0].max)
638
- TuiTui::TextView.draw(canvas, rect, lines, top: @preview_scroll, scrollbar: @theme)
647
+ TuiTui::Widget::TextView.draw(canvas, rect, lines, top: @preview_scroll, scrollbar: @theme)
639
648
  end
640
649
 
641
650
  # Preview as styled display lines (Line). Wrap mode wraps to the width (plain,
@@ -671,10 +680,10 @@ module FileBrowserSample
671
680
  end
672
681
 
673
682
  def draw_status(canvas, rect)
674
- left = @finder ? " > #{@finder}" : " #{@dir}"
683
+ left = @finder ? " > #{@finder.value}" : " #{@dir}"
675
684
  hints = @finder ? "Esc=cancel Enter=open" : "?=help /=find :=cmds m=menu t=#{THEMES[@theme_i]} q=quit"
676
685
  right = "#{@list.cursor + 1}/#{@entries.size} #{hints} "
677
- 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])
678
687
  end
679
688
 
680
689
  # Lines of the selected file's preview, cached per selection so we do not