Integrate persistent canvas controls with scenario lifetime rendering
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
//! 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
|
||||
&& !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.reference_input_active()
|
||||
}
|
||||
|
||||
pub fn controls<'a>(
|
||||
scope: Scope,
|
||||
keyboard: bool,
|
||||
scale: f32,
|
||||
details: Message,
|
||||
focus: Option<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,
|
||||
)
|
||||
};
|
||||
row![
|
||||
control("Details", "Details", Some(details)),
|
||||
tooltip(control("Canvas", "Canvas", focus),
|
||||
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
|
||||
tooltip::Position::Bottom),
|
||||
control("Fit", "Fit", Some(zoom(None))),
|
||||
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
|
||||
control("ActualSize", "100%", Some(zoom(Some(1.)))),
|
||||
].spacing(4).align_y(iced::Center).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
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,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
} else {
|
||||
let context = app.topology_context()?;
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
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
|
||||
))
|
||||
}
|
||||
}
|
||||
/// 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 },
|
||||
Focus,
|
||||
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 {
|
||||
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) };
|
||||
let next = (old * factor).clamp(0.05, 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
Reference in New Issue
Block a user