//! Shared presentation and input contract; contains no model commands. use crate::app::{App, Message}; use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip}; use iced::{Element, Length::Fill, Point, Theme, Vector}; use oa_core::Id; mod control; mod focus; mod viewport; pub use control::Control; pub use viewport::Viewport; pub fn keyboard_enabled(app: &crate::app::App) -> bool { !app.busy && keyboard_owner_enabled(app) } /// Passive I/O suspends commands without changing the logical input owner. pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool { !app.hosted.visible && app.workflow.is_none() && app.file_prompt.is_none() && !app.delete_pending && app.pending.is_none() && app.model_menu.is_none() && app.diagram_usages.is_none() && !app.reference_input_active() } pub fn controls<'a>( scope: Scope, keyboard: bool, scale: f32, details: Message, zoom: impl Fn(Option) -> Message, publish: impl Fn(Event) -> Message + Copy + 'a, ) -> Element<'a, Message> { let control = |name: &'static str, label: &'static str, message: Option| { let message = message.map(|message| intent(scope, name, message)); Control::new( button(text(label).size(12)) .padding(6) .on_press_maybe(message.clone()), scope, name, keyboard, message, publish, ) }; let controls = row![ control("Details", "Details", Some(details)), control("Fit", "Fit", Some(zoom(None))), control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))), text(zoom_label(scale)).size(12), control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))), control("ActualSize", "100%", Some(zoom(Some(1.)))), ] .spacing(4) .align_y(iced::Center); tooltip( controls, container(text("F6: enter/leave canvas keyboard controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card), tooltip::Position::Bottom, ).into() } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Scope { pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>, pub document: Id, pub owner: Id, pub revision: u64, pub epoch: u64, } impl Scope { pub(crate) fn current(app: &App) -> Option { if let Some(context) = app.scenario_context() { Some(Self { document: context.document, owner: context.scenario, scenario_view: Some((context.representation, context.visit)), revision: context.revision, epoch: app.camera_epoch, }) } else { let context = app.topology_context()?; Some(Self { document: context.document, owner: context.owner, scenario_view: None, revision: context.revision, epoch: app.camera_epoch, }) } } pub(crate) fn input_owner(app: &App) -> Option { if keyboard_enabled(app) && !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty() { Self::current(app) } else { None } } pub fn control_id(self, name: &str) -> iced::advanced::widget::Id { iced::advanced::widget::Id::from(format!( "behavior-control:{}:{}:{}:{:?}:{name}", self.document, self.owner, self.revision, self.scenario_view )) } } /// Only a live presentation intent may carry a toolbar action across its own pan. #[derive(Debug, Clone)] pub struct Intent { pub scope: Scope, pub name: &'static str, pub message: Box, } pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message { Message::BehaviorControl(Intent { scope, name, message: Box::new(message), }) } #[derive(Debug, Clone, Copy)] pub enum Event { CancelGesture, Pan(Vector), Zoom { factor: f32, at: Point, base: f32 }, Traverse(bool), ControlFocus(&'static str), ToggleFocus, Leave, } pub use focus::{focus_task, root as focus_root}; /// Fold relative input in event order. The base scale is used only on leaving Fit. pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: Event) -> bool { change_with_minimum(pan, zoom, fit, event, 0.05) } /// Scenario extents can require scales below the topology canvas's 5% floor. pub(super) fn change_with_minimum( pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: Event, minimum: f32, ) -> bool { if !minimum.is_finite() || minimum <= 0. || minimum > 3. { return false; } match event { Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => { let next = *pan + delta; if next.x.is_finite() && next.y.is_finite() && next != *pan { *pan = next; true } else { false } } Event::Zoom { factor, at, base } if factor.is_finite() && factor > 0. && base.is_finite() && base > 0. && at.x.is_finite() && at.y.is_finite() => { let old = if *fit { base } else { zoom.unwrap_or(base) }; if !old.is_finite() || old <= 0. { return false; } let next = (old * factor).clamp(minimum, 3.); if old == next { return false; } let anchor = Vector::new(at.x, at.y); let next_pan = anchor - (anchor - *pan) * (next / old); if !next_pan.x.is_finite() || !next_pan.y.is_finite() { return false; } *pan = next_pan; *zoom = Some(next); *fit = false; true } _ => false, } } fn zoom_label(scale: f32) -> String { let percent = f64::from(scale) * 100.; if !percent.is_finite() || percent <= 0. { return "—".into(); } if percent >= 1. { format!("{percent:.0}%") } else if percent >= 0.01 { format!("{percent:.3}%") } else { format!("{percent:.2e}%") } } #[cfg(test)] mod camera_policy_tests { use super::*; #[test] fn topology_retains_five_percent_floor() { let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false); assert!(change( &mut pan, &mut zoom, &mut fit, Event::Zoom { factor: 0.1, at: Point::ORIGIN, base: 0.06, } )); assert_eq!(zoom, Some(0.05)); } #[test] fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() { assert_eq!(zoom_label(1.), "100%"); assert_eq!(zoom_label(0.05), "5%"); assert_eq!(zoom_label(0.00034193), "0.034%"); assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%"); assert_eq!(zoom_label(1e-7), "1.00e-5%"); assert_ne!(zoom_label(f32::from_bits(1)), "0%"); } } /// Constant-width labelled palette. Arming a tool never reallocates the canvas. pub fn palette<'a>( scope: Scope, keyboard: bool, publish: impl Fn(Event) -> Message + Copy + 'a, entries: impl IntoIterator)>, ) -> Element<'a, Message> { let mut tools = column![].spacing(3); for (glyph, label, selected, message) in entries { let message = message.map(|message| intent(scope, glyph, message)); tools = tools.push(tooltip( container(Control::new( button(text(glyph).size(12)) .width(34) .height(30) .padding(3) .style(if selected { button::primary } else { crate::style::nav }) .on_press_maybe(message.clone()), scope, glyph, keyboard, message, publish, )) .id(format!("behavior-tool:{glyph}")), container(text(label).size(12)) .padding(7) .style(crate::style::card), tooltip::Position::Left, )); } container(scrollable(tools).id(scope.control_id("palette-scroll"))) .width(40) .height(Fill) .padding(3) .style(crate::style::panel) .into() } /// Header and palette are permanent. Details/editors overlay the same viewport. pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>( header: Element<'a, Message, Theme, R>, palette: Element<'a, Message, Theme, R>, canvas: Element<'a, Message, Theme, R>, editor: Option>, ) -> Element<'a, Message, Theme, R> { let mut body = stack![canvas]; if let Some(editor) = editor { body = body.push( container(opaque( container(editor) .width(300) .height(Fill) .style(crate::style::card), )) .align_right(Fill) .height(Fill) .padding(8), ); } column![header, row![body, palette].height(Fill)] .spacing(6) .padding(8) .width(Fill) .height(Fill) .into() } #[cfg(test)] pub(crate) mod tests;