""" Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos """ from __future__ import annotations import time from collections.abc import Callable import pyray as rl from iqpilot.system.ui.iqwidgets.lib.styles import ink, metrics from iqpilot.system.ui.widgets.button import Button, ButtonStyle from iqpilot.system.ui.iqwidgets.lib import canvas from iqpilot.system.ui.widgets.scroller_tici import LineSeparator, LINE_COLOR, LINE_PADDING from iqpilot.system.ui.lib.text_measure import measure_text_cached from iqpilot.system.ui.widgets.toggle import Toggle import unicodedata from iqpilot.common.params import Params, UnknownKeyName from iqpilot.system.ui.widgets.label import gui_label from iqpilot.system.ui.lib.application import gui_app, MousePos, FontWeight from iqpilot.system.ui.widgets import Widget from iqpilot.system.ui.widgets.list_view import (ListItem, ToggleAction, ItemAction, MultipleButtonAction, ButtonAction, _resolve_value, DualButtonAction) _Dyn = str | Callable[[], str] # a literal or a late-bound provider _Pred = Callable[[], bool] LABEL_WIDTH = 350 # hoisted: used as a default arg in option_item before the option-control section _SPINNER_TEAL = rl.Color(16, 185, 169, 255) _SPINNER_HALO = rl.Color(255, 255, 255, 26) class Spacer(Widget): def __init__(self, height: int = 1): super().__init__() self._rect = rl.Rectangle(0, 0, 0, height) def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: super().set_parent_rect(parent_rect) self._rect.width = parent_rect.width def _render(self, _): pass class IQLineSeparator(LineSeparator): def __init__(self, height: int = 1): super().__init__() self._rect = rl.Rectangle(0, 0, 0, height) def _render(self, _): mid_y = int(self._rect.y + self._rect.height // 2) rl.draw_line(int(self._rect.x) + LINE_PADDING, mid_y, int(self._rect.x + self._rect.width) - LINE_PADDING, mid_y, LINE_COLOR) class IQToggleAction(ToggleAction): def __init__(self, initial_state: bool = False, width: int = metrics.TOGGLE_W, enabled: bool | _Pred = True, callback: Callable[[bool], None] | None = None, param: str | None = None): super().__init__(initial_state, width, enabled, callback) self.toggle = IQToggle(initial_state=initial_state, callback=callback, param=param) class SafeIQToggleAction(IQToggleAction): """Toggle bound to a param that may be missing from the build's registry. IQToggle's ParamSlot already degrades reads/writes on unknown keys; this adds the caller-chosen fallback state and skips the callback on dead keys. """ def __init__(self, param: str, default_on: bool = True, width: int = metrics.TOGGLE_W, enabled: bool | _Pred = True, callback: Callable[[bool], None] | None = None): slot = ParamSlot(param) def _guarded(state: bool): if slot.write(state) and callback: callback(state) super().__init__(initial_state=slot.read_bool(default_on), width=width, enabled=enabled, callback=_guarded, param=param) self.toggle._slot = slot # share one slot; IQToggle re-syncs from it every frame class IQButton(Button): def _update_state(self): super()._update_state() self._background_color, label_color = self._palette() if label_color is not None: self._label.set_text_color(label_color) def _palette(self) -> tuple[rl.Color, rl.Color | None]: if not self.enabled: return ink.PUSH_DISABLED, ink.PUSH_TEXT_DISABLED if self._button_style == ButtonStyle.PRIMARY: # honor the "on"/active style (e.g. Onroad Uploads, Always Offroad, Force On-Road) — otherwise # set_button_style(PRIMARY) was ignored and toggles stayed grey with no visual feedback c = ink.KEY_ACTION if self.is_pressed: return rl.Color(min(255, c.r + 22), min(255, c.g + 22), min(255, c.b + 22), 255), None return c, None return (ink.PUSH_PRESSED if self.is_pressed else ink.PUSH), None class NavSectionButton(IQButton): """A clean navigable section row: icon (left) + label + chevron (right), full width.""" def __init__(self, text, icon_path: str | None = None, click_callback: Callable | None = None): super().__init__(text, click_callback=click_callback, button_style=ButtonStyle.NORMAL, text_padding=0) self._label_src = text self._icon = gui_app.texture(icon_path, 64, 64, keep_aspect_ratio=True) if icon_path else None self._chevron = gui_app.texture("icons/iq/chevron_right.png", 50, 50, keep_aspect_ratio=True) self._border_radius = 40 def _render(self, _): rect = self._rect roundness = self._border_radius / (min(rect.width, rect.height) / 2) canvas.panel(rect, roundness, 10, self._background_color) cy = rect.y + rect.height / 2 x = rect.x + 44 if self._icon: rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE) x += self._icon.width + 30 text = self._label_src() if callable(self._label_src) else self._label_src font = gui_app.font(FontWeight.MEDIUM) fs = 58 ts = measure_text_cached(font, text, fs) canvas.glyphs(font, text, rl.Vector2(int(x), int(cy - ts.y / 2)), fs, rl.WHITE) rl.draw_texture(self._chevron, int(rect.x + rect.width - 44 - self._chevron.width), int(cy - self._chevron.height / 2), rl.Color(170, 172, 178, 255)) class IQSimpleButtonAction(ItemAction): """A single wide standalone button occupying the row.""" def __init__(self, button_text: _Dyn, callback: Callable | None = None, enabled: bool | _Pred = True, button_width: int = metrics.WIDE_BTN_W): super().__init__(width=button_width, enabled=enabled) self.button_action = IQButton(button_text, click_callback=callback, button_style=ButtonStyle.NORMAL, border_radius=48) def set_touch_valid_callback(self, touch_callback: _Pred) -> None: super().set_touch_valid_callback(touch_callback) self.button_action.set_touch_valid_callback(touch_callback) def _render(self, rect: rl.Rectangle) -> bool | int | None: self.button_action.set_enabled(self.enabled) return self.button_action.render(rect) class IQButtonAction(ButtonAction): """Stock button action plus a colourable readout and an in-progress spinner.""" def __init__(self, text: _Dyn, width: int = metrics.ACTION_W, enabled: bool | _Pred = True): super().__init__(text=text, width=width, enabled=enabled) self._readout_tint: rl.Color = ink.READOUT self._loading = False def set_value(self, value: _Dyn, color: rl.Color = ink.READOUT): self._value_source = value self._readout_tint = color def set_loading(self, loading: bool): self._loading = loading def _button_only(self, rect: rl.Rectangle) -> bool: """Let the base draw just the trailing button by hiding the value for one pass.""" stash, self._value_source = self._value_source, None try: return super()._render(rect) finally: self._value_source = stash @staticmethod def _draw_spinner(cx: float, cy: float): ang = (rl.get_time() * 280) % 360 canvas.annulus(rl.Vector2(cx, cy), 16, 24, 0, 360, 40, _SPINNER_HALO) canvas.annulus(rl.Vector2(cx, cy), 16, 24, ang, ang + 100, 28, _SPINNER_TEAL) def _render(self, rect: rl.Rectangle) -> bool: from iqpilot.system.ui.widgets.list_view import BUTTON_WIDTH, TEXT_PADDING if self._loading: pressed = self._button_only(rect) # teal spinner sits in the readout slot, next to the button self._draw_spinner(rect.x + rect.width - BUTTON_WIDTH - TEXT_PADDING - 28, rect.y + rect.height / 2) return pressed value = self.value if not value: return super()._render(rect) pressed = self._button_only(rect) body = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) if measure_text_cached(self._font, value, metrics.TITLE_FS).x > body.width: self._value_label.set_text(value) self._value_label.set_font_size(metrics.TITLE_FS) self._value_label.set_text_color(self._readout_tint) self._value_label.render(body) else: gui_label(body, value, font_size=metrics.TITLE_FS, color=self._readout_tint, font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) return pressed class IQDualButtonAction(DualButtonAction): BUTTON_GAP = 20 BUTTON_H = 150 def __init__(self, left_text: _Dyn, right_text: _Dyn, left_callback: Callable | None = None, right_callback: Callable | None = None, enabled: bool | _Pred = True, border_radius: int = 40): super().__init__(left_text, right_text, left_callback, right_callback, enabled) # Neutral left action uses IQButton (lighter grey + IQ press states); right keeps its style (e.g. DANGER). self.left_button = IQButton(left_text, click_callback=left_callback, button_style=ButtonStyle.NORMAL, text_padding=0) self.left_button._border_radius = self.right_button._border_radius = border_radius def _pair_rects(self, rect: rl.Rectangle) -> tuple[rl.Rectangle, rl.Rectangle]: w = (rect.width - self.BUTTON_GAP) / 2 y = rect.y + (rect.height - self.BUTTON_H) / 2 left = rl.Rectangle(rect.x, y, w, self.BUTTON_H) right = rl.Rectangle(rect.x + w + self.BUTTON_GAP, y, w, self.BUTTON_H) if not self.left_button.is_visible: right = rl.Rectangle(rect.x, y, rect.width, self.BUTTON_H) elif not self.right_button.is_visible: left = rl.Rectangle(rect.x, y, rect.width, self.BUTTON_H) return left, right def _render(self, rect: rl.Rectangle): left, right = self._pair_rects(rect) self.left_button.render(left) self.right_button.render(right) # Segmented (multi-button) control — dark container with a teal "active" pill + soft glow SEG_CONTAINER_BG = rl.Color(42, 45, 52, 255) SEG_CONTAINER_BORDER = rl.Color(255, 255, 255, 22) SEG_ACTIVE = rl.Color(18, 191, 173, 255) SEG_TEXT_SELECTED = rl.Color(8, 16, 16, 255) SEG_H_PAD = 8 SEG_V_PAD = 9 SEG_TEXT_PAD = 16 _DOUBLE_CLICK_S = 0.5 class IQMultipleButtonAction(MultipleButtonAction): def __init__(self, buttons: list[_Dyn], button_width: int, selected_index: int = 0, callback: Callable | None = None, param: str | None = None): super().__init__(buttons, button_width, selected_index, callback) self._slot = ParamSlot(param) if self._slot.bound: self.selected_button = self._slot.read_int(selected_index) self._anim_x: float | None = None self._double_click_callbacks: dict[int, Callable] = {} self._last_click: tuple[int, float] = (-1, 0.0) @property def param_key(self): return self._slot.key def set_double_click_callback(self, button_index: int, callback: Callable) -> None: self._double_click_callbacks[button_index] = callback def _track_rect(self) -> rl.Rectangle: y = self._rect.y + (self._rect.height - metrics.ROW_BTN_H) / 2 return rl.Rectangle(self._rect.x, y, self._rect.width, metrics.ROW_BTN_H) def _segment_at(self, mouse_pos: MousePos) -> int: track = self._track_rect() if not rl.check_collision_point_rec(mouse_pos, track): return -1 return min(len(self.buttons) - 1, int((mouse_pos.x - track.x) / (track.width / len(self.buttons)))) def _render(self, rect: rl.Rectangle): track = rl.Rectangle(rect.x, rect.y + (rect.height - metrics.ROW_BTN_H) / 2, rect.width, metrics.ROW_BTN_H) seg_w = track.width / len(self.buttons) selected_enabled = self._is_button_enabled(self.selected_button) # Dark container canvas.panel(track, 0.35, 20, SEG_CONTAINER_BG) canvas.panel_outline(track, 0.35, 20, 2, SEG_CONTAINER_BORDER if self.enabled else ink.FAINT) # Animated active pill target_x = track.x + self.selected_button * seg_w self._anim_x = target_x if self._anim_x is None else self._anim_x + (target_x - self._anim_x) * 0.2 pill = rl.Rectangle(self._anim_x + SEG_H_PAD, track.y + SEG_V_PAD, seg_w - 2 * SEG_H_PAD, track.height - 2 * SEG_V_PAD) if selected_enabled: # Soft teal glow behind the active pill for grow, alpha in ((16, 16), (10, 28), (5, 44)): halo = rl.Rectangle(pill.x - grow, pill.y - grow, pill.width + 2 * grow, pill.height + 2 * grow) canvas.panel(halo, 0.6, 20, rl.Color(SEG_ACTIVE.r, SEG_ACTIVE.g, SEG_ACTIVE.b, alpha)) canvas.panel(pill, 0.6, 20, SEG_ACTIVE) else: canvas.panel(pill, 0.6, 20, ink.FAINT) # Labels, shrunk to fit their segment for i, source in enumerate(self.buttons): text = _resolve_value(source, "") fs = 40 limit = max(1, seg_w - SEG_TEXT_PAD * 2) ts = measure_text_cached(self._font, text, fs) while ts.x > limit and fs > 30: fs -= 2 ts = measure_text_cached(self._font, text, fs) if i == self.selected_button and selected_enabled: color = SEG_TEXT_SELECTED elif self._is_button_enabled(i): color = ink.TITLE else: color = ink.FAINT rl.draw_text_ex(self._font, text, rl.Vector2(track.x + i * seg_w + (seg_w - ts.x) / 2, track.y + (track.height - ts.y) / 2), fs, 0, color) def _handle_mouse_release(self, mouse_pos: MousePos): hit = self._segment_at(mouse_pos) if self.enabled and hit >= 0 and self._is_button_enabled(hit): self.selected_button = hit if self.callback: self.callback(hit) self._slot.write(self.selected_button) if hit < 0 or not self._double_click_callbacks: return prev_hit, prev_time = self._last_click now = time.monotonic() if hit == prev_hit and (now - prev_time) < _DOUBLE_CLICK_S: self._last_click = (-1, 0.0) if hit in self._double_click_callbacks: self._double_click_callbacks[hit]() else: self._last_click = (hit, now) class IQListItem(ListItem): """Settings row with IQ extensions: leading toggles/buttons, title badge chips, right-aligned readouts, and a stacked (non-inline) action layout.""" STACKED_EXTRA = metrics.ROW / 1.75 def __init__(self, title: _Dyn = "", icon: str | None = None, description: _Dyn | None = None, description_visible: bool = False, callback: Callable | None = None, action_item: ItemAction | None = None, inline: bool = True, title_color: rl.Color = ink.TITLE): super().__init__(title, icon, description, description_visible, callback, action_item) self.title_color = title_color self.inline = inline if not self.inline: self._rect.height += self.STACKED_EXTRA self.title_badge: tuple[str, rl.Color] | None = None self._ro_val: _Dyn | None = None self._ro_font = gui_app.font(FontWeight.NORMAL) self._ro_tint: rl.Color = ink.READOUT # -- value/badge API ------------------------------------------------------ def set_title(self, title: _Dyn = ""): self._title = title def set_right_value(self, value: _Dyn, color: rl.Color = ink.READOUT): self._ro_val = value self._ro_tint = color @property def right_value(self) -> str: if self._ro_val is None: return "" return str(_resolve_value(self._ro_val, "")) def show_description(self, show: bool): self._set_description_visible(show) # -- geometry ------------------------------------------------------------- def _leading_action(self) -> bool: return isinstance(self.action_item, ToggleAction) or isinstance(self.action_item, IQSimpleButtonAction) def _update_state(self): prev_desc = self._prev_description super()._update_state() if self.description_visible and self._prev_description != prev_desc: self._rect.height = self.get_item_height(self._font, int(self._rect.width - metrics.GUTTER * 2)) def get_item_height(self, font: rl.Font, max_width: int) -> float: extra = 0.0 if self.description_visible: extra += metrics.GUTTER * 1.5 if not self.inline: extra += self.STACKED_EXTRA return super().get_item_height(font, max_width) + extra def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle: if not self.action_item: return rl.Rectangle(0, 0, 0, 0) pad = metrics.GUTTER inset_x = item_rect.x + pad inner_w = item_rect.width - pad * 2 top = item_rect.y if not self.inline: # stacked: the action spans the row width, below the title title_h = measure_text_cached(self._font, self.title, metrics.TITLE_FS).y return rl.Rectangle(inset_x, top + title_h + pad * 3, inner_w, metrics.ROW_BTN_H) hint = self.action_item.get_width_hint() if hint == 0: return rl.Rectangle(inset_x, top, inner_w, metrics.ROW) title_w = measure_text_cached(self._font, self.title, metrics.TITLE_FS).x hint = min(inner_w - title_w, hint) x = item_rect.x if self._leading_action() else item_rect.x + item_rect.width - hint return rl.Rectangle(x, top, hint, metrics.ROW) # -- drawing -------------------------------------------------------------- def _draw_title_badge(self, x: float, cy: float): """Rounded status chip just after the title text.""" label, color = self.title_badge font = gui_app.font(FontWeight.MEDIUM) fs = 30 ts = measure_text_cached(font, label, fs) pad_x = 18 chip = rl.Rectangle(x, cy - (ts.y + 16) / 2, ts.x + pad_x * 2, ts.y + 16) canvas.panel(chip, 0.5, 12, color) lum = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b txt_color = rl.Color(10, 14, 16, 255) if lum > 140 else rl.WHITE canvas.glyphs(font, label, rl.Vector2(chip.x + pad_x, cy - ts.y / 2), fs, txt_color) def _draw_title(self, x: float, with_badge: bool) -> None: self._text_size = measure_text_cached(self._font, self.title, metrics.TITLE_FS) y = self._rect.y + (metrics.ROW - self._text_size.y) // 2 if self.inline else self._rect.y + metrics.GUTTER * 1.5 canvas.glyphs(self._font, self.title, rl.Vector2(x, y), metrics.TITLE_FS, self.title_color) if with_badge and self.title_badge: self._draw_title_badge(x + self._text_size.x + 24, y + self._text_size.y / 2) def _draw_right_value(self, from_x: float) -> None: span = rl.Rectangle(from_x, self._rect.y, self._rect.width - (from_x - self._rect.x) - metrics.GUTTER, metrics.ROW) if span.width > 0: gui_label(span, self.right_value, font_size=metrics.TITLE_FS, color=self._ro_tint, font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) def _draw_caption(self) -> None: content_w = int(self._rect.width - metrics.GUTTER * 2) y = self._rect.y + metrics.CAPTION_DY if not self.inline and self.action_item: y = self.action_item.rect.y + metrics.CAPTION_DY - metrics.GUTTER * 0.5 self._html_renderer.render(rl.Rectangle(self._rect.x + metrics.GUTTER, y, content_w, self._html_renderer.get_total_height(content_w))) def _fire_action(self, action_rect: rl.Rectangle) -> None: if self.action_item.render(action_rect) and self.action_item.enabled and self.callback: self.callback() def _render(self, _): if not self.is_visible: return if (self._rect.y + self.rect.height) <= self._parent_rect.y or self._rect.y >= (self._parent_rect.y + self._parent_rect.height): return content_x = self._rect.x + metrics.GUTTER if self._leading_action(): slot_h = metrics.WIDE_BTN_H if isinstance(self.action_item, IQSimpleButtonAction) else metrics.TOGGLE_H lead = rl.Rectangle(content_x, self._rect.y + (metrics.ROW - slot_h) // 2, self.action_item.rect.width, slot_h) text_x = lead.x + lead.width + metrics.GUTTER * 1.5 if self.title: self._draw_title(text_x, with_badge=False) if self.right_value: self._draw_right_value(text_x) self._fire_action(lead) else: if self.title: self._draw_title(content_x, with_badge=True) if self.action_item: self._fire_action(self.get_right_item_rect(self._rect)) if self.description_visible: self._draw_caption() def simple_button_item_iq(button_text: _Dyn, callback: Callable | None = None, enabled: bool | _Pred = True, button_width: int = metrics.WIDE_BTN_W) -> IQListItem: action = IQSimpleButtonAction(button_text=button_text, enabled=enabled, callback=callback, button_width=button_width) return IQListItem(title="", callback=callback, description="", action_item=action) def toggle_item_iq(title: _Dyn, description: _Dyn | None = None, initial_state: bool = False, callback: Callable | None = None, icon: str = "", enabled: bool | _Pred = True, param: str | None = None) -> IQListItem: action = IQToggleAction(initial_state=initial_state, enabled=enabled, callback=callback, param=param) return IQListItem(title=title, description=description, action_item=action, icon=icon, callback=callback) def multiple_button_item_iq(title: _Dyn, description: _Dyn, buttons: list[_Dyn], selected_index: int = 0, button_width: int = metrics.ACTION_W, callback: Callable | None = None, icon: str = "", param: str | None = None, inline: bool = False) -> IQListItem: action = IQMultipleButtonAction(buttons, button_width, selected_index, callback=callback, param=param) return IQListItem(title=title, description=description, icon=icon, action_item=action, inline=inline) def option_item_iq(title: _Dyn, param: str, min_value: int, max_value: int, description: _Dyn | None = None, value_change_step: int = 1, on_value_changed: Callable[[int], None] | None = None, enabled: bool | _Pred = True, icon: str = "", label_width: int = LABEL_WIDTH, value_map: dict[int, int] | None = None, use_float_scaling: bool = False, label_callback: Callable[[int], str] | None = None, inline: bool = False) -> IQListItem: action = IQOptionControl( param, min_value, max_value, value_change_step, enabled, on_value_changed, value_map, label_width, use_float_scaling, label_callback ) return IQListItem(title=title, description=description, action_item=action, icon=icon, inline=inline) def button_item_iq(title: _Dyn, button_text: _Dyn, description: _Dyn | None = None, callback: Callable | None = None, enabled: bool | _Pred = True) -> IQListItem: action = IQButtonAction(text=button_text, enabled=enabled) return IQListItem(title=title, description=description, action_item=action, callback=callback) def dual_button_item_iq(left_text: _Dyn, right_text: _Dyn, left_callback: Callable | None = None, right_callback: Callable | None = None, description: _Dyn | None = None, enabled: bool | _Pred = True, border_radius: int = 40) -> IQListItem: action = IQDualButtonAction(left_text, right_text, left_callback, right_callback, enabled, border_radius) return IQListItem(title="", description=description, action_item=action) # Preferred IQ helper names. simple_button_item = simple_button_item_iq toggle_item = toggle_item_iq multiple_button_item = multiple_button_item_iq option_item = option_item_iq button_item = button_item_iq dual_button_item = dual_button_item_iq # ===== toggle ===== KNOB_PADDING = 10 KNOB_RADIUS = metrics.TOGGLE_TRACK_H / 2 - KNOB_PADDING # Track colors: grey when off, teal gradient (left -> right) when on. OFF_COLOR = rl.Color(58, 61, 68, 255) ON_START = rl.Color(52, 231, 200, 255) # #34E7C8 ON_END = rl.Color(4, 140, 155, 255) # #048C9B OFF_DISABLED = rl.Color(45, 47, 52, 255) ON_START_DISABLED = rl.Color(40, 110, 100, 255) ON_END_DISABLED = rl.Color(18, 78, 86, 255) KNOB_ON = rl.WHITE KNOB_DISABLED = rl.Color(150, 150, 150, 255) TRACK_BORDER = rl.Color(0, 0, 0, 45) class IQToggle(Toggle): def __init__(self, initial_state=False, callback: Callable[[bool], None] | None = None, param: str | None = None): self._slot = ParamSlot(param) if self._slot.bound: initial_state = self._slot.read_bool(initial_state) super().__init__(initial_state, callback) @property def param_key(self): return self._slot.key def set_rect(self, rect: rl.Rectangle): self._rect = rl.Rectangle(rect.x, rect.y, metrics.TOGGLE_W, metrics.TOGGLE_H) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) if self._enabled: self._slot.write(self._state) def update(self): # Framerate-independent exponential ease-out (springy settle) instead of the base class's # constant-velocity linear slide, which read as robotic. if abs(self._progress - self._target) > 0.001: self._progress += (self._target - self._progress) * min(1.0, rl.get_frame_time() * 16.0) else: self._progress = self._target def _sync_from_param(self): if self._slot.bound: stored = self._slot.read_bool(self._state) if stored != self._state: self.set_state(stored) def _render(self, rect: rl.Rectangle): self._sync_from_param() self.update() self._rect.y -= metrics.GUTTER / 2 # Track color blends grey -> teal gradient as the toggle animates on. if self._enabled: c_start = self._blend_color(OFF_COLOR, ON_START, self._progress) c_end = self._blend_color(OFF_COLOR, ON_END, self._progress) knob_color = KNOB_ON else: c_start = self._blend_color(OFF_DISABLED, ON_START_DISABLED, self._progress) c_end = self._blend_color(OFF_DISABLED, ON_END_DISABLED, self._progress) knob_color = KNOB_DISABLED x = self._rect.x y = self._rect.y w = metrics.TOGGLE_W h = metrics.TOGGLE_TRACK_H r = h / 2 # Gradient pill: rounded end caps + horizontal gradient body rl.draw_circle(int(x + r), int(y + r), r, c_start) rl.draw_circle(int(x + w - r), int(y + r), r, c_end) canvas.h_sweep(int(x + r), int(y), int(w - 2 * r), int(h), c_start, c_end) # Subtle outline for definition canvas.panel_outline(rl.Rectangle(x, y, w, h), 1.0, 16, 2, TRACK_BORDER) # Knob position p = self._progress left_edge = x + KNOB_PADDING right_edge = x + w - KNOB_PADDING knob_travel_distance = right_edge - left_edge - 2 * KNOB_RADIUS knob_x = left_edge + KNOB_RADIUS + knob_travel_distance * p knob_y = y + h / 2 # Subtle squish: the knob stretches along its travel and rounds back out at the ends — a tactile, # liquid feel. m peaks (1.0) at mid-travel and is 0 at rest, so a settled knob is a clean circle. m = 4.0 * p * (1.0 - p) rh = KNOB_RADIUS * (1.0 + 0.22 * m) rv = KNOB_RADIUS * (1.0 - 0.12 * m) # Soft drop shadow under the knob for depth rl.draw_ellipse(int(knob_x), int(knob_y + 4), rh + 1, rv + 1, rl.Color(0, 0, 0, 38)) rl.draw_ellipse(int(knob_x), int(knob_y + 2), rh, rv, rl.Color(0, 0, 0, 30)) rl.draw_ellipse(int(knob_x), int(knob_y), rh, rv, knob_color) # ===== option_control ===== # Dimensions and styling constants BUTTON_WIDTH = 150 BUTTON_HEIGHT = 150 LABEL_WIDTH = 350 BUTTON_SPACING = 25 VALUE_FONT_SIZE = 50 BUTTON_FONT_SIZE = 60 CONTAINER_PADDING = 20 # Circular +/- button styling BTN_INSET = 24 # gap between circle and container edge GLYPH_THICK = 9 # +/- stroke thickness CONTAINER_ROUNDNESS = 0.5 BTN_BG = rl.Color(64, 67, 75, 255) # idle circle (lifted off the container) BTN_BG_DISABLED = rl.Color(40, 42, 48, 255) BTN_BG_PRESSED = rl.Color(16, 185, 169, 255) # teal on press GLYPH_COLOR = rl.Color(52, 231, 200, 255) # teal +/- GLYPH_PRESSED = rl.Color(10, 14, 16, 255) # dark glyph on teal GLYPH_DISABLED = rl.Color(110, 112, 118, 255) class _ValueCodec: """Maps between the stepper's internal integer position and the stored/displayed value.""" def __init__(self, value_map: dict[int, int] | None, float_scaled: bool, label_fn: Callable[[float | int], str] | None): self._map = value_map self._scaled = float_scaled self._label_fn = label_fn def external(self, position: int): if self._map: return self._map[position] return position / 100.0 if self._scaled else position def position_of(self, stored, fallback: int) -> int: if self._map: for pos, mapped in self._map.items(): if mapped == stored: return int(pos) return fallback try: return int(round(float(stored) * 100.0)) if self._scaled else int(stored) except (TypeError, ValueError): return fallback def label(self, position: int) -> str: if self._label_fn: return self._label_fn(self.external(position)) if self._map: return str(self._map.get(position, position)) if self._scaled: return f"{position / 100.0:.2f}" return str(position) class IQOptionControl(ItemAction): def __init__(self, param: str, min_value: int, max_value: int, value_change_step: int = 1, enabled: bool | _Pred = True, on_value_changed: Callable[[int], None] | None = None, value_map: dict[int, int] | None = None, label_width: int = LABEL_WIDTH, use_float_scaling: bool = False, label_callback: Callable[[float | int], str] | None = None): super().__init__(enabled=enabled) self.param_key = param self.min_value = min_value self.max_value = max_value self.value_change_step = value_change_step self.on_value_changed = on_value_changed self.label_width = label_width # kept for callers introspecting the control self.value_map = value_map self.use_float_scaling = use_float_scaling self.label_callback = label_callback self._slot = ParamSlot(param) self._codec = _ValueCodec(value_map, use_float_scaling, label_callback) self.current_value = self._clamp(self._codec.position_of(self._slot.read_raw(), min_value)) self._font = gui_app.font(FontWeight.MEDIUM) self._zones: dict[int, rl.Rectangle] = {} # step delta sign -> hit rect self.label_rect = rl.Rectangle(0, 0, 0, 0) def _clamp(self, position: int) -> int: return max(self.min_value, min(self.max_value, position)) def get_value(self) -> int: return self.current_value def set_value(self, value: int): if not (self.min_value <= value <= self.max_value): return self.current_value = value self._slot.write(self._codec.external(value)) if self.on_value_changed: self.on_value_changed(value) def get_displayed_value(self) -> str: return self._codec.label(self.current_value) def _place(self, rect: rl.Rectangle): control_w = BUTTON_WIDTH * 2 + self.label_width + BUTTON_SPACING * 2 total_w = control_w + CONTAINER_PADDING * 2 self._rect.width = total_w x0 = self._rect.x + self._rect.width - total_w y0 = rect.y + (rect.height - BUTTON_HEIGHT) / 2 self.container_rect = rl.Rectangle(x0, y0, total_w, BUTTON_HEIGHT) self._zones = { -1: rl.Rectangle(x0, y0, BUTTON_WIDTH + CONTAINER_PADDING, BUTTON_HEIGHT), +1: rl.Rectangle(x0 + CONTAINER_PADDING + BUTTON_WIDTH + BUTTON_SPACING + self.label_width + BUTTON_SPACING, y0, BUTTON_WIDTH + CONTAINER_PADDING, BUTTON_HEIGHT), } self.label_rect = rl.Rectangle(x0 + CONTAINER_PADDING + BUTTON_WIDTH + BUTTON_SPACING, y0, self.label_width, BUTTON_HEIGHT) def _step_allowed(self, direction: int) -> bool: if not self.enabled: return False return self.current_value > self.min_value if direction < 0 else self.current_value < self.max_value def _render(self, rect: rl.Rectangle): if self._rect.width == 0 or self._rect.height == 0 or not self.is_visible: return self._place(rect) canvas.panel(self.container_rect, CONTAINER_ROUNDNESS, 20, ink.PANEL) for direction, zone in self._zones.items(): self._draw_stepper(zone, direction, self._step_allowed(direction)) label = self.get_displayed_value() size = measure_text_cached(self._font, label, VALUE_FONT_SIZE) origin = rl.Vector2(self.label_rect.x + (self.label_rect.width - size.x) / 2, self.label_rect.y + (self.label_rect.height - size.y) / 2) canvas.glyphs(self._font, label, origin, VALUE_FONT_SIZE, ink.TITLE if self.enabled else ink.DISABLED) def _draw_stepper(self, zone: rl.Rectangle, direction: int, allowed: bool): pressed = (allowed and self._touch_valid() and rl.check_collision_point_rec(rl.get_mouse_position(), zone) and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)) if not allowed: circle, glyph = BTN_BG_DISABLED, GLYPH_DISABLED elif pressed: circle, glyph = BTN_BG_PRESSED, GLYPH_PRESSED else: circle, glyph = BTN_BG, GLYPH_COLOR cx = zone.x + zone.width / 2 cy = zone.y + zone.height / 2 radius = (BUTTON_HEIGHT - 2 * BTN_INSET) / 2 rl.draw_circle(int(cx), int(cy), radius, circle) # Crisp drawn glyph (rounded bars) instead of a font character arm = radius * 0.46 canvas.panel(rl.Rectangle(cx - arm, cy - GLYPH_THICK / 2, arm * 2, GLYPH_THICK), 1.0, 6, glyph) if direction > 0: canvas.panel(rl.Rectangle(cx - GLYPH_THICK / 2, cy - arm, GLYPH_THICK, arm * 2), 1.0, 6, glyph) def _handle_mouse_release(self, mouse_pos: MousePos): for direction, zone in self._zones.items(): if self._step_allowed(direction) and rl.check_collision_point_rec(mouse_pos, zone): self.set_value(self._clamp(self.current_value + direction * self.value_change_step)) return self.set_value(self.current_value) # Preferred IQ-compatible generic alias. OptionControl = IQOptionControl # ===== progress_bar ===== # Download progress fill: gradient from 09A3AB (left) to E30985 (right). FILL_START = rl.Color(0x09, 0xA3, 0xAB, 255) FILL_END = rl.Color(0xE3, 0x09, 0x85, 255) _FONT_SIZE = 40 _BAR_H = 60 _PAD = 30 _INSET = 4 _SWEEP_FRACTION = 0.35 _SWEEP_HZ = 0.7 def _mix(a: rl.Color, b: rl.Color, t: float) -> rl.Color: t = max(0.0, min(1.0, t)) return rl.Color(int(a.r + (b.r - a.r) * t), int(a.g + (b.g - a.g) * t), int(a.b + (b.b - a.b) * t), 255) class ProgressBarAction(ItemAction): def __init__(self, width=600): super().__init__(width=width) self.progress = 0.0 self.text = "" self.show_progress = False self.indeterminate = False self.text_color = rl.GRAY self._font = gui_app.font(FontWeight.NORMAL) def update(self, progress, text, show_progress=False, text_color=rl.GRAY, indeterminate=False): self.progress = progress self.text = text self.show_progress = show_progress self.text_color = text_color self.indeterminate = indeterminate def _bar_geometry(self, rect: rl.Rectangle) -> tuple[rl.Rectangle, float]: """Right-aligned bar sized to the text; a "NN% - detail" label reserves the width of a full "100%" prefix so the bar doesn't jitter as digits change.""" label_w = measure_text_cached(self._font, self.text, _FONT_SIZE).x text_dx = _PAD prefix, sep, _ = self.text.partition(" - ") if self.show_progress and sep: widest_prefix = measure_text_cached(self._font, "100%", _FONT_SIZE).x prefix_w = measure_text_cached(self._font, prefix, _FONT_SIZE).x slack = widest_prefix - prefix_w bar_w = label_w + slack + 2 * _PAD text_dx = _PAD + slack else: bar_w = label_w + 2 * _PAD text_dx = (bar_w - label_w) / 2 bar = rl.Rectangle(rect.x + rect.width - bar_w, rect.y + (rect.height - _BAR_H) / 2, bar_w, _BAR_H) return bar, text_dx @staticmethod def _fill_span(track: rl.Rectangle, lo: float, hi: float): """Paint the [lo, hi] fraction of the track with the gradient section that belongs to that span, so partial fills stay colour-consistent.""" lo, hi = max(0.0, lo), min(1.0, hi) if hi <= lo: return x0 = track.x + track.width * lo x1 = track.x + track.width * hi canvas.h_sweep(int(x0), int(track.y), int(x1 - x0), int(track.height), _mix(FILL_START, FILL_END, lo), _mix(FILL_START, FILL_END, hi)) def _render(self, rect: rl.Rectangle): bar, text_dx = self._bar_geometry(rect) if self.show_progress: track = rl.Rectangle(bar.x + _INSET, bar.y + _INSET, bar.width - 2 * _INSET, bar.height - 2 * _INSET) if track.width > 0: if self.indeterminate: # no known percentage (host didn't expose a size) — sweep a gradient window across phase = (rl.get_time() * _SWEEP_HZ) % 1.0 head = phase * (1.0 + _SWEEP_FRACTION) self._fill_span(track, head - _SWEEP_FRACTION, head) else: self._fill_span(track, 0.0, self.progress / 100.0) label_h = measure_text_cached(self._font, self.text, _FONT_SIZE).y rl.draw_text_ex(self._font, self.text, rl.Vector2(bar.x + text_dx, bar.y + (_BAR_H - label_h) / 2), _FONT_SIZE, 0, self.text_color) def progress_item(title): return ListItem(title=title, action_item=ProgressBarAction()) from dataclasses import dataclass, field from iqpilot.common.params import Params from iqpilot.system.ui.iqwidgets.lib.styles import ink from iqpilot.system.ui.iqwidgets.widgets.helpers.glyphs import draw_star from iqpilot.system.ui.lib.application import FontWeight, gui_app from iqpilot.system.ui.lib.application import gui_app from iqpilot.system.ui.lib.multilang import tr from iqpilot.system.ui.widgets import DialogResult from iqpilot.system.ui.widgets.button import Button, ButtonStyle, BUTTON_PRESSED_BACKGROUND_COLORS from iqpilot.system.ui.widgets.html_render import HtmlModal from iqpilot.system.ui.widgets.keyboard import Keyboard from iqpilot.system.ui.widgets.option_dialog import MultiOptionDialog # ===== tree_dialog ===== TREE_TEAL = rl.Color(16, 185, 169, 255) TREE_FOLDER_COLOR = rl.Color(48, 51, 58, 255) # brand header rows (lighter) TREE_SEARCH_FILL = rl.Color(42, 45, 52, 255) TREE_SEARCH_PRESSED = rl.Color(58, 62, 70, 255) TREE_SEARCH_BORDER = rl.Color(255, 255, 255, 38) _STAR_SLOT = 90 # right-edge inset reserved for the favourite star _ROW_H = 120 _FRAME_PAD = 50 @dataclass class PickerItem: ref: str data: dict = field(default_factory=dict) @dataclass class PickerGroup: folder: str nodes: list class _TreeRow(Button): """One rendered row: a brand/folder header, or a selectable leaf with an optional star.""" def __init__(self, text, ref, is_folder=False, indent_level=0, click_callback=None, favorite_callback=None, is_favorite=False, is_expanded=False): super().__init__(text, click_callback, button_style=ButtonStyle.NORMAL, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20 + indent_level * 30, elide_right=True) self.text = text self.ref = ref self.is_folder = is_folder self.indent_level = indent_level self.is_favorite = is_favorite self.selected = False self.is_expanded = is_expanded self._favorite_callback = favorite_callback self.text_padding = 20 + indent_level * 30 self.border_radius = 10 def _fill_color(self): if self.is_pressed: return BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style] if self.selected and self.ref != "search_bar": return TREE_TEAL return TREE_FOLDER_COLOR if self.is_folder else ink.KEY_SUNKEN def _star_hitbox(self) -> rl.Rectangle: return rl.Rectangle(self._rect.x + self._rect.width - _STAR_SLOT - 40, self._rect.y + self._rect.height / 2 - 40, 80, 80) def _render(self, rect): indent = 60 * self.indent_level self._rect = rl.Rectangle(rect.x + indent, rect.y, rect.width - indent, rect.height) roundness = self.border_radius / (min(self._rect.width, self._rect.height) / 2) rl.draw_rectangle_rounded(self._rect, roundness, 10, self._fill_color()) cy = self._rect.y + self._rect.height / 2 text_offset = self.text_padding + 20 if self.is_folder: # chevron: down when expanded, right when collapsed chev = gui_app.texture("icons/iq/chevron_down.png" if self.is_expanded else "icons/iq/chevron_right.png", 40, 40, keep_aspect_ratio=True) rl.draw_texture(chev, int(self._rect.x + self.text_padding + 6), int(cy - chev.height / 2), rl.Color(205, 205, 210, 255)) text_offset = self.text_padding + 6 + 40 + 22 elif self.indent_level > 0 and not self.selected: # teal accent marking a model under the expanded brand rl.draw_rectangle_rounded(rl.Rectangle(self._rect.x + 16, cy - 24, 6, 48), 1.0, 4, TREE_TEAL) self._label.render(rl.Rectangle(self._rect.x + text_offset, self._rect.y, self._rect.width - text_offset - _STAR_SLOT, self._rect.height)) if not self.is_folder and self._favorite_callback: draw_star(self._rect.x + self._rect.width - _STAR_SLOT, cy, 40, self.is_favorite, ink.ACCENT if self.is_favorite else rl.GRAY) def _handle_mouse_release(self, mouse_pos): if not self.is_folder and self._favorite_callback and rl.check_collision_point_rec(mouse_pos, self._star_hitbox()): self._favorite_callback() return True return super()._handle_mouse_release(mouse_pos) class PickerDialog(MultiOptionDialog): """Folder/leaf picker with search, favourites, and a pinned current selection.""" def __init__(self, title, folders, current_ref="", fav_param="", option_font_weight=FontWeight.MEDIUM, search_prompt=None, get_folders_fn=None, on_exit=None, display_func=None, search_funcs=None, search_title=None, search_subtitle=None): super().__init__(title, [], current_ref, option_font_weight) self.folders = folders self.selection_ref = current_ref self.fav_param = fav_param self.expanded = set() self._fav_slot = ParamSlot(fav_param or None) stored = self._fav_slot.read_raw() self.favorites = set(stored.split(';')) if stored else set() self.query = "" self.search_prompt = search_prompt or tr("Search") self.get_folders_fn = get_folders_fn self.on_exit = on_exit self.display_func = display_func or (lambda node: node.data.get('display_name', node.ref)) self.search_funcs = search_funcs or [lambda node: node.data.get('display_name', ''), lambda node: node.data.get('short_name', '')] self.search_title = search_title or tr("Type to search") self.search_subtitle = search_subtitle self._search_rect: rl.Rectangle | None = None self._search_pressed = False self.selection_node = self._locate_current(current_ref) if self.selection_node is not None: self.selection = self.current = self.display_func(self.selection_node) self._build_visible_items() # -- model ---------------------------------------------------------------- def _locate_current(self, current_ref): """Match by ref, by display text, or fall back to "Default" when no ref is set.""" for folder in self.folders: for node in folder.nodes: display = self.display_func(node) if node.ref == current_ref or display == current_ref or (not current_ref and node.ref == "Default"): return node return None def _node_matches(self, node) -> bool: if not self.query: return True haystacks = [fn(node) for fn in self.search_funcs] return bool(rank_matches(self.query, [h for h in haystacks if h])) def _leaf_row(self, node, depth: int, expanded: bool = False) -> _TreeRow: fav_cb = None if self.fav_param and node.ref != "Default": fav_cb = lambda n=node: self._toggle_favorite(n) # noqa: E731 return _TreeRow(self.display_func(node), node.ref, False, depth, lambda n=node: self._select_node(n), fav_cb, node.ref in self.favorites, is_expanded=expanded) def _build_visible_items(self, reset_scroll=True): rows: list[_TreeRow] = [] # Pinned selected item at the very top (if any) pinned = getattr(self, "selection_node", None) if pinned is not None: self.selection = self.current = self.display_func(pinned) rows.append(self._leaf_row(pinned, 0, expanded=True)) for folder in self.folders: visible_nodes = [n for n in folder.nodes if self._node_matches(n)] if not visible_nodes and self.query: continue expanded = folder.folder in self.expanded or not folder.folder or bool(self.query) if folder.folder: rows.append(_TreeRow(folder.folder, "", True, 0, lambda f=folder: self._toggle_folder(f), is_expanded=expanded)) if expanded: for node in visible_nodes: if pinned is not None and node.ref == pinned.ref and not folder.folder: continue # already pinned at the top rows.append(self._leaf_row(node, 1 if folder.folder else 0, expanded=expanded)) self.visible_items = rows self.option_buttons = rows self.options = [row.text for row in rows] self.scroller._items = rows if reset_scroll: self.scroller.scroll_panel.set_offset(0) # -- interactions --------------------------------------------------------- def _select_node(self, node): self.selection = self.display_func(node) self.selection_ref = node.ref def _toggle_folder(self, folder): if not folder.folder: return self.expanded.symmetric_difference_update({folder.folder}) if folder is self.folders[-1] and folder.folder in self.expanded: self.scroller.scroll_panel.set_offset(self.scroller.scroll_panel.offset - 200) self._build_visible_items(reset_scroll=False) def _toggle_favorite(self, node): self.favorites.symmetric_difference_update({node.ref}) self._fav_slot.write(';'.join(self.favorites)) if self.get_folders_fn: self.folders = self.get_folders_fn(self.favorites) self._build_visible_items(reset_scroll=False) def _open_search(self): def _apply(result, text): if result == DialogResult.CONFIRM: self.query = text self._build_visible_items() gui_app.set_modal_overlay(self, callback=self.on_exit) open_text_prompt(self.search_title, self.search_subtitle, initial=self.query, on_done=_apply) # -- drawing -------------------------------------------------------------- def _render(self, rect): frame = rl.Rectangle(rect.x + _FRAME_PAD, rect.y + _FRAME_PAD, rect.width - 2 * _FRAME_PAD, rect.height - 2 * _FRAME_PAD) rl.draw_rectangle_rounded(frame, 0.02, 20, rl.BLACK) title_rect = rl.Rectangle(frame.x + _FRAME_PAD, frame.y + _FRAME_PAD, frame.width * 0.5, 70) gui_label(title_rect, self.title, 70, font_weight=FontWeight.BOLD) search_bottom = self._draw_search_field(frame, title_rect.y + title_rect.height + 36) self._draw_choice_list(frame, search_bottom + 36) self._draw_footer(frame) return self._result def _draw_search_field(self, frame: rl.Rectangle, top: float) -> float: self._search_rect = rl.Rectangle(frame.x + _FRAME_PAD, top, frame.width - 2 * _FRAME_PAD, 100) rl.draw_rectangle_rounded(self._search_rect, 0.4, 16, TREE_SEARCH_PRESSED if self._search_pressed else TREE_SEARCH_FILL) rl.draw_rectangle_rounded_lines_ex(self._search_rect, 0.4, 16, 2, TREE_SEARCH_BORDER) # Magnifying glass icon icon_color = rl.Color(190, 190, 195, 255) cx = self._search_rect.x + 54 cy = self._search_rect.y + self._search_rect.height / 2 - 2 radius = 24 for i in range(4): rl.draw_circle_lines(int(cx), int(cy), radius - i, icon_color) rl.draw_line_ex(rl.Vector2(cx + radius * 0.65, cy + radius * 0.65), rl.Vector2(cx + radius * 1.4, cy + radius * 1.4), 5, icon_color) # Query text, or muted placeholder text_x = cx + radius * 1.4 + 34 text_rect = rl.Rectangle(text_x, self._search_rect.y, self._search_rect.x + self._search_rect.width - text_x - 24, self._search_rect.height) if self.query: gui_label(text_rect, self.query, 56, font_weight=FontWeight.MEDIUM) else: gui_label(text_rect, self.search_prompt, 56, color=rl.Color(150, 150, 155, 255), font_weight=FontWeight.NORMAL) return self._search_rect.y + self._search_rect.height def _draw_choice_list(self, frame: rl.Rectangle, top: float): area = rl.Rectangle(frame.x + _FRAME_PAD, top, frame.width - 2 * _FRAME_PAD, frame.height - (top - frame.y) - 210) for row in self.option_buttons: row.selected = (row.text == self.selection) row.set_button_style(ButtonStyle.PRIMARY if row.selected else ButtonStyle.NORMAL) row.set_rect(rl.Rectangle(0, 0, area.width, _ROW_H)) self.scroller.render(area) def _draw_footer(self, frame: rl.Rectangle): btn_w = (frame.width - 3 * _FRAME_PAD) / 2 btn_y = frame.y + frame.height - 160 self.cancel_button._border_radius = self.select_button._border_radius = 44 self.cancel_button.render(rl.Rectangle(frame.x + _FRAME_PAD, btn_y, btn_w, 160)) select_rect = rl.Rectangle(frame.x + 2 * _FRAME_PAD + btn_w, btn_y, btn_w, 160) can_select = self.selection != self.current self.select_button.set_enabled(can_select) if can_select: rl.draw_rectangle_rounded(select_rect, 44 / (min(select_rect.width, select_rect.height) / 2), 10, TREE_TEAL) self.select_button.set_button_style(ButtonStyle.TRANSPARENT_WHITE_TEXT) else: self.select_button.set_button_style(ButtonStyle.NORMAL) self.select_button.render(select_rect) # -- input ---------------------------------------------------------------- def _handle_mouse_press(self, mouse_pos): if self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect): self._search_pressed = True return True return super()._handle_mouse_press(mouse_pos) def _handle_mouse_release(self, mouse_pos): tapped_search = (self._search_pressed and self._search_rect and rl.check_collision_point_rec(mouse_pos, self._search_rect)) self._search_pressed = False if tapped_search: self._open_search() return True return super()._handle_mouse_release(mouse_pos) # ===== text_prompt ===== def open_text_prompt(title: str, subtitle: str | None = None, initial: str = "", param_key: str | None = None, on_done: Callable[[DialogResult, str], None] | None = None, min_len: int = 0, password: bool = False) -> Keyboard: """Open the on-screen keyboard as a modal text prompt. On confirm, the entered text is optionally persisted to `param_key` and passed to `on_done`; a cancel reports an empty string. """ pad = Keyboard(max_text_size=255, min_text_size=min_len, password_mode=password) pad.set_title(title, subtitle) if subtitle else pad.set_title(title) pad.set_text(initial) def _finish(result: DialogResult): entered = pad.text if result == DialogResult.CONFIRM else "" if entered and result == DialogResult.CONFIRM and param_key: Params().put(param_key, entered) if on_done: on_done(result, entered) gui_app.set_modal_overlay(pad, _finish) return pad # ===== notice_modal ===== class NoticeModal(HtmlModal): """HTML notice dialog whose OK press closes the overlay and reports CONFIRM.""" def __init__(self, file_path=None, text=None, callback=None): super().__init__(file_path=file_path, text=text) self.result = DialogResult.NO_ACTION def _dismiss(): self.result = DialogResult.CONFIRM gui_app.set_modal_overlay(None) if callback: callback(self.result) self._ok_button._click_callback = _dismiss def reset(self): self.result = DialogResult.NO_ACTION # ===== param binding (typed per-key params handle for IQ settings widgets) ===== class ParamSlot: """Typed read/write handle for one params key, shared by IQ settings widgets. A widget owns a slot instead of talking to Params directly; missing keys (params compiled out of a build) degrade to the fallback instead of raising. """ _store = Params() def __init__(self, key: str | None): self.key = key @property def bound(self) -> bool: return bool(self.key) def read_bool(self, fallback: bool = False) -> bool: if not self.key: return fallback try: return self._store.get_bool(self.key) except UnknownKeyName: return fallback def read_raw(self, fallback=None): if not self.key: return fallback try: value = self._store.get(self.key, return_default=True) except UnknownKeyName: return fallback return fallback if value is None else value def read_int(self, fallback: int = 0) -> int: try: return int(self.read_raw(fallback)) except (TypeError, ValueError): return fallback def write(self, value) -> bool: if not self.key: return False try: if isinstance(value, bool): self._store.put_bool(self.key, value) else: self._store.put(self.key, value) except UnknownKeyName: return False return True # ===== fuzzy match ranking (folder/search filtering for tree pickers) ===== def _fold(text: str) -> str: return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower() def _words_of(text: str) -> list[str]: out, cur = [], [] for ch in _fold(text): if ch.isalnum(): cur.append(ch) elif cur: out.append("".join(cur)) cur = [] if cur: out.append("".join(cur)) return out def _is_subsequence(needle: str, haystack: str) -> bool: it = iter(haystack) return all(ch in it for ch in needle) def _token_score(token: str, words: list[str], joined: str) -> int: best = 0 for w in words: if w == token: best = max(best, 100) elif w.startswith(token): best = max(best, 75) elif token in w: best = max(best, 40) if not best and _is_subsequence(token, joined): best = 10 return best def rank_matches(query: str, items: list[str]) -> list[str]: tokens = _words_of(query) if not tokens: return list(items) ranked = [] for item in items: words = _words_of(item) joined = "".join(words) total = 0 for tok in tokens: s = _token_score(tok, words, joined) if s == 0: total = 0 break total += s if total: ranked.append((total, item)) ranked.sort(key=lambda pair: (-pair[0], items.index(pair[1]))) return [item for _, item in ranked]