rubytui 1.2.3
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/LICENSE +21 -0
- data/README.md +574 -0
- data/lib/rubytui/app.rb +174 -0
- data/lib/rubytui/backend.rb +158 -0
- data/lib/rubytui/backends/ansi_backend.rb +391 -0
- data/lib/rubytui/backends/test_backend.rb +223 -0
- data/lib/rubytui/buffer.rb +400 -0
- data/lib/rubytui/cell.rb +55 -0
- data/lib/rubytui/color.rb +153 -0
- data/lib/rubytui/color_mode.rb +203 -0
- data/lib/rubytui/errors.rb +13 -0
- data/lib/rubytui/event.rb +161 -0
- data/lib/rubytui/frame.rb +70 -0
- data/lib/rubytui/input/key.rb +93 -0
- data/lib/rubytui/input/parser.rb +231 -0
- data/lib/rubytui/input/reader.rb +119 -0
- data/lib/rubytui/layout/constraint.rb +83 -0
- data/lib/rubytui/layout/flex.rb +16 -0
- data/lib/rubytui/layout/layout.rb +205 -0
- data/lib/rubytui/modifier.rb +67 -0
- data/lib/rubytui/rect.rb +126 -0
- data/lib/rubytui/stateful_widget.rb +20 -0
- data/lib/rubytui/style.rb +143 -0
- data/lib/rubytui/symbols.rb +88 -0
- data/lib/rubytui/terminal.rb +218 -0
- data/lib/rubytui/text/line.rb +67 -0
- data/lib/rubytui/text/span.rb +34 -0
- data/lib/rubytui/text/text.rb +82 -0
- data/lib/rubytui/unicode.rb +162 -0
- data/lib/rubytui/version.rb +6 -0
- data/lib/rubytui/widget.rb +20 -0
- data/lib/rubytui/widgets/async_image.rb +248 -0
- data/lib/rubytui/widgets/block.rb +260 -0
- data/lib/rubytui/widgets/canvas.rb +248 -0
- data/lib/rubytui/widgets/chart.rb +224 -0
- data/lib/rubytui/widgets/gauge.rb +139 -0
- data/lib/rubytui/widgets/image.rb +330 -0
- data/lib/rubytui/widgets/input_field.rb +245 -0
- data/lib/rubytui/widgets/list.rb +186 -0
- data/lib/rubytui/widgets/paragraph.rb +181 -0
- data/lib/rubytui/widgets/popup.rb +140 -0
- data/lib/rubytui/widgets/scrollbar.rb +175 -0
- data/lib/rubytui/widgets/sparkline.rb +86 -0
- data/lib/rubytui/widgets/table.rb +231 -0
- data/lib/rubytui/widgets/tabs.rb +90 -0
- data/lib/rubytui.rb +389 -0
- metadata +89 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Popup renders an overlay on top of existing content.
|
|
6
|
+
# It clears its area, optionally draws a drop shadow, and draws its block;
|
|
7
|
+
# popup content is rendered separately into {#inner}.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# popup = RubyTUI::Widgets::Popup.new(width: 40, height: 7, shadow: true)
|
|
11
|
+
# popup.render(frame.area, frame.buffer)
|
|
12
|
+
# inner = popup.inner(frame.area) # draw popup content here
|
|
13
|
+
class Popup
|
|
14
|
+
include Widget
|
|
15
|
+
|
|
16
|
+
# @param width [Integer, nil] fixed width, clamped to the parent width
|
|
17
|
+
# (default: nil, uses +width_percent+)
|
|
18
|
+
# @param height [Integer, nil] fixed height, clamped to the parent height
|
|
19
|
+
# (default: nil, uses +height_percent+)
|
|
20
|
+
# @param width_percent [Integer] width as a percentage of the parent width when
|
|
21
|
+
# +width+ is nil (default: 60)
|
|
22
|
+
# @param height_percent [Integer] height as a percentage of the parent height when
|
|
23
|
+
# +height+ is nil (default: 60)
|
|
24
|
+
# @param x [Integer, nil] absolute column of the left edge (default: nil, centered
|
|
25
|
+
# horizontally in the parent area)
|
|
26
|
+
# @param y [Integer, nil] absolute row of the top edge (default: nil, centered
|
|
27
|
+
# vertically in the parent area)
|
|
28
|
+
# @param block [Widgets::Block, nil] border/title block (default: nil, a block with
|
|
29
|
+
# all borders and {Symbols::ROUNDED} border characters)
|
|
30
|
+
# @param style [Style] style used to clear the popup area (default: {Style::DEFAULT})
|
|
31
|
+
# @param shadow [Boolean] draw a one-cell shadow to the right of and below the
|
|
32
|
+
# popup (default: false)
|
|
33
|
+
# @param shadow_style [Style, nil] shadow style (default: bright black foreground
|
|
34
|
+
# on a black background)
|
|
35
|
+
def initialize(
|
|
36
|
+
width: nil, # fixed width or nil for percentage
|
|
37
|
+
height: nil, # fixed height or nil for percentage
|
|
38
|
+
width_percent: 60, # percentage of parent width
|
|
39
|
+
height_percent: 60, # percentage of parent height
|
|
40
|
+
x: nil, # fixed x position or nil for centered
|
|
41
|
+
y: nil, # fixed y position or nil for centered
|
|
42
|
+
block: nil, # Block widget for border/title
|
|
43
|
+
style: Style::DEFAULT,
|
|
44
|
+
shadow: false,
|
|
45
|
+
shadow_style: nil
|
|
46
|
+
)
|
|
47
|
+
@fixed_width = width
|
|
48
|
+
@fixed_height = height
|
|
49
|
+
@width_percent = width_percent
|
|
50
|
+
@height_percent = height_percent
|
|
51
|
+
@fixed_x = x
|
|
52
|
+
@fixed_y = y
|
|
53
|
+
@block = block || Widgets::Block.new(borders: :all, border_set: Symbols::ROUNDED)
|
|
54
|
+
@style = style
|
|
55
|
+
@shadow = shadow
|
|
56
|
+
@shadow_style = shadow_style || Style.new.fg(Color::BRIGHT_BLACK).bg(Color::BLACK)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Calculate the popup area within the given parent area
|
|
60
|
+
#
|
|
61
|
+
# The size is clamped to the parent size. A fixed +x+ or +y+ is used as is and
|
|
62
|
+
# is not clamped to the parent area.
|
|
63
|
+
#
|
|
64
|
+
# @param parent_area [Rect] area the popup is sized and centered against
|
|
65
|
+
# @return [Rect] the popup's outer area, including the block's borders
|
|
66
|
+
def popup_area(parent_area)
|
|
67
|
+
w = @fixed_width || (parent_area.width * @width_percent / 100)
|
|
68
|
+
h = @fixed_height || (parent_area.height * @height_percent / 100)
|
|
69
|
+
w = [w, parent_area.width].min
|
|
70
|
+
h = [h, parent_area.height].min
|
|
71
|
+
|
|
72
|
+
px = @fixed_x || (parent_area.x + (parent_area.width - w) / 2)
|
|
73
|
+
py = @fixed_y || (parent_area.y + (parent_area.height - h) / 2)
|
|
74
|
+
|
|
75
|
+
Rect.new(x: px, y: py, width: w, height: h)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get the inner area (inside the popup's block)
|
|
79
|
+
#
|
|
80
|
+
# @param parent_area [Rect] area the popup is sized and centered against
|
|
81
|
+
# @return [Rect] the area inside the block's borders and padding, for popup content
|
|
82
|
+
def inner(parent_area)
|
|
83
|
+
@block.inner(popup_area(parent_area))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Draw the shadow (if enabled), clear the popup area with +style+, then draw
|
|
87
|
+
# the block.
|
|
88
|
+
#
|
|
89
|
+
# +area+ is the parent area; the popup is placed within it by {#popup_area}.
|
|
90
|
+
#
|
|
91
|
+
# @param area [Rect] the parent area to place the popup in
|
|
92
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
93
|
+
# @return [void]
|
|
94
|
+
def render(area, buf)
|
|
95
|
+
return if area.empty?
|
|
96
|
+
|
|
97
|
+
pa = popup_area(area)
|
|
98
|
+
return if pa.empty?
|
|
99
|
+
|
|
100
|
+
# Draw shadow first (offset by 1,1)
|
|
101
|
+
if @shadow
|
|
102
|
+
render_shadow(pa, buf)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Clear the popup area with background style
|
|
106
|
+
clear_area(pa, buf)
|
|
107
|
+
|
|
108
|
+
# Draw the block (border + title)
|
|
109
|
+
@block.render(pa, buf)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def clear_area(area, buf)
|
|
115
|
+
(area.y...area.bottom).each do |y|
|
|
116
|
+
(area.x...area.right).each do |x|
|
|
117
|
+
cell = buf[x, y]
|
|
118
|
+
cell&.set(" ", @style)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def render_shadow(popup_area, buf)
|
|
124
|
+
# Right shadow (1 column)
|
|
125
|
+
shadow_x = popup_area.right
|
|
126
|
+
(popup_area.y + 1...popup_area.bottom + 1).each do |y|
|
|
127
|
+
cell = buf[shadow_x, y]
|
|
128
|
+
cell&.set(" ", @shadow_style) if cell
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Bottom shadow (full width + 1)
|
|
132
|
+
shadow_y = popup_area.bottom
|
|
133
|
+
(popup_area.x + 1..popup_area.right).each do |x|
|
|
134
|
+
cell = buf[x, shadow_y]
|
|
135
|
+
cell&.set(" ", @shadow_style) if cell
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Scrollbar renders a vertical or horizontal scroll indicator.
|
|
6
|
+
#
|
|
7
|
+
# @example Vertical scrollbar in the rightmost column
|
|
8
|
+
# bar = RubyTUI::Widgets::Scrollbar.new(content_length: 100, position: offset, viewport_length: 20)
|
|
9
|
+
# frame.render_widget(bar, Rect.new(area.right - 1, area.y, 1, area.height))
|
|
10
|
+
class Scrollbar
|
|
11
|
+
include Widget
|
|
12
|
+
|
|
13
|
+
# Vertical orientation, drawn in the first column of the area.
|
|
14
|
+
VERTICAL = :vertical
|
|
15
|
+
# Horizontal orientation, drawn in the first row of the area.
|
|
16
|
+
HORIZONTAL = :horizontal
|
|
17
|
+
|
|
18
|
+
# Unicode characters for scrollbar parts
|
|
19
|
+
|
|
20
|
+
# Vertical track.
|
|
21
|
+
TRACK_V = "\u2502" # │
|
|
22
|
+
# Vertical thumb.
|
|
23
|
+
THUMB_V = "\u2588" # █
|
|
24
|
+
# Horizontal track.
|
|
25
|
+
TRACK_H = "\u2500" # ─
|
|
26
|
+
# Horizontal thumb.
|
|
27
|
+
THUMB_H = "\u2588" # █
|
|
28
|
+
# Up arrow.
|
|
29
|
+
ARROW_UP = "\u25B2" # ▲
|
|
30
|
+
# Down arrow.
|
|
31
|
+
ARROW_DOWN = "\u25BC" # ▼
|
|
32
|
+
# Left arrow.
|
|
33
|
+
ARROW_LEFT = "\u25C0" # ◀
|
|
34
|
+
# Right arrow.
|
|
35
|
+
ARROW_RIGHT = "\u25B6" # ▶
|
|
36
|
+
|
|
37
|
+
# @param content_length [Integer] total content length, clamped to >= 1
|
|
38
|
+
# @param position [Integer] scroll offset into the content, clamped to
|
|
39
|
+
# <tt>0..(content_length - viewport_length)</tt> when drawing (default: 0)
|
|
40
|
+
# @param viewport_length [Integer, nil] visible length of the content
|
|
41
|
+
# (default: nil, the track length in cells, arrows included)
|
|
42
|
+
# @param orientation [Symbol] {VERTICAL} (default) or {HORIZONTAL}; any other
|
|
43
|
+
# value is drawn horizontally
|
|
44
|
+
# @param style [Style] currently unused by rendering (default: {Style::DEFAULT})
|
|
45
|
+
# @param thumb_style [Style, nil] thumb style (default: white foreground)
|
|
46
|
+
# @param track_style [Style, nil] track and arrow style (default: bright black foreground)
|
|
47
|
+
# @param show_arrows [Boolean] draw an arrow at each end (default: true)
|
|
48
|
+
def initialize(
|
|
49
|
+
content_length:,
|
|
50
|
+
position: 0,
|
|
51
|
+
viewport_length: nil,
|
|
52
|
+
orientation: VERTICAL,
|
|
53
|
+
style: Style::DEFAULT,
|
|
54
|
+
thumb_style: nil,
|
|
55
|
+
track_style: nil,
|
|
56
|
+
show_arrows: true
|
|
57
|
+
)
|
|
58
|
+
@content_length = [content_length, 1].max
|
|
59
|
+
@position = position
|
|
60
|
+
@viewport_length = viewport_length
|
|
61
|
+
@orientation = orientation
|
|
62
|
+
@style = style
|
|
63
|
+
@thumb_style = thumb_style || Style.new.fg(Color::WHITE)
|
|
64
|
+
@track_style = track_style || Style.new.fg(Color::BRIGHT_BLACK)
|
|
65
|
+
@show_arrows = show_arrows
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Draw the track, thumb and optional arrows along the first column (vertical)
|
|
69
|
+
# or first row (horizontal) of the area.
|
|
70
|
+
#
|
|
71
|
+
# The thumb fills the whole track when the content fits in the viewport.
|
|
72
|
+
# Nothing is drawn if no track cells remain after the arrows.
|
|
73
|
+
#
|
|
74
|
+
# @param area [Rect] the rectangular area to render into
|
|
75
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
76
|
+
# @return [void]
|
|
77
|
+
def render(area, buf)
|
|
78
|
+
return if area.empty?
|
|
79
|
+
|
|
80
|
+
if @orientation == VERTICAL
|
|
81
|
+
render_vertical(area, buf)
|
|
82
|
+
else
|
|
83
|
+
render_horizontal(area, buf)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def render_vertical(area, buf)
|
|
90
|
+
track_length = area.height
|
|
91
|
+
viewport = @viewport_length || track_length
|
|
92
|
+
arrow_offset = @show_arrows ? 1 : 0
|
|
93
|
+
usable = track_length - (arrow_offset * 2)
|
|
94
|
+
|
|
95
|
+
return if usable <= 0
|
|
96
|
+
|
|
97
|
+
# Calculate thumb size and position
|
|
98
|
+
thumb_size = calculate_thumb_size(usable, viewport)
|
|
99
|
+
thumb_pos = calculate_thumb_position(usable, thumb_size, viewport)
|
|
100
|
+
|
|
101
|
+
x = area.x
|
|
102
|
+
|
|
103
|
+
# Top arrow
|
|
104
|
+
if @show_arrows
|
|
105
|
+
buf[x, area.y]&.set(ARROW_UP, @track_style)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Track and thumb
|
|
109
|
+
usable.times do |i|
|
|
110
|
+
y = area.y + arrow_offset + i
|
|
111
|
+
if i >= thumb_pos && i < thumb_pos + thumb_size
|
|
112
|
+
buf[x, y]&.set(THUMB_V, @thumb_style)
|
|
113
|
+
else
|
|
114
|
+
buf[x, y]&.set(TRACK_V, @track_style)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Bottom arrow
|
|
119
|
+
if @show_arrows
|
|
120
|
+
buf[x, area.bottom - 1]&.set(ARROW_DOWN, @track_style)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_horizontal(area, buf)
|
|
125
|
+
track_length = area.width
|
|
126
|
+
viewport = @viewport_length || track_length
|
|
127
|
+
arrow_offset = @show_arrows ? 1 : 0
|
|
128
|
+
usable = track_length - (arrow_offset * 2)
|
|
129
|
+
|
|
130
|
+
return if usable <= 0
|
|
131
|
+
|
|
132
|
+
thumb_size = calculate_thumb_size(usable, viewport)
|
|
133
|
+
thumb_pos = calculate_thumb_position(usable, thumb_size, viewport)
|
|
134
|
+
|
|
135
|
+
y = area.y
|
|
136
|
+
|
|
137
|
+
# Left arrow
|
|
138
|
+
if @show_arrows
|
|
139
|
+
buf[area.x, y]&.set(ARROW_LEFT, @track_style)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Track and thumb
|
|
143
|
+
usable.times do |i|
|
|
144
|
+
x = area.x + arrow_offset + i
|
|
145
|
+
if i >= thumb_pos && i < thumb_pos + thumb_size
|
|
146
|
+
buf[x, y]&.set(THUMB_H, @thumb_style)
|
|
147
|
+
else
|
|
148
|
+
buf[x, y]&.set(TRACK_H, @track_style)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Right arrow
|
|
153
|
+
if @show_arrows
|
|
154
|
+
buf[area.right - 1, y]&.set(ARROW_RIGHT, @track_style)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def calculate_thumb_size(track, viewport)
|
|
159
|
+
return track if @content_length <= viewport
|
|
160
|
+
|
|
161
|
+
size = (track.to_f * viewport / @content_length).round
|
|
162
|
+
size.clamp(1, track)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def calculate_thumb_position(track, thumb_size, viewport)
|
|
166
|
+
return 0 if @content_length <= viewport
|
|
167
|
+
|
|
168
|
+
max_scroll = @content_length - viewport
|
|
169
|
+
pos = @position.clamp(0, max_scroll)
|
|
170
|
+
available = track - thumb_size
|
|
171
|
+
(available.to_f * pos / max_scroll).round.clamp(0, available)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Sparkline renders a mini bar chart from an array of numeric data.
|
|
6
|
+
# Each data point maps to a single column using Unicode block characters.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# spark = RubyTUI::Widgets::Sparkline.new(data: [1, 4, 2, 8, 5], max: 10)
|
|
10
|
+
# frame.render_widget(spark, area)
|
|
11
|
+
class Sparkline
|
|
12
|
+
include Widget
|
|
13
|
+
|
|
14
|
+
# Bar characters from empty to full (8 levels)
|
|
15
|
+
BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"].freeze
|
|
16
|
+
|
|
17
|
+
# @param data [Array<Numeric>] values; the most recent values that fit the
|
|
18
|
+
# width are drawn, right-aligned (default: [])
|
|
19
|
+
# @param style [Style] style of the empty cells above the bars (default: {Style::DEFAULT})
|
|
20
|
+
# @param bar_style [Style, nil] bar style (default: green foreground)
|
|
21
|
+
# @param max [Numeric, nil] value drawn at full height; larger values are clamped
|
|
22
|
+
# to full height (default: nil, the largest visible value, or 1 if that is 0)
|
|
23
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
24
|
+
def initialize(
|
|
25
|
+
data: [],
|
|
26
|
+
style: Style::DEFAULT,
|
|
27
|
+
bar_style: nil,
|
|
28
|
+
max: nil,
|
|
29
|
+
block: nil
|
|
30
|
+
)
|
|
31
|
+
@data = data
|
|
32
|
+
@style = style
|
|
33
|
+
@bar_style = bar_style || Style.new.fg(Color::GREEN)
|
|
34
|
+
@max = max
|
|
35
|
+
@block = block
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Draw one column per value, bottom-up, with eighth-cell height precision.
|
|
39
|
+
#
|
|
40
|
+
# @param area [Rect] the rectangular area to render into
|
|
41
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
42
|
+
# @return [void]
|
|
43
|
+
def render(area, buf)
|
|
44
|
+
return if area.empty?
|
|
45
|
+
|
|
46
|
+
render_area = area
|
|
47
|
+
if @block
|
|
48
|
+
@block.render(area, buf)
|
|
49
|
+
render_area = @block.inner(area)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return if render_area.empty? || @data.empty?
|
|
53
|
+
|
|
54
|
+
width = render_area.width
|
|
55
|
+
height = render_area.height
|
|
56
|
+
|
|
57
|
+
# Use the most recent data points that fit
|
|
58
|
+
visible_data = @data.last(width)
|
|
59
|
+
max_val = @max || visible_data.max || 1
|
|
60
|
+
max_val = 1 if max_val == 0
|
|
61
|
+
|
|
62
|
+
visible_data.each_with_index do |val, i|
|
|
63
|
+
x = render_area.x + (width - visible_data.length) + i
|
|
64
|
+
next if x < render_area.x
|
|
65
|
+
|
|
66
|
+
# Scale value to available height * 8 (for sub-character precision)
|
|
67
|
+
scaled = ((val.to_f / max_val) * height * 8).round.clamp(0, height * 8)
|
|
68
|
+
|
|
69
|
+
# Render from bottom to top
|
|
70
|
+
(0...height).each do |row|
|
|
71
|
+
y = render_area.bottom - 1 - row
|
|
72
|
+
row_value = scaled - (row * 8)
|
|
73
|
+
|
|
74
|
+
if row_value >= 8
|
|
75
|
+
buf[x, y]&.set(BARS[8], @bar_style)
|
|
76
|
+
elsif row_value > 0
|
|
77
|
+
buf[x, y]&.set(BARS[row_value], @bar_style)
|
|
78
|
+
else
|
|
79
|
+
buf[x, y]&.set(" ", @style)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# TableState holds external state for the Table widget.
|
|
6
|
+
#
|
|
7
|
+
# +selected+ is the index of the selected row and +offset+ the index of the
|
|
8
|
+
# first visible row. {Table#render} clamps both in place.
|
|
9
|
+
#
|
|
10
|
+
# @see Table
|
|
11
|
+
class TableState
|
|
12
|
+
attr_accessor :selected, :offset
|
|
13
|
+
|
|
14
|
+
# @param selected [Integer] initial selected row index (default: 0)
|
|
15
|
+
# @param offset [Integer] initial scroll offset (default: 0)
|
|
16
|
+
def initialize(selected: 0, offset: 0)
|
|
17
|
+
@selected = selected
|
|
18
|
+
@offset = offset
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Select the next row, wrapping from the last row to the first.
|
|
22
|
+
#
|
|
23
|
+
# Does nothing when +total+ is 0 or negative.
|
|
24
|
+
#
|
|
25
|
+
# @param total [Integer] number of rows in the table
|
|
26
|
+
# @return [void]
|
|
27
|
+
def select_next(total)
|
|
28
|
+
@selected = (@selected + 1) % total if total > 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Select the previous row, wrapping from the first row to the last.
|
|
32
|
+
#
|
|
33
|
+
# Does nothing when +total+ is 0 or negative.
|
|
34
|
+
#
|
|
35
|
+
# @param total [Integer] number of rows in the table
|
|
36
|
+
# @return [void]
|
|
37
|
+
def select_previous(total)
|
|
38
|
+
@selected = (@selected - 1) % total if total > 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Table renders tabular data with headers, rows, and configurable column widths.
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# state = RubyTUI::Widgets::TableState.new
|
|
46
|
+
# table = RubyTUI::Widgets::Table.new(header: ["Name", "Size"],
|
|
47
|
+
# rows: [["a.txt", "1K"], ["b.txt", "2K"]],
|
|
48
|
+
# widths: [20, 8])
|
|
49
|
+
# frame.render_stateful_widget(table, area, state)
|
|
50
|
+
#
|
|
51
|
+
# @see TableState
|
|
52
|
+
class Table
|
|
53
|
+
include StatefulWidget
|
|
54
|
+
|
|
55
|
+
# @param header [Array<String, Span, #to_s>, nil] header cells, each converted to
|
|
56
|
+
# a {Span}; when set, a header row and a separator line are drawn (default: nil)
|
|
57
|
+
# @param rows [Array<Array<String, Span, #to_s>>] data rows (default: [])
|
|
58
|
+
# @param widths [Array<Integer>] column widths in cells; when empty, the available
|
|
59
|
+
# width is split equally across the columns of +header+, or of the first row
|
|
60
|
+
# (default: [])
|
|
61
|
+
# @param style [Style] base row style (default: {Style::DEFAULT})
|
|
62
|
+
# @param header_style [Style, nil] style of the header row and separator
|
|
63
|
+
# (default: bold cyan foreground)
|
|
64
|
+
# @param highlight_style [Style, nil] patched onto +style+ for the selected row
|
|
65
|
+
# (default: bold yellow foreground)
|
|
66
|
+
# @param highlight_symbol [String] prefix of the selected row; other rows and the
|
|
67
|
+
# header are prefixed with spaces of the same width (default: "> ")
|
|
68
|
+
# @param column_spacing [Integer] blank cells between columns (default: 1)
|
|
69
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
70
|
+
def initialize(
|
|
71
|
+
header: nil,
|
|
72
|
+
rows: [],
|
|
73
|
+
widths: [],
|
|
74
|
+
style: Style::DEFAULT,
|
|
75
|
+
header_style: nil,
|
|
76
|
+
highlight_style: nil,
|
|
77
|
+
highlight_symbol: "> ",
|
|
78
|
+
column_spacing: 1,
|
|
79
|
+
block: nil
|
|
80
|
+
)
|
|
81
|
+
@header = header&.map { |h| normalize_cell(h) }
|
|
82
|
+
@rows = rows.map { |row| row.map { |c| normalize_cell(c) } }
|
|
83
|
+
@widths = widths
|
|
84
|
+
@style = style
|
|
85
|
+
@header_style = header_style || Style.new.fg(Color::CYAN).bold
|
|
86
|
+
@highlight_style = highlight_style || Style.new.fg(Color::YELLOW).bold
|
|
87
|
+
@highlight_symbol = highlight_symbol
|
|
88
|
+
@highlight_symbol_width = Buffer.string_width(@highlight_symbol)
|
|
89
|
+
@normal_symbol = " " * @highlight_symbol_width
|
|
90
|
+
@column_spacing = column_spacing
|
|
91
|
+
@block = block
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Render the header, separator and visible rows.
|
|
95
|
+
#
|
|
96
|
+
# Clamps <tt>state.selected</tt> to the row range and adjusts
|
|
97
|
+
# <tt>state.offset</tt> in place so the selected row is visible. Cell text is
|
|
98
|
+
# truncated to its column width; explicit +widths+ are not reduced to fit the area.
|
|
99
|
+
#
|
|
100
|
+
# @param area [Rect] the rectangular area to render into
|
|
101
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
102
|
+
# @param state [TableState] selection and scroll state, updated in place
|
|
103
|
+
# @return [void]
|
|
104
|
+
def render(area, buf, state)
|
|
105
|
+
return if area.empty?
|
|
106
|
+
|
|
107
|
+
render_area = area
|
|
108
|
+
if @block
|
|
109
|
+
@block.render(area, buf)
|
|
110
|
+
render_area = @block.inner(area)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return if render_area.empty?
|
|
114
|
+
|
|
115
|
+
# Resolve column widths
|
|
116
|
+
col_widths = resolve_widths(render_area.width - @highlight_symbol_width)
|
|
117
|
+
return if col_widths.empty?
|
|
118
|
+
|
|
119
|
+
y = render_area.y
|
|
120
|
+
|
|
121
|
+
# Render header
|
|
122
|
+
if @header
|
|
123
|
+
render_row(@header, col_widths, render_area.x, y, buf, @header_style, @normal_symbol)
|
|
124
|
+
y += 1
|
|
125
|
+
|
|
126
|
+
# Header separator
|
|
127
|
+
if y < render_area.bottom
|
|
128
|
+
x = render_area.x + @highlight_symbol_width
|
|
129
|
+
col_widths.each_with_index do |w, i|
|
|
130
|
+
buf.set_string(x, y, "\u2500" * w, @header_style)
|
|
131
|
+
x += w + (i < col_widths.length - 1 ? @column_spacing : 0)
|
|
132
|
+
end
|
|
133
|
+
y += 1
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Available height for rows
|
|
138
|
+
visible_height = render_area.bottom - y
|
|
139
|
+
return if visible_height <= 0
|
|
140
|
+
|
|
141
|
+
# Clamp state
|
|
142
|
+
total = @rows.length
|
|
143
|
+
state.selected = state.selected.clamp(0, [total - 1, 0].max)
|
|
144
|
+
if state.selected < state.offset
|
|
145
|
+
state.offset = state.selected
|
|
146
|
+
elsif state.selected >= state.offset + visible_height
|
|
147
|
+
state.offset = state.selected - visible_height + 1
|
|
148
|
+
end
|
|
149
|
+
state.offset = state.offset.clamp(0, [total - visible_height, 0].max)
|
|
150
|
+
|
|
151
|
+
# Render visible rows
|
|
152
|
+
visible_rows = @rows[state.offset, visible_height] || []
|
|
153
|
+
visible_rows.each_with_index do |row, i|
|
|
154
|
+
break if y >= render_area.bottom
|
|
155
|
+
|
|
156
|
+
actual_index = state.offset + i
|
|
157
|
+
is_selected = actual_index == state.selected
|
|
158
|
+
row_style = is_selected ? @style.patch(@highlight_style) : @style
|
|
159
|
+
symbol = is_selected ? @highlight_symbol : @normal_symbol
|
|
160
|
+
|
|
161
|
+
render_row(row, col_widths, render_area.x, y, buf, row_style, symbol)
|
|
162
|
+
|
|
163
|
+
# Fill remaining width for highlight
|
|
164
|
+
if is_selected
|
|
165
|
+
row_end = render_area.x + @highlight_symbol_width
|
|
166
|
+
col_widths.each_with_index do |w, ci|
|
|
167
|
+
row_end += w + (ci < col_widths.length - 1 ? @column_spacing : 0)
|
|
168
|
+
end
|
|
169
|
+
if row_end < render_area.right
|
|
170
|
+
buf.set_string(row_end, y, " " * (render_area.right - row_end), row_style)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
y += 1
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
def normalize_cell(cell)
|
|
181
|
+
case cell
|
|
182
|
+
when Span then cell
|
|
183
|
+
when String then Span.new(cell)
|
|
184
|
+
else Span.new(cell.to_s)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def resolve_widths(available)
|
|
189
|
+
return @widths unless @widths.empty?
|
|
190
|
+
|
|
191
|
+
# Auto-calculate: equal widths based on column count
|
|
192
|
+
ncols = (@header&.length || @rows.first&.length || 0)
|
|
193
|
+
return [] if ncols == 0
|
|
194
|
+
|
|
195
|
+
total_spacing = @column_spacing * [ncols - 1, 0].max
|
|
196
|
+
usable = [available - total_spacing, 0].max
|
|
197
|
+
base = usable / ncols
|
|
198
|
+
remainder = usable % ncols
|
|
199
|
+
|
|
200
|
+
Array.new(ncols) { |i| i < remainder ? base + 1 : base }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def render_row(cells, col_widths, area_x, y, buf, row_style, symbol)
|
|
204
|
+
x = area_x
|
|
205
|
+
buf.set_string(x, y, symbol, row_style)
|
|
206
|
+
x += @highlight_symbol_width
|
|
207
|
+
|
|
208
|
+
cells.each_with_index do |cell, i|
|
|
209
|
+
break if i >= col_widths.length
|
|
210
|
+
|
|
211
|
+
w = col_widths[i]
|
|
212
|
+
text = Buffer.truncate_to_width(cell.content, w)
|
|
213
|
+
text_width = Buffer.string_width(text)
|
|
214
|
+
effective_style = row_style.patch(cell.style)
|
|
215
|
+
buf.set_string(x, y, text, effective_style)
|
|
216
|
+
|
|
217
|
+
# Pad remaining column width
|
|
218
|
+
pad = w - text_width
|
|
219
|
+
buf.set_string(x + text_width, y, " " * pad, row_style) if pad > 0
|
|
220
|
+
|
|
221
|
+
x += w
|
|
222
|
+
# Fill column spacing gap with row style
|
|
223
|
+
if i < col_widths.length - 1 && @column_spacing > 0
|
|
224
|
+
buf.set_string(x, y, " " * @column_spacing, row_style)
|
|
225
|
+
x += @column_spacing
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|