9eead719b0
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>
496 lines
19 KiB
Rust
496 lines
19 KiB
Rust
//! 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<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())
|
||
}
|