f7bfdb4a0b
Blender wird das Haupt-3D-Tool: zwei Loader (engine/gltf.rs für .glb als Hauptpfad, engine/obj.rs aus irl3d eingebettet) liefern ein neutrales engine/model.rs::Model. Loader sind reine Dekoder; das Frontend zieht sie und schiebt das Ergebnis per Session::load_props in den State, der Renderer baut sein Mesh (render/props.rs, verschmolzen mit der Brush-Welt über eine geteilte Texturliste). glTF trägt Blender-Custom-Properties (node.extras → Object::props) und Empties (Nodes ohne Mesh) als Entity-Kanal. Namenskonvention: `col_*` = unsichtbarer Collision-Proxy, aus dem collision::add_model konvexe Ebenen-Sets baut (konvex-only, daher kein Default-Collider auf Sichtgeometrie). Trigger (engine/trigger.rs) hängen an der Custom Property `signal`: mit Collider = Point-and-Click-Ziel (Session::pick raycastet aus der Bildmitte, Treffer läuft durch den `use <name>`-Trichter), ohne Collider = unsichtbare Betretens-Zone, die einmal je Eintritt feuert. Gefeuert wird nur über Session::exec — der Eingabe-Trichter bleibt der einzige Weg in den State. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
422 lines
18 KiB
Rust
422 lines
18 KiB
Rust
//! UI-Overlay: **state-driven** und **immediate-mode**.
|
||
//!
|
||
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält
|
||
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die
|
||
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem
|
||
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü →
|
||
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch
|
||
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein
|
||
//! einziger Trichter in den State, kein UI-Backdoor.
|
||
//!
|
||
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
|
||
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
|
||
|
||
use crate::engine::tga::Image;
|
||
use crate::render::font::{self, Font, Fonts};
|
||
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
||
use crate::session::{Dialog, Mode, Session};
|
||
|
||
/// Feste Textur-Indizes im Sprite-Pass — Reihenfolge = `ui_textures` in
|
||
/// render::run. `WHITE` ist ein 1×1-Weiß-Texel für Solid-Fills (Farbe kommt
|
||
/// rein aus dem Tint), `FONT_*` die CP437-Glyph-Atlanten, `CURSORS` das
|
||
/// 2×2-Raster der 16×16-Cursor, `ORN` der 9-Slice-Ornamentrahmen.
|
||
pub(crate) const WHITE: usize = 0;
|
||
pub(crate) const FONT_EGA: usize = 1;
|
||
pub(crate) const FONT_CGA: usize = 2;
|
||
pub(crate) const CURSORS: usize = 3;
|
||
pub(crate) const ORN: usize = 4;
|
||
|
||
/// Cursor-Indizes im 2×2-Raster (Pivot mittig je 16×16-Zelle).
|
||
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
|
||
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
|
||
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
|
||
pub(crate) const CUR_HUD_INTERACT: usize = 3; // dito, über einem Klick-Ziel
|
||
|
||
const CURSOR_PX: f32 = 16.0;
|
||
const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel)
|
||
|
||
/// 1×1 weißes RGBA8-`Image` für getintete Solid-Flächen.
|
||
pub(crate) fn white_pixel() -> Image {
|
||
Image { width: 1, height: 1, rgba: vec![255, 255, 255, 255] }
|
||
}
|
||
|
||
/// Weiß-auf-schwarz-Sprite (Font, Cursor, Ornament) → getintbare Maske:
|
||
/// rgb = weiß, alpha = Luminanz. So blendet der Sprite-Pass den schwarzen
|
||
/// Hintergrund weg und der Tint setzt die Farbe. Reine CPU-Nachbearbeitung
|
||
/// beim Laden.
|
||
pub(crate) fn key_luminance(img: &Image) -> Image {
|
||
let mut rgba = img.rgba.clone();
|
||
for px in rgba.chunks_mut(4) {
|
||
let lum = px[0].max(px[1]).max(px[2]);
|
||
px[0] = 255; px[1] = 255; px[2] = 255; px[3] = lum;
|
||
}
|
||
Image { width: img.width, height: img.height, rgba }
|
||
}
|
||
|
||
/// Cursor, wie ihn die App pro Frame an [`layout`] gibt.
|
||
pub(crate) struct Cursor {
|
||
/// Position in internen Pixeln (über die Letterbox gemappt).
|
||
pub(crate) pos: [f32; 2],
|
||
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
||
/// Maus-Cursor an `pos`.
|
||
pub(crate) grabbed: bool,
|
||
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
|
||
/// Variante des HUD-Pointers.
|
||
pub(crate) hud_interact: bool,
|
||
}
|
||
|
||
/// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass.
|
||
#[derive(Default)]
|
||
pub(crate) struct Ui {
|
||
pub(crate) verts: Vec<SpriteVertex>,
|
||
pub(crate) batches: Vec<SpriteBatch>,
|
||
}
|
||
|
||
impl Ui {
|
||
/// Ein getintetes Quad. `rect` = (x, y, w, h) in internen Pixeln
|
||
/// (oben-links), `uv` = (u0, v0, u1, v1) in 0..1. Aufeinanderfolgende
|
||
/// Quads derselben Textur landen im selben Batch.
|
||
pub(crate) fn quad(&mut self, tex: usize, rect: [f32; 4], uv: [f32; 4], color: [f32; 4]) {
|
||
let [x, y, w, h] = rect;
|
||
let [u0, v0, u1, v1] = uv;
|
||
let base = self.verts.len() as u32;
|
||
// Zwei Dreiecke (TL, BL, BR / TL, BR, TR).
|
||
let tl = SpriteVertex { pos: [x, y ], uv: [u0, v0], color };
|
||
let bl = SpriteVertex { pos: [x, y + h], uv: [u0, v1], color };
|
||
let br = SpriteVertex { pos: [x + w, y + h], uv: [u1, v1], color };
|
||
let tr = SpriteVertex { pos: [x + w, y ], uv: [u1, v0], color };
|
||
self.verts.extend([tl, bl, br, tl, br, tr]);
|
||
|
||
// An letzten Batch anhängen, wenn gleiche Textur, sonst neuen öffnen.
|
||
match self.batches.last_mut() {
|
||
Some(b) if b.texture == tex => b.count += 6,
|
||
_ => self.batches.push(SpriteBatch { texture: tex, start: base, count: 6 }),
|
||
}
|
||
}
|
||
|
||
/// Getintetes Solid-Rechteck (weißer Texel).
|
||
pub(crate) fn fill(&mut self, rect: [f32; 4], color: [f32; 4]) {
|
||
self.quad(WHITE, rect, [0.0, 0.0, 1.0, 1.0], color);
|
||
}
|
||
|
||
/// Eine Textzeile ab (x, y) oben-links in `font`, proportional gesetzt
|
||
/// (variable Laufweite, leere Glyphen werden übersprungen). `\n` springt
|
||
/// eine Zeile weiter. Gibt die x-Endposition zurück.
|
||
pub(crate) fn text(&mut self, font: &Font, x: f32, y: f32, s: &str, color: [f32; 4]) -> f32 {
|
||
let (mut cx, mut cy) = (x, y);
|
||
for ch in s.chars() {
|
||
if ch == '\n' { cx = x; cy += font.glyph_h; continue; }
|
||
let code = font::cp437(ch);
|
||
if let Some((uv, w)) = font.glyph_quad(code) {
|
||
self.quad(font.tex(), [cx, cy, w, font.glyph_h], uv, color);
|
||
}
|
||
cx += font.advance(code);
|
||
}
|
||
cx
|
||
}
|
||
|
||
/// Ein Cursor-Sprite (16×16, Pivot mittig) an `pos`, getintet.
|
||
pub(crate) fn cursor(&mut self, idx: usize, pos: [f32; 2], color: [f32; 4]) {
|
||
let rect = [pos[0] - CURSOR_PX * 0.5, pos[1] - CURSOR_PX * 0.5, CURSOR_PX, CURSOR_PX];
|
||
self.quad(CURSORS, rect, cursor_uv(idx), color);
|
||
}
|
||
|
||
/// Opakes Panel mit Ornamentrahmen. `rect` = Außenbounds. Erst der
|
||
/// Hintergrund (Solid, getintet), dann der 9-Slice-Rahmen darüber: feste
|
||
/// 8×8-Ecken, die Kanten dazwischen gekachelt bis zur nächsten Ecke.
|
||
pub(crate) fn panel(&mut self, rect: [f32; 4], bg: [f32; 4], frame: [f32; 4]) {
|
||
self.fill(rect, bg);
|
||
self.ornament_frame(rect, frame);
|
||
}
|
||
|
||
fn ornament_frame(&mut self, rect: [f32; 4], color: [f32; 4]) {
|
||
let [x, y, w, h] = rect;
|
||
let c = ORN_CORNER;
|
||
let a = ORN_ATLAS;
|
||
// Vier Ecken (1:1 aus dem Atlas).
|
||
self.quad(ORN, [x, y, c, c], orn_uv(0.0, 0.0, c, c), color);
|
||
self.quad(ORN, [x + w - c, y, c, c], orn_uv(a - c, 0.0, c, c), color);
|
||
self.quad(ORN, [x, y + h - c, c, c], orn_uv(0.0, a - c, c, c), color);
|
||
self.quad(ORN, [x + w - c, y + h - c, c, c], orn_uv(a - c, a - c, c, c), color);
|
||
// Kanten zwischen den Ecken kacheln.
|
||
self.tile_h(x + c, x + w - c, y, c, [c, 0.0], color); // oben
|
||
self.tile_h(x + c, x + w - c, y + h - c, c, [c, a - c], color); // unten
|
||
self.tile_v(y + c, y + h - c, x, c, [0.0, c], color); // links
|
||
self.tile_v(y + c, y + h - c, x + w - c, c, [a - c, c], color); // rechts
|
||
}
|
||
|
||
/// Horizontale Kante von `x0` bis `x1` bei `y` (Höhe `c`) mit dem
|
||
/// `c`-breiten Atlas-Sample ab `src` (Pixel) kacheln; letzte Kachel wird
|
||
/// passend beschnitten.
|
||
fn tile_h(&mut self, x0: f32, x1: f32, y: f32, c: f32, src: [f32; 2], color: [f32; 4]) {
|
||
let mut cx = x0;
|
||
while cx < x1 - 0.5 {
|
||
let seg = (x1 - cx).min(ORN_EDGE);
|
||
self.quad(ORN, [cx, y, seg, c], orn_uv(src[0], src[1], seg, c), color);
|
||
cx += seg;
|
||
}
|
||
}
|
||
|
||
fn tile_v(&mut self, y0: f32, y1: f32, x: f32, c: f32, src: [f32; 2], color: [f32; 4]) {
|
||
let mut cy = y0;
|
||
while cy < y1 - 0.5 {
|
||
let seg = (y1 - cy).min(ORN_EDGE);
|
||
self.quad(ORN, [x, cy, c, seg], orn_uv(src[0], src[1], c, seg), color);
|
||
cy += seg;
|
||
}
|
||
}
|
||
}
|
||
|
||
const ORN_ATLAS: f32 = 32.0; // Ornament-Atlasgröße (32×32)
|
||
const ORN_EDGE: f32 = 16.0; // Kanten-Sample-Länge (zwischen den 8px-Ecken)
|
||
|
||
/// Atlas-Pixel-Rechteck → UV (0..1) im 32×32-Ornamentatlas.
|
||
fn orn_uv(x: f32, y: f32, w: f32, h: f32) -> [f32; 4] {
|
||
[x / ORN_ATLAS, y / ORN_ATLAS, (x + w) / ORN_ATLAS, (y + h) / ORN_ATLAS]
|
||
}
|
||
|
||
/// UV-Rechteck einer Cursor-Zelle im 2×2-Raster (16×16 je Zelle, Atlas 32×32).
|
||
fn cursor_uv(idx: usize) -> [f32; 4] {
|
||
let col = (idx % 2) as f32;
|
||
let row = (idx / 2) as f32;
|
||
let (x0, y0) = (col * CURSOR_PX, row * CURSOR_PX);
|
||
[x0 / 32.0, y0 / 32.0, (x0 + CURSOR_PX) / 32.0, (y0 + CURSOR_PX) / 32.0]
|
||
}
|
||
|
||
/// Ergebnis eines Frames: die Overlay-Geometrie und — falls der Cursor über
|
||
/// einem Klickziel steht — dessen Aktion (Eingabe-String für `Session::exec`).
|
||
pub(crate) struct Screen {
|
||
pub(crate) ui: Ui,
|
||
pub(crate) hover_action: Option<String>,
|
||
}
|
||
|
||
/// Punkt in Rechteck (x, y, w, h)?
|
||
fn in_rect(p: [f32; 2], r: [f32; 4]) -> bool {
|
||
p[0] >= r[0] && p[0] < r[0] + r[2] && p[1] >= r[1] && p[1] < r[1] + r[3]
|
||
}
|
||
|
||
/// Ein klickbarer Button: Fläche + zentriertes Label. Steht der Cursor
|
||
/// darüber, wird er hervorgehoben und seine `action` in `hover` vermerkt
|
||
/// (der Klick wirkt dann auf genau diese Aktion).
|
||
fn button(
|
||
ui: &mut Ui, font: &Font, rect: [f32; 4], label: &str, action: &str,
|
||
cur: [f32; 2], hover: &mut Option<String>,
|
||
) {
|
||
let hot = in_rect(cur, rect);
|
||
if hot { *hover = Some(action.to_string()); }
|
||
let bg = if hot { [0.34, 0.22, 0.46, 1.0] } else { [0.15, 0.12, 0.22, 1.0] };
|
||
ui.fill(rect, bg);
|
||
let tx = rect[0] + (rect[2] - font.text_width(label)) * 0.5;
|
||
let ty = rect[1] + (rect[3] - font.glyph_h) * 0.5;
|
||
ui.text(font, tx.round(), ty.round(), label, [0.95, 0.95, 1.0, 1.0]);
|
||
}
|
||
|
||
/// Innenabstand vom Panelrand zum Inhalt: 8px Ornamentrahmen + 3px Luft.
|
||
const INSET: f32 = ORN_CORNER + 3.0;
|
||
const PANEL_BG: [f32; 4] = [0.06, 0.05, 0.10, 1.0];
|
||
const PANEL_FRAME: [f32; 4] = [0.85, 0.75, 0.95, 1.0];
|
||
const TEXT: [f32; 4] = [0.92, 0.92, 1.0, 1.0];
|
||
|
||
/// Das Overlay für den aktuellen Frame bauen — verzweigt nach `session.mode`.
|
||
pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: &Cursor) -> Screen {
|
||
let mut ui = Ui::default();
|
||
let mut hover: Option<String> = None;
|
||
let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font.
|
||
|
||
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
||
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
||
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
||
if !cur.grabbed {
|
||
match &session.mode {
|
||
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen
|
||
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
|
||
Mode::FirstPerson | Mode::Free => {}
|
||
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
||
Mode::Dialog(d) => dialog(&mut ui, f, internal, d, cur.pos, &mut hover),
|
||
}
|
||
}
|
||
|
||
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
|
||
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
|
||
if cur.grabbed {
|
||
let idx = if cur.hud_interact { CUR_HUD_INTERACT } else { CUR_HUD };
|
||
ui.cursor(idx, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
|
||
} else {
|
||
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
|
||
ui.cursor(idx, cur.pos, [1.0; 4]);
|
||
}
|
||
|
||
Screen { ui, hover_action: hover }
|
||
}
|
||
|
||
/// Menu-Modus: das (noch demohafte) Menü, zentriert.
|
||
fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Option<String>) {
|
||
let items = [
|
||
("Fortsetzen", "menu"), // schließt das Menü → ins Spiel (Play)
|
||
("Hilfe", "help"),
|
||
("KV anzeigen", "kv"),
|
||
("Beenden", "quit"),
|
||
];
|
||
let (bw, bh, gap) = (112.0, 14.0, 4.0);
|
||
let title_h = f.glyph_h;
|
||
let body_h = title_h + gap + items.len() as f32 * bh + (items.len() as f32 - 1.0) * gap;
|
||
let pw = bw + 2.0 * INSET;
|
||
let ph = body_h + 2.0 * INSET;
|
||
let px = ((internal[0] - pw) * 0.5).round();
|
||
let py = ((internal[1] - ph) * 0.5).round();
|
||
|
||
ui.panel([px, py, pw, ph], PANEL_BG, PANEL_FRAME);
|
||
ui.text(f, px + INSET, py + INSET, "Menü", [0.85, 0.8, 0.6, 1.0]);
|
||
|
||
let bx = px + INSET;
|
||
let mut by = py + INSET + title_h + gap;
|
||
for (label, action) in items {
|
||
button(ui, f, [bx, by, bw, bh], label, action, cur, hover);
|
||
by += bh + gap;
|
||
}
|
||
}
|
||
|
||
/// Dialog-Modus: Textpanel unten, darunter ein Button je Choice. Ohne Choices
|
||
/// ein „Weiter"-Button, dessen Aktion die Leereingabe ist (blättert weiter) —
|
||
/// derselbe Pfad wie Enter im Terminal (`dialog_input`).
|
||
fn dialog(ui: &mut Ui, f: &Font, internal: [f32; 2], d: &Dialog, cur: [f32; 2], hover: &mut Option<String>) {
|
||
let margin = 16.0;
|
||
let (bh, gap) = (14.0, 4.0);
|
||
let pw = internal[0] - 2.0 * margin;
|
||
let line_h = f.glyph_h + 2.0;
|
||
|
||
// Text auf die Panelbreite umbrechen.
|
||
let lines = wrap(f, &d.text, pw - 2.0 * INSET);
|
||
let text_h = lines.len() as f32 * line_h;
|
||
|
||
// Buttonzeilen: je Choice eine, mindestens die „Weiter"-Zeile.
|
||
let n = d.choices.len().max(1) as f32;
|
||
let buttons_h = n * bh + (n - 1.0) * gap;
|
||
|
||
let ph = 2.0 * INSET + text_h + gap + buttons_h;
|
||
let py = (internal[1] - margin - ph).max(margin).round();
|
||
let px = margin;
|
||
|
||
ui.panel([px, py, pw, ph], PANEL_BG, PANEL_FRAME);
|
||
|
||
let mut ty = py + INSET;
|
||
for line in &lines {
|
||
ui.text(f, px + INSET, ty, line, TEXT);
|
||
ty += line_h;
|
||
}
|
||
|
||
let (cbx, cbw) = (px + INSET, pw - 2.0 * INSET);
|
||
let mut cby = ty + gap;
|
||
if d.choices.is_empty() {
|
||
button(ui, f, [cbx, cby, cbw, bh], "Weiter", "", cur, hover);
|
||
} else {
|
||
for (i, opt) in d.choices.iter().enumerate() {
|
||
let label = format!("{}) {opt}", i + 1);
|
||
button(ui, f, [cbx, cby, cbw, bh], &label, &(i + 1).to_string(), cur, hover);
|
||
cby += bh + gap;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Text in Zeilen umbrechen, die in `max_w` Pixel passen. Vorhandene
|
||
/// `\n` sind harte Umbrüche; sonst wird wortweise gegriffen. Ein Wort breiter
|
||
/// als `max_w` bleibt allein in seiner Zeile (Überlauf statt Endlosschleife).
|
||
fn wrap(font: &Font, text: &str, max_w: f32) -> Vec<String> {
|
||
let mut lines = Vec::new();
|
||
for para in text.split('\n') {
|
||
let mut line = String::new();
|
||
for word in para.split_whitespace() {
|
||
let trial = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||
if line.is_empty() || font.text_width(&trial) <= max_w {
|
||
line = trial;
|
||
} else {
|
||
lines.push(std::mem::take(&mut line));
|
||
line = word.to_string();
|
||
}
|
||
}
|
||
lines.push(line);
|
||
}
|
||
lines
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn same_texture_quads_merge_into_one_batch() {
|
||
let mut ui = Ui::default();
|
||
ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]);
|
||
ui.fill([20.0, 0.0, 10.0, 10.0], [1.0; 4]);
|
||
assert_eq!(ui.verts.len(), 12); // 2 Quads · 6 Verts
|
||
assert_eq!(ui.batches.len(), 1); // gleiche Textur → ein Batch
|
||
assert_eq!(ui.batches[0].count, 12);
|
||
}
|
||
|
||
#[test]
|
||
fn different_textures_split_batches() {
|
||
let mut ui = Ui::default();
|
||
ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]); // WHITE
|
||
ui.quad(1, [0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 1.0, 1.0], [1.0; 4]); // Textur 1
|
||
ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]); // WHITE wieder
|
||
assert_eq!(ui.batches.len(), 3);
|
||
assert_eq!((ui.batches[0].start, ui.batches[1].start, ui.batches[2].start), (0, 6, 12));
|
||
}
|
||
|
||
#[test]
|
||
fn key_luminance_white_opaque_black_transparent() {
|
||
let img = Image { width: 2, height: 1, rgba: vec![255, 255, 255, 255, 0, 0, 0, 255] };
|
||
let k = key_luminance(&img);
|
||
assert_eq!(&k.rgba[0..4], &[255, 255, 255, 255]); // weiß → opak
|
||
assert_eq!(&k.rgba[4..8], &[255, 255, 255, 0]); // schwarz → transparent
|
||
}
|
||
|
||
#[test]
|
||
fn button_registers_hover_only_when_cursor_inside() {
|
||
// Leerer 256×64-Atlas → CGA-Font mit lauter leeren Glyphen; text_width
|
||
// bleibt endlich, Button funktioniert trotzdem.
|
||
let atlas = Image { width: 256, height: 64, rgba: vec![0; 256 * 64 * 4] };
|
||
let font = Font::cga(FONT_CGA, &atlas);
|
||
let rect = [10.0, 10.0, 100.0, 20.0];
|
||
|
||
let mut ui = Ui::default();
|
||
let mut hover = None;
|
||
button(&mut ui, &font, rect, "X", "do_x", [50.0, 15.0], &mut hover);
|
||
assert_eq!(hover.as_deref(), Some("do_x"));
|
||
|
||
let mut outside = None;
|
||
button(&mut ui, &font, rect, "X", "do_x", [200.0, 15.0], &mut outside);
|
||
assert!(outside.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_breaks_on_width_and_hard_newlines() {
|
||
// Leerer Atlas → jedes Zeichen rückt um space_adv (cga: 4px) vor.
|
||
let atlas = Image { width: 256, height: 64, rgba: vec![0; 256 * 64 * 4] };
|
||
let f = Font::cga(FONT_CGA, &atlas);
|
||
// 3-Zeichen-Wörter = 12px. Bei max 12 passt je ein Wort pro Zeile.
|
||
assert_eq!(wrap(&f, "aaa bbb ccc", 12.0), vec!["aaa", "bbb", "ccc"]);
|
||
// Bei max 28 passen zwei ("aaa bbb" = 7·4 = 28).
|
||
assert_eq!(wrap(&f, "aaa bbb ccc", 28.0), vec!["aaa bbb", "ccc"]);
|
||
// Harte Umbrüche bleiben erhalten.
|
||
assert_eq!(wrap(&f, "a\nb", 999.0), vec!["a", "b"]);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_uv_picks_quadrant() {
|
||
// idx 3 = (col 1, row 1) → rechte untere 16×16-Zelle.
|
||
assert_eq!(cursor_uv(3), [0.5, 0.5, 1.0, 1.0]);
|
||
assert_eq!(cursor_uv(0), [0.0, 0.0, 0.5, 0.5]);
|
||
}
|
||
|
||
#[test]
|
||
fn ornament_frame_tiles_edges_and_places_corners() {
|
||
let mut ui = Ui::default();
|
||
// 48×48-Panel: Kanten je 48-16=32 px → 32/16 = 2 Kacheln pro Seite.
|
||
// Erwartung: 4 Ecken + 4·2 Kanten = 12 Ornament-Quads (nach dem fill).
|
||
ui.panel([0.0, 0.0, 48.0, 48.0], [0.0; 4], [1.0; 4]);
|
||
let orn_quads = ui.verts.len() / 6 - 1; // minus das Hintergrund-fill
|
||
assert_eq!(orn_quads, 12);
|
||
}
|
||
}
|