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
data/lib/tui_tui/pager.rb DELETED
@@ -1,106 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "display_text"
4
- require_relative "style"
5
- require_relative "span"
6
- require_relative "line"
7
- require_relative "rect"
8
- require_relative "modal"
9
- require_relative "key_intent"
10
-
11
- module TuiTui
12
- # Scrollable read-only text modal.
13
- class Pager < Modal
14
- MARGIN = 2
15
- WHEEL = 3
16
-
17
- # Each line may be a plain String, a `Line`, or an array of `Span`s, so a
18
- # log/diff/error view can color whole lines or runs within them. Spans
19
- # without a style fall back to theme.muted.
20
- def initialize(title, lines, start: 0, close_keys: [], theme: Theme::DEFAULT)
21
- @title = title
22
- @theme = theme
23
- @lines = lines.map { |line| normalize_line(line) }
24
- @top = start
25
- @page = 1
26
- @close_keys = close_keys
27
- end
28
-
29
- def handle(key)
30
- return :close if @close_keys.include?(key)
31
-
32
- case KeyIntent.for(key)
33
- when :up
34
- scroll(-1)
35
- when :down
36
- scroll(1)
37
- when :top
38
- scroll(-@lines.size)
39
- when :bottom
40
- scroll(@lines.size)
41
- when :cancel
42
- :close
43
- else
44
- paginate(key)
45
- end
46
- end
47
-
48
- def handle_mouse(event)
49
- scroll(event.button == :wheel_up ? -WHEEL : WHEEL) if event.action == :wheel
50
- end
51
-
52
- def draw(canvas, size)
53
- width = [size.cols - (MARGIN * 2), 20].max
54
- height = [size.rows - (MARGIN * 2), 5].max
55
- rect = Rect.centered(size, cols: width, rows: height)
56
- canvas.frame(rect, style: theme.frame)
57
-
58
- inner = width - 4
59
- body = [height - 4, 1].max
60
- @page = body
61
- clamp(body)
62
-
63
- canvas.text(rect.row + 1, rect.col + 2, DisplayText.new(title_line(body)).truncate(inner), theme.title)
64
- body.times do |offset|
65
- line = @lines[@top + offset]
66
- next if line.nil?
67
-
68
- canvas.line(rect.row + 3 + offset, rect.col + 2, line.truncate(inner))
69
- end
70
-
71
- canvas
72
- end
73
-
74
- private
75
-
76
- # -> Line. Accepts a String, a Line, or a Span array; spans left unstyled
77
- # default to theme.muted so a bare line still reads as muted body text.
78
- def normalize_line(line)
79
- spans = Line.coerce(line).spans.map { |span| Span[span.text, span.style || theme.muted] }
80
- Line.new(spans)
81
- end
82
-
83
- def paginate(key)
84
- case key
85
- when " ", :pgdn
86
- scroll(@page)
87
- when "b", :pgup
88
- scroll(-@page)
89
- end
90
- end
91
-
92
- def scroll(delta)
93
- @top += delta
94
- nil
95
- end
96
-
97
- def clamp(body)
98
- @top = @top.clamp(0, [@lines.size - body, 0].max)
99
- end
100
-
101
- def title_line(body)
102
- last = [@top + body, @lines.size].min
103
- "#{@title} (#{@top + 1}-#{last}/#{@lines.size})"
104
- end
105
- end
106
- end
@@ -1,108 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "display_text"
4
- require_relative "text_sanitizer"
5
- require_relative "style"
6
- require_relative "modal"
7
- require_relative "key_code"
8
-
9
- module TuiTui
10
- # Single-line text input modal with terminal-column-aware cursor placement.
11
- class Prompt < Modal
12
- MIN_INNER = 24
13
-
14
- def initialize(label, value: "", theme: Theme::DEFAULT)
15
- @label = DisplayText.new(label)
16
- @graphemes = value.grapheme_clusters
17
- @pos = @graphemes.length
18
- @theme = theme
19
- end
20
-
21
- def value = @graphemes.join
22
-
23
- def handle(key)
24
- case key
25
- when "\r"
26
- [:ok, value]
27
- when :escape, KeyCode::CTRL_C
28
- :cancel
29
- when KeyCode::BACKSPACE, :backspace
30
- edit { delete_back }
31
- when :delete
32
- edit { delete_forward }
33
- when :left
34
- edit { @pos = [@pos - 1, 0].max }
35
- when :right
36
- edit { @pos = [@pos + 1, @graphemes.length].min }
37
- when :home
38
- edit { @pos = 0 }
39
- when :end
40
- edit { @pos = @graphemes.length }
41
- when String
42
- edit { insert(key) if TextSanitizer.printable?(key) }
43
- end
44
- end
45
-
46
- def handle_mouse(event)
47
- return nil unless event.action == :press && @text_row == event.row
48
-
49
- edit { @pos = index_at(event.col - @text_col) }
50
- end
51
-
52
- def draw(canvas, size)
53
- inner = [MIN_INNER, @label.width + 1 + DisplayText.new(value).width].max
54
- rect, col = panel(canvas, inner: inner, body_rows: 1)
55
-
56
- canvas.text(rect.row + 1, col, @label, theme.title)
57
- @text_row = rect.row + 1
58
- @text_col = col + @label.width + 1
59
- canvas.text(@text_row, @text_col, value, theme.text)
60
- draw_cursor(canvas, @text_row, @text_col)
61
- canvas
62
- end
63
-
64
- private
65
-
66
- def edit
67
- yield
68
- nil
69
- end
70
-
71
- # Grapheme index whose left edge is closest to `rel` columns into the value.
72
- def index_at(rel)
73
- return 0 if rel <= 0
74
-
75
- width = 0
76
- @graphemes.each_with_index do |grapheme, i|
77
- w = DisplayText.new(grapheme).width
78
- return i if rel < width + ((w + 1) / 2)
79
-
80
- width += w
81
- end
82
-
83
- @graphemes.length
84
- end
85
-
86
- def draw_cursor(canvas, row, text_col)
87
- cursor_col = text_col + DisplayText.new(@graphemes[0...@pos].join).width
88
- canvas.text(row, cursor_col, @graphemes[@pos] || " ", theme.cursor)
89
- end
90
-
91
- def insert(string)
92
- head = @graphemes[0...@pos].join
93
- @graphemes = (head + string + @graphemes[@pos..].join).grapheme_clusters
94
- @pos = (head + string).grapheme_clusters.length
95
- end
96
-
97
- def delete_back
98
- return if @pos.zero?
99
-
100
- @graphemes.delete_at(@pos - 1)
101
- @pos -= 1
102
- end
103
-
104
- def delete_forward
105
- @graphemes.delete_at(@pos) if @pos < @graphemes.length
106
- end
107
- end
108
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TuiTui
4
- # Cursor and viewport arithmetic shared by list-like widgets.
5
- class ScrollList
6
- attr_reader :cursor, :top
7
-
8
- def initialize(count = 0)
9
- @count = count
10
- @cursor = 0
11
- @top = 0
12
- end
13
-
14
- def count=(value)
15
- @count = [value, 0].max
16
- @cursor = @cursor.clamp(0, last)
17
- end
18
-
19
- attr_reader :count
20
-
21
- def empty? = @count.zero?
22
- def last = [@count - 1, 0].max
23
- def at_end? = @cursor == last
24
-
25
- def move(delta) = go_to(@cursor + delta)
26
- def page(height) = move(height)
27
- def to_top = go_to(0)
28
- def to_end = go_to(last)
29
-
30
- def go_to(index)
31
- @cursor = index.clamp(0, last)
32
- self
33
- end
34
-
35
- def ensure_visible(height)
36
- return self if height <= 0
37
-
38
- @top = @cursor if @cursor < @top
39
- @top = @cursor - height + 1 if @cursor >= @top + height
40
- @top = 0 if @top.negative?
41
- self
42
- end
43
-
44
- def each_visible(height)
45
- return enum_for(:each_visible, height) unless block_given?
46
-
47
- height.times do |offset|
48
- index = @top + offset
49
- break if index >= @count
50
-
51
- yield index, offset
52
- end
53
-
54
- self
55
- end
56
- end
57
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "rect"
4
- require_relative "style"
5
- require_relative "theme"
6
-
7
- module TuiTui
8
- # A vertical scroll indicator for a 1-column gutter.
9
- # It sizes a thumb from top/visible/total and draws ASCII-only chrome.
10
- module Scrollbar
11
- module_function
12
-
13
- TRACK = Theme::DEFAULT.scroll_track
14
- THUMB = Theme::DEFAULT.scroll_thumb
15
-
16
- def draw(canvas, rect, top:, visible:, total:, track: nil, thumb: " ", track_style: TRACK, thumb_style: THUMB)
17
- return canvas if rect.rows <= 0
18
-
19
- track ||= canvas.chrome.track
20
- length, offset = geometry(rect.rows, top, visible, total)
21
- rect.rows.times do |i|
22
- in_thumb = i >= offset && i < offset + length
23
- canvas.text(rect.row + i, rect.col, in_thumb ? thumb : track, in_thumb ? thumb_style : track_style)
24
- end
25
-
26
- canvas
27
- end
28
-
29
- # The thumb's [length, offset] in rows for a `height`-row track. Returns
30
- # [0, 0] (no thumb, track only) when everything fits — nothing to scroll.
31
- def geometry(height, top, visible, total)
32
- visible = [visible, 1].max
33
- total = [total, visible].max
34
- return [0, 0] if total <= visible
35
-
36
- length = [(height * visible / total.to_f).round, 1].max.clamp(1, height)
37
- offset = ((height - length) * top.to_f / (total - visible)).round
38
- [length, offset.clamp(0, height - length)]
39
- end
40
- end
41
- end
@@ -1,103 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "display_text"
4
- require_relative "style"
5
- require_relative "scroll_list"
6
- require_relative "list"
7
- require_relative "line"
8
- require_relative "span"
9
- require_relative "rect"
10
- require_relative "modal"
11
- require_relative "key_intent"
12
-
13
- module TuiTui
14
- # Scrollable list picker modal.
15
- class Select < Modal
16
- MAX_ROWS = 12
17
- MIN_INNER = 16
18
- WHEEL = 3
19
-
20
- def initialize(title, items, default: 0, theme: Theme::DEFAULT)
21
- @title = DisplayText.new(title)
22
- @items = items.map { |item| DisplayText.new(item) }
23
- @list = ScrollList.new(@items.size)
24
- @list.go_to(default)
25
- @theme = theme
26
- end
27
-
28
- def cursor = @list.cursor
29
-
30
- def handle(key)
31
- case KeyIntent.for(key)
32
- when :up
33
- nudge(-1)
34
- when :down
35
- nudge(1)
36
- when :top
37
- nudge_to(0)
38
- when :bottom
39
- nudge_to(@list.last)
40
- when :cancel
41
- :cancel
42
- else
43
- @list.cursor if ["\r", " "].include?(key)
44
- end
45
- end
46
-
47
- # Wheel moves the highlighted item; a click on an item picks it. Returns the
48
- # chosen index on a click, otherwise nil (stay open).
49
- def handle_mouse(event)
50
- case event.action
51
- when :wheel
52
- nudge(event.button == :wheel_up ? -WHEEL : WHEEL)
53
- when :press
54
- click(event)
55
- end
56
- end
57
-
58
- def draw(canvas, size)
59
- rows = visible_rows(size)
60
- inner = [MIN_INNER, @title.width, *@items.map(&:width)].max
61
- rect, text_col = panel(canvas, inner: inner, body_rows: rows + 2)
62
-
63
- canvas.text(rect.row + 1, text_col, @title.truncate(inner), theme.title)
64
- draw_items(canvas, rect.row + 3, text_col, inner, rows)
65
- canvas
66
- end
67
-
68
- private
69
-
70
- def nudge(delta)
71
- @list.move(delta)
72
- nil
73
- end
74
-
75
- def nudge_to(index)
76
- @list.go_to(index)
77
- nil
78
- end
79
-
80
- def visible_rows(size)
81
- room = [size.rows - 4, 1].max
82
- [@items.size, MAX_ROWS, room].min
83
- end
84
-
85
- def draw_items(canvas, row, col, inner, rows)
86
- @items_rect = Rect.new(row: row, col: col, rows: rows, cols: inner)
87
- List.new(@list).draw(canvas, @items_rect) do |index, focused|
88
- Line[Span[@items[index].to_s, focused ? theme.selection : theme.text]]
89
- end
90
- end
91
-
92
- # The item under a click, picked, or nil if the click missed the list.
93
- def click(event)
94
- return nil unless @items_rect
95
-
96
- index = List.new(@list).index_at(@items_rect, event)
97
- return nil if index.nil?
98
-
99
- @list.go_to(index)
100
- @list.cursor
101
- end
102
- end
103
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "display_text"
4
-
5
- module TuiTui
6
- # A one-row status/footer bar.
7
- # It draws left text from the start and optional right text flush right.
8
- module StatusBar
9
- module_function
10
-
11
- def draw(canvas, rect, left: "", right: nil, style: nil)
12
- canvas.fill(rect, style)
13
-
14
- right_width = right ? DisplayText.new(right).width : 0
15
- fits_right = right && right_width < rect.cols
16
- left_max = fits_right ? rect.cols - right_width : rect.cols
17
-
18
- canvas.text(rect.row, rect.col, DisplayText.new(left).truncate(left_max), style)
19
- canvas.text(rect.row, rect.col + rect.cols - right_width, right, style) if fits_right
20
- canvas
21
- end
22
- end
23
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "line"
4
- require_relative "span"
5
- require_relative "scrollbar"
6
-
7
- module TuiTui
8
- # A scrolled read-only text window.
9
- # Lines may be Strings, Lines, or Span arrays, supplied eagerly or lazily.
10
- module TextView
11
- module_function
12
-
13
- def draw(canvas, rect, lines = nil, top: 0, style: nil, scrollbar: nil, total: nil, auto: false)
14
- # With auto:, reserve the gutter only when the content overflows the rect.
15
- # When the total is unknown (lazy block, no total:), the bar is kept.
16
- count = total || lines&.length
17
- show_bar = scrollbar && !(auto && count && count <= rect.rows)
18
- body, gutter = show_bar ? rect.split_gutter : [rect, nil]
19
- body.rows.times do |offset|
20
- index = top + offset
21
- content = lines ? lines[index] : yield(index)
22
- next if content.nil?
23
-
24
- canvas.line(body.row + offset, body.col, Line.coerce(content, style).truncate(body.cols))
25
- end
26
-
27
- draw_scrollbar(canvas, gutter, top, total || lines&.length, body.rows, scrollbar) if gutter
28
- canvas
29
- end
30
-
31
- def draw_scrollbar(canvas, gutter, top, total, visible, theme)
32
- return unless total
33
-
34
- Scrollbar.draw(
35
- canvas,
36
- gutter,
37
- top: top,
38
- visible: visible,
39
- total: total,
40
- track_style: theme.scroll_track,
41
- thumb_style: theme.scroll_thumb
42
- )
43
- end
44
- end
45
- end
data/lib/tui_tui/toast.rb DELETED
@@ -1,83 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "display_text"
4
- require_relative "style"
5
- require_relative "clock"
6
-
7
- module TuiTui
8
- # A transient notification overlay.
9
- # The clock is injectable so expiry is testable without sleeping.
10
- class Toast
11
- DEFAULT_SECONDS = 2.0
12
- DEFAULT_STYLE = Style.new(attrs: [:reverse])
13
- DEFAULT_POSITION = :bottom_center
14
- POSITIONS = {
15
- top_left: [:top, :left],
16
- top_center: [:top, :center],
17
- top_right: [:top, :right],
18
- middle_left: [:middle, :left],
19
- middle_center: [:middle, :center],
20
- middle_right: [:middle, :right],
21
- bottom_left: [:bottom, :left],
22
- bottom_center: [:bottom, :center],
23
- bottom_right: [:bottom, :right],
24
- center: [:middle, :center]
25
- }.freeze
26
- MONOTONIC = -> { Clock.monotonic }
27
-
28
- def initialize(message, seconds: DEFAULT_SECONDS, position: DEFAULT_POSITION, clock: MONOTONIC)
29
- @message = DisplayText.new(message)
30
- @position = position
31
- @clock = clock
32
- @expires_at = clock.call + seconds
33
- validate_position!
34
- end
35
-
36
- def expired? = @clock.call >= @expires_at
37
-
38
- def draw(canvas, size, style: DEFAULT_STYLE, position: @position)
39
- return canvas if expired?
40
-
41
- label = DisplayText.new(" #{@message} ").truncate(size.cols)
42
- vertical, horizontal = position_parts(position)
43
- row = row_for(size, vertical)
44
- col = col_for(size, label.width, horizontal)
45
- canvas.text(row, col, label, style)
46
- canvas
47
- end
48
-
49
- private
50
-
51
- def validate_position!
52
- position_parts(@position)
53
- end
54
-
55
- def position_parts(position)
56
- POSITIONS.fetch(position) do
57
- raise ArgumentError, "unknown toast position: #{position.inspect}"
58
- end
59
- end
60
-
61
- def row_for(size, vertical)
62
- case vertical
63
- when :top
64
- 1
65
- when :middle
66
- ((size.rows + 1) / 2).clamp(1, size.rows)
67
- when :bottom
68
- [size.rows - 2, 1].max
69
- end
70
- end
71
-
72
- def col_for(size, width, horizontal)
73
- case horizontal
74
- when :left
75
- 1
76
- when :center
77
- [((size.cols - width) / 2) + 1, 1].max
78
- when :right
79
- [size.cols - width + 1, 1].max
80
- end
81
- end
82
- end
83
- end
File without changes