15 Commits
0.4.0 ... 0.4.1

Author SHA1 Message Date
Tom A. Wagner
fdcc6146ec Release 0.4.1 2023-08-18 09:12:49 +02:00
Tom A. Wagner
14f17b3f24 Stop using deprecated gtk::StyleContext::add_provider_for_display function 2023-08-17 21:00:18 +02:00
Tom A. Wagner
48cc5672fd ci: Update ci to latest Fedora and Rust 2023-08-17 20:22:10 +02:00
Tom A. Wagner
bf5c7e4636 Update dependencies 2023-08-17 20:14:23 +02:00
Tom A. Wagner
7145c83ae1 graph: Draw "fake" link during port drag-and-drop to visualize link creating
Cursor movement during port drag-and-drop on the graph is now being tracked
and a link is drawn from the dragged port to the cursor.

If the cursor is hovering a port the source port can link to,
the second end of the link instead attaches to the ports link anchor
so that the link "snaps" to the linkable port.
2023-08-04 14:58:05 +02:00
Tom A. Wagner
d99c5e253c port: Rework how port drag-and-drop is handled
Instead of different types for each direction to avoid linking ports
of the same direction, we reject the drop early if directions of both
ports are the same.

The direction check is easily extendable to also deny links between
ports of different media types in the future.
2023-08-03 21:40:35 +02:00
Tom A. Wagner
15df88a0af graph: Move port link anchor calculation into port widget 2023-08-03 21:12:15 +02:00
Tom A. Wagner
0b3b124cdf graph: Refactor graph item management into new graph_manager object
The graph widgets management (watching a glib receiver, adding and removing
Nodes, Ports and Links) currently done in the `Application` and `GraphView`
objects has been extracted into a new GraphManager object, which watches the
receiver instead, pushes changes directly to the widgets, and reacts to their signals.

This seperates widget logic and management logic cleanly instead of both
being mixed into the GraphView, and also reduces the code size for the
Application object.
2023-08-01 09:15:40 +02:00
Tom A. Wagner
a9ad1cccf0 fix clippy warnings 2023-08-01 09:13:16 +02:00
Tom A. Wagner
7a9bc84b8b port: Use glib::properties derive macro for properties 2023-08-01 09:09:57 +02:00
Tom A. Wagner
27b76b0fe1 node: Use glib::properties derive macro for properties 2023-08-01 09:09:46 +02:00
Tom A. Wagner
f986902929 graph: Move link data into new GObject subclass 2023-07-27 14:16:42 +02:00
Tom A. Wagner
475a83fab7 Restructure view module into ui folder, graph specific widgets into graph subfolder 2023-07-27 14:16:42 +02:00
Tom A. Wagner
0e699288e1 graph: Allocate proper size to nodes when zoomed
Previously, the allocated height and width to a node on the graph was divided by the zoom factor,
to account for the changed size from them being zoomed.

To zoom each node, we `size_allocate` it with a GskTransform that scales it.

However, using a scaling transform to allocate the node already takes care of scaling the height and
width, so us also scaling the height and width manually means we were overcompensating.

This resulted in the allocation becoming to big when zooming out, and to small when zooming in.

This is observable as labels will become smaller when zooming in and ellipsize their content.

The commit removes the extra manual scaling so nodes get allocated properly when zoomed.
2023-07-27 13:14:03 +02:00
Carlos Martín Nieto
84570f44bf port: add the dragged port as the drag icon
This makes it the port label follows your cursor around instead of the generic
text document icon that doesn't make a lot of sense here.
2023-03-14 19:52:54 +01:00
18 changed files with 1216 additions and 921 deletions

View File

@@ -15,9 +15,9 @@ variables:
# Version and tag for our current container
.fedora:
variables:
FDO_DISTRIBUTION_VERSION: '36'
FDO_DISTRIBUTION_VERSION: '38'
# Update this to trigger a container rebuild
FDO_DISTRIBUTION_TAG: '2022-11-09.0'
FDO_DISTRIBUTION_TAG: '2023-08-17.0'
build-fedora-container:
extends:

522
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "helvum"
version = "0.4.0"
version = "0.4.1"
authors = ["Tom A. Wagner <tom.a.wagner@protonmail.com>"]
edition = "2021"
rust-version = "1.56"
rust-version = "1.70"
license = "GPL-3.0-only"
description = "A GTK patchbay for pipewire"
repository = "https://gitlab.freedesktop.org/pipewire/helvum"
@@ -14,9 +14,9 @@ categories = ["gui", "multimedia"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
pipewire = "0.6"
gtk = { version = "0.6", package = "gtk4" }
glib = { version = "0.17", features = ["log"] }
pipewire = "0.7"
gtk = { version = "0.7", package = "gtk4" }
glib = { version = "0.18", features = ["log"] }
log = "0.4.11"

View File

@@ -23,6 +23,7 @@
<url type="bugtracker">https://gitlab.freedesktop.org/pipewire/helvum/-/issues</url>
<content_rating type="oars-1.0" />
<releases>
<release version="0.4.1" date="2023-08-18" />
<release version="0.4.0" date="2023-02-12" />
<release version="0.3.4" date="2022-02-02" />
<release version="0.3.3" date="2022-01-28" />

View File

@@ -1,7 +1,7 @@
project(
'helvum',
'rust',
version: '0.4.0',
version: '0.4.1',
license: 'GPL-3.0',
meson_version: '>=0.59.0'
)

View File

@@ -14,21 +14,15 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use std::cell::RefCell;
use gtk::{
gio,
glib::{self, clone, Continue, Receiver},
glib::{self, clone, Receiver},
prelude::*,
subclass::prelude::*,
};
use log::info;
use pipewire::{channel::Sender, spa::Direction};
use pipewire::channel::Sender;
use crate::{
view::{self},
GtkMessage, MediaType, NodeType, PipewireLink, PipewireMessage,
};
use crate::{graph_manager::GraphManager, ui, GtkMessage, PipewireMessage};
static STYLE: &str = include_str!("style.css");
@@ -39,8 +33,8 @@ mod imp {
#[derive(Default)]
pub struct Application {
pub(super) graphview: view::GraphView,
pub(super) pw_sender: OnceCell<RefCell<Sender<GtkMessage>>>,
pub(super) graphview: ui::graph::GraphView,
pub(super) graph_manager: OnceCell<GraphManager>,
}
#[glib::object_subclass]
@@ -54,11 +48,12 @@ mod imp {
impl ApplicationImpl for Application {
fn activate(&self) {
let app = &*self.obj();
let scrollwindow = gtk::ScrolledWindow::builder()
.child(&self.graphview)
.build();
let headerbar = gtk::HeaderBar::new();
let zoomentry = view::ZoomEntry::new(&self.graphview);
let zoomentry = ui::graph::ZoomEntry::new(&self.graphview);
headerbar.pack_end(&zoomentry);
let window = gtk::ApplicationWindow::builder()
@@ -92,7 +87,7 @@ mod imp {
// Load CSS from the STYLE variable.
let provider = gtk::CssProvider::new();
provider.load_from_data(STYLE);
gtk::StyleContext::add_provider_for_display(
gtk::style_context_add_provider_for_display(
&gtk::gdk::Display::default().expect("Error initializing gtk css provider."),
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
@@ -116,15 +111,14 @@ impl Application {
pw_sender: Sender<GtkMessage>,
) -> Self {
let app: Application = glib::Object::builder()
.property("application-id", &"org.pipewire.Helvum")
.property("application-id", "org.pipewire.Helvum")
.build();
let imp = app.imp();
imp.pw_sender
.set(RefCell::new(pw_sender))
// Discard the returned sender, as it does not implement `Debug`.
.map_err(|_| ())
.expect("pw_sender field was already set");
imp.graph_manager
.set(GraphManager::new(&imp.graphview, pw_sender, gtk_receiver))
.expect("Should be able to set graph manager");
// Add <Control-Q> shortcut for quitting the application.
let quit = gtk::gio::SimpleAction::new("quit", None);
@@ -134,138 +128,6 @@ impl Application {
app.set_accels_for_action("app.quit", &["<Control>Q"]);
app.add_action(&quit);
// React to messages received from the pipewire thread.
gtk_receiver.attach(
None,
clone!(
@weak app => @default-return Continue(true),
move |msg| {
match msg {
PipewireMessage::NodeAdded{ id, name, node_type } => app.add_node(id, name.as_str(), node_type),
PipewireMessage::PortAdded{ id, node_id, name, direction, media_type } => app.add_port(id, name.as_str(), node_id, direction, media_type),
PipewireMessage::LinkAdded{ id, node_from, port_from, node_to, port_to, active} => app.add_link(id, node_from, port_from, node_to, port_to, active),
PipewireMessage::LinkStateChanged { id, active } => app.link_state_changed(id, active), // TODO
PipewireMessage::NodeRemoved { id } => app.remove_node(id),
PipewireMessage::PortRemoved { id, node_id } => app.remove_port(id, node_id),
PipewireMessage::LinkRemoved { id } => app.remove_link(id)
};
Continue(true)
}
),
);
app
}
/// Add a new node to the view.
fn add_node(&self, id: u32, name: &str, node_type: Option<NodeType>) {
info!("Adding node to graph: id {}", id);
self.imp()
.graphview
.add_node(id, view::Node::new(name, id), node_type);
}
/// Add a new port to the view.
fn add_port(
&self,
id: u32,
name: &str,
node_id: u32,
direction: Direction,
media_type: Option<MediaType>,
) {
info!("Adding port to graph: id {}", id);
let port = view::Port::new(id, name, direction, media_type);
// Create or delete a link if the widget emits the "port-toggled" signal.
port.connect_local(
"port_toggled",
false,
clone!(@weak self as app => @default-return None, move |args| {
// Args always look like this: &[widget, id_port_from, id_port_to]
let port_from = args[1].get::<u32>().unwrap();
let port_to = args[2].get::<u32>().unwrap();
app.toggle_link(port_from, port_to);
None
}),
);
self.imp().graphview.add_port(node_id, id, port);
}
/// Add a new link to the view.
fn add_link(
&self,
id: u32,
node_from: u32,
port_from: u32,
node_to: u32,
port_to: u32,
active: bool,
) {
info!("Adding link to graph: id {}", id);
// FIXME: Links should be colored depending on the data they carry (video, audio, midi) like ports are.
// Update graph to contain the new link.
self.imp().graphview.add_link(
id,
PipewireLink {
node_from,
port_from,
node_to,
port_to,
},
active,
);
}
fn link_state_changed(&self, id: u32, active: bool) {
info!(
"Link state changed: Link (id={}) is now {}",
id,
if active { "active" } else { "inactive" }
);
self.imp().graphview.set_link_state(id, active);
}
// Toggle a link between the two specified ports on the remote pipewire server.
fn toggle_link(&self, port_from: u32, port_to: u32) {
let sender = self
.imp()
.pw_sender
.get()
.expect("pw_sender not set")
.borrow_mut();
sender
.send(GtkMessage::ToggleLink { port_from, port_to })
.expect("Failed to send message");
}
/// Remove the node with the specified id from the view.
fn remove_node(&self, id: u32) {
info!("Removing node from graph: id {}", id);
self.imp().graphview.remove_node(id);
}
/// Remove the port with the id `id` from the node with the id `node_id`
/// from the view.
fn remove_port(&self, id: u32, node_id: u32) {
info!("Removing port from graph: id {}, node_id: {}", id, node_id);
self.imp().graphview.remove_port(id, node_id);
}
/// Remove the link with the specified id from the view.
fn remove_link(&self, id: u32) {
info!("Removing link from graph: id {}", id);
self.imp().graphview.remove_link(id);
}
}

272
src/graph_manager.rs Normal file
View File

@@ -0,0 +1,272 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// 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 <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{glib, prelude::*, subclass::prelude::*};
use pipewire::channel::Sender as PwSender;
use crate::{ui::graph::GraphView, GtkMessage, PipewireMessage};
mod imp {
use super::*;
use std::{cell::RefCell, collections::HashMap};
use once_cell::unsync::OnceCell;
use crate::{ui::graph, MediaType, NodeType};
#[derive(Default, glib::Properties)]
#[properties(wrapper_type = super::GraphManager)]
pub struct GraphManager {
#[property(get, set, construct_only)]
pub graph: OnceCell<crate::ui::graph::GraphView>,
pub pw_sender: OnceCell<PwSender<crate::GtkMessage>>,
pub items: RefCell<HashMap<u32, glib::Object>>,
}
#[glib::object_subclass]
impl ObjectSubclass for GraphManager {
const NAME: &'static str = "HelvumGraphManager";
type Type = super::GraphManager;
type ParentType = glib::Object;
}
#[glib::derived_properties]
impl ObjectImpl for GraphManager {}
impl GraphManager {
pub fn attach_receiver(&self, receiver: glib::Receiver<crate::PipewireMessage>) {
receiver.attach(None, glib::clone!(
@weak self as imp => @default-return glib::ControlFlow::Continue,
move |msg| {
match msg {
PipewireMessage::NodeAdded{ id, name, node_type } => imp.add_node(id, name.as_str(), node_type),
PipewireMessage::PortAdded{ id, node_id, name, direction, media_type } => imp.add_port(id, name.as_str(), node_id, direction, media_type),
PipewireMessage::LinkAdded{ id, port_from, port_to, active} => imp.add_link(id, port_from, port_to, active),
PipewireMessage::LinkStateChanged { id, active } => imp.link_state_changed(id, active),
PipewireMessage::NodeRemoved { id } => imp.remove_node(id),
PipewireMessage::PortRemoved { id, node_id } => imp.remove_port(id, node_id),
PipewireMessage::LinkRemoved { id } => imp.remove_link(id)
};
glib::ControlFlow::Continue
}
));
}
/// Add a new node to the view.
fn add_node(&self, id: u32, name: &str, node_type: Option<NodeType>) {
log::info!("Adding node to graph: id {}", id);
let node = graph::Node::new(name, id);
self.items.borrow_mut().insert(id, node.clone().upcast());
self.obj().graph().add_node(node, node_type);
}
/// Remove the node with the specified id from the view.
fn remove_node(&self, id: u32) {
log::info!("Removing node from graph: id {}", id);
let Some(node) = self.items.borrow_mut().remove(&id) else {
log::warn!("Unknown node (id={id}) removed from graph");
return;
};
let Ok(node) = node.dynamic_cast::<graph::Node>() else {
log::warn!("Graph Manager item under node id {id} is not a node");
return;
};
self.obj().graph().remove_node(&node);
}
/// Add a new port to the view.
fn add_port(
&self,
id: u32,
name: &str,
node_id: u32,
direction: pipewire::spa::Direction,
media_type: Option<MediaType>,
) {
log::info!("Adding port to graph: id {}", id);
let mut items = self.items.borrow_mut();
let Some(node) = items.get(&node_id) else {
log::warn!("Node (id: {node_id}) for port (id: {id}) not found in graph manager");
return;
};
let Ok(node) = node.clone().dynamic_cast::<graph::Node>() else {
log::warn!("Graph Manager item under node id {node_id} is not a node");
return;
};
let port = graph::Port::new(id, name, direction, media_type);
// Create or delete a link if the widget emits the "port-toggled" signal.
port.connect_local(
"port_toggled",
false,
glib::clone!(@weak self as app => @default-return None, move |args| {
// Args always look like this: &[widget, id_port_from, id_port_to]
let port_from = args[1].get::<u32>().unwrap();
let port_to = args[2].get::<u32>().unwrap();
app.toggle_link(port_from, port_to);
None
}),
);
items.insert(id, port.clone().upcast());
node.add_port(port);
}
/// Remove the port with the id `id` from the node with the id `node_id`
/// from the view.
fn remove_port(&self, id: u32, node_id: u32) {
log::info!("Removing port from graph: id {}, node_id: {}", id, node_id);
let mut items = self.items.borrow_mut();
let Some(node) = items.get(&node_id) else {
log::warn!("Node (id: {node_id}) for port (id: {id}) not found in graph manager");
return;
};
let Ok(node) = node.clone().dynamic_cast::<graph::Node>() else {
log::warn!("Graph Manager item under node id {node_id} is not a node");
return;
};
let Some(port) = items.remove(&id) else {
log::warn!("Unknown Port (id: {id}) removed from graph");
return;
};
let Ok(port) = port.dynamic_cast::<graph::Port>() else {
log::warn!("Graph Manager item under port id {id} is not a port");
return;
};
node.remove_port(&port);
}
/// Add a new link to the view.
fn add_link(&self, id: u32, output_port_id: u32, input_port_id: u32, active: bool) {
log::info!("Adding link to graph: id {}", id);
let mut items = self.items.borrow_mut();
let Some(output_port) = items.get(&output_port_id) else {
log::warn!("Output port (id: {output_port_id}) for link (id: {id}) not found in graph manager");
return;
};
let Ok(output_port) = output_port.clone().dynamic_cast::<graph::Port>() else {
log::warn!("Graph Manager item under port id {output_port_id} is not a port");
return;
};
let Some(input_port) = items.get(&input_port_id) else {
log::warn!("Output port (id: {input_port_id}) for link (id: {id}) not found in graph manager");
return;
};
let Ok(input_port) = input_port.clone().dynamic_cast::<graph::Port>() else {
log::warn!("Graph Manager item under port id {input_port_id} is not a port");
return;
};
let link = graph::Link::new();
link.set_output_port(Some(&output_port));
link.set_input_port(Some(&input_port));
link.set_active(active);
items.insert(id, link.clone().upcast());
// Update graph to contain the new link.
self.graph
.get()
.expect("graph should be set")
.add_link(link);
}
fn link_state_changed(&self, id: u32, active: bool) {
log::info!(
"Link state changed: Link (id={id}) is now {}",
if active { "active" } else { "inactive" }
);
let items = self.items.borrow();
let Some(link) = items.get(&id) else {
log::warn!("Link state changed on unknown link (id={id})");
return;
};
let Some(link) = link.dynamic_cast_ref::<graph::Link>() else {
log::warn!("Graph Manager item under link id {id} is not a link");
return;
};
link.set_active(active);
}
// Toggle a link between the two specified ports on the remote pipewire server.
fn toggle_link(&self, port_from: u32, port_to: u32) {
let sender = self.pw_sender.get().expect("pw_sender shoud be set");
sender
.send(crate::GtkMessage::ToggleLink { port_from, port_to })
.expect("Failed to send message");
}
/// Remove the link with the specified id from the view.
fn remove_link(&self, id: u32) {
log::info!("Removing link from graph: id {}", id);
let Some(link) = self.items.borrow_mut().remove(&id) else {
log::warn!("Unknown Link (id={id}) removed from graph");
return;
};
let Ok(link) = link.dynamic_cast::<graph::Link>() else {
log::warn!("Graph Manager item under link id {id} is not a link");
return;
};
self.obj().graph().remove_link(&link);
}
}
}
glib::wrapper! {
pub struct GraphManager(ObjectSubclass<imp::GraphManager>);
}
impl GraphManager {
pub fn new(
graph: &GraphView,
sender: PwSender<GtkMessage>,
receiver: glib::Receiver<PipewireMessage>,
) -> Self {
let res: Self = glib::Object::builder().property("graph", graph).build();
res.imp().attach_receiver(receiver);
assert!(
res.imp().pw_sender.set(sender).is_ok(),
"Should be able to set pw_sender)"
);
res
}
}

View File

@@ -15,16 +15,16 @@
// SPDX-License-Identifier: GPL-3.0-only
mod application;
mod graph_manager;
mod pipewire_connection;
mod view;
mod ui;
use glib::PRIORITY_DEFAULT;
use gtk::prelude::*;
use pipewire::spa::Direction;
/// Messages sent by the GTK thread to notify the pipewire thread.
#[derive(Debug, Clone)]
enum GtkMessage {
pub enum GtkMessage {
/// Toggle a link between the two specified ports.
ToggleLink { port_from: u32, port_to: u32 },
/// Quit the event loop and let the thread finish.
@@ -33,7 +33,7 @@ enum GtkMessage {
/// Messages sent by the pipewire thread to notify the GTK thread.
#[derive(Debug, Clone)]
enum PipewireMessage {
pub enum PipewireMessage {
NodeAdded {
id: u32,
name: String,
@@ -48,9 +48,7 @@ enum PipewireMessage {
},
LinkAdded {
id: u32,
node_from: u32,
port_from: u32,
node_to: u32,
port_to: u32,
active: bool,
},
@@ -112,7 +110,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _guard = ctx.acquire().unwrap();
// Start the pipewire thread with channels in both directions.
let (gtk_sender, gtk_receiver) = glib::MainContext::channel(PRIORITY_DEFAULT);
let (gtk_sender, gtk_receiver) = glib::MainContext::channel(glib::Priority::DEFAULT);
let (pw_sender, pw_receiver) = pipewire::channel::channel();
let pw_thread =
std::thread::spawn(move || pipewire_connection::thread_main(gtk_sender, pw_receiver));

View File

@@ -243,9 +243,7 @@ fn handle_link(
// TODO -- check other values that might have changed
} else {
// First time we get info. We can now notify the gtk thread of a new link.
let node_from = info.output_node_id();
let port_from = info.output_port_id();
let node_to = info.input_node_id();
let port_to = info.input_port_id();
state.insert(id, Item::Link {
@@ -254,9 +252,7 @@ fn handle_link(
sender.send(PipewireMessage::LinkAdded {
id,
node_from,
port_from,
node_to,
port_to,
active: matches!(info.state(), LinkState::Active)
}).expect(

View File

@@ -14,20 +14,18 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use super::{Node, Port};
use gtk::{
cairo, gio,
glib::{self, clone},
graphene,
graphene::Point,
graphene::{self, Point},
gsk,
prelude::*,
subclass::prelude::*,
};
use log::{error, warn};
use std::{cmp::Ordering, collections::HashMap};
use std::cmp::Ordering;
use super::{Link, Node, Port};
use crate::NodeType;
const CANVAS_SIZE: f64 = 5000.0;
@@ -36,6 +34,7 @@ mod imp {
use super::*;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use gtk::{
gdk::{self, RGBA},
@@ -44,6 +43,7 @@ mod imp {
};
use log::warn;
use once_cell::sync::Lazy;
use pipewire::spa::Direction;
pub struct DragState {
node: glib::WeakRef<Node>,
@@ -54,22 +54,46 @@ mod imp {
offset: Point,
}
#[derive(Default)]
pub struct GraphView {
/// Stores nodes and their positions.
pub(super) nodes: RefCell<HashMap<u32, (Node, Point)>>,
/// Stores the link and whether it is currently active.
pub(super) links: RefCell<HashMap<u32, (crate::PipewireLink, bool)>>,
pub(super) nodes: RefCell<HashMap<Node, Point>>,
/// Stores the links and whether they are currently active.
pub(super) links: RefCell<HashSet<Link>>,
// Properties for zooming and scrolling the hraph
pub hadjustment: RefCell<Option<gtk::Adjustment>>,
pub vadjustment: RefCell<Option<gtk::Adjustment>>,
pub zoom_factor: Cell<f64>,
/// This keeps track of an ongoing node drag operation.
pub dragged_node: RefCell<Option<DragState>>,
// These keep track of an ongoing port drag operation
pub dragged_port: glib::WeakRef<Port>,
pub port_drag_cursor: Cell<Point>,
// Memorized data for an in-progress zoom gesture
pub zoom_gesture_initial_zoom: Cell<Option<f64>>,
pub zoom_gesture_anchor: Cell<Option<(f64, f64)>>,
}
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 = "GraphView";
@@ -89,6 +113,7 @@ mod imp {
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();
}
@@ -96,7 +121,7 @@ mod imp {
fn dispose(&self) {
self.nodes
.borrow()
.values()
.iter()
.for_each(|(node, _)| node.unparent())
}
@@ -153,9 +178,7 @@ mod imp {
fn size_allocate(&self, _width: i32, _height: i32, baseline: i32) {
let widget = &*self.obj();
let zoom_factor = self.zoom_factor.get();
for (node, point) in self.nodes.borrow().values() {
for (node, point) in self.nodes.borrow().iter() {
let (_, natural_size) = node.preferred_size();
let transform = self
@@ -163,8 +186,8 @@ mod imp {
.translate(point);
node.allocate(
(natural_size.width() as f64 / zoom_factor).ceil() as i32,
(natural_size.height() as f64 / zoom_factor).ceil() as i32,
natural_size.width(),
natural_size.height(),
baseline,
Some(transform),
);
@@ -187,7 +210,7 @@ mod imp {
// Draw all visible children
self.nodes
.borrow()
.values()
.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));
@@ -292,6 +315,78 @@ mod imp {
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::<super::GraphView>()
.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::<super::GraphView>()
.expect("Widget should be a graphview");
graph.imp().port_drag_motion(x, y)
});
controller.connect_leave(|controller| {
let graph = controller
.widget()
.downcast::<super::GraphView>()
.expect("Widget should be a graphview");
graph.imp().port_drag_leave()
});
self.obj().add_controller(controller);
}
fn port_drag_enter(&self, controller: &gtk::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
@@ -312,9 +407,9 @@ mod imp {
.unwrap();
widget.set_zoom_factor(widget.zoom_factor() + (0.1 * -delta_y), None);
gtk::Inhibit(true)
glib::Propagation::Stop
} else {
gtk::Inhibit(false)
glib::Propagation::Proceed
}
});
self.obj().add_controller(scroll_controller);
@@ -409,6 +504,84 @@ mod imp {
snapshot.pop();
}
fn draw_link(
&self,
link_cr: &cairo::Context,
output_anchor: &Point,
input_anchor: &Point,
active: bool,
) {
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);
}
// 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) {
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::<Port>())
.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 port.direction() {
Direction::Output => (&port_anchor, &other_anchor),
Direction::Input => (&other_anchor, &port_anchor),
_ => unreachable!(),
};
self.draw_link(link_cr, output_anchor, input_anchor, false);
}
fn snapshot_links(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
let alloc = widget.allocation();
@@ -433,80 +606,38 @@ mod imp {
rgba.alpha().into(),
);
for (link, active) in self.links.borrow().values() {
for link in self.links.borrow().iter() {
// TODO: Do not draw links when they are outside the view
if let Some((from_x, from_y, to_x, to_y)) = self.get_link_coordinates(link) {
link_cr.move_to(from_x, from_y);
// 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);
}
// 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 from_x > to_x {
f64::max(0.0, 25.0 - (from_y - to_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(from_x - to_x) / 2.0;
link_cr.curve_to(
from_x + half_x_dist,
from_y - y_control_offset,
to_x - half_x_dist,
to_y - y_control_offset,
to_x,
to_y,
);
if let Err(e) = link_cr.stroke() {
warn!("Failed to draw graphview links: {}", e);
};
} else {
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());
}
if let Some(port) = self.dragged_port.upgrade() {
self.draw_dragged_link(&port, &link_cr);
}
}
/// Get coordinates for the drawn link to start at and to end at.
///
/// # Returns
/// `Some((from_x, from_y, to_x, to_y))` if all objects the links refers to exist as widgets.
fn get_link_coordinates(&self, link: &crate::PipewireLink) -> Option<(f64, f64, f64, f64)> {
/// `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 nodes = self.nodes.borrow();
let output_port = &nodes.get(&link.node_from)?.0.get_port(link.port_from)?;
let output_port = link.output_port()?;
let output_anchor = output_port.compute_point(widget, &output_port.link_anchor())?;
let output_port_padding =
(output_port.allocated_width() - output_port.width()) as f64 / 2.0;
let input_port = link.input_port()?;
let input_anchor = input_port.compute_point(widget, &input_port.link_anchor())?;
let (from_x, from_y) = output_port.translate_coordinates(
widget,
output_port.width() as f64 + output_port_padding,
(output_port.height() / 2) as f64,
)?;
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(
widget,
-input_port_padding,
(input_port.height() / 2) as f64,
)?;
Some((from_x, from_y, to_x, to_y))
Some((output_anchor, input_anchor))
}
fn set_adjustment(
@@ -609,7 +740,7 @@ impl GraphView {
self.set_property("zoom-factor", zoom_factor);
}
pub fn add_node(&self, id: u32, node: Node, node_type: Option<NodeType>) {
pub fn add_node(&self, node: Node, node_type: Option<NodeType>) {
let imp = self.imp();
node.set_parent(self);
@@ -626,7 +757,7 @@ impl GraphView {
let y = imp
.nodes
.borrow()
.values()
.iter()
.map(|node| {
// Map nodes to their locations
let point = self.node_position(&node.0.clone().upcast()).unwrap();
@@ -642,56 +773,33 @@ impl GraphView {
})
.map_or(20_f32, |(_x, y)| y + 120.0);
imp.nodes.borrow_mut().insert(id, (node, Point::new(x, y)));
imp.nodes.borrow_mut().insert(node, Point::new(x, y));
}
pub fn remove_node(&self, id: u32) {
pub fn remove_node(&self, node: &Node) {
let mut nodes = self.imp().nodes.borrow_mut();
if let Some((node, _)) = nodes.remove(&id) {
if nodes.remove(node).is_some() {
node.unparent();
} else {
warn!("Tried to remove non-existant node (id={}) from graph", id);
log::warn!("Tried to remove non-existant node widget from graph");
}
}
pub fn add_port(&self, node_id: u32, port_id: u32, port: crate::view::port::Port) {
if let Some((node, _)) = self.imp().nodes.borrow_mut().get_mut(&node_id) {
node.add_port(port_id, port);
} else {
error!(
"Node with id {} not found when trying to add port with id {} to graph",
node_id, port_id
pub fn add_link(&self, link: Link) {
link.connect_notify_local(
Some("active"),
glib::clone!(@weak self as graph => move |_, _| {
graph.queue_draw();
}),
);
}
}
pub fn remove_port(&self, id: u32, node_id: u32) {
let nodes = self.imp().nodes.borrow();
if let Some((node, _)) = nodes.get(&node_id) {
node.remove_port(id);
}
}
pub fn add_link(&self, link_id: u32, link: crate::PipewireLink, active: bool) {
self.imp()
.links
.borrow_mut()
.insert(link_id, (link, active));
self.imp().links.borrow_mut().insert(link);
self.queue_draw();
}
pub fn set_link_state(&self, link_id: u32, active: bool) {
if let Some((_, state)) = self.imp().links.borrow_mut().get_mut(&link_id) {
*state = active;
self.queue_draw();
} else {
warn!("Link state changed on unknown link (id={})", link_id);
}
}
pub fn remove_link(&self, id: u32) {
pub fn remove_link(&self, link: &Link) {
let mut links = self.imp().links.borrow_mut();
links.remove(&id);
links.remove(link);
self.queue_draw();
}
@@ -700,30 +808,22 @@ impl 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<Point> {
self.imp()
.nodes
.borrow()
.get(&node.pipewire_id())
.map(|(_, point)| *point)
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 mut node = nodes
.get_mut(&widget.pipewire_id())
.expect("Node is not on the graph");
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.1 = Point::new(
point.x().clamp(
node_point.set_x(point.x().clamp(
-(CANVAS_SIZE / 2.0) as f32,
(CANVAS_SIZE / 2.0) as f32 - widget.width() as f32,
),
point.y().clamp(
));
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();
}

120
src/ui/graph/link.rs Normal file
View File

@@ -0,0 +1,120 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// 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 <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{glib, prelude::*, subclass::prelude::*};
use super::Port;
mod imp {
use super::*;
use std::cell::Cell;
use once_cell::sync::Lazy;
#[derive(Default)]
pub struct Link {
pub output_port: glib::WeakRef<Port>,
pub input_port: glib::WeakRef<Port>,
pub active: Cell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for Link {
const NAME: &'static str = "HelvumLink";
type Type = super::Link;
type ParentType = glib::Object;
}
impl ObjectImpl for Link {
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecObject::builder::<Port>("output-port")
.flags(glib::ParamFlags::READWRITE)
.build(),
glib::ParamSpecObject::builder::<Port>("input-port")
.flags(glib::ParamFlags::READWRITE)
.build(),
glib::ParamSpecBoolean::builder("active")
.default_value(false)
.flags(glib::ParamFlags::READWRITE)
.build(),
]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"output-port" => self.output_port.upgrade().to_value(),
"input-port" => self.input_port.upgrade().to_value(),
"active" => self.active.get().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"output-port" => self.output_port.set(value.get().unwrap()),
"input-port" => self.input_port.set(value.get().unwrap()),
"active" => self.active.set(value.get().unwrap()),
_ => unimplemented!(),
}
}
}
}
glib::wrapper! {
pub struct Link(ObjectSubclass<imp::Link>);
}
impl Link {
pub fn new() -> Self {
glib::Object::new()
}
pub fn output_port(&self) -> Option<Port> {
self.property("output-port")
}
pub fn set_output_port(&self, port: Option<&Port>) {
self.set_property("output-port", port);
}
pub fn input_port(&self) -> Option<Port> {
self.property("input-port")
}
pub fn set_input_port(&self, port: Option<&Port>) {
self.set_property("input-port", port);
}
pub fn active(&self) -> bool {
self.property("active")
}
pub fn set_active(&self, active: bool) {
self.set_property("active", active);
}
}
impl Default for Link {
fn default() -> Self {
Self::new()
}
}

26
src/ui/graph/mod.rs Normal file
View File

@@ -0,0 +1,26 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// 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 <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
mod graph_view;
pub use graph_view::*;
mod node;
pub use node::*;
mod port;
pub use port::*;
mod link;
pub use link::*;
mod zoomentry;
pub use zoomentry::*;

View File

@@ -17,21 +17,32 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
use pipewire::spa::Direction;
use std::collections::HashMap;
use super::Port;
mod imp {
use glib::ParamFlags;
use once_cell::sync::Lazy;
use super::*;
use std::cell::{Cell, RefCell};
use std::{
cell::{Cell, RefCell},
collections::HashSet,
};
#[derive(glib::Properties)]
#[properties(wrapper_type = super::Node)]
pub struct Node {
#[property(get, set, construct_only)]
pub(super) pipewire_id: Cell<u32>,
pub(super) grid: gtk::Grid,
#[property(
name = "name", type = String,
get = |this: &Self| this.label.text().to_string(),
set = |this: &Self, val| {
this.label.set_text(val);
this.label.set_tooltip_text(Some(val));
}
)]
pub(super) label: gtk::Label,
pub(super) ports: RefCell<HashMap<u32, crate::view::port::Port>>,
pub(super) ports: RefCell<HashSet<Port>>,
pub(super) num_ports_in: Cell<i32>,
pub(super) num_ports_out: Cell<i32>,
}
@@ -64,51 +75,20 @@ mod imp {
pipewire_id: Cell::new(0),
grid,
label,
ports: RefCell::new(HashMap::new()),
ports: RefCell::new(HashSet::new()),
num_ports_in: Cell::new(0),
num_ports_out: Cell::new(0),
}
}
}
#[glib::derived_properties]
impl ObjectImpl for Node {
fn constructed(&self) {
self.parent_constructed();
self.grid.set_parent(&*self.obj());
}
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecUInt::builder("pipewire-id")
.flags(ParamFlags::READWRITE | ParamFlags::CONSTRUCT_ONLY)
.build(),
glib::ParamSpecString::builder("name").build(),
]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"pipewire-id" => self.pipewire_id.get().to_value(),
"name" => self.label.text().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"name" => {
self.label.set_text(value.get().unwrap());
self.label.set_tooltip_text(value.get().ok());
}
"pipewire-id" => self.pipewire_id.set(value.get().unwrap()),
_ => unimplemented!(),
}
}
fn dispose(&self) {
self.grid.unparent();
}
@@ -125,26 +105,12 @@ glib::wrapper! {
impl Node {
pub fn new(name: &str, pipewire_id: u32) -> Self {
glib::Object::builder()
.property("name", &name)
.property("pipewire-id", &pipewire_id)
.property("name", name)
.property("pipewire-id", pipewire_id)
.build()
}
pub fn pipewire_id(&self) -> u32 {
self.property("pipewire-id")
}
/// Get the nodes `name` property, which represents the displayed name.
pub fn name(&self) -> String {
self.property("name")
}
/// Set the nodes `name` property, which represents the displayed name.
pub fn set_name(&self, name: &str) {
self.set_property("name", name);
}
pub fn add_port(&mut self, id: u32, port: super::port::Port) {
pub fn add_port(&self, port: Port) {
let imp = self.imp();
match port.direction() {
@@ -156,24 +122,24 @@ impl Node {
imp.grid.attach(&port, 1, imp.num_ports_out.get() + 1, 1, 1);
imp.num_ports_out.set(imp.num_ports_out.get() + 1);
}
_ => unreachable!(),
}
imp.ports.borrow_mut().insert(id, port);
imp.ports.borrow_mut().insert(port);
}
pub fn get_port(&self, id: u32) -> Option<super::port::Port> {
self.imp().ports.borrow_mut().get(&id).cloned()
}
pub fn remove_port(&self, id: u32) {
pub fn remove_port(&self, port: &Port) {
let imp = self.imp();
if let Some(port) = imp.ports.borrow_mut().remove(&id) {
if imp.ports.borrow_mut().remove(port) {
match port.direction() {
Direction::Input => imp.num_ports_in.set(imp.num_ports_in.get() - 1),
Direction::Output => imp.num_ports_in.set(imp.num_ports_out.get() - 1),
_ => unreachable!(),
}
port.unparent();
} else {
log::warn!("Tried to remove non-existant port widget from node");
}
}
}

250
src/ui/graph/port.rs Normal file
View File

@@ -0,0 +1,250 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// 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 <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{
gdk,
glib::{self, subclass::Signal},
graphene,
prelude::*,
subclass::prelude::*,
};
use pipewire::spa::Direction;
use crate::MediaType;
mod imp {
use super::*;
use once_cell::{sync::Lazy, unsync::OnceCell};
use pipewire::spa::Direction;
/// Graphical representation of a pipewire port.
#[derive(Default, glib::Properties)]
#[properties(wrapper_type = super::Port)]
pub struct Port {
#[property(get, set, construct_only)]
pub(super) pipewire_id: OnceCell<u32>,
#[property(
name = "name", type = String,
get = |this: &Self| this.label.text().to_string(),
set = |this: &Self, val| {
this.label.set_text(val);
this.label.set_tooltip_text(Some(val));
}
)]
pub(super) label: gtk::Label,
pub(super) direction: OnceCell<Direction>,
}
#[glib::object_subclass]
impl ObjectSubclass for Port {
const NAME: &'static str = "HelvumPort";
type Type = super::Port;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BinLayout>();
// Make it look like a GTK button.
klass.set_css_name("button");
}
}
#[glib::derived_properties]
impl ObjectImpl for Port {
fn constructed(&self) {
self.parent_constructed();
self.label.set_parent(&*self.obj());
self.label.set_wrap(true);
self.label.set_lines(2);
self.label.set_max_width_chars(20);
self.label.set_ellipsize(gtk::pango::EllipsizeMode::End);
self.setup_port_drag_and_drop();
}
fn dispose(&self) {
self.label.unparent()
}
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
vec![Signal::builder("port-toggled")
// Provide id of output port and input port to signal handler.
.param_types([<u32>::static_type(), <u32>::static_type()])
.build()]
});
SIGNALS.as_ref()
}
}
impl WidgetImpl for Port {}
impl Port {
fn setup_port_drag_and_drop(&self) {
let obj = &*self.obj();
// Add a drag source and drop target controller with the type depending on direction,
// they will be responsible for link creation by dragging an output port onto an input port or the other way around.
// The port will simply provide its pipewire id to the drag target.
// The drop target will accept the source port and use it to emit its `port-toggled` signal.
// FIXME: We should protect against different media types, e.g. it should not be possible to drop a video port on an audio port.
let drag_src = gtk::DragSource::builder()
.content(&gdk::ContentProvider::for_value(&obj.to_value()))
.build();
// Override the default drag icon with an empty one so that only a grab cursor is shown.
// The graph will render a link from the source port to the cursor to visualize the drag instead.
drag_src.set_icon(Some(&gdk::Paintable::new_empty(0, 0)), 0, 0);
drag_src.connect_drag_begin(|drag_source, _| {
let port = drag_source
.widget()
.dynamic_cast::<super::Port>()
.expect("Widget should be a Port");
log::trace!("Drag started from port {}", port.pipewire_id());
});
drag_src.connect_drag_cancel(|drag_source, _, _| {
let port = drag_source
.widget()
.dynamic_cast::<super::Port>()
.expect("Widget should be a Port");
log::trace!("Drag from port {} was cancelled", port.pipewire_id());
false
});
obj.add_controller(drag_src);
let drop_target =
gtk::DropTarget::new(super::Port::static_type(), gdk::DragAction::COPY);
drop_target.set_preload(true);
drop_target.connect_value_notify(|drop_target| {
let port = drop_target
.widget()
.dynamic_cast::<super::Port>()
.expect("Widget should be a Port");
let Some(value) = drop_target.value() else {
return;
};
let other_port: super::Port = value.get().expect("Drop value should be a port");
// Disallow drags between two ports that have the same direction
if !port.is_linkable_to(&other_port) {
// FIXME: For some reason, this prints error:
// "gdk_drop_get_actions: assertion 'GDK_IS_DROP (self)' failed"
drop_target.reject();
}
});
drop_target.connect_drop(|drop_target, val, _, _| {
let port = drop_target
.widget()
.dynamic_cast::<super::Port>()
.expect("Widget should be a Port");
let other_port = val
.get::<super::Port>()
.expect("Dropped value should be a Port");
// Do not accept a drop between imcompatible ports
if !port.is_linkable_to(&other_port) {
log::warn!("Tried to link incompatible ports");
return false;
}
let (output_port, input_port) = match port.direction() {
Direction::Output => (&port, &other_port),
Direction::Input => (&other_port, &port),
_ => unreachable!(),
};
port.emit_by_name::<()>(
"port-toggled",
&[&output_port.pipewire_id(), &input_port.pipewire_id()],
);
true
});
obj.add_controller(drop_target);
}
}
}
glib::wrapper! {
pub struct Port(ObjectSubclass<imp::Port>)
@extends gtk::Widget;
}
impl Port {
pub fn new(id: u32, name: &str, direction: Direction, media_type: Option<MediaType>) -> Self {
// Create the widget and initialize needed fields
let res: Self = glib::Object::builder()
.property("pipewire-id", id)
.property("name", name)
.build();
let imp = res.imp();
imp.direction
.set(direction)
.expect("Port direction already set");
// Display a grab cursor when the mouse is over the port so the user knows it can be dragged to another port.
res.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
// Color the port according to its media type.
match media_type {
Some(MediaType::Video) => res.add_css_class("video"),
Some(MediaType::Audio) => res.add_css_class("audio"),
Some(MediaType::Midi) => res.add_css_class("midi"),
None => {}
}
res
}
pub fn direction(&self) -> Direction {
*self
.imp()
.direction
.get()
.expect("Port direction is not set")
}
pub fn link_anchor(&self) -> graphene::Point {
let style_context = self.style_context();
let padding_right: f32 = style_context.padding().right().into();
let border_right: f32 = style_context.border().right().into();
let padding_left: f32 = style_context.padding().left().into();
let border_left: f32 = style_context.border().left().into();
graphene::Point::new(
match self.direction() {
Direction::Output => self.width() as f32 + padding_right + border_right,
Direction::Input => 0.0 - padding_left - border_left,
_ => unreachable!(),
},
self.height() as f32 / 2.0,
)
}
pub fn is_linkable_to(&self, other_port: &Self) -> bool {
self.direction() != other_port.direction()
}
}

View File

@@ -1,6 +1,6 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
use crate::view;
use super::GraphView;
mod imp {
use std::cell::RefCell;
@@ -13,7 +13,7 @@ mod imp {
#[derive(gtk::CompositeTemplate)]
#[template(file = "zoomentry.ui")]
pub struct ZoomEntry {
pub graphview: RefCell<Option<view::GraphView>>,
pub graphview: RefCell<Option<GraphView>>,
#[template_child]
pub zoom_out_button: TemplateChild<gtk::Button>,
#[template_child]
@@ -109,11 +109,9 @@ mod imp {
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecObject::builder::<view::GraphView>("zoomed-widget")
vec![glib::ParamSpecObject::builder::<GraphView>("zoomed-widget")
.flags(glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT)
.build(),
]
.build()]
});
PROPERTIES.as_ref()
@@ -129,7 +127,7 @@ mod imp {
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"zoomed-widget" => {
let widget: view::GraphView = value.get().unwrap();
let widget: GraphView = value.get().unwrap();
widget.connect_notify_local(
Some("zoom-factor"),
clone!(@weak self as imp => move |graphview, _| {
@@ -149,11 +147,11 @@ mod imp {
impl ZoomEntry {
/// Update the text contained in the combobox's entry to reflect the provided zoom factor.
///
/// This does not update the associated [`view::GraphView`]s zoom level.
/// This does not update the associated [`GraphView`]s zoom level.
fn update_zoom_factor_text(&self, zoom_factor: f64) {
self.entry
.buffer()
.set_text(&format!("{factor:.0}%", factor = zoom_factor * 100.));
.set_text(format!("{factor:.0}%", factor = zoom_factor * 100.));
}
}
}
@@ -164,7 +162,7 @@ glib::wrapper! {
}
impl ZoomEntry {
pub fn new(zoomed_widget: &view::GraphView) -> Self {
pub fn new(zoomed_widget: &GraphView) -> Self {
glib::Object::builder()
.property("zoomed-widget", zoomed_widget)
.build()

View File

@@ -18,12 +18,4 @@
//!
//! This module contains gtk widgets needed to present the graphical user interface.
mod graph_view;
mod node;
mod port;
mod zoomentry;
pub use graph_view::GraphView;
pub use node::Node;
pub use port::Port;
pub use zoomentry::ZoomEntry;
pub mod graph;

View File

@@ -1,249 +0,0 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// 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 <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{
gdk,
glib::{self, clone, subclass::Signal},
prelude::*,
subclass::prelude::*,
};
use log::{trace, warn};
use pipewire::spa::Direction;
use crate::MediaType;
/// A helper struct for linking a output port to an input port.
/// It carries the output ports id.
#[derive(Clone, Debug, glib::Boxed)]
#[boxed_type(name = "HelvumForwardLink")]
struct ForwardLink(u32);
/// A helper struct for linking an input to an output port.
/// It carries the input ports id.
#[derive(Clone, Debug, glib::Boxed)]
#[boxed_type(name = "HelvumReversedLink")]
struct ReversedLink(u32);
mod imp {
use glib::ParamFlags;
use once_cell::{sync::Lazy, unsync::OnceCell};
use pipewire::spa::Direction;
use super::*;
/// Graphical representation of a pipewire port.
#[derive(Default)]
pub struct Port {
pub(super) pipewire_id: OnceCell<u32>,
pub(super) label: gtk::Label,
pub(super) direction: OnceCell<Direction>,
}
#[glib::object_subclass]
impl ObjectSubclass for Port {
const NAME: &'static str = "HelvumPort";
type Type = super::Port;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BinLayout>();
// Make it look like a GTK button.
klass.set_css_name("button");
}
}
impl ObjectImpl for Port {
fn constructed(&self) {
self.parent_constructed();
self.label.set_parent(&*self.obj());
self.label.set_wrap(true);
self.label.set_lines(2);
self.label.set_max_width_chars(20);
self.label.set_ellipsize(gtk::pango::EllipsizeMode::End);
}
fn dispose(&self) {
self.label.unparent()
}
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecUInt::builder("pipewire-id")
.flags(ParamFlags::READWRITE | ParamFlags::CONSTRUCT_ONLY)
.build(),
glib::ParamSpecString::builder("name").build(),
]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"pipewire-id" => self.pipewire_id.get().unwrap().to_value(),
"name" => self.label.text().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"name" => {
self.label.set_text(value.get().unwrap());
self.label.set_tooltip_text(value.get().ok());
}
"pipewire-id" => self.pipewire_id.set(value.get().unwrap()).unwrap(),
_ => unimplemented!(),
}
}
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
vec![Signal::builder("port-toggled")
// Provide id of output port and input port to signal handler.
.param_types([<u32>::static_type(), <u32>::static_type()])
.build()]
});
SIGNALS.as_ref()
}
}
impl WidgetImpl for Port {}
}
glib::wrapper! {
pub struct Port(ObjectSubclass<imp::Port>)
@extends gtk::Widget;
}
impl Port {
pub fn new(id: u32, name: &str, direction: Direction, media_type: Option<MediaType>) -> Self {
// Create the widget and initialize needed fields
let res: Self = glib::Object::builder()
.property("pipewire-id", &id)
.property("name", &name)
.build();
let imp = res.imp();
imp.direction
.set(direction)
.expect("Port direction already set");
// Add a drag source and drop target controller with the type depending on direction,
// they will be responsible for link creation by dragging an output port onto an input port or the other way around.
// FIXME: We should protect against different media types, e.g. it should not be possible to drop a video port on an audio port.
// The port will simply provide its pipewire id to the drag target.
let drag_src = gtk::DragSource::builder()
.content(&gdk::ContentProvider::for_value(&match direction {
Direction::Input => ReversedLink(id).to_value(),
Direction::Output => ForwardLink(id).to_value(),
}))
.build();
drag_src.connect_drag_begin(move |_, _| {
trace!("Drag started from port {}", id);
});
drag_src.connect_drag_cancel(move |_, _, _| {
trace!("Drag from port {} was cancelled", id);
false
});
res.add_controller(drag_src);
// The drop target will accept either a `ForwardLink` or `ReversedLink` depending in its own direction,
// and use it to emit its `port-toggled` signal.
let drop_target = gtk::DropTarget::new(
match direction {
Direction::Input => ForwardLink::static_type(),
Direction::Output => ReversedLink::static_type(),
},
gdk::DragAction::COPY,
);
match direction {
Direction::Input => {
drop_target.connect_drop(
clone!(@weak res as this => @default-panic, move |drop_target, val, _, _| {
if let Ok(ForwardLink(source_id)) = val.get::<ForwardLink>() {
// Get the callback registered in the widget and call it
drop_target
.widget()
.emit_by_name::<()>("port-toggled", &[&source_id, &this.pipewire_id()]);
} else {
warn!("Invalid type dropped on ingoing port");
}
true
}),
);
}
Direction::Output => {
drop_target.connect_drop(
clone!(@weak res as this => @default-panic, move |drop_target, val, _, _| {
if let Ok(ReversedLink(target_id)) = val.get::<ReversedLink>() {
// Get the callback registered in the widget and call it
drop_target
.widget()
.emit_by_name::<()>("port-toggled", &[&this.pipewire_id(), &target_id]);
} else {
warn!("Invalid type dropped on outgoing port");
}
true
}),
);
}
}
res.add_controller(drop_target);
// Display a grab cursor when the mouse is over the port so the user knows it can be dragged to another port.
res.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
// Color the port according to its media type.
match media_type {
Some(MediaType::Video) => res.add_css_class("video"),
Some(MediaType::Audio) => res.add_css_class("audio"),
Some(MediaType::Midi) => res.add_css_class("midi"),
None => {}
}
res
}
pub fn pipewire_id(&self) -> u32 {
self.property("pipewire-id")
}
/// Get the nodes `name` property, which represents the displayed name.
pub fn name(&self) -> String {
self.property("name")
}
/// Set the nodes `name` property, which represents the displayed name.
pub fn set_name(&self, name: &str) {
self.set_property("name", name);
}
pub fn direction(&self) -> &Direction {
self.imp()
.direction
.get()
.expect("Port direction is not set")
}
}