Files
Oxide/examples/src/bin/ui_menu.rs
T
Homer Simpson f56a1eea3b Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

496 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 12** — 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<GpuState>,
}
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())
}