Tryst::Switch
Inherits Tryst::OwnerDrawnWidget < Reference < Object
A Bootstrap/iOS-style on/off switch for tryst: a rounded-pill track, an antialiased thumb with a soft shadow, a ~120ms eased slide plus a track color crossfade on toggle. Built on OwnerDrawnWidget and rendered through tryst-vector - the flagship "this is what a custom widget looks like in tryst" showcase, deliberately built as boring and copyable a template as the two widget shards before it (tryst-spinner, tryst-value-slider).
switch = Tryst::Switch.new(app, value: true, text: "Dark mode")
switch.pack
switch.on_action { |on| puts "switch is now #{on ? "on" : "off"}" }
Nothing about canvas, Surface, or ThorVG is part of this class's own
public surface - same boundary OwnerDrawnWidget itself draws (see
its own doc comment on why #canvas is protected). There is no
ui.switch/DSL bind: wiring - see CUSTOM_WIDGETS.md's own doc on
why a stateful, animated widget like this one lives at the App
layer instead: a WidgetType hook only ever gets the narrow
AppContract, which has no Photo/#every access at all. A caller
wanting two-way sync with a Tryst::UI::Var wires it manually, same
as ValueSlider's own README documents for #on_change:
switch.on_action { |v| var.value = v } and
var.on_change { |v| switch.value = v }.
Constants
Reserved space around every side of the track for the ONE thing that's meant to draw past the track's own bounds: the focus ring (radius thumb_r + FOCUS_RING, centered on a thumb resting flush against the track's own edge - a focus ring bleeding outside the control it belongs to is the normal, accessible thing for it to do, same as a browser's own default focus outline). Overhang past the track edge = FOCUS_RING - THUMB_INSET = 2.0px at the defaults above. Without this margin, a Surface sized exactly to the track clips the ring at the buffer's own boundary - the same "AA-clipped by the buffer's own bounds" issue Spinner's own EDGE_MARGIN documents, just for a ring rather than a stroke. MARGIN sits comfortably above that 2.0px with room to spare for antialiasing.
The thumb's own shadow is deliberately NOT a reason for this margin - unlike the ring, a shadow spilling past the track's own filled shape onto the page behind it looks like a rendering bug, not a real drop shadow (confirmed against a real render: it did, before SHADOW_EXTRA_R/SHADOW_OFFSET_Y were sized down). At the off/on resting position the thumb sits exactly concentric with the track's own rounded end cap (both center on pill_left + track_h / 2), so containing the shadow within that cap - radius track_h / 2, i.e. thumb_r + THUMB_INSET - is one clean inequality: SHADOW_OFFSET_Y + SHADOW_EXTRA_R <= THUMB_INSET. The defaults above (0.5 + 1.2 = 1.7) stay under THUMB_INSET's 2.0 with a little slack for antialiasing at the exact edge.
Constructors
size: the one sizing knob, in logical pixels - track height; track width is size * TRACK_WIDTH_RATIO, thumb radius is inset from the track edge. Same "one knob scales everything" shape as Spinner's own size: - a switch has no independent aspect ratio worth exposing (unlike ValueSlider's explicit width:/height:).
text:/label_side: (:leading or :trailing) add an optional real Tk label beside the pill - tryst-vector has no text support (see its own README), so the label is never drawn into the Surface, the same reason ValueSlider's own bubble text is a real overlaid label rather than rendered chrome.
accent: nil uses the active ttk theme's own accent color, or a "#rrggbb" hex string to override it (same convention as ValueSlider/Spinner's own accent:). disabled_dim: how much the accent/track dim by when #disabled? is true (0.0 = invisible, 1.0 = no dimming at all) - the siblings hardcode this at 0.45; exposing it here is this widget's own extra knob, for a caller that wants a different disabled look than the default.
animate_set: whether a PROGRAMMATIC #value= slides the thumb (default) or jumps straight to the new state. A user toggle always animates either way - this knob only covers the Crystal-driven side of the same "user action vs Crystal-driven set" split #value= already draws. Pass false for a switch whose value gets pushed in from saved state: an app that populates a settings panel during startup otherwise arms one 16ms tween per switch, animating a window nobody is looking at yet, and each of those tweens reports the rest of startup as timer lateness on the way through.
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 toggle (click, Space, Return) - never for a programmatic #value=.
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 the state programmatically - animates the same as a user toggle unless the widget was built with animate_set: false, in which case the thumb jumps straight to the new state. Never fires #on_action either way. See this class's own doc comment on why setting this does NOT fire #on_action (the same "user action vs Crystal-driven set" split every stateful widget in this codebase draws).