tuile 0.16.0 → 0.17.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 +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
|
@@ -7,7 +7,7 @@ module Tuile
|
|
|
7
7
|
# {MIDNIGHT}. Give it a single-row {#rect}:
|
|
8
8
|
#
|
|
9
9
|
# field = Component::TimeField.new
|
|
10
|
-
# field.on_value_change
|
|
10
|
+
# field.on_value_change { |e| puts e.value&.strftime("%H:%M") }
|
|
11
11
|
# field.set_to(13, 45) # field shows "13:45"
|
|
12
12
|
# field.placeholder # => "hh:mm"
|
|
13
13
|
# field.value # => 2000-01-01 13:45:00 UTC
|
|
@@ -157,8 +157,8 @@ module Tuile
|
|
|
157
157
|
editor.max_text_length = MAX_TEXT_LENGTH
|
|
158
158
|
# Claiming the editor's two arrow slots, not the general interceptor:
|
|
159
159
|
# that one stays free for the app.
|
|
160
|
-
editor.on_key_up
|
|
161
|
-
editor.on_key_down
|
|
160
|
+
editor.on_key_up { step_by(@step) }
|
|
161
|
+
editor.on_key_down { step_by(-@step) }
|
|
162
162
|
@settled = false
|
|
163
163
|
@placeholder_override = nil
|
|
164
164
|
@step = DEFAULT_STEP
|
|
@@ -192,12 +192,13 @@ module Tuile
|
|
|
192
192
|
# @return [void]
|
|
193
193
|
# @raise [TypeError] on a `Date` (it has no hour, and midnight would be
|
|
194
194
|
# invented) or a `String` (that is what the buffer is for).
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
196
|
+
def set_value(new_value, from_user:)
|
|
197
|
+
editor.set_value(new_value.nil? ? "" : coerce(new_value).strftime(formats.first), from_user:)
|
|
197
198
|
editor.caret = editor.text.length
|
|
198
199
|
# The edit above announced nothing ({#notify_on_edit?}); a time written
|
|
199
200
|
# rather than typed has no prefix to be mistaken for a value.
|
|
200
|
-
fire_if_changed
|
|
201
|
+
fire_if_changed(from_user:)
|
|
201
202
|
end
|
|
202
203
|
|
|
203
204
|
# Sets the value from its parts, so nothing assembles a `Time` on the
|
|
@@ -320,6 +321,14 @@ module Tuile
|
|
|
320
321
|
# @return [String, nil]
|
|
321
322
|
def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
|
|
322
323
|
|
|
324
|
+
# Every prefix of a time is bad input, so the report is latched to the
|
|
325
|
+
# commit gestures instead of shown per keystroke: `1`, `13`, `13:` on the
|
|
326
|
+
# way to `13:45` never redden and announce nothing, and a time the field
|
|
327
|
+
# cannot parse reddens the moment the user leaves the field or presses
|
|
328
|
+
# ENTER ({HasBadInput}).
|
|
329
|
+
# @return [Boolean]
|
|
330
|
+
def bad_input_settled? = @settled
|
|
331
|
+
|
|
323
332
|
# Claims PageUp/PageDown for the hour step; they reach this field only
|
|
324
333
|
# because the editor declines them.
|
|
325
334
|
# @param key [String]
|
|
@@ -342,7 +351,8 @@ module Tuile
|
|
|
342
351
|
# @return [void]
|
|
343
352
|
def commit
|
|
344
353
|
time = value
|
|
345
|
-
|
|
354
|
+
# The user's: a commit gesture, and a held notice announces here.
|
|
355
|
+
set_value(time, from_user: true) unless time.nil? # …which unsettles, hence the order
|
|
346
356
|
settle(true)
|
|
347
357
|
end
|
|
348
358
|
|
|
@@ -352,20 +362,14 @@ module Tuile
|
|
|
352
362
|
# @return [Boolean]
|
|
353
363
|
def notify_on_edit? = false
|
|
354
364
|
|
|
355
|
-
# Every prefix of a time is bad input, so the well is latched to the
|
|
356
|
-
# commit gestures instead of painted per keystroke: `1`, `13`, `13:` on
|
|
357
|
-
# the way to `13:45` never redden, and a time the field cannot parse
|
|
358
|
-
# reddens the moment the user leaves the field or presses ENTER
|
|
359
|
-
# ({HasBadInput}).
|
|
360
|
-
# @return [Boolean]
|
|
361
|
-
def bad_input_settled? = @settled
|
|
362
|
-
|
|
363
365
|
# An edit is the user having another go, so the well goes quiet again
|
|
364
366
|
# until the next commit gesture.
|
|
365
367
|
# @return [void]
|
|
366
368
|
def handle_editor_change
|
|
367
|
-
super
|
|
369
|
+
# Unsettled before the sync riding `super`, which would otherwise read
|
|
370
|
+
# the new buffer against the old latch.
|
|
368
371
|
settle(false)
|
|
372
|
+
super
|
|
369
373
|
end
|
|
370
374
|
|
|
371
375
|
# @return [void]
|
|
@@ -387,7 +391,7 @@ module Tuile
|
|
|
387
391
|
sync_placeholder
|
|
388
392
|
time = value || losslessly(carried)
|
|
389
393
|
self.value = time unless time.nil?
|
|
390
|
-
fire_if_changed # for the buffer that just *stopped* parsing: nothing above touched it
|
|
394
|
+
fire_if_changed(from_user: false) # for the buffer that just *stopped* parsing: nothing above touched it
|
|
391
395
|
end
|
|
392
396
|
|
|
393
397
|
# A narrowing {#step=} must not discard seconds the user typed — but
|
|
@@ -432,14 +436,12 @@ module Tuile
|
|
|
432
436
|
end
|
|
433
437
|
|
|
434
438
|
# Steps {#value} by `delta` seconds; an empty or unparseable field steps
|
|
435
|
-
# to *now* ({#set_to_now}) instead, and `delta` is ignored.
|
|
439
|
+
# to *now* (as {#set_to_now} would) instead, and `delta` is ignored.
|
|
436
440
|
# @param delta [Integer] seconds, either sign.
|
|
437
441
|
# @return [void]
|
|
438
442
|
def step_by(delta)
|
|
439
443
|
time = value
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
self.value = advance(time, delta)
|
|
444
|
+
set_value(time.nil? ? now : advance(time, delta), from_user: true)
|
|
443
445
|
end
|
|
444
446
|
|
|
445
447
|
# @param time [Time]
|
|
@@ -463,6 +465,9 @@ module Tuile
|
|
|
463
465
|
# Nothing else painted: an ENTER on an untouched buffer writes no cells,
|
|
464
466
|
# and neither does leaving the field with bad input in it.
|
|
465
467
|
invalidate
|
|
468
|
+
# The latch moves without the buffer, so this is the one input to the
|
|
469
|
+
# showable report that {HasBadInput}'s edit funnel cannot see.
|
|
470
|
+
sync_bad_input
|
|
466
471
|
end
|
|
467
472
|
|
|
468
473
|
# @return [void]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A one-column scrollbar the user can drag, and press the track of to page.
|
|
6
|
+
# It is chrome a container owns: you hand it a column, keep it told how
|
|
7
|
+
# tall the content is and how far it has scrolled, and listen for the row
|
|
8
|
+
# it asks for:
|
|
9
|
+
#
|
|
10
|
+
# @scrollbar = VerticalScrollBar.new(row_count: @content_rows)
|
|
11
|
+
# @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
|
|
12
|
+
# add_child(@scrollbar)
|
|
13
|
+
# # …and from wherever the geometry settles:
|
|
14
|
+
# @scrollbar.rect = Rect.new(rect.width - 1, 0, 1, rect.height)
|
|
15
|
+
# @scrollbar.scroll_top_row = @scroll_top_row
|
|
16
|
+
#
|
|
17
|
+
# **It never moves itself.** Both gestures compute a row and fire
|
|
18
|
+
# {#on_scroll_request}; nothing changes until the owner assigns
|
|
19
|
+
# {#scroll_top_row=}. So an unwired bar is inert — the honest picture,
|
|
20
|
+
# nothing behind it having scrolled either.
|
|
21
|
+
#
|
|
22
|
+
# **Dragging needs `run_event_loop(capture_mouse: :drag)`**: the `:clicks`
|
|
23
|
+
# default asks the terminal for no motion reports, so
|
|
24
|
+
# {Component#handle_mouse_drag} never fires and the handle sits still.
|
|
25
|
+
# Pressing the track works at every level.
|
|
26
|
+
#
|
|
27
|
+
# Its track is the whole {#rect} — no arrow buttons — so `rect.height` is
|
|
28
|
+
# the viewport height. The glyphs are the app-global {handle_char} /
|
|
29
|
+
# {track_char} pair and the color is `Theme#scrollbar_color`, read at paint
|
|
30
|
+
# time. {Scroller} is the worked example; why a component rather than drag
|
|
31
|
+
# handlers on the owner, and why the handle sits where it does, are
|
|
32
|
+
# `D_draggable_scrollbar`.
|
|
33
|
+
class VerticalScrollBar < Component
|
|
34
|
+
class << self
|
|
35
|
+
# The glyph drawn where the handle covers a row, `█` by default. Set
|
|
36
|
+
# the pair at startup for a lazygit-style bar:
|
|
37
|
+
#
|
|
38
|
+
# Tuile::Component::VerticalScrollBar.handle_char = "▐"
|
|
39
|
+
# Tuile::Component::VerticalScrollBar.track_char = "│"
|
|
40
|
+
#
|
|
41
|
+
# Process-global, and assigning invalidates nothing — a change after
|
|
42
|
+
# the first paint shows up only where something repaints anyway. Why
|
|
43
|
+
# app-global rather than per instance is `D_scrollbar_ink`.
|
|
44
|
+
# @return [String]
|
|
45
|
+
attr_reader :handle_char
|
|
46
|
+
|
|
47
|
+
# The glyph drawn on the rows the handle doesn't cover, `░` by
|
|
48
|
+
# default — and on every row when nothing scrolls ({#repaint}).
|
|
49
|
+
# @return [String]
|
|
50
|
+
attr_reader :track_char
|
|
51
|
+
|
|
52
|
+
# @param char [String]
|
|
53
|
+
# @return [String]
|
|
54
|
+
# @raise [TypeError] when `char` is not a String.
|
|
55
|
+
# @raise [ArgumentError] when `char` is not exactly one grapheme
|
|
56
|
+
# cluster one column wide.
|
|
57
|
+
def handle_char=(char)
|
|
58
|
+
@handle_char = StyledString.validate_glyph(char, :handle_char)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param char [String] see {handle_char=}.
|
|
62
|
+
# @return [String]
|
|
63
|
+
def track_char=(char)
|
|
64
|
+
@track_char = StyledString.validate_glyph(char, :track_char)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
self.handle_char = "█"
|
|
69
|
+
self.track_char = "░"
|
|
70
|
+
|
|
71
|
+
# What {#on_scroll_request} fires.
|
|
72
|
+
#
|
|
73
|
+
# @!attribute [r] source
|
|
74
|
+
# @return [VerticalScrollBar] the bar that was dragged or pressed.
|
|
75
|
+
# @!attribute [r] scroll_top_row
|
|
76
|
+
# @return [Integer] the content row the user is asking to see at the
|
|
77
|
+
# top, clamped so it never asks past the last scrollable row.
|
|
78
|
+
ScrollRequestEvent = Data.define(:source, :scroll_top_row) { include Tuile::Event }
|
|
79
|
+
|
|
80
|
+
# @!method on_scroll_request
|
|
81
|
+
# Fired with a {ScrollRequestEvent} when a drag or a track press asks
|
|
82
|
+
# for a row other than the current one — never from {#scroll_top_row=},
|
|
83
|
+
# so an owner assigning the row it was just handed loops nothing.
|
|
84
|
+
#
|
|
85
|
+
# **Empty means a bar that does not move**: it reports the request and
|
|
86
|
+
# waits to be told, so nothing at all happens until somebody listens.
|
|
87
|
+
# @return [Listeners]
|
|
88
|
+
listener :on_scroll_request
|
|
89
|
+
|
|
90
|
+
# @param row_count [Integer] see {#row_count=}.
|
|
91
|
+
# @param scroll_top_row [Integer] see {#scroll_top_row=}.
|
|
92
|
+
def initialize(row_count: 0, scroll_top_row: 0)
|
|
93
|
+
super()
|
|
94
|
+
@row_count = validate_rows(row_count, :row_count)
|
|
95
|
+
@scroll_top_row = validate_rows(scroll_top_row, :scroll_top_row)
|
|
96
|
+
@drag_origin = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Integer] how many rows of content the bar stands for.
|
|
100
|
+
attr_reader :row_count
|
|
101
|
+
|
|
102
|
+
# @return [Integer] the content row currently at the top of the viewport.
|
|
103
|
+
attr_reader :scroll_top_row
|
|
104
|
+
|
|
105
|
+
# @param count [Integer] `>= 0`.
|
|
106
|
+
# @raise [ArgumentError] unless a non-negative Integer.
|
|
107
|
+
# @return [void]
|
|
108
|
+
def row_count=(count)
|
|
109
|
+
count = validate_rows(count, :row_count)
|
|
110
|
+
return if @row_count == count
|
|
111
|
+
|
|
112
|
+
@row_count = count
|
|
113
|
+
invalidate
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Not clamped against {#row_count} — the owner's scroll state is
|
|
117
|
+
# authoritative, and a bar that silently disagreed with it would paint a
|
|
118
|
+
# handle where the content is not.
|
|
119
|
+
# @param row [Integer] `>= 0`.
|
|
120
|
+
# @raise [ArgumentError] unless a non-negative Integer.
|
|
121
|
+
# @return [void]
|
|
122
|
+
def scroll_top_row=(row)
|
|
123
|
+
row = validate_rows(row, :scroll_top_row)
|
|
124
|
+
return if @scroll_top_row == row
|
|
125
|
+
|
|
126
|
+
@scroll_top_row = row
|
|
127
|
+
invalidate
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Boolean] whether there is content out of view — `false` also
|
|
131
|
+
# for an empty rect, and what {#repaint} asks before drawing a handle.
|
|
132
|
+
def scrollable? = rect.height >= 1 && @row_count > rect.height
|
|
133
|
+
|
|
134
|
+
# Rows the handle covers, capped at one below the track so there is
|
|
135
|
+
# always somewhere to drag to: a 10-row viewport onto 11 rows of content
|
|
136
|
+
# gets a 9-row handle, not an immovable 10-row one.
|
|
137
|
+
# @return [Integer] `0` for an empty rect, the whole track when the
|
|
138
|
+
# content fits (nothing is *painted* then — see {#repaint}).
|
|
139
|
+
def handle_height
|
|
140
|
+
return rect.height unless scrollable?
|
|
141
|
+
|
|
142
|
+
(rect.height**2.0 / @row_count).ceil.clamp(1, [rect.height - 1, 1].max)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The handle's first row, mapped over the *free* track so that the last
|
|
146
|
+
# scrollable row puts the handle's last row at the bottom of the track.
|
|
147
|
+
# @return [Integer] 0-based row within the track; `0` when the content
|
|
148
|
+
# fits.
|
|
149
|
+
def handle_start
|
|
150
|
+
free = free_rows
|
|
151
|
+
return 0 if free.zero?
|
|
152
|
+
|
|
153
|
+
(free * @scroll_top_row.clamp(0, max_top) / max_top.to_f).round
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# One column wide, however wide a rect it was handed — which is also what
|
|
157
|
+
# the router hit-tests, so a press on the dead columns beside the bar
|
|
158
|
+
# reaches whatever is behind it instead.
|
|
159
|
+
# @return [Size]
|
|
160
|
+
def extent = Size.new([1, rect.width].min, rect.height)
|
|
161
|
+
|
|
162
|
+
# Claims every left press: on the handle it starts a drag, on the track
|
|
163
|
+
# it pages towards the pointer.
|
|
164
|
+
# @param event [Mouse::DownEvent]
|
|
165
|
+
# @return [Boolean]
|
|
166
|
+
def handle_mouse_down?(event)
|
|
167
|
+
return false unless event.button == :left
|
|
168
|
+
|
|
169
|
+
@drag_origin = nil
|
|
170
|
+
if scrollable? && event.y >= handle_start && event.y < handle_start + handle_height
|
|
171
|
+
@drag_origin = [event.y, @scroll_top_row]
|
|
172
|
+
else
|
|
173
|
+
request(@scroll_top_row + (event.y < handle_start ? -page_rows : page_rows))
|
|
174
|
+
end
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Scrolls by how far the pointer has moved *since the press*, not by
|
|
179
|
+
# where it is. Absolute positioning would jump on the first report: the
|
|
180
|
+
# track has a row per several rows of content, so inverting the handle's
|
|
181
|
+
# rounded position does not give back the row it came from.
|
|
182
|
+
# @param event [Mouse::DragEvent]
|
|
183
|
+
# @return [void]
|
|
184
|
+
def handle_mouse_drag(event)
|
|
185
|
+
super
|
|
186
|
+
return if @drag_origin.nil? || free_rows.zero?
|
|
187
|
+
|
|
188
|
+
y_at_press, top_at_press = @drag_origin
|
|
189
|
+
request(top_at_press + ((event.y - y_at_press) * max_top / free_rows.to_f).round)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @param event [Mouse::UpEvent]
|
|
193
|
+
# @return [void]
|
|
194
|
+
def handle_mouse_up(event)
|
|
195
|
+
super
|
|
196
|
+
@drag_origin = nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Paints {track_char} down the column with {handle_char} over it — and
|
|
200
|
+
# bare track when {#scrollable?} is false, a solid handle filling the
|
|
201
|
+
# track carrying no information (`D_scrollbar_ink`).
|
|
202
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
203
|
+
# @return [void]
|
|
204
|
+
def repaint(canvas)
|
|
205
|
+
super
|
|
206
|
+
return if rect.empty?
|
|
207
|
+
|
|
208
|
+
style = StyledString::Style.new(fg: screen.theme.scrollbar_color)
|
|
209
|
+
handle = (handle_start...handle_start + handle_height) if scrollable?
|
|
210
|
+
rect.height.times do |row|
|
|
211
|
+
# Named, not `self.class` — a class-level ivar is not inherited, so a
|
|
212
|
+
# subclassed bar would read nil off its own class.
|
|
213
|
+
glyph = handle&.cover?(row) ? VerticalScrollBar.handle_char : VerticalScrollBar.track_char
|
|
214
|
+
canvas.set_char(0, row, glyph, style)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [Array<String>]
|
|
219
|
+
def inspect_details = super + ["#{scroll_top_row}/#{row_count} rows"]
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
# @return [Integer] the largest {#scroll_top_row} that still fills the
|
|
224
|
+
# viewport; `0` when the content fits.
|
|
225
|
+
def max_top = [@row_count - rect.height, 0].max
|
|
226
|
+
|
|
227
|
+
# @return [Integer] track rows the handle can travel over; `0` means no
|
|
228
|
+
# drag is possible.
|
|
229
|
+
def free_rows = scrollable? ? rect.height - handle_height : 0
|
|
230
|
+
|
|
231
|
+
# @return [Integer] a viewport, at least one row.
|
|
232
|
+
def page_rows = [rect.height, 1].max
|
|
233
|
+
|
|
234
|
+
# Fires {#on_scroll_request} for `row`, clamped; silent on a no-op.
|
|
235
|
+
# @param row [Integer]
|
|
236
|
+
# @return [void]
|
|
237
|
+
def request(row)
|
|
238
|
+
row = row.clamp(0, max_top)
|
|
239
|
+
return if row == @scroll_top_row
|
|
240
|
+
|
|
241
|
+
on_scroll_request.fire(ScrollRequestEvent.new(source: self, scroll_top_row: row))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @param rows [Integer]
|
|
245
|
+
# @param name [Symbol] the accessor, for the message.
|
|
246
|
+
# @return [Integer] `rows`.
|
|
247
|
+
# @raise [ArgumentError] unless `rows` is a non-negative Integer.
|
|
248
|
+
def validate_rows(rows, name)
|
|
249
|
+
unless rows.is_a?(Integer) && !rows.negative?
|
|
250
|
+
raise ArgumentError, "#{name} expects a non-negative Integer, got #{rows.inspect}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
rows
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -66,17 +66,10 @@ module Tuile
|
|
|
66
66
|
# @return [void]
|
|
67
67
|
def footer=(new_footer)
|
|
68
68
|
@footer_slot.content = new_footer
|
|
69
|
-
|
|
69
|
+
invalidate_layout
|
|
70
70
|
invalidate # repaint border row that the footer covers/uncovers
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
# @param new_rect [Rect]
|
|
74
|
-
# @return [void]
|
|
75
|
-
def rect=(new_rect)
|
|
76
|
-
super
|
|
77
|
-
layout_footer
|
|
78
|
-
end
|
|
79
|
-
|
|
80
73
|
# @param value [Boolean]
|
|
81
74
|
# @return [void]
|
|
82
75
|
def scrollbar=(value)
|
|
@@ -88,7 +81,7 @@ module Tuile
|
|
|
88
81
|
content.scrollbar_visibility = value ? :visible : :gone
|
|
89
82
|
@border_right = value ? 0 : 1
|
|
90
83
|
invalidate
|
|
91
|
-
|
|
84
|
+
invalidate_layout
|
|
92
85
|
end
|
|
93
86
|
|
|
94
87
|
# Fully repaints the window: the border ring here, the interior through
|
|
@@ -103,57 +96,65 @@ module Tuile
|
|
|
103
96
|
# (`D_component_contract`). The ring is this window's own paint and the
|
|
104
97
|
# interior is the content's, so the only cell nobody covers is an
|
|
105
98
|
# interior with no content in it — cleared here, exactly.
|
|
99
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
106
100
|
# @return [void]
|
|
107
|
-
def repaint
|
|
101
|
+
def repaint(canvas)
|
|
108
102
|
return if rect.empty?
|
|
109
103
|
|
|
110
|
-
|
|
104
|
+
inner = content_rect
|
|
105
|
+
canvas.fill(inner) if content.nil? && !inner.empty?
|
|
111
106
|
invalidate_children
|
|
112
|
-
repaint_border
|
|
107
|
+
repaint_border(canvas)
|
|
113
108
|
end
|
|
114
109
|
|
|
115
110
|
protected
|
|
116
111
|
|
|
117
|
-
#
|
|
112
|
+
# The content takes the interior, the footer slot the bottom border row.
|
|
118
113
|
# @return [void]
|
|
119
|
-
def
|
|
114
|
+
def relayout
|
|
115
|
+
content&.rect = content_rect
|
|
116
|
+
layout_footer
|
|
117
|
+
end
|
|
120
118
|
|
|
121
119
|
# The interior the content fills: inside the border on three sides, and on
|
|
122
120
|
# the fourth only while there is a right border — {#scrollbar=} drops it so
|
|
123
121
|
# the content's own bar takes that column.
|
|
122
|
+
#
|
|
123
|
+
# One rect for both jobs, since a window paints and places its children in
|
|
124
|
+
# the same coordinates: it is what {#relayout} assigns the content, and what
|
|
125
|
+
# {#repaint} blanks when there is no content to cover it.
|
|
124
126
|
# @return [Rect] may be {Rect#empty? empty}, for a window too small to have
|
|
125
127
|
# an inside.
|
|
126
128
|
def content_rect
|
|
127
|
-
Rect.new(
|
|
129
|
+
Rect.new(1, 1, rect.width - 1 - @border_right, rect.height - 2)
|
|
128
130
|
end
|
|
129
131
|
|
|
130
|
-
# Paints the window border via {
|
|
131
|
-
# so the border cells inherit {
|
|
132
|
+
# Paints the window border via {Canvas#set_text} / {Canvas#set_char},
|
|
133
|
+
# so the border cells inherit {ComponentBackground#effective} — a
|
|
132
134
|
# {Component#bg_color} on the window tints border and content alike. Both
|
|
133
135
|
# border rows are clipped by *display* width, so no caption overflows the
|
|
134
136
|
# box; when the window is active the whole border — the caption's own
|
|
135
137
|
# colors included — is drawn in {Theme#active_border_color}.
|
|
138
|
+
# @param canvas [Canvas] the surface to paint onto.
|
|
136
139
|
# @return [void]
|
|
137
|
-
def repaint_border
|
|
140
|
+
def repaint_border(canvas)
|
|
138
141
|
return if rect.empty?
|
|
139
142
|
|
|
140
143
|
w = rect.width
|
|
141
144
|
h = rect.height
|
|
142
|
-
top = rect.top
|
|
143
|
-
left = rect.left
|
|
144
145
|
inner_w = [w - 2, 0].max
|
|
145
146
|
|
|
146
147
|
fg = active? ? screen.theme.active_border_color : nil
|
|
147
148
|
bar = StyledString::Style.new(fg: fg)
|
|
148
|
-
|
|
149
|
-
(1..(h - 2)).each do |
|
|
150
|
-
|
|
149
|
+
canvas.set_text(0, 0, top_border(inner_w, fg).slice(0, w))
|
|
150
|
+
(1..(h - 2)).each do |row|
|
|
151
|
+
canvas.set_char(0, row, "│", bar)
|
|
151
152
|
# Skipped once {#scrollbar=} has given that column to the content: the
|
|
152
153
|
# bar would paint over the border anyway, and painting it first only
|
|
153
154
|
# dirties the column into every frame's diff (`D_component_contract`).
|
|
154
|
-
|
|
155
|
+
canvas.set_char(w - 1, row, "│", bar) if @border_right.positive?
|
|
155
156
|
end
|
|
156
|
-
|
|
157
|
+
canvas.set_text(0, h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
|
|
157
158
|
end
|
|
158
159
|
|
|
159
160
|
# Builds the top border row: corners, {#caption} embedded at its own
|
|
@@ -204,7 +205,7 @@ module Tuile
|
|
|
204
205
|
end
|
|
205
206
|
|
|
206
207
|
width = [rect.width - 2, 0].max
|
|
207
|
-
@footer_slot.rect = Rect.new(
|
|
208
|
+
@footer_slot.rect = Rect.new(1, rect.height - 1, width, 1)
|
|
208
209
|
end
|
|
209
210
|
end
|
|
210
211
|
end
|