//! Stage 8 example: main menu + settings panel built with the engine's UI //! system. //! //! Run with: //! cargo run -p oxide-examples --bin ui_menu //! //! Walks the full Stage-8 pipeline: //! //! - **Pieces 1–2** — widget tree, layout, themed visual style. //! - **Piece 3** — text shaping + glyph atlas (loads a system font). //! - **Piece 4a** — screen-space `UiOverlayPass` renders quads + glyphs. //! - **Piece 5** — `Router` hit-tests cursor + tracks hover/press. //! - **Piece 6** — immediate-mode `frame.clicked_left(...)` queries + typed //! `WidgetValue`s for the volume slider and invert-Y checkbox. //! //! Main menu has Play (logs), Settings (navigates), Quit (exits). The //! settings panel has a draggable volume slider, a clickable invert-Y //! checkbox, and a Back button. #![deny(warnings)] use oxide_engine::prelude::*; use oxide_engine::ui::text::{common_system_font_paths, Font}; use oxide_engine::window::event::{ElementState, Key, NamedKey, WindowEvent}; use oxide_engine::window::RenderCtx; use oxide_engine::winit::event::MouseButton; #[derive(Clone, Copy, PartialEq)] enum Screen { Main, Settings, } struct UiMenu { screen: Screen, volume: f32, invert_y: bool, /// True while the user is dragging the volume slider — set on /// `Pressed("volume", Left)`, cleared on release. While true the /// slider tracks cursor.x clamped to the track even if the cursor /// drifts outside the rect (standard "drag capture" behavior). dragging_volume: bool, router: UiRouter, theme: UiTheme, main_font_descriptor: UiFontRef, /// Built on the first frame once the surface format is known. gpu: Option, } struct GpuState { ui_pass: UiOverlayPass, } impl Default for UiMenu { fn default() -> Self { Self { screen: Screen::Main, volume: 0.6, invert_y: false, dragging_volume: false, router: UiRouter::new(), theme: build_theme(), main_font_descriptor: UiFontRef::regular("System"), gpu: None, } } } fn build_theme() -> UiTheme { let descriptor = UiFontRef::regular("System"); UiTheme::new() .with_default(UiVisualStyle { foreground: Some(Color::WHITE), font: Some(descriptor.clone()), font_size: Some(16.0), ..UiVisualStyle::EMPTY }) .with_style( "panel", UiVisualStyle { background: Some(Color::rgb(0.15, 0.16, 0.18)), ..UiVisualStyle::EMPTY }, ) .with_style( "button", UiVisualStyle { background: Some(Color::rgb(0.25, 0.27, 0.30)), foreground: Some(Color::WHITE), font: Some(descriptor.clone()), font_size: Some(16.0), ..UiVisualStyle::EMPTY }, ) .with_style( "button-hover", UiVisualStyle { background: Some(Color::rgb(0.35, 0.37, 0.40)), ..UiVisualStyle::EMPTY }, ) .with_style( "button-primary", UiVisualStyle { background: Some(Color::rgb(0.20, 0.45, 0.80)), foreground: Some(Color::WHITE), ..UiVisualStyle::EMPTY }, ) .with_style( "track", UiVisualStyle { background: Some(Color::rgb(0.10, 0.11, 0.13)), ..UiVisualStyle::EMPTY }, ) .with_style( "fill", UiVisualStyle { background: Some(Color::rgb(0.30, 0.55, 0.90)), ..UiVisualStyle::EMPTY }, ) .with_style( "checkbox-on", UiVisualStyle { background: Some(Color::rgb(0.30, 0.55, 0.90)), ..UiVisualStyle::EMPTY }, ) .with_style( "checkbox-off", UiVisualStyle { background: Some(Color::rgb(0.20, 0.22, 0.25)), ..UiVisualStyle::EMPTY }, ) } fn button(id: &str, text: &str, hovered: bool) -> Widget { Widget::leaf(Vec2::new(200.0, 48.0)) .with_id(id) .with_text(text) .with_theme_style(if hovered { "button-hover" } else { "button" }) .with_style(UiLayoutStyle { width: UiSizing::Fixed(200.0), height: UiSizing::Fixed(48.0), padding: UiInsets::all(12.0), align_horizontal: UiAlign::Center, align_vertical: UiAlign::Center, ..Default::default() }) } fn primary_button(id: &str, text: &str, hovered: bool) -> Widget { let mut w = button(id, text, hovered); if !hovered { w = w.with_theme_style("button-primary"); } w } fn build_main_menu(hovered: Option<&str>) -> Widget { let is = |id: &str| hovered == Some(id); Widget::anchor() .with_style(UiLayoutStyle { width: UiSizing::Grow(1.0), height: UiSizing::Grow(1.0), ..Default::default() }) .with_child( Widget::column() .with_style(UiLayoutStyle { width: UiSizing::Fixed(240.0), height: UiSizing::Fixed(280.0), padding: UiInsets::all(20.0), anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5)) .with_offsets(Vec2::new(-120.0, -140.0), Vec2::new(120.0, 140.0)), ..Default::default() }) .with_theme_style("panel") .with_gap(12.0) .with_child( Widget::leaf(Vec2::new(200.0, 32.0)) .with_text("Oxide") .with_style(UiLayoutStyle { width: UiSizing::Fixed(200.0), height: UiSizing::Fixed(32.0), align_horizontal: UiAlign::Center, align_vertical: UiAlign::Center, ..Default::default() }) .with_visual(UiVisualStyle { font_size: Some(22.0), ..UiVisualStyle::EMPTY }), ) .with_child(primary_button("play", "Play", is("play"))) .with_child(button("settings", "Settings", is("settings"))) .with_child(button("quit", "Quit", is("quit"))), ) } fn build_settings(volume: f32, invert_y: bool, hovered: Option<&str>) -> Widget { let is = |id: &str| hovered == Some(id); // Fill spans 0..volume of the parent's width via a percentage anchor — // scales with whatever the track width ends up being instead of // hardcoding pixels. let fill_fraction = volume.clamp(0.0, 1.0); Widget::anchor() .with_style(UiLayoutStyle { width: UiSizing::Grow(1.0), height: UiSizing::Grow(1.0), ..Default::default() }) .with_child( Widget::column() .with_style(UiLayoutStyle { width: UiSizing::Fixed(320.0), height: UiSizing::Fixed(280.0), padding: UiInsets::all(20.0), anchor: UiAnchor::between(Vec2::splat(0.5), Vec2::splat(0.5)) .with_offsets(Vec2::new(-160.0, -140.0), Vec2::new(160.0, 140.0)), ..Default::default() }) .with_theme_style("panel") .with_gap(16.0) .with_child( Widget::leaf(Vec2::new(200.0, 32.0)) .with_text("Settings") .with_style(UiLayoutStyle { width: UiSizing::Fixed(280.0), height: UiSizing::Fixed(32.0), align_horizontal: UiAlign::Center, align_vertical: UiAlign::Center, ..Default::default() }) .with_visual(UiVisualStyle { font_size: Some(20.0), ..UiVisualStyle::EMPTY }), ) // Volume row: label + slider track + fill. .with_child( Widget::column() .with_style(UiLayoutStyle { width: UiSizing::Fixed(280.0), height: UiSizing::Fixed(48.0), ..Default::default() }) .with_gap(4.0) .with_child( Widget::leaf(Vec2::new(200.0, 18.0)) .with_text("Volume") .with_style(UiLayoutStyle { width: UiSizing::Fixed(280.0), height: UiSizing::Fixed(18.0), align_vertical: UiAlign::Center, ..Default::default() }), ) .with_child( Widget::anchor() .with_style(UiLayoutStyle { width: UiSizing::Fixed(280.0), height: UiSizing::Fixed(20.0), ..Default::default() }) .with_child( Widget::leaf(Vec2::ZERO) .with_id("volume") .with_value(volume) .with_theme_style("track") .with_style(UiLayoutStyle { anchor: UiAnchor::FILL, ..Default::default() }), ) .with_child( Widget::leaf(Vec2::ZERO) .with_theme_style("fill") .with_style(UiLayoutStyle { anchor: UiAnchor::between( Vec2::ZERO, Vec2::new(fill_fraction, 1.0), ), ..Default::default() }), ), ), ) // Invert-Y row: checkbox box (clickable) + label. .with_child( Widget::row() .with_style(UiLayoutStyle { width: UiSizing::Fixed(280.0), height: UiSizing::Fixed(28.0), ..Default::default() }) .with_gap(12.0) .with_child( Widget::leaf(Vec2::new(24.0, 24.0)) .with_id("invert_y") .with_value(invert_y) .with_theme_style(if invert_y { "checkbox-on" } else { "checkbox-off" }) .with_style(UiLayoutStyle { width: UiSizing::Fixed(24.0), height: UiSizing::Fixed(24.0), align_vertical: UiAlign::Center, ..Default::default() }), ) .with_child( Widget::leaf(Vec2::new(200.0, 24.0)) .with_text("Invert Y axis") .with_style(UiLayoutStyle { width: UiSizing::Grow(1.0), height: UiSizing::Fixed(24.0), align_vertical: UiAlign::Center, ..Default::default() }), ), ) .with_child(button("back", "Back", is("back"))), ) } impl UiMenu { fn current_document(&self) -> Widget { let hovered = self.router.hovered().map(|id| id.as_str()); match self.screen { Screen::Main => build_main_menu(hovered), Screen::Settings => build_settings(self.volume, self.invert_y, hovered), } } } impl WindowApp for UiMenu { fn init(&mut self, ctx: &mut AppCtx<'_>) { ctx.set_clear_color(Color::rgb(0.08, 0.09, 0.10)); log::info!("ui_menu: main menu → click Play/Settings/Quit (Esc quits)"); } fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) { if let WindowEvent::KeyboardInput { event: key, .. } = event { if key.state == ElementState::Pressed && key.logical_key == Key::Named(NamedKey::Escape) { ctx.request_exit(); } } } fn update(&mut self, ctx: &mut AppCtx<'_>) { let (w, h) = ctx.size(); let viewport = oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32)); let document = self.current_document(); let tree = ui_layout(&document, viewport, 1.0); let frame = self.router.process(&tree, ctx.input()); match self.screen { Screen::Main => { if frame.clicked_left("play") { log::info!("Play clicked (would start a game)"); } if frame.clicked_left("settings") { log::info!("→ settings"); self.screen = Screen::Settings; } if frame.clicked_left("quit") { ctx.request_exit(); } } Screen::Settings => { if frame.clicked_left("back") { log::info!("← back to main"); self.screen = Screen::Main; } if frame.clicked_left("invert_y") { self.invert_y = !self.invert_y; log::info!("invert_y = {}", self.invert_y); } // Volume slider: drag-capture. // - Press on the track → start tracking. // - While tracking AND mouse held: update volume from // cursor.x clamped to the track rect, regardless of // whether the cursor is still inside it (so dragging // past either edge pins to 0.0 or 1.0). // - On release: stop tracking. if frame.pressed("volume", MouseButton::Left) { self.dragging_volume = true; } if !ctx.input().mouse_held(MouseButton::Left) { self.dragging_volume = false; } if self.dragging_volume { if let Some(cursor) = ctx.input().cursor() { if let Some(node) = tree.find(&"volume".into()) { let new_v = ((cursor.x - node.rect.min.x) / node.rect.width().max(1.0)) .clamp(0.0, 1.0); if (new_v - self.volume).abs() > 0.001 { self.volume = new_v; log::debug!("volume = {:.2}", self.volume); } } } } } } } fn render(&mut self, ctx: &RenderCtx<'_>) { let device = ctx.gpu.device(); let queue = ctx.gpu.queue(); // Lazy-init GPU resources once the surface format is known. if self.gpu.is_none() { let mut ui_pass = UiOverlayPass::new(device, ctx.surface_format); // Load a system font and register it under the same descriptor // the theme uses. let font = common_system_font_paths() .iter() .find_map(|p| Font::from_path(p).ok()); match font { Some(f) => { ui_pass .fonts_mut() .insert_with_descriptor(self.main_font_descriptor.clone(), f); } None => { log::error!( "No system sans-serif font found in any of {:?}. Install \ 'liberation-fonts' or 'dejavu-sans' and re-run.", common_system_font_paths() ); } } self.gpu = Some(GpuState { ui_pass }); } // Build the document + tree + painted frame before taking the // mutable borrow on the pass, so self.theme + self.current_document // (which need &self) and gpu.ui_pass (which needs &mut self.gpu) // don't clash. let (w, h) = ctx.size; let viewport = oxide_engine::math::Rect::from_min_size(Vec2::ZERO, Vec2::new(w as f32, h as f32)); let document = self.current_document(); let tree = ui_layout(&document, viewport, 1.0); let painted = ui_paint( &document, &tree, &self.theme, self.gpu.as_ref().unwrap().ui_pass.fonts(), 1.0, ); let gpu = self.gpu.as_mut().unwrap(); gpu.ui_pass .set_batches(vec![UiBatch::screen_space(painted, (w, h))]); let camera = Camera::default(); let view_transform = oxide_engine::math::Transform::default(); let lighting = Lighting::default(); let mut frame = FrameContext { device, queue, color: ctx.view, size: ctx.size, viewport_rect: None, clear_color: Color::rgb(0.08, 0.09, 0.10), camera: &camera, view_transform: &view_transform, lighting: &lighting, objects: &[], }; // The window runner already cleared the surface to the configured // clear color, so we just run the UI overlay on top. gpu.ui_pass.run(&mut frame); } } fn main() -> anyhow::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let config = WindowConfig { title: "Oxide — ui_menu".to_string(), width: 960, height: 600, ..Default::default() }; run(config, UiMenu::default()) }