class

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

FOCUS_RING = 4.0
LABEL_GAP = 8.0
MARGIN = 4.0

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.

SHADOW_ALPHA = 60_u8
SHADOW_EXTRA_R = 1.2
SHADOW_OFFSET_Y = 0.5
THUMB_INSET = 2.0
TOGGLE_TWEEN_MS = 120
TRACK_WIDTH_RATIO = 1.8

Constructors

new(app : App, value : Bool = false, text : String | Nil = nil, label_side : Symbol = :trailing, accent : String | Nil = nil, disabled_dim : Float64 = 0.45, font : String = "TkDefaultFont", size : Int32 = 24, animate_set : Bool = true, parent = nil)

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.

Source

Instance methods

animate_set?

Whether a programmatic #value= animates - see #initialize.

Source
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
label_side
Source
on_action

Fires on every user-driven toggle (click, Space, Return) - never for a programmatic #value=.

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
value

The current on/off state.

Source
value=(new_value : Bool) : Bool

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).

Source