graphview: Make graph widget zoomable via a zoom-factor property.

This adds a `zoom-factor` property.

Changing the property zooms in the entire widget including background, nodes, links, etc.
This commit is contained in:
Tom A. Wagner
2022-11-09 12:47:24 +01:00
committed by Tom Wagner
parent bcef1300ca
commit 56e73d33c9

View File

@@ -18,7 +18,9 @@ use super::{Node, Port};
use gtk::{ use gtk::{
glib::{self, clone}, glib::{self, clone},
graphene::{self, Point}, graphene,
graphene::Point,
gsk,
prelude::*, prelude::*,
subclass::prelude::*, subclass::prelude::*,
}; };
@@ -33,12 +35,21 @@ const CANVAS_SIZE: f64 = 5000.0;
mod imp { mod imp {
use super::*; use super::*;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use gtk::{gdk::RGBA, graphene::Rect, gsk::ColorStop}; use gtk::{gdk::RGBA, graphene::Rect, gsk::ColorStop};
use log::warn; use log::warn;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
pub struct DragState {
node: glib::WeakRef<Node>,
/// This stores the offset of the pointer to the origin of the node,
/// so that we can keep the pointer over the same position when moving the node
///
/// The offset is normalized to the default zoom-level of 1.0.
offset: Point,
}
#[derive(Default)] #[derive(Default)]
pub struct GraphView { pub struct GraphView {
/// Stores nodes and their positions. /// Stores nodes and their positions.
@@ -47,8 +58,9 @@ mod imp {
pub(super) links: RefCell<HashMap<u32, (crate::PipewireLink, bool)>>, pub(super) links: RefCell<HashMap<u32, (crate::PipewireLink, bool)>>,
pub hadjustment: RefCell<Option<gtk::Adjustment>>, pub hadjustment: RefCell<Option<gtk::Adjustment>>,
pub vadjustment: RefCell<Option<gtk::Adjustment>>, pub vadjustment: RefCell<Option<gtk::Adjustment>>,
/// When a node drag is ongoing, this stores the dragged node and the initial coordinates on the widget surface. pub zoom_factor: Cell<f64>,
pub drag_state: RefCell<Option<(Node, Point)>>, /// This keeps track of an ongoing node drag operation.
pub dragged_node: RefCell<Option<DragState>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@@ -69,55 +81,7 @@ mod imp {
obj.set_overflow(gtk::Overflow::Hidden); obj.set_overflow(gtk::Overflow::Hidden);
let drag_controller = gtk::GestureDrag::new(); self.setup_node_dragging();
drag_controller.connect_drag_begin(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.dynamic_cast::<Self::Type>()
.expect("drag-begin event is not on the GraphView");
let mut drag_state = widget.imp().drag_state.borrow_mut();
// pick() should at least return the widget itself.
let target = widget
.pick(x, y, gtk::PickFlags::DEFAULT)
.expect("drag-begin pick() did not return a widget");
*drag_state = if target.ancestor(Port::static_type()).is_some() {
// The user targeted a port, so the dragging should be handled by the Port
// component instead of here.
None
} else if let Some(target) = target.ancestor(Node::static_type()) {
// The user targeted a Node without targeting a specific Port.
// Drag the Node around the screen.
let node = target.dynamic_cast_ref::<Node>().unwrap();
// We use the upper-left corner of the widget as the start position instead of the actual
// cursor location, this lets us move the node around easier because we don't need to
// account for where the cursor is on the node.
let alloc = node.allocation();
Some((node.clone(), Point::new(alloc.x() as f32, alloc.y() as f32)))
} else {
None
}
});
drag_controller.connect_drag_update(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.dynamic_cast::<Self::Type>()
.expect("drag-update event is not on the GraphView");
let drag_state = widget.imp().drag_state.borrow();
let hadj = widget.imp().hadjustment.borrow();
let vadj = widget.imp().vadjustment.borrow();
if let Some((ref node, ref start_point)) = *drag_state {
widget.move_node(
node,
start_point.x() + hadj.as_ref().unwrap().value() as f32 + x as f32,
start_point.y() + vadj.as_ref().unwrap().value() as f32 + y as f32,
);
}
});
obj.add_controller(&drag_controller);
} }
fn dispose(&self, _obj: &Self::Type) { fn dispose(&self, _obj: &Self::Type) {
@@ -134,6 +98,15 @@ mod imp {
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vadjustment"), glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vadjustment"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("hscroll-policy"), glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("hscroll-policy"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vscroll-policy"), glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vscroll-policy"),
glib::ParamSpecDouble::new(
"zoom-factor",
"zoom-factor",
"zoom-factor",
0.3,
4.0,
1.0,
glib::ParamFlags::CONSTRUCT | glib::ParamFlags::READWRITE,
),
] ]
}); });
@@ -145,6 +118,7 @@ mod imp {
"hadjustment" => self.hadjustment.borrow().to_value(), "hadjustment" => self.hadjustment.borrow().to_value(),
"vadjustment" => self.vadjustment.borrow().to_value(), "vadjustment" => self.vadjustment.borrow().to_value(),
"hscroll-policy" | "vscroll-policy" => gtk::ScrollablePolicy::Natural.to_value(), "hscroll-policy" | "vscroll-policy" => gtk::ScrollablePolicy::Natural.to_value(),
"zoom-factor" => self.zoom_factor.get().to_value(),
_ => unimplemented!(), _ => unimplemented!(),
} }
} }
@@ -164,26 +138,33 @@ mod imp {
self.set_adjustment(obj, value.get().ok(), gtk::Orientation::Vertical) self.set_adjustment(obj, value.get().ok(), gtk::Orientation::Vertical)
} }
"hscroll-policy" | "vscroll-policy" => {} "hscroll-policy" | "vscroll-policy" => {}
"zoom-factor" => {
self.zoom_factor.set(value.get().unwrap());
obj.queue_allocate();
}
_ => unimplemented!(), _ => unimplemented!(),
} }
} }
} }
impl WidgetImpl for GraphView { impl WidgetImpl for GraphView {
fn size_allocate(&self, widget: &Self::Type, _width: i32, _height: i32, _baseline: i32) { fn size_allocate(&self, widget: &Self::Type, _width: i32, _height: i32, baseline: i32) {
let zoom_factor = self.zoom_factor.get();
for (node, point) in self.nodes.borrow().values() { for (node, point) in self.nodes.borrow().values() {
let (_, natural_size) = node.preferred_size(); let (_, natural_size) = node.preferred_size();
node.size_allocate(
&gtk::Allocation::new( let transform = self
(f64::from(point.x()) - self.hadjustment.borrow().as_ref().unwrap().value()) .canvas_space_to_screen_space_transform()
as i32, .translate(point)
(f64::from(point.y()) - self.vadjustment.borrow().as_ref().unwrap().value()) .unwrap();
as i32,
natural_size.width(), node.allocate(
natural_size.height(), (natural_size.width() as f64 / zoom_factor).ceil() as i32,
), (natural_size.height() as f64 / zoom_factor).ceil() as i32,
-1, baseline,
) Some(&transform),
);
} }
if let Some(ref hadjustment) = *self.hadjustment.borrow() { if let Some(ref hadjustment) = *self.hadjustment.borrow() {
@@ -214,13 +195,114 @@ mod imp {
impl ScrollableImpl for GraphView {} impl ScrollableImpl for GraphView {}
impl GraphView { impl GraphView {
/// Returns a [`gsk::Transform`] matrix that can translate from canvas space to screen space.
///
/// Canvas space is non-zoomed, and (0, 0) is fixed at the middle of the graph. \
/// Screen space is zoomed and adjusted for scrolling, (0, 0) is at the top-left corner of the window.
///
/// This is the inverted form of [`Self::screen_space_to_canvas_space_transform()`].
fn canvas_space_to_screen_space_transform(&self) -> gsk::Transform {
let hadj = self.hadjustment.borrow().as_ref().unwrap().value();
let vadj = self.vadjustment.borrow().as_ref().unwrap().value();
let zoom_factor = self.zoom_factor.get();
gsk::Transform::new()
.translate(&Point::new(-hadj as f32, -vadj as f32))
.unwrap()
.scale(zoom_factor as f32, zoom_factor as f32)
.unwrap()
}
/// Returns a [`gsk::Transform`] matrix that can translate from screen space to canvas space.
///
/// This is the inverted form of [`Self::canvas_space_to_screen_space_transform()`], see that function for a more detailed explantion.
fn screen_space_to_canvas_space_transform(&self) -> gsk::Transform {
self.canvas_space_to_screen_space_transform()
.invert()
.unwrap()
}
fn setup_node_dragging(&self) {
let obj = self.instance();
let drag_controller = gtk::GestureDrag::new();
drag_controller.connect_drag_begin(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.dynamic_cast::<super::GraphView>()
.expect("drag-begin event is not on the GraphView");
let mut dragged_node = widget.imp().dragged_node.borrow_mut();
// pick() should at least return the widget itself.
let target = widget
.pick(x, y, gtk::PickFlags::DEFAULT)
.expect("drag-begin pick() did not return a widget");
*dragged_node = if target.ancestor(Port::static_type()).is_some() {
// The user targeted a port, so the dragging should be handled by the Port
// component instead of here.
None
} else if let Some(target) = target.ancestor(Node::static_type()) {
// The user targeted a Node without targeting a specific Port.
// Drag the Node around the screen.
let node = target.dynamic_cast_ref::<Node>().unwrap();
let Some(canvas_node_pos) = widget.node_position(node) else { return };
let canvas_cursor_pos = widget
.imp()
.screen_space_to_canvas_space_transform()
.transform_point(&Point::new(x as f32, y as f32));
Some(DragState {
node: node.clone().downgrade(),
offset: Point::new(
canvas_cursor_pos.x() - canvas_node_pos.x(),
canvas_cursor_pos.y() - canvas_node_pos.y(),
),
})
} else {
None
}
});
drag_controller.connect_drag_update(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.dynamic_cast::<super::GraphView>()
.expect("drag-update event is not on the GraphView");
let dragged_node = widget.imp().dragged_node.borrow();
let Some(DragState { node, offset }) = dragged_node.as_ref() else { return };
let Some(node) = node.upgrade() else { return };
let (start_x, start_y) = drag_controller
.start_point()
.expect("Drag has no start point");
let onscreen_node_origin = Point::new((start_x + x) as f32, (start_y + y) as f32);
let transform = widget.imp().screen_space_to_canvas_space_transform();
let canvas_node_origin = transform.transform_point(&onscreen_node_origin);
widget.move_node(
&node,
&Point::new(
canvas_node_origin.x() - offset.x(),
canvas_node_origin.y() - offset.y(),
),
);
});
obj.add_controller(&drag_controller);
}
fn snapshot_background(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) { fn snapshot_background(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
const GRID_SIZE: f32 = 20.0; // Grid size and line width during neutral zoom (factor 1.0).
const GRID_LINE_WIDTH: f32 = 1.0; const NORMAL_GRID_SIZE: f32 = 20.0;
const NORMAL_GRID_LINE_WIDTH: f32 = 1.0;
let zoom_factor = self.zoom_factor.get();
let grid_size = NORMAL_GRID_SIZE * zoom_factor as f32;
let grid_line_width = NORMAL_GRID_LINE_WIDTH * zoom_factor as f32;
let alloc = widget.allocation(); let alloc = widget.allocation();
// We need to offset the lines between 0 and (excluding) GRID_SIZE so the grid moves with // We need to offset the lines between 0 and (excluding) `grid_size` so the grid moves with
// the rest of the view when scrolling. // the rest of the view when scrolling.
// The offset is rounded so the grid is always aligned to a row of pixels. // The offset is rounded so the grid is always aligned to a row of pixels.
let hadj = self let hadj = self
@@ -229,22 +311,22 @@ mod imp {
.as_ref() .as_ref()
.map(|hadjustment| hadjustment.value()) .map(|hadjustment| hadjustment.value())
.unwrap_or(0.0); .unwrap_or(0.0);
let hoffset = ((GRID_SIZE - (hadj as f32 % GRID_SIZE)) % GRID_SIZE).floor(); let hoffset = (grid_size - (hadj as f32 % grid_size)) % grid_size;
let vadj = self let vadj = self
.vadjustment .vadjustment
.borrow() .borrow()
.as_ref() .as_ref()
.map(|vadjustment| vadjustment.value()) .map(|vadjustment| vadjustment.value())
.unwrap_or(0.0); .unwrap_or(0.0);
let voffset = ((GRID_SIZE - (vadj as f32 % GRID_SIZE)) % GRID_SIZE).floor(); let voffset = (grid_size - (vadj as f32 % grid_size)) % grid_size;
snapshot.push_repeat( snapshot.push_repeat(
&Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32), &Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32),
Some(&Rect::new(0.0, voffset, alloc.width() as f32, GRID_SIZE)), Some(&Rect::new(0.0, voffset, alloc.width() as f32, grid_size)),
); );
let grid_color = RGBA::new(0.137, 0.137, 0.137, 1.0); let grid_color = RGBA::new(0.137, 0.137, 0.137, 1.0);
snapshot.append_linear_gradient( snapshot.append_linear_gradient(
&Rect::new(0.0, voffset, alloc.width() as f32, GRID_LINE_WIDTH), &Rect::new(0.0, voffset, alloc.width() as f32, grid_line_width),
&Point::new(0.0, 0.0), &Point::new(0.0, 0.0),
&Point::new(alloc.width() as f32, 0.0), &Point::new(alloc.width() as f32, 0.0),
&[ &[
@@ -256,10 +338,10 @@ mod imp {
snapshot.push_repeat( snapshot.push_repeat(
&Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32), &Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32),
Some(&Rect::new(hoffset, 0.0, GRID_SIZE, alloc.height() as f32)), Some(&Rect::new(hoffset, 0.0, grid_size, alloc.height() as f32)),
); );
snapshot.append_linear_gradient( snapshot.append_linear_gradient(
&Rect::new(hoffset, 0.0, GRID_LINE_WIDTH, alloc.height() as f32), &Rect::new(hoffset, 0.0, grid_line_width, alloc.height() as f32),
&Point::new(0.0, 0.0), &Point::new(0.0, 0.0),
&Point::new(0.0, alloc.height() as f32), &Point::new(0.0, alloc.height() as f32),
&[ &[
@@ -280,7 +362,7 @@ mod imp {
alloc.height() as f32, alloc.height() as f32,
)); ));
link_cr.set_line_width(2.0); link_cr.set_line_width(2.0 * self.zoom_factor.get());
let rgba = widget let rgba = widget
.style_context() .style_context()
@@ -344,30 +426,29 @@ mod imp {
fn get_link_coordinates(&self, link: &crate::PipewireLink) -> Option<(f64, f64, f64, f64)> { fn get_link_coordinates(&self, link: &crate::PipewireLink) -> Option<(f64, f64, f64, f64)> {
let nodes = self.nodes.borrow(); let nodes = self.nodes.borrow();
// For some reason, gtk4::WidgetExt::translate_coordinates gives me incorrect values, let output_port = &nodes.get(&link.node_from)?.0.get_port(link.port_from)?;
// so we manually calculate the needed offsets here.
let from_port = &nodes.get(&link.node_from)?.0.get_port(link.port_from)?; let output_port_padding =
let from_node = from_port (output_port.allocated_width() - output_port.width()) as f64 / 2.0;
.ancestor(Node::static_type())
.expect("Port is not a child of a node");
let from_x = from_node.allocation().x()
+ from_port.allocation().x()
+ from_port.allocation().width();
let from_y = from_node.allocation().y()
+ from_port.allocation().y()
+ (from_port.allocation().height() / 2);
let to_port = &nodes.get(&link.node_to)?.0.get_port(link.port_to)?; let (from_x, from_y) = output_port.translate_coordinates(
let to_node = to_port &self.instance(),
.ancestor(Node::static_type()) output_port.width() as f64 + output_port_padding,
.expect("Port is not a child of a node"); (output_port.height() / 2) as f64,
let to_x = to_node.allocation().x() + to_port.allocation().x(); )?;
let to_y = to_node.allocation().y()
+ to_port.allocation().y()
+ (to_port.allocation().height() / 2);
Some((from_x.into(), from_y.into(), to_x.into(), to_y.into())) let input_port = &nodes.get(&link.node_to)?.0.get_port(link.port_to)?;
let input_port_padding =
(input_port.allocated_width() - input_port.width()) as f64 / 2.0;
let (to_x, to_y) = input_port.translate_coordinates(
&self.instance(),
-input_port_padding,
(input_port.height() / 2) as f64,
)?;
Some((from_x, from_y, to_x, to_y))
} }
fn set_adjustment( fn set_adjustment(
@@ -401,14 +482,15 @@ mod imp {
gtk::Orientation::Vertical => obj.height(), gtk::Orientation::Vertical => obj.height(),
_ => unimplemented!(), _ => unimplemented!(),
}; };
let zoom_factor = self.zoom_factor.get();
adjustment.configure( adjustment.configure(
adjustment.value(), adjustment.value(),
-(CANVAS_SIZE / 2.0), -(CANVAS_SIZE / 2.0) * zoom_factor,
CANVAS_SIZE / 2.0, (CANVAS_SIZE / 2.0) * zoom_factor,
f64::from(size) * 0.1, (f64::from(size) * 0.1) * zoom_factor,
f64::from(size) * 0.9, (f64::from(size) * 0.9) * zoom_factor,
f64::from(size), f64::from(size) * zoom_factor,
); );
} }
} }
@@ -420,10 +502,27 @@ glib::wrapper! {
} }
impl GraphView { impl GraphView {
pub const ZOOM_MIN: f64 = 0.3;
pub const ZOOM_MAX: f64 = 4.0;
pub fn new() -> Self { pub fn new() -> Self {
glib::Object::new(&[]).expect("Failed to create GraphView") glib::Object::new(&[]).expect("Failed to create GraphView")
} }
pub fn zoom_factor(&self) -> f64 {
self.property("zoom-factor")
}
/// Set the scale factor.
///
/// A factor of 1.0 is equivalent to 100% zoom, 0.5 to 50% zoom etc.
///
/// Note that the zoom level is limited to between 30% and 300%.
/// See [`ZOOM_MIN`] and [`ZOOM_MAX`].
pub fn set_zoom_factor(&self, scale_factor: f64) {
self.set_property("zoom-factor", scale_factor)
}
pub fn add_node(&self, id: u32, node: Node, node_type: Option<NodeType>) { pub fn add_node(&self, id: u32, node: Node, node_type: Option<NodeType>) {
let private = imp::GraphView::from_instance(self); let private = imp::GraphView::from_instance(self);
node.set_parent(self); node.set_parent(self);
@@ -444,7 +543,8 @@ impl GraphView {
.values() .values()
.map(|node| { .map(|node| {
// Map nodes to their locations // Map nodes to their locations
self.get_node_position(&node.0.clone().upcast()).unwrap() let point = self.node_position(&node.0.clone().upcast()).unwrap();
(point.x(), point.y())
}) })
.filter(|(x2, _)| { .filter(|(x2, _)| {
// Only look for other nodes that have a similar x coordinate // Only look for other nodes that have a similar x coordinate
@@ -518,15 +618,17 @@ impl GraphView {
} }
/// Get the position of the specified node inside the graphview. /// Get the position of the specified node inside the graphview.
pub(super) fn get_node_position(&self, node: &Node) -> Option<(f32, f32)> { ///
/// The returned position is in canvas-space (non-zoomed, (0, 0) fixed in the middle of the canvas).
pub(super) fn node_position(&self, node: &Node) -> Option<Point> {
self.imp() self.imp()
.nodes .nodes
.borrow() .borrow()
.get(&node.pipewire_id()) .get(&node.pipewire_id())
.map(|(_, point)| (point.x(), point.y())) .map(|(_, point)| *point)
} }
pub(super) fn move_node(&self, widget: &Node, x: f32, y: f32) { pub(super) fn move_node(&self, widget: &Node, point: &Point) {
let mut nodes = self.imp().nodes.borrow_mut(); let mut nodes = self.imp().nodes.borrow_mut();
let mut node = nodes let mut node = nodes
.get_mut(&widget.pipewire_id()) .get_mut(&widget.pipewire_id())
@@ -534,11 +636,11 @@ impl GraphView {
// Clamp the new position to within the graph, so a node can't be moved outside it and be lost. // Clamp the new position to within the graph, so a node can't be moved outside it and be lost.
node.1 = Point::new( node.1 = Point::new(
x.clamp( point.x().clamp(
-(CANVAS_SIZE / 2.0) as f32, -(CANVAS_SIZE / 2.0) as f32,
(CANVAS_SIZE / 2.0) as f32 - widget.width() as f32, (CANVAS_SIZE / 2.0) as f32 - widget.width() as f32,
), ),
y.clamp( point.y().clamp(
-(CANVAS_SIZE / 2.0) as f32, -(CANVAS_SIZE / 2.0) as f32,
(CANVAS_SIZE / 2.0) as f32 - widget.height() as f32, (CANVAS_SIZE / 2.0) as f32 - widget.height() as f32,
), ),