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.
@@ -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