Class: Tuile::Component::VerticalScrollBar
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::VerticalScrollBar
- Defined in:
- lib/tuile/component/vertical_scroll_bar.rb,
sig/tuile.rbs
Overview
A one-column scrollbar the user can drag, and press the track of to page. It is chrome a container owns: you hand it a column, keep it told how tall the content is and how far it has scrolled, and listen for the row it asks for:
@scrollbar = VerticalScrollBar.new(row_count: @content_rows)
@scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
add_child(@scrollbar)
# …and from wherever the geometry settles:
@scrollbar.rect = Rect.new(rect.width - 1, 0, 1, rect.height)
@scrollbar.scroll_top_row = @scroll_top_row
It never moves itself. Both gestures compute a row and fire #on_scroll_request; nothing changes until the owner assigns #scroll_top_row=. So an unwired bar is inert — the honest picture, nothing behind it having scrolled either.
Dragging needs run_event_loop(capture_mouse: :drag): the :clicks
default asks the terminal for no motion reports, so
#handle_mouse_drag never fires and the handle sits still.
Pressing the track works at every level.
Its track is the whole #rect — no arrow buttons — so rect.height is
the viewport height. The glyphs are the app-global VerticalScrollBar.handle_char /
VerticalScrollBar.track_char pair and the color is Theme#scrollbar_color, read at paint
time. Scroller is the worked example; why a component rather than drag
handlers on the owner, and why the handle sits where it does, are
D_draggable_scrollbar.
Defined Under Namespace
Classes: ScrollRequestEvent
Class Attribute Summary collapse
-
.handle_char ⇒ String
The glyph drawn where the handle covers a row,
█by default. -
.track_char ⇒ String
The glyph drawn on the rows the handle doesn't cover,
░by default — and on every row when nothing scrolls (#repaint).
Instance Attribute Summary collapse
-
#row_count ⇒ Integer
@return — how many rows of content the bar stands for.
-
#scroll_top_row ⇒ Integer
@return — the content row currently at the top of the viewport.
Instance Method Summary collapse
-
#extent ⇒ Size
One column wide, however wide a rect it was handed — which is also what the router hit-tests, so a press on the dead columns beside the bar reaches whatever is behind it instead.
-
#free_rows ⇒ Integer
@return — track rows the handle can travel over;
0means no drag is possible. -
#handle_height ⇒ Integer
Rows the handle covers, capped at one below the track so there is always somewhere to drag to: a 10-row viewport onto 11 rows of content gets a 9-row handle, not an immovable 10-row one.
-
#handle_mouse_down?(event) ⇒ Boolean
Claims every left press: on the handle it starts a drag, on the track it pages towards the pointer.
-
#handle_mouse_drag(event) ⇒ void
Scrolls by how far the pointer has moved since the press, not by where it is.
-
#handle_mouse_up(event) ⇒ void
@param
event. -
#handle_start ⇒ Integer
The handle's first row, mapped over the free track so that the last scrollable row puts the handle's last row at the bottom of the track.
-
#initialize(row_count: 0, scroll_top_row: 0) ⇒ VerticalScrollBar
constructor
@param
row_count— see #row_count=. - #inspect_details ⇒ Array<String>
-
#max_top ⇒ Integer
@return — the largest #scroll_top_row that still fills the viewport;
0when the content fits. -
#on_scroll_request ⇒ Listeners
Fired with a ScrollRequestEvent when a drag or a track press asks for a row other than the current one — never from #scroll_top_row=, so an owner assigning the row it was just handed loops nothing.
-
#page_rows ⇒ Integer
@return — a viewport, at least one row.
-
#repaint(canvas) ⇒ void
Paints VerticalScrollBar.track_char down the column with VerticalScrollBar.handle_char over it — and bare track when #scrollable? is false, a solid handle filling the track carrying no information (
D_scrollbar_ink). -
#request(row) ⇒ void
Fires #on_scroll_request for
row, clamped; silent on a no-op. -
#scrollable? ⇒ Boolean
@return — whether there is content out of view —
falsealso for an empty rect, and what #repaint asks before drawing a handle. -
#validate_rows(rows, name) ⇒ Integer
@param
rows.
Constructor Details
#initialize(row_count: 0, scroll_top_row: 0) ⇒ VerticalScrollBar
@param row_count — see #row_count=.
@param scroll_top_row — see #scroll_top_row=.
92 93 94 95 96 97 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 92 def initialize(row_count: 0, scroll_top_row: 0) super() @row_count = validate_rows(row_count, :row_count) @scroll_top_row = validate_rows(scroll_top_row, :scroll_top_row) @drag_origin = nil end |
Class Attribute Details
.handle_char ⇒ String
The glyph drawn where the handle covers a row, █ by default. Set
the pair at startup for a lazygit-style bar:
Tuile::Component::VerticalScrollBar.handle_char = "▐"
Tuile::Component::VerticalScrollBar.track_char = "│"
Process-global, and assigning invalidates nothing — a change after
the first paint shows up only where something repaints anyway. Why
app-global rather than per instance is D_scrollbar_ink.
45 46 47 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 45 def handle_char @handle_char end |
.track_char ⇒ String
The glyph drawn on the rows the handle doesn't cover, ░ by
default — and on every row when nothing scrolls (#repaint).
50 51 52 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 50 def track_char @track_char end |
Instance Attribute Details
#row_count ⇒ Integer
@return — how many rows of content the bar stands for.
100 101 102 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 100 def row_count @row_count end |
#scroll_top_row ⇒ Integer
@return — the content row currently at the top of the viewport.
103 104 105 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 103 def scroll_top_row @scroll_top_row end |
Instance Method Details
#extent ⇒ Size
One column wide, however wide a rect it was handed — which is also what the router hit-tests, so a press on the dead columns beside the bar reaches whatever is behind it instead.
160 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 160 def extent = Size.new([1, rect.width].min, rect.height) |
#free_rows ⇒ Integer
@return — track rows the handle can travel over; 0 means no
drag is possible.
229 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 229 def free_rows = scrollable? ? rect.height - handle_height : 0 |
#handle_height ⇒ Integer
Rows the handle covers, capped at one below the track so there is always somewhere to drag to: a 10-row viewport onto 11 rows of content gets a 9-row handle, not an immovable 10-row one.
@return — 0 for an empty rect, the whole track when the
content fits (nothing is painted then — see #repaint).
139 140 141 142 143 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 139 def handle_height return rect.height unless scrollable? (rect.height**2.0 / @row_count).ceil.clamp(1, [rect.height - 1, 1].max) end |
#handle_mouse_down?(event) ⇒ Boolean
Claims every left press: on the handle it starts a drag, on the track it pages towards the pointer.
@param event
166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 166 def handle_mouse_down?(event) return false unless event. == :left @drag_origin = nil if scrollable? && event.y >= handle_start && event.y < handle_start + handle_height @drag_origin = [event.y, @scroll_top_row] else request(@scroll_top_row + (event.y < handle_start ? -page_rows : page_rows)) end true end |
#handle_mouse_drag(event) ⇒ void
This method returns an undefined value.
Scrolls by how far the pointer has moved since the press, not by where it is. Absolute positioning would jump on the first report: the track has a row per several rows of content, so inverting the handle's rounded position does not give back the row it came from.
@param event
184 185 186 187 188 189 190 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 184 def handle_mouse_drag(event) super return if @drag_origin.nil? || free_rows.zero? y_at_press, top_at_press = @drag_origin request(top_at_press + ((event.y - y_at_press) * max_top / free_rows.to_f).round) end |
#handle_mouse_up(event) ⇒ void
This method returns an undefined value.
@param event
194 195 196 197 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 194 def handle_mouse_up(event) super @drag_origin = nil end |
#handle_start ⇒ Integer
The handle's first row, mapped over the free track so that the last scrollable row puts the handle's last row at the bottom of the track.
@return — 0-based row within the track; 0 when the content
fits.
149 150 151 152 153 154 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 149 def handle_start free = free_rows return 0 if free.zero? (free * @scroll_top_row.clamp(0, max_top) / max_top.to_f).round end |
#inspect_details ⇒ Array<String>
219 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 219 def inspect_details = super + ["#{scroll_top_row}/#{row_count} rows"] |
#max_top ⇒ Integer
@return — the largest #scroll_top_row that still fills the
viewport; 0 when the content fits.
225 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 225 def max_top = [@row_count - rect.height, 0].max |
#on_scroll_request ⇒ Listeners
Fired with a ScrollRequestEvent when a drag or a track press asks for a row other than the current one — never from #scroll_top_row=, so an owner assigning the row it was just handed loops nothing.
Empty means a bar that does not move: it reports the request and waits to be told, so nothing at all happens until somebody listens.
88 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 88 listener :on_scroll_request |
#page_rows ⇒ Integer
@return — a viewport, at least one row.
232 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 232 def page_rows = [rect.height, 1].max |
#repaint(canvas) ⇒ void
This method returns an undefined value.
Paints track_char down the column with handle_char over it — and
bare track when #scrollable? is false, a solid handle filling the
track carrying no information (D_scrollbar_ink).
@param canvas — see Tuile::Component#repaint.
204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 204 def repaint(canvas) super return if rect.empty? style = StyledString::Style.new(fg: screen.theme.) handle = (handle_start...handle_start + handle_height) if scrollable? rect.height.times do |row| # Named, not `self.class` — a class-level ivar is not inherited, so a # subclassed bar would read nil off its own class. glyph = handle&.cover?(row) ? VerticalScrollBar.handle_char : VerticalScrollBar.track_char canvas.set_char(0, row, glyph, style) end end |
#request(row) ⇒ void
This method returns an undefined value.
Fires #on_scroll_request for row, clamped; silent on a no-op.
@param row
237 238 239 240 241 242 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 237 def request(row) row = row.clamp(0, max_top) return if row == @scroll_top_row on_scroll_request.fire(ScrollRequestEvent.new(source: self, scroll_top_row: row)) end |
#scrollable? ⇒ Boolean
@return — whether there is content out of view — false also
for an empty rect, and what #repaint asks before drawing a handle.
132 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 132 def scrollable? = rect.height >= 1 && @row_count > rect.height |
#validate_rows(rows, name) ⇒ Integer
@param rows
@param name — the accessor, for the message.
@return — rows.
248 249 250 251 252 253 254 |
# File 'lib/tuile/component/vertical_scroll_bar.rb', line 248 def validate_rows(rows, name) unless rows.is_a?(Integer) && !rows.negative? raise ArgumentError, "#{name} expects a non-negative Integer, got #{rows.inspect}" end rows end |