Class: Tuile::Component::ProgressBar

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

Overview

A one-row progress bar: a run of growing left to right across #rect, over a track.

████████░░░░░░░░░░░░

bar   = Component::ProgressBar.new(range: 0..files.size)
label = Component::Label.new
add(bar)
add(label)

def rect=(new_rect)             # the enclosing Layout positions both
super
bar.rect   = Rect.new(rect.left, rect.top, rect.width, 1)
label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
end

bar.value  = done
label.text = "#{bar.percent}% — #{done}/#{files.size}"

The bar paints no text of its own: put a Label beside it and feed it #percent or #fraction, so the app words it ("42% — 3/7 files") and places it freely. Display-only — not focusable, no keys, no mouse.

While the total is still unknown, #indeterminate= swaps the fill for a block sliding across the bar:

░░░░░░░████░░░░░░░░░

Both endpoints are exact: the bar is full only at #max and empty only at #min, so a full bar always means done. Assign a one-row #rect; a taller one paints the bar on its first row and leaves the rest to the background.

Implementation details

The / pair is the same one VerticalScrollBar uses — East-Asian Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal the rendered length would vary with the fill level. Shipped anyway, per DECISIONS.md D-ambiguous-width: a bar that rhymes with the scrollbar beats a third convention, and if that bet is ever reversed both swap together.

Constant Summary collapse

DEFAULT_RANGE =

Range covering the whole bar when none is given.

Returns:

  • (Range)
(0.0..1.0)
INDETERMINATE_FPS =

Frames per second of the indeterminate animation. The block advances one cell per frame, so this is also its speed in cells/second.

Returns:

  • (Integer)
5
BLOCK_DIVISOR =

The indeterminate block is this fraction of the bar, at least one cell.

Returns:

  • (Integer)
5

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(range: DEFAULT_RANGE, value: nil, indeterminate: false) ⇒ ProgressBar

@param range — initial #range=.

@param value — initial #value=; nil starts at the range's lower bound.

@param indeterminate — initial #indeterminate=.

Parameters:

  • range: (::Range[untyped]) (defaults to: DEFAULT_RANGE)
  • value: (Numeric, nil) (defaults to: nil)
  • indeterminate: (Boolean) (defaults to: false)


62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/tuile/component/progress_bar.rb', line 62

def initialize(range: DEFAULT_RANGE, value: nil, indeterminate: false)
  super()
  @value = 0.0
  @min = 0.0
  @max = 1.0
  @phase = 0
  @ticker = nil
  @indeterminate = false
  @bar_color = nil
  self.range = range
  self.value = value unless value.nil?
  self.indeterminate = indeterminate
end

Instance Attribute Details

#bar_colorColor, ...

@return — the value as set, so a Theme::Ref comes back unresolved. Both glyphs paint in it; nil (the default) is the terminal's default foreground.

Returns:



85
86
87
# File 'lib/tuile/component/progress_bar.rb', line 85

def bar_color
  @bar_color
end

#maxFloat (readonly)

@return — upper bound of #range.

Returns:

  • (Float)


80
81
82
# File 'lib/tuile/component/progress_bar.rb', line 80

def max
  @max
end

#minFloat (readonly)

@return — lower bound of #range.

Returns:

  • (Float)


77
78
79
# File 'lib/tuile/component/progress_bar.rb', line 77

def min
  @min
end

#valueFloat, Numeric

@return — the progress, clamped into #range when assigned — so bar.value = 999 on a 0..250 bar reads back as 250.0.

Returns:

  • (Float, Numeric)


119
120
121
# File 'lib/tuile/component/progress_bar.rb', line 119

def value
  @value
end

Instance Method Details

#block_at(width) ⇒ [Integer, Integer]

Where the sliding block sits this frame: it enters at the left edge and leaves at the right, one cell per frame, then loops. The period is one short of width + block so at least one cell is always lit — a full width + block blanks the bar for exactly one frame per cycle.

@param width — columns available.

@return — start column and length, clipped.

Parameters:

  • width (Integer)

Returns:

  • ([Integer, Integer])


242
243
244
245
246
247
248
# File 'lib/tuile/component/progress_bar.rb', line 242

def block_at(width)
  block = [width / BLOCK_DIVISOR, 1].max
  start = (@phase % (width + block - 1)) - (block - 1)
  first = [start, 0].max
  last = [start + block, width].min
  [first, last - first]
end

#fractionFloat

@return#value as 0.0..1.0. 1.0 when the range is empty.

Returns:

  • (Float)


139
140
141
142
143
# File 'lib/tuile/component/progress_bar.rb', line 139

def fraction
  return 1.0 if @max == @min

  (@value - @min) / (@max - @min)
end

#glyphs(width) ⇒ String

@param width — columns available.

@return — the row, width glyphs wide.

Parameters:

  • width (Integer)

Returns:

  • (String)


231
232
233
234
# File 'lib/tuile/component/progress_bar.rb', line 231

def glyphs(width)
  start, length = @indeterminate ? block_at(width) : [0, scale(width)]
  [("" * start), ("" * length), ("" * (width - start - length))].join
end

#indeterminate=(flag) ⇒ void

This method returns an undefined value.

Switches between the fill and the sliding block. #value keeps working while indeterminate — it is simply not painted — so switching back shows the progress that accumulated meanwhile.

The animation only runs while the bar is attached, and stops on detach. It also keeps the event loop awake at INDETERMINATE_FPS, so turn it off (or remove the bar) when the job ends.

@param flag — coerced; truthiness decides.

Parameters:

  • flag (Boolean)


185
186
187
188
189
190
191
192
# File 'lib/tuile/component/progress_bar.rb', line 185

def indeterminate=(flag)
  flag = flag ? true : false
  return if @indeterminate == flag

  @indeterminate = flag
  sync_ticker
  invalidate # the picture changes now, not on the next frame
end

#indeterminate?Boolean

@return — whether the sliding-block animation is showing.

Returns:

  • (Boolean)


173
# File 'lib/tuile/component/progress_bar.rb', line 173

def indeterminate? = @indeterminate

#on_attachedvoid

This method returns an undefined value.



195
# File 'lib/tuile/component/progress_bar.rb', line 195

def on_attached = sync_ticker

#on_detachedvoid

This method returns an undefined value.



198
# File 'lib/tuile/component/progress_bar.rb', line 198

def on_detached = sync_ticker

#percentInteger

@return#fraction as 0..100, floored — 100 means done and nothing else does, matching the painted bar exactly.

Returns:

  • (Integer)


147
# File 'lib/tuile/component/progress_bar.rb', line 147

def percent = scale(100)

#range::Range[untyped]

@return — the scale #value is measured against.

Returns:

  • (::Range[untyped])


88
# File 'lib/tuile/component/progress_bar.rb', line 88

def range = @min..@max

#range=(new_range) ⇒ void

This method returns an undefined value.

Replaces the scale, re-clamping #value into it. min == max is legal and reads as complete — a zero-length job has nothing outstanding — so bar.range = 0..files.size needs no special case for an empty list.

@param new_range — inclusive; endpoints Numeric and finite.

Parameters:

  • new_range (::Range[untyped])


100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/tuile/component/progress_bar.rb', line 100

def range=(new_range)
  raise ArgumentError, "range must be inclusive, got #{new_range.inspect}" if new_range.exclude_end?

  min = Float(new_range.begin)
  max = Float(new_range.end)
  raise ArgumentError, "range end #{max} is below its start #{min}" if max < min

  unless min.finite? && max.finite?
    raise ArgumentError, "range endpoints must be finite (use indeterminate = true)"
  end

  @min = min
  @max = max
  self.value = @value
  invalidate # the scale moved even when the clamped value did not
end

#repaintvoid

This method returns an undefined value.

Paints the bar on the first row of Tuile::Component#rect and blanks the rest.

Deliberately not super: Tuile::Component#repaint's default blanks the whole rect, which dirties every cell of the bar's own row before it is painted over — so Buffer#flush re-emits the entire row every frame instead of the one or two cells that actually moved.



207
208
209
210
211
212
# File 'lib/tuile/component/progress_bar.rb', line 207

def repaint
  return if rect.empty?

  draw_line(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
  clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
end

#resolved_bar_colorColor?

Returns:



251
252
253
# File 'lib/tuile/component/progress_bar.rb', line 251

def resolved_bar_color
  @bar_color.is_a?(Theme::Ref) ? @bar_color.resolve(screen.theme) : @bar_color
end

#scale(steps) ⇒ Integer

Filled cells out of steps — the rect width when painting, 100 for #percent, so the bar and a Label showing the percentage can never disagree about being done.

@param steps

Parameters:

  • steps (Integer)

Returns:

  • (Integer)


221
222
223
224
225
226
227
# File 'lib/tuile/component/progress_bar.rb', line 221

def scale(steps)
  return 0 if fraction <= 0.0
  return steps if fraction >= 1.0
  return 0 if steps < 2 # no interior to land in; fills only when done

  (fraction * steps).floor.clamp(1, steps - 1)
end

#sync_tickervoid

This method returns an undefined value.

Brings the ticker in line with "animating and on screen". The sole writer of @ticker, and idempotent, so the attach/detach hooks and #indeterminate= are all the same call and a repeated indeterminate = true cannot start a second one.



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/tuile/component/progress_bar.rb', line 260

def sync_ticker
  want = attached? && @indeterminate
  return if want == !@ticker.nil?

  if want
    @ticker = screen.event_queue.tick_fps(INDETERMINATE_FPS) do |tick|
      # The paint that already happened is frame 0; a ticker's first
      # firing is one interval later, so it is frame 1.
      @phase = tick + 1
      invalidate
    end
  else
    @ticker.cancel
    @ticker = nil
  end
end