Source code for wijjit.elements.input.toggle

"""Toggle switch element for boolean input.

This module provides the Toggle element for creating visual switch controls
distinct from checkboxes. Supports single label mode (label after switch)
and dual label mode (labels on both sides showing On/Off states).
"""

from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Literal

from wijjit.elements.base import Element, ElementType, invoke_callback
from wijjit.terminal.ansi import supports_unicode
from wijjit.terminal.input import Key, Keys
from wijjit.terminal.mouse import MouseButton, MouseEvent, MouseEventType

if TYPE_CHECKING:
    from wijjit.rendering.paint_context import PaintContext


LabelMode = Literal["single", "dual"]


[docs] class Toggle(Element): """Toggle switch element for boolean values. A toggle switch provides a clear visual indicator for on/off states, distinct from a checkbox. Uses colored block characters to show state. Supports two label modes: - Single mode: Label displayed after the switch - Dual mode: Labels on both sides showing Off/On states Parameters ---------- id : str, optional Element identifier for state binding checked : bool, optional Initial checked state (default: False) label : str, optional Label for single mode, displayed after switch on_label : str, optional Label shown for "on" state in dual mode (default: "ON") off_label : str, optional Label shown for "off" state in dual mode (default: "OFF") label_mode : {"single", "dual"}, optional Label display mode (default: "single") Attributes ---------- checked : bool Current checked state label : str or None Label text (single mode) on_label : str On state label (dual mode) off_label : str Off state label (dual mode) label_mode : str Label display mode on_change : callable or None Callback (old_value, new_value) when the checked state changes on_action : callable or None Callback when the toggle is activated (space/enter/click) action : str or None Action ID to dispatch (set by template extension) bind : bool Whether to auto-bind to state[id] (default: True) Notes ----- Keyboard controls: - Space/Enter: Toggle the switch Mouse controls: - Click: Toggle the switch Visual representation (Unicode): - Single mode (off): Gray track with knob on right + Label - Single mode (on): Green track with knob on left + Label - Dual mode: OFF/ON labels on sides with colored switch Visual representation (ASCII): - Off: [ O] or OFF [ O] ON - On: [O ] or OFF [O ] ON """ # Unicode block characters TRACK_CHAR = "\u2591" # Light shade block for track KNOB_CHAR = "\u2588" # Full block for knob # ASCII fallback TRACK_CHAR_ASCII = "-" KNOB_CHAR_ASCII = "O" # Colors ON_COLOR = (0, 200, 0) # Green when on OFF_COLOR = (100, 100, 100) # Gray when off FOCUS_COLOR = (0, 255, 255) # Cyan when focused
[docs] def __init__( self, id: str | None = None, classes: str | list[str] | set[str] | None = None, tab_index: int | None = None, checked: bool = False, label: str | None = None, on_label: str = "ON", off_label: str = "OFF", label_mode: LabelMode = "single", bind: bool | str = True, ) -> None: super().__init__(id=id, classes=classes, tab_index=tab_index) self.element_type = ElementType.BUTTON # Interactive like button self.focusable = True self._checked = checked self.label = label self.on_label = on_label self.off_label = off_label self.label_mode = label_mode # Callbacks. ``on_action`` is the "user activated it" hook, aligning # Toggle with Checkbox/Radio. self.on_change: Callable[[bool, bool], None] | None = None self.on_action: Callable[[], None] | None = None # Template metadata self.action: str | None = None self.bind: bool | str = bind
@property def checked(self) -> bool: """Get checked state. Returns ------- bool Current checked state """ return self._checked @checked.setter def checked(self, value: bool) -> None: """Set checked state, triggering callbacks if changed. Parameters ---------- value : bool New checked state """ old_value = self._checked self._checked = value if old_value != value: if self.on_change: invoke_callback(self.on_change, old_value, value)
[docs] def toggle(self) -> None: """Toggle the current state. Flips the checked state (which fires ``on_change``) and then fires ``on_action``. """ self.checked = not self._checked if self.on_action: invoke_callback(self.on_action)
[docs] def render_signature(self) -> Any: """Paint memo for the skip-unchanged fast path. Captures the label mode and the three label strings; the knob position, track color, and active-label choice all follow from ``checked``/``focused`` (folded into :meth:`_style_signature`). See :meth:`wijjit.elements.base.Element.render_signature`. """ return ( "toggle", self.label_mode, self.label, self.on_label, self.off_label, self._style_signature(), )
[docs] def render_to(self, ctx: "PaintContext") -> None: """Render toggle switch to paint context. Parameters ---------- ctx : PaintContext Paint context with buffer, style resolver, and bounds Theme styles: This element uses the following theme style classes for colors: - ``toggle.on``: On state color (default: green) - ``toggle.off``: Off state color (default: gray) - ``toggle:focus``: Focused state color (default: cyan) - ``toggle.label``: Label text style - ``toggle.label.active``: Active label style (dual mode) - ``toggle.label.inactive``: Inactive label style (dual mode) Example CSS to customize colors:: toggle.on { color: rgb(0, 255, 0); } toggle.off { color: rgb(255, 0, 0); } """ from wijjit.styling.style import Style # Determine characters based on unicode support use_unicode = supports_unicode() if use_unicode: track_char = self.TRACK_CHAR knob_char = self.KNOB_CHAR else: track_char = self.TRACK_CHAR_ASCII knob_char = self.KNOB_CHAR_ASCII # Resolve colors from theme, fall back to defaults # Theme can define: toggle.on, toggle.off, toggle:focus on_style = ctx.style_resolver.resolve_style(self, "toggle.on") off_style = ctx.style_resolver.resolve_style(self, "toggle.off") focus_style = ctx.style_resolver.resolve_style(self, "toggle:focus") # Use theme colors if defined, otherwise use defaults on_color = on_style.fg_color if on_style.fg_color else self.ON_COLOR off_color = off_style.fg_color if off_style.fg_color else self.OFF_COLOR focus_color = focus_style.fg_color if focus_style.fg_color else self.FOCUS_COLOR # Determine colors based on state if self.focused: track_color = focus_color knob_color = (255, 255, 255) # White knob when focused elif self._checked: track_color = on_color knob_color = (255, 255, 255) # White knob else: track_color = off_color knob_color = (200, 200, 200) # Light gray knob track_style = Style(fg_color=track_color) knob_style = Style(fg_color=knob_color, bold=True) label_style = ctx.style_resolver.resolve_style(self, "toggle.label") active_label_style = ctx.style_resolver.resolve_style( self, "toggle.label.active" ) inactive_label_style = ctx.style_resolver.resolve_style( self, "toggle.label.inactive" ) x_offset = 0 if self.label_mode == "dual": # Dual mode: OFF [switch] ON # Off label off_text = f"{self.off_label} " if self._checked: ctx.write_text(x_offset, 0, off_text, inactive_label_style) else: ctx.write_text(x_offset, 0, off_text, active_label_style) x_offset += len(off_text) # Render switch: 4 chars total [knob + track or track + knob] if self._checked: # On: knob on left [O---] ctx.write_text(x_offset, 0, knob_char, knob_style) ctx.write_text(x_offset + 1, 0, track_char * 3, track_style) else: # Off: knob on right [---O] ctx.write_text(x_offset, 0, track_char * 3, track_style) ctx.write_text(x_offset + 3, 0, knob_char, knob_style) x_offset += 4 # On label on_text = f" {self.on_label}" if self._checked: ctx.write_text(x_offset, 0, on_text, active_label_style) else: ctx.write_text(x_offset, 0, on_text, inactive_label_style) else: # Single mode: [switch] label # Render switch: 4 chars total if self._checked: # On: knob on left [O---] ctx.write_text(x_offset, 0, knob_char, knob_style) ctx.write_text(x_offset + 1, 0, track_char * 3, track_style) else: # Off: knob on right [---O] ctx.write_text(x_offset, 0, track_char * 3, track_style) ctx.write_text(x_offset + 3, 0, knob_char, knob_style) x_offset += 4 if self.label: ctx.write_text(x_offset, 0, f" {self.label}", label_style)
[docs] def get_intrinsic_size(self) -> tuple[int, int]: """Get the intrinsic (preferred) size of the toggle. Returns ------- tuple[int, int] (width, height) based on mode and labels """ switch_width = 4 # 4-character switch (knob + 3 track chars) if self.label_mode == "dual": # OFF [switch] ON width = len(self.off_label) + 1 + switch_width + 1 + len(self.on_label) else: # [switch] label width = switch_width if self.label: width += 1 + len(self.label) # space + label return (width, 1)
[docs] def handle_key(self, key: Key) -> bool: """Handle keyboard input. Parameters ---------- key : Key Key press to handle Returns ------- bool True if key was handled """ if key == Keys.SPACE or key == Keys.ENTER: self.toggle() return True return False
[docs] async def handle_mouse(self, event: MouseEvent) -> bool: """Handle mouse click to toggle. Parameters ---------- event : MouseEvent Mouse event to handle Returns ------- bool True if event was handled """ # Delegate to the base handler first so double-click / context-menu # callbacks fire even though this element consumes click events. if await super().handle_mouse(event): return True if event.type in (MouseEventType.CLICK, MouseEventType.DOUBLE_CLICK): if event.button == MouseButton.LEFT: self.toggle() return True return False