tui_tui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -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 +106 -56
  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/box_prober.rb +3 -4
  18. data/lib/tui_tui/canvas.rb +6 -55
  19. data/lib/tui_tui/canvas_compositor.rb +12 -10
  20. data/lib/tui_tui/canvas_renderer.rb +59 -0
  21. data/lib/tui_tui/cell.rb +4 -3
  22. data/lib/tui_tui/clock.rb +11 -0
  23. data/lib/tui_tui/command.rb +21 -0
  24. data/lib/tui_tui/event_stream.rb +42 -7
  25. data/lib/tui_tui/key_code.rb +3 -0
  26. data/lib/tui_tui/key_reader.rb +1 -1
  27. data/lib/tui_tui/line.rb +11 -0
  28. data/lib/tui_tui/modal/command_palette.rb +193 -0
  29. data/lib/tui_tui/modal/confirm.rb +74 -0
  30. data/lib/tui_tui/modal/help.rb +44 -0
  31. data/lib/tui_tui/modal/pager.rb +106 -0
  32. data/lib/tui_tui/modal/prompt.rb +66 -0
  33. data/lib/tui_tui/modal/select.rb +103 -0
  34. data/lib/tui_tui/modal.rb +4 -0
  35. data/lib/tui_tui/modal_host.rb +2 -2
  36. data/lib/tui_tui/rect.rb +69 -0
  37. data/lib/tui_tui/runtime.rb +52 -20
  38. data/lib/tui_tui/screen.rb +15 -3
  39. data/lib/tui_tui/terminal_session.rb +16 -1
  40. data/lib/tui_tui/test_runtime.rb +71 -0
  41. data/lib/tui_tui/text_input.rb +168 -0
  42. data/lib/tui_tui/text_sanitizer.rb +3 -2
  43. data/lib/tui_tui/theme.rb +25 -27
  44. data/lib/tui_tui/version.rb +1 -1
  45. data/lib/tui_tui/widget/list.rb +59 -0
  46. data/lib/tui_tui/widget/progress.rb +33 -0
  47. data/lib/tui_tui/widget/scroll_list.rb +59 -0
  48. data/lib/tui_tui/widget/scrollbar.rb +43 -0
  49. data/lib/tui_tui/widget/spinner.rb +37 -0
  50. data/lib/tui_tui/widget/status_bar.rb +25 -0
  51. data/lib/tui_tui/widget/table.rb +81 -0
  52. data/lib/tui_tui/widget/tabs.rb +67 -0
  53. data/lib/tui_tui/widget/text_view.rb +47 -0
  54. data/lib/tui_tui/widget/toast.rb +83 -0
  55. data/lib/tui_tui.rb +24 -14
  56. metadata +28 -13
  57. data/lib/tui_tui/confirm.rb +0 -74
  58. data/lib/tui_tui/help.rb +0 -44
  59. data/lib/tui_tui/list.rb +0 -59
  60. data/lib/tui_tui/pager.rb +0 -94
  61. data/lib/tui_tui/prompt.rb +0 -111
  62. data/lib/tui_tui/scroll_list.rb +0 -57
  63. data/lib/tui_tui/scrollbar.rb +0 -41
  64. data/lib/tui_tui/select.rb +0 -104
  65. data/lib/tui_tui/status_bar.rb +0 -23
  66. data/lib/tui_tui/text_view.rb +0 -56
  67. data/lib/tui_tui/toast.rb +0 -82
  68. /data/lib/tui_tui/{event.rb → events.rb} +0 -0
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "display_text"
4
- require_relative "style"
5
- require_relative "modal"
6
- require_relative "key_code"
7
-
8
- module TuiTui
9
- # Single-line text input modal with terminal-column-aware cursor placement.
10
- class Prompt < Modal
11
- MIN_INNER = 24
12
-
13
- def initialize(label, value: "", theme: Theme::DEFAULT)
14
- @label = DisplayText.new(label)
15
- @graphemes = value.grapheme_clusters
16
- @pos = @graphemes.length
17
- @theme = theme
18
- end
19
-
20
- def value = @graphemes.join
21
-
22
- def handle(key)
23
- case key
24
- when "\r"
25
- [:ok, value]
26
- when :escape, KeyCode::CTRL_C
27
- :cancel
28
- when KeyCode::BACKSPACE, :backspace
29
- edit { delete_back }
30
- when :delete
31
- edit { delete_forward }
32
- when :left
33
- edit { @pos = [@pos - 1, 0].max }
34
- when :right
35
- edit { @pos = [@pos + 1, @graphemes.length].min }
36
- when :home
37
- edit { @pos = 0 }
38
- when :end
39
- edit { @pos = @graphemes.length }
40
- when String
41
- edit { insert(key) if printable?(key) }
42
- end
43
- end
44
-
45
- def handle_mouse(event)
46
- return nil unless event.action == :press && @text_row == event.row
47
-
48
- edit { @pos = index_at(event.col - @text_col) }
49
- end
50
-
51
- def draw(canvas, size)
52
- inner = [MIN_INNER, @label.width + 1 + DisplayText.new(value).width].max
53
- rect, col = panel(canvas, inner: inner, body_rows: 1)
54
-
55
- canvas.text(rect.row + 1, col, @label, theme.title)
56
- @text_row = rect.row + 1
57
- @text_col = col + @label.width + 1
58
- canvas.text(@text_row, @text_col, value, theme.text)
59
- draw_cursor(canvas, @text_row, @text_col)
60
- canvas
61
- end
62
-
63
- private
64
-
65
- def edit
66
- yield
67
- nil
68
- end
69
-
70
- # Grapheme index whose left edge is closest to `rel` columns into the value.
71
- def index_at(rel)
72
- return 0 if rel <= 0
73
-
74
- width = 0
75
- @graphemes.each_with_index do |grapheme, i|
76
- w = DisplayText.new(grapheme).width
77
- return i if rel < width + ((w + 1) / 2)
78
-
79
- width += w
80
- end
81
-
82
- @graphemes.length
83
- end
84
-
85
- def draw_cursor(canvas, row, text_col)
86
- cursor_col = text_col + DisplayText.new(@graphemes[0...@pos].join).width
87
- canvas.text(row, cursor_col, @graphemes[@pos] || " ", theme.cursor)
88
- end
89
-
90
- def insert(string)
91
- head = @graphemes[0...@pos].join
92
- @graphemes = (head + string + @graphemes[@pos..].join).grapheme_clusters
93
- @pos = (head + string).grapheme_clusters.length
94
- end
95
-
96
- def delete_back
97
- return if @pos.zero?
98
-
99
- @graphemes.delete_at(@pos - 1)
100
- @pos -= 1
101
- end
102
-
103
- def delete_forward
104
- @graphemes.delete_at(@pos) if @pos < @graphemes.length
105
- end
106
-
107
- def printable?(string)
108
- string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
109
- end
110
- end
111
- 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,104 +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
- rect = @items_rect
95
- return nil unless rect && event.col.between?(rect.col, rect.col + rect.cols - 1)
96
-
97
- index = @list.top + (event.row - rect.row)
98
- return nil unless (event.row - rect.row).between?(0, rect.rows - 1) && !@list.empty? && index <= @list.last
99
-
100
- @list.go_to(index)
101
- @list.cursor
102
- end
103
- end
104
- 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,56 +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, as_line(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
-
45
- def as_line(content, style)
46
- case content
47
- when Line
48
- content
49
- when Array
50
- Line.new(content)
51
- else
52
- Line[Span[content.to_s, style]]
53
- end
54
- end
55
- end
56
- end
data/lib/tui_tui/toast.rb DELETED
@@ -1,82 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "display_text"
4
- require_relative "style"
5
-
6
- module TuiTui
7
- # A transient notification overlay.
8
- # The clock is injectable so expiry is testable without sleeping.
9
- class Toast
10
- DEFAULT_SECONDS = 2.0
11
- DEFAULT_STYLE = Style.new(attrs: [:reverse])
12
- DEFAULT_POSITION = :bottom_center
13
- POSITIONS = {
14
- top_left: [:top, :left],
15
- top_center: [:top, :center],
16
- top_right: [:top, :right],
17
- middle_left: [:middle, :left],
18
- middle_center: [:middle, :center],
19
- middle_right: [:middle, :right],
20
- bottom_left: [:bottom, :left],
21
- bottom_center: [:bottom, :center],
22
- bottom_right: [:bottom, :right],
23
- center: [:middle, :center]
24
- }.freeze
25
- MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
26
-
27
- def initialize(message, seconds: DEFAULT_SECONDS, position: DEFAULT_POSITION, clock: MONOTONIC)
28
- @message = DisplayText.new(message)
29
- @position = position
30
- @clock = clock
31
- @expires_at = clock.call + seconds
32
- validate_position!
33
- end
34
-
35
- def expired? = @clock.call >= @expires_at
36
-
37
- def draw(canvas, size, style: DEFAULT_STYLE, position: @position)
38
- return canvas if expired?
39
-
40
- label = DisplayText.new(" #{@message} ").truncate(size.cols)
41
- vertical, horizontal = position_parts(position)
42
- row = row_for(size, vertical)
43
- col = col_for(size, label.width, horizontal)
44
- canvas.text(row, col, label, style)
45
- canvas
46
- end
47
-
48
- private
49
-
50
- def validate_position!
51
- position_parts(@position)
52
- end
53
-
54
- def position_parts(position)
55
- POSITIONS.fetch(position) do
56
- raise ArgumentError, "unknown toast position: #{position.inspect}"
57
- end
58
- end
59
-
60
- def row_for(size, vertical)
61
- case vertical
62
- when :top
63
- 1
64
- when :middle
65
- ((size.rows + 1) / 2).clamp(1, size.rows)
66
- when :bottom
67
- [size.rows - 2, 1].max
68
- end
69
- end
70
-
71
- def col_for(size, width, horizontal)
72
- case horizontal
73
- when :left
74
- 1
75
- when :center
76
- [((size.cols - width) / 2) + 1, 1].max
77
- when :right
78
- [size.cols - width + 1, 1].max
79
- end
80
- end
81
- end
82
- end
File without changes