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/lib/tui_tui/help.rb
ADDED
|
@@ -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,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
|
data/lib/tui_tui/line.rb
ADDED
|
@@ -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
|
data/lib/tui_tui/list.rb
ADDED
|
@@ -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
|
data/lib/tui_tui/rect.rb
ADDED
|
@@ -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
|