feat: add docked UI shell navigation #11
@@ -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 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## Current reconciliation summary
|
||||||
|
|||||||
+181
-70
@@ -3,12 +3,17 @@ use image::RgbImage;
|
|||||||
|
|
||||||
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
|
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
|
||||||
use crate::scene::Vec3;
|
use crate::scene::Vec3;
|
||||||
|
use crate::ui_shell::{ShellSection, UiShellState};
|
||||||
|
|
||||||
pub const WINDOW_TITLE: &str = "OpenVistaPro";
|
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)]
|
#[derive(Default)]
|
||||||
pub struct OpenVistaProApp {
|
pub struct OpenVistaProApp {
|
||||||
data: AppData,
|
data: AppData,
|
||||||
|
shell: UiShellState,
|
||||||
texture: Option<egui::TextureHandle>,
|
texture: Option<egui::TextureHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,82 +38,164 @@ impl OpenVistaProApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for OpenVistaProApp {
|
/// Top command/navigation bar. Every section is always present so
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
/// navigation stays stable even where the surface is only a placeholder.
|
||||||
let mut changed = false;
|
fn command_bar(&mut self, ctx: &egui::Context) {
|
||||||
|
egui::TopBottomPanel::top("command_bar").show(ctx, |ui| {
|
||||||
egui::SidePanel::left("scene_controls").show(ctx, |ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.heading("OpenVistaPro");
|
ui.strong(WINDOW_TITLE);
|
||||||
ui.label("CPU preview shell");
|
ui.separator();
|
||||||
|
for §ion in self.shell.sections() {
|
||||||
ui.separator();
|
let active = self.shell.is_active(section);
|
||||||
ui.label("Terrain");
|
if ui.selectable_label(active, section.title()).clicked() {
|
||||||
let mut preset = self.data.terrain_preset;
|
self.shell.activate(section);
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if changed || self.texture.is_none() {
|
/// Left panel: controls contextual to the active section. Sections without
|
||||||
self.rebuild_texture(ctx);
|
/// 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| {
|
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 {
|
if let Some(texture) = &self.texture {
|
||||||
ui.image((texture.id(), texture.size_vec2()));
|
ui.image((texture.id(), texture.size_vec2()));
|
||||||
} else {
|
} 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 {
|
fn vec3_controls(ui: &mut egui::Ui, label: &str, value: &mut Vec3) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
ui.label(label);
|
ui.label(label);
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ pub mod scene_file;
|
|||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod script_exec;
|
pub mod script_exec;
|
||||||
pub mod terrain;
|
pub mod terrain;
|
||||||
|
pub mod ui_shell;
|
||||||
|
|||||||
+224
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user