Class: Tuile::Component::Scroller

Inherits:
Component
  • Object
show all
Includes:
HasContent
Defined in:
lib/tuile/component/scroller.rb,
sig/tuile.rbs

Overview

A viewport onto a taller pile of components: one content child, scrolled vertically, with a VerticalScrollBar in the rightmost column.

form = Component::FormLayout.new
# …nine fields, 27 rows of them…
window.content = Component::Scroller.new(form, content_rows: 27)

You say how tall the content is. Nothing in Tuile measures (D_declared_size), so #content_rows is the number of rows the content wants, and the child is handed that many or the viewport's height, whichever is more — a rect taller than this component's, whose top goes negative as you scroll. Keep it current as the content grows.

Four things scroll it: the wheel, the bar's handle (dragging it wants run_event_loop(capture_mouse: :drag); pressing the track pages either way), #scroll_half_page_up / #scroll_half_page_down from app code, and #scroll_to_visible, which Screen#focused= makes on every focus change — so Tab into a field below the fold brings that field into view. It claims no keys: PgUp/PgDn and the arrows belong to the focused field.

A scrolled-away child is not hidden. It keeps its rect, its keys and its tab stop, and merely paints into cells the clip drops (D_clip), which is what lets Tab reach it in the first place.

Why a component you compose rather than a capability every container grows, and what the app-supplied row count costs: D_scroller.

Instance Attribute Summary collapse

Attributes included from HasContent

#content

Instance Method Summary collapse

Constructor Details

#initialize(content = nil, content_rows: 0) ⇒ Scroller

@param content — the content child, also settable later through HasContent#content=.

@param content_rows — see #content_rows=.

Parameters:

  • content (Component, nil) (defaults to: nil)
  • content_rows: (Integer) (defaults to: 0)


38
39
40
41
42
43
44
45
46
47
48
# File 'lib/tuile/component/scroller.rb', line 38

def initialize(content = nil, content_rows: 0)
  super()
  @content = nil
  @scroll_top_row = 0
  @content_rows = validate_rows(content_rows, :content_rows)
  @scrollbar_visibility = :visible
  @scrollbar = VerticalScrollBar.new(row_count: @content_rows)
  @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
  add_child(@scrollbar) # chrome, appended: the content goes in at 0
  self.content = content unless content.nil?
end

Instance Attribute Details

#content_rows ⇒ Integer

@return — how tall the content says it is, in rows.

Returns:

  • (Integer)


54
55
56
# File 'lib/tuile/component/scroller.rb', line 54

def content_rows
  @content_rows
end

#scroll_top_row ⇒ Integer

@return — the content row painted at the top of the viewport.

Returns:

  • (Integer)


51
52
53
# File 'lib/tuile/component/scroller.rb', line 51

def scroll_top_row
  @scroll_top_row
end

#scrollbar_visibility ⇒ Symbol

@return — :visible (the default), :gone, or :auto — shown only while #content_rows exceeds #viewport_rows.

Returns:

  • (Symbol)


58
59
60
# File 'lib/tuile/component/scroller.rb', line 58

def scrollbar_visibility
  @scrollbar_visibility
end

Instance Method Details

#content_height ⇒ Integer

@return — the rows the content child is given: what it declared, or the viewport, whichever is taller. An unset #content_rows makes a scroller that behaves like a plain one-child container.

Returns:

  • (Integer)


211
# File 'lib/tuile/component/scroller.rb', line 211

def content_height = [@content_rows, viewport_rows].max

#focusable? ⇒ Boolean

A container, so focus lands here only to be forwarded into the content by HasContent#handle_focus; it is no tab stop of its own.

Returns:

  • (Boolean)


164
# File 'lib/tuile/component/scroller.rb', line 164

def focusable? = true

#half_page_rows ⇒ Integer

@return — half a viewport, at least one row.

Returns:

  • (Integer)


244
# File 'lib/tuile/component/scroller.rb', line 244

def half_page_rows = [viewport_rows / 2, 1].max

#handle_focus ⇒ void

This method returns an undefined value.



8230
# File 'sig/tuile.rbs', line 8230

def handle_focus: () -> void

#handle_mouse_scroll?(event) ⇒ Boolean

Four rows a notch, and declines — so the notch bubbles to an outer scroller — once this one is at that end.

@param event

Parameters:

Returns:

  • (Boolean)


151
152
153
154
155
156
157
158
159
# File 'lib/tuile/component/scroller.rb', line 151

def handle_mouse_scroll?(event)
  before = @scroll_top_row
  case event.direction
  when :down then move_scroll_top_row_by(4)
  when :up   then move_scroll_top_row_by(-4)
  else return false
  end
  @scroll_top_row != before
end

#inner_width ⇒ Integer

@return — columns left for the content once the bar has its own.

Returns:

  • (Integer)


214
# File 'lib/tuile/component/scroller.rb', line 214

def inner_width = [rect.width - scrollbar_columns, 0].max

#inspect_details ⇒ Array<String>

Returns:

  • (Array<String>)


167
# File 'lib/tuile/component/scroller.rb', line 167

def inspect_details = super + ["#{scroll_top_row}/#{content_rows} rows"]

#move_scroll_top_row_by(delta) ⇒ void

This method returns an undefined value.

@param delta — negative scrolls up, positive down.

Parameters:

  • delta (Integer)


248
249
250
251
# File 'lib/tuile/component/scroller.rb', line 248

def move_scroll_top_row_by(delta)
  clamped = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
  self.scroll_top_row = clamped unless @scroll_top_row == clamped
end

#place_scrollbar ⇒ void

This method returns an undefined value.

The bar's whole state, pushed in one place: its column, and the two numbers it paints a handle from. It holds no authority over either — it asks through on_scroll_request and is told here.



187
188
189
190
191
# File 'lib/tuile/component/scroller.rb', line 187

def place_scrollbar
  @scrollbar.rect = scrollbar_visible? ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0)
  @scrollbar.row_count = @content_rows
  @scrollbar.scroll_top_row = @scroll_top_row
end

#relayout ⇒ void

This method returns an undefined value.

The content is as wide as the viewport minus the bar's columns, and as tall as it says it is — lifted by #scroll_top_row, which is what puts a negative top on a Tuile rect. Where all three knobs end: each moves the content's rect, the bar's handle, or both.



176
177
178
179
# File 'lib/tuile/component/scroller.rb', line 176

def relayout
  content&.rect = Rect.new(0, -@scroll_top_row, inner_width, content_height)
  place_scrollbar
end

#scroll_delta_for(rect) ⇒ Integer

Rows to scroll — positive down — to bring rect (in this component's coordinates) fully into view. The four cases are JViewport's: fully visible and covering the viewport both answer zero, and a rect too tall to fit aligns its top.

@param rect

Parameters:

Returns:

  • (Integer)


199
200
201
202
203
204
205
206
# File 'lib/tuile/component/scroller.rb', line 199

def scroll_delta_for(rect)
  top = rect.top
  bottom = top + rect.height
  return 0 if (top >= 0 && bottom <= viewport_rows) || (top <= 0 && bottom >= viewport_rows)
  return top if top.negative?

  [bottom - viewport_rows, top].min
end

#scroll_half_page_down ⇒ void

This method returns an undefined value.

#scroll_half_page_up's twin, clamped at the last row.



124
# File 'lib/tuile/component/scroller.rb', line 124

def scroll_half_page_down = move_scroll_top_row_by(half_page_rows)

#scroll_half_page_up ⇒ void

This method returns an undefined value.

Scrolls up half a viewport, clamped at the top — the verb to bind a key to, a scroller claiming none of its own.



120
# File 'lib/tuile/component/scroller.rb', line 120

def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)

#scroll_to_visible(rect = local_extent_rect) ⇒ void

This method returns an undefined value.

Scrolls the minimum that brings rect fully into the viewport, then passes the request on to whatever scrolls above this — see Tuile::Component#scroll_to_visible.

Nothing moves when rect is already visible, and nothing moves when it covers the viewport, so a focused child taller than the viewport doesn't jump to its own top every time it is re-focused. When it must move and still cannot fit, the top edge wins.

@param rect — in this component's coordinates.

Parameters:

  • rect (Rect) (defaults to: local_extent_rect)


136
137
138
139
140
141
142
143
144
145
# File 'lib/tuile/component/scroller.rb', line 136

def scroll_to_visible(rect = local_extent_rect)
  before = @scroll_top_row
  move_scroll_top_row_by(scroll_delta_for(rect))
  # The scroll just latched moved every rect beneath this scroller, and
  # layout is deferred — so settle before the request climbs on, or a
  # second request (a {FormItem} makes two) measures the content where it
  # used to be and scrolls again to undo this one.
  flush_layout unless before == @scroll_top_row
  super(rect.moved_by(Point.new(0, before - @scroll_top_row)))
end

#scroll_top_row_max ⇒ Integer

@return — the largest #scroll_top_row that still shows content in every viewport row; 0 when the content fits.

Returns:

  • (Integer)


241
# File 'lib/tuile/component/scroller.rb', line 241

def scroll_top_row_max = [@content_rows - viewport_rows, 0].max

#scrollbar_columns ⇒ Integer

The bar's column plus the blank one beside it, so content never runs into the glyph — TextView's reserve, including its drop below width 3 (D_scrollbar_reserve).

@return — 0, 1 or 2.

Returns:

  • (Integer)


220
221
222
223
224
# File 'lib/tuile/component/scroller.rb', line 220

def scrollbar_columns
  return 0 unless scrollbar_visible?

  rect.width >= 3 ? 2 : 1
end

#scrollbar_visible? ⇒ Boolean

Derived on every read, so #relayout places the bar and sizes the content from one answer.

Returns:

  • (Boolean)


229
230
231
232
233
234
235
236
237
# File 'lib/tuile/component/scroller.rb', line 229

def scrollbar_visible?
  return false if rect.empty?

  case @scrollbar_visibility
  when :visible then true
  when :auto then @content_rows > viewport_rows
  else false
  end
end

#validate_rows(rows, name) ⇒ Integer

@param rows

@param name — the accessor, for the message.

@return — rows.

Parameters:

  • rows (Integer)
  • name (Symbol)

Returns:

  • (Integer)


257
258
259
260
261
262
263
# File 'lib/tuile/component/scroller.rb', line 257

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

#viewport_rows ⇒ Integer

@return — rows of content on screen at once — Tuile::Component#rect's height, the bar taking a column rather than a row.

Returns:

  • (Integer)


62
# File 'lib/tuile/component/scroller.rb', line 62

def viewport_rows = rect.height