feat: add docked UI shell navigation #11

Merged
moldybits merged 1 commits from feat/ui-shell-framework into main 2026-05-17 01:09:17 -04:00
4 changed files with 407 additions and 71 deletions
Showing only changes of commit dbcc8bcd7d - Show all commits
+1 -1
View File
@@ -34,7 +34,7 @@ Notes:
| Script language parser | MakePath guide and VistaPro manual describe scripts and “Run Script” workflows. | Partial | `src/script.rs` parser, tests in `src/script.rs`, `README.md` script section. | Parser exists, but script execution is intentionally deferred. |
| Script execution and animation frames | MakePath guide says scripts should render full animations and VistaPro can run scripts from the Script menu. | Planned | No script runner or frame-sequencing engine exists yet. | Add execution semantics once the command model is stable. |
| MakePath-style path generation and motion models | MakePath guide describes spline nodes, previewing a path, and vehicle models (jet, glider, dune buggy, motorcycle, helicopter, cruise missile, custom). | Planned | No path generator or motion-model layer exists yet. | This is a separate planner/animation feature, not just a script parser. |
| UI shell, menus, dialogs, and numeric gadgets | VistaPro screenshots/manuals show dense menus, dialogs, map tools, and numeric gadgets. | Partial | `src/app.rs`, `src/app_state.rs`, `src/bin/openvistapro_app.rs`. | Current UI is an egui CPU-preview shell with a small control set, not the legacy menu hierarchy. |
| UI shell, menus, dialogs, and numeric gadgets | VistaPro screenshots/manuals show dense menus, dialogs, map tools, and numeric gadgets. | Partial | `src/app.rs`, `src/app_state.rs`, `src/ui_shell.rs`, `src/bin/openvistapro_app.rs`. | OpenVistaPro now has a durable docked egui shell with stable navigation, sidebar, viewport, inspector, and status chrome; menus/dialogs/numeric gadgets still remain to be filled in. |
| Legacy image / landscape export formats | VistaPro manuals mention saving rendered images and landscapes in formats like IFF/IFF24/RGB and DEM/binary landscape files. | Planned | Current output is PNG plus project-owned `.ovp.toml` scenes. | Add separate compatibility/export work only after the clean internal pipeline is stable. |
## Current reconciliation summary
+181 -70
View File
@@ -3,12 +3,17 @@ use image::RgbImage;
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
use crate::scene::Vec3;
use crate::ui_shell::{ShellSection, UiShellState};
pub const WINDOW_TITLE: &str = "OpenVistaPro";
/// Top-level egui application. Owns the renderable scene state ([`AppData`])
/// and the navigation state ([`UiShellState`]); the `update` body is a thin
/// view that renders durable panels around those two state objects.
#[derive(Default)]
pub struct OpenVistaProApp {
data: AppData,
shell: UiShellState,
texture: Option<egui::TextureHandle>,
}
@@ -33,82 +38,164 @@ impl OpenVistaProApp {
}
}
}
}
impl eframe::App for OpenVistaProApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let mut changed = false;
egui::SidePanel::left("scene_controls").show(ctx, |ui| {
ui.heading("OpenVistaPro");
ui.label("CPU preview shell");
ui.separator();
ui.label("Terrain");
let mut preset = self.data.terrain_preset;
changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed();
changed |= ui
.radio_value(&mut preset, TerrainPreset::Plane, "Plane")
.changed();
if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset));
}
ui.separator();
ui.label("Renderer");
let mut renderer_mode = self.data.renderer_mode;
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
.changed();
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::Perspective, "Perspective")
.changed();
if renderer_mode != self.data.renderer_mode {
self.data.apply(AppAction::SetRendererMode(renderer_mode));
}
ui.separator();
ui.label("Scene bands");
let mut water = self.data.scene.water_level;
let mut trees = self.data.scene.tree_line;
let mut snow = self.data.scene.snow_line;
let mut haze = self.data.scene.haze;
changed |= ui
.add(egui::Slider::new(&mut water, -5.0..=10.0).text("Water"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut trees, -5.0..=12.0).text("Trees"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut snow, -5.0..=15.0).text("Snow"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut haze, 0.0..=1.0).text("Haze"))
.changed();
self.data.apply(AppAction::SetWaterLevel(water));
self.data.apply(AppAction::SetTreeLine(trees));
self.data.apply(AppAction::SetSnowLine(snow));
self.data.apply(AppAction::SetHaze(haze));
ui.separator();
ui.label("Camera");
let mut camera_position = self.data.scene.camera.position;
let mut camera_target = self.data.scene.camera.target;
changed |= vec3_controls(ui, "Position", &mut camera_position);
changed |= vec3_controls(ui, "Target", &mut camera_target);
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
/// Top command/navigation bar. Every section is always present so
/// navigation stays stable even where the surface is only a placeholder.
fn command_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("command_bar").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
ui.strong(WINDOW_TITLE);
ui.separator();
for &section in self.shell.sections() {
let active = self.shell.is_active(section);
if ui.selectable_label(active, section.title()).clicked() {
self.shell.activate(section);
}
}
});
});
}
if changed || self.texture.is_none() {
self.rebuild_texture(ctx);
/// Left panel: controls contextual to the active section. Sections without
/// an implemented surface still show their labelled placeholder.
///
/// Returns `true` when an edit changed something the preview depends on.
fn controls_panel(&mut self, ctx: &egui::Context) -> bool {
let mut changed = false;
egui::SidePanel::left("controls_panel")
.resizable(true)
.default_width(240.0)
.show(ctx, |ui| {
ui.heading(self.shell.section_title());
ui.label(self.shell.section_summary());
ui.separator();
match self.shell.active_section {
ShellSection::Terrain => changed |= self.terrain_controls(ui),
ShellSection::Scene => changed |= self.scene_controls(ui),
ShellSection::Render => changed |= self.render_controls(ui),
ShellSection::Import
| ShellSection::Script
| ShellSection::Path
| ShellSection::Project => {
ui.label(self.shell.placeholder_label());
}
}
});
changed
}
fn terrain_controls(&mut self, ui: &mut egui::Ui) -> bool {
let mut changed = false;
let mut preset = self.data.terrain_preset;
changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed();
changed |= ui
.radio_value(&mut preset, TerrainPreset::Plane, "Plane")
.changed();
if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset));
}
changed
}
fn render_controls(&mut self, ui: &mut egui::Ui) -> bool {
let mut changed = false;
let mut renderer_mode = self.data.renderer_mode;
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
.changed();
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::Perspective, "Perspective")
.changed();
if renderer_mode != self.data.renderer_mode {
self.data.apply(AppAction::SetRendererMode(renderer_mode));
}
changed
}
fn scene_controls(&mut self, ui: &mut egui::Ui) -> bool {
let mut changed = false;
ui.label("Scene bands");
let mut water = self.data.scene.water_level;
let mut trees = self.data.scene.tree_line;
let mut snow = self.data.scene.snow_line;
let mut haze = self.data.scene.haze;
changed |= ui
.add(egui::Slider::new(&mut water, -5.0..=10.0).text("Water"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut trees, -5.0..=12.0).text("Trees"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut snow, -5.0..=15.0).text("Snow"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut haze, 0.0..=1.0).text("Haze"))
.changed();
self.data.apply(AppAction::SetWaterLevel(water));
self.data.apply(AppAction::SetTreeLine(trees));
self.data.apply(AppAction::SetSnowLine(snow));
self.data.apply(AppAction::SetHaze(haze));
ui.separator();
ui.label("Camera");
let mut camera_position = self.data.scene.camera.position;
let mut camera_target = self.data.scene.camera.target;
changed |= vec3_controls(ui, "Position", &mut camera_position);
changed |= vec3_controls(ui, "Target", &mut camera_target);
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
changed
}
/// Right panel: durable inspector summarising current scene state.
fn inspector_panel(&self, ctx: &egui::Context) {
egui::SidePanel::right("inspector_panel")
.resizable(true)
.default_width(220.0)
.show(ctx, |ui| {
ui.heading("Inspector");
ui.separator();
let info = self.shell.active_info();
ui.label(format!("Section: {}", info.title));
ui.label(info.summary);
ui.separator();
ui.label(format!("Terrain: {:?}", self.data.terrain_preset));
ui.label(format!("Renderer: {:?}", self.data.renderer_mode));
let (width, height) = self.data.preview_size;
ui.label(format!("Preview: {width} x {height}"));
ui.label(format!("Water level: {:.2}", self.data.scene.water_level));
});
}
/// Bottom panel: durable status bar.
fn status_panel(&self, ctx: &egui::Context) {
egui::TopBottomPanel::bottom("status_panel").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Ready");
ui.separator();
ui.label(format!("Active: {}", self.shell.section_title()));
ui.separator();
let scene_label = match &self.data.loaded_scene_path {
Some(path) => format!("Scene: {path}"),
None => "Scene: unsaved".to_owned(),
};
ui.label(scene_label);
});
});
}
/// Central viewport: shows the CPU preview, with a placeholder banner for
/// sections whose dedicated surface is not built yet.
fn viewport_panel(&self, ctx: &egui::Context) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Preview");
ui.heading(format!("{} viewport", self.shell.section_title()));
if section_is_placeholder(self.shell.active_section) {
ui.label(self.shell.placeholder_label());
ui.separator();
}
if let Some(texture) = &self.texture {
ui.image((texture.id(), texture.size_vec2()));
} else {
@@ -118,6 +205,30 @@ impl eframe::App for OpenVistaProApp {
}
}
impl eframe::App for OpenVistaProApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.command_bar(ctx);
let changed = self.controls_panel(ctx);
self.inspector_panel(ctx);
self.status_panel(ctx);
if changed || self.texture.is_none() {
self.rebuild_texture(ctx);
}
self.viewport_panel(ctx);
}
}
/// Sections whose dedicated workspace is not built yet — they render a
/// placeholder banner over the shared preview rather than being hidden.
fn section_is_placeholder(section: ShellSection) -> bool {
matches!(
section,
ShellSection::Import | ShellSection::Script | ShellSection::Path | ShellSection::Project
)
}
fn vec3_controls(ui: &mut egui::Ui, label: &str, value: &mut Vec3) -> bool {
let mut changed = false;
ui.label(label);
+1
View File
@@ -11,3 +11,4 @@ pub mod scene_file;
pub mod script;
pub mod script_exec;
pub mod terrain;
pub mod ui_shell;
+224
View File
@@ -0,0 +1,224 @@
//! Pure state model for the OpenVistaPro UI shell.
//!
//! This module owns the navigation model — which feature family is currently
//! active — plus the static metadata describing every section. It deliberately
//! contains no egui types so the shell can be exercised by unit tests without a
//! windowing backend, and so the rendering code in `app.rs` stays a thin view
//! over this state.
/// One feature family surfaced by the shell's command/navigation bar.
///
/// The ordering of these variants is the canonical tab order used throughout
/// the UI; see [`UiShellState::sections`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShellSection {
#[default]
Terrain,
Scene,
Render,
Import,
Script,
Path,
Project,
}
/// Stable, ordered list of every section the shell exposes.
///
/// Navigation is intentionally static: unimplemented surfaces stay visible as
/// placeholders rather than being hidden, so this list never changes at
/// runtime.
const SECTIONS: [ShellSection; 7] = [
ShellSection::Terrain,
ShellSection::Scene,
ShellSection::Render,
ShellSection::Import,
ShellSection::Script,
ShellSection::Path,
ShellSection::Project,
];
/// Static description of a single section, used to render its nav entry and
/// its placeholder surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SectionInfo {
/// The section this metadata describes.
pub section: ShellSection,
/// Short human-readable label shown in the nav bar and panel headings.
pub title: &'static str,
/// One-line description of what the section is for.
pub summary: &'static str,
/// Label shown on the central viewport when the section has no
/// implemented surface yet.
pub placeholder: &'static str,
}
impl ShellSection {
/// Returns the static metadata for this section.
pub fn info(self) -> SectionInfo {
match self {
ShellSection::Terrain => SectionInfo {
section: self,
title: "Terrain",
summary: "Generate and sculpt terrain height fields.",
placeholder: "Terrain workspace coming soon",
},
ShellSection::Scene => SectionInfo {
section: self,
title: "Scene",
summary: "Tune scene bands, lighting, and camera framing.",
placeholder: "Scene workspace coming soon",
},
ShellSection::Render => SectionInfo {
section: self,
title: "Render",
summary: "Configure renderer mode and preview output.",
placeholder: "Render workspace coming soon",
},
ShellSection::Import => SectionInfo {
section: self,
title: "Import",
summary: "Bring in external height data and imagery.",
placeholder: "Import workspace coming soon",
},
ShellSection::Script => SectionInfo {
section: self,
title: "Script",
summary: "Automate scene setup with scripted commands.",
placeholder: "Script workspace coming soon",
},
ShellSection::Path => SectionInfo {
section: self,
title: "Path",
summary: "Lay out camera paths and animation keyframes.",
placeholder: "Path workspace coming soon",
},
ShellSection::Project => SectionInfo {
section: self,
title: "Project",
summary: "Manage project files, scenes, and settings.",
placeholder: "Project workspace coming soon",
},
}
}
/// Short label for the nav bar and panel headings.
pub fn title(self) -> &'static str {
self.info().title
}
/// One-line description of the section.
pub fn summary(self) -> &'static str {
self.info().summary
}
/// Placeholder label for the section's not-yet-built surface.
pub fn placeholder(self) -> &'static str {
self.info().placeholder
}
}
/// Navigation state for the shell: the single source of truth for which
/// section the UI is currently showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct UiShellState {
pub active_section: ShellSection,
}
impl UiShellState {
/// Creates a shell at its default section ([`ShellSection::Terrain`]).
pub fn new() -> Self {
Self::default()
}
/// Returns the canonical, ordered list of all sections.
pub fn sections(&self) -> &'static [ShellSection] {
&SECTIONS
}
/// Switches the active section. Navigation never fails — every section is
/// always reachable, even if its surface is only a placeholder.
pub fn activate(&mut self, section: ShellSection) {
self.active_section = section;
}
/// Returns `true` if `section` is the one currently shown.
pub fn is_active(&self, section: ShellSection) -> bool {
self.active_section == section
}
/// Metadata for the currently active section.
pub fn active_info(&self) -> SectionInfo {
self.active_section.info()
}
/// Title of the active section.
pub fn section_title(&self) -> &'static str {
self.active_section.title()
}
/// One-line summary of the active section.
pub fn section_summary(&self) -> &'static str {
self.active_section.summary()
}
/// Placeholder label for the active section's surface.
pub fn placeholder_label(&self) -> &'static str {
self.active_section.placeholder()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ui_shell_sections_cover_the_major_feature_families() {
let shell = UiShellState::new();
assert_eq!(
shell.sections(),
&[
ShellSection::Terrain,
ShellSection::Scene,
ShellSection::Render,
ShellSection::Import,
ShellSection::Script,
ShellSection::Path,
ShellSection::Project,
]
);
}
#[test]
fn ui_shell_active_section_has_a_named_placeholder() {
let shell = UiShellState::new();
assert_eq!(shell.section_title(), "Terrain");
assert!(shell.section_summary().contains("terrain"));
assert!(shell.placeholder_label().contains("coming soon"));
}
#[test]
fn ui_shell_starts_on_terrain_and_navigates_between_sections() {
let mut shell = UiShellState::new();
assert!(shell.is_active(ShellSection::Terrain));
shell.activate(ShellSection::Render);
assert!(shell.is_active(ShellSection::Render));
assert!(!shell.is_active(ShellSection::Terrain));
assert_eq!(shell.section_title(), "Render");
}
#[test]
fn every_section_has_non_empty_metadata() {
let shell = UiShellState::new();
for &section in shell.sections() {
let info = section.info();
assert_eq!(info.section, section);
assert!(!info.title.is_empty());
assert!(!info.summary.is_empty());
assert!(info.placeholder.contains("coming soon"));
}
}
}