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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +8 -0
- data/examples/clock.rb +112 -0
- data/examples/counter.rb +48 -0
- data/examples/csv_viewer.rb +233 -0
- data/examples/file_browser.rb +665 -0
- data/examples/form.rb +633 -0
- data/examples/life.rb +144 -0
- data/examples/paint.rb +246 -0
- data/examples/todo.rb +250 -0
- data/examples/widgets.rb +101 -0
- data/lib/tui_tui/ansi.rb +34 -0
- data/lib/tui_tui/canvas.rb +187 -0
- data/lib/tui_tui/canvas_compositor.rb +45 -0
- data/lib/tui_tui/cell.rb +11 -0
- data/lib/tui_tui/color_depth.rb +39 -0
- data/lib/tui_tui/confirm.rb +74 -0
- data/lib/tui_tui/display_text.rb +73 -0
- data/lib/tui_tui/event.rb +10 -0
- data/lib/tui_tui/event_stream.rb +39 -0
- data/lib/tui_tui/focus_ring.rb +25 -0
- data/lib/tui_tui/fuzzy.rb +56 -0
- data/lib/tui_tui/help.rb +44 -0
- data/lib/tui_tui/key_code.rb +9 -0
- data/lib/tui_tui/key_intent.rb +29 -0
- data/lib/tui_tui/key_reader.rb +175 -0
- data/lib/tui_tui/line.rb +59 -0
- data/lib/tui_tui/list.rb +45 -0
- data/lib/tui_tui/modal.rb +30 -0
- data/lib/tui_tui/pager.rb +94 -0
- data/lib/tui_tui/palette.rb +49 -0
- data/lib/tui_tui/prompt.rb +111 -0
- data/lib/tui_tui/rect.rb +48 -0
- data/lib/tui_tui/runtime.rb +53 -0
- data/lib/tui_tui/screen.rb +85 -0
- data/lib/tui_tui/scroll_list.rb +57 -0
- data/lib/tui_tui/scrollbar.rb +40 -0
- data/lib/tui_tui/select.rb +104 -0
- data/lib/tui_tui/size.rb +5 -0
- data/lib/tui_tui/span.rb +14 -0
- data/lib/tui_tui/status_bar.rb +23 -0
- data/lib/tui_tui/style.rb +101 -0
- data/lib/tui_tui/terminal_session.rb +65 -0
- data/lib/tui_tui/terminal_size.rb +24 -0
- data/lib/tui_tui/text_sanitizer.rb +13 -0
- data/lib/tui_tui/text_view.rb +52 -0
- data/lib/tui_tui/theme.rb +127 -0
- data/lib/tui_tui/toast.rb +82 -0
- data/lib/tui_tui/version.rb +5 -0
- data/lib/tui_tui/width.rb +101 -0
- data/lib/tui_tui.rb +51 -0
- metadata +98 -0
data/examples/widgets.rb
ADDED
|
@@ -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
|
data/lib/tui_tui/ansi.rb
ADDED
|
@@ -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
|
data/lib/tui_tui/cell.rb
ADDED
|
@@ -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,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
|