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,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_text"
4
+ require_relative "style"
5
+ require_relative "modal"
6
+
7
+ module TuiTui
8
+ # Key-binding cheat sheet modal.
9
+ class Help < Modal
10
+ COLGAP = 2
11
+
12
+ def initialize(title, entries, theme: Theme::DEFAULT)
13
+ @title = DisplayText.new(title)
14
+ @entries = entries.map { |keys, desc| [DisplayText.new(keys), DisplayText.new(desc)] }
15
+ @theme = theme
16
+ end
17
+
18
+ def handle(_key) = :close
19
+
20
+ # Any click dismisses the sheet, like any key does.
21
+ def handle_mouse(event) = event.action == :press ? :close : nil
22
+
23
+ def draw(canvas, size)
24
+ key_w = @entries.map { |keys, _| keys.width }.max || 0
25
+ body_w = @entries.map { |keys, desc| keys.width + COLGAP + desc.width }.max || 0
26
+ inner = [@title.width, body_w].max
27
+
28
+ rect, col = panel(canvas, inner: inner, body_rows: @entries.size + 2)
29
+
30
+ canvas.text(rect.row + 1, col, @title.truncate(inner), theme.title)
31
+ draw_entries(canvas, rect.row + 3, col, key_w)
32
+ canvas
33
+ end
34
+
35
+ private
36
+
37
+ def draw_entries(canvas, row, col, key_w)
38
+ @entries.each_with_index do |(keys, desc), index|
39
+ canvas.text(row + index, col, keys, theme.accent)
40
+ canvas.text(row + index, col + key_w + COLGAP, desc, theme.muted)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ module KeyCode
5
+ ESCAPE = "\e"
6
+ CTRL_C = "\u0003"
7
+ BACKSPACE = "\u007F"
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "key_code"
4
+
5
+ module TuiTui
6
+ # Shared navigation intent mapping for widgets.
7
+ class KeyIntent
8
+ NAVIGATION = {
9
+ :up => :up,
10
+ "k" => :up,
11
+ :down => :down,
12
+ "j" => :down,
13
+ :home => :top,
14
+ "g" => :top,
15
+ :end => :bottom,
16
+ "G" => :bottom
17
+ }.freeze
18
+
19
+ CANCEL = [:escape, "q", KeyCode::CTRL_C].freeze
20
+
21
+ def self.for(key) = new.for(key)
22
+
23
+ def for(key)
24
+ return :cancel if CANCEL.include?(key)
25
+
26
+ NAVIGATION[key]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event"
4
+ require_relative "key_code"
5
+
6
+ module TuiTui
7
+ # Decodes raw-mode terminal input into literal keys, named keys, and mouse events.
8
+ class KeyReader
9
+ ESCAPES = {
10
+ "\e[A" => :up,
11
+ "\e[B" => :down,
12
+ "\e[C" => :right,
13
+ "\e[D" => :left,
14
+ "\eOA" => :up,
15
+ "\eOB" => :down,
16
+ "\eOC" => :right,
17
+ "\eOD" => :left,
18
+ "\e[H" => :home,
19
+ "\e[F" => :end,
20
+ "\eOH" => :home,
21
+ "\eOF" => :end,
22
+ "\e[1~" => :home,
23
+ "\e[4~" => :end,
24
+ "\e[5~" => :pgup,
25
+ "\e[6~" => :pgdn,
26
+ "\e[3~" => :delete,
27
+ "\e[Z" => :backtab
28
+ }.freeze
29
+
30
+ ESCAPE_TAIL_BYTES = 256
31
+
32
+ # SGR mouse reports can grow with coordinate length; decode the first report
33
+ # if several drag reports arrive in one non-blocking read.
34
+ MOUSE = /\A\[<(\d+);(\d+);(\d+)([Mm])/.freeze
35
+ MOUSE_MOTION = 0x20
36
+ MOUSE_WHEEL = 0x40
37
+
38
+ # A modified special key arrives as a CSI with a "1;<mod>" parameter, e.g.
39
+ # Ctrl+Right = "\e[1;5C", Shift+Up = "\e[1;2A", Ctrl+Delete = "\e[3;5~".
40
+ # MOD encodes the held modifiers as (1 + Shift(1) + Alt(2) + Ctrl(4)).
41
+ MODIFIED = /\A\[(\d*);(\d+)([A-Za-z~])/.freeze
42
+ MOD_LETTER = {"A" => :up, "B" => :down, "C" => :right, "D" => :left, "H" => :home, "F" => :end}.freeze
43
+ MOD_TILDE = {1 => :home, 3 => :delete, 4 => :end, 5 => :pgup, 6 => :pgdn, 7 => :home, 8 => :end}.freeze
44
+ MOD_BITS = {shift: 1, alt: 2, ctrl: 4}.freeze
45
+
46
+ # One keypress/event from `io` (String, Symbol, or MouseEvent), nil at EOF.
47
+ def read(io) = read_all(io)&.first
48
+
49
+ def read_all(io)
50
+ first = io.getch
51
+ return nil if first.nil?
52
+ return decode_escape_events(read_escape_tail(io)) if first == KeyCode::ESCAPE
53
+ return [assemble_utf8(io, first)] if first.bytesize == 1 && first.getbyte(0) >= 0x80
54
+
55
+ [first]
56
+ end
57
+
58
+ def decode_escape(rest)
59
+ return :escape if rest.nil? || rest.empty?
60
+
61
+ mouse = decode_mouse(rest)
62
+ return mouse if mouse
63
+
64
+ modified = decode_modified(rest)
65
+ return modified if modified
66
+
67
+ ESCAPES["\e" + rest] || :escape
68
+ end
69
+
70
+ # Decode a whole ESC tail into events, batching consecutive mouse reports
71
+ # (and otherwise yielding a single key/escape).
72
+ def decode_escape_events(rest)
73
+ return [:escape] if rest.nil? || rest.empty?
74
+
75
+ mice = []
76
+ remainder = rest
77
+ loop do
78
+ remainder = remainder.sub(/\A\e/, "")
79
+ match = MOUSE.match(remainder) or break
80
+
81
+ mice << mouse_event_from(match)
82
+ remainder = match.post_match
83
+ end
84
+
85
+ mice.empty? ? [decode_escape(rest)] : mice
86
+ end
87
+
88
+ private
89
+
90
+ def decode_modified(rest)
91
+ match = MODIFIED.match(rest)
92
+ return nil unless match
93
+
94
+ final = match[3]
95
+ base = final == "~" ? MOD_TILDE[match[1].to_i] : MOD_LETTER[final]
96
+ return nil unless base
97
+
98
+ prefix = modifier_prefix(match[2].to_i)
99
+ return nil unless prefix
100
+
101
+ prefix.empty? ? base : :"#{prefix}_#{base}"
102
+ end
103
+
104
+ # "ctrl", "shift", "ctrl_shift", ... for an xterm modifier parameter, "" for
105
+ # no modifiers, or nil when the value is out of range.
106
+ def modifier_prefix(mod)
107
+ bits = mod - 1
108
+ return nil if bits.negative? || bits > 7
109
+
110
+ %i[ctrl alt shift].select { |name| bits.anybits?(MOD_BITS[name]) }.join("_")
111
+ end
112
+
113
+ def read_escape_tail(io)
114
+ rest = io.read_nonblock(ESCAPE_TAIL_BYTES, exception: false)
115
+ rest.is_a?(String) ? rest : nil
116
+ end
117
+
118
+ def assemble_utf8(io, lead)
119
+ # Raw mode may deliver multibyte input one byte at a time.
120
+ bytes = +lead.b
121
+ utf8_continuation_count(lead.getbyte(0)).times do
122
+ nxt = io.read_nonblock(1, exception: false)
123
+ break unless nxt.is_a?(String) && !nxt.empty?
124
+
125
+ bytes << nxt
126
+ end
127
+
128
+ char = bytes.force_encoding("UTF-8")
129
+ char.valid_encoding? ? char : :unknown
130
+ end
131
+
132
+ def utf8_continuation_count(byte)
133
+ return 1 if byte.between?(0xC0, 0xDF)
134
+ return 2 if byte.between?(0xE0, 0xEF)
135
+ return 3 if byte.between?(0xF0, 0xF7)
136
+
137
+ 0
138
+ end
139
+
140
+ def decode_mouse(rest)
141
+ match = MOUSE.match(rest)
142
+ match && mouse_event_from(match)
143
+ end
144
+
145
+ def mouse_event_from(match)
146
+ mouse_event(match[1].to_i, match[2].to_i, match[3].to_i, match[4] == "m")
147
+ end
148
+
149
+ def mouse_event(flags, col, row, released)
150
+ if flags & MOUSE_WHEEL != 0
151
+ button = (flags & 0b1).zero? ? :wheel_up : :wheel_down
152
+ MouseEvent.new(action: :wheel, button: button, col: col, row: row)
153
+ elsif flags & MOUSE_MOTION != 0
154
+ MouseEvent.new(action: :drag, button: button_name(flags), col: col, row: row)
155
+ elsif released
156
+ MouseEvent.new(action: :release, button: button_name(flags), col: col, row: row)
157
+ else
158
+ MouseEvent.new(action: :press, button: button_name(flags), col: col, row: row)
159
+ end
160
+ end
161
+
162
+ def button_name(flags)
163
+ case flags & 0b11
164
+ when 0
165
+ :left
166
+ when 1
167
+ :middle
168
+ when 2
169
+ :right
170
+ else
171
+ :none
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "span"
4
+ require_relative "display_text"
5
+
6
+ module TuiTui
7
+ # An ordered list of styled Spans.
8
+ # Width-aware truncation preserves each span's style.
9
+ class Line
10
+ # Convenience constructor: Line[Span["a", s1], Span["b", s2]].
11
+ def self.[](*spans) = new(spans)
12
+
13
+ def initialize(spans = [])
14
+ @spans = spans
15
+ end
16
+
17
+ attr_reader :spans
18
+
19
+ def width = @spans.sum(&:width)
20
+ def each(&block) = @spans.each(&block)
21
+ def to_s = @spans.map(&:text).join
22
+
23
+ # Truncate to `max` columns, keeping span styles. When content is dropped,
24
+ # `marker` is appended in the style of the span it cut into; the marker's own
25
+ # width is reserved so the result never exceeds `max`.
26
+ def truncate(max, marker: "...")
27
+ return self if width <= max
28
+
29
+ budget = max - DisplayText.new(marker).width
30
+ if budget <= 0
31
+ clipped = DisplayText.new(marker).truncate(max, marker: "").to_s
32
+ return self.class.new([Span[clipped, @spans.first&.style]])
33
+ end
34
+
35
+ take_until(budget, marker)
36
+ end
37
+
38
+ private
39
+
40
+ def take_until(budget, marker)
41
+ kept = []
42
+ used = 0
43
+ @spans.each do |span|
44
+ if used + span.width <= budget
45
+ kept << span
46
+ used += span.width
47
+ next
48
+ end
49
+
50
+ room = budget - used
51
+ kept << Span[DisplayText.new(span.text).truncate(room, marker: "").to_s, span.style] if room.positive?
52
+ kept << Span[marker, (kept.last || span).style]
53
+ break
54
+ end
55
+
56
+ self.class.new(kept)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rect"
4
+ require_relative "line"
5
+ require_relative "scrollbar"
6
+
7
+ module TuiTui
8
+ # Drawing companion for ScrollList.
9
+ # Row content comes from the caller, keeping the list domain-agnostic.
10
+ class List
11
+ def initialize(scroll)
12
+ @scroll = scroll
13
+ end
14
+
15
+ def draw(canvas, rect, highlight: nil, scrollbar: nil)
16
+ body, gutter = scrollbar ? rect.split_gutter : [rect, nil]
17
+ @scroll.ensure_visible(body.rows)
18
+ @scroll.each_visible(body.rows) do |index, offset|
19
+ row = body.row + offset
20
+ selected = index == @scroll.cursor
21
+ canvas.fill(Rect.new(row: row, col: body.col, rows: 1, cols: body.cols), highlight) if highlight && selected
22
+ canvas.line(row, body.col, as_line(yield(index, selected)).truncate(body.cols))
23
+ end
24
+
25
+ draw_scrollbar(canvas, gutter, scrollbar) if gutter
26
+ canvas
27
+ end
28
+
29
+ private
30
+
31
+ def draw_scrollbar(canvas, gutter, theme)
32
+ Scrollbar.draw(
33
+ canvas,
34
+ gutter,
35
+ top: @scroll.top,
36
+ visible: gutter.rows,
37
+ total: @scroll.count,
38
+ track_style: theme.scroll_track,
39
+ thumb_style: theme.scroll_thumb
40
+ )
41
+ end
42
+
43
+ def as_line(content) = content.is_a?(Line) ? content : Line.new(Array(content))
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rect"
4
+ require_relative "theme"
5
+
6
+ module TuiTui
7
+ # Base protocol and shared centered-panel framing for overlay widgets.
8
+ class Modal
9
+ PAD = 2
10
+
11
+ def handle(_key) = raise NotImplementedError, "#{self.class}#handle"
12
+
13
+ # Optional mouse handling, same return contract as #handle (resolved value,
14
+ # or nil to stay open). Default no-op so widgets opt in only as needed; the
15
+ # host routes MouseEvents here and KeyEvents to #handle.
16
+ def handle_mouse(_event) = nil
17
+
18
+ def draw(_canvas, _size) = raise NotImplementedError, "#{self.class}#draw"
19
+
20
+ private
21
+
22
+ def theme = @theme || Theme::DEFAULT
23
+
24
+ def panel(canvas, inner:, body_rows:)
25
+ rect = Rect.centered(canvas, cols: inner + (PAD * 2) + 2, rows: body_rows + 2)
26
+ canvas.frame(rect, style: theme.frame)
27
+ [rect, rect.col + PAD + 1]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "display_text"
4
+ require_relative "style"
5
+ require_relative "rect"
6
+ require_relative "modal"
7
+ require_relative "key_intent"
8
+
9
+ module TuiTui
10
+ # Scrollable read-only text modal.
11
+ class Pager < Modal
12
+ MARGIN = 2
13
+ WHEEL = 3
14
+
15
+ def initialize(title, lines, start: 0, close_keys: [], theme: Theme::DEFAULT)
16
+ @title = title
17
+ @lines = lines.map { |line| DisplayText.new(line) }
18
+ @top = start
19
+ @page = 1
20
+ @close_keys = close_keys
21
+ @theme = theme
22
+ end
23
+
24
+ def handle(key)
25
+ return :close if @close_keys.include?(key)
26
+
27
+ case KeyIntent.for(key)
28
+ when :up
29
+ scroll(-1)
30
+ when :down
31
+ scroll(1)
32
+ when :top
33
+ scroll(-@lines.size)
34
+ when :bottom
35
+ scroll(@lines.size)
36
+ when :cancel
37
+ :close
38
+ else
39
+ paginate(key)
40
+ end
41
+ end
42
+
43
+ def handle_mouse(event)
44
+ scroll(event.button == :wheel_up ? -WHEEL : WHEEL) if event.action == :wheel
45
+ end
46
+
47
+ def draw(canvas, size)
48
+ width = [size.cols - (MARGIN * 2), 20].max
49
+ height = [size.rows - (MARGIN * 2), 5].max
50
+ rect = Rect.centered(size, cols: width, rows: height)
51
+ canvas.frame(rect, style: theme.frame)
52
+
53
+ inner = width - 4
54
+ body = [height - 4, 1].max
55
+ @page = body
56
+ clamp(body)
57
+
58
+ canvas.text(rect.row + 1, rect.col + 2, DisplayText.new(title_line(body)).truncate(inner), theme.title)
59
+ body.times do |offset|
60
+ line = @lines[@top + offset]
61
+ next if line.nil?
62
+
63
+ canvas.text(rect.row + 3 + offset, rect.col + 2, line.truncate(inner), theme.muted)
64
+ end
65
+
66
+ canvas
67
+ end
68
+
69
+ private
70
+
71
+ def paginate(key)
72
+ case key
73
+ when " ", :pgdn
74
+ scroll(@page)
75
+ when "b", :pgup
76
+ scroll(-@page)
77
+ end
78
+ end
79
+
80
+ def scroll(delta)
81
+ @top += delta
82
+ nil
83
+ end
84
+
85
+ def clamp(body)
86
+ @top = @top.clamp(0, [@lines.size - body, 0].max)
87
+ end
88
+
89
+ def title_line(body)
90
+ last = [@top + body, @lines.size].min
91
+ "#{@title} (#{@top + 1}-#{last}/#{@lines.size})"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Color math for downgrading 256-color and RGB values to ANSI-16.
5
+ class Palette
6
+ ANSI16 = [
7
+ [[0, 0, 0], 30],
8
+ [[205, 0, 0], 31],
9
+ [[0, 205, 0], 32],
10
+ [[205, 205, 0], 33],
11
+ [[0, 0, 238], 34],
12
+ [[205, 0, 205], 35],
13
+ [[0, 205, 205], 36],
14
+ [[229, 229, 229], 37],
15
+ [[127, 127, 127], 90],
16
+ [[255, 0, 0], 91],
17
+ [[0, 255, 0], 92],
18
+ [[255, 255, 0], 93],
19
+ [[92, 92, 255], 94],
20
+ [[255, 0, 255], 95],
21
+ [[0, 255, 255], 96],
22
+ [[255, 255, 255], 97]
23
+ ].freeze
24
+
25
+ def nearest_code(rgb)
26
+ ANSI16.min_by { |color, _code| distance(color, rgb) }.last
27
+ end
28
+
29
+ def rgb_from_256(index)
30
+ return ANSI16[index].first if index < 16
31
+
32
+ if index <= 231
33
+ i = index - 16
34
+ [cube(i / 36), cube((i % 36) / 6), cube(i % 6)]
35
+ else
36
+ v = 8 + (10 * (index - 232))
37
+ [v, v, v]
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def cube(step) = step.zero? ? 0 : (55 + (40 * step))
44
+
45
+ def distance(a, b)
46
+ (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,111 @@
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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # A 1-origin screen rectangle with pure layout helpers.
5
+ Rect = Data.define(:row, :col, :rows, :cols) do
6
+ def self.centered(within, cols:, rows:)
7
+ new(
8
+ row: [((within.rows - rows) / 2) + 1, 1].max,
9
+ col: [((within.cols - cols) / 2) + 1, 1].max,
10
+ rows: rows,
11
+ cols: cols
12
+ )
13
+ end
14
+
15
+ def split_h(top_rows)
16
+ top = Rect.new(row: row, col: col, rows: top_rows, cols: cols)
17
+ bottom = Rect.new(row: row + top_rows, col: col, rows: rows - top_rows, cols: cols)
18
+ [top, bottom]
19
+ end
20
+
21
+ def split_v(left_cols)
22
+ left = Rect.new(row: row, col: col, rows: rows, cols: left_cols)
23
+ right = Rect.new(row: row, col: col + left_cols, rows: rows, cols: cols - left_cols)
24
+ [left, right]
25
+ end
26
+
27
+ # Split into [left, right] by `ratio` of the width
28
+ def split_ratio(ratio, min: 0, gutter: 0)
29
+ lo = min
30
+ hi = cols - min - gutter
31
+ left_cols = hi < lo ? cols / 2 : (cols * ratio).round.clamp(lo, hi)
32
+ left, right = split_v(left_cols)
33
+ [left, right.shift_right(gutter)]
34
+ end
35
+
36
+ def shift_right(by)
37
+ Rect.new(row: row, col: col + by, rows: rows, cols: cols - by)
38
+ end
39
+
40
+ # Carve `width` columns off the right edge for a scrollbar gutter. Returns
41
+ # [body, gutter]; gutter is nil when the rect is too narrow to spare them.
42
+ def split_gutter(width = 1)
43
+ return [self, nil] if cols <= width
44
+
45
+ [with(cols: cols - width), Rect.new(row: row, col: col + cols - width, rows: rows, cols: width)]
46
+ end
47
+ end
48
+ end