tui_tui 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +51 -14
- data/examples/README.md +28 -0
- data/examples/breakout.rb +225 -0
- data/examples/counter.rb +11 -12
- data/examples/csv_viewer.rb +28 -50
- data/examples/downloads.rb +128 -0
- data/examples/file_browser.rb +64 -55
- data/examples/form.rb +80 -102
- data/examples/life.rb +1 -1
- data/examples/log_viewer.rb +118 -0
- data/examples/todo.rb +43 -64
- data/examples/widgets.rb +4 -4
- data/lib/tui_tui/box_chrome.rb +0 -9
- data/lib/tui_tui/box_chrome_resolver.rb +21 -0
- data/lib/tui_tui/canvas.rb +6 -55
- data/lib/tui_tui/canvas_compositor.rb +12 -10
- data/lib/tui_tui/canvas_renderer.rb +59 -0
- data/lib/tui_tui/cell.rb +4 -3
- data/lib/tui_tui/command.rb +21 -0
- data/lib/tui_tui/event_stream.rb +42 -7
- data/lib/tui_tui/key_reader.rb +1 -1
- data/lib/tui_tui/modal/command_palette.rb +193 -0
- data/lib/tui_tui/modal/confirm.rb +74 -0
- data/lib/tui_tui/modal/help.rb +44 -0
- data/lib/tui_tui/modal/pager.rb +106 -0
- data/lib/tui_tui/modal/prompt.rb +66 -0
- data/lib/tui_tui/modal/select.rb +103 -0
- data/lib/tui_tui/modal.rb +4 -0
- data/lib/tui_tui/modal_host.rb +2 -2
- data/lib/tui_tui/rect.rb +69 -0
- data/lib/tui_tui/runtime.rb +46 -23
- data/lib/tui_tui/screen.rb +7 -3
- data/lib/tui_tui/test_runtime.rb +71 -0
- data/lib/tui_tui/text_input.rb +168 -0
- data/lib/tui_tui/text_sanitizer.rb +3 -10
- data/lib/tui_tui/theme.rb +25 -27
- data/lib/tui_tui/version.rb +1 -1
- data/lib/tui_tui/widget/list.rb +59 -0
- data/lib/tui_tui/widget/progress.rb +33 -0
- data/lib/tui_tui/widget/scroll_list.rb +59 -0
- data/lib/tui_tui/widget/scrollbar.rb +43 -0
- data/lib/tui_tui/widget/spinner.rb +37 -0
- data/lib/tui_tui/widget/status_bar.rb +25 -0
- data/lib/tui_tui/widget/table.rb +81 -0
- data/lib/tui_tui/widget/tabs.rb +67 -0
- data/lib/tui_tui/widget/text_view.rb +47 -0
- data/lib/tui_tui/widget/toast.rb +83 -0
- data/lib/tui_tui.rb +23 -15
- metadata +28 -15
- data/lib/tui_tui/command_palette.rb +0 -190
- data/lib/tui_tui/confirm.rb +0 -74
- data/lib/tui_tui/help.rb +0 -44
- data/lib/tui_tui/list.rb +0 -57
- data/lib/tui_tui/pager.rb +0 -106
- data/lib/tui_tui/prompt.rb +0 -108
- data/lib/tui_tui/scroll_list.rb +0 -57
- data/lib/tui_tui/scrollbar.rb +0 -41
- data/lib/tui_tui/select.rb +0 -103
- data/lib/tui_tui/status_bar.rb +0 -23
- data/lib/tui_tui/text_view.rb +0 -45
- data/lib/tui_tui/toast.rb +0 -83
- /data/lib/tui_tui/{event.rb → events.rb} +0 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "display_text"
|
|
4
|
+
require_relative "key_code"
|
|
5
|
+
|
|
6
|
+
module TuiTui
|
|
7
|
+
# A single-line, grapheme-aware text buffer with a cursor: the editing model
|
|
8
|
+
# shared by the prompt, search bars, command-palette queries, and editable
|
|
9
|
+
# cells. It is Canvas-agnostic — it knows only graphemes, cursor position, and
|
|
10
|
+
# display columns — so the same model drives any chrome around it.
|
|
11
|
+
#
|
|
12
|
+
# Editing happens by grapheme cluster, so the cursor never lands inside an
|
|
13
|
+
# emoji modifier or a base+combining-mark pair, and a combining mark typed
|
|
14
|
+
# after its base merges into one grapheme.
|
|
15
|
+
#
|
|
16
|
+
# The buffer owns only the *editing* keys (text, Backspace/Delete, arrows,
|
|
17
|
+
# Home/End) via #handle_editing; semantic keys like Enter and Escape belong to
|
|
18
|
+
# the host, which decides what they mean.
|
|
19
|
+
class TextInput
|
|
20
|
+
# The cursor as a grapheme index in 0..length.
|
|
21
|
+
attr_reader :cursor
|
|
22
|
+
|
|
23
|
+
def initialize(value: "")
|
|
24
|
+
@graphemes = value.grapheme_clusters
|
|
25
|
+
@cursor = @graphemes.length
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def value = @graphemes.join
|
|
29
|
+
def empty? = @graphemes.empty?
|
|
30
|
+
def length = @graphemes.length
|
|
31
|
+
|
|
32
|
+
# Move the cursor, clamping into 0..length.
|
|
33
|
+
def cursor=(index)
|
|
34
|
+
@cursor = index.clamp(0, @graphemes.length)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Handle an editing key, returning true if it was consumed. Enter, Escape and
|
|
38
|
+
# any other non-editing key return false so the host can act on them.
|
|
39
|
+
def handle_editing(key)
|
|
40
|
+
case key
|
|
41
|
+
when KeyCode::BACKSPACE, :backspace then delete_back
|
|
42
|
+
when :delete then delete_forward
|
|
43
|
+
when :left then left
|
|
44
|
+
when :right then right
|
|
45
|
+
when :home then to_home
|
|
46
|
+
when :end then to_end
|
|
47
|
+
when String then return type(key)
|
|
48
|
+
else return false
|
|
49
|
+
end
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Insert `key` as literal text when it is printable (no control bytes),
|
|
54
|
+
# returning whether it was accepted. The entry point for typed input — hosts
|
|
55
|
+
# that bypass #handle_editing (e.g. append-only queries) call this directly so
|
|
56
|
+
# the printable rule lives in one place rather than at every call site.
|
|
57
|
+
def type(key)
|
|
58
|
+
return false unless printable?(key)
|
|
59
|
+
|
|
60
|
+
insert(key)
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Insert a string at the cursor, re-clustering across both boundaries so a
|
|
65
|
+
# combining mark merges into its neighbour. The cursor lands after the text.
|
|
66
|
+
def insert(string)
|
|
67
|
+
head = @graphemes[0...@cursor].join
|
|
68
|
+
@graphemes = (head + string + @graphemes[@cursor..].join).grapheme_clusters
|
|
69
|
+
@cursor = (head + string).grapheme_clusters.length
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def delete_back
|
|
73
|
+
return false if @cursor.zero?
|
|
74
|
+
|
|
75
|
+
@graphemes.delete_at(@cursor - 1)
|
|
76
|
+
@cursor -= 1
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def delete_forward
|
|
81
|
+
return false if @cursor >= @graphemes.length
|
|
82
|
+
|
|
83
|
+
@graphemes.delete_at(@cursor)
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def left = (@cursor = [@cursor - 1, 0].max)
|
|
88
|
+
def right = (@cursor = [@cursor + 1, @graphemes.length].min)
|
|
89
|
+
def to_home = (@cursor = 0)
|
|
90
|
+
def to_end = (@cursor = @graphemes.length)
|
|
91
|
+
|
|
92
|
+
# The grapheme index whose left edge is closest to `rel` columns past the
|
|
93
|
+
# grapheme `from` — for mapping a mouse click onto the cursor, where `from`
|
|
94
|
+
# is the left grapheme of the visible viewport (see #viewport).
|
|
95
|
+
def index_at(rel, from: 0)
|
|
96
|
+
return from if rel <= 0
|
|
97
|
+
|
|
98
|
+
width = 0
|
|
99
|
+
@graphemes[from..].each_with_index do |grapheme, i|
|
|
100
|
+
w = DisplayText.new(grapheme).width
|
|
101
|
+
return from + i if rel < width + ((w + 1) / 2)
|
|
102
|
+
|
|
103
|
+
width += w
|
|
104
|
+
end
|
|
105
|
+
@graphemes.length
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The visible region of a horizontally-scrolled value: the slice to draw,
|
|
109
|
+
# scrolled so the cursor (and the cell its block occupies) stays inside `cols`
|
|
110
|
+
# display columns. Lets a long value be drawn in a narrow field without the
|
|
111
|
+
# cursor running off the edge.
|
|
112
|
+
#
|
|
113
|
+
# `text` is the visible substring, `cursor_col` the display columns from the
|
|
114
|
+
# viewport's left edge to the cursor (measured from the slice, not the start
|
|
115
|
+
# of the value), `cursor_grapheme` the grapheme under the cursor (nil at the
|
|
116
|
+
# end), and `start` the left grapheme index (feed it to #index_at as `from:`).
|
|
117
|
+
Viewport = Data.define(:text, :cursor_col, :cursor_grapheme, :start)
|
|
118
|
+
|
|
119
|
+
def viewport(cols)
|
|
120
|
+
cols = [cols, 1].max
|
|
121
|
+
# Reserve room for the block cursor: a real grapheme's width, else one
|
|
122
|
+
# column for the trailing space drawn when the cursor sits at the end.
|
|
123
|
+
cell = grapheme_at_cursor ? DisplayText.new(grapheme_at_cursor).width : 1
|
|
124
|
+
# Scroll right one grapheme at a time until the cursor cell fits, shrinking
|
|
125
|
+
# the running leading width instead of recomputing it from scratch.
|
|
126
|
+
start = 0
|
|
127
|
+
lead = width_before_cursor
|
|
128
|
+
while start < @cursor && lead + cell > cols
|
|
129
|
+
lead -= DisplayText.new(@graphemes[start]).width
|
|
130
|
+
start += 1
|
|
131
|
+
end
|
|
132
|
+
Viewport.new(
|
|
133
|
+
text: visible_slice(start, cols),
|
|
134
|
+
cursor_col: lead,
|
|
135
|
+
cursor_grapheme: grapheme_at_cursor,
|
|
136
|
+
start: start
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Whether `string` is safe to insert as literal text: no C0 control bytes and
|
|
143
|
+
# no DEL. Multibyte UTF-8 passes, since its bytes are all >= 0x80.
|
|
144
|
+
def printable?(string) = string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
|
|
145
|
+
|
|
146
|
+
# Display columns from the start of the value to the cursor. The viewport-
|
|
147
|
+
# relative counterpart is Viewport#cursor_col.
|
|
148
|
+
def width_before_cursor = DisplayText.new(@graphemes[0...@cursor].join).width
|
|
149
|
+
|
|
150
|
+
# The grapheme under the cursor (drawn as a block cursor), or nil when the
|
|
151
|
+
# cursor sits past the last grapheme.
|
|
152
|
+
def grapheme_at_cursor = @graphemes[@cursor]
|
|
153
|
+
|
|
154
|
+
# Graphemes from `start` that fit within `cols` display columns.
|
|
155
|
+
def visible_slice(start, cols)
|
|
156
|
+
text = +""
|
|
157
|
+
width = 0
|
|
158
|
+
@graphemes[start..].each do |grapheme|
|
|
159
|
+
w = DisplayText.new(grapheme).width
|
|
160
|
+
break if width + w > cols
|
|
161
|
+
|
|
162
|
+
text << grapheme
|
|
163
|
+
width += w
|
|
164
|
+
end
|
|
165
|
+
text
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -1,21 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module TuiTui
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# Encoding hygiene for the render path: replaces malformed byte sequences so a
|
|
5
|
+
# string can be displayed safely instead of raising encoding errors. (Input
|
|
6
|
+
# acceptance — rejecting control keys — lives with the input model, TextInput.)
|
|
7
7
|
module TextSanitizer
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
def sanitize(string)
|
|
11
11
|
string.valid_encoding? ? string : string.scrub("?")
|
|
12
12
|
end
|
|
13
|
-
|
|
14
|
-
# Whether `string` is safe to insert as literal text: every byte is a
|
|
15
|
-
# printable character (no C0 controls and no DEL). Multibyte UTF-8 passes,
|
|
16
|
-
# since its bytes are all >= 0x80.
|
|
17
|
-
def printable?(string)
|
|
18
|
-
string.bytes.all? { |byte| byte >= 0x20 && byte != 0x7F }
|
|
19
|
-
end
|
|
20
13
|
end
|
|
21
14
|
end
|
data/lib/tui_tui/theme.rb
CHANGED
|
@@ -31,17 +31,6 @@ module TuiTui
|
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
class Theme
|
|
34
|
-
# Map a symbolic status to its semantic role Style (with common aliases).
|
|
35
|
-
def status(kind)
|
|
36
|
-
case kind
|
|
37
|
-
when :ok, :success then success
|
|
38
|
-
when :warn, :warning then warning
|
|
39
|
-
when :error, :danger then danger
|
|
40
|
-
when :info then info
|
|
41
|
-
else text
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
34
|
# Background-dependent neutral roles.
|
|
46
35
|
SURFACES = {
|
|
47
36
|
dark: {
|
|
@@ -116,6 +105,21 @@ module TuiTui
|
|
|
116
105
|
|
|
117
106
|
def self.build(background: :dark, hue: :cool) = TABLE.fetch([background, hue])
|
|
118
107
|
|
|
108
|
+
LIGHT = Theme.build(background: :light, hue: :cool)
|
|
109
|
+
WARM = Theme.build(background: :dark, hue: :warm)
|
|
110
|
+
MONO = Theme.build(background: :dark, hue: :mono)
|
|
111
|
+
DARK = Theme.build(background: :dark, hue: :cool)
|
|
112
|
+
DEFAULT = DARK
|
|
113
|
+
|
|
114
|
+
PRESETS = {
|
|
115
|
+
default: DEFAULT,
|
|
116
|
+
cool: DEFAULT,
|
|
117
|
+
dark: DARK,
|
|
118
|
+
light: LIGHT,
|
|
119
|
+
warm: WARM,
|
|
120
|
+
mono: MONO
|
|
121
|
+
}.freeze
|
|
122
|
+
|
|
119
123
|
# Best-effort terminal background (:light/:dark).
|
|
120
124
|
def self.detect_background(env: ENV)
|
|
121
125
|
case env["TUITUI_BACKGROUND"]&.downcase
|
|
@@ -134,22 +138,16 @@ module TuiTui
|
|
|
134
138
|
|
|
135
139
|
# Fetch a preset by name (Symbol/String); unknown names fall back to DEFAULT.
|
|
136
140
|
def self.named(name) = PRESETS.fetch(name&.to_sym, DEFAULT)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
Theme::DARK = Theme.build(background: :dark, hue: :cool)
|
|
140
|
-
Theme::LIGHT = Theme.build(background: :light, hue: :cool)
|
|
141
|
-
Theme::DEFAULT = Theme::DARK
|
|
142
|
-
Theme::WARM = Theme.build(background: :dark, hue: :warm)
|
|
143
|
-
Theme::MONO = Theme.build(background: :dark, hue: :mono)
|
|
144
141
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
142
|
+
# Map a symbolic status to its semantic role Style (with common aliases).
|
|
143
|
+
def status(kind)
|
|
144
|
+
case kind
|
|
145
|
+
when :ok, :success then success
|
|
146
|
+
when :warn, :warning then warning
|
|
147
|
+
when :error, :danger then danger
|
|
148
|
+
when :info then info
|
|
149
|
+
else text
|
|
150
|
+
end
|
|
151
|
+
end
|
|
154
152
|
end
|
|
155
153
|
end
|
data/lib/tui_tui/version.rb
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rect"
|
|
4
|
+
require_relative "../line"
|
|
5
|
+
require_relative "scrollbar"
|
|
6
|
+
|
|
7
|
+
module TuiTui
|
|
8
|
+
module Widget
|
|
9
|
+
# Drawing companion for ScrollList.
|
|
10
|
+
# Row content comes from the caller, keeping the list domain-agnostic.
|
|
11
|
+
class List
|
|
12
|
+
def initialize(scroll)
|
|
13
|
+
@scroll = scroll
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def draw(canvas, rect, highlight: nil, scrollbar: nil, auto: false)
|
|
17
|
+
# With auto:, reserve the gutter only when the content overflows the rect.
|
|
18
|
+
show_bar = scrollbar && !(auto && @scroll.count <= rect.rows)
|
|
19
|
+
body, gutter = show_bar ? rect.split_gutter : [rect, nil]
|
|
20
|
+
@scroll.ensure_visible(body.rows)
|
|
21
|
+
@scroll.each_visible(body.rows) do |index, offset|
|
|
22
|
+
row = body.row + offset
|
|
23
|
+
selected = index == @scroll.cursor
|
|
24
|
+
canvas.fill(Rect.new(row: row, col: body.col, rows: 1, cols: body.cols), highlight) if highlight && selected
|
|
25
|
+
canvas.line(row, body.col, Line.coerce(yield(index, selected)).truncate(body.cols))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
draw_scrollbar(canvas, gutter, scrollbar) if gutter
|
|
29
|
+
canvas
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Map a MouseEvent to the list index under it, or nil. Pass the same `rect`
|
|
33
|
+
# and `scrollbar:` used for `draw` so the gutter column is excluded and the
|
|
34
|
+
# scroll offset matches what was rendered. Returns nil for clicks outside the
|
|
35
|
+
# body or below the last item.
|
|
36
|
+
def index_at(rect, event, scrollbar: nil)
|
|
37
|
+
body = scrollbar ? rect.split_gutter.first : rect
|
|
38
|
+
return nil unless body.hit?(event)
|
|
39
|
+
|
|
40
|
+
index = @scroll.top + (event.row - body.row)
|
|
41
|
+
index < @scroll.count ? index : nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def draw_scrollbar(canvas, gutter, theme)
|
|
47
|
+
Scrollbar.draw(
|
|
48
|
+
canvas,
|
|
49
|
+
gutter,
|
|
50
|
+
top: @scroll.top,
|
|
51
|
+
visible: gutter.rows,
|
|
52
|
+
total: @scroll.count,
|
|
53
|
+
track_style: theme.scroll_track,
|
|
54
|
+
thumb_style: theme.scroll_thumb
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../display_text"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
module Widget
|
|
7
|
+
# A stateless horizontal progress bar for a 1-row rect.
|
|
8
|
+
# The caller passes the ratio and Theme roles (e.g. bar_style:
|
|
9
|
+
# theme.selection, track_style: theme.muted); default glyphs are ASCII
|
|
10
|
+
# (README N7). If the rect is taller than one row, every row gets the bar.
|
|
11
|
+
module Progress
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def draw(canvas, rect, ratio, bar_style:, track_style: nil, char: "#", track_char: "-")
|
|
15
|
+
return canvas if rect.rows <= 0 || rect.cols <= 0
|
|
16
|
+
|
|
17
|
+
filled = (rect.cols * ratio.to_f.clamp(0.0, 1.0)).round
|
|
18
|
+
# char / track_char are meant to be 1 column wide (the ASCII defaults
|
|
19
|
+
# are, per N7); a wider glyph is clipped to the cell budget so the bar
|
|
20
|
+
# never overflows the rect.
|
|
21
|
+
bar = DisplayText.new(char * filled).truncate(filled, marker: "").to_s
|
|
22
|
+
track = DisplayText.new(track_char * (rect.cols - filled)).truncate(rect.cols - filled, marker: "").to_s
|
|
23
|
+
rect.rows.times do |dr|
|
|
24
|
+
row = rect.row + dr
|
|
25
|
+
canvas.text(row, rect.col, bar, bar_style) if filled.positive?
|
|
26
|
+
canvas.text(row, rect.col + filled, track, track_style) if filled < rect.cols
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
canvas
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TuiTui
|
|
4
|
+
module Widget
|
|
5
|
+
# Cursor and viewport arithmetic shared by list-like widgets.
|
|
6
|
+
class ScrollList
|
|
7
|
+
attr_reader :cursor, :top
|
|
8
|
+
|
|
9
|
+
def initialize(count = 0)
|
|
10
|
+
@count = count
|
|
11
|
+
@cursor = 0
|
|
12
|
+
@top = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def count=(value)
|
|
16
|
+
@count = [value, 0].max
|
|
17
|
+
@cursor = @cursor.clamp(0, last)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :count
|
|
21
|
+
|
|
22
|
+
def empty? = @count.zero?
|
|
23
|
+
def last = [@count - 1, 0].max
|
|
24
|
+
def at_end? = @cursor == last
|
|
25
|
+
|
|
26
|
+
def move(delta) = go_to(@cursor + delta)
|
|
27
|
+
def page(height) = move(height)
|
|
28
|
+
def to_top = go_to(0)
|
|
29
|
+
def to_end = go_to(last)
|
|
30
|
+
|
|
31
|
+
def go_to(index)
|
|
32
|
+
@cursor = index.clamp(0, last)
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ensure_visible(height)
|
|
37
|
+
return self if height <= 0
|
|
38
|
+
|
|
39
|
+
@top = @cursor if @cursor < @top
|
|
40
|
+
@top = @cursor - height + 1 if @cursor >= @top + height
|
|
41
|
+
@top = 0 if @top.negative?
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def each_visible(height)
|
|
46
|
+
return enum_for(:each_visible, height) unless block_given?
|
|
47
|
+
|
|
48
|
+
height.times do |offset|
|
|
49
|
+
index = @top + offset
|
|
50
|
+
break if index >= @count
|
|
51
|
+
|
|
52
|
+
yield index, offset
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rect"
|
|
4
|
+
require_relative "../style"
|
|
5
|
+
require_relative "../theme"
|
|
6
|
+
|
|
7
|
+
module TuiTui
|
|
8
|
+
module Widget
|
|
9
|
+
# A vertical scroll indicator for a 1-column gutter.
|
|
10
|
+
# It sizes a thumb from top/visible/total and draws ASCII-only chrome.
|
|
11
|
+
module Scrollbar
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
TRACK = Theme::DEFAULT.scroll_track
|
|
15
|
+
THUMB = Theme::DEFAULT.scroll_thumb
|
|
16
|
+
|
|
17
|
+
def draw(canvas, rect, top:, visible:, total:, track: nil, thumb: " ", track_style: TRACK, thumb_style: THUMB)
|
|
18
|
+
return canvas if rect.rows <= 0
|
|
19
|
+
|
|
20
|
+
track ||= canvas.chrome.track
|
|
21
|
+
length, offset = geometry(rect.rows, top, visible, total)
|
|
22
|
+
rect.rows.times do |i|
|
|
23
|
+
in_thumb = i >= offset && i < offset + length
|
|
24
|
+
canvas.text(rect.row + i, rect.col, in_thumb ? thumb : track, in_thumb ? thumb_style : track_style)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
canvas
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The thumb's [length, offset] in rows for a `height`-row track. Returns
|
|
31
|
+
# [0, 0] (no thumb, track only) when everything fits — nothing to scroll.
|
|
32
|
+
def geometry(height, top, visible, total)
|
|
33
|
+
visible = [visible, 1].max
|
|
34
|
+
total = [total, visible].max
|
|
35
|
+
return [0, 0] if total <= visible
|
|
36
|
+
|
|
37
|
+
length = [(height * visible / total.to_f).round, 1].max.clamp(1, height)
|
|
38
|
+
offset = ((height - length) * top.to_f / (total - visible)).round
|
|
39
|
+
[length, offset.clamp(0, height - length)]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TuiTui
|
|
4
|
+
module Widget
|
|
5
|
+
# A tiny frame-cycling state machine for "working..." indicators.
|
|
6
|
+
# It knows nothing about time: the app advances it (typically once per
|
|
7
|
+
# TickEvent) and draws the current glyph wherever it likes.
|
|
8
|
+
class Spinner
|
|
9
|
+
# Default frames are ASCII so every glyph is guaranteed 1 column wide
|
|
10
|
+
# (README N7). FRAMES_BRAILLE is offered for terminals with good glyph
|
|
11
|
+
# coverage — whether to use it is the caller's judgement, not ours.
|
|
12
|
+
FRAMES_ASCII = %w[- \\ | /].freeze
|
|
13
|
+
FRAMES_BRAILLE = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(frames: FRAMES_ASCII)
|
|
16
|
+
raise ArgumentError, "spinner needs at least one frame" if frames.empty?
|
|
17
|
+
|
|
18
|
+
@frames = frames
|
|
19
|
+
@index = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Step to the next frame (wrapping). Returns self for chaining.
|
|
23
|
+
def advance
|
|
24
|
+
@index = (@index + 1) % @frames.length
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The current frame's string.
|
|
29
|
+
def glyph = @frames[@index]
|
|
30
|
+
|
|
31
|
+
def draw(canvas, row, col, style: nil)
|
|
32
|
+
canvas.text(row, col, glyph, style)
|
|
33
|
+
canvas
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../display_text"
|
|
4
|
+
|
|
5
|
+
module TuiTui
|
|
6
|
+
module Widget
|
|
7
|
+
# A one-row status/footer bar.
|
|
8
|
+
# It draws left text from the start and optional right text flush right.
|
|
9
|
+
module StatusBar
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def draw(canvas, rect, left: "", right: nil, style: nil)
|
|
13
|
+
canvas.fill(rect, style)
|
|
14
|
+
|
|
15
|
+
right_width = right ? DisplayText.new(right).width : 0
|
|
16
|
+
fits_right = right && right_width < rect.cols
|
|
17
|
+
left_max = fits_right ? rect.cols - right_width : rect.cols
|
|
18
|
+
|
|
19
|
+
canvas.text(rect.row, rect.col, DisplayText.new(left).truncate(left_max), style)
|
|
20
|
+
canvas.text(rect.row, rect.col + rect.cols - right_width, right, style) if fits_right
|
|
21
|
+
canvas
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rect"
|
|
4
|
+
require_relative "../line"
|
|
5
|
+
require_relative "../span"
|
|
6
|
+
require_relative "../display_text"
|
|
7
|
+
require_relative "list"
|
|
8
|
+
|
|
9
|
+
module TuiTui
|
|
10
|
+
module Widget
|
|
11
|
+
# Drawing companion for ScrollList with a fixed header row.
|
|
12
|
+
# The first row of the rect is the header (built from `columns:`, an array
|
|
13
|
+
# of [label, width] pairs); the remaining rows are the scrolled body, drawn
|
|
14
|
+
# by Widget::List. Cell content comes from the caller's block, keeping the
|
|
15
|
+
# table domain-agnostic: `yield(index, selected)` returns one value per
|
|
16
|
+
# column — a String (styled with the row's base style) or a Span (its own
|
|
17
|
+
# style wins, e.g. to accent one cell). Labels may be Spans too.
|
|
18
|
+
#
|
|
19
|
+
# Each cell is truncated/padded to its column width (CJK-safe via
|
|
20
|
+
# DisplayText), columns are joined with a 1-space gap, and anything past
|
|
21
|
+
# the rect's right edge is clipped.
|
|
22
|
+
class Table
|
|
23
|
+
COLUMN_GAP = 1
|
|
24
|
+
|
|
25
|
+
def initialize(scroll)
|
|
26
|
+
@scroll = scroll
|
|
27
|
+
@list = List.new(scroll)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def draw(canvas, rect, columns:, header_style: nil, highlight: nil, scrollbar: nil)
|
|
31
|
+
header, body = rect.split_rows(1, :rest)
|
|
32
|
+
draw_header(canvas, header, columns, header_style)
|
|
33
|
+
return canvas if body.rows <= 0
|
|
34
|
+
|
|
35
|
+
@list.draw(canvas, body, highlight: highlight, scrollbar: scrollbar) do |index, selected|
|
|
36
|
+
format_row(yield(index, selected), columns, selected ? highlight : nil)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Map a MouseEvent to the row index under it, or nil. The header row and
|
|
41
|
+
# anything outside the body map to nil. Pass the same `rect` and
|
|
42
|
+
# `scrollbar:` used for `draw`.
|
|
43
|
+
def index_at(rect, event, scrollbar: nil)
|
|
44
|
+
_header, body = rect.split_rows(1, :rest)
|
|
45
|
+
return nil if body.rows <= 0
|
|
46
|
+
|
|
47
|
+
@list.index_at(body, event, scrollbar: scrollbar)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def draw_header(canvas, rect, columns, style)
|
|
53
|
+
return if rect.rows <= 0 || rect.cols <= 0
|
|
54
|
+
|
|
55
|
+
canvas.fill(rect, style)
|
|
56
|
+
line = format_row(columns.map { |label, _width| label }, columns, style)
|
|
57
|
+
canvas.line(rect.row, rect.col, line.truncate(rect.cols))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One Line for a row: each cell fitted to its column width, joined with
|
|
61
|
+
# a base-styled gap so a highlight fill is not punched through.
|
|
62
|
+
def format_row(cells, columns, base_style)
|
|
63
|
+
spans = []
|
|
64
|
+
columns.each_with_index do |(_label, width), index|
|
|
65
|
+
spans << Span[" " * COLUMN_GAP, base_style] unless spans.empty?
|
|
66
|
+
spans << fit(cells[index], width, base_style)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Line.new(spans)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Truncate/pad one cell to `width` columns. A Span keeps its own style;
|
|
73
|
+
# anything else is rendered with the row's base style.
|
|
74
|
+
def fit(cell, width, base_style)
|
|
75
|
+
style = cell.is_a?(Span) ? cell.style : base_style
|
|
76
|
+
text = DisplayText.new(cell.is_a?(Span) ? cell.text : cell.to_s).truncate(width)
|
|
77
|
+
Span[text.to_s + (" " * [width - text.width, 0].max), style]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../display_text"
|
|
4
|
+
require_relative "../theme"
|
|
5
|
+
|
|
6
|
+
module TuiTui
|
|
7
|
+
module Widget
|
|
8
|
+
# A one-row tab bar: labels rendered as ` label ` segments separated by a
|
|
9
|
+
# single space. The active tab uses theme.selection, the rest theme.muted.
|
|
10
|
+
# Stateless like StatusBar / Scrollbar — the active index lives in the app;
|
|
11
|
+
# `index_at` maps a mouse event back to the tab under it.
|
|
12
|
+
#
|
|
13
|
+
# Labels that overflow rect.cols are clipped at the right edge; there is no
|
|
14
|
+
# scrolling. Advanced handling for bars with more tabs than fit (scrolling,
|
|
15
|
+
# collapsing, overflow menus) is out of scope.
|
|
16
|
+
module Tabs
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Horizontal padding inside a tab, on each side of the label.
|
|
20
|
+
PAD = 1
|
|
21
|
+
# Columns between adjacent tabs.
|
|
22
|
+
GAP = 1
|
|
23
|
+
|
|
24
|
+
def draw(canvas, rect, labels, active:, theme: Theme::DEFAULT)
|
|
25
|
+
canvas.fill(rect, nil)
|
|
26
|
+
segments(labels).each_with_index do |(offset, _width, padded), index|
|
|
27
|
+
remaining = rect.cols - offset
|
|
28
|
+
break if remaining <= 0
|
|
29
|
+
|
|
30
|
+
style = index == active ? theme.selection : theme.muted
|
|
31
|
+
canvas.text(rect.row, rect.col + offset, padded.truncate(remaining, marker: ""), style)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
canvas
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The tab index under a MouseEvent, or nil: outside the rect, past the
|
|
38
|
+
# visible (clipped) columns, or beyond the last tab. The single separator
|
|
39
|
+
# space between tabs belongs to no tab and also yields nil. Shares the
|
|
40
|
+
# segment layout with `draw`, so hits always match what was rendered.
|
|
41
|
+
def index_at(rect, event, labels)
|
|
42
|
+
return nil unless rect.hit?(event)
|
|
43
|
+
|
|
44
|
+
offset = event.col - rect.col
|
|
45
|
+
segments(labels).each_with_index do |(start, width, _padded), index|
|
|
46
|
+
return index if offset >= start && offset < start + width
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The layout both draw and index_at share: for each label, its starting
|
|
53
|
+
# column offset (0-based, relative to rect.col), its rendered width, and
|
|
54
|
+
# the padded ` label ` DisplayText.
|
|
55
|
+
def segments(labels)
|
|
56
|
+
offset = 0
|
|
57
|
+
labels.map do |label|
|
|
58
|
+
padded = DisplayText.new(" " * PAD + label.to_s + " " * PAD)
|
|
59
|
+
width = padded.width
|
|
60
|
+
segment = [offset, width, padded]
|
|
61
|
+
offset += width + GAP
|
|
62
|
+
segment
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|