BigUI WIP: In-line sliders

This commit is contained in:
firestarsdog
2026-04-30 14:28:09 -04:00
parent f93a4f5adb
commit b820a3214f
2 changed files with 862 additions and 89 deletions
@@ -216,6 +216,17 @@ class AetherListMetrics:
toggle_width: int = 78
toggle_height: int = 42
toggle_right_inset: int = 34
adjustor_row_height: int = 94
adjustor_row_active_height: int = 154
adjustor_preset_height: int = 30
adjustor_preset_gap: int = 10
adjustor_scrubber_height: int = 52
adjustor_value_pill_height: int = 36
adjustor_value_pill_width: int = 144
range_row_height: int = 140
range_control_height: int = 56
range_control_bottom: int = 14
range_control_inset_x: int = 18
utility_value_right: int = 270
utility_value_width: int = 220
utility_chevron_right: int = 62
@@ -754,6 +765,645 @@ def draw_settings_list_row(
)
def format_adjustor_value(value: float, *, step: float = 1.0, unit: str = "", labels: dict[float, str] | None = None) -> str:
label_map = labels or {}
tolerance = max(abs(step) * 0.5, 1e-4) if step != 0 else 1e-4
for label_value, label in label_map.items():
if abs(float(label_value) - float(value)) <= tolerance:
return label
if step > 0 and step < 1:
decimals = max(1, min(3, len(f"{step:.6f}".rstrip("0").split(".")[-1])))
return f"{value:.{decimals}f}{unit}"
if abs(value - round(value)) <= tolerance:
return f"{int(round(value))}{unit}"
return f"{value:.2f}".rstrip("0").rstrip(".") + unit
def draw_range_setting_row(
rect: rl.Rectangle,
*,
title: str,
subtitle: str = "",
value: str = "",
enabled: bool = True,
hovered: bool = False,
pressed: bool = False,
is_last: bool = False,
title_size: int = 24,
subtitle_size: int = 18,
value_size: int = 24,
separator_inset: int = 22,
control_height: int = AETHER_LIST_METRICS.range_control_height,
control_bottom: int = AETHER_LIST_METRICS.range_control_bottom,
control_inset_x: int = AETHER_LIST_METRICS.range_control_inset_x,
title_color: rl.Color | None = None,
subtitle_color: rl.Color | None = None,
value_color: rl.Color | None = None,
style: PanelStyle = DEFAULT_PANEL_STYLE,
) -> rl.Rectangle:
draw_rect = _snap_rect(rect)
resolved_title_color = title_color or (style.title_color if enabled else style.muted_color)
resolved_subtitle_color = subtitle_color or (style.subtitle_color if enabled else style.muted_color)
resolved_value_color = value_color or (style.title_color if enabled else style.muted_color)
value_reserved = min(240.0, draw_rect.width * 0.24) if value else 0.0
content_left = draw_rect.x + 24
content_right = draw_rect.x + draw_rect.width - 24
if value_reserved:
content_right -= value_reserved + 20
content_width = max(120.0, content_right - content_left)
draw_list_row_shell(
draw_rect,
hovered=hovered and enabled,
pressed=pressed and enabled,
is_last=is_last,
row_bg=rl.Color(255, 255, 255, 0),
row_border=rl.Color(255, 255, 255, 0),
row_separator=style.divider_color,
current_bg=rl.Color(255, 255, 255, 0),
current_border=rl.Color(255, 255, 255, 0),
separator_inset=separator_inset,
)
gui_label(
rl.Rectangle(content_left, draw_rect.y + 14, content_width, title_size + 6),
title,
title_size,
resolved_title_color,
FontWeight.MEDIUM,
)
if subtitle:
gui_label(
rl.Rectangle(content_left, draw_rect.y + 46, content_width, subtitle_size + 8),
subtitle,
subtitle_size,
resolved_subtitle_color,
FontWeight.NORMAL,
)
if value:
gui_label(
rl.Rectangle(draw_rect.x + draw_rect.width - value_reserved - 24, draw_rect.y + 18, value_reserved, value_size + 6),
value,
value_size,
resolved_value_color,
FontWeight.MEDIUM,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
)
return _snap_rect(
rl.Rectangle(
draw_rect.x + control_inset_x,
draw_rect.y + draw_rect.height - control_height - control_bottom,
draw_rect.width - control_inset_x * 2,
control_height,
)
)
class AetherInlineRangeControl(Widget):
def __init__(
self,
min_val: float,
max_val: float,
step: float,
current_val: float,
on_change: Callable[[float], None],
*,
on_commit: Callable[[float], None] | None = None,
unit: str = "",
labels: dict[float, str] | None = None,
color: rl.Color = AetherListColors.PRIMARY,
major_tick_count: int = 5,
):
super().__init__()
self.min_val = min_val
self.max_val = max_val
self.step = step
self.current_val = current_val
self._on_change = on_change
self._on_commit = on_commit
self._unit = unit
self._labels = labels or {}
self._color = color
self._major_tick_count = max(0, major_tick_count)
self._font = gui_app.font(FontWeight.SEMI_BOLD)
self._smooth_value = current_val
self._thumb_focus = 0.0
self._pending_drag = False
self._is_dragging = False
self._started_on_thumb = False
self._press_start = rl.Vector2(0, 0)
self._value_at_press = current_val
self._pressed_button: int = 0
self._button_press_started = 0.0
self._next_repeat_at = 0.0
self._repeat_count = 0
self._button_press_changed = False
self._minus_rect = rl.Rectangle(0, 0, 0, 0)
self._plus_rect = rl.Rectangle(0, 0, 0, 0)
self._track_rect = rl.Rectangle(0, 0, 0, 0)
self._thumb_rect = rl.Rectangle(0, 0, 0, 0)
@property
def is_interacting(self) -> bool:
return self._pending_drag or self._is_dragging or self._pressed_button != 0
def set_value(self, value: float) -> None:
self.current_val = self._clamp_and_snap(value)
def reset_interaction(self) -> None:
self._pending_drag = False
self._is_dragging = False
self._started_on_thumb = False
self._pressed_button = 0
self._repeat_count = 0
self._button_press_changed = False
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
def _cancel_interaction(self, *, revert: bool = False) -> None:
if revert and self.current_val != self._value_at_press:
self.current_val = self._value_at_press
self._on_change(self.current_val)
self.reset_interaction()
def _clamp_and_snap(self, value: float) -> float:
if self.step <= 0:
return max(self.min_val, min(self.max_val, value))
snapped = round((value - self.min_val) / self.step) * self.step + self.min_val
return max(self.min_val, min(self.max_val, snapped))
def _step_value(self, direction: int) -> bool:
new_val = self._clamp_and_snap(self.current_val + direction * self.step)
if new_val == self.current_val:
return False
self.current_val = new_val
self._on_change(self.current_val)
self._button_press_changed = True
return True
def _value_fraction(self, value: float) -> float:
value_range = self.max_val - self.min_val
if value_range == 0:
return 0.0
return max(0.0, min(1.0, (value - self.min_val) / value_range))
def _update_val_from_mouse(self, mouse_pos: MousePos) -> None:
if self._track_rect.width <= 0:
return
rel_x = max(0.0, min(1.0, (mouse_pos.x - self._track_rect.x) / self._track_rect.width))
value = self.min_val + rel_x * (self.max_val - self.min_val)
snapped = self._clamp_and_snap(value)
if snapped != self.current_val:
self.current_val = snapped
self._on_change(self.current_val)
def _commit_if_needed(self) -> None:
if self.current_val != self._value_at_press and self._on_commit is not None:
self._on_commit(self.current_val)
def _update_state(self):
super()._update_state()
if self._pressed_button == 0:
return
now = time.monotonic()
if now < self._next_repeat_at:
return
if self._step_value(self._pressed_button):
self._repeat_count += 1
repeat_interval = 0.12 if self._repeat_count < 3 else 0.075
self._next_repeat_at = now + repeat_interval
def _handle_mouse_press(self, mouse_pos: MousePos):
if not self._touch_valid() or not rl.check_collision_point_rec(mouse_pos, self._rect):
return
self._value_at_press = self.current_val
self._button_press_changed = False
now = time.monotonic()
if rl.check_collision_point_rec(mouse_pos, self._minus_rect):
self._pressed_button = -1
self._button_press_started = now
self._next_repeat_at = now + 0.34
self._repeat_count = 0
return
if rl.check_collision_point_rec(mouse_pos, self._plus_rect):
self._pressed_button = 1
self._button_press_started = now
self._next_repeat_at = now + 0.34
self._repeat_count = 0
return
self._pending_drag = True
self._started_on_thumb = rl.check_collision_point_rec(mouse_pos, _inflate_rect(self._thumb_rect, 8, 8))
self._press_start = rl.Vector2(mouse_pos.x, mouse_pos.y)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._pressed_button != 0:
direction = self._pressed_button
button_rect = self._minus_rect if direction < 0 else self._plus_rect
if rl.check_collision_point_rec(mouse_pos, button_rect) and self._repeat_count == 0:
self._step_value(direction)
self._pressed_button = 0
self._repeat_count = 0
self._commit_if_needed()
return
if self._is_dragging:
self._is_dragging = False
self._commit_if_needed()
return
if self._pending_drag:
if not self._started_on_thumb and rl.check_collision_point_rec(mouse_pos, self._rect):
self._update_val_from_mouse(mouse_pos)
self._commit_if_needed()
self._pending_drag = False
self._started_on_thumb = False
def _handle_mouse_event(self, mouse_event: MouseEvent):
mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, self._rect)
if mouse_event.left_released and self.is_interacting and not mouse_in_rect:
if self._pressed_button != 0:
self._pressed_button = 0
self._repeat_count = 0
self._commit_if_needed()
elif self._is_dragging or self._pending_drag:
self._cancel_interaction(revert=True)
return
if not self._touch_valid():
self._cancel_interaction(revert=True)
return
if self._pressed_button != 0:
button_rect = self._minus_rect if self._pressed_button < 0 else self._plus_rect
if not rl.check_collision_point_rec(mouse_event.pos, _inflate_rect(button_rect, 4, 4)):
self._pressed_button = 0
self._repeat_count = 0
if self._button_press_changed:
self._commit_if_needed()
return
if self._pending_drag and not self._is_dragging:
dx = mouse_event.pos.x - self._press_start.x
dy = mouse_event.pos.y - self._press_start.y
if abs(dy) > 12 and abs(dy) > abs(dx):
self._pending_drag = False
self._started_on_thumb = False
return
if abs(dx) > 12 and abs(dx) >= abs(dy):
self._pending_drag = False
self._is_dragging = True
if self._is_dragging:
dx = mouse_event.pos.x - self._press_start.x
dy = mouse_event.pos.y - self._press_start.y
if abs(dy) > 18 and abs(dy) > abs(dx) * 1.15:
self._cancel_interaction(revert=True)
return
self._update_val_from_mouse(mouse_event.pos)
def _draw_button(self, rect: rl.Rectangle, label: str, *, pressed: bool = False):
fill = rl.Color(255, 255, 255, 8 if not pressed else 14)
border = rl.Color(255, 255, 255, 18 if not pressed else 28)
_draw_rounded_fill(rect, fill, radius_px=14)
_draw_rounded_stroke(rect, border, radius_px=14)
_draw_text_fit_common(
self._font,
label,
rl.Vector2(rect.x + 10, rect.y + (rect.height - 22) / 2),
max(1.0, rect.width - 20),
22,
align_center=True,
color=AetherListColors.HEADER,
)
def _render(self, rect: rl.Rectangle):
rect = _snap_rect(rect)
self.set_rect(rect)
dt = rl.get_frame_time()
self._smooth_value += (self.current_val - self._smooth_value) * (1 - math.exp(-dt / 0.075))
thumb_target = 1.0 if self._is_dragging or self._pending_drag else 0.0
self._thumb_focus += (thumb_target - self._thumb_focus) * (1 - math.exp(-dt / 0.070))
button_size = min(rect.height, 44)
button_y = rect.y + (rect.height - button_size) / 2
self._minus_rect = _snap_rect(rl.Rectangle(rect.x, button_y, button_size, button_size))
self._plus_rect = _snap_rect(rl.Rectangle(rect.x + rect.width - button_size, button_y, button_size, button_size))
self._draw_button(self._minus_rect, "-", pressed=self._pressed_button < 0)
self._draw_button(self._plus_rect, "+", pressed=self._pressed_button > 0)
track_x = self._minus_rect.x + self._minus_rect.width + 14
track_w = max(1.0, self._plus_rect.x - 14 - track_x)
lane_h = rect.height
track_h = 4.0
track_y = rect.y + (lane_h - track_h) / 2
self._track_rect = _snap_rect(rl.Rectangle(track_x, track_y, track_w, track_h))
rl.draw_rectangle_rounded(self._track_rect, 1.0, 12, rl.Color(255, 255, 255, 18))
if self._major_tick_count > 1:
for index in range(self._major_tick_count):
frac = index / max(1, self._major_tick_count - 1)
tick_x = self._track_rect.x + frac * self._track_rect.width
rl.draw_rectangle_rec(rl.Rectangle(tick_x - 1, rect.y + rect.height / 2 - 8, 2, 16), rl.Color(255, 255, 255, 24))
fill_frac = self._value_fraction(self._smooth_value)
fill_w = fill_frac * self._track_rect.width
if fill_w > 1:
fill_rect = _snap_rect(rl.Rectangle(self._track_rect.x, self._track_rect.y, fill_w, self._track_rect.height))
rl.draw_rectangle_rounded(fill_rect, 1.0, 12, _with_alpha(self._color, 220))
thumb_w = 18 + self._thumb_focus * 4
thumb_h = 28 + self._thumb_focus * 4
thumb_center_x = self._track_rect.x + fill_frac * self._track_rect.width
thumb_center_y = rect.y + rect.height / 2
self._thumb_rect = _snap_rect(rl.Rectangle(thumb_center_x - thumb_w / 2, thumb_center_y - thumb_h / 2, thumb_w, thumb_h))
thumb_fill = _mix_colors(rl.Color(224, 230, 238, 255), self._color, 0.12)
_draw_rounded_fill(self._thumb_rect, thumb_fill, radius_px=12)
_draw_rounded_stroke(self._thumb_rect, rl.Color(14, 17, 23, 52), radius_px=12)
if self._thumb_focus > 0.02:
_draw_rounded_stroke(_inflate_rect(self._thumb_rect, 1, 1), _with_alpha(self._color, int(70 * self._thumb_focus)), radius_px=13)
if self._is_dragging or self._pressed_button != 0:
bubble_text = format_adjustor_value(self.current_val, step=self.step, unit=self._unit, labels=self._labels)
bubble_w = max(80.0, min(132.0, 44.0 + len(bubble_text) * 10.0))
bubble_rect = _snap_rect(rl.Rectangle(thumb_center_x - bubble_w / 2, rect.y - 40, bubble_w, 32))
bubble_fill = _mix_colors(rl.Color(18, 22, 28, 255), self._color, 0.18)
bubble_border = _with_alpha(self._color, 70)
_draw_rounded_fill(bubble_rect, bubble_fill, radius_px=14)
_draw_rounded_stroke(bubble_rect, bubble_border, radius_px=14)
_draw_text_fit_common(
gui_app.font(FontWeight.MEDIUM),
bubble_text,
rl.Vector2(bubble_rect.x + 10, bubble_rect.y + 7),
max(1.0, bubble_rect.width - 20),
16,
align_center=True,
color=AetherListColors.HEADER,
)
class AetherAdjustorRow(Widget):
def __init__(
self,
title: str,
subtitle: str,
min_val: float,
max_val: float,
step: float,
get_value: Callable[[], float],
on_change: Callable[[float], None],
*,
on_commit: Callable[[float], None] | None = None,
unit: str = "",
labels: dict[float, str] | None = None,
presets: list[float] | None = None,
is_active: bool | Callable[[], bool] = False,
set_active: Callable[[bool], None] | None = None,
style: PanelStyle = DEFAULT_PANEL_STYLE,
color: rl.Color | None = None,
):
super().__init__()
self._title = title
self._subtitle = subtitle
self._get_value = get_value
self._is_active = is_active
self._set_active = set_active
self._style = style
self._color = color or style.accent
self._presets = presets or []
self._font_title = gui_app.font(FontWeight.MEDIUM)
self._font_subtitle = gui_app.font(FontWeight.NORMAL)
self._font_value = gui_app.font(FontWeight.SEMI_BOLD)
self._focus_progress = 0.0
self._pressed_zone: str | None = None
self._is_last = False
self._header_rect = rl.Rectangle(0, 0, 0, 0)
self._value_rect = rl.Rectangle(0, 0, 0, 0)
self._hint_rect = rl.Rectangle(0, 0, 0, 0)
self._preset_rects: list[tuple[float, rl.Rectangle]] = []
self._scrubber_rect = rl.Rectangle(0, 0, 0, 0)
self._scrubber = self._child(
AetherInlineRangeControl(
min_val,
max_val,
step,
get_value(),
on_change,
on_commit=on_commit,
unit=unit,
labels=labels,
color=self._color,
)
)
self._unit = unit
self._labels = labels or {}
self._step = step
@property
def is_interacting(self) -> bool:
return self._scrubber.is_interacting
def _active(self) -> bool:
return bool(self._is_active() if callable(self._is_active) else self._is_active)
def reset_interaction(self) -> None:
self._pressed_zone = None
self._scrubber.reset_interaction()
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
self._scrubber.set_touch_valid_callback(touch_callback)
def set_is_last(self, is_last: bool) -> None:
self._is_last = is_last
def _current_value(self) -> float:
return self._scrubber.current_val if (self._active() or self._scrubber.is_interacting) else self._get_value()
def formatted_value(self) -> str:
return format_adjustor_value(self._current_value(), step=self._step, unit=self._unit, labels=self._labels)
def measure_height(self, width: float) -> float:
del width
if not self._active():
return float(AETHER_LIST_METRICS.adjustor_row_height)
preset_height = AETHER_LIST_METRICS.adjustor_preset_height + AETHER_LIST_METRICS.adjustor_preset_gap if self._presets else 0
return float(AETHER_LIST_METRICS.adjustor_row_active_height + preset_height)
def _set_active_state(self, active: bool) -> None:
if self._set_active is not None:
self._set_active(active)
def _handle_mouse_press(self, mouse_pos: MousePos):
if not self._touch_valid() or not rl.check_collision_point_rec(mouse_pos, self._rect):
return
self._pressed_zone = None
if self._active():
for preset_value, preset_rect in self._preset_rects:
if rl.check_collision_point_rec(mouse_pos, _inflate_rect(preset_rect, 4, 4)):
self._pressed_zone = f"preset:{preset_value}"
return
if rl.check_collision_point_rec(mouse_pos, self._scrubber_rect):
self._pressed_zone = "scrubber"
return
if rl.check_collision_point_rec(mouse_pos, self._header_rect) or rl.check_collision_point_rec(mouse_pos, self._value_rect) or rl.check_collision_point_rec(mouse_pos, self._hint_rect):
self._pressed_zone = "header"
def _handle_mouse_release(self, mouse_pos: MousePos):
pressed_zone = self._pressed_zone
self._pressed_zone = None
if pressed_zone == "scrubber":
return
if pressed_zone and pressed_zone.startswith("preset:"):
self._scrubber._value_at_press = self._scrubber.current_val
try:
preset_value = float(pressed_zone.split(":", 1)[1])
except ValueError:
return
for value, preset_rect in self._preset_rects:
if value == preset_value and rl.check_collision_point_rec(mouse_pos, _inflate_rect(preset_rect, 4, 4)):
self._scrubber.set_value(preset_value)
self._scrubber._on_change(self._scrubber.current_val)
self._scrubber._commit_if_needed()
return
return
if pressed_zone == "header":
active = self._active()
if rl.check_collision_point_rec(mouse_pos, _inflate_rect(self._header_rect, 6, 4)) or rl.check_collision_point_rec(mouse_pos, _inflate_rect(self._value_rect, 6, 4)) or rl.check_collision_point_rec(mouse_pos, _inflate_rect(self._hint_rect, 6, 4)):
self._set_active_state(not active)
def _handle_mouse_event(self, mouse_event: MouseEvent):
del mouse_event
def _render_preset_chip(self, rect: rl.Rectangle, text: str, *, current: bool, pressed: bool):
fill = rl.Color(255, 255, 255, 5)
border = rl.Color(255, 255, 255, 14)
text_color = self._style.subtitle_color
if current:
fill = _mix_colors(rl.Color(18, 22, 28, 255), self._color, 0.22, alpha=255)
border = _with_alpha(self._color, 72)
text_color = self._style.title_color
elif pressed:
fill = rl.Color(255, 255, 255, 10)
border = rl.Color(255, 255, 255, 22)
_draw_rounded_fill(rect, fill, radius_px=13)
_draw_rounded_stroke(rect, border, radius_px=13)
_draw_text_fit_common(
gui_app.font(FontWeight.MEDIUM),
text,
rl.Vector2(rect.x + 10, rect.y + 7),
max(1.0, rect.width - 20),
15,
align_center=True,
color=text_color,
)
def _render(self, rect: rl.Rectangle):
rect = _snap_rect(rect)
self.set_rect(rect)
active = self._active()
if not self._scrubber.is_interacting:
self._scrubber.set_value(self._get_value())
dt = rl.get_frame_time()
focus_target = 1.0 if active else 0.0
self._focus_progress += (focus_target - self._focus_progress) * (1 - math.exp(-dt / 0.09))
hovered = _point_hits(gui_app.last_mouse_event.pos, rect, self._parent_rect, pad_x=6, pad_y=0)
current_bg = _with_alpha(_mix_colors(rl.Color(18, 22, 28, 255), self._color, 0.16), int(18 + self._focus_progress * 14))
current_border = _with_alpha(self._color, int(34 + self._focus_progress * 34))
draw_list_row_shell(
rect,
current=active or self._focus_progress > 0.02,
hovered=hovered and not self.is_interacting,
pressed=self._pressed_zone == "header",
is_last=self._is_last,
row_bg=rl.Color(255, 255, 255, 0),
row_border=rl.Color(255, 255, 255, 0),
row_separator=self._style.divider_color,
current_bg=current_bg,
current_border=current_border,
)
value_pill_w = min(float(AETHER_LIST_METRICS.adjustor_value_pill_width), max(118.0, rect.width * 0.22))
self._header_rect = rl.Rectangle(rect.x, rect.y, rect.width, min(rect.height, 78))
self._value_rect = _snap_rect(rl.Rectangle(rect.x + rect.width - value_pill_w - 18, rect.y + 14, value_pill_w, AETHER_LIST_METRICS.adjustor_value_pill_height))
content_right = self._value_rect.x - 18
content_left = rect.x + 24
content_width = max(120.0, content_right - content_left)
gui_label(rl.Rectangle(content_left, rect.y + 14, content_width, 28), self._title, 24, self._style.title_color, FontWeight.MEDIUM)
gui_label(rl.Rectangle(content_left, rect.y + 44, content_width, 22), self._subtitle, 18, self._style.subtitle_color, FontWeight.NORMAL)
pill_fill = rl.Color(255, 255, 255, 5)
pill_border = rl.Color(255, 255, 255, 14)
if active:
pill_fill = _mix_colors(rl.Color(18, 22, 28, 255), self._color, 0.20, alpha=255)
pill_border = _with_alpha(self._color, 64)
_draw_rounded_fill(self._value_rect, pill_fill, radius_px=16)
_draw_rounded_stroke(self._value_rect, pill_border, radius_px=16)
_draw_text_fit_common(
self._font_value,
self.formatted_value(),
rl.Vector2(self._value_rect.x + 14, self._value_rect.y + 8),
max(1.0, self._value_rect.width - 28),
18,
align_center=True,
color=self._style.title_color,
)
hint_y = rect.y + 76
self._hint_rect = _snap_rect(rl.Rectangle(content_left, hint_y, rect.width - 48, 8))
hint_track = _snap_rect(rl.Rectangle(self._hint_rect.x, self._hint_rect.y + 2, self._hint_rect.width, 4))
rl.draw_rectangle_rounded(hint_track, 1.0, 10, rl.Color(255, 255, 255, 10))
fill_w = hint_track.width * self._scrubber._value_fraction(self._current_value())
if fill_w > 0:
rl.draw_rectangle_rounded(_snap_rect(rl.Rectangle(hint_track.x, hint_track.y, fill_w, hint_track.height)), 1.0, 10, _with_alpha(self._color, 180 if active else 120))
if not active:
return
tray_alpha = max(0, min(255, int(255 * self._focus_progress)))
tray_top = rect.y + 92 - (1.0 - self._focus_progress) * 6
current_y = tray_top
self._preset_rects.clear()
if self._presets:
chip_gap = 8.0
chip_h = float(AETHER_LIST_METRICS.adjustor_preset_height)
chip_w = max(68.0, (rect.width - 48 - chip_gap * (len(self._presets) - 1)) / max(1, len(self._presets)))
chip_x = content_left
for preset_value in self._presets:
chip_rect = _snap_rect(rl.Rectangle(chip_x, current_y, chip_w, chip_h))
self._preset_rects.append((preset_value, chip_rect))
self._render_preset_chip(
chip_rect,
format_adjustor_value(preset_value, step=self._step, unit=self._unit, labels=self._labels),
current=abs(self._current_value() - preset_value) <= max(abs(self._step) * 0.5, 1e-4),
pressed=self._pressed_zone == f"preset:{preset_value}",
)
chip_x += chip_w + chip_gap
current_y += chip_h + AETHER_LIST_METRICS.adjustor_preset_gap
self._scrubber_rect = _snap_rect(rl.Rectangle(content_left, current_y, rect.width - 48, AETHER_LIST_METRICS.adjustor_scrubber_height))
self._scrubber.set_parent_rect(self._parent_rect)
self._scrubber.render(self._scrubber_rect)
def draw_selection_list_row(
rect: rl.Rectangle,
*,
@@ -1705,10 +2355,14 @@ class AetherSlider(Widget):
unit: str = "",
labels: dict[float, str] | None = None,
color: rl.Color = rl.Color(54, 77, 239, 255),
on_commit: Callable[[float], None] | None = None,
show_value_label: bool = True,
):
super().__init__()
self.min_val, self.max_val, self.step, self.current_val = min_val, max_val, step, current_val
self.on_change, self.unit, self.labels, self.color = on_change, unit, labels or {}, color
self.on_change, self.on_commit = on_change, on_commit
self.unit, self.labels, self.color = unit, labels or {}, color
self.show_value_label = show_value_label
self._is_dragging = False
self._font = gui_app.font(FontWeight.BOLD)
self._thumb_offset: float = 0.0
@@ -1716,6 +2370,70 @@ class AetherSlider(Widget):
self._plus_offset: float = 0.0
self._minus_pressed = False
self._plus_pressed = False
self._pending_drag = False
self._press_start = rl.Vector2(0, 0)
self._started_on_thumb = False
self._value_at_press = current_val
@property
def is_interacting(self) -> bool:
return self._is_dragging or self._pending_drag or self._minus_pressed or self._plus_pressed
def set_value(self, value: float) -> None:
self.current_val = self._clamp_and_snap(value)
def reset_interaction(self) -> None:
self._is_dragging = False
self._pending_drag = False
self._started_on_thumb = False
self._thumb_offset = 0.0
self._minus_pressed = False
self._plus_pressed = False
def _cancel_interaction(self, *, revert: bool = False) -> None:
if revert and self.current_val != self._value_at_press:
self.current_val = self._value_at_press
self.on_change(self.current_val)
self.reset_interaction()
def _finalize_interaction(self, mouse_pos: MousePos, *, inside_release: bool) -> None:
button_w = self._button_width(self._rect)
changed = False
if self._minus_pressed:
self._minus_pressed = False
if inside_release and rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._rect.x, self._rect.y, button_w, self._rect.height)):
new_val = self._clamp_and_snap(self.current_val - self.step)
if new_val != self.current_val:
self.current_val = new_val
self.on_change(self.current_val)
changed = True
if self._plus_pressed:
self._plus_pressed = False
if inside_release and rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._rect.x + self._rect.width - button_w, self._rect.y, button_w, self._rect.height)):
new_val = self._clamp_and_snap(self.current_val + self.step)
if new_val != self.current_val:
self.current_val = new_val
self.on_change(self.current_val)
changed = True
if self._is_dragging:
changed = changed or self.current_val != self._value_at_press
self._is_dragging = False
self._thumb_offset = 0.0
elif self._pending_drag:
if inside_release and not self._started_on_thumb and rl.check_collision_point_rec(mouse_pos, self._rect):
before_tap = self.current_val
self._update_val_from_mouse(mouse_pos)
changed = changed or before_tap != self.current_val
self._pending_drag = False
self._thumb_offset = 0.0
changed = changed or self.current_val != self._value_at_press
self._started_on_thumb = False
if changed and self.on_commit is not None:
self.on_commit(self.current_val)
def _clamp_and_snap(self, val: float) -> float:
if self.step <= 0:
@@ -1786,8 +2504,9 @@ class AetherSlider(Widget):
if self.step > 0:
n_steps = int(round(value_range / self.step))
if n_steps > 0:
for i in range(n_steps + 1):
tick_x = track_x + (i / n_steps) * track_w
tick_count = min(n_steps, 24)
for i in range(tick_count + 1):
tick_x = track_x + (i / max(1, tick_count)) * track_w
tick_h = int(track_h * 0.6)
tick_y = track_rect.y + (track_h - tick_h) / 2
rl.draw_rectangle_rec(rl.Rectangle(tick_x - 1, tick_y, 2, tick_h), rl.Color(255, 255, 255, 60))
@@ -1799,16 +2518,18 @@ class AetherSlider(Widget):
_draw_rounded_fill(t_face_rect, rl.Color(230, 235, 242, 255), radius_px=12)
_draw_rounded_stroke(t_face_rect, rl.Color(20, 22, 28, 46), radius_px=12)
rl.draw_rectangle_rec(rl.Rectangle(t_face_rect.x, t_face_rect.y, t_face_rect.width, 1), rl.Color(255, 255, 255, 40))
val_str = self.labels.get(self.current_val, f"{self.current_val:.2f}".rstrip('0').rstrip('.') + self.unit)
label_size = max(18, int(round(rect.height * 0.38)))
ts = measure_text_cached(self._font, val_str, label_size)
val_x = max(rect.x, min(thumb_x + (thumb_w - ts.x) / 2, rect.x + rect.width - ts.x))
val_pos = rl.Vector2(val_x, thumb_y - label_size - 10)
rl.draw_text_ex(self._font, val_str, rl.Vector2(round(val_pos.x), round(val_pos.y)), label_size, 0, rl.WHITE)
if self.show_value_label:
val_str = self.labels.get(self.current_val, f"{self.current_val:.2f}".rstrip('0').rstrip('.') + self.unit)
label_size = max(18, int(round(rect.height * 0.38)))
ts = measure_text_cached(self._font, val_str, label_size)
val_x = max(rect.x, min(thumb_x + (thumb_w - ts.x) / 2, rect.x + rect.width - ts.x))
val_pos = rl.Vector2(val_x, thumb_y - label_size - 10)
rl.draw_text_ex(self._font, val_str, rl.Vector2(round(val_pos.x), round(val_pos.y)), label_size, 0, rl.WHITE)
def _handle_mouse_press(self, mouse_pos: MousePos):
if not rl.check_collision_point_rec(mouse_pos, self._rect):
if not self._touch_valid() or not rl.check_collision_point_rec(mouse_pos, self._rect):
return
self._value_at_press = self.current_val
button_w = self._button_width(self._rect)
minus_rect = rl.Rectangle(self._rect.x, self._rect.y, button_w, self._rect.height)
plus_rect = rl.Rectangle(self._rect.x + self._rect.width - button_w, self._rect.y, button_w, self._rect.height)
@@ -1823,38 +2544,48 @@ class AetherSlider(Widget):
thumb_y = self._rect.y + (self._rect.height - thumb_h) / 2
thumb_rect = rl.Rectangle(thumb_x - 8, thumb_y - 8, thumb_w + 16, thumb_h + 16)
if rl.check_collision_point_rec(mouse_pos, thumb_rect):
self._is_dragging = True
self._pending_drag = True
self._started_on_thumb = True
self._press_start = rl.Vector2(mouse_pos.x, mouse_pos.y)
self._thumb_offset = 1.0
else:
track_x = self._rect.x + button_w
track_w = self._rect.width - 2 * button_w
if track_w > 0:
rel_x = max(0.0, min(1.0, (mouse_pos.x - track_x) / track_w))
val = self.min_val + rel_x * (self.max_val - self.min_val)
snapped = self._clamp_and_snap(val)
if snapped != self.current_val:
self.current_val = snapped
self.on_change(self.current_val)
self._pending_drag = True
self._started_on_thumb = False
self._press_start = rl.Vector2(mouse_pos.x, mouse_pos.y)
def _handle_mouse_release(self, mouse_pos: MousePos):
button_w = self._button_width(self._rect)
if self._minus_pressed:
self._minus_pressed = False
if rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._rect.x, self._rect.y, button_w, self._rect.height)):
new_val = self._clamp_and_snap(self.current_val - self.step)
if new_val != self.current_val:
self.current_val = new_val
self.on_change(self.current_val)
if self._plus_pressed:
self._plus_pressed = False
if rl.check_collision_point_rec(mouse_pos, rl.Rectangle(self._rect.x + self._rect.width - button_w, self._rect.y, button_w, self._rect.height)):
new_val = self._clamp_and_snap(self.current_val + self.step)
if new_val != self.current_val:
self.current_val = new_val
self.on_change(self.current_val)
self._finalize_interaction(mouse_pos, inside_release=True)
def _handle_mouse_event(self, mouse_event: MouseEvent):
mouse_in_rect = rl.check_collision_point_rec(mouse_event.pos, self._rect)
if mouse_event.left_released and self.is_interacting and not mouse_in_rect:
self._finalize_interaction(mouse_event.pos, inside_release=False)
return
if not self._touch_valid():
self._cancel_interaction(revert=True)
return
if self._pending_drag and not self._is_dragging:
dx = mouse_event.pos.x - self._press_start.x
dy = mouse_event.pos.y - self._press_start.y
if abs(dy) > 12 and abs(dy) > abs(dx):
self._pending_drag = False
self._started_on_thumb = False
self._thumb_offset = 0.0
return
if abs(dx) > 12 and abs(dx) >= abs(dy):
self._pending_drag = False
self._is_dragging = True
self._thumb_offset = 1.0
if self._is_dragging:
self._is_dragging = False
self._thumb_offset = 0.0
dx = mouse_event.pos.x - self._press_start.x
dy = mouse_event.pos.y - self._press_start.y
if abs(dy) > 18 and abs(dy) > abs(dx) * 1.15:
self._cancel_interaction(revert=True)
return
self._update_val_from_mouse(mouse_event.pos)
def _update_val_from_mouse(self, mouse_pos: MousePos):
button_w = self._button_width(self._rect)
@@ -2211,8 +2942,9 @@ class TileGrid(Widget):
def add_tile(self, tile: Widget):
self.tiles.append(tile)
if self._touch_valid_callback is not None and hasattr(tile, "set_touch_valid_callback"):
tile.set_touch_valid_callback(self._touch_valid_callback)
touch_valid_callback = getattr(self, "_touch_valid_callback", None)
if touch_valid_callback is not None and hasattr(tile, "set_touch_valid_callback"):
tile.set_touch_valid_callback(touch_valid_callback)
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(touch_callback)
@@ -25,9 +25,9 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPan
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import (
AETHER_COMPACT_ROW_HEIGHT,
AETHER_LIST_METRICS,
AetherAdjustorRow,
AetherScrollbar,
AetherSegmentedControl,
AetherSliderDialog,
AetherListColors,
DEFAULT_PANEL_STYLE,
_point_hits,
@@ -89,6 +89,7 @@ class SystemSettingsManagerView(Widget):
SECTION_GAP = AETHER_LIST_METRICS.section_gap
SECTION_HEADER_HEIGHT = AETHER_LIST_METRICS.section_header_height
SECTION_HEADER_GAP = AETHER_LIST_METRICS.section_header_gap
SLIDER_ROW_HEIGHT = AETHER_LIST_METRICS.range_row_height
ROW_HEIGHT = AETHER_COMPACT_ROW_HEIGHT
CONTENT_GUTTER = AETHER_LIST_METRICS.content_right_gutter
FADE_HEIGHT = AETHER_LIST_METRICS.fade_height
@@ -106,10 +107,13 @@ class SystemSettingsManagerView(Widget):
self._scrollbar = AetherScrollbar()
self._content_height = 0.0
self._scroll_offset = 0.0
self._ensure_visible_key: str | None = None
self._interactive_rects: dict[str, rl.Rectangle] = {}
self._pressed_target: str | None = None
self._can_click = True
self._active_tab_key = "basics"
self._active_adjustor_key: str | None = None
self._adjustor_rows: dict[str, AetherAdjustorRow] = {}
self._display_slider_keys = ["ScreenBrightness", "ScreenBrightnessOnroad", "ScreenTimeout", "ScreenTimeoutOnroad"]
self._power_slider_keys = ["DeviceShutdown", "LowVoltageShutdown"]
@@ -127,6 +131,8 @@ class SystemSettingsManagerView(Widget):
"min": 0,
"max": 101,
"step": 1,
"live": True,
"presets": [0, 25, 50, 75, 101],
"get": lambda: float(self._controller._params.get_int("ScreenBrightness")),
"set": lambda v: self._controller._set_brightness("ScreenBrightness", v),
},
@@ -138,6 +144,8 @@ class SystemSettingsManagerView(Widget):
"min": 1,
"max": 101,
"step": 1,
"live": True,
"presets": [1, 35, 60, 80, 101],
"get": lambda: float(max(1, self._controller._params.get_int("ScreenBrightnessOnroad"))),
"set": lambda v: self._controller._set_brightness("ScreenBrightnessOnroad", max(1, int(v))),
},
@@ -149,6 +157,8 @@ class SystemSettingsManagerView(Widget):
"min": 5,
"max": 60,
"step": 5,
"live": False,
"presets": [5, 15, 30, 60],
"get": lambda: float(self._controller._params.get_int("ScreenTimeout")),
"set": lambda v: self._controller._params.put_int("ScreenTimeout", int(v)),
},
@@ -160,6 +170,8 @@ class SystemSettingsManagerView(Widget):
"min": 5,
"max": 60,
"step": 5,
"live": False,
"presets": [5, 15, 30, 60],
"get": lambda: float(self._controller._params.get_int("ScreenTimeoutOnroad")),
"set": lambda v: self._controller._params.put_int("ScreenTimeoutOnroad", int(v)),
},
@@ -171,6 +183,8 @@ class SystemSettingsManagerView(Widget):
"min": 0,
"max": 33,
"step": 1,
"live": False,
"presets": [0, 1, 4, 8],
"get": lambda: float(self._controller._params.get_int("DeviceShutdown")),
"set": lambda v: self._controller._params.put_int("DeviceShutdown", int(v)),
},
@@ -182,11 +196,38 @@ class SystemSettingsManagerView(Widget):
"min": 11.8,
"max": 12.5,
"step": 0.1,
"live": False,
"presets": [11.8, 12.0, 12.2, 12.5],
"get": lambda: float(self._controller._params.get_float("LowVoltageShutdown")),
"set": lambda v: self._controller._params.put_float("LowVoltageShutdown", float(v)),
},
}
for key, spec in self._slider_specs.items():
on_change = (lambda value, setter=spec["set"]: setter(value)) if spec.get("live") else (lambda _value: None)
on_commit = None if spec.get("live") else (lambda value, setter=spec["set"]: setter(value))
adjustor = self._child(
AetherAdjustorRow(
spec["title"],
spec["subtitle"],
spec["min"],
spec["max"],
spec["step"],
spec["get"],
on_change,
on_commit=on_commit,
unit=spec["unit"],
labels=spec["labels"],
presets=spec.get("presets", []),
is_active=lambda key=key: self._active_adjustor_key == key,
set_active=lambda active, key=key: self._set_active_adjustor(key, active),
style=self.PANEL_STYLE,
color=self.PANEL_STYLE.accent,
)
)
adjustor.set_touch_valid_callback(lambda adjustor=adjustor: self._scroll_panel.is_touch_valid() or adjustor.is_interacting)
self._adjustor_rows[key] = adjustor
self._toggle_defs = [
{
"id": "StandbyMode",
@@ -352,36 +393,24 @@ class SystemSettingsManagerView(Widget):
return self._controller.backup_status_text()
def _format_slider_value(self, key: str) -> str:
adjustor = self._adjustor_rows.get(key)
if adjustor is not None:
return adjustor.formatted_value()
spec = self._slider_specs[key]
current_val = spec["get"]()
if current_val in spec["labels"]:
return spec["labels"][current_val]
if spec["step"] < 1:
suffix = spec["unit"]
return f"{current_val:.1f}{suffix}"
suffix = spec["unit"]
return f"{int(current_val)}{suffix}"
return f"{current_val:.1f}{spec['unit']}"
return f"{int(current_val)}{spec['unit']}"
def _open_slider_dialog(self, key: str):
spec = self._slider_specs[key]
def on_close(result, value):
if result == DialogResult.CONFIRM:
spec["set"](value)
gui_app.push_widget(
AetherSliderDialog(
spec["title"],
spec["min"],
spec["max"],
spec["step"],
spec["get"](),
on_close,
unit=spec["unit"],
labels=spec["labels"],
color=AetherListColors.PRIMARY,
)
)
def _set_active_adjustor(self, key: str, active: bool):
if active:
self._active_adjustor_key = key
self._ensure_visible_key = key
elif self._active_adjustor_key == key:
self._active_adjustor_key = None
self._ensure_visible_key = None
def _get_drive_mode_index(self):
state = self._controller._get_force_drive_state()
@@ -401,6 +430,10 @@ class SystemSettingsManagerView(Widget):
def _clear_ephemeral_state(self):
self._pressed_target = None
self._can_click = True
self._active_adjustor_key = None
self._ensure_visible_key = None
for adjustor in self._adjustor_rows.values():
adjustor.reset_interaction()
def show_event(self):
super().show_event()
@@ -439,11 +472,11 @@ class SystemSettingsManagerView(Widget):
if not target_id:
return
prefix, _, value = target_id.partition(":")
if prefix == "slider":
self._open_slider_dialog(value)
return
if prefix == "tab":
self._active_tab_key = value
self._active_adjustor_key = None
for adjustor in self._adjustor_rows.values():
adjustor.reset_interaction()
return
if prefix == "toggle":
toggle_def = self._lookup_toggle(value)
@@ -525,8 +558,8 @@ class SystemSettingsManagerView(Widget):
return self.TAB_HEIGHT + self.TAB_BOTTOM_GAP + content_height
def _measure_active_tab_height(self, width: float) -> float:
display_h = self._section_block_height(self._section_height(len(self._display_slider_keys), self.ROW_HEIGHT))
power_h = self._section_block_height(self._section_height(len(self._power_slider_keys), self.ROW_HEIGHT))
display_h = self._section_block_height(self._slider_section_height(self._display_slider_keys, width))
power_h = self._section_block_height(self._slider_section_height(self._power_slider_keys, width))
backups_h = self._section_block_height(self._section_height(2, self.ROW_HEIGHT))
maintenance_h = self._section_block_height(self._maintenance_section_content_height())
if self._active_tab_key == "basics":
@@ -547,6 +580,13 @@ class SystemSettingsManagerView(Widget):
def _section_block_height(self, content_height: float) -> float:
return self.SECTION_HEADER_HEIGHT + self.SECTION_HEADER_GAP + content_height + self.SECTION_GAP
def _slider_section_height(self, keys: list[str], width: float) -> float:
total = 0.0
for key in keys:
adjustor = self._adjustor_rows[key]
total += adjustor.measure_height(width)
return total
def _maintenance_section_content_height(self) -> float:
support_h = self._section_height(len(self._support_rows), self.ROW_HEIGHT)
danger_h = self._section_height(len(self._danger_rows), self.ROW_HEIGHT)
@@ -618,30 +658,31 @@ class SystemSettingsManagerView(Widget):
def _draw_slider_section(self, y: float, x: float, width: float, title: str, keys: list[str]) -> float:
draw_section_header(rl.Rectangle(x, y, width, self.SECTION_HEADER_HEIGHT), title, style=self.PANEL_STYLE)
y += self.SECTION_HEADER_HEIGHT + self.SECTION_HEADER_GAP
group_rect = rl.Rectangle(x, y, width, self._section_height(len(keys), self.ROW_HEIGHT))
group_rect = rl.Rectangle(x, y, width, self._slider_section_height(keys, width))
draw_list_group_shell(group_rect)
current_y = group_rect.y
for index, key in enumerate(keys):
self._draw_slider_row(rl.Rectangle(group_rect.x, y + index * self.ROW_HEIGHT, group_rect.width, self.ROW_HEIGHT), key, is_last=index == len(keys) - 1)
current_y = self._draw_slider_row(rl.Rectangle(group_rect.x, current_y, group_rect.width, 0), key, is_last=index == len(keys) - 1)
return y + group_rect.height + self.SECTION_GAP
def _draw_slider_row(self, rect: rl.Rectangle, key: str, is_last: bool):
spec = self._slider_specs[key]
target_id = f"slider:{key}"
hovered, pressed = self._interactive_state(target_id, rect)
draw_settings_list_row(
rect,
title=spec["title"],
subtitle=spec["subtitle"],
value=self._format_slider_value(key),
hovered=hovered,
pressed=pressed,
is_last=is_last,
show_chevron=True,
title_size=24,
subtitle_size=18,
value_size=22,
style=self.PANEL_STYLE,
)
def _draw_slider_row(self, rect: rl.Rectangle, key: str, is_last: bool) -> float:
adjustor = self._adjustor_rows[key]
adjustor.set_is_last(is_last)
row_h = adjustor.measure_height(rect.width)
row_rect = rl.Rectangle(rect.x, rect.y, rect.width, row_h)
if self._ensure_visible_key == key:
padding = 12.0
min_offset = min(0.0, self._scroll_rect.y + padding - row_rect.y)
max_offset = min(0.0, self._scroll_rect.y + self._scroll_rect.height - padding - (row_rect.y + row_rect.height))
current_offset = self._scroll_panel.get_offset()
if current_offset < max_offset:
self._scroll_panel.set_offset(max_offset)
elif current_offset > min_offset:
self._scroll_panel.set_offset(min_offset)
self._ensure_visible_key = None
adjustor.set_parent_rect(self._scroll_rect)
adjustor.render(row_rect)
return rect.y + row_h
def _draw_toggle_group_section(self, y: float, x: float, width: float, group: dict) -> float:
toggles = self._toggle_defs_for_group(group)