316 lines
9.6 KiB
Rust
316 lines
9.6 KiB
Rust
//! 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<f32>) -> Message,
|
||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||
) -> Element<'a, Message> {
|
||
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
|
||
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<Self> {
|
||
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<Self> {
|
||
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<Message>,
|
||
}
|
||
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<f32>, 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<f32>,
|
||
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<Item = (&'static str, &'static str, bool, Option<Message>)>,
|
||
) -> 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>>,
|
||
) -> 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;
|