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,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# InputState holds the mutable state for the InputField widget.
|
|
6
|
+
#
|
|
7
|
+
# +cursor+ is a character index into +text+. +scroll_offset+ is the index of
|
|
8
|
+
# the first visible character and is updated by {InputField#render}.
|
|
9
|
+
#
|
|
10
|
+
# @see InputField
|
|
11
|
+
class InputState
|
|
12
|
+
attr_accessor :text, :cursor, :scroll_offset
|
|
13
|
+
|
|
14
|
+
# @param text [String] initial text; a mutable copy is stored (default: "")
|
|
15
|
+
# @param cursor [Integer] initial cursor character index, not clamped to the
|
|
16
|
+
# text length (default: 0)
|
|
17
|
+
def initialize(text: "", cursor: 0)
|
|
18
|
+
@text = String.new(text) # ensure mutable copy
|
|
19
|
+
@cursor = cursor
|
|
20
|
+
@scroll_offset = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Insert a character at the cursor position
|
|
24
|
+
#
|
|
25
|
+
# @param char [String] character (or string) to insert; the cursor advances by its length
|
|
26
|
+
# @return [void]
|
|
27
|
+
def insert(char)
|
|
28
|
+
@text.insert(@cursor, char)
|
|
29
|
+
@cursor += char.length
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Delete character before cursor (backspace)
|
|
33
|
+
#
|
|
34
|
+
# Does nothing when the cursor is at the start.
|
|
35
|
+
#
|
|
36
|
+
# @return [void]
|
|
37
|
+
def backspace
|
|
38
|
+
return if @cursor == 0
|
|
39
|
+
|
|
40
|
+
@text.slice!(@cursor - 1)
|
|
41
|
+
@cursor -= 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Delete character at cursor (delete key)
|
|
45
|
+
#
|
|
46
|
+
# Does nothing when the cursor is at the end.
|
|
47
|
+
#
|
|
48
|
+
# @return [void]
|
|
49
|
+
def delete
|
|
50
|
+
return if @cursor >= @text.length
|
|
51
|
+
|
|
52
|
+
@text.slice!(@cursor)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Move cursor left
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
def move_left
|
|
59
|
+
@cursor = [@cursor - 1, 0].max
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Move cursor right
|
|
63
|
+
#
|
|
64
|
+
# @return [void]
|
|
65
|
+
def move_right
|
|
66
|
+
@cursor = [@cursor + 1, @text.length].min
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Move cursor to start
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
def home
|
|
73
|
+
@cursor = 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Move cursor to end
|
|
77
|
+
#
|
|
78
|
+
# @return [void]
|
|
79
|
+
def end_pos
|
|
80
|
+
@cursor = @text.length
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Delete from cursor to end of line
|
|
84
|
+
#
|
|
85
|
+
# The cursor does not move.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
def kill_to_end
|
|
89
|
+
@text.slice!(@cursor..)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Delete from start to cursor
|
|
93
|
+
#
|
|
94
|
+
# The cursor moves to the start.
|
|
95
|
+
#
|
|
96
|
+
# @return [void]
|
|
97
|
+
def kill_to_start
|
|
98
|
+
@text.slice!(0...@cursor)
|
|
99
|
+
@cursor = 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Delete the word before the cursor
|
|
103
|
+
#
|
|
104
|
+
# Deletes any spaces directly before the cursor and the run of non-space
|
|
105
|
+
# characters before them. Only the space character counts as a separator.
|
|
106
|
+
# Does nothing when the cursor is at the start.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def delete_word_back
|
|
110
|
+
return if @cursor == 0
|
|
111
|
+
|
|
112
|
+
# Find start of previous word
|
|
113
|
+
pos = @cursor - 1
|
|
114
|
+
pos -= 1 while pos > 0 && @text[pos] == " "
|
|
115
|
+
pos -= 1 while pos > 0 && @text[pos] != " "
|
|
116
|
+
pos += 1 if pos > 0
|
|
117
|
+
|
|
118
|
+
@text.slice!(pos...@cursor)
|
|
119
|
+
@cursor = pos
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Handle a key event, returns true if the event was consumed
|
|
123
|
+
#
|
|
124
|
+
# Character keys without Ctrl insert <tt>event.char</tt>. Backspace, Delete,
|
|
125
|
+
# Left, Right, Home and End edit or move. Ctrl-A / Ctrl-E move to start / end,
|
|
126
|
+
# Ctrl-K / Ctrl-U delete to end / start, Ctrl-W deletes the previous word.
|
|
127
|
+
# Other Ctrl combinations, other keys and non-{KeyEvent} events are not consumed.
|
|
128
|
+
#
|
|
129
|
+
# @param event [Event] the input event
|
|
130
|
+
# @return [Boolean] true if the event was consumed
|
|
131
|
+
def handle_key(event)
|
|
132
|
+
return false unless event.is_a?(KeyEvent)
|
|
133
|
+
|
|
134
|
+
case event.code
|
|
135
|
+
when Input::Key::CHAR
|
|
136
|
+
if event.ctrl?
|
|
137
|
+
case event.char
|
|
138
|
+
when "a" then home; true
|
|
139
|
+
when "e" then end_pos; true
|
|
140
|
+
when "k" then kill_to_end; true
|
|
141
|
+
when "u" then kill_to_start; true
|
|
142
|
+
when "w" then delete_word_back; true
|
|
143
|
+
else false
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
insert(event.char) if event.char
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
when Input::Key::BACKSPACE then backspace; true
|
|
150
|
+
when Input::Key::DELETE then delete; true
|
|
151
|
+
when Input::Key::LEFT then move_left; true
|
|
152
|
+
when Input::Key::RIGHT then move_right; true
|
|
153
|
+
when Input::Key::HOME then home; true
|
|
154
|
+
when Input::Key::END_KEY then end_pos; true
|
|
155
|
+
else
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# InputField renders a single-line text input with cursor.
|
|
162
|
+
#
|
|
163
|
+
# @example
|
|
164
|
+
# state = RubyTUI::Widgets::InputState.new
|
|
165
|
+
# state.handle_key(event) # in the event loop
|
|
166
|
+
# field = RubyTUI::Widgets::InputField.new(placeholder: "Type here...")
|
|
167
|
+
# frame.render_stateful_widget(field, area, state)
|
|
168
|
+
#
|
|
169
|
+
# @see InputState
|
|
170
|
+
class InputField
|
|
171
|
+
include StatefulWidget
|
|
172
|
+
|
|
173
|
+
# @param style [Style] text style (default: {Style::DEFAULT})
|
|
174
|
+
# @param cursor_style [Style, nil] style of the cell under the cursor
|
|
175
|
+
# (default: reversed video)
|
|
176
|
+
# @param placeholder [String] text shown while the input is empty (default: "")
|
|
177
|
+
# @param placeholder_style [Style, nil] placeholder style (default: bright black foreground)
|
|
178
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
179
|
+
def initialize(
|
|
180
|
+
style: Style::DEFAULT,
|
|
181
|
+
cursor_style: nil,
|
|
182
|
+
placeholder: "",
|
|
183
|
+
placeholder_style: nil,
|
|
184
|
+
block: nil
|
|
185
|
+
)
|
|
186
|
+
@style = style
|
|
187
|
+
@cursor_style = cursor_style || Style.new.reversed
|
|
188
|
+
@placeholder = placeholder
|
|
189
|
+
@placeholder_style = placeholder_style || Style.new.fg(Color::BRIGHT_BLACK)
|
|
190
|
+
@block = block
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Render the visible part of the text, or the placeholder, on the first row of
|
|
194
|
+
# the area and apply +cursor_style+ to the cell at the cursor.
|
|
195
|
+
#
|
|
196
|
+
# Updates <tt>state.scroll_offset</tt> in place so the cursor stays visible.
|
|
197
|
+
# Scrolling and cursor placement count characters, not display columns.
|
|
198
|
+
#
|
|
199
|
+
# @param area [Rect] the rectangular area to render into
|
|
200
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
201
|
+
# @param state [InputState] text, cursor and scroll state
|
|
202
|
+
# @return [void]
|
|
203
|
+
def render(area, buf, state)
|
|
204
|
+
return if area.empty?
|
|
205
|
+
|
|
206
|
+
render_area = area
|
|
207
|
+
if @block
|
|
208
|
+
@block.render(area, buf)
|
|
209
|
+
render_area = @block.inner(area)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return if render_area.empty?
|
|
213
|
+
|
|
214
|
+
width = render_area.width
|
|
215
|
+
|
|
216
|
+
# Adjust scroll offset to keep cursor visible
|
|
217
|
+
if state.cursor < state.scroll_offset
|
|
218
|
+
state.scroll_offset = state.cursor
|
|
219
|
+
elsif state.cursor >= state.scroll_offset + width
|
|
220
|
+
state.scroll_offset = state.cursor - width + 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if state.text.empty?
|
|
224
|
+
# Show placeholder
|
|
225
|
+
visible = @placeholder[0, width]
|
|
226
|
+
buf.set_string(render_area.x, render_area.y, visible, @placeholder_style)
|
|
227
|
+
else
|
|
228
|
+
# Show text with scrolling
|
|
229
|
+
visible = state.text[state.scroll_offset, width] || ""
|
|
230
|
+
buf.set_string(render_area.x, render_area.y, visible, @style)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Draw cursor
|
|
234
|
+
cursor_screen_x = render_area.x + state.cursor - state.scroll_offset
|
|
235
|
+
if cursor_screen_x >= render_area.x && cursor_screen_x < render_area.right
|
|
236
|
+
cell = buf[cursor_screen_x, render_area.y]
|
|
237
|
+
if cell
|
|
238
|
+
char = cell.symbol == " " && state.cursor >= state.text.length ? " " : cell.symbol
|
|
239
|
+
cell.set(char, @cursor_style)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# ListState holds external state for the List widget.
|
|
6
|
+
#
|
|
7
|
+
# +selected+ is the index of the selected item and +offset+ the index of the
|
|
8
|
+
# first visible item. {List#render} clamps both in place.
|
|
9
|
+
#
|
|
10
|
+
# @see List
|
|
11
|
+
class ListState
|
|
12
|
+
attr_accessor :selected, :offset
|
|
13
|
+
|
|
14
|
+
# @param selected [Integer] initial selected 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 item, wrapping from the last item to the first.
|
|
22
|
+
#
|
|
23
|
+
# Does nothing when +total+ is 0 or negative.
|
|
24
|
+
#
|
|
25
|
+
# @param total [Integer] number of items in the list
|
|
26
|
+
# @return [void]
|
|
27
|
+
def select_next(total)
|
|
28
|
+
@selected = (@selected + 1) % total if total > 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Select the previous item, wrapping from the first item to the last.
|
|
32
|
+
#
|
|
33
|
+
# Does nothing when +total+ is 0 or negative.
|
|
34
|
+
#
|
|
35
|
+
# @param total [Integer] number of items in the list
|
|
36
|
+
# @return [void]
|
|
37
|
+
def select_previous(total)
|
|
38
|
+
@selected = (@selected - 1) % total if total > 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Select the first item and reset the scroll offset to 0.
|
|
42
|
+
#
|
|
43
|
+
# @return [void]
|
|
44
|
+
def select_first
|
|
45
|
+
@selected = 0
|
|
46
|
+
@offset = 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Select the last item.
|
|
50
|
+
#
|
|
51
|
+
# The offset is not changed; {List#render} scrolls to keep the selection visible.
|
|
52
|
+
#
|
|
53
|
+
# @param total [Integer] number of items in the list
|
|
54
|
+
# @return [void]
|
|
55
|
+
def select_last(total)
|
|
56
|
+
@selected = [total - 1, 0].max
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# List renders a scrollable, selectable list of items.
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# state = RubyTUI::Widgets::ListState.new
|
|
64
|
+
# list = RubyTUI::Widgets::List.new(items: ["One", "Two", "Three"])
|
|
65
|
+
# frame.render_stateful_widget(list, area, state)
|
|
66
|
+
# state.select_next(list.items.length) # e.g. on a Down key press
|
|
67
|
+
#
|
|
68
|
+
# @see ListState
|
|
69
|
+
class List
|
|
70
|
+
include StatefulWidget
|
|
71
|
+
|
|
72
|
+
attr_reader :items
|
|
73
|
+
|
|
74
|
+
# @param items [Array<String, Span, Line, #to_s>] list items, each converted to a
|
|
75
|
+
# {Line} (default: [])
|
|
76
|
+
# @param style [Style] base style of every item (default: {Style::DEFAULT})
|
|
77
|
+
# @param highlight_style [Style, nil] patched onto +style+ for the selected item
|
|
78
|
+
# (default: bold yellow foreground)
|
|
79
|
+
# @param highlight_symbol [String] prefix of the selected item; other items are
|
|
80
|
+
# prefixed with spaces of the same width (default: "> ")
|
|
81
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
82
|
+
def initialize(
|
|
83
|
+
items: [],
|
|
84
|
+
style: Style::DEFAULT,
|
|
85
|
+
highlight_style: nil,
|
|
86
|
+
highlight_symbol: "> ",
|
|
87
|
+
block: nil
|
|
88
|
+
)
|
|
89
|
+
@items = items.map { |item| normalize_item(item) }
|
|
90
|
+
@style = style
|
|
91
|
+
@highlight_style = highlight_style || Style.new.fg(Color::YELLOW).bold
|
|
92
|
+
@highlight_symbol = highlight_symbol
|
|
93
|
+
@highlight_symbol_width = Buffer.string_width(@highlight_symbol)
|
|
94
|
+
@normal_symbol = " " * @highlight_symbol_width
|
|
95
|
+
@block = block
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Render the visible items, one per row, marking the selected item with
|
|
99
|
+
# +highlight_symbol+ and +highlight_style+.
|
|
100
|
+
#
|
|
101
|
+
# Clamps <tt>state.selected</tt> to the item range and adjusts
|
|
102
|
+
# <tt>state.offset</tt> in place so the selected item is visible. Item text is
|
|
103
|
+
# truncated to the area width; span styles are patched onto the item style.
|
|
104
|
+
#
|
|
105
|
+
# @param area [Rect] the rectangular area to render into
|
|
106
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
107
|
+
# @param state [ListState] selection and scroll state, updated in place
|
|
108
|
+
# @return [void]
|
|
109
|
+
def render(area, buf, state)
|
|
110
|
+
return if area.empty?
|
|
111
|
+
|
|
112
|
+
render_area = area
|
|
113
|
+
if @block
|
|
114
|
+
@block.render(area, buf)
|
|
115
|
+
render_area = @block.inner(area)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
return if render_area.empty? || @items.empty?
|
|
119
|
+
|
|
120
|
+
# Adjust scroll offset to keep selected item visible
|
|
121
|
+
visible_height = render_area.height
|
|
122
|
+
clamp_state(state, visible_height)
|
|
123
|
+
|
|
124
|
+
# Render visible items
|
|
125
|
+
visible_items = @items[state.offset, visible_height] || []
|
|
126
|
+
visible_items.each_with_index do |item, i|
|
|
127
|
+
y = render_area.y + i
|
|
128
|
+
actual_index = state.offset + i
|
|
129
|
+
is_selected = actual_index == state.selected
|
|
130
|
+
|
|
131
|
+
symbol = is_selected ? @highlight_symbol : @normal_symbol
|
|
132
|
+
item_style = is_selected ? @style.patch(@highlight_style) : @style
|
|
133
|
+
|
|
134
|
+
# Write selection symbol
|
|
135
|
+
x = render_area.x
|
|
136
|
+
buf.set_string(x, y, symbol, item_style)
|
|
137
|
+
x += @highlight_symbol_width
|
|
138
|
+
|
|
139
|
+
# Write item text
|
|
140
|
+
available = render_area.width - @highlight_symbol_width
|
|
141
|
+
item.spans.each do |span|
|
|
142
|
+
break if available <= 0
|
|
143
|
+
|
|
144
|
+
text = Buffer.truncate_to_width(span.content, available)
|
|
145
|
+
text_width = Buffer.string_width(text)
|
|
146
|
+
effective_style = item_style.patch(span.style)
|
|
147
|
+
buf.set_string(x, y, text, effective_style)
|
|
148
|
+
x += text_width
|
|
149
|
+
available -= text_width
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Fill remaining width with style (for highlight background)
|
|
153
|
+
if is_selected && x < render_area.right
|
|
154
|
+
remaining = render_area.right - x
|
|
155
|
+
buf.set_string(x, y, " " * remaining, item_style)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def normalize_item(item)
|
|
163
|
+
case item
|
|
164
|
+
when Line then item
|
|
165
|
+
when Span then Line.new([item])
|
|
166
|
+
when String then Line.from(item)
|
|
167
|
+
else Line.from(item.to_s)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def clamp_state(state, visible_height)
|
|
172
|
+
total = @items.length
|
|
173
|
+
state.selected = state.selected.clamp(0, [total - 1, 0].max)
|
|
174
|
+
|
|
175
|
+
# Scroll to keep selected visible
|
|
176
|
+
if state.selected < state.offset
|
|
177
|
+
state.offset = state.selected
|
|
178
|
+
elsif state.selected >= state.offset + visible_height
|
|
179
|
+
state.offset = state.selected - visible_height + 1
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
state.offset = state.offset.clamp(0, [total - visible_height, 0].max)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
module Widgets
|
|
5
|
+
# Paragraph renders styled text with wrapping and alignment.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# para = RubyTUI::Widgets::Paragraph.new(text: "Hello\nWorld", alignment: :center)
|
|
9
|
+
# frame.render_widget(para, area)
|
|
10
|
+
class Paragraph
|
|
11
|
+
include Widget
|
|
12
|
+
|
|
13
|
+
# Wrap at whitespace; a single word wider than the area is not broken.
|
|
14
|
+
WRAP_WORD = :word
|
|
15
|
+
# Wrap at any character.
|
|
16
|
+
WRAP_CHAR = :char
|
|
17
|
+
# Do not wrap.
|
|
18
|
+
WRAP_NONE = :none
|
|
19
|
+
|
|
20
|
+
attr_reader :text, :style, :alignment, :wrap
|
|
21
|
+
|
|
22
|
+
# @param text [String, Line, Text, nil, #to_s] content; a String is split into
|
|
23
|
+
# lines on newlines, +nil+ means no text, other objects are converted with
|
|
24
|
+
# +to_s+ (default: nil)
|
|
25
|
+
# @param style [Style] base style under each span's style; when it is not
|
|
26
|
+
# {Style::DEFAULT} it is also patched onto every cell of the area
|
|
27
|
+
# (default: {Style::DEFAULT})
|
|
28
|
+
# @param alignment [Symbol] +:left+ (default), +:center+ or +:right+
|
|
29
|
+
# @param wrap [Symbol] {WRAP_WORD} (default), {WRAP_CHAR} or {WRAP_NONE}. Wrapped
|
|
30
|
+
# lines take the style of the original line's first span
|
|
31
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
32
|
+
def initialize(
|
|
33
|
+
text: nil,
|
|
34
|
+
style: Style::DEFAULT,
|
|
35
|
+
alignment: :left,
|
|
36
|
+
wrap: WRAP_WORD,
|
|
37
|
+
block: nil # optional Block to render inside
|
|
38
|
+
)
|
|
39
|
+
@text = normalize_text(text)
|
|
40
|
+
@style = style
|
|
41
|
+
@alignment = alignment
|
|
42
|
+
@wrap = wrap
|
|
43
|
+
@block = block
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Render the wrapped lines from the top of the area; lines beyond its height
|
|
47
|
+
# are not drawn.
|
|
48
|
+
#
|
|
49
|
+
# Lines are not clipped at the right edge of the area, only at the buffer
|
|
50
|
+
# edge, so unwrapped lines wider than the area extend past it.
|
|
51
|
+
#
|
|
52
|
+
# @param area [Rect] the rectangular area to render into
|
|
53
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
54
|
+
# @return [void]
|
|
55
|
+
def render(area, buf)
|
|
56
|
+
return if area.empty?
|
|
57
|
+
|
|
58
|
+
render_area = area
|
|
59
|
+
if @block
|
|
60
|
+
@block.render(area, buf)
|
|
61
|
+
render_area = @block.inner(area)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return if render_area.empty?
|
|
65
|
+
|
|
66
|
+
# Apply background style to entire area
|
|
67
|
+
buf.set_style(render_area, @style) if @style != Style::DEFAULT
|
|
68
|
+
|
|
69
|
+
# Wrap and render lines
|
|
70
|
+
wrapped = wrap_lines(render_area.width)
|
|
71
|
+
|
|
72
|
+
wrapped.each_with_index do |line, i|
|
|
73
|
+
break if i >= render_area.height
|
|
74
|
+
|
|
75
|
+
render_line(line, render_area, i, buf)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def normalize_text(text)
|
|
82
|
+
case text
|
|
83
|
+
when Text then text
|
|
84
|
+
when String then Text.from(text)
|
|
85
|
+
when Line then Text.new([text])
|
|
86
|
+
when nil then Text.new([])
|
|
87
|
+
else Text.from(text.to_s)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Wrap text lines to fit within the given width.
|
|
92
|
+
# Returns an Array of Lines.
|
|
93
|
+
def wrap_lines(max_width)
|
|
94
|
+
return @text.lines if @wrap == WRAP_NONE || max_width <= 0
|
|
95
|
+
|
|
96
|
+
result = []
|
|
97
|
+
@text.lines.each do |line|
|
|
98
|
+
if line.width <= max_width
|
|
99
|
+
result << line
|
|
100
|
+
elsif @wrap == WRAP_WORD
|
|
101
|
+
result.concat(word_wrap(line, max_width))
|
|
102
|
+
else
|
|
103
|
+
result.concat(char_wrap(line, max_width))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def word_wrap(line, max_width)
|
|
110
|
+
plain = line.to_s
|
|
111
|
+
# Use the first span's style for wrapped text (simplification for v0.1)
|
|
112
|
+
line_style = line.spans.first&.style || Style::DEFAULT
|
|
113
|
+
|
|
114
|
+
words = plain.split(/(\s+)/)
|
|
115
|
+
wrapped = []
|
|
116
|
+
current = String.new
|
|
117
|
+
current_width = 0
|
|
118
|
+
|
|
119
|
+
words.each do |word|
|
|
120
|
+
word_width = Buffer.string_width(word)
|
|
121
|
+
if current.empty?
|
|
122
|
+
current << word
|
|
123
|
+
current_width = word_width
|
|
124
|
+
elsif current_width + word_width <= max_width
|
|
125
|
+
current << word
|
|
126
|
+
current_width += word_width
|
|
127
|
+
else
|
|
128
|
+
wrapped << Line.from(current.rstrip, line_style) unless current.strip.empty?
|
|
129
|
+
current = word.lstrip
|
|
130
|
+
current_width = Buffer.string_width(current)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
wrapped << Line.from(current.rstrip, line_style) unless current.strip.empty?
|
|
135
|
+
wrapped << Line.new if wrapped.empty?
|
|
136
|
+
wrapped
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def char_wrap(line, max_width)
|
|
140
|
+
plain = line.to_s
|
|
141
|
+
line_style = line.spans.first&.style || Style::DEFAULT
|
|
142
|
+
|
|
143
|
+
wrapped = []
|
|
144
|
+
current = String.new
|
|
145
|
+
current_width = 0
|
|
146
|
+
|
|
147
|
+
plain.each_char do |ch|
|
|
148
|
+
ch_width = Buffer.char_width(ch)
|
|
149
|
+
if current_width + ch_width > max_width
|
|
150
|
+
wrapped << Line.from(current, line_style) unless current.empty?
|
|
151
|
+
current = String.new
|
|
152
|
+
current_width = 0
|
|
153
|
+
end
|
|
154
|
+
current << ch
|
|
155
|
+
current_width += ch_width
|
|
156
|
+
end
|
|
157
|
+
wrapped << Line.from(current, line_style) unless current.empty?
|
|
158
|
+
wrapped << Line.new if wrapped.empty?
|
|
159
|
+
wrapped
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def render_line(line, area, row, buf)
|
|
163
|
+
line_width = line.width
|
|
164
|
+
x_offset = case @alignment
|
|
165
|
+
when :center then [(area.width - line_width) / 2, 0].max
|
|
166
|
+
when :right then [area.width - line_width, 0].max
|
|
167
|
+
else 0
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
x = area.x + x_offset
|
|
171
|
+
y = area.y + row
|
|
172
|
+
|
|
173
|
+
line.spans.each do |span|
|
|
174
|
+
effective_style = @style.patch(span.style)
|
|
175
|
+
count = buf.set_string(x, y, span.content, effective_style)
|
|
176
|
+
x += count
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|