class

Tryst::RangeSlider

Inherits Tryst::OwnerDrawnWidget < Reference < Object

A dual-thumb range slider. Built on OwnerDrawnWidget, same App-layer pattern as ValueSlider/Switch/SegmentedControl: no ui.<type>, no bind: (see CUSTOM_WIDGETS.md for why a stateful, animated widget doesn't fit the WidgetType/AppContract seam).

slider = Tryst::RangeSlider.new(app, min: 0.0, max: 100.0, low: 20.0, high: 70.0)
slider.pack
slider.on_action { |(low, high)| puts "now #{low}..#{high}" }

One canvas, two thumbs, one focus

OwnerDrawnWidget tracks exactly one hover/focused/pressed state per canvas - there's no per-region concept, and layering a second real Tk widget over a thumb to give it its own focus/hit-target doesn't work either: a plain Tk widget is OPAQUE (paints its own solid -background), and there's no flat color that could stand in for an antialiased circle sitting on a track that's sometimes fill-colored and sometimes not. So this widget has ONE real Tab stop (the canvas), same as every sibling, plus @active_thumb tracking which of the two DRAWN thumbs currently owns keyboard input and the focus ring. Clicking either thumb (nearest-hit-test in #on_press, not a second widget) makes it active; Tab, while the canvas already has focus, cycles active thumb low->high without leaving the widget, then lets a second Tab through to real Tab traversal once already on high (signal.break! is what makes that non-trapping - see #initialize).

Value flow

#low=/#high=/#set_range set values programmatically and do NOT fire #on_action - only a user-driven change (drag, click-to-position, keyboard) does, the same split every other stateful widget in this codebase draws. The two thumbs can never cross: each clamps against the other's CURRENT position plus min_gap (defaults to step), so dragging one past the other simply stops it min_gap away rather than swapping which is "low" and which is "high".

Constants

BIG_STEP_MULT = 10.0
BUBBLE_ARROW_H = 5.0
BUBBLE_ARROW_W = 10.0
BUBBLE_GAP = 10.0
BUBBLE_MARGIN = 4.0
FOCUS_RING = 5.0
HIDE_DELAY_MS = 3000
LABEL_GAP = 6.0
MARGIN = 14.0

Layout constants, all in logical pixels - see ValueSlider's own comment on why these aren't constructor options (a slider that wants a genuinely different look is a different widget).

SHOW_MS = 140
THUMB_RADIUS = 9.0
TRACK_HEIGHT = 8.0

Constructors

new(app : App, min : Float64 = 0.0, max : Float64 = 100.0, step : Float64 = 1.0, low : Float64 | Nil = nil, high : Float64 | Nil = nil, min_gap : Float64 | Nil = nil, format : Proc(Float64, String) = ->(v : Float64) do v.round.to_i.to_s end, accent : String | Nil = nil, bubble_width : Int32 | Nil = nil, font : String = "TkTextFont", width : Int32 = 260, height : Int32 = 104, parent = nil)
Source

Class methods

snap(raw : Float64, min : Float64, max : Float64, step : Float64) : Float64

See ValueSlider.snap - identical pure function, duplicated rather than shared (no shared "widgets common" dependency between independently-distributable shards - established precedent, not an oversight).

Source

Instance methods

destroy

Releases the underlying canvas (and its Photo, if any) now, rather than waiting for a collection - same contract as Photo#delete. Bind-callback cleanup for #canvas's own path happens for free (see App's setup_destroy_cleanup, installed unconditionally for every widget); only this class's own extra state (running tweens) needs explicit teardown here.

Source
high
Source
high=(new_value : Float64) : Float64
Source
low=(new_value : Float64) : Float64
Source
min_gap
Source
on_action

Fires on every user-driven change to either thumb (drag, click- to-position, keyboard) - never for a programmatic #low=/#high=/ #set_range. The block receives {low, high} together, current values for both regardless of which thumb actually moved.

Source
redraw

Subclasses draw here - called after every resize and every state change (#hover?/#pressed?/#focused?/#disabled? all changing). Given no arguments deliberately: #canvas's own current width/height (via #canvas.width/#canvas.height, i.e. real winfo queries) are always the authority, not a value that could go stale between when a resize fired and when this actually runs.

Source
set_range(low : Float64, high : Float64) : Tuple(Float64, Float64)

Sets both bounds together, avoiding the "clamped against the OTHER thumb's stale position" trap of calling #low=/#high= separately when moving both at once (e.g. shifting a [10, 20] window to [40, 60] one at a time would clamp the new low against the still-old high first). low is resolved first and wins any conflict with the requested high.

Source
step
Source

Nested types