From a41d39cc788a71c88ea445edfcce2c20aec58f98 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 13 Jun 2026 19:10:12 +0200 Subject: [PATCH] Menu-Panel --- src/render/font.rs | 2 + src/render/mod.rs | 35 +++++++++++---- src/render/ui.rs | 109 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 122 insertions(+), 24 deletions(-) diff --git a/src/render/font.rs b/src/render/font.rs index 95bc5e5..94392ed 100644 --- a/src/render/font.rs +++ b/src/render/font.rs @@ -40,6 +40,8 @@ pub(crate) struct Font { /// Die im Projekt geladenen Fonts, gebaut in render::run und an `ui::layout` /// gereicht. pub(crate) struct Fonts { + /// Größerer Font, für spätere Überschriften/HUD bereitgehalten. + #[allow(dead_code)] pub(crate) ega: Font, pub(crate) cga: Font, } diff --git a/src/render/mod.rs b/src/render/mod.rs index 28107b5..4c338b8 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -150,6 +150,8 @@ struct App { last: Option, /// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor). cursor_win: [f32; 2], + /// Linksklick steht aus, wird im nächsten redraw gegen das UI aufgelöst. + pending_click: bool, /// CPU-seitige Welt-Geometrie + Texturen, in `resumed` einmal auf die /// GPU geladen. mesh: Mesh, @@ -177,6 +179,7 @@ impl App { input: Input::default(), last: None, cursor_win: [0.0, 0.0], + pending_click: false, mesh, images, ui_textures, @@ -255,9 +258,10 @@ impl ApplicationHandler for App { self.cursor_win = [position.x as f32, position.y as f32]; } WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => { - // Nur im freien Modus greift der Klick die Maus (Flycam). Im - // UI-/Dialog-Modus bleibt die Maus frei (für Klicks aufs UI). - if matches!(self.session.mode, Mode::Free) { self.set_grab(true); } + // Klick nur vormerken; der nächste redraw entscheidet (mit der + // frisch berechneten Hover-Aktion) zwischen UI-Klick und + // Flycam-Greifen. + self.pending_click = true; } WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key), WindowEvent::Resized(size) => { @@ -265,7 +269,7 @@ impl ApplicationHandler for App { gpu.resize(size.width, size.height); } } - WindowEvent::RedrawRequested => self.redraw(), + WindowEvent::RedrawRequested => self.redraw(event_loop), _ => {} } } @@ -293,7 +297,7 @@ impl App { } } - fn redraw(&mut self) { + fn redraw(&mut self, event_loop: &ActiveEventLoop) { // dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert, // während die Konsole den Dialog treibt. let now = Instant::now(); @@ -323,11 +327,26 @@ impl App { .unwrap_or([0.0, 0.0]), grabbed: self.input.grabbed, }; - // Overlay state-driven bauen (Stage 3: Demo-Panel + Cursor). - let overlay = ui::layout( + // Overlay state-driven bauen; Hover-Aktion kommt aus der Maus-Position. + let screen = ui::layout( [gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &cursor, ); - if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &overlay); } + + // Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion + // durch denselben exec-Trichter wie die Konsole; sonst im Free-Modus + // die Maus für die Flycam greifen. + if self.pending_click { + self.pending_click = false; + if let Some(action) = screen.hover_action.clone() { + let r = self.session.exec(&action); + for out in r.output { println!("{out}"); } + if r.quit { event_loop.exit(); } + } else if matches!(self.session.mode, Mode::Free) && !self.input.grabbed { + self.set_grab(true); + } + } + + if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &screen.ui); } } } diff --git a/src/render/ui.rs b/src/render/ui.rs index b4deffa..c4cc8ff 100644 --- a/src/render/ui.rs +++ b/src/render/ui.rs @@ -26,11 +26,11 @@ 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_HUD: usize = 2; // First-Person-Pointer, normal -// Die „interagierbar"-Varianten (1, 3) kommen mit dem Hit-Testing in Stage 4. -#[allow(dead_code)] pub(crate) const CUR_MOUSE_INTERACT: usize = 1; -#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3; +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 +// Die interagierbare HUD-Variante (3) kommt mit dem Welt-Raycast. +#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3; const CURSOR_PX: f32 = 16.0; const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel) @@ -180,27 +180,86 @@ fn cursor_uv(idx: usize) -> [f32; 4] { [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, +} + +/// 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, +) { + 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]); +} + /// Das Overlay für den aktuellen Frame bauen. // -// Stage 3: Demo-Panel mit Ornamentrahmen + Text, plus der Cursor. Ab Stage 4 -// verzweigt das hier nach `session.mode` (Dialog-Panel, Menü, HUD). -pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, cur: &Cursor) -> Ui { +// Stage 4: klickbare Demo-Buttons (nur wenn die Maus frei ist). Als Nächstes +// verzweigt das hier nach `session.mode` (Dialog-Panel, Menü) und liefert die +// jeweiligen Aktionen. +pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, cur: &Cursor) -> Screen { let mut ui = Ui::default(); + let mut hover: Option = None; + let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font. - // Demo-Panel (opak) mit Ornamentrahmen + beide Fonts zum Vergleich. - ui.panel([8.0, 8.0, 216.0, 58.0], [0.06, 0.05, 0.10, 1.0], [0.85, 0.75, 0.95, 1.0]); - ui.text(&fonts.ega, 16.0, 16.0, "EGA 8x14 — Schöne Grüße!", [0.9, 0.9, 1.0, 1.0]); - ui.text(&fonts.cga, 16.0, 34.0, "CGA 8x8 — abcABC 0123 äöüß", [0.6, 1.0, 0.7, 1.0]); + // Im Flycam-Modus (Maus gefangen) ist die freie Sicht wichtiger → kein + // Panel; sonst die klickbare Demo. Hit-Tests laufen nur bei freier Maus. + if !cur.grabbed { + let demo = [ + ("Hilfe", "help"), + ("KV anzeigen", "kv"), + ("Flag setzen", "signal set demo_flag true"), + ("Beenden", "quit"), + ]; + // Innenmaße aus Inhalt: der Inhalt sitzt um `inset` (Rahmendicke + + // etwas Padding) vom Panelrand weg. Panel daraus zentriert auf der + // internen Auflösung. + let (bw, bh, gap) = (112.0, 14.0, 4.0); + let inset = ORN_CORNER + 3.0; // 8px Ornamentrahmen + 3px Luft + let title_h = f.glyph_h; + let body_h = title_h + gap + demo.len() as f32 * bh + (demo.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(); - // Cursor zuletzt → liegt über allem. Gefangen (Flycam) → HUD-Pointer - // mittig; sonst Maus-Cursor an der Mausposition. + ui.panel([px, py, pw, ph], [0.06, 0.05, 0.10, 1.0], [0.85, 0.75, 0.95, 1.0]); + ui.text(f, px + inset, py + inset, "Klick-Demo", [0.85, 0.8, 0.6, 1.0]); + + let bx = px + inset; + let mut by = py + inset + title_h + gap; + for (label, action) in demo { + button(&mut ui, f, [bx, by, bw, bh], label, action, cur.pos, &mut hover); + by += bh + gap; + } + } + + // Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst + // Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht. if cur.grabbed { ui.cursor(CUR_HUD, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]); } else { - ui.cursor(CUR_MOUSE, cur.pos, [1.0; 4]); + let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE }; + ui.cursor(idx, cur.pos, [1.0; 4]); } - ui + Screen { ui, hover_action: hover } } #[cfg(test)] @@ -235,6 +294,24 @@ mod tests { 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 cursor_uv_picks_quadrant() { // idx 3 = (col 1, row 1) → rechte untere 16×16-Zelle.