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,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Input
|
|
5
|
+
# Decodes raw terminal input into {KeyEvent} and {MouseEvent} objects.
|
|
6
|
+
#
|
|
7
|
+
# Recognised input: printable ASCII, Enter, Tab, Backspace, Ctrl+letter,
|
|
8
|
+
# multi-byte UTF-8 characters given as raw bytes (as read by {Reader}), ESC,
|
|
9
|
+
# Alt+character (ESC followed by one character),
|
|
10
|
+
# CSI and SS3 key sequences (arrows, Home/End, Insert/Delete, Page Up/Down,
|
|
11
|
+
# Back-tab, F1-F12, xterm modified arrows such as <tt>ESC [ 1 ; 5 A</tt>),
|
|
12
|
+
# and SGR and legacy mouse reports.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Input::Parser.new.parse("\e[A") # => KeyEvent(up)
|
|
16
|
+
class Parser
|
|
17
|
+
# Parse raw bytes into an Event.
|
|
18
|
+
#
|
|
19
|
+
# @param bytes [String, nil] raw bytes of one key press or mouse report
|
|
20
|
+
# @return [KeyEvent, MouseEvent, nil] the decoded event, or nil if +bytes+
|
|
21
|
+
# is nil, empty or not a recognised sequence
|
|
22
|
+
def parse(bytes)
|
|
23
|
+
return nil if bytes.nil? || bytes.empty?
|
|
24
|
+
|
|
25
|
+
# ESC sequence
|
|
26
|
+
if bytes.start_with?("\e")
|
|
27
|
+
parse_escape(bytes)
|
|
28
|
+
elsif bytes.length == 1
|
|
29
|
+
parse_single(bytes)
|
|
30
|
+
else
|
|
31
|
+
# Multi-byte UTF-8 character
|
|
32
|
+
KeyEvent.new(code: Key::CHAR, char: bytes)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parse_single(byte)
|
|
39
|
+
ord = byte.ord
|
|
40
|
+
|
|
41
|
+
case ord
|
|
42
|
+
when 13 # CR
|
|
43
|
+
KeyEvent.new(code: Key::ENTER)
|
|
44
|
+
when 9 # TAB
|
|
45
|
+
KeyEvent.new(code: Key::TAB)
|
|
46
|
+
when 127 # DEL (backspace on most terminals)
|
|
47
|
+
KeyEvent.new(code: Key::BACKSPACE)
|
|
48
|
+
when 8 # BS (backspace on some terminals)
|
|
49
|
+
KeyEvent.new(code: Key::BACKSPACE)
|
|
50
|
+
when 1..26
|
|
51
|
+
# Ctrl+A through Ctrl+Z
|
|
52
|
+
char = (ord + 96).chr # Convert to lowercase letter
|
|
53
|
+
KeyEvent.new(code: Key::CHAR, char: char, modifiers: Key::CTRL)
|
|
54
|
+
when 32..126
|
|
55
|
+
# Printable ASCII
|
|
56
|
+
KeyEvent.new(code: Key::CHAR, char: byte)
|
|
57
|
+
else
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_escape(bytes)
|
|
63
|
+
return KeyEvent.new(code: Key::ESCAPE) if bytes == "\e"
|
|
64
|
+
|
|
65
|
+
case bytes
|
|
66
|
+
# SGR mouse: ESC [ < Cb ; Cx ; Cy M/m
|
|
67
|
+
when /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
|
|
68
|
+
parse_sgr_mouse($1.to_i, $2.to_i, $3.to_i, $4)
|
|
69
|
+
# CSI sequences: ESC [ ...
|
|
70
|
+
when /\A\e\[([A-Za-z])\z/
|
|
71
|
+
parse_csi_letter($1)
|
|
72
|
+
when /\A\e\[(\d+)~\z/
|
|
73
|
+
parse_csi_tilde($1.to_i)
|
|
74
|
+
when /\A\e\[1;(\d+)([A-Za-z])\z/
|
|
75
|
+
parse_csi_modified($2, $1.to_i)
|
|
76
|
+
# Normal mouse: ESC [ M Cb Cx Cy (legacy, 3 raw bytes after M)
|
|
77
|
+
when /\A\e\[M(.{3})\z/m
|
|
78
|
+
parse_normal_mouse($1)
|
|
79
|
+
# SS3 sequences: ESC O ...
|
|
80
|
+
when /\A\eO([A-Za-z])\z/
|
|
81
|
+
parse_ss3($1)
|
|
82
|
+
# Alt+letter: ESC + letter
|
|
83
|
+
when /\A\e(.)\z/
|
|
84
|
+
KeyEvent.new(code: Key::CHAR, char: $1, modifiers: Key::ALT)
|
|
85
|
+
else
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# CSI single letter: ESC [ X
|
|
91
|
+
def parse_csi_letter(letter)
|
|
92
|
+
case letter
|
|
93
|
+
when "A" then KeyEvent.new(code: Key::UP)
|
|
94
|
+
when "B" then KeyEvent.new(code: Key::DOWN)
|
|
95
|
+
when "C" then KeyEvent.new(code: Key::RIGHT)
|
|
96
|
+
when "D" then KeyEvent.new(code: Key::LEFT)
|
|
97
|
+
when "H" then KeyEvent.new(code: Key::HOME)
|
|
98
|
+
when "F" then KeyEvent.new(code: Key::END_KEY)
|
|
99
|
+
when "Z" then KeyEvent.new(code: Key::BACKTAB)
|
|
100
|
+
else nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# CSI tilde sequences: ESC [ N ~
|
|
105
|
+
def parse_csi_tilde(n)
|
|
106
|
+
case n
|
|
107
|
+
when 2 then KeyEvent.new(code: Key::INSERT)
|
|
108
|
+
when 3 then KeyEvent.new(code: Key::DELETE)
|
|
109
|
+
when 5 then KeyEvent.new(code: Key::PAGE_UP)
|
|
110
|
+
when 6 then KeyEvent.new(code: Key::PAGE_DOWN)
|
|
111
|
+
when 15 then KeyEvent.new(code: Key::F5)
|
|
112
|
+
when 17 then KeyEvent.new(code: Key::F6)
|
|
113
|
+
when 18 then KeyEvent.new(code: Key::F7)
|
|
114
|
+
when 19 then KeyEvent.new(code: Key::F8)
|
|
115
|
+
when 20 then KeyEvent.new(code: Key::F9)
|
|
116
|
+
when 21 then KeyEvent.new(code: Key::F10)
|
|
117
|
+
when 23 then KeyEvent.new(code: Key::F11)
|
|
118
|
+
when 24 then KeyEvent.new(code: Key::F12)
|
|
119
|
+
else nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# CSI modified: ESC [ 1 ; MOD X (e.g., Shift+Up = ESC[1;2A)
|
|
124
|
+
def parse_csi_modified(letter, mod_code)
|
|
125
|
+
modifiers = decode_modifiers(mod_code)
|
|
126
|
+
base = parse_csi_letter(letter)
|
|
127
|
+
return nil unless base
|
|
128
|
+
|
|
129
|
+
KeyEvent.new(code: base.code, char: base.char, modifiers: modifiers)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# SS3 sequences: ESC O X (function keys on some terminals)
|
|
133
|
+
def parse_ss3(letter)
|
|
134
|
+
case letter
|
|
135
|
+
when "P" then KeyEvent.new(code: Key::F1)
|
|
136
|
+
when "Q" then KeyEvent.new(code: Key::F2)
|
|
137
|
+
when "R" then KeyEvent.new(code: Key::F3)
|
|
138
|
+
when "S" then KeyEvent.new(code: Key::F4)
|
|
139
|
+
when "H" then KeyEvent.new(code: Key::HOME)
|
|
140
|
+
when "F" then KeyEvent.new(code: Key::END_KEY)
|
|
141
|
+
else nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Decode xterm modifier parameter:
|
|
146
|
+
# 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, 7=Alt+Ctrl, 8=Shift+Alt+Ctrl
|
|
147
|
+
def decode_modifiers(code)
|
|
148
|
+
val = code - 1
|
|
149
|
+
mods = Key::NONE
|
|
150
|
+
mods |= Key::SHIFT if (val & 0b001) != 0
|
|
151
|
+
mods |= Key::ALT if (val & 0b010) != 0
|
|
152
|
+
mods |= Key::CTRL if (val & 0b100) != 0
|
|
153
|
+
mods
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# SGR extended mouse mode: ESC [ < Cb ; Cx ; Cy M (press) or m (release)
|
|
157
|
+
# Cb encodes button + modifiers + drag flag
|
|
158
|
+
def parse_sgr_mouse(cb, cx, cy, terminator)
|
|
159
|
+
# Coordinates are 1-based in SGR mode
|
|
160
|
+
x = cx - 1
|
|
161
|
+
y = cy - 1
|
|
162
|
+
|
|
163
|
+
# Decode modifiers from cb
|
|
164
|
+
mods = Key::NONE
|
|
165
|
+
mods |= Key::SHIFT if (cb & 4) != 0
|
|
166
|
+
mods |= Key::ALT if (cb & 8) != 0
|
|
167
|
+
mods |= Key::CTRL if (cb & 16) != 0
|
|
168
|
+
|
|
169
|
+
is_drag = (cb & 32) != 0
|
|
170
|
+
is_release = terminator == "m"
|
|
171
|
+
button_bits = cb & 3
|
|
172
|
+
is_scroll = (cb & 64) != 0
|
|
173
|
+
|
|
174
|
+
if is_scroll
|
|
175
|
+
button = button_bits == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
|
|
176
|
+
action = MouseEvent::PRESS
|
|
177
|
+
else
|
|
178
|
+
button = case button_bits
|
|
179
|
+
when 0 then MouseEvent::LEFT
|
|
180
|
+
when 1 then MouseEvent::MIDDLE
|
|
181
|
+
when 2 then MouseEvent::RIGHT
|
|
182
|
+
else MouseEvent::NONE
|
|
183
|
+
end
|
|
184
|
+
action = if is_release
|
|
185
|
+
MouseEvent::RELEASE
|
|
186
|
+
elsif is_drag
|
|
187
|
+
MouseEvent::DRAG
|
|
188
|
+
else
|
|
189
|
+
MouseEvent::PRESS
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
MouseEvent.new(button: button, action: action, x: x, y: y, modifiers: mods)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Legacy normal mouse: ESC [ M Cb Cx Cy (raw bytes, 32-based encoding)
|
|
197
|
+
def parse_normal_mouse(raw)
|
|
198
|
+
cb = raw.getbyte(0) - 32
|
|
199
|
+
x = raw.getbyte(1) - 33
|
|
200
|
+
y = raw.getbyte(2) - 33
|
|
201
|
+
|
|
202
|
+
mods = Key::NONE
|
|
203
|
+
mods |= Key::SHIFT if (cb & 4) != 0
|
|
204
|
+
mods |= Key::ALT if (cb & 8) != 0
|
|
205
|
+
mods |= Key::CTRL if (cb & 16) != 0
|
|
206
|
+
|
|
207
|
+
is_drag = (cb & 32) != 0
|
|
208
|
+
is_scroll = (cb & 64) != 0
|
|
209
|
+
button_bits = cb & 3
|
|
210
|
+
|
|
211
|
+
if is_scroll
|
|
212
|
+
button = button_bits == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
|
|
213
|
+
action = MouseEvent::PRESS
|
|
214
|
+
elsif button_bits == 3
|
|
215
|
+
button = MouseEvent::NONE
|
|
216
|
+
action = MouseEvent::RELEASE
|
|
217
|
+
else
|
|
218
|
+
button = case button_bits
|
|
219
|
+
when 0 then MouseEvent::LEFT
|
|
220
|
+
when 1 then MouseEvent::MIDDLE
|
|
221
|
+
when 2 then MouseEvent::RIGHT
|
|
222
|
+
else MouseEvent::NONE
|
|
223
|
+
end
|
|
224
|
+
action = is_drag ? MouseEvent::DRAG : MouseEvent::PRESS
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
MouseEvent.new(button: button, action: action, x: x, y: y, modifiers: mods)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module RubyTUI
|
|
6
|
+
module Input
|
|
7
|
+
# Reads terminal input without blocking and decodes it with a {Parser}.
|
|
8
|
+
#
|
|
9
|
+
# A lone ESC byte is told apart from the start of an escape sequence by
|
|
10
|
+
# waiting up to {ESC_TIMEOUT_MS} for more bytes.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# reader = Input::Reader.new
|
|
14
|
+
# event = reader.poll(timeout_ms: 50) # => KeyEvent, MouseEvent or nil
|
|
15
|
+
class Reader
|
|
16
|
+
# Timeout for disambiguating standalone ESC from escape sequences (ms)
|
|
17
|
+
ESC_TIMEOUT_MS = 50
|
|
18
|
+
|
|
19
|
+
# @param input [IO] input stream; must support <tt>IO.select</tt> and
|
|
20
|
+
# +read_nonblock+ (default: +$stdin+)
|
|
21
|
+
def initialize(input: $stdin)
|
|
22
|
+
@input = input
|
|
23
|
+
@parser = Parser.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Poll for an event with the given timeout.
|
|
27
|
+
#
|
|
28
|
+
# @param timeout_ms [Integer] maximum time to wait for input, in milliseconds
|
|
29
|
+
# (default: 100)
|
|
30
|
+
# @return [KeyEvent, MouseEvent, nil] the decoded event, or nil if no input
|
|
31
|
+
# arrived within the timeout or the bytes read were not recognised
|
|
32
|
+
def poll(timeout_ms: 100)
|
|
33
|
+
timeout_sec = timeout_ms / 1000.0
|
|
34
|
+
ready = IO.select([@input], nil, nil, timeout_sec)
|
|
35
|
+
return nil unless ready
|
|
36
|
+
|
|
37
|
+
read_event
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Blocking read - waits indefinitely for an event.
|
|
41
|
+
#
|
|
42
|
+
# Calls {#poll} in a loop until it returns an event.
|
|
43
|
+
#
|
|
44
|
+
# @return [KeyEvent, MouseEvent] the next recognised event
|
|
45
|
+
def read
|
|
46
|
+
loop do
|
|
47
|
+
event = poll(timeout_ms: 1000)
|
|
48
|
+
return event if event
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def read_event
|
|
55
|
+
# Read the first byte
|
|
56
|
+
first = read_byte
|
|
57
|
+
return nil unless first
|
|
58
|
+
|
|
59
|
+
if first == "\e"
|
|
60
|
+
# Could be standalone ESC or start of escape sequence.
|
|
61
|
+
# Wait briefly for more bytes.
|
|
62
|
+
if has_more_input?(ESC_TIMEOUT_MS)
|
|
63
|
+
bytes = first + read_available
|
|
64
|
+
@parser.parse(bytes)
|
|
65
|
+
else
|
|
66
|
+
@parser.parse("\e")
|
|
67
|
+
end
|
|
68
|
+
elsif first.ord < 128
|
|
69
|
+
@parser.parse(first)
|
|
70
|
+
else
|
|
71
|
+
# Multi-byte UTF-8: read continuation bytes
|
|
72
|
+
bytes = read_utf8(first)
|
|
73
|
+
@parser.parse(bytes)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_byte
|
|
78
|
+
@input.read_nonblock(1)
|
|
79
|
+
rescue IO::WaitReadable, EOFError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def read_available
|
|
84
|
+
buf = String.new
|
|
85
|
+
loop do
|
|
86
|
+
byte = @input.read_nonblock(1)
|
|
87
|
+
buf << byte
|
|
88
|
+
rescue IO::WaitReadable, EOFError
|
|
89
|
+
break
|
|
90
|
+
end
|
|
91
|
+
buf
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def has_more_input?(timeout_ms)
|
|
95
|
+
ready = IO.select([@input], nil, nil, timeout_ms / 1000.0)
|
|
96
|
+
!ready.nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Read remaining bytes of a UTF-8 multibyte character
|
|
100
|
+
def read_utf8(first_byte)
|
|
101
|
+
ord = first_byte.ord
|
|
102
|
+
remaining = if ord >= 0xF0 then 3
|
|
103
|
+
elsif ord >= 0xE0 then 2
|
|
104
|
+
elsif ord >= 0xC0 then 1
|
|
105
|
+
else 0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
bytes = first_byte
|
|
109
|
+
remaining.times do
|
|
110
|
+
b = read_byte
|
|
111
|
+
break unless b
|
|
112
|
+
|
|
113
|
+
bytes << b
|
|
114
|
+
end
|
|
115
|
+
bytes
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Layout
|
|
5
|
+
# A sizing rule for one segment of a {Layout} split.
|
|
6
|
+
#
|
|
7
|
+
# Create instances with the factory methods; {Layout#split} resolves them.
|
|
8
|
+
# Fixed constraints ({.length}, {.percentage}, {.ratio}) are allocated
|
|
9
|
+
# first, then the remaining space is shared among {.fill}, {.min} and
|
|
10
|
+
# {.max} constraints.
|
|
11
|
+
#
|
|
12
|
+
# @example Header, body and status line
|
|
13
|
+
# layout = Layout::Layout.vertical([
|
|
14
|
+
# Layout::Constraint.length(3),
|
|
15
|
+
# Layout::Constraint.fill,
|
|
16
|
+
# Layout::Constraint.length(1)
|
|
17
|
+
# ])
|
|
18
|
+
# header, body, status = layout.split(area)
|
|
19
|
+
class Constraint
|
|
20
|
+
attr_reader :kind, :value
|
|
21
|
+
|
|
22
|
+
# @param kind [Symbol] +:length+, +:percentage+, +:min+, +:max+, +:fill+ or +:ratio+
|
|
23
|
+
# @param value [Integer, Array(Integer, Integer)] size parameter; a
|
|
24
|
+
# <tt>[num, den]</tt> pair for +:ratio+
|
|
25
|
+
def initialize(kind, value)
|
|
26
|
+
@kind = kind
|
|
27
|
+
@value = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Factory methods
|
|
31
|
+
class << self
|
|
32
|
+
# Fixed size, reduced if less space remains.
|
|
33
|
+
#
|
|
34
|
+
# @param n [Integer] size in cells
|
|
35
|
+
# @return [Constraint]
|
|
36
|
+
def length(n) = new(:length, n)
|
|
37
|
+
# Percentage of the available space, rounded; reduced if less space remains.
|
|
38
|
+
#
|
|
39
|
+
# @param n [Numeric] percentage (0-100)
|
|
40
|
+
# @return [Constraint]
|
|
41
|
+
def percentage(n) = new(:percentage, n)
|
|
42
|
+
# At least +n+ cells when space allows, plus a weight-1 share of the
|
|
43
|
+
# space left after fixed constraints.
|
|
44
|
+
#
|
|
45
|
+
# @param n [Integer] minimum size in cells
|
|
46
|
+
# @return [Constraint]
|
|
47
|
+
def min(n) = new(:min, n)
|
|
48
|
+
# A weight-1 share of the space left after fixed constraints, capped
|
|
49
|
+
# at +n+ cells.
|
|
50
|
+
#
|
|
51
|
+
# @param n [Integer] maximum size in cells
|
|
52
|
+
# @return [Constraint]
|
|
53
|
+
def max(n) = new(:max, n)
|
|
54
|
+
# A share of the space left after fixed constraints, proportional to
|
|
55
|
+
# +weight+ (+min+ and +max+ constraints count as weight 1).
|
|
56
|
+
#
|
|
57
|
+
# @param weight [Integer] relative weight (default: 1)
|
|
58
|
+
# @return [Constraint]
|
|
59
|
+
def fill(weight = 1) = new(:fill, weight)
|
|
60
|
+
# Fraction <tt>num / den</tt> of the available space, rounded; reduced if
|
|
61
|
+
# less space remains.
|
|
62
|
+
#
|
|
63
|
+
# @param num [Integer] numerator
|
|
64
|
+
# @param den [Integer] denominator
|
|
65
|
+
# @return [Constraint]
|
|
66
|
+
def ratio(num, den) = new(:ratio, [num, den].freeze)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Value equality: same kind and value.
|
|
70
|
+
#
|
|
71
|
+
# @param other [Object] object to compare
|
|
72
|
+
# @return [Boolean] true if +other+ is a Constraint with equal {#kind} and {#value}
|
|
73
|
+
def ==(other)
|
|
74
|
+
other.is_a?(Constraint) && @kind == other.kind && @value == other.value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [String] human-readable form, e.g. <tt>Constraint(length: 3)</tt>
|
|
78
|
+
def to_s = "Constraint(#{@kind}: #{@value})"
|
|
79
|
+
# @return [String] same as {#to_s}
|
|
80
|
+
def inspect = to_s
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Layout
|
|
5
|
+
# Flex modes control how spare space is distributed when constraints
|
|
6
|
+
# don't perfectly consume the available area.
|
|
7
|
+
module Flex
|
|
8
|
+
START = :start # Pack items to start, spare space at end
|
|
9
|
+
END_POS = :end # Pack items to end, spare space at start
|
|
10
|
+
CENTER = :center # Center items, spare space split around edges
|
|
11
|
+
SPACE_BETWEEN = :space_between # Distribute spare space between items
|
|
12
|
+
SPACE_AROUND = :space_around # Distribute spare space around items
|
|
13
|
+
STRETCH = :stretch # Stretch fill constraints to consume all space (default)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# Constraint-based layout: {Layout::Layout} splits a {Rect} into rows or
|
|
5
|
+
# columns sized by {Constraint} objects and positioned by a {Flex} mode.
|
|
6
|
+
module Layout
|
|
7
|
+
# Splits a {Rect} along one axis into sub-rects sized by {Constraint}
|
|
8
|
+
# objects, with optional spacing between them.
|
|
9
|
+
#
|
|
10
|
+
# @example Sidebar and main area
|
|
11
|
+
# sidebar, main = Layout::Layout.horizontal(
|
|
12
|
+
# [Layout::Constraint.length(20), Layout::Constraint.fill]
|
|
13
|
+
# ).split(area)
|
|
14
|
+
class Layout
|
|
15
|
+
# Split along the x axis into side-by-side columns.
|
|
16
|
+
HORIZONTAL = :horizontal
|
|
17
|
+
# Split along the y axis into stacked rows.
|
|
18
|
+
VERTICAL = :vertical
|
|
19
|
+
|
|
20
|
+
attr_reader :direction, :constraints, :spacing, :flex
|
|
21
|
+
|
|
22
|
+
# @param direction [Symbol] {HORIZONTAL} or {VERTICAL}
|
|
23
|
+
# @param constraints [Array<Constraint>] one constraint per segment
|
|
24
|
+
# @param spacing [Integer] gap in cells between adjacent segments (default: 0)
|
|
25
|
+
# @param flex [Symbol] {Flex} mode for placing spare space (default: {Flex::STRETCH})
|
|
26
|
+
def initialize(direction:, constraints:, spacing: 0, flex: Flex::STRETCH)
|
|
27
|
+
@direction = direction
|
|
28
|
+
@constraints = constraints
|
|
29
|
+
@spacing = spacing
|
|
30
|
+
@flex = flex
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Build a {VERTICAL} layout (segments stacked top to bottom).
|
|
35
|
+
#
|
|
36
|
+
# @param constraints [Array<Constraint>] one constraint per segment
|
|
37
|
+
# @param spacing [Integer] gap in rows between adjacent segments (default: 0)
|
|
38
|
+
# @param flex [Symbol] {Flex} mode for placing spare space (default: {Flex::STRETCH})
|
|
39
|
+
# @return [Layout]
|
|
40
|
+
def vertical(constraints, spacing: 0, flex: Flex::STRETCH)
|
|
41
|
+
new(direction: VERTICAL, constraints: constraints, spacing: spacing, flex: flex)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Build a {HORIZONTAL} layout (segments placed left to right).
|
|
45
|
+
#
|
|
46
|
+
# @param constraints [Array<Constraint>] one constraint per segment
|
|
47
|
+
# @param spacing [Integer] gap in columns between adjacent segments (default: 0)
|
|
48
|
+
# @param flex [Symbol] {Flex} mode for placing spare space (default: {Flex::STRETCH})
|
|
49
|
+
# @return [Layout]
|
|
50
|
+
def horizontal(constraints, spacing: 0, flex: Flex::STRETCH)
|
|
51
|
+
new(direction: HORIZONTAL, constraints: constraints, spacing: spacing, flex: flex)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Split a Rect into sub-Rects according to constraints.
|
|
56
|
+
#
|
|
57
|
+
# Sizes are solved along {#direction} over the area's length minus the
|
|
58
|
+
# total spacing; the other dimension is taken from +area+. Space left
|
|
59
|
+
# unallocated is placed according to {#flex}.
|
|
60
|
+
#
|
|
61
|
+
# @param area [Rect] area to split
|
|
62
|
+
# @return [Array<Rect>] one Rect per constraint, in order (empty if there
|
|
63
|
+
# are no constraints)
|
|
64
|
+
def split(area)
|
|
65
|
+
return [] if @constraints.empty?
|
|
66
|
+
|
|
67
|
+
total = @direction == HORIZONTAL ? area.width : area.height
|
|
68
|
+
total_spacing = @spacing * [@constraints.length - 1, 0].max
|
|
69
|
+
available = [total - total_spacing, 0].max
|
|
70
|
+
|
|
71
|
+
sizes = solve(available)
|
|
72
|
+
allocated = sizes.sum + total_spacing
|
|
73
|
+
spare = [total - allocated, 0].max
|
|
74
|
+
|
|
75
|
+
# Apply flex positioning
|
|
76
|
+
build_rects(area, sizes, spare)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def build_rects(area, sizes, spare)
|
|
82
|
+
n = sizes.length
|
|
83
|
+
rects = []
|
|
84
|
+
|
|
85
|
+
# Calculate gaps based on flex mode
|
|
86
|
+
leading, between, trailing = compute_flex_gaps(spare, n)
|
|
87
|
+
|
|
88
|
+
offset = leading
|
|
89
|
+
sizes.each_with_index do |size, i|
|
|
90
|
+
rect = if @direction == HORIZONTAL
|
|
91
|
+
Rect.new(x: area.x + offset, y: area.y, width: size, height: area.height)
|
|
92
|
+
else
|
|
93
|
+
Rect.new(x: area.x, y: area.y + offset, width: area.width, height: size)
|
|
94
|
+
end
|
|
95
|
+
rects << rect
|
|
96
|
+
offset += size + @spacing + (i < n - 1 ? between : 0)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
rects
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns [leading_gap, between_gap, trailing_gap]
|
|
103
|
+
def compute_flex_gaps(spare, n)
|
|
104
|
+
return [0, 0, 0] if spare <= 0 || n <= 0
|
|
105
|
+
|
|
106
|
+
case @flex
|
|
107
|
+
when Flex::START, Flex::STRETCH
|
|
108
|
+
[0, 0, spare]
|
|
109
|
+
when Flex::END_POS
|
|
110
|
+
[spare, 0, 0]
|
|
111
|
+
when Flex::CENTER
|
|
112
|
+
half = spare / 2
|
|
113
|
+
[half, 0, spare - half]
|
|
114
|
+
when Flex::SPACE_BETWEEN
|
|
115
|
+
if n <= 1
|
|
116
|
+
[0, 0, spare]
|
|
117
|
+
else
|
|
118
|
+
gap = spare / (n - 1)
|
|
119
|
+
remainder = spare - gap * (n - 1)
|
|
120
|
+
[0, gap, -remainder] # trailing absorbs rounding
|
|
121
|
+
end
|
|
122
|
+
when Flex::SPACE_AROUND
|
|
123
|
+
if n == 0
|
|
124
|
+
[spare, 0, 0]
|
|
125
|
+
else
|
|
126
|
+
# n items create n+1 gaps (before first, between each, after last)
|
|
127
|
+
# but space_around puts equal space on each side of each item
|
|
128
|
+
gap = spare / (n * 2)
|
|
129
|
+
remainder = spare - gap * n * 2
|
|
130
|
+
[gap + remainder / 2, gap * 2, gap + (remainder + 1) / 2]
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
[0, 0, spare]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Two-pass constraint solver:
|
|
138
|
+
# Pass 1: Resolve fixed constraints (Length, Percentage, Ratio)
|
|
139
|
+
# Pass 2: Distribute remaining space to Fill, apply Min/Max clamping
|
|
140
|
+
def solve(total)
|
|
141
|
+
n = @constraints.length
|
|
142
|
+
sizes = Array.new(n, 0)
|
|
143
|
+
remaining = total
|
|
144
|
+
fill_indices = []
|
|
145
|
+
|
|
146
|
+
# Pass 1: Fixed allocations
|
|
147
|
+
@constraints.each_with_index do |c, i|
|
|
148
|
+
case c.kind
|
|
149
|
+
when :length
|
|
150
|
+
sizes[i] = [c.value, remaining].min
|
|
151
|
+
remaining -= sizes[i]
|
|
152
|
+
when :percentage
|
|
153
|
+
sizes[i] = [(total * c.value / 100.0).round, remaining].min
|
|
154
|
+
remaining -= sizes[i]
|
|
155
|
+
when :ratio
|
|
156
|
+
num, den = c.value
|
|
157
|
+
sizes[i] = [(total * num / den.to_f).round, remaining].min
|
|
158
|
+
remaining -= sizes[i]
|
|
159
|
+
when :min
|
|
160
|
+
sizes[i] = [c.value, remaining].min
|
|
161
|
+
remaining -= sizes[i]
|
|
162
|
+
fill_indices << i
|
|
163
|
+
when :max
|
|
164
|
+
fill_indices << i
|
|
165
|
+
when :fill
|
|
166
|
+
fill_indices << i
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Pass 2: Distribute remaining space among fill/min/max constraints
|
|
171
|
+
if fill_indices.any? && remaining > 0
|
|
172
|
+
total_weight = fill_indices.sum do |i|
|
|
173
|
+
c = @constraints[i]
|
|
174
|
+
c.kind == :fill ? c.value : 1
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
fill_indices.each do |i|
|
|
178
|
+
c = @constraints[i]
|
|
179
|
+
weight = c.kind == :fill ? c.value : 1
|
|
180
|
+
share = (remaining * weight / total_weight.to_f).round
|
|
181
|
+
|
|
182
|
+
case c.kind
|
|
183
|
+
when :fill
|
|
184
|
+
sizes[i] = share
|
|
185
|
+
when :min
|
|
186
|
+
sizes[i] = [sizes[i] + share, c.value].max
|
|
187
|
+
when :max
|
|
188
|
+
sizes[i] = [share, c.value].min
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Adjust rounding errors
|
|
193
|
+
allocated = sizes.sum
|
|
194
|
+
diff = total - allocated
|
|
195
|
+
if diff != 0 && fill_indices.any?
|
|
196
|
+
sizes[fill_indices.last] += diff
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
sizes.map! { |s| [s, 0].max }
|
|
201
|
+
sizes
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|