Class: Tuile::Component::VerticalScrollBar

Inherits:
Component
  • Object
show all
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

Instance Attribute Summary collapse

Instance Method Summary collapse

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

Parameters:

  • row_count: (Integer) (defaults to: 0)
  • scroll_top_row: (Integer) (defaults to: 0)


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.

Returns:

  • (String)


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

Returns:

  • (String)


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.

Returns:

  • (Integer)


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.

Returns:

  • (Integer)


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.

Returns:



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.

Returns:

  • (Integer)


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

Returns:

  • (Integer)


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

Parameters:

Returns:

  • (Boolean)


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.button == :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

Parameters:



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

Parameters:



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.

Returns:

  • (Integer)


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>

Returns:

  • (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.

Returns:

  • (Integer)


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.

Returns:



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.

Returns:

  • (Integer)


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.

Parameters:



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.scrollbar_color)
  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

Parameters:

  • row (Integer)


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.

Returns:

  • (Boolean)


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.

Parameters:

  • rows (Integer)
  • name (Symbol)

Returns:

  • (Integer)


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