BigUI WIP : AetherMultiSelect Tile + Dialog

This commit is contained in:
firestarsdog
2026-06-15 22:28:44 -04:00
parent 59336096e7
commit 18ea1dd170
@@ -589,6 +589,17 @@ class PanelManagerView(AetherInteractiveMixin, Widget):
disabled_label=d.get("disabled_label", ""),
)
def _make_multi_select_tile(self, d: dict) -> AetherMultiSelectTile:
return AetherMultiSelectTile(
title=d["title"],
options=d["options"],
get_values=d.get("get_values", d.get("get")),
set_values=d.get("set_values", d.get("set")),
bg_color=self.PANEL_STYLE.accent,
desc=d.get("subtitle", d.get("desc", "")),
is_enabled=d.get("is_enabled", d.get("enabled")),
)
# ── pagination ─────────────────────────────────────────────
PAGE_COMMIT_RATIO = 0.20
@@ -3975,6 +3986,275 @@ class RadioTileGroup(Widget):
rl.draw_text_ex(self._font, opt, rl.Vector2(round(text_pos.x), round(text_pos.y)), font_size, spacing, AetherListColors.HEADER if is_active else AetherListColors.SUBTEXT)
class AetherMultiSelectDialog(Widget):
def __init__(
self,
title: str,
options: dict[str, str],
current_values: list[str],
on_close: Callable,
color: rl.Color | str = "#8B5CF6",
):
super().__init__()
self.title = title
self._user_callback = on_close
self._options = options
self._selected = set(current_values)
self._color = hex_to_color(color) if isinstance(color, str) else color
self._font_title = gui_app.font(FontWeight.BOLD)
self._font_btn = gui_app.font(FontWeight.BOLD)
self._font_chip = gui_app.font(FontWeight.MEDIUM)
self._chip_rects: list[tuple[str, rl.Rectangle]] = []
self._pressed_zone: str | None = None
self._is_pressed_ok = False
self._is_pressed_cancel = False
self._ok_offset = 0.0
self._cancel_offset = 0.0
self._ok_target = 0.0
self._cancel_target = 0.0
self._ok_rect = rl.Rectangle(0, 0, 0, 0)
self._cancel_rect = rl.Rectangle(0, 0, 0, 0)
def _handle_mouse_press(self, mouse_pos: MousePos):
if rl.check_collision_point_rec(mouse_pos, self._ok_rect):
self._is_pressed_ok = True
self._ok_target = 1.0
return
if rl.check_collision_point_rec(mouse_pos, self._cancel_rect):
self._is_pressed_cancel = True
self._cancel_target = 1.0
return
for slug, rect in self._chip_rects:
if rl.check_collision_point_rec(mouse_pos, rect):
self._pressed_zone = f"chip:{slug}"
return
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._is_pressed_ok:
self._ok_target = 0.0
if rl.check_collision_point_rec(mouse_pos, self._ok_rect):
gui_app.pop_widget()
self._user_callback(DialogResult.CONFIRM, list(self._selected))
self._is_pressed_ok = False
elif self._is_pressed_cancel:
self._cancel_target = 0.0
if rl.check_collision_point_rec(mouse_pos, self._cancel_rect):
gui_app.pop_widget()
self._user_callback(DialogResult.CANCEL, list(self._selected))
self._is_pressed_cancel = False
elif self._pressed_zone:
if self._pressed_zone.startswith("chip:"):
slug = self._pressed_zone.split(":", 1)[1]
for s, rect in self._chip_rects:
if s == slug and rl.check_collision_point_rec(mouse_pos, rect):
if slug in self._selected:
self._selected.remove(slug)
else:
self._selected.add(slug)
break
self._pressed_zone = None
def _handle_mouse_event(self, mouse_event):
pass
def _render_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 = AetherListColors.SUBTEXT
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 = AetherListColors.HEADER
elif pressed:
fill = rl.Color(255, 255, 255, 10)
border = rl.Color(255, 255, 255, 22)
_draw_rounded_fill(rect, fill, radius_px=16)
_draw_rounded_stroke(rect, border, radius_px=16)
_draw_text_fit_common(
self._font_chip,
text,
rl.Vector2(rect.x + 10, rect.y + (rect.height - 28) / 2),
max(1.0, rect.width - 20),
28,
align_center=True,
color=text_color,
)
def _render(self, rect: rl.Rectangle):
dt = rl.get_frame_time()
self._ok_offset += (self._ok_target - self._ok_offset) * (1 - math.exp(-dt / PLATE_TAU))
self._cancel_offset += (self._cancel_target - self._cancel_offset) * (1 - math.exp(-dt / PLATE_TAU))
rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 160))
dialog_w = 1600
dialog_h = 840
button_height = 110
button_width = 600
dx, dy = rect.x + (rect.width - dialog_w) / 2, rect.y + (rect.height - dialog_h) / 2
self._ok_rect = rl.Rectangle(dx + dialog_w - button_width - 80, dy + dialog_h - button_height - 60, button_width, button_height)
self._cancel_rect = rl.Rectangle(dx + 80, dy + dialog_h - button_height - 60, button_width, button_height)
d_rect = _snap_rect(rl.Rectangle(dx, dy, dialog_w, dialog_h))
_draw_rounded_fill(d_rect, rl.Color(10, 12, 16, 255), radius_px=24)
_draw_rounded_stroke(d_rect, rl.Color(255, 255, 255, 16), radius_px=24)
rl.draw_rectangle_rec(rl.Rectangle(d_rect.x, d_rect.y, d_rect.width, 3), self._color)
title_size = 44
ts = measure_text_cached(self._font_title, self.title, title_size)
rl.draw_text_ex(self._font_title, self.title, rl.Vector2(round(dx + (dialog_w - ts.x) / 2), round(dy + 60)), title_size, 0, rl.WHITE)
# Render options grid
chip_h = 84.0
chip_gap_x = 24.0
chip_gap_y = 24.0
chips_per_row = 4
total_chips = len(self._options)
if total_chips > 0:
chip_w = max(90.0, (dialog_w - 80 * 2 - chip_gap_x * (chips_per_row - 1)) / chips_per_row)
start_y = dy + 160
self._chip_rects.clear()
for index, (slug, display_name) in enumerate(self._options.items()):
row = index // chips_per_row
col = index % chips_per_row
chip_x = dx + 80 + col * (chip_w + chip_gap_x)
chip_y = start_y + row * (chip_h + chip_gap_y)
chip_rect = _snap_rect(rl.Rectangle(chip_x, chip_y, chip_w, chip_h))
self._chip_rects.append((slug, chip_rect))
self._render_chip(
chip_rect,
display_name,
current=slug in self._selected,
pressed=self._pressed_zone == f"chip:{slug}",
)
# Cancel Button
c_off = self._cancel_offset * 4.0
c_rect = rl.Rectangle(self._cancel_rect.x + c_off, self._cancel_rect.y + c_off, self._cancel_rect.width - c_off * 2, self._cancel_rect.height - c_off * 2)
_draw_rounded_fill(c_rect, rl.Color(255, 255, 255, 12), radius_px=button_height / 2)
_draw_rounded_stroke(c_rect, rl.Color(255, 255, 255, 32), radius_px=button_height / 2)
cts = measure_text_cached(self._font_btn, tr("Cancel"), 38)
rl.draw_text_ex(self._font_btn, tr("Cancel"), rl.Vector2(round(c_rect.x + (c_rect.width - cts.x) / 2), round(c_rect.y + (c_rect.height - cts.y) / 2)), 38, 0, rl.WHITE)
# OK Button
o_off = self._ok_offset * 4.0
o_rect = rl.Rectangle(self._ok_rect.x + o_off, self._ok_rect.y + o_off, self._ok_rect.width - o_off * 2, self._ok_rect.height - o_off * 2)
_draw_rounded_fill(o_rect, self._color, radius_px=button_height / 2)
_draw_rounded_stroke(o_rect, _mix_colors(self._color, rl.WHITE, 0.4, alpha=160), thickness=2, radius_px=button_height / 2)
ots = measure_text_cached(self._font_btn, tr("OK"), 38)
rl.draw_text_ex(self._font_btn, tr("OK"), rl.Vector2(round(o_rect.x + (o_rect.width - ots.x) / 2), round(o_rect.y + (o_rect.height - ots.y) / 2)), 38, 0, rl.WHITE)
class AetherMultiSelectTile(AetherTile):
def __init__(
self,
title: str,
options: dict[str, str],
get_values: Callable[[], list[str]],
set_values: Callable[[list[str]], None],
bg_color: rl.Color | str | None = None,
is_enabled: Callable[[], bool] | None = None,
desc: str = "",
):
super().__init__(surface_color=bg_color)
self.title = title
self.desc = desc
self._options = options
self.get_values = get_values
self.set_values = set_values
self.set_enabled(is_enabled or (lambda: True))
self._font = gui_app.font(FontWeight.BOLD)
self._font_desc = gui_app.font(FontWeight.NORMAL)
self._active_color = self.surface_color
self._disabled_color = rl.Color(120, 120, 120, 255)
self._squish: float = 1.0
def _update_state(self):
dt = rl.get_frame_time()
if self._squish < 1.0:
self._squish += (1.0 - self._squish) * 15.0 * dt
def _handle_mouse_press(self, mouse_pos: MousePos):
if not self.enabled:
return
if rl.check_collision_point_rec(mouse_pos, self._hit_rect):
self._is_pressed = True
self._plate_target = 1.0
self._squish = 0.95
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._is_pressed:
if rl.check_collision_point_rec(mouse_pos, self._hit_rect) and self.enabled:
def on_close(res, new_values):
if res == DialogResult.CONFIRM:
self.set_values(new_values)
dialog = AetherMultiSelectDialog(
title=self.title,
options=self._options,
current_values=self.get_values(),
on_close=on_close,
color=self._active_color,
)
gui_app.push_widget(dialog)
self._plate_target = 0.0
self._is_pressed = False
def _render(self, rect: rl.Rectangle):
enabled = self.enabled
self._animate_plate(rl.get_frame_time())
if not enabled:
self._plate_offset = 0.0
self._plate_target = 0.0
color = self._active_color if enabled else self._disabled_color
glow = 0.0
face, accent = self._render_hud_background(rect, color, glow)
rx, ry, rw, rh = face.x, face.y, face.width, face.height
content_pad = SPACING.tile_content
max_w = rw - content_pad * 2
text_scale = min(rw / 360.0, rh / 205.0)
title_size = max(18, int(round(22 * text_scale)))
self._draw_text_fit(self._font, self.title,
rl.Vector2(rx + content_pad, ry + int(rh * 0.15)),
max_w, title_size, align_center=True, color=_HUD_TEXT_DIM)
current_values = self.get_values()
val_size = max(14, int(round(18 * text_scale)))
val_color = rl.WHITE if enabled else _HUD_TEXT_DIM
list_start_y = ry + int(rh * 0.40)
line_spacing = max(18, int(round(26 * text_scale)))
for i, val_slug in enumerate(current_values):
if i >= 4:
break
y = list_start_y + i * line_spacing
name = self._options.get(val_slug, val_slug)
led_cx = rx + content_pad + 10
led_cy = y + val_size // 2 + 1
if enabled:
rl.draw_circle(int(led_cx), int(led_cy), 8, rl.Color(accent.r, accent.g, accent.b, 24))
rl.draw_circle(int(led_cx), int(led_cy), 4, accent)
else:
rl.draw_circle(int(led_cx), int(led_cy), 5, rl.Color(14, 16, 22, 255))
rl.draw_ring(rl.Vector2(led_cx, led_cy), 3, 4, 0, 360, 24, rl.Color(70, 78, 95, 140))
self._draw_text_fit(self._font_desc, name,
rl.Vector2(rx + content_pad + 26, y),
max_w - 26, val_size, align_center=False, color=val_color)
class AetherSegmentedControl(Widget):
def __init__(
self,