tuile 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/examples/file_commander.rb +196 -0
- data/examples/hello_world.rb +29 -0
- data/lib/tuile/component/has_content.rb +69 -0
- data/lib/tuile/component/info_window.rb +30 -0
- data/lib/tuile/component/label.rb +63 -0
- data/lib/tuile/component/layout.rb +98 -0
- data/lib/tuile/component/list.rb +583 -0
- data/lib/tuile/component/log_window.rb +59 -0
- data/lib/tuile/component/picker_window.rb +97 -0
- data/lib/tuile/component/popup.rb +127 -0
- data/lib/tuile/component/text_field.rb +209 -0
- data/lib/tuile/component/window.rb +215 -0
- data/lib/tuile/component.rb +236 -0
- data/lib/tuile/event_queue.rb +192 -0
- data/lib/tuile/fake_event_queue.rb +31 -0
- data/lib/tuile/fake_screen.rb +58 -0
- data/lib/tuile/keys.rb +63 -0
- data/lib/tuile/mouse_event.rb +49 -0
- data/lib/tuile/point.rb +14 -0
- data/lib/tuile/rect.rb +58 -0
- data/lib/tuile/screen.rb +377 -0
- data/lib/tuile/screen_pane.rb +174 -0
- data/lib/tuile/size.rb +42 -0
- data/lib/tuile/version.rb +6 -0
- data/lib/tuile/vertical_scroll_bar.rb +46 -0
- data/lib/tuile.rb +37 -0
- data/sig/tuile.rbs +1502 -0
- metadata +197 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A scrollable list of String items with cursor support.
|
|
6
|
+
#
|
|
7
|
+
# Items are lines painted directly into the component's {#rect}. Lines are
|
|
8
|
+
# automatically clipped horizontally. Vertical scrolling is supported via
|
|
9
|
+
# {#top_line}; the list can also automatically scroll to the bottom if
|
|
10
|
+
# {#auto_scroll} is enabled.
|
|
11
|
+
#
|
|
12
|
+
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
13
|
+
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
|
|
14
|
+
# automatically.
|
|
15
|
+
class List < Component
|
|
16
|
+
def initialize
|
|
17
|
+
super
|
|
18
|
+
@lines = []
|
|
19
|
+
@auto_scroll = false
|
|
20
|
+
@top_line = 0
|
|
21
|
+
@cursor = Cursor::None.new
|
|
22
|
+
@scrollbar_visibility = :gone
|
|
23
|
+
@show_cursor_when_inactive = false
|
|
24
|
+
@on_item_chosen = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Proc, nil] callback fired when an item is chosen — by pressing
|
|
28
|
+
# Enter on the cursor's item, or by left-clicking an item. Called as
|
|
29
|
+
# `proc.call(index, line)` with the chosen 0-based index and its line.
|
|
30
|
+
# Never fires when the cursor's position is outside the content (e.g.
|
|
31
|
+
# {Cursor::None}, or empty content).
|
|
32
|
+
attr_accessor :on_item_chosen
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] if true and a line is added or new content is set,
|
|
35
|
+
# auto-scrolls to the bottom.
|
|
36
|
+
attr_reader :auto_scroll
|
|
37
|
+
|
|
38
|
+
# @return [Integer] top line of the viewport. 0 or positive.
|
|
39
|
+
attr_reader :top_line
|
|
40
|
+
|
|
41
|
+
# @return [Cursor] the list's cursor.
|
|
42
|
+
attr_reader :cursor
|
|
43
|
+
|
|
44
|
+
# @return [Symbol] scrollbar visibility: `:gone` or `:visible`.
|
|
45
|
+
attr_reader :scrollbar_visibility
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] when true, the cursor highlight is painted even while
|
|
48
|
+
# the list is inactive (e.g. when focus is on a sibling search field).
|
|
49
|
+
# Defaults to false.
|
|
50
|
+
attr_reader :show_cursor_when_inactive
|
|
51
|
+
|
|
52
|
+
# @param value [Boolean]
|
|
53
|
+
def show_cursor_when_inactive=(value)
|
|
54
|
+
value = value ? true : false
|
|
55
|
+
return if @show_cursor_when_inactive == value
|
|
56
|
+
|
|
57
|
+
@show_cursor_when_inactive = value
|
|
58
|
+
invalidate
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sets the scrollbar visibility.
|
|
62
|
+
# @param value [Symbol] `:gone` or `:visible`.
|
|
63
|
+
def scrollbar_visibility=(value)
|
|
64
|
+
raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
|
|
65
|
+
return if @scrollbar_visibility == value
|
|
66
|
+
|
|
67
|
+
@scrollbar_visibility = value
|
|
68
|
+
invalidate
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Sets the new auto_scroll. If true, immediately scrolls to the bottom.
|
|
72
|
+
# @param new_auto_scroll [Boolean]
|
|
73
|
+
def auto_scroll=(new_auto_scroll)
|
|
74
|
+
@auto_scroll = new_auto_scroll
|
|
75
|
+
update_top_line_if_auto_scroll
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sets a new cursor.
|
|
79
|
+
# @param cursor [Cursor] new cursor.
|
|
80
|
+
def cursor=(cursor)
|
|
81
|
+
raise TypeError, "expected Cursor, got #{cursor.inspect}" unless cursor.is_a? Cursor
|
|
82
|
+
|
|
83
|
+
old_position = @cursor.position
|
|
84
|
+
@cursor = cursor
|
|
85
|
+
invalidate if old_position != cursor.position
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Sets the top line.
|
|
89
|
+
# @param new_top_line [Integer] 0 or greater.
|
|
90
|
+
def top_line=(new_top_line)
|
|
91
|
+
raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
|
|
92
|
+
raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
|
|
93
|
+
return unless @top_line != new_top_line
|
|
94
|
+
|
|
95
|
+
@top_line = new_top_line
|
|
96
|
+
invalidate
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Sets new lines. Each entry is coerced via `#to_s`, split on `\n` into
|
|
100
|
+
# separate lines, and trailing whitespace stripped — symmetric with
|
|
101
|
+
# {#add_lines}, so the stored `@lines` is always `Array<String>`.
|
|
102
|
+
# @param lines [Array] new lines. Entries need only respond to `#to_s`.
|
|
103
|
+
# @return [void]
|
|
104
|
+
def lines=(lines)
|
|
105
|
+
raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
|
|
106
|
+
|
|
107
|
+
@lines = lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
|
|
108
|
+
@content_size = nil
|
|
109
|
+
update_top_line_if_auto_scroll
|
|
110
|
+
invalidate
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Without a block, returns the current lines. With a block, fully
|
|
114
|
+
# re-populates the list:
|
|
115
|
+
# ```ruby
|
|
116
|
+
# list.lines do |buffer|
|
|
117
|
+
# buffer << "Hello!"
|
|
118
|
+
# end
|
|
119
|
+
# ```
|
|
120
|
+
# @yield [buffer]
|
|
121
|
+
# @yieldparam buffer [Array<String>] mutable buffer to push lines into.
|
|
122
|
+
# @yieldreturn [void]
|
|
123
|
+
# @return [Array<String>] current lines (when called without a block).
|
|
124
|
+
def lines
|
|
125
|
+
return @lines unless block_given?
|
|
126
|
+
|
|
127
|
+
buffer = []
|
|
128
|
+
yield buffer
|
|
129
|
+
self.lines = buffer
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Adds a line.
|
|
133
|
+
# @param line [String]
|
|
134
|
+
# @return [void]
|
|
135
|
+
def add_line(line)
|
|
136
|
+
add_lines [line]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Appends given lines. Each entry is coerced via `#to_s`, split on `\n`
|
|
140
|
+
# into separate lines, and trailing whitespace stripped — symmetric with
|
|
141
|
+
# {#lines=}.
|
|
142
|
+
# @param lines [Array] entries need only respond to `#to_s`.
|
|
143
|
+
# @return [void]
|
|
144
|
+
def add_lines(lines)
|
|
145
|
+
screen.check_locked
|
|
146
|
+
@lines += lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
|
|
147
|
+
@content_size = nil
|
|
148
|
+
update_top_line_if_auto_scroll
|
|
149
|
+
invalidate
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Size]
|
|
153
|
+
def content_size
|
|
154
|
+
@content_size ||= begin
|
|
155
|
+
content_width = @lines.map { |line| Unicode::DisplayWidth.of(Rainbow.uncolor(line)) }.max || 0
|
|
156
|
+
width = @lines.empty? ? 0 : content_width + 2
|
|
157
|
+
Size.new(width, @lines.size)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def focusable? = true
|
|
162
|
+
|
|
163
|
+
# @param key [String] a key.
|
|
164
|
+
# @return [Boolean] true if the key was handled.
|
|
165
|
+
def handle_key(key)
|
|
166
|
+
if !active?
|
|
167
|
+
false
|
|
168
|
+
elsif super
|
|
169
|
+
true
|
|
170
|
+
elsif key == Keys::PAGE_UP
|
|
171
|
+
move_top_line_by(-viewport_lines)
|
|
172
|
+
true
|
|
173
|
+
elsif key == Keys::PAGE_DOWN
|
|
174
|
+
move_top_line_by(viewport_lines)
|
|
175
|
+
true
|
|
176
|
+
elsif key == Keys::ENTER && cursor_on_item?
|
|
177
|
+
fire_item_chosen
|
|
178
|
+
true
|
|
179
|
+
elsif @cursor.handle_key(key, @lines.size, viewport_lines)
|
|
180
|
+
move_viewport_to_cursor
|
|
181
|
+
invalidate
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Moves the cursor to the next line whose text contains `query`
|
|
189
|
+
# (case-insensitive substring match). Search wraps around the end of the
|
|
190
|
+
# list. Only lines reachable by the current {#cursor} are considered.
|
|
191
|
+
#
|
|
192
|
+
# @param query [String] substring to match. Empty query never matches.
|
|
193
|
+
# @param include_current [Boolean] when true, the current cursor position
|
|
194
|
+
# is eligible (useful when the query has just changed and the current
|
|
195
|
+
# line may still match); when false, the search starts after the
|
|
196
|
+
# current position (useful for "find next" key bindings that should
|
|
197
|
+
# advance past the current).
|
|
198
|
+
# @return [Boolean] true if a match was found.
|
|
199
|
+
def select_next(query, include_current: false)
|
|
200
|
+
search_and_go(query, include_current: include_current, reverse: false)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Mirror of {#select_next} that walks the list backwards.
|
|
204
|
+
# @param query [String]
|
|
205
|
+
# @param include_current [Boolean]
|
|
206
|
+
# @return [Boolean] true if a match was found.
|
|
207
|
+
def select_prev(query, include_current: false)
|
|
208
|
+
search_and_go(query, include_current: include_current, reverse: true)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @param event [MouseEvent]
|
|
212
|
+
# @return [void]
|
|
213
|
+
def handle_mouse(event)
|
|
214
|
+
super
|
|
215
|
+
if event.button == :scroll_down
|
|
216
|
+
move_top_line_by(4)
|
|
217
|
+
elsif event.button == :scroll_up
|
|
218
|
+
move_top_line_by(-4)
|
|
219
|
+
else
|
|
220
|
+
return unless rect.contains?(event.point)
|
|
221
|
+
|
|
222
|
+
line = event.y - rect.top + top_line
|
|
223
|
+
if @cursor.handle_mouse(line, event, @lines.size)
|
|
224
|
+
move_viewport_to_cursor
|
|
225
|
+
invalidate
|
|
226
|
+
end
|
|
227
|
+
fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Paints the list items into {#rect}.
|
|
232
|
+
# @return [void]
|
|
233
|
+
def repaint
|
|
234
|
+
super
|
|
235
|
+
return if rect.empty?
|
|
236
|
+
|
|
237
|
+
width = rect.width
|
|
238
|
+
scrollbar = if scrollbar_visible?
|
|
239
|
+
VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
|
|
240
|
+
end
|
|
241
|
+
(0..(rect.height - 1)).each do |line_no|
|
|
242
|
+
line_index = line_no + @top_line
|
|
243
|
+
line = paintable_line(line_index, line_no, width, scrollbar)
|
|
244
|
+
screen.print TTY::Cursor.move_to(rect.left, line_no + rect.top), line
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Tracks cursor position within the list.
|
|
249
|
+
class Cursor
|
|
250
|
+
# @param position [Integer] the initial cursor position.
|
|
251
|
+
def initialize(position: 0)
|
|
252
|
+
@position = position
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# No cursor — cursor is disabled.
|
|
256
|
+
class None < Cursor
|
|
257
|
+
def initialize
|
|
258
|
+
super(position: -1)
|
|
259
|
+
freeze
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @param _key [String]
|
|
263
|
+
# @param _line_count [Integer]
|
|
264
|
+
# @param _viewport_lines [Integer]
|
|
265
|
+
# @return [Boolean]
|
|
266
|
+
def handle_key(_key, _line_count, _viewport_lines)
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @param _line [Integer]
|
|
271
|
+
# @param _event [MouseEvent]
|
|
272
|
+
# @param _line_count [Integer]
|
|
273
|
+
# @return [Boolean]
|
|
274
|
+
def handle_mouse(_line, _event, _line_count)
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @param _line_count [Integer]
|
|
279
|
+
# @return [Array<Integer>]
|
|
280
|
+
def candidate_positions(_line_count)
|
|
281
|
+
[]
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# @return [Integer] 0-based line index of the current cursor position.
|
|
286
|
+
attr_reader :position
|
|
287
|
+
|
|
288
|
+
# @param line_count [Integer] number of lines in the list.
|
|
289
|
+
# @return [Array<Integer>] positions the cursor can land on, in
|
|
290
|
+
# ascending order.
|
|
291
|
+
def candidate_positions(line_count)
|
|
292
|
+
(0...line_count).to_a
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @param key [String] pressed keyboard key.
|
|
296
|
+
# @param line_count [Integer] number of lines in the list.
|
|
297
|
+
# @param viewport_lines [Integer] number of visible lines.
|
|
298
|
+
# @return [Boolean] true if the cursor moved.
|
|
299
|
+
def handle_key(key, line_count, viewport_lines)
|
|
300
|
+
case key
|
|
301
|
+
when *Keys::DOWN_ARROWS
|
|
302
|
+
go_down_by(1, line_count)
|
|
303
|
+
when *Keys::UP_ARROWS
|
|
304
|
+
go_up_by(1)
|
|
305
|
+
when Keys::HOME
|
|
306
|
+
go_to_first
|
|
307
|
+
when Keys::END_
|
|
308
|
+
go_to_last(line_count)
|
|
309
|
+
when Keys::CTRL_U
|
|
310
|
+
go_up_by(viewport_lines / 2)
|
|
311
|
+
when Keys::CTRL_D
|
|
312
|
+
go_down_by(viewport_lines / 2, line_count)
|
|
313
|
+
else
|
|
314
|
+
false
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# @param line [Integer] cursor is hovering over this line.
|
|
319
|
+
# @param event [MouseEvent] the event.
|
|
320
|
+
# @param line_count [Integer] number of lines in the list.
|
|
321
|
+
# @return [Boolean] true if the event was handled.
|
|
322
|
+
def handle_mouse(line, event, line_count)
|
|
323
|
+
if event.button == :left
|
|
324
|
+
go(line.clamp(nil, line_count - 1))
|
|
325
|
+
else
|
|
326
|
+
false
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Moves the cursor to the new position. Public only because of testing.
|
|
331
|
+
# @param new_position [Integer] new 0-based cursor position.
|
|
332
|
+
# @return [Boolean] true if the position changed.
|
|
333
|
+
def go(new_position)
|
|
334
|
+
new_position = new_position.clamp(0, nil)
|
|
335
|
+
return false if @position == new_position
|
|
336
|
+
|
|
337
|
+
@position = new_position
|
|
338
|
+
true
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
protected
|
|
342
|
+
|
|
343
|
+
# @param lines [Integer]
|
|
344
|
+
# @param line_count [Integer]
|
|
345
|
+
# @return [Boolean]
|
|
346
|
+
def go_down_by(lines, line_count)
|
|
347
|
+
go((@position + lines).clamp(nil, line_count - 1))
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# @param lines [Integer]
|
|
351
|
+
# @return [Boolean]
|
|
352
|
+
def go_up_by(lines)
|
|
353
|
+
go(@position - lines)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @return [Boolean]
|
|
357
|
+
def go_to_first
|
|
358
|
+
go(0)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @param line_count [Integer]
|
|
362
|
+
# @return [Boolean]
|
|
363
|
+
def go_to_last(line_count)
|
|
364
|
+
go(line_count - 1)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Cursor which can only land on specific allowed lines.
|
|
368
|
+
class Limited < Cursor
|
|
369
|
+
# @param positions [Array<Integer>] allowed positions. Must not be
|
|
370
|
+
# empty.
|
|
371
|
+
# @param position [Integer] initial position.
|
|
372
|
+
def initialize(positions, position: positions[0])
|
|
373
|
+
@positions = positions.sort
|
|
374
|
+
position = @positions[@positions.rindex { it < position } || 0] unless @positions.include?(position)
|
|
375
|
+
super(position: position)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# @param line [Integer]
|
|
379
|
+
# @param event [MouseEvent]
|
|
380
|
+
# @param _line_count [Integer]
|
|
381
|
+
# @return [Boolean]
|
|
382
|
+
def handle_mouse(line, event, _line_count)
|
|
383
|
+
if event.button == :left
|
|
384
|
+
prev_pos = @positions.reverse_each.find { it <= line }
|
|
385
|
+
return go_to_first if prev_pos.nil?
|
|
386
|
+
|
|
387
|
+
go(prev_pos)
|
|
388
|
+
else
|
|
389
|
+
false
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# @param line_count [Integer]
|
|
394
|
+
# @return [Array<Integer>]
|
|
395
|
+
def candidate_positions(line_count)
|
|
396
|
+
@positions.select { it < line_count }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
protected
|
|
400
|
+
|
|
401
|
+
# @param lines [Integer]
|
|
402
|
+
# @param line_count [Integer]
|
|
403
|
+
# @return [Boolean]
|
|
404
|
+
def go_down_by(lines, line_count)
|
|
405
|
+
next_pos = @positions.find { it >= @position + lines }
|
|
406
|
+
return go_to_last(line_count) if next_pos.nil?
|
|
407
|
+
|
|
408
|
+
go(next_pos)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# @param lines [Integer]
|
|
412
|
+
# @return [Boolean]
|
|
413
|
+
def go_up_by(lines)
|
|
414
|
+
prev_pos = @positions.reverse_each.find { it <= @position - lines }
|
|
415
|
+
return go_to_first if prev_pos.nil?
|
|
416
|
+
|
|
417
|
+
go(prev_pos)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# @return [Boolean]
|
|
421
|
+
def go_to_first
|
|
422
|
+
go(@positions.first)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# @param _line_count [Integer]
|
|
426
|
+
# @return [Boolean]
|
|
427
|
+
def go_to_last(_line_count)
|
|
428
|
+
go(@positions.last)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
private
|
|
434
|
+
|
|
435
|
+
# @return [Boolean] true if the cursor sits on a real content line.
|
|
436
|
+
def cursor_on_item?
|
|
437
|
+
pos = @cursor.position
|
|
438
|
+
pos >= 0 && pos < @lines.size
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Calls {#on_item_chosen} with the cursor's current `(index, line)`.
|
|
442
|
+
# Caller must ensure {#cursor_on_item?}.
|
|
443
|
+
# @return [void]
|
|
444
|
+
def fire_item_chosen
|
|
445
|
+
pos = @cursor.position
|
|
446
|
+
@on_item_chosen&.call(pos, @lines[pos])
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# @param query [String]
|
|
450
|
+
# @param include_current [Boolean]
|
|
451
|
+
# @param reverse [Boolean]
|
|
452
|
+
# @return [Boolean]
|
|
453
|
+
def search_and_go(query, include_current:, reverse:)
|
|
454
|
+
return false if query.empty?
|
|
455
|
+
|
|
456
|
+
candidates = @cursor.candidate_positions(@lines.size)
|
|
457
|
+
return false if candidates.empty?
|
|
458
|
+
|
|
459
|
+
ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
|
|
460
|
+
query_lc = query.downcase
|
|
461
|
+
match = ordered.find { |idx| Rainbow.uncolor(@lines[idx]).downcase.include?(query_lc) }
|
|
462
|
+
return false unless match
|
|
463
|
+
|
|
464
|
+
@cursor.go(match)
|
|
465
|
+
move_viewport_to_cursor
|
|
466
|
+
invalidate
|
|
467
|
+
true
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Rotates `candidates` (sorted ascending) so iteration starts from the
|
|
471
|
+
# position appropriate for "find next" / "find prev" with optional
|
|
472
|
+
# inclusion of the current.
|
|
473
|
+
# @param candidates [Array<Integer>]
|
|
474
|
+
# @param current [Integer]
|
|
475
|
+
# @param include_current [Boolean]
|
|
476
|
+
# @param reverse [Boolean]
|
|
477
|
+
# @return [Array<Integer>]
|
|
478
|
+
def order_for_search(candidates, current, include_current:, reverse:)
|
|
479
|
+
if reverse
|
|
480
|
+
before, after = if include_current
|
|
481
|
+
[candidates.select { it <= current }, candidates.select { it > current }]
|
|
482
|
+
else
|
|
483
|
+
[candidates.select { it < current }, candidates.select { it >= current }]
|
|
484
|
+
end
|
|
485
|
+
before.reverse + after.reverse
|
|
486
|
+
else
|
|
487
|
+
after, before = if include_current
|
|
488
|
+
[candidates.select { it >= current }, candidates.select { it < current }]
|
|
489
|
+
else
|
|
490
|
+
[candidates.select { it > current }, candidates.select { it <= current }]
|
|
491
|
+
end
|
|
492
|
+
after + before
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Scrolls the viewport so the cursor is visible.
|
|
497
|
+
# @return [void]
|
|
498
|
+
def move_viewport_to_cursor
|
|
499
|
+
pos = @cursor.position
|
|
500
|
+
return unless pos >= 0
|
|
501
|
+
|
|
502
|
+
if @top_line > pos
|
|
503
|
+
self.top_line = pos
|
|
504
|
+
elsif pos > @top_line + rect.height - 1
|
|
505
|
+
self.top_line = pos - rect.height + 1
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# @return [Integer] the max value of {#top_line}.
|
|
510
|
+
def top_line_max = (@lines.size - rect.height).clamp(0, nil)
|
|
511
|
+
|
|
512
|
+
# @return [Integer] the number of visible lines.
|
|
513
|
+
def viewport_lines = rect.height
|
|
514
|
+
|
|
515
|
+
# Scrolls the list.
|
|
516
|
+
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
517
|
+
# @return [void]
|
|
518
|
+
def move_top_line_by(delta)
|
|
519
|
+
new_top_line = (@top_line + delta).clamp(0, top_line_max)
|
|
520
|
+
return if @top_line == new_top_line
|
|
521
|
+
|
|
522
|
+
@top_line = new_top_line
|
|
523
|
+
invalidate
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# If auto-scrolling, recalculate the top line.
|
|
527
|
+
# @return [void]
|
|
528
|
+
def update_top_line_if_auto_scroll
|
|
529
|
+
return unless @auto_scroll
|
|
530
|
+
|
|
531
|
+
new_top_line = (@lines.size - viewport_lines).clamp(0, nil)
|
|
532
|
+
return unless @top_line != new_top_line
|
|
533
|
+
|
|
534
|
+
self.top_line = new_top_line
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# @return [Boolean] whether the scrollbar should be drawn right now.
|
|
538
|
+
def scrollbar_visible?
|
|
539
|
+
return false if rect.empty?
|
|
540
|
+
|
|
541
|
+
@scrollbar_visibility == :visible
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Trims string exactly to `width` columns.
|
|
545
|
+
# @param str [String]
|
|
546
|
+
# @param width [Integer]
|
|
547
|
+
# @return [String]
|
|
548
|
+
def trim_to(str, width)
|
|
549
|
+
return " " * width if str.empty?
|
|
550
|
+
|
|
551
|
+
truncated_line = Strings::Truncation.truncate(str, length: width)
|
|
552
|
+
return truncated_line unless truncated_line == str
|
|
553
|
+
|
|
554
|
+
length = Unicode::DisplayWidth.of(Rainbow.uncolor(str))
|
|
555
|
+
str += " " * (width - length) if length < width
|
|
556
|
+
str
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# @param index [Integer] 0-based index into {#lines}.
|
|
560
|
+
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
561
|
+
# @param width [Integer] number of columns the line should occupy.
|
|
562
|
+
# @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil if
|
|
563
|
+
# not shown.
|
|
564
|
+
# @return [String] paintable line exactly `width` columns wide;
|
|
565
|
+
# highlighted if cursor is here.
|
|
566
|
+
def paintable_line(index, row_in_viewport, width, scrollbar)
|
|
567
|
+
content_width = scrollbar ? width - 1 : width
|
|
568
|
+
line = @lines[index] || ""
|
|
569
|
+
line = trim_to(line, content_width - 2)
|
|
570
|
+
line = " #{line} "
|
|
571
|
+
is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
|
|
572
|
+
line = if is_cursor
|
|
573
|
+
Rainbow(Rainbow.uncolor(line)).bg(:darkslategray)
|
|
574
|
+
else
|
|
575
|
+
line
|
|
576
|
+
end
|
|
577
|
+
return line unless scrollbar
|
|
578
|
+
|
|
579
|
+
line + scrollbar.scrollbar_char(row_in_viewport)
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
|
|
6
|
+
# log lines into this window:
|
|
7
|
+
#
|
|
8
|
+
# log_window = Tuile::Component::LogWindow.new
|
|
9
|
+
# logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
|
|
10
|
+
#
|
|
11
|
+
# Any logger that writes formatted lines to an IO works the same way —
|
|
12
|
+
# for example `TTY::Logger` configured with the `:console` handler and
|
|
13
|
+
# `output: LogWindow::IO.new(window)`.
|
|
14
|
+
class LogWindow < Window
|
|
15
|
+
# @param caption [String]
|
|
16
|
+
def initialize(caption = "Log")
|
|
17
|
+
super
|
|
18
|
+
list = Component::List.new
|
|
19
|
+
list.auto_scroll = true
|
|
20
|
+
# Allow scrolling when a long stacktrace is logged.
|
|
21
|
+
list.cursor = Component::List::Cursor.new
|
|
22
|
+
self.content = list
|
|
23
|
+
self.scrollbar = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# IO-shaped adapter that forwards each log line to the owning {LogWindow}.
|
|
27
|
+
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
28
|
+
# call `output.puts`, e.g. `TTY::Logger`).
|
|
29
|
+
class IO
|
|
30
|
+
# @param window [LogWindow]
|
|
31
|
+
def initialize(window)
|
|
32
|
+
@window = window
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param string [String]
|
|
36
|
+
# @return [void]
|
|
37
|
+
def write(string)
|
|
38
|
+
@window.screen.event_queue.submit do
|
|
39
|
+
@window.content.add_line(string.chomp)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param string [String]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def puts(string)
|
|
46
|
+
@window.screen.event_queue.submit do
|
|
47
|
+
@window.content.add_line(string)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stdlib `Logger` only treats an object as an IO target when it
|
|
52
|
+
# responds to both {#write} and {#close}; otherwise it tries to
|
|
53
|
+
# interpret it as a filename. This is a no-op.
|
|
54
|
+
# @return [void]
|
|
55
|
+
def close; end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|