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,101 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # A gallery of the built-in TuiTui widgets (Confirm / Select / Prompt / Help)
5
+ # driven as modal overlays. The host keeps one modal while open, routes keys to
6
+ # its `handle`, draws it over its own frame, and interprets the resolved value —
7
+ # the same pattern any TuiTui app uses. The main screen shows the last result.
8
+ # Press `t` to cycle the theme; the gallery text and the next modal follow it.
9
+ #
10
+ # ruby examples/widgets.rb
11
+ #
12
+ # Keys: c confirm, s select, p prompt, ? help, t theme, q (or Ctrl-C) quit.
13
+
14
+ require_relative "../lib/tui_tui"
15
+
16
+ module WidgetsSample
17
+ THEMES = %i[cool warm mono].freeze
18
+
19
+ ITEMS = ["Red", "Green", "Blue", "日本語の項目", "Yellow"].freeze
20
+ HELP = [
21
+ ["c", "open a confirm dialog"],
22
+ ["s", "open a select list"],
23
+ ["p", "open a text prompt"],
24
+ ["t", "cycle theme (cool / warm / mono, follows light/dark)"],
25
+ ["?", "this help"],
26
+ ["q", "quit"],
27
+ ].freeze
28
+
29
+ class Gallery
30
+ def initialize
31
+ @last = "(nothing yet)"
32
+ @modal = nil
33
+ @on_result = nil
34
+ @theme_i = 0
35
+ @theme = TuiTui::Theme.auto(hue: THEMES[@theme_i])
36
+ end
37
+
38
+ def update(event)
39
+ case event
40
+ when TuiTui::MouseEvent then @modal ? route_modal_mouse(event) : self
41
+ when TuiTui::KeyEvent
42
+ return route_modal(event.key) if @modal
43
+
44
+ handle_key(event.key)
45
+ else self
46
+ end
47
+ end
48
+
49
+ def view(size)
50
+ canvas = TuiTui::Canvas.blank(size)
51
+ canvas.text(2, 3, "TuiTui widget gallery", @theme.title)
52
+ canvas.text(4, 3, "theme: #{THEMES[@theme_i]}", @theme.accent)
53
+ canvas.text(5, 3, "last result: #{@last}", @theme.text)
54
+ canvas.text(7, 3, "c confirm s select p prompt ? help t theme q quit", @theme.muted)
55
+ @modal&.draw(canvas, size) # modal overlays the main screen
56
+ canvas
57
+ end
58
+
59
+ private
60
+
61
+ def handle_key(key)
62
+ case key
63
+ when "q", TuiTui::KeyCode::CTRL_C then return :quit
64
+ when "c" then open(TuiTui::Confirm.new("Proceed?", theme: @theme)) { |r| @last = "confirm -> #{r}" }
65
+ when "s" then open(TuiTui::Select.new("Pick a color", ITEMS, theme: @theme)) { |r| @last = "select -> #{label(r)}" }
66
+ when "p" then open(TuiTui::Prompt.new("Name:", theme: @theme)) { |r| @last = "prompt -> #{prompt_value(r)}" }
67
+ when "?" then open(TuiTui::Help.new("Keys", HELP, theme: @theme)) { nil }
68
+ when "t" then cycle_theme
69
+ end
70
+ self
71
+ end
72
+
73
+ def cycle_theme
74
+ @theme_i = (@theme_i + 1) % THEMES.size
75
+ @theme = TuiTui::Theme.auto(hue: THEMES[@theme_i])
76
+ end
77
+
78
+ def open(widget, &on_result)
79
+ @modal = widget
80
+ @on_result = on_result
81
+ end
82
+
83
+ def route_modal(key) = resolve_modal(@modal.handle(key))
84
+ def route_modal_mouse(event) = resolve_modal(@modal.handle_mouse(event))
85
+
86
+ def resolve_modal(result)
87
+ return self if result.nil? # still open
88
+
89
+ @modal = nil
90
+ @on_result.call(result)
91
+ self
92
+ end
93
+
94
+ def label(result) = result.is_a?(Integer) ? ITEMS[result] : result
95
+ def prompt_value(result) = result.is_a?(Array) ? result[1].inspect : result
96
+ end
97
+ end
98
+
99
+ if $PROGRAM_NAME == __FILE__
100
+ TuiTui::Runtime.new(WidgetsSample::Gallery.new).run
101
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Raw VT/ANSI escape sequences as plain strings.
5
+ module Ansi
6
+ # enter alternate screen buffer
7
+ ALT_ON = "\e[?1049h"
8
+ # leave it, restoring the user's scrollback
9
+ ALT_OFF = "\e[?1049l"
10
+ # hide the cursor
11
+ HIDE = "\e[?25l"
12
+ # show it again
13
+ SHOW = "\e[?25h"
14
+ # clear the whole screen
15
+ CLEAR = "\e[2J"
16
+ # clear the current line
17
+ CLEAR_LINE = "\e[2K"
18
+ # move to row 1, col 1
19
+ HOME = "\e[H"
20
+ # reset all SGR attributes
21
+ RESET = "\e[0m"
22
+
23
+ # Mouse reporting:
24
+ # 1002 = button-event tracking
25
+ # 1006 = SGR extended coordinates
26
+ MOUSE_ON = "\e[?1002h\e[?1006h"
27
+ MOUSE_OFF = "\e[?1006l\e[?1002l"
28
+
29
+ def self.move(row, col) = "\e[#{row};#{col}H"
30
+
31
+ # OSC 52: set the terminal clipboard to `text` (base64-encoded).
32
+ def self.clipboard(text) = "\e]52;c;#{[text].pack("m0")}\a"
33
+ end
34
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "width"
4
+ require_relative "text_sanitizer"
5
+ require_relative "display_text"
6
+ require_relative "style"
7
+ require_relative "cell"
8
+
9
+ module TuiTui
10
+ # Pure drawing surface. Coordinates are 1-origin to match terminal cursor
11
+ # addressing, and text layout is terminal-column aware.
12
+ class Canvas
13
+ # Control bytes are rendered visibly instead of being emitted to the terminal.
14
+ CONTROL_GLYPH = "?"
15
+ FRAME = Style.new(fg: :bright_black)
16
+
17
+ def self.blank(size)
18
+ new(size.rows, size.cols)
19
+ end
20
+
21
+ attr_reader :rows, :cols
22
+ attr_reader :cursor
23
+
24
+ def initialize(rows, cols)
25
+ @rows = rows
26
+ @cols = cols
27
+ @grid = Array.new(rows) { Array.new(cols, Cell::BLANK) }
28
+ @cursor = nil
29
+ end
30
+
31
+ def cursor_at(row, col)
32
+ @cursor = [row, col] if row.between?(1, @rows) && col.between?(1, @cols)
33
+ self
34
+ end
35
+
36
+ def cell(row, col)
37
+ return nil unless row.between?(1, @rows) && col.between?(1, @cols)
38
+
39
+ @grid[row - 1][col - 1]
40
+ end
41
+
42
+ def text(row, col, string, style = nil)
43
+ return self unless row.between?(1, @rows)
44
+
45
+ column = col
46
+ TextSanitizer.sanitize(string.to_s).each_grapheme_cluster do |grapheme|
47
+ if Width.control?(grapheme.ord)
48
+ break if column > @cols
49
+
50
+ place(row, column, Cell.new(char: CONTROL_GLYPH, style: style))
51
+ column += 1
52
+ next
53
+ end
54
+
55
+ width = Width.cluster(grapheme)
56
+ # Leading combining marks have no base cell to attach to.
57
+ next if width.zero?
58
+
59
+ break if column > @cols
60
+ # Do not split a wide glyph across the right edge.
61
+ break if width == 2 && column == @cols
62
+
63
+ place(row, column, Cell.new(char: grapheme, style: style))
64
+ place(row, column + 1, Cell.new(char: nil, style: style)) if width == 2
65
+ column += width
66
+ end
67
+
68
+ self
69
+ end
70
+
71
+ def line(row, col, spans)
72
+ column = col
73
+ spans.each do |span|
74
+ text(row, column, span.text, span.style)
75
+ column += DisplayText.new(span.text).width
76
+ end
77
+
78
+ self
79
+ end
80
+
81
+ def fill(rect, style, char = " ")
82
+ cell = Cell.new(char: fill_char(char), style: style)
83
+ rect.rows.times do |dr|
84
+ row = rect.row + dr
85
+ rect.cols.times { |dc| place(row, rect.col + dc, cell) }
86
+ end
87
+
88
+ self
89
+ end
90
+
91
+ def hline(row, col, len, char = "-", style = nil)
92
+ text(row, col, char * len, style)
93
+ end
94
+
95
+ def frame(rect, style: FRAME)
96
+ fill(rect, nil)
97
+ bar = "+#{"-" * (rect.cols - 2)}+"
98
+ text(rect.row, rect.col, bar, style)
99
+ text(rect.row + rect.rows - 1, rect.col, bar, style)
100
+ (1...(rect.rows - 1)).each do |dy|
101
+ text(rect.row + dy, rect.col, "|", style)
102
+ text(rect.row + dy, rect.col + rect.cols - 1, "|", style)
103
+ end
104
+
105
+ self
106
+ end
107
+
108
+ def same_row?(other, r)
109
+ grid_row(r) == other.grid_row(r)
110
+ end
111
+
112
+ def same_size?(other)
113
+ @rows == other.rows && @cols == other.cols
114
+ end
115
+
116
+ # The changed column span of row `r` versus `other`, as [from, to] (1-origin,
117
+ # inclusive), or nil if the row is identical. The start is backed up off any
118
+ # wide-char continuation cell so a partial repaint never begins mid-glyph.
119
+ # Used by the compositor to repaint only the part of a row that moved.
120
+ def changed_span(other, r)
121
+ mine = grid_row(r)
122
+ theirs = other.grid_row(r)
123
+ first = last = nil
124
+ mine.each_index do |i|
125
+ next if mine[i] == theirs[i]
126
+
127
+ first ||= i
128
+ last = i
129
+ end
130
+
131
+ return nil if first.nil?
132
+
133
+ first -= 1 while first.positive? && mine[first].continuation?
134
+ [first + 1, last + 1]
135
+ end
136
+
137
+ # Render row `r`, or just the column span [from, to], coalescing same-styled
138
+ # runs and skipping wide-char continuation cells.
139
+ def render_row(r, from: 1, to: @cols, depth: :ansi256, enabled: true)
140
+ out = +""
141
+ run = +""
142
+ run_style = :none
143
+ grid_row(r)[(from - 1)..(to - 1)].each do |c|
144
+ next if c.continuation?
145
+
146
+ if run_style != :none && run_style != c.style
147
+ out << paint(run, run_style, depth, enabled)
148
+ run = +""
149
+ end
150
+
151
+ run_style = c.style
152
+ run << c.char
153
+ end
154
+
155
+ out << paint(run, run_style, depth, enabled) unless run.empty?
156
+ out
157
+ end
158
+
159
+ def grid_row(r) = @grid[r - 1]
160
+
161
+ private
162
+
163
+ # A fill glyph with any control bytes replaced by CONTROL_GLYPH, so `fill`
164
+ # upholds the same "Canvas never emits a raw control byte" guarantee as
165
+ # `text` (cheap: computed once per fill, not per cell).
166
+ def fill_char(char)
167
+ char.to_s.each_char.map { |c| Width.control?(c.ord) ? CONTROL_GLYPH : c }.join
168
+ end
169
+
170
+ def place(row, col, cell)
171
+ return unless row.between?(1, @rows) && col.between?(1, @cols)
172
+
173
+ grid_row = @grid[row - 1]
174
+ # Overwriting one half of a wide glyph orphans the other; blank it so the
175
+ # row keeps its column count (no stale continuation, no half-glyph).
176
+ grid_row[col] = Cell::BLANK if col < @cols && grid_row[col].continuation?
177
+ grid_row[col - 2] = Cell::BLANK if col >= 2 && grid_row[col - 1].continuation?
178
+ grid_row[col - 1] = cell
179
+ end
180
+
181
+ def paint(text, style, depth, enabled)
182
+ return text if style.nil? || style == :none
183
+
184
+ style.paint(text, depth: depth, enabled: enabled)
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ansi"
4
+
5
+ module TuiTui
6
+ # Builds the terminal update string.
7
+ # Same-size frames repaint only changed column spans of changed rows.
8
+ class CanvasCompositor
9
+ def initialize(depth: :ansi256)
10
+ @depth = depth
11
+ end
12
+
13
+ def render(previous, canvas)
14
+ out = +""
15
+ if full_repaint?(previous, canvas)
16
+ out << Ansi::CLEAR
17
+ (1..canvas.rows).each { |row| out << row_paint(canvas, row) }
18
+ else
19
+ (1..canvas.rows).each { |row| out << row_diff(canvas, previous, row) }
20
+ end
21
+
22
+ out
23
+ end
24
+
25
+ private
26
+
27
+ def full_repaint?(previous, canvas)
28
+ previous.nil? || !previous.same_size?(canvas)
29
+ end
30
+
31
+ # The whole row, positioned at column 1 (used for a full repaint).
32
+ def row_paint(canvas, row)
33
+ Ansi.move(row, 1) + canvas.render_row(row, depth: @depth, enabled: true)
34
+ end
35
+
36
+ # Only the changed span of `row`, positioned at its first changed column; ""
37
+ # when the row is unchanged.
38
+ def row_diff(canvas, previous, row)
39
+ span = canvas.changed_span(previous, row)
40
+ return "" unless span
41
+
42
+ Ansi.move(row, span.first) + canvas.render_row(row, from: span.first, to: span.last, depth: @depth, enabled: true)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # One terminal cell; a nil char marks the continuation cell of a wide glyph.
5
+ Cell = Data.define(:char, :style) do
6
+ def self.blank = BLANK
7
+ def continuation? = char.nil?
8
+ end
9
+
10
+ Cell::BLANK = Cell.new(char: " ", style: nil)
11
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Resolves the color depth the renderer may safely emit.
5
+ module ColorDepth
6
+ TRUECOLOR = %w[truecolor 24bit].freeze
7
+ MODES = %i[none basic16 ansi256 truecolor].freeze
8
+
9
+ def self.detect(env = ENV)
10
+ return :none if disabled?(env)
11
+ return :truecolor if TRUECOLOR.include?(env["COLORTERM"])
12
+
13
+ :ansi256
14
+ end
15
+
16
+ def self.from(name, env = ENV)
17
+ # Unknown overrides fall back to auto-detection instead of failing startup.
18
+ case name.to_s.downcase
19
+ when "none", "no", "off", "0"
20
+ :none
21
+ when "16", "basic", "basic16", "ansi16"
22
+ :basic16
23
+ when "256", "ansi256"
24
+ :ansi256
25
+ when "truecolor", "24bit", "full"
26
+ :truecolor
27
+ else
28
+ detect(env)
29
+ end
30
+ end
31
+
32
+ def self.disabled?(env)
33
+ return true if env.key?("NO_COLOR")
34
+
35
+ term = env["TERM"]
36
+ term.nil? || term.empty? || term == "dumb"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,74 @@
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
+ # OK / Cancel confirmation modal.
10
+ class Confirm < Modal
11
+ GAP = 2
12
+
13
+ attr_reader :focus
14
+
15
+ def initialize(message, ok: "OK", cancel: "Cancel", default: :cancel, theme: Theme::DEFAULT)
16
+ @message = DisplayText.new(message)
17
+ @ok = button_text(ok)
18
+ @cancel = button_text(cancel)
19
+ @focus = default
20
+ @theme = theme
21
+ end
22
+
23
+ def handle(key)
24
+ case key
25
+ when :left, :right, "\t", "h", "l"
26
+ toggle
27
+ when "\r", " "
28
+ @focus
29
+ when "y", "Y"
30
+ :ok
31
+ when "n", "N", :escape, KeyCode::CTRL_C
32
+ :cancel
33
+ end
34
+ end
35
+
36
+ def handle_mouse(event)
37
+ return nil unless event.action == :press && @buttons_row == event.row
38
+
39
+ return :ok if hit?(event.col, @ok_at, @ok.width)
40
+ return :cancel if hit?(event.col, @cancel_at, @cancel.width)
41
+
42
+ nil
43
+ end
44
+
45
+ def draw(canvas, size)
46
+ inner = [@message.width, buttons_width].max
47
+ rect, col = panel(canvas, inner: inner, body_rows: 3)
48
+ canvas.text(rect.row + 1, col, @message.center(inner), theme.text)
49
+ draw_buttons(canvas, rect.row + 3, col, inner)
50
+ canvas
51
+ end
52
+
53
+ private
54
+
55
+ def hit?(col, start, width) = col.between?(start, start + width - 1)
56
+
57
+ def toggle
58
+ @focus = @focus == :ok ? :cancel : :ok
59
+ nil
60
+ end
61
+
62
+ def draw_buttons(canvas, row, col, inner)
63
+ start = col + [(inner - buttons_width) / 2, 0].max
64
+ @buttons_row = row
65
+ @ok_at = start
66
+ @cancel_at = start + @ok.width + GAP
67
+ canvas.text(row, @ok_at, @ok, @focus == :ok ? theme.selection : theme.text)
68
+ canvas.text(row, @cancel_at, @cancel, @focus == :cancel ? theme.selection : theme.text)
69
+ end
70
+
71
+ def buttons_width = @ok.width + GAP + @cancel.width
72
+ def button_text(label) = DisplayText.new("[ #{label} ]")
73
+ end
74
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "width"
4
+ require_relative "text_sanitizer"
5
+
6
+ module TuiTui
7
+ # String wrapper for width-aware truncation, centering, and wrapping.
8
+ class DisplayText
9
+ def initialize(string)
10
+ @string = string.is_a?(DisplayText) ? string.to_s : TextSanitizer.sanitize(string.to_s)
11
+ end
12
+
13
+ def to_s = @string
14
+
15
+ def width
16
+ @string.each_grapheme_cluster.sum { |grapheme| Width.cluster(grapheme) }
17
+ end
18
+
19
+ def truncate(max, marker: "...")
20
+ return self.class.new("") if max <= 0
21
+ return self if width <= max
22
+
23
+ marker = self.class.new(marker)
24
+ budget = [max - marker.width, 0].max
25
+ kept = +""
26
+ used = 0
27
+ @string.each_grapheme_cluster do |grapheme|
28
+ grapheme_width = Width.cluster(grapheme)
29
+ break if used + grapheme_width > budget
30
+
31
+ kept << grapheme
32
+ used += grapheme_width
33
+ end
34
+
35
+ self.class.new(kept + marker.to_s)
36
+ end
37
+
38
+ def center(columns)
39
+ gap = columns - width
40
+ return self if gap <= 0
41
+
42
+ left = gap / 2
43
+ self.class.new((" " * left) + @string + (" " * (gap - left)))
44
+ end
45
+
46
+ def wrap(max, indent: "")
47
+ return [self] if max <= 0 || width <= max
48
+
49
+ indent = self.class.new(indent)
50
+ chunks = []
51
+ current = +""
52
+ current_width = 0
53
+ budget = max
54
+ @string.each_grapheme_cluster do |grapheme|
55
+ grapheme_width = Width.cluster(grapheme)
56
+ if current_width + grapheme_width > budget && !current.empty?
57
+ chunks << current
58
+ current = +""
59
+ current_width = 0
60
+ budget = [max - indent.width, 1].max
61
+ end
62
+
63
+ current << grapheme
64
+ current_width += grapheme_width
65
+ end
66
+
67
+ chunks << current unless current.empty?
68
+ return [self] if chunks.empty?
69
+
70
+ [self.class.new(chunks.first)] + chunks[1..].map { |chunk| self.class.new(indent.to_s + chunk) }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Runtime event values.
5
+ KeyEvent = Data.define(:key)
6
+ ResizeEvent = Data.define(:size)
7
+ TickEvent = Data.define
8
+ MouseEvent = Data.define(:action, :button, :col, :row)
9
+ EofEvent = Data.define
10
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event"
4
+ require_relative "key_reader"
5
+
6
+ module TuiTui
7
+ # Converts terminal input readiness and resize notifications into runtime events.
8
+ class EventStream
9
+ def initialize(input:, size:)
10
+ @input = input
11
+ @size = size
12
+ @key_reader = KeyReader.new
13
+ @resized = false
14
+ @queue = []
15
+ end
16
+
17
+ def resized!
18
+ @resized = true
19
+ end
20
+
21
+ def next_event(tick: 0.1)
22
+ return @queue.shift unless @queue.empty?
23
+
24
+ if @resized
25
+ @resized = false
26
+ return ResizeEvent.new(size: @size.size)
27
+ end
28
+
29
+ ready = IO.select([@input], nil, nil, tick)
30
+ return TickEvent.new unless ready
31
+
32
+ raw = @key_reader.read_all(@input)
33
+ return EofEvent.new if raw.nil?
34
+
35
+ @queue.concat(raw.map { |event| event.is_a?(MouseEvent) ? event : KeyEvent.new(key: event) })
36
+ @queue.shift
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Immutable focus state for a fixed set of targets.
5
+ class FocusRing
6
+ attr_reader :current
7
+
8
+ def initialize(*targets, current: nil)
9
+ @targets = targets.flatten.freeze
10
+ raise ArgumentError, "FocusRing needs at least one target" if @targets.empty?
11
+
12
+ @current = current || @targets.first
13
+ end
14
+
15
+ def focused?(target) = @current == target
16
+
17
+ def next
18
+ focus(@targets[(@targets.index(@current) + 1) % @targets.size])
19
+ end
20
+
21
+ def focus(target)
22
+ @targets.include?(target) ? self.class.new(@targets, current: target) : self
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TuiTui
4
+ # Query-prepared subsequence matcher used to rank many candidates consistently.
5
+ class Fuzzy
6
+ Match = Data.define(:score, :positions)
7
+
8
+ BOUNDARY = "/\\_-. ".freeze
9
+
10
+ def initialize(query)
11
+ @query = query.to_s.downcase.grapheme_clusters
12
+ end
13
+
14
+ def match(string)
15
+ return Match.new(score: 0, positions: []) if @query.empty?
16
+
17
+ haystack = string.downcase.grapheme_clusters
18
+ positions = []
19
+ from = 0
20
+ @query.each do |grapheme|
21
+ at = (from...haystack.size).find { |i| haystack[i] == grapheme } or return nil
22
+
23
+ positions << at
24
+ from = at + 1
25
+ end
26
+
27
+ Match.new(score: score(haystack, positions), positions: positions)
28
+ end
29
+
30
+ def rank(candidates)
31
+ candidates
32
+ .filter_map do |item|
33
+ found = match(block_given? ? yield(item) : item)
34
+ [item, found] if found
35
+ end
36
+ .sort_by { |(_item, found)| -found.score }
37
+ end
38
+
39
+ private
40
+
41
+ def score(graphemes, positions)
42
+ total = 0
43
+ positions.each_with_index do |pos, i|
44
+ total += 10
45
+ total += 6 if i.positive? && positions[i - 1] == pos - 1
46
+ total += 8 if boundary?(graphemes, pos)
47
+ end
48
+
49
+ total - (positions.last - positions.first)
50
+ end
51
+
52
+ def boundary?(graphemes, pos)
53
+ pos.zero? || BOUNDARY.include?(graphemes[pos - 1])
54
+ end
55
+ end
56
+ end