Skip to content

Add PTZ support for flipping the canvas #2394

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions editor/src/messages/input_mapper/input_mappings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(NumpadAdd); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }),
entry!(KeyDown(Equal); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }),
entry!(KeyDown(Minus); modifiers=[Accel], action_dispatch=NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }),
entry!(KeyDown(KeyF); modifiers=[Alt], action_dispatch=NavigationMessage::CanvasFlip),
entry!(WheelScroll; modifiers=[Control], action_dispatch=NavigationMessage::CanvasZoomMouseWheel),
entry!(WheelScroll; modifiers=[Shift], action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: true }),
entry!(WheelScroll; action_dispatch=NavigationMessage::CanvasPanMouseWheel { use_y_as_x: false }),
Expand Down
73 changes: 38 additions & 35 deletions editor/src/messages/portfolio/document/document_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,29 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
continue;
}
let Some(bounds) = self.metadata().bounding_box_document(layer) else { continue };
let min = bounds[0].min(bounds[1]);
let max = bounds[0].max(bounds[1]);

let name = self.network_interface.display_name(&layer.to_node(), &[]);

// Calculate position of the text
let corner_pos = if !self.document_ptz.flip {
// Use the top-left corner
min
} else {
// Use the top-right corner, which appears to be the top-left due to being flipped
DVec2::new(max.x, min.y)
};

// When the canvas is flipped, mirror the text so it appears correctly
let scale = if !self.document_ptz.flip { DVec2::ONE } else { DVec2::new(-1., 1.) };

// Create a transform that puts the text at the true top-left regardless of flip
let transform = self.metadata().document_to_viewport
* DAffine2::from_translation(bounds[0].min(bounds[1]))
* DAffine2::from_translation(corner_pos)
* DAffine2::from_scale(DVec2::splat(self.document_ptz.zoom().recip()))
* DAffine2::from_translation(-DVec2::Y * 4.);
* DAffine2::from_translation(-DVec2::Y * 4.)
* DAffine2::from_scale(scale);

overlay_context.text(&name, COLOR_OVERLAY_GRAY, None, transform, 0., [Pivot::Start, Pivot::End]);
}
Expand Down Expand Up @@ -1477,6 +1493,9 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
self.network_interface.document_bounds_document_space(true)
};
if let Some(bounds) = bounds {
if self.document_ptz.flip {
responses.add(NavigationMessage::CanvasFlip);
}
responses.add(NavigationMessage::CanvasTiltSet { angle_radians: 0. });
responses.add(NavigationMessage::FitViewportToBounds { bounds, prevent_zoom_past_100: true });
} else {
Expand Down Expand Up @@ -2365,7 +2384,7 @@ impl DocumentMessageHandler {
Separator::new(SeparatorType::Unrelated).widget_holder(),
];

widgets.extend(navigation_controls(&self.document_ptz, &self.navigation_handler, "Canvas"));
widgets.extend(navigation_controls(&self.document_ptz, &self.navigation_handler, false));

let tilt_value = self.navigation_handler.snapped_tilt(self.document_ptz.tilt()) / (std::f64::consts::PI / 180.);
if tilt_value.abs() > 0.00001 {
Expand Down Expand Up @@ -2817,8 +2836,8 @@ impl<'a> ClickXRayIter<'a> {
}
}

pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHandler, tooltip_name: &str) -> [WidgetHolder; 5] {
[
pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHandler, node_graph: bool) -> Vec<WidgetHolder> {
let mut list = vec![
IconButton::new("ZoomIn", 24)
.tooltip("Zoom In")
.tooltip_shortcut(action_keys!(NavigationMessageDiscriminant::CanvasZoomIncrease))
Expand All @@ -2835,40 +2854,23 @@ pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHand
.on_update(|_| NavigationMessage::CanvasTiltResetAndZoomTo100Percent.into())
.disabled(ptz.tilt().abs() < 1e-4 && (ptz.zoom() - 1.).abs() < 1e-4)
.widget_holder(),
// PopoverButton::new()
// .popover_layout(vec![
// LayoutGroup::Row {
// widgets: vec![TextLabel::new(format!("{tooltip_name} Navigation")).bold(true).widget_holder()],
// },
// LayoutGroup::Row {
// widgets: vec![TextLabel::new({
// let tilt = if tooltip_name == "Canvas" { "Tilt:\n• Alt + Middle Click Drag\n\n" } else { "" };
// format!(
// "
// Interactive controls in this\n\
// menu are coming soon.\n\
// \n\
// Pan:\n\
// • Middle Click Drag\n\
// \n\
// {tilt}Zoom:\n\
// • Shift + Middle Click Drag\n\
// • Ctrl + Scroll Wheel Roll
// "
// )
// .trim()
// })
// .multiline(true)
// .widget_holder()],
// },
// ])
// .widget_holder(),
];
if ptz.flip && !node_graph {
list.push(
IconButton::new("Reverse", 24)
.tooltip("Flip the canvas back to its standard orientation")
.tooltip_shortcut(action_keys!(NavigationMessageDiscriminant::CanvasFlip))
.on_update(|_| NavigationMessage::CanvasFlip.into())
.widget_holder(),
);
}
list.extend([
Separator::new(SeparatorType::Related).widget_holder(),
NumberInput::new(Some(navigation_handler.snapped_zoom(ptz.zoom()) * 100.))
.unit("%")
.min(0.000001)
.max(1000000.)
.tooltip(format!("{tooltip_name} Zoom"))
.tooltip(if node_graph { "Node Graph Zoom" } else { "Canvas Zoom" })
.on_update(|number_input: &NumberInput| {
NavigationMessage::CanvasZoomSet {
zoom_factor: number_input.value.unwrap() / 100.,
Expand All @@ -2879,7 +2881,8 @@ pub fn navigation_controls(ptz: &PTZ, navigation_handler: &NavigationMessageHand
.increment_callback_decrease(|_| NavigationMessage::CanvasZoomDecrease { center_on_mouse: false }.into())
.increment_callback_increase(|_| NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }.into())
.widget_holder(),
]
]);
list
}

impl Iterator for ClickXRayIter<'_> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub enum NavigationMessage {
CanvasZoomIncrease { center_on_mouse: bool },
CanvasZoomMouseWheel,
CanvasZoomSet { zoom_factor: f64 },
CanvasFlip,
EndCanvasPTZ { abort_transform: bool },
EndCanvasPTZWithClick { commit_key: Key },
FitViewportToBounds { bounds: [DVec2; 2], prevent_zoom_past_100: bool },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for Navigation
responses.add(DocumentMessage::PTZUpdate);
if !graph_view_overlay_open {
responses.add(PortfolioMessage::UpdateDocumentWidgets);
responses.add(MenuBarMessage::SendLayout);
}
}
NavigationMessage::CanvasZoomDecrease { center_on_mouse } => {
Expand Down Expand Up @@ -273,6 +274,22 @@ impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for Navigation
responses.add(DocumentMessage::PTZUpdate);
responses.add(NodeGraphMessage::SetGridAlignedEdges);
}
NavigationMessage::CanvasFlip => {
if graph_view_overlay_open {
return;
}
let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
log::error!("Could not get mutable PTZ in CanvasFlip");
return;
};

ptz.flip = !ptz.flip;

responses.add(DocumentMessage::PTZUpdate);
responses.add(BroadcastEvent::CanvasTransformed);
responses.add(MenuBarMessage::SendLayout);
responses.add(PortfolioMessage::UpdateDocumentWidgets);
}
NavigationMessage::EndCanvasPTZ { abort_transform } => {
let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
log::error!("Could not get mutable PTZ in EndCanvasPTZ");
Expand Down Expand Up @@ -393,9 +410,11 @@ impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for Navigation
..
} => {
let tilt_raw_not_snapped = {
// Compute the angle in document space to counter for the canvas being flipped
let viewport_to_document = network_interface.document_metadata().document_to_viewport.inverse();
let half_viewport = ipp.viewport_bounds.size() / 2.;
let start_offset = self.mouse_position - half_viewport;
let end_offset = ipp.mouse.position - half_viewport;
let start_offset = viewport_to_document.transform_vector2(self.mouse_position - half_viewport);
let end_offset = viewport_to_document.transform_vector2(ipp.mouse.position - half_viewport);
let angle = start_offset.angle_to(end_offset);

tilt_raw_not_snapped + angle
Expand Down Expand Up @@ -471,6 +490,7 @@ impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for Navigation
CanvasZoomDecrease,
CanvasZoomIncrease,
CanvasZoomMouseWheel,
CanvasFlip,
FitViewportToSelection,
);

Expand Down Expand Up @@ -513,15 +533,16 @@ impl NavigationMessageHandler {
let tilt = ptz.tilt();
let zoom = ptz.zoom();

let scaled_center = viewport_center / self.snapped_zoom(zoom);
let scale = self.snapped_zoom(zoom);
let scale_vec = if ptz.flip { DVec2::new(-scale, scale) } else { DVec2::splat(scale) };
let scaled_center = viewport_center / scale_vec;

// Try to avoid fractional coordinates to reduce anti aliasing.
let scale = self.snapped_zoom(zoom);
let rounded_pan = ((pan + scaled_center) * scale).round() / scale - scaled_center;

// TODO: replace with DAffine2::from_scale_angle_translation and fix the errors
let offset_transform = DAffine2::from_translation(scaled_center);
let scale_transform = DAffine2::from_scale(DVec2::splat(scale));
let scale_transform = DAffine2::from_scale(scale_vec);
let angle_transform = DAffine2::from_angle(self.snapped_tilt(tilt));
let translation_transform = DAffine2::from_translation(rounded_pan);
scale_transform * offset_transform * angle_transform * translation_transform
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1969,7 +1969,7 @@ impl NodeGraphMessageHandler {
.widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
];
widgets.extend(navigation_controls(node_graph_ptz, navigation_handler, "Node Graph"));
widgets.extend(navigation_controls(node_graph_ptz, navigation_handler, true));
widgets.extend([
Separator::new(SeparatorType::Unrelated).widget_holder(),
TextButton::new("Node Graph")
Expand Down
9 changes: 8 additions & 1 deletion editor/src/messages/portfolio/document/utility_types/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,18 @@ pub struct PTZ {
tilt: f64,
/// Scale factor.
zoom: f64,
/// Flipped status.
pub flip: bool,
}

impl Default for PTZ {
fn default() -> Self {
Self { pan: DVec2::ZERO, tilt: 0., zoom: 1. }
Self {
pan: DVec2::ZERO,
tilt: 0.,
zoom: 1.,
flip: false,
}
}
}

Expand Down
16 changes: 13 additions & 3 deletions editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use graphene_std::vector::misc::BooleanOperation;
#[derive(Debug, Clone, Default)]
pub struct MenuBarMessageHandler {
pub has_active_document: bool,
pub canvas_tilted: bool,
pub canvas_flipped: bool,
pub rulers_visible: bool,
pub node_graph_open: bool,
pub has_selected_nodes: bool,
Expand Down Expand Up @@ -503,7 +505,7 @@ impl LayoutHolder for MenuBarMessageHandler {
icon: Some("TiltReset".into()),
shortcut: action_keys!(NavigationMessageDiscriminant::CanvasTiltSet),
action: MenuBarEntry::create_action(|_| NavigationMessage::CanvasTiltSet { angle_radians: 0.into() }.into()),
disabled: no_active_document || node_graph_open,
disabled: no_active_document || node_graph_open || !self.canvas_tilted,
..MenuBarEntry::default()
},
],
Expand All @@ -525,15 +527,15 @@ impl LayoutHolder for MenuBarMessageHandler {
..MenuBarEntry::default()
},
MenuBarEntry {
label: "Zoom to Fit Selection".into(),
label: "Zoom to Selection".into(),
icon: Some("FrameSelected".into()),
shortcut: action_keys!(NavigationMessageDiscriminant::FitViewportToSelection),
action: MenuBarEntry::create_action(|_| NavigationMessage::FitViewportToSelection.into()),
disabled: no_active_document || !has_selected_layers,
..MenuBarEntry::default()
},
MenuBarEntry {
label: "Zoom to Fit All".into(),
label: "Zoom to Fit".into(),
icon: Some("FrameAll".into()),
shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasToFitAll),
action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasToFitAll.into()),
Expand All @@ -557,6 +559,14 @@ impl LayoutHolder for MenuBarMessageHandler {
..MenuBarEntry::default()
},
],
vec![MenuBarEntry {
label: "Flip".into(),
icon: Some(if self.canvas_flipped { "CheckboxChecked" } else { "CheckboxUnchecked" }.into()),
shortcut: action_keys!(NavigationMessageDiscriminant::CanvasFlip),
action: MenuBarEntry::create_action(|_| NavigationMessage::CanvasFlip.into()),
disabled: no_active_document || node_graph_open,
..MenuBarEntry::default()
}],
vec![MenuBarEntry {
label: "Rulers".into(),
icon: Some(if self.rulers_visible { "CheckboxChecked" } else { "CheckboxUnchecked" }.into()),
Expand Down
4 changes: 4 additions & 0 deletions editor/src/messages/portfolio/portfolio_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
// Sub-messages
PortfolioMessage::MenuBar(message) => {
self.menu_bar_message_handler.has_active_document = false;
self.menu_bar_message_handler.canvas_tilted = false;
self.menu_bar_message_handler.canvas_flipped = false;
self.menu_bar_message_handler.rulers_visible = false;
self.menu_bar_message_handler.node_graph_open = false;
self.menu_bar_message_handler.has_selected_nodes = false;
Expand All @@ -80,6 +82,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes

if let Some(document) = self.active_document_id.and_then(|document_id| self.documents.get_mut(&document_id)) {
self.menu_bar_message_handler.has_active_document = true;
self.menu_bar_message_handler.canvas_tilted = document.document_ptz.tilt() != 0.;
self.menu_bar_message_handler.canvas_flipped = document.document_ptz.flip;
self.menu_bar_message_handler.rulers_visible = document.rulers_visible;
self.menu_bar_message_handler.node_graph_open = document.is_graph_overlay_open();
let selected_nodes = document.network_interface.selected_nodes();
Expand Down
5 changes: 3 additions & 2 deletions website/content/learn/interface/menu-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,11 @@ The **View menu** lists actions related to the view of the canvas within the vie
| Reset Tilt | <p>Sets the viewport tilt angle back to 0°.</p> |
| Zoom In | <p>Narrows the view to the next whole zoom increment, such as:</p><p>25%, 33.33%, 40%, 50%, 66.67%, 80%, 100%, 125%, 160%, 200%, 250%, 320%, 400%, 500%</p> |
| Zoom Out | <p>Widens the view to the next whole zoom increment, such as above.</p> |
| Zoom to Fit Selection | <p>Zooms and frames the viewport to the bounding box of the selected layer(s).</p> |
| Zoom to Fit All | <p>Zooms and frames the viewport to fit all artboards, or all artwork if using infinite canvas.</p> |
| Zoom to Selection | <p>Zooms and frames the viewport to the bounding box of the selected layer(s).</p> |
| Zoom to Fit | <p>Zooms and frames the viewport to fit all artboards, or all artwork if using infinite canvas.</p> |
| Zoom to 100% | <p>Zooms the viewport in or out to 100% scale, making the document and viewport scales match 1:1.</p> |
| Zoom to 200% | <p>Zooms the viewport in or out to 200% scale, displaying the artwork at twice the actual size.</p> |
| Flip | <p>Mirrors the viewport horizontally, flipping the view of the artwork until deactivated.</p> |
| Rulers | <p>Toggles visibility of the rulers along the top/left edges of the viewport.</p> |

## Help
Expand Down
Loading