proejct commit

This commit is contained in:
2026-09-24 15:45:31 +02:00
commit 82d52be8a5
63 changed files with 8748 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar)."""
+65
View File
@@ -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)
+298
View File
@@ -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()
+59
View File
@@ -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))),
)
+500
View File
@@ -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,
)