From dbcc8bcd7db845a339a996f87f8c95c551b3b7a2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 16:29:57 -0400 Subject: [PATCH] feat: add docked UI shell navigation --- docs/knowledgebase/feature-inventory.md | 2 +- src/app.rs | 251 +++++++++++++++++------- src/lib.rs | 1 + src/ui_shell.rs | 224 +++++++++++++++++++++ 4 files changed, 407 insertions(+), 71 deletions(-) create mode 100644 src/ui_shell.rs diff --git a/docs/knowledgebase/feature-inventory.md b/docs/knowledgebase/feature-inventory.md index 274a17d..6fc8aff 100644 --- a/docs/knowledgebase/feature-inventory.md +++ b/docs/knowledgebase/feature-inventory.md @@ -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 diff --git a/src/app.rs b/src/app.rs index 8b9d7cf..6d8d475 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, } @@ -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 §ion 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); diff --git a/src/lib.rs b/src/lib.rs index f33e3ec..64779aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,4 @@ pub mod scene_file; pub mod script; pub mod script_exec; pub mod terrain; +pub mod ui_shell; diff --git a/src/ui_shell.rs b/src/ui_shell.rs new file mode 100644 index 0000000..22420a4 --- /dev/null +++ b/src/ui_shell.rs @@ -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 §ion 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")); + } + } +} -- 2.39.5