proejct commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar)."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Zeichenhelfer für Körperformen – gemeinsam genutzt von Renderer und Vorschau-Kachel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TypeAlias
|
||||
|
||||
import pygame
|
||||
|
||||
#: RGB- oder RGBA-Farbe (RGBA für halbtransparente Vorschauen).
|
||||
DrawColor: TypeAlias = tuple[int, ...]
|
||||
|
||||
|
||||
def draw_circle(
|
||||
surface: pygame.Surface,
|
||||
color: DrawColor,
|
||||
outline: DrawColor,
|
||||
center: tuple[float, float],
|
||||
radius: float,
|
||||
*,
|
||||
outline_width: int,
|
||||
angle: float | None = None,
|
||||
marker_length: float = 0.0,
|
||||
) -> None:
|
||||
"""Zeichnet einen geglätteten Kreis mit Umrandung und optionalem Drehmarker.
|
||||
|
||||
Args:
|
||||
surface: Zielfläche.
|
||||
color: Füllfarbe.
|
||||
outline: Farbe von Umrandung und Drehmarker.
|
||||
center: Mittelpunkt in Bildschirmkoordinaten.
|
||||
radius: Radius in px.
|
||||
outline_width: Linienstärke der Umrandung.
|
||||
angle: Drehwinkel in rad. Ohne Marker wäre die Drehung eines einfarbigen Kreises
|
||||
unsichtbar – man sähe nicht, ob er rollt oder rutscht.
|
||||
marker_length: Länge des Markers als Anteil des Radius.
|
||||
"""
|
||||
# Die Füllung braucht keine Kantenglättung: Die geglättete Umrandung liegt genau auf
|
||||
# ihrem Rand und verdeckt die Treppenstufen. Das halbiert die teuren aacircle-Aufrufe.
|
||||
pygame.draw.circle(surface, color, center, radius)
|
||||
pygame.draw.aacircle(surface, outline, center, radius, outline_width)
|
||||
if angle is not None:
|
||||
end = (
|
||||
center[0] + math.cos(angle) * radius * marker_length,
|
||||
center[1] + math.sin(angle) * radius * marker_length,
|
||||
)
|
||||
pygame.draw.line(surface, outline, center, end, outline_width)
|
||||
|
||||
|
||||
def draw_box(
|
||||
surface: pygame.Surface,
|
||||
color: DrawColor,
|
||||
outline: DrawColor,
|
||||
center: tuple[float, float],
|
||||
half_extents: tuple[float, float],
|
||||
*,
|
||||
outline_width: int,
|
||||
corner_radius: int = 0,
|
||||
) -> None:
|
||||
"""Zeichnet ein achsenparalleles Rechteck mit Umrandung."""
|
||||
half_width, half_height = half_extents
|
||||
rect = pygame.Rect(0, 0, round(2 * half_width), round(2 * half_height))
|
||||
rect.center = (round(center[0]), round(center[1]))
|
||||
pygame.draw.rect(surface, color, rect, border_radius=corner_radius)
|
||||
pygame.draw.rect(surface, outline, rect, outline_width, border_radius=corner_radius)
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Werkzeugleiste am linken Fensterrand. Sie gehört nicht zur Physikwelt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import Protocol
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import Color, InteractionConfig
|
||||
from sandbox.interaction import ShapeKind, ToolSettings
|
||||
from sandbox.ui.style import UiStyle
|
||||
from sandbox.ui.widgets import (
|
||||
Button,
|
||||
ColorPicker,
|
||||
Label,
|
||||
LabelKind,
|
||||
SegmentedControl,
|
||||
Slider,
|
||||
SpawnTile,
|
||||
Widget,
|
||||
)
|
||||
|
||||
|
||||
class SidebarControls(Protocol):
|
||||
"""Was die Sidebar von der Anwendung braucht (erfüllt von :class:`~sandbox.main.App`)."""
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
"""Ob die Simulation pausiert ist."""
|
||||
...
|
||||
|
||||
def toggle_pause(self) -> None:
|
||||
"""Pausiert bzw. setzt fort."""
|
||||
...
|
||||
|
||||
def gravity_enabled(self) -> bool:
|
||||
"""Ob die Schwerkraft wirkt."""
|
||||
...
|
||||
|
||||
def toggle_gravity(self) -> None:
|
||||
"""Schaltet die Schwerkraft um."""
|
||||
...
|
||||
|
||||
def clear_world(self) -> None:
|
||||
"""Entfernt alle Körper."""
|
||||
...
|
||||
|
||||
def begin_spawn_drag(self) -> None:
|
||||
"""Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel."""
|
||||
...
|
||||
|
||||
def preview_color(self) -> Color:
|
||||
"""Farbe des nächsten Objekts."""
|
||||
...
|
||||
|
||||
def status_lines(self) -> list[str]:
|
||||
"""Statuszeilen für den unteren Rand (Objektzahl, FPS …)."""
|
||||
...
|
||||
|
||||
|
||||
def _format_size(value: float) -> str:
|
||||
return f"{value:.0f} px"
|
||||
|
||||
|
||||
def _format_ratio(value: float) -> str:
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
def _format_density(value: float) -> str:
|
||||
return f"{value:.1f}"
|
||||
|
||||
|
||||
class Sidebar:
|
||||
"""Baut die Bedienelemente auf, verteilt Events an sie und zeichnet sie.
|
||||
|
||||
Args:
|
||||
rect: Bereich der Sidebar auf dem Bildschirm.
|
||||
style: Farben, Schriften, Maße.
|
||||
tools: Die Werkzeug-Einstellungen, die die Regler verändern.
|
||||
limits: Wertebereiche der Regler.
|
||||
palette: Farbpalette.
|
||||
controls: Anbindung an die Anwendung.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
style: UiStyle,
|
||||
tools: ToolSettings,
|
||||
limits: InteractionConfig,
|
||||
palette: tuple[Color, ...],
|
||||
controls: SidebarControls,
|
||||
) -> None:
|
||||
self.rect = pygame.Rect(rect)
|
||||
self.style = style
|
||||
self.tools = tools
|
||||
self.controls = controls
|
||||
self.widgets: list[Widget] = []
|
||||
self._status_top = 0
|
||||
self._build(limits, palette)
|
||||
|
||||
# ------------------------------------------------------------------ Aufbau
|
||||
|
||||
def _build(self, limits: InteractionConfig, palette: tuple[Color, ...]) -> None:
|
||||
"""Ordnet die Widgets von oben nach unten an."""
|
||||
ui = self.style.ui
|
||||
tools = self.tools
|
||||
controls = self.controls
|
||||
left = self.rect.x + ui.padding
|
||||
width = self.rect.width - 2 * ui.padding
|
||||
y = self.rect.y + ui.padding
|
||||
|
||||
def row(height: int) -> pygame.Rect:
|
||||
return pygame.Rect(left, y, width, height)
|
||||
|
||||
def add(widget: Widget, gap: int = ui.row_gap) -> None:
|
||||
nonlocal y
|
||||
self.widgets.append(widget)
|
||||
y = widget.rect.bottom + gap
|
||||
|
||||
def slider(
|
||||
name: str,
|
||||
label: str,
|
||||
value_format: Callable[[float], str],
|
||||
*,
|
||||
minimum: float,
|
||||
maximum: float,
|
||||
step: float,
|
||||
is_enabled: Callable[[], bool] | None = None,
|
||||
) -> Slider:
|
||||
# Der Regler liest und schreibt direkt das gleichnamige Attribut der Einstellungen.
|
||||
return Slider(
|
||||
row(ui.slider_height),
|
||||
label,
|
||||
minimum=minimum,
|
||||
maximum=maximum,
|
||||
step=step,
|
||||
get_value=partial(getattr, tools, name),
|
||||
set_value=partial(setattr, tools, name),
|
||||
value_format=value_format,
|
||||
knob_radius=ui.slider_knob_radius,
|
||||
is_enabled=is_enabled,
|
||||
name=name,
|
||||
)
|
||||
|
||||
add(Label(row(ui.header_height + ui.padding), "Physik-Sandbox", LabelKind.TITLE))
|
||||
|
||||
add(Label(row(ui.header_height), "OBJEKT", LabelKind.HEADER))
|
||||
add(
|
||||
SegmentedControl(
|
||||
row(ui.button_height),
|
||||
[(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")],
|
||||
partial(getattr, tools, "shape"),
|
||||
partial(setattr, tools, "shape"),
|
||||
name="shape",
|
||||
)
|
||||
)
|
||||
add(
|
||||
slider(
|
||||
"size",
|
||||
"Größe",
|
||||
_format_size,
|
||||
minimum=limits.min_size,
|
||||
maximum=limits.max_size,
|
||||
step=limits.size_step,
|
||||
)
|
||||
)
|
||||
add(
|
||||
slider(
|
||||
"aspect_ratio",
|
||||
"Seitenverhältnis",
|
||||
_format_ratio,
|
||||
minimum=limits.min_aspect_ratio,
|
||||
maximum=limits.max_aspect_ratio,
|
||||
step=limits.aspect_ratio_step,
|
||||
is_enabled=lambda: tools.shape is ShapeKind.BOX,
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
|
||||
add(Label(row(ui.header_height), "MATERIAL", LabelKind.HEADER))
|
||||
add(
|
||||
slider(
|
||||
"restitution",
|
||||
"Elastizität",
|
||||
_format_ratio,
|
||||
minimum=0.0,
|
||||
maximum=limits.max_restitution,
|
||||
step=limits.restitution_step,
|
||||
)
|
||||
)
|
||||
add(
|
||||
slider(
|
||||
"density",
|
||||
"Dichte",
|
||||
_format_density,
|
||||
minimum=limits.min_density,
|
||||
maximum=limits.max_density,
|
||||
step=limits.density_step,
|
||||
)
|
||||
)
|
||||
add(Label(row(ui.header_height), "FARBE", LabelKind.HEADER))
|
||||
add(
|
||||
ColorPicker(
|
||||
row(ui.swatch_size),
|
||||
palette,
|
||||
partial(getattr, tools, "color_index"),
|
||||
partial(setattr, tools, "color_index"),
|
||||
swatch_size=ui.swatch_size,
|
||||
swatch_gap=ui.swatch_gap,
|
||||
name="color",
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
add(
|
||||
SpawnTile(
|
||||
row(ui.spawn_tile_height),
|
||||
lambda: tools,
|
||||
controls.preview_color,
|
||||
controls.begin_spawn_drag,
|
||||
hint="Ziehen oder in die Welt klicken",
|
||||
name="spawn_tile",
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
|
||||
add(Label(row(ui.header_height), "SIMULATION", LabelKind.HEADER))
|
||||
half = (width - ui.row_gap) // 2
|
||||
self.widgets.append(
|
||||
Button(
|
||||
pygame.Rect(left, y, half, ui.button_height),
|
||||
lambda: "Weiter" if controls.is_paused() else "Pause",
|
||||
controls.toggle_pause,
|
||||
is_active=controls.is_paused,
|
||||
name="pause",
|
||||
)
|
||||
)
|
||||
add(
|
||||
Button(
|
||||
pygame.Rect(
|
||||
left + half + ui.row_gap, y, width - half - ui.row_gap, ui.button_height
|
||||
),
|
||||
lambda: "Schwerkraft an" if controls.gravity_enabled() else "Schwerkraft aus",
|
||||
controls.toggle_gravity,
|
||||
is_active=controls.gravity_enabled,
|
||||
name="gravity",
|
||||
)
|
||||
)
|
||||
add(
|
||||
Button(
|
||||
row(ui.button_height),
|
||||
"Alles löschen",
|
||||
controls.clear_world,
|
||||
danger=True,
|
||||
name="clear",
|
||||
),
|
||||
ui.section_gap,
|
||||
)
|
||||
self._status_top = y
|
||||
|
||||
# ------------------------------------------------------------------ Laufzeit
|
||||
|
||||
def widget(self, name: str) -> Widget:
|
||||
"""Findet ein Widget über seinen Namen."""
|
||||
for widget in self.widgets:
|
||||
if widget.name == name:
|
||||
return widget
|
||||
raise KeyError(name)
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Leitet ein Event an die Widgets weiter.
|
||||
|
||||
Returns:
|
||||
True, wenn ein Widget das Event verbraucht hat oder ein Klick auf die Sidebar
|
||||
fiel – solche Klicks dürfen nie in der Welt landen.
|
||||
"""
|
||||
if event.type == pygame.MOUSEMOTION:
|
||||
# Alle Widgets sehen jede Mausbewegung (Hover-Zustand, gezogene Regler).
|
||||
results = [widget.handle_event(event, mouse_pos) for widget in self.widgets]
|
||||
return any(results)
|
||||
for widget in self.widgets:
|
||||
if widget.handle_event(event, mouse_pos):
|
||||
return True
|
||||
is_click = event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP)
|
||||
return is_click and self.rect.collidepoint(mouse_pos)
|
||||
|
||||
def draw(self, surface: pygame.Surface) -> None:
|
||||
"""Zeichnet Hintergrund, Widgets und Statuszeilen."""
|
||||
style = self.style
|
||||
pygame.draw.rect(surface, style.theme.sidebar_background, self.rect)
|
||||
for widget in self.widgets:
|
||||
widget.draw(surface, style)
|
||||
y = self._status_top
|
||||
for line in self.controls.status_lines():
|
||||
rendered = style.render_text(line, style.small_font, style.theme.text_dim)
|
||||
surface.blit(rendered, (self.rect.x + style.ui.padding, y))
|
||||
y += rendered.get_height()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Gemeinsamer Zeichenstil: Farben, Schriften und ein kleiner Text-Cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import Color, Theme, UiConfig
|
||||
|
||||
|
||||
def _load_font(names: tuple[str, ...], size: int, *, bold: bool = False) -> pygame.font.Font:
|
||||
"""Lädt die erste verfügbare Systemschrift, sonst die eingebaute pygame-Schrift."""
|
||||
for name in names:
|
||||
path = pygame.font.match_font(name)
|
||||
if path:
|
||||
font = pygame.font.Font(path, size)
|
||||
break
|
||||
else:
|
||||
font = pygame.font.Font(None, size)
|
||||
font.bold = bold
|
||||
return font
|
||||
|
||||
|
||||
class UiStyle:
|
||||
"""Farben, Maße und Schriften, die alle Widgets und der Renderer gemeinsam nutzen.
|
||||
|
||||
Args:
|
||||
theme: Farbschema.
|
||||
ui: Schriften und Maße.
|
||||
"""
|
||||
|
||||
def __init__(self, theme: Theme, ui: UiConfig) -> None:
|
||||
if not pygame.font.get_init():
|
||||
pygame.font.init()
|
||||
self.theme = theme
|
||||
self.ui = ui
|
||||
self.title_font = _load_font(ui.font_names, ui.title_font_size, bold=True)
|
||||
self.label_font = _load_font(ui.font_names, ui.label_font_size)
|
||||
self.small_font = _load_font(ui.font_names, ui.small_font_size)
|
||||
self._text_cache: dict[tuple[int, str, Color], pygame.Surface] = {}
|
||||
|
||||
def render_text(self, text: str, font: pygame.font.Font, color: Color) -> pygame.Surface:
|
||||
"""Rendert Text (mit Zwischenspeicher, da sich die meisten Texte selten ändern)."""
|
||||
key = (id(font), text, color)
|
||||
surface = self._text_cache.get(key)
|
||||
if surface is None:
|
||||
if len(self._text_cache) >= self.ui.text_cache_size:
|
||||
self._text_cache.clear() # einfach und ausreichend: selten, dann komplett
|
||||
surface = font.render(text, True, color)
|
||||
self._text_cache[key] = surface
|
||||
return surface
|
||||
|
||||
|
||||
def shade(color: Color, factor: float) -> Color:
|
||||
"""Verdunkelt (factor < 1) oder erhellt (factor > 1) eine Farbe."""
|
||||
return (
|
||||
max(0, min(255, round(color[0] * factor))),
|
||||
max(0, min(255, round(color[1] * factor))),
|
||||
max(0, min(255, round(color[2] * factor))),
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Selbst gezeichnete Bedienelemente: Beschriftung, Button, Umschalter, Regler, Farbwahl.
|
||||
|
||||
Jedes Widget hat ein Rechteck, verarbeitet pygame-Events (:meth:`Widget.handle_event`
|
||||
liefert True, wenn es das Event verbraucht hat) und zeichnet sich selbst. Zustände wie
|
||||
"ist die Pause aktiv?" fragen die Widgets über Callbacks ab, statt sie zu kopieren – so
|
||||
zeigen sie immer den aktuellen Stand, auch wenn er per Tastenkürzel geändert wurde.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from enum import Enum
|
||||
from typing import Generic, TypeAlias, TypeVar
|
||||
|
||||
import pygame
|
||||
|
||||
from sandbox.config import Color
|
||||
from sandbox.interaction import ShapeKind, ToolSettings
|
||||
from sandbox.ui.drawing import draw_box, draw_circle
|
||||
from sandbox.ui.style import UiStyle, shade
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
#: Fester Text oder Funktion, die den aktuellen Text liefert.
|
||||
TextSource: TypeAlias = str | Callable[[], str]
|
||||
|
||||
|
||||
def _resolve(text: TextSource) -> str:
|
||||
return text() if callable(text) else text
|
||||
|
||||
|
||||
def _is_left_press(event: pygame.event.Event, rect: pygame.Rect) -> bool:
|
||||
return (
|
||||
event.type == pygame.MOUSEBUTTONDOWN
|
||||
and event.button == pygame.BUTTON_LEFT
|
||||
and rect.collidepoint(event.pos)
|
||||
)
|
||||
|
||||
|
||||
def _is_left_release(event: pygame.event.Event) -> bool:
|
||||
return event.type == pygame.MOUSEBUTTONUP and event.button == pygame.BUTTON_LEFT
|
||||
|
||||
|
||||
class Widget:
|
||||
"""Basisklasse aller Bedienelemente.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe auf dem Bildschirm.
|
||||
name: Eindeutiger Name, über den z. B. Tests das Widget finden.
|
||||
"""
|
||||
|
||||
def __init__(self, rect: pygame.Rect, name: str = "") -> None:
|
||||
self.rect = pygame.Rect(rect)
|
||||
self.name = name
|
||||
self.hovered = False
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Verarbeitet ein Event; True, wenn das Widget es verbraucht hat."""
|
||||
if event.type == pygame.MOUSEMOTION:
|
||||
self.hovered = self.rect.collidepoint(mouse_pos)
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet das Widget."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LabelKind(Enum):
|
||||
"""Darstellungsart einer Beschriftung."""
|
||||
|
||||
TITLE = "title"
|
||||
HEADER = "header"
|
||||
TEXT = "text"
|
||||
HINT = "hint"
|
||||
|
||||
|
||||
class Label(Widget):
|
||||
"""Einzeilige Beschriftung (Text fest oder dynamisch über eine Funktion)."""
|
||||
|
||||
def __init__(
|
||||
self, rect: pygame.Rect, text: TextSource, kind: LabelKind = LabelKind.TEXT, name: str = ""
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.text = text
|
||||
self.kind = kind
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet den Text linksbündig und vertikal zentriert."""
|
||||
theme = style.theme
|
||||
font, color = {
|
||||
LabelKind.TITLE: (style.title_font, theme.text),
|
||||
LabelKind.HEADER: (style.small_font, theme.text_dim),
|
||||
LabelKind.TEXT: (style.label_font, theme.text),
|
||||
LabelKind.HINT: (style.small_font, theme.text_dim),
|
||||
}[self.kind]
|
||||
rendered = style.render_text(_resolve(self.text), font, color)
|
||||
surface.blit(rendered, (self.rect.x, self.rect.centery - rendered.get_height() // 2))
|
||||
|
||||
|
||||
class Button(Widget):
|
||||
"""Schaltfläche. Löst beim Loslassen über dem Button aus (übliches UI-Verhalten).
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
text: Beschriftung (fest oder dynamisch).
|
||||
on_click: Wird beim Klick aufgerufen.
|
||||
is_active: Optional; hebt den Button hervor, solange die Funktion True liefert.
|
||||
danger: Rote Darstellung für zerstörerische Aktionen.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
text: TextSource,
|
||||
on_click: Callable[[], None],
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
danger: bool = False,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.text = text
|
||||
self.on_click = on_click
|
||||
self.is_active = is_active
|
||||
self.danger = danger
|
||||
self.pressed = False
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Drücken merkt sich den Button, Loslassen über dem Button löst aus."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
if _is_left_press(event, self.rect):
|
||||
self.pressed = True
|
||||
return True
|
||||
if _is_left_release(event) and self.pressed:
|
||||
self.pressed = False
|
||||
if self.rect.collidepoint(event.pos):
|
||||
self.on_click()
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet Hintergrund (je nach Zustand) und zentrierten Text."""
|
||||
theme = style.theme
|
||||
active = self.is_active is not None and self.is_active()
|
||||
if active:
|
||||
background = theme.panel_active
|
||||
elif self.hovered:
|
||||
background = theme.panel_hover
|
||||
else:
|
||||
background = theme.panel
|
||||
radius = style.ui.corner_radius
|
||||
pygame.draw.rect(surface, background, self.rect, border_radius=radius)
|
||||
text_color = theme.danger if self.danger else theme.text
|
||||
if self.danger or active:
|
||||
border = theme.danger if self.danger else theme.accent
|
||||
pygame.draw.rect(
|
||||
surface, border, self.rect, style.ui.border_width, border_radius=radius
|
||||
)
|
||||
rendered = style.render_text(_resolve(self.text), style.label_font, text_color)
|
||||
surface.blit(rendered, rendered.get_rect(center=self.rect.center))
|
||||
|
||||
|
||||
class SegmentedControl(Widget, Generic[T]):
|
||||
"""Reihe gleich breiter Optionen, von denen genau eine gewählt ist.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
options: Paare aus Wert und Beschriftung.
|
||||
get_value: Liefert den aktuell gewählten Wert.
|
||||
set_value: Setzt einen neuen Wert.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
options: Sequence[tuple[T, str]],
|
||||
get_value: Callable[[], T],
|
||||
set_value: Callable[[T], None],
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.options = list(options)
|
||||
self.get_value = get_value
|
||||
self.set_value = set_value
|
||||
|
||||
def segment_rects(self) -> list[pygame.Rect]:
|
||||
"""Rechtecke der einzelnen Optionen."""
|
||||
count = len(self.options)
|
||||
rects = []
|
||||
for index in range(count):
|
||||
left = self.rect.x + self.rect.width * index // count
|
||||
right = self.rect.x + self.rect.width * (index + 1) // count
|
||||
rects.append(pygame.Rect(left, self.rect.y, right - left, self.rect.height))
|
||||
return rects
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Ein Klick auf eine Option wählt sie sofort aus."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
for (value, _), rect in zip(self.options, self.segment_rects(), strict=True):
|
||||
if _is_left_press(event, rect):
|
||||
self.set_value(value)
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet alle Optionen, die gewählte hervorgehoben."""
|
||||
theme = style.theme
|
||||
radius = style.ui.corner_radius
|
||||
pygame.draw.rect(surface, theme.panel, self.rect, border_radius=radius)
|
||||
current = self.get_value()
|
||||
for (value, text), rect in zip(self.options, self.segment_rects(), strict=True):
|
||||
selected = value == current
|
||||
if selected:
|
||||
pygame.draw.rect(surface, theme.panel_active, rect, border_radius=radius)
|
||||
pygame.draw.rect(
|
||||
surface, theme.accent, rect, style.ui.border_width, border_radius=radius
|
||||
)
|
||||
color = theme.text if selected else theme.text_dim
|
||||
rendered = style.render_text(text, style.label_font, color)
|
||||
surface.blit(rendered, rendered.get_rect(center=rect.center))
|
||||
|
||||
|
||||
class Slider(Widget):
|
||||
"""Schieberegler mit Beschriftung und Wertanzeige.
|
||||
|
||||
Oben stehen Beschriftung und aktueller Wert, darunter die Schiene mit Knopf. Der Wert
|
||||
lässt sich klicken, ziehen oder mit dem Mausrad (in ``step``-Schritten) ändern.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
label: Beschriftung.
|
||||
minimum: Kleinster Wert.
|
||||
maximum: Größter Wert.
|
||||
step: Rasterung der Werte.
|
||||
get_value: Liefert den aktuellen Wert.
|
||||
set_value: Setzt einen neuen Wert.
|
||||
value_format: Formatiert den Wert für die Anzeige.
|
||||
knob_radius: Radius des Knopfs in px (bestimmt auch den Rand der Schiene).
|
||||
is_enabled: Optional; ein deaktivierter Regler ist ausgegraut und reagiert nicht.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
label: str,
|
||||
*,
|
||||
minimum: float,
|
||||
maximum: float,
|
||||
step: float,
|
||||
get_value: Callable[[], float],
|
||||
set_value: Callable[[float], None],
|
||||
value_format: Callable[[float], str],
|
||||
knob_radius: int,
|
||||
is_enabled: Callable[[], bool] | None = None,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
if not (maximum > minimum and step > 0.0):
|
||||
raise ValueError("Der Regler braucht maximum > minimum und step > 0")
|
||||
self.label = label
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.step = step
|
||||
self.get_value = get_value
|
||||
self.set_value = set_value
|
||||
self.value_format = value_format
|
||||
self.knob_radius = knob_radius
|
||||
self.is_enabled = is_enabled
|
||||
self.dragging = False
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Ob der Regler bedienbar ist."""
|
||||
return self.is_enabled is None or self.is_enabled()
|
||||
|
||||
@property
|
||||
def track_left(self) -> int:
|
||||
"""x-Koordinate des linken Schienenendes."""
|
||||
return self.rect.x + self.knob_radius
|
||||
|
||||
@property
|
||||
def track_width(self) -> int:
|
||||
"""Breite der Schiene in px."""
|
||||
return self.rect.width - 2 * self.knob_radius
|
||||
|
||||
@property
|
||||
def track_y(self) -> int:
|
||||
"""y-Koordinate der Schienenmitte."""
|
||||
return self.rect.bottom - self.knob_radius
|
||||
|
||||
def snap(self, value: float) -> float:
|
||||
"""Rastet einen Wert auf das Raster ein und begrenzt ihn auf [minimum, maximum]."""
|
||||
steps = round((value - self.minimum) / self.step)
|
||||
snapped = self.minimum + steps * self.step
|
||||
return min(max(snapped, self.minimum), self.maximum)
|
||||
|
||||
def value_at(self, x: float) -> float:
|
||||
"""Wert, der zur Bildschirmposition ``x`` auf der Schiene gehört."""
|
||||
fraction = (x - self.track_left) / self.track_width
|
||||
fraction = min(max(fraction, 0.0), 1.0)
|
||||
return self.snap(self.minimum + fraction * (self.maximum - self.minimum))
|
||||
|
||||
def x_of(self, value: float) -> float:
|
||||
"""Bildschirmposition ``x`` eines Werts (Umkehrung von :meth:`value_at`)."""
|
||||
fraction = (value - self.minimum) / (self.maximum - self.minimum)
|
||||
return self.track_left + fraction * self.track_width
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Klicken, Ziehen und Mausrad ändern den Wert."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
if not self.enabled:
|
||||
self.dragging = False
|
||||
return _is_left_press(event, self.rect) # Klick verschlucken, nichts tun
|
||||
if _is_left_press(event, self.rect):
|
||||
self.dragging = True
|
||||
self.set_value(self.value_at(event.pos[0]))
|
||||
return True
|
||||
if event.type == pygame.MOUSEMOTION and self.dragging:
|
||||
self.set_value(self.value_at(mouse_pos[0]))
|
||||
return True
|
||||
if _is_left_release(event) and self.dragging:
|
||||
self.dragging = False
|
||||
return True
|
||||
if event.type == pygame.MOUSEWHEEL and self.rect.collidepoint(mouse_pos):
|
||||
self.set_value(self.snap(self.get_value() + event.y * self.step))
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet Beschriftung, Wert, Schiene und Knopf."""
|
||||
theme = style.theme
|
||||
enabled = self.enabled
|
||||
text_color = theme.text if enabled else theme.text_dim
|
||||
label = style.render_text(self.label, style.label_font, text_color)
|
||||
surface.blit(label, (self.rect.x, self.rect.y))
|
||||
value = style.render_text(
|
||||
self.value_format(self.get_value()), style.small_font, theme.text_dim
|
||||
)
|
||||
# Unterkanten bündig: Der kleinere Wert steht so auf derselben Grundlinie wie der Text.
|
||||
label_bottom = self.rect.y + label.get_height()
|
||||
surface.blit(value, value.get_rect(bottomright=(self.rect.right, label_bottom)))
|
||||
|
||||
track = pygame.Rect(0, 0, self.track_width, style.ui.slider_track_height)
|
||||
track.midleft = (self.track_left, self.track_y)
|
||||
pygame.draw.rect(surface, theme.panel_hover, track, border_radius=track.height)
|
||||
knob_x = round(self.x_of(self.get_value()))
|
||||
filled = track.copy()
|
||||
filled.width = max(0, knob_x - track.x)
|
||||
fill_color = theme.accent if enabled else theme.text_dim
|
||||
pygame.draw.rect(surface, fill_color, filled, border_radius=track.height)
|
||||
knob_color = theme.text if (self.hovered or self.dragging) and enabled else fill_color
|
||||
pygame.draw.aacircle(surface, knob_color, (knob_x, self.track_y), self.knob_radius)
|
||||
|
||||
|
||||
class ColorPicker(Widget):
|
||||
"""Farbfelder zur Auswahl; das erste Feld steht für "Zufallsfarbe".
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
colors: Farbpalette.
|
||||
get_index: Liefert den gewählten Palettenindex (``None`` = Zufall).
|
||||
set_index: Setzt den Palettenindex (``None`` = Zufall).
|
||||
swatch_size: Kantenlänge eines Farbfelds in px.
|
||||
swatch_gap: Abstand zwischen den Feldern in px.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
colors: Sequence[Color],
|
||||
get_index: Callable[[], int | None],
|
||||
set_index: Callable[[int | None], None],
|
||||
*,
|
||||
swatch_size: int,
|
||||
swatch_gap: int,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.colors = list(colors)
|
||||
self.get_index = get_index
|
||||
self.set_index = set_index
|
||||
self.swatch_size = swatch_size
|
||||
self.swatch_gap = swatch_gap
|
||||
|
||||
def swatches(self) -> list[tuple[int | None, pygame.Rect]]:
|
||||
"""Alle Farbfelder mit ihrem Index (zuerst das Zufallsfeld)."""
|
||||
indices: list[int | None] = [None, *range(len(self.colors))]
|
||||
pitch = self.swatch_size + self.swatch_gap
|
||||
return [
|
||||
(
|
||||
index,
|
||||
pygame.Rect(
|
||||
self.rect.x + i * pitch, self.rect.y, self.swatch_size, self.swatch_size
|
||||
),
|
||||
)
|
||||
for i, index in enumerate(indices)
|
||||
]
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Ein Klick auf ein Farbfeld wählt es aus."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
for index, rect in self.swatches():
|
||||
if _is_left_press(event, rect):
|
||||
self.set_index(index)
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet die Felder; das gewählte bekommt einen hellen Ring."""
|
||||
theme = style.theme
|
||||
selected = self.get_index()
|
||||
for index, rect in self.swatches():
|
||||
if index is None:
|
||||
# Zufallsfeld: vier Palettenfarben als Viertel.
|
||||
half = rect.width // 2
|
||||
quarters = self.colors[:: max(1, len(self.colors) // 4)][:4]
|
||||
for k, color in enumerate(quarters):
|
||||
quarter = pygame.Rect(
|
||||
rect.x + (k % 2) * half, rect.y + (k // 2) * half, half, half
|
||||
)
|
||||
pygame.draw.rect(surface, color, quarter)
|
||||
else:
|
||||
pygame.draw.rect(surface, self.colors[index], rect)
|
||||
if index == selected:
|
||||
gap = style.ui.selection_ring_gap
|
||||
ring = rect.inflate(2 * gap, 2 * gap)
|
||||
pygame.draw.rect(surface, theme.highlight, ring, style.ui.selection_ring_width)
|
||||
|
||||
|
||||
class SpawnTile(Widget):
|
||||
"""Vorschau-Kachel des nächsten Objekts; aus ihr zieht man Objekte in die Welt.
|
||||
|
||||
Args:
|
||||
rect: Position und Größe.
|
||||
get_settings: Liefert die aktuellen Werkzeug-Einstellungen.
|
||||
get_color: Liefert die Farbe des nächsten Objekts.
|
||||
on_drag_start: Wird beim Drücken auf die Kachel aufgerufen.
|
||||
hint: Hinweistext unter der Vorschau.
|
||||
name: Name des Widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rect: pygame.Rect,
|
||||
get_settings: Callable[[], ToolSettings],
|
||||
get_color: Callable[[], Color],
|
||||
on_drag_start: Callable[[], None],
|
||||
*,
|
||||
hint: str,
|
||||
name: str = "",
|
||||
) -> None:
|
||||
super().__init__(rect, name)
|
||||
self.get_settings = get_settings
|
||||
self.get_color = get_color
|
||||
self.on_drag_start = on_drag_start
|
||||
self.hint = hint
|
||||
|
||||
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
|
||||
"""Drücken auf die Kachel beginnt das Herausziehen eines neuen Objekts."""
|
||||
super().handle_event(event, mouse_pos)
|
||||
if _is_left_press(event, self.rect):
|
||||
self.on_drag_start()
|
||||
return True
|
||||
return False
|
||||
|
||||
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
|
||||
"""Zeichnet Kachel, maßstäblich verkleinerte Vorschau und Hinweis."""
|
||||
theme = style.theme
|
||||
ui = style.ui
|
||||
background = theme.panel_hover if self.hovered else theme.panel
|
||||
pygame.draw.rect(surface, background, self.rect, border_radius=ui.corner_radius)
|
||||
hint = style.render_text(self.hint, style.small_font, theme.text_dim)
|
||||
hint_rect = hint.get_rect(midbottom=(self.rect.centerx, self.rect.bottom - ui.row_gap))
|
||||
surface.blit(hint, hint_rect)
|
||||
|
||||
settings = self.get_settings()
|
||||
half_width, half_height = settings.half_extents()
|
||||
area_height = hint_rect.top - self.rect.top
|
||||
available = min(self.rect.width, area_height) * ui.preview_scale_limit / 2
|
||||
scale = min(1.0, available / max(half_width, half_height))
|
||||
center = (self.rect.centerx, self.rect.top + area_height / 2)
|
||||
color = self.get_color()
|
||||
outline = shade(color, theme.outline_shade)
|
||||
if settings.shape is ShapeKind.CIRCLE:
|
||||
draw_circle(
|
||||
surface, color, outline, center, half_width * scale, outline_width=ui.outline_width
|
||||
)
|
||||
else:
|
||||
draw_box(
|
||||
surface,
|
||||
color,
|
||||
outline,
|
||||
center,
|
||||
(half_width * scale, half_height * scale),
|
||||
outline_width=ui.outline_width,
|
||||
)
|
||||
Reference in New Issue
Block a user