// Copyright 2021 Tom A. Wagner // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License version 3 as published by // the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-only use adw::{ gio, glib::{self, clone}, gtk::{ self, cairo, graphene::{self, Point}, gsk, }, prelude::*, subclass::prelude::*, }; use std::cmp::Ordering; use super::{Link, Node, Port}; use crate::NodeType; const CANVAS_SIZE: f64 = 5000.0; mod imp { use super::*; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet}; use adw::gtk::gdk::{self}; use log::warn; use once_cell::sync::Lazy; use pipewire::spa::format::MediaType; use pipewire::spa::Direction; pub struct Colors { audio: gdk::RGBA, video: gdk::RGBA, midi: gdk::RGBA, unknown: gdk::RGBA, } impl Colors { pub fn color_for_media_type(&self, media_type: MediaType) -> &gdk::RGBA { match media_type { MediaType::Audio => &self.audio, MediaType::Video => &self.video, MediaType::Stream | MediaType::Application => &self.midi, _ => &self.unknown, } } } pub struct DragState { node: glib::WeakRef, /// 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, } pub struct GraphView { /// Stores nodes and their positions. pub(super) nodes: RefCell>, /// Stores the links and whether they are currently active. pub(super) links: RefCell>, // Properties for zooming and scrolling the hraph pub hadjustment: RefCell>, pub vadjustment: RefCell>, pub zoom_factor: Cell, /// This keeps track of an ongoing node drag operation. pub dragged_node: RefCell>, // These keep track of an ongoing port drag operation pub dragged_port: glib::WeakRef, pub port_drag_cursor: Cell, // Memorized data for an in-progress zoom gesture pub zoom_gesture_initial_zoom: Cell>, pub zoom_gesture_anchor: Cell>, } impl Default for GraphView { fn default() -> Self { Self { nodes: Default::default(), links: Default::default(), hadjustment: Default::default(), vadjustment: Default::default(), zoom_factor: Default::default(), dragged_node: Default::default(), dragged_port: Default::default(), port_drag_cursor: Cell::new(Point::new(0.0, 0.0)), zoom_gesture_initial_zoom: Default::default(), zoom_gesture_anchor: Default::default(), } } } #[glib::object_subclass] impl ObjectSubclass for GraphView { const NAME: &'static str = "HelvumGraphView"; type Type = super::GraphView; type ParentType = gtk::Widget; type Interfaces = (gtk::Scrollable,); fn class_init(klass: &mut Self::Class) { klass.set_css_name("graphview"); } } impl ObjectImpl for GraphView { fn constructed(&self) { self.parent_constructed(); self.obj().add_css_class("view"); self.obj().set_overflow(gtk::Overflow::Hidden); self.setup_node_dragging(); self.setup_port_drag_and_drop(); self.setup_scroll_zooming(); self.setup_zoom_gesture(); } fn dispose(&self) { self.nodes .borrow() .iter() .for_each(|(node, _)| node.unparent()) } fn properties() -> &'static [glib::ParamSpec] { static PROPERTIES: Lazy> = Lazy::new(|| { vec![ glib::ParamSpecOverride::for_interface::("hadjustment"), glib::ParamSpecOverride::for_interface::("vadjustment"), glib::ParamSpecOverride::for_interface::("hscroll-policy"), glib::ParamSpecOverride::for_interface::("vscroll-policy"), glib::ParamSpecDouble::builder("zoom-factor") .minimum(0.3) .maximum(4.0) .default_value(1.0) .flags(glib::ParamFlags::CONSTRUCT | glib::ParamFlags::READWRITE) .build(), ] }); PROPERTIES.as_ref() } fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { match pspec.name() { "hadjustment" => self.hadjustment.borrow().to_value(), "vadjustment" => self.vadjustment.borrow().to_value(), "hscroll-policy" | "vscroll-policy" => gtk::ScrollablePolicy::Natural.to_value(), "zoom-factor" => self.zoom_factor.get().to_value(), _ => unimplemented!(), } } fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { let obj = self.obj(); match pspec.name() { "hadjustment" => { self.set_adjustment(&obj, value.get().ok(), gtk::Orientation::Horizontal) } "vadjustment" => { self.set_adjustment(&obj, value.get().ok(), gtk::Orientation::Vertical) } "hscroll-policy" | "vscroll-policy" => {} "zoom-factor" => { self.zoom_factor.set(value.get().unwrap()); obj.queue_allocate(); } _ => unimplemented!(), } } } impl WidgetImpl for GraphView { fn size_allocate(&self, _width: i32, _height: i32, baseline: i32) { let widget = &*self.obj(); for (node, point) in self.nodes.borrow().iter() { let (_, natural_size) = node.preferred_size(); let transform = self .canvas_space_to_screen_space_transform() .translate(point); node.allocate( natural_size.width(), natural_size.height(), baseline, Some(transform), ); } if let Some(ref hadjustment) = *self.hadjustment.borrow() { self.set_adjustment_values(widget, hadjustment, gtk::Orientation::Horizontal); } if let Some(ref vadjustment) = *self.vadjustment.borrow() { self.set_adjustment_values(widget, vadjustment, gtk::Orientation::Vertical); } } fn snapshot(&self, snapshot: >k::Snapshot) { let widget = &*self.obj(); let alloc = widget.allocation(); // Draw all visible children self.nodes .borrow() .iter() // Cull nodes from rendering when they are outside the visible canvas area .filter(|(node, _)| alloc.intersect(&node.allocation()).is_some()) .for_each(|(node, _)| widget.snapshot_child(node, snapshot)); self.snapshot_links(widget, snapshot); } } impl ScrollableImpl for 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)) .scale(zoom_factor as f32, zoom_factor as f32) } /// 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 drag_controller = gtk::GestureDrag::new(); drag_controller.connect_drag_begin(|drag_controller, x, y| { let widget = drag_controller .widget() .dynamic_cast::() .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::().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::() .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(), ), ); }); self.obj().add_controller(drag_controller); } fn setup_port_drag_and_drop(&self) { let controller = gtk::DropControllerMotion::new(); controller.connect_enter(|controller, x, y| { let graph = controller .widget() .downcast::() .expect("Widget should be a graphview"); graph.imp().port_drag_enter(controller, x, y) }); controller.connect_motion(|controller, x, y| { let graph = controller .widget() .downcast::() .expect("Widget should be a graphview"); graph.imp().port_drag_motion(x, y) }); controller.connect_leave(|controller| { let graph = controller .widget() .downcast::() .expect("Widget should be a graphview"); graph.imp().port_drag_leave() }); self.obj().add_controller(controller); } fn port_drag_enter(&self, controller: >k::DropControllerMotion, x: f64, y: f64) { let Some(drop) = controller.drop() else { return; }; self.port_drag_cursor.set(Point::new(x as f32, y as f32)); drop.read_value_async( Port::static_type(), glib::Priority::DEFAULT, Option::<&gio::Cancellable>::None, clone!(@weak self as imp => move|value| { let Ok(value) = value else { return; }; let port: &Port = value.get().expect("Value should contain a port"); imp.dragged_port.set(Some(port)); }), ); self.obj().queue_draw(); } fn port_drag_motion(&self, x: f64, y: f64) { if self.dragged_port.upgrade().is_some() { self.port_drag_cursor.set(Point::new(x as f32, y as f32)); self.obj().queue_draw(); } } fn port_drag_leave(&self) { if self.dragged_port.upgrade().is_some() { self.dragged_port.set(None); self.obj().queue_draw(); } } fn setup_scroll_zooming(&self) { // We're only interested in the vertical axis, but for devices like touchpads, // not capturing a small accidental horizontal move may cause the scroll to be disrupted if a widget // higher up captures it instead. let scroll_controller = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES); scroll_controller.connect_scroll(|eventcontroller, _, delta_y| { let event = eventcontroller.current_event().unwrap(); // We are inside the event handler, so it must have an event if event .modifier_state() .contains(gdk::ModifierType::CONTROL_MASK) { let widget = eventcontroller .widget() .downcast::() .unwrap(); widget.set_zoom_factor(widget.zoom_factor() + (0.1 * -delta_y), None); glib::Propagation::Stop } else { glib::Propagation::Proceed } }); self.obj().add_controller(scroll_controller); } fn setup_zoom_gesture(&self) { let zoom_gesture = gtk::GestureZoom::new(); zoom_gesture.connect_begin(|gesture, _| { let widget = gesture.widget().downcast::().unwrap(); widget .imp() .zoom_gesture_initial_zoom .set(Some(widget.zoom_factor())); widget .imp() .zoom_gesture_anchor .set(gesture.bounding_box_center()); }); zoom_gesture.connect_scale_changed(move |gesture, delta| { let widget = gesture.widget().downcast::().unwrap(); let initial_zoom = widget .imp() .zoom_gesture_initial_zoom .get() .expect("Initial zoom not set during zoom gesture"); widget.set_zoom_factor(initial_zoom * delta, gesture.bounding_box_center()); }); self.obj().add_controller(zoom_gesture); } fn draw_link( &self, link_cr: &cairo::Context, output_anchor: &Point, input_anchor: &Point, active: bool, color: &gdk::RGBA, ) { let output_x: f64 = output_anchor.x().into(); let output_y: f64 = output_anchor.y().into(); let input_x: f64 = input_anchor.x().into(); let input_y: f64 = input_anchor.y().into(); // Use dashed line for inactive links, full line otherwise. if active { link_cr.set_dash(&[], 0.0); } else { link_cr.set_dash(&[10.0, 5.0], 0.0); } link_cr.set_source_rgba( color.red().into(), color.green().into(), color.blue().into(), color.alpha().into(), ); // If the output port is farther right than the input port and they have // a similar y coordinate, apply a y offset to the control points // so that the curve sticks out a bit. let y_control_offset = if output_x > input_x { f64::max(0.0, 25.0 - (output_y - input_y).abs()) } else { 0.0 }; // Place curve control offset by half the x distance between the two points. // This makes the curve scale well for varying distances between the two ports, // especially when the output port is farther right than the input port. let half_x_dist = f64::abs(output_x - input_x) / 2.0; link_cr.move_to(output_x, output_y); link_cr.curve_to( output_x + half_x_dist, output_y - y_control_offset, input_x - half_x_dist, input_y - y_control_offset, input_x, input_y, ); if let Err(e) = link_cr.stroke() { warn!("Failed to draw graphview links: {}", e); }; } fn draw_dragged_link(&self, port: &Port, link_cr: &cairo::Context, colors: &Colors) { let Some(port_anchor) = port.compute_point(&*self.obj(), &port.link_anchor()) else { return; }; let drag_cursor = self.port_drag_cursor.get(); /* If we can find a linkable port under the cursor, link to its anchor, * otherwise link to the mouse cursor */ let picked_port = self .obj() .pick( drag_cursor.x().into(), drag_cursor.y().into(), gtk::PickFlags::DEFAULT, ) .and_then(|widget| widget.ancestor(Port::static_type()).and_downcast::()) .filter(|picked_port| port.is_linkable_to(picked_port)); let picked_port_anchor = picked_port.and_then(|picked_port| { picked_port.compute_point(&*self.obj(), &picked_port.link_anchor()) }); let other_anchor = picked_port_anchor.unwrap_or(drag_cursor); let (output_anchor, input_anchor) = match Direction::from_raw(port.direction()) { Direction::Output => (&port_anchor, &other_anchor), Direction::Input => (&other_anchor, &port_anchor), _ => unreachable!(), }; let color = &colors.color_for_media_type(MediaType::from_raw(port.media_type())); self.draw_link(link_cr, output_anchor, input_anchor, false, color); } fn snapshot_links(&self, widget: &super::GraphView, snapshot: >k::Snapshot) { let alloc = widget.allocation(); let link_cr = snapshot.append_cairo(&graphene::Rect::new( 0.0, 0.0, alloc.width() as f32, alloc.height() as f32, )); link_cr.set_line_width(2.0 * self.zoom_factor.get()); let colors = Colors { audio: widget .style_context() .lookup_color("media-type-audio") .expect("color not found"), video: widget .style_context() .lookup_color("media-type-video") .expect("color not found"), midi: widget .style_context() .lookup_color("media-type-midi") .expect("color not found"), unknown: widget .style_context() .lookup_color("media-type-unknown") .expect("color not found"), }; for link in self.links.borrow().iter() { let color = &colors.color_for_media_type(link.media_type()); // TODO: Do not draw links when they are outside the view let Some((output_anchor, input_anchor)) = self.get_link_coordinates(link) else { warn!("Could not get allocation of ports of link: {:?}", link); continue; }; self.draw_link( &link_cr, &output_anchor, &input_anchor, link.active(), color, ); } if let Some(port) = self.dragged_port.upgrade() { self.draw_dragged_link(&port, &link_cr, &colors); } } /// Get coordinates for the drawn link to start at and to end at. /// /// # Returns /// `Some((output_anchor, input_anchor))` if all objects the links refers to exist as widgets /// and those widgets are contained by the graph. /// /// The returned coordinates are in screen-space of the graph. fn get_link_coordinates(&self, link: &Link) -> Option<(graphene::Point, graphene::Point)> { let widget = &*self.obj(); let output_port = link.output_port()?; let output_anchor = output_port.compute_point(widget, &output_port.link_anchor())?; let input_port = link.input_port()?; let input_anchor = input_port.compute_point(widget, &input_port.link_anchor())?; Some((output_anchor, input_anchor)) } fn set_adjustment( &self, obj: &super::GraphView, adjustment: Option<>k::Adjustment>, orientation: gtk::Orientation, ) { match orientation { gtk::Orientation::Horizontal => { *self.hadjustment.borrow_mut() = adjustment.cloned() } gtk::Orientation::Vertical => *self.vadjustment.borrow_mut() = adjustment.cloned(), _ => unimplemented!(), } if let Some(adjustment) = adjustment { adjustment .connect_value_changed(clone!(@weak obj => move |_| obj.queue_allocate() )); } } fn set_adjustment_values( &self, obj: &super::GraphView, adjustment: >k::Adjustment, orientation: gtk::Orientation, ) { let size = match orientation { gtk::Orientation::Horizontal => obj.width(), gtk::Orientation::Vertical => obj.height(), _ => unimplemented!(), }; let zoom_factor = self.zoom_factor.get(); adjustment.configure( adjustment.value(), -(CANVAS_SIZE / 2.0) * zoom_factor, (CANVAS_SIZE / 2.0) * zoom_factor, (f64::from(size) * 0.1) * zoom_factor, (f64::from(size) * 0.9) * zoom_factor, f64::from(size) * zoom_factor, ); } } } glib::wrapper! { pub struct GraphView(ObjectSubclass) @extends gtk::Widget; } impl GraphView { pub const ZOOM_MIN: f64 = 0.3; pub const ZOOM_MAX: f64 = 4.0; pub fn new() -> Self { glib::Object::new() } 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. /// /// An optional anchor (in canvas-space coordinates) can be specified, which will be used as the center of the zoom, /// so that its position stays fixed. /// If no anchor is specified, the middle of the screen is used instead. /// /// Note that the zoom level is [clamped](`f64::clamp`) to between 30% and 300%. /// See [`Self::ZOOM_MIN`] and [`Self::ZOOM_MAX`]. pub fn set_zoom_factor(&self, zoom_factor: f64, anchor: Option<(f64, f64)>) { let zoom_factor = zoom_factor.clamp(Self::ZOOM_MIN, Self::ZOOM_MAX); let (anchor_x_screen, anchor_y_screen) = anchor.unwrap_or_else(|| { ( self.allocation().width() as f64 / 2.0, self.allocation().height() as f64 / 2.0, ) }); let old_zoom = self.imp().zoom_factor.get(); let hadjustment_ref = self.imp().hadjustment.borrow(); let vadjustment_ref = self.imp().vadjustment.borrow(); let hadjustment = hadjustment_ref.as_ref().unwrap(); let vadjustment = vadjustment_ref.as_ref().unwrap(); let x_total = (anchor_x_screen + hadjustment.value()) / old_zoom; let y_total = (anchor_y_screen + vadjustment.value()) / old_zoom; let new_hadjustment = x_total * zoom_factor - anchor_x_screen; let new_vadjustment = y_total * zoom_factor - anchor_y_screen; hadjustment.set_value(new_hadjustment); vadjustment.set_value(new_vadjustment); self.set_property("zoom-factor", zoom_factor); } pub fn add_node(&self, node: Node, node_type: Option) { let imp = self.imp(); node.set_parent(self); // Place widgets in colums of 3, growing down let x = if let Some(node_type) = node_type { match node_type { NodeType::Output => 20.0, NodeType::Input => 820.0, } } else { 420.0 }; let y = imp .nodes .borrow() .iter() .map(|node| { // Map nodes to their locations let point = self.node_position(&node.0.clone().upcast()).unwrap(); (point.x(), point.y()) }) .filter(|(x2, _)| { // Only look for other nodes that have a similar x coordinate (x - x2).abs() < 50.0 }) .max_by(|y1, y2| { // Get max in column y1.partial_cmp(y2).unwrap_or(Ordering::Equal) }) .map_or(20_f32, |(_x, y)| y + 120.0); imp.nodes.borrow_mut().insert(node, Point::new(x, y)); } pub fn remove_node(&self, node: &Node) { let mut nodes = self.imp().nodes.borrow_mut(); if nodes.remove(node).is_some() { node.unparent(); } else { log::warn!("Tried to remove non-existant node widget from graph"); } } pub fn add_link(&self, link: Link) { link.connect_notify_local( Some("active"), glib::clone!(@weak self as graph => move |_, _| { graph.queue_draw(); }), ); link.connect_notify_local( Some("media-type"), glib::clone!(@weak self as graph => move |_, _| { graph.queue_draw(); }), ); self.imp().links.borrow_mut().insert(link); self.queue_draw(); } pub fn remove_link(&self, link: &Link) { let mut links = self.imp().links.borrow_mut(); links.remove(link); self.queue_draw(); } /// Get the position of the specified node inside the graphview. /// /// 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 { self.imp().nodes.borrow().get(node).copied() } pub(super) fn move_node(&self, widget: &Node, point: &Point) { let mut nodes = self.imp().nodes.borrow_mut(); let node_point = nodes.get_mut(widget).expect("Node is not on the graph"); // Clamp the new position to within the graph, so a node can't be moved outside it and be lost. node_point.set_x(point.x().clamp( -(CANVAS_SIZE / 2.0) as f32, (CANVAS_SIZE / 2.0) as f32 - widget.width() as f32, )); node_point.set_y(point.y().clamp( -(CANVAS_SIZE / 2.0) as f32, (CANVAS_SIZE / 2.0) as f32 - widget.height() as f32, )); self.queue_allocate(); } } impl Default for GraphView { fn default() -> Self { Self::new() } }