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
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).
Constructors
Class methods
Instance methods
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.
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.
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.
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.