53 Commits

Author SHA1 Message Date
Tom Wagner
d7dd6033a6 graphview: Draw links using new gdk path API instead of cairo 2024-03-25 13:55:49 +01:00
Tom Wagner
f32559511d Update to latest gtk-rs crates 2024-03-25 10:08:38 +01:00
Tom Wagner
57cba6381b flatpak: Update runtime to gnome 46 2024-03-25 09:49:16 +01:00
Dorinda Bassey
d1b9b0f11f update Helvum to track the latest pipewire0.8.0
update Helvum to track the latest pipewire0.8.0
2024-03-19 14:06:57 +01:00
Tom A. Wagner
4549ba6ff5 ui: Force LTR direction on the nodes port grid and on ports
Forces the direction on the nodes internal port grid and on the ports themselves
to be always left-to-right (LTR), to avoid the UI becoming messed up when defaulting
to right-to-left (RTL).

Previously, when using RTL, the side of input and output ports would be swapped,
causing the port handles to be inside the node instead of at the edge.
2023-12-09 16:01:11 +01:00
Denis Drakhnia
e78d6f5fb4 ui: Move view with middle mouse button. 2023-11-18 14:19:20 +02:00
Denis Drakhnia
96c079d29e ui: Display node media name in graph view 2023-10-13 11:40:21 +00:00
Tom A. Wagner
5d4931b418 pw: Set media.category property to manager
This will make the session manager give Helvum full permissions even when
used from flatpak or otherwise restricted, so that we can always change
the graph even if permissions become more restricted in the future.
2023-10-12 08:36:08 +00:00
Tom A. Wagner
b983ade736 ui: Move "disconnected" banner from headerbar into content
The change to AdwToolbarView put the disconnected banner into the toolbar, resulting in a weird-looking
separator when the bar is shown.

This moves the banner into the "content" widget of the AdwToolbarView to fix that issue.
2023-10-11 22:48:37 +02:00
Angelo Verlain
94d5e95695 use AdwToolbarView 2023-10-11 22:48:37 +02:00
Angelo Verlain Shema
e1f63ddd28 Use responsive design 2023-10-10 18:16:23 +00:00
Angelo Verlain
903df21ba3 attach about window 2023-10-06 17:46:59 +02:00
Tom A. Wagner
39437eaf29 Release v0.5.1 2023-09-28 14:13:00 +02:00
Tom A. Wagner
a1a4594a25 ui: Fix headerbar becoming too large
Removes the .toolbar class from a box in the headerbar and instead sets spacing property.

The .toolbar class added extra vertical padding, so the headerbar had to increase its size
2023-09-28 14:05:48 +02:00
Tom A. Wagner
3dd4623ab9 Release v0.5.0 2023-09-28 13:38:07 +02:00
Denis Drakhnia
20f64595ac pipewire connection: Show banner if disconnected 2023-09-28 12:25:04 +03:00
Tom Wagner
7d6aae70c5 pipewire connection: restart the outer loop 2023-09-27 14:08:10 +03:00
Denis Drakhnia
94323510aa pipewire connection: Reconnection to PipeWire server 2023-09-27 10:54:32 +00:00
Tom A. Wagner
f0da839383 ci: Update gnome runtime to gnome 45 2023-09-26 18:28:02 +00:00
Tom A. Wagner
7e29462b6f flatpak: Update to gnome 45 2023-09-26 18:28:02 +00:00
Tom A. Wagner
bc006fe393 ui: Add "About" window to display version, authors, license, etc.
This adds a new adw::AboutWindow containing information about version, authors, license, links etc.
It is opened via a new menu button in the toolbar, which opens a menu containing an "About Helvum" button.

The version and authors are pulled from the Cargo.toml file.
2023-09-19 16:12:29 +02:00
Tom A. Wagner
e92c77f2b1 Add flatpak builder folders to .gitignore 2023-09-19 15:30:58 +02:00
Tom A. Wagner
b9929ba776 Run rustfmt 2023-09-19 08:27:11 +02:00
Tom A. Wagner
1d51b12061 ci: Update gnome runtime image 2023-09-18 19:21:48 +02:00
Tom A. Wagner
1db39fb71f css: Add more padding to node and port names, remove column spacing in ports grid 2023-09-18 18:02:00 +02:00
Tom A. Wagner
89f417f260 node: Remove transparency in dark mode, improve contrast to graphview in light mode 2023-08-28 20:58:54 +02:00
Tom A. Wagner
2343ef824e application: Set PreferDark color scheme
Run the app in dark mode by default, unless the user explicitely wants light mode.
2023-08-28 20:58:54 +02:00
Tom A. Wagner
24724b330f readme: Update requirements section to reflect port to libadwaita 2023-08-28 20:58:54 +02:00
Tom A. Wagner
1707e84c4c readme: Update Screenshot to reflect libadwaita port and redesign 2023-08-28 20:58:54 +02:00
Tom A. Wagner
8aade39aeb css: Improve padding of node title and ports 2023-08-28 20:58:54 +02:00
Tom A. Wagner
0cb40f5cab Node: Rework adding of ports, sort ports and hide seperator when node has no ports
This reworks the adding of a port to nodes, to avoid assigning multiple nodes
to the same grid cell when a node which was not the last in its column has previously
been removed. Instead, the grid is emptied and repopulated each time.

This also lets us sort the nodes each time by name.

Finally, this hides the seperator if a node has no nodes, as it is unneeded.
2023-08-28 20:58:54 +02:00
Tom A. Wagner
4ed7e1f4be graph: Redesign nodes and ports
Nodes now have a background using the libadwaita .card style class.

Ports now have a circular handle, which is positioned on the edge of the node so that half of the circle sticks out.
Ports are also no longer themed like a button and don't receive a color based on the guessed media type, in a future commit,
the handle will be colored instead.
2023-08-28 20:58:54 +02:00
Tom A. Wagner
af4051c3c2 ui: Port to libadwaita
This ports the application to libadwaita, enabling us to use the libadwaita stylesheet and
widgets to better implement the Gnome Human Interface Guidelines.
2023-08-28 19:47:37 +02:00
Tom A. Wagner
6fd3691733 ci: Rework CI to run in flatpak 2023-08-21 14:16:46 +02:00
Tom A. Wagner
189288bb56 deps: Update to pipewire-rs 0.7.1
Fixes an issue where bindgen fails to load the clang library in some cases
2023-08-21 12:14:28 +02:00
Tom A. Wagner
c5adb2eca2 flatpak: Update to gnome 44 runtime and llvm 15 2023-08-20 14:24:02 +02:00
Tom A. Wagner
7f754b207c Color links and ports according to their formats
Format params for links and ports are now being watched for in the pipewire connection code.

The parsed media type is then set on the port widget / link object
and they are colored accordingly.

For ports, which were already colored before, this new method of determining the media type
should be more reliable and accurate as this uses the real Format/EnumFormat
params instead of parsing optional properties.
2023-08-20 10:55:25 +02:00
Tom A. Wagner
ba73d8cdcc pipewire connection: Move module to mod.rs in its folder 2023-08-18 10:24:54 +02:00
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
31 changed files with 2958 additions and 1573 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
/.flatpak
/.flatpak-builder /.flatpak-builder
/.vscode /.vscode
/_build
/target /target

View File

@@ -1,75 +1,53 @@
include:
- project: 'freedesktop/ci-templates' # the project to include from
ref: '34f4ade99434043f88e164933f570301fd18b125' # git ref of that project
file: '/templates/fedora.yml' # the actual file to include
stages: stages:
- prepare - build
- lint - lint
- test
- extras
variables: .flatpak:
FDO_UPSTREAM_REPO: 'pipewire/helvum' image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-46'
# Version and tag for our current container
.fedora:
variables: variables:
FDO_DISTRIBUTION_VERSION: '36' FLATPAK_BUILD_DIR: _build
# Update this to trigger a container rebuild MANIFEST_PATH: build-aux/org.pipewire.Helvum.json
FDO_DISTRIBUTION_TAG: '2022-11-09.0' APP_FLATPAK_MODULE: Helvum
before_script:
- flatpak --version
- flatpak info org.gnome.Platform
- flatpak info org.gnome.Sdk
- flatpak info org.freedesktop.Sdk.Extension.llvm16
- flatpak info org.freedesktop.Sdk.Extension.rust-stable
- flatpak-builder --version
build-fedora-container: build:
extends: stage: build
- .fedora # our template job above extends: .flatpak
- .fdo.container-build@fedora@x86_64 # the CI template
stage: prepare
variables:
# clang-devel: required by rust bindgen
FDO_DISTRIBUTION_PACKAGES: >-
rust
cargo
rustfmt
clippy
pipewire-devel
gtk4-devel
clang-devel
rustfmt:
extends:
- .fedora
- .fdo.distribution-image@fedora
stage: lint
script: script:
- cargo fmt --version - flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse ${FLATPAK_BUILD_DIR} ${MANIFEST_PATH}
- cargo fmt -- --color=always --check
test-stable: # TODO: Run meson test
extends:
- .fedora
- .fdo.distribution-image@fedora
stage: test
script:
- rustc --version
- cargo build --color=always --all-targets
- cargo test --color=always
rustdoc:
extends:
- .fedora
- .fdo.distribution-image@fedora
stage: extras
variables:
RUSTDOCFLAGS: '-Dwarnings'
script:
- rustdoc --version
- cargo doc --no-deps
clippy: clippy:
extends: stage: lint
- .fedora extends: .flatpak
- .fdo.distribution-image@fedora
stage: extras
script: script:
- cargo clippy --version - flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${APP_FLATPAK_MODULE} ${FLATPAK_BUILD_DIR} ${MANIFEST_PATH}
- cargo clippy --color=always --all-targets -- -D warnings - >-
flatpak-builder --run ${FLATPAK_BUILD_DIR} ${MANIFEST_PATH}
cargo clippy --color=always --all-targets -- -D warnings
rustfmt:
stage: lint
image: "rust:slim" # TODO: Check image
script:
- rustup component add rustfmt
- rustc -Vv && cargo -Vv
- cargo fmt --version
- cargo fmt --all -- --color=always --check
rustdoc:
stage: lint
extends: .flatpak
script:
- flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${APP_FLATPAK_MODULE} ${FLATPAK_BUILD_DIR} ${MANIFEST_PATH}
- >-
flatpak-builder --run ${FLATPAK_BUILD_DIR} ${MANIFEST_PATH}
env RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps

688
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package] [package]
name = "helvum" name = "helvum"
version = "0.4.0" version = "0.5.1"
authors = ["Tom A. Wagner <tom.a.wagner@protonmail.com>"] authors = ["Tom Wagner <tom.a.wagner@protonmail.com>"]
edition = "2021" edition = "2021"
rust-version = "1.56" rust-version = "1.70"
license = "GPL-3.0-only" license = "GPL-3.0-only"
description = "A GTK patchbay for pipewire" description = "A GTK patchbay for pipewire"
repository = "https://gitlab.freedesktop.org/pipewire/helvum" repository = "https://gitlab.freedesktop.org/pipewire/helvum"
@@ -14,10 +14,14 @@ categories = ["gui", "multimedia"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
pipewire = "0.6" pipewire = "0.8.0"
gtk = { version = "0.6", package = "gtk4" } adw = { version = "0.6", package = "libadwaita", features = ["v1_4"] }
glib = { version = "0.17", features = ["log"] } gtk = { version = "0.8", package = "gtk4", features = ["v4_14"] }
glib = { version = "0.19", features = ["log"] }
async-channel = "2.2"
log = "0.4.11" log = "0.4.11"
once_cell = "1.7.2" once_cell = "1.19"
libc = "0.2"

View File

@@ -23,7 +23,7 @@ $ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.fl
Then install the required flatpak platform and SDK, if you dont have them already: Then install the required flatpak platform and SDK, if you dont have them already:
```shell ```shell
$ flatpak install org.gnome.{Platform,Sdk}//43 org.freedesktop.Sdk.Extension.rust-stable//22.08 org.freedesktop.Sdk.Extension.llvm14//22.08 $ flatpak install org.gnome.{Platform,Sdk}//46 org.freedesktop.Sdk.Extension.rust-stable//23.08 org.freedesktop.Sdk.Extension.llvm16//23.08
``` ```
To compile and install as a flatpak, clone the project, change to the project directory, and run: To compile and install as a flatpak, clone the project, change to the project directory, and run:
@@ -42,7 +42,7 @@ For compilation, you will need:
- Meson - Meson
- An up-to-date rust toolchain - An up-to-date rust toolchain
- `libclang-3.7` or higher - `libclang-3.7` or higher
- `gtk-4.0` and `pipewire-0.3` development headers - `libadwaita-1` and `libpipewire-0.3` development packages and their dependencies
To compile and install, run To compile and install, run

View File

@@ -1,11 +1,11 @@
{ {
"app-id": "org.pipewire.Helvum", "id": "org.pipewire.Helvum",
"runtime": "org.gnome.Platform", "runtime": "org.gnome.Platform",
"runtime-version": "43", "runtime-version": "46",
"sdk": "org.gnome.Sdk", "sdk": "org.gnome.Sdk",
"sdk-extensions": [ "sdk-extensions": [
"org.freedesktop.Sdk.Extension.rust-stable", "org.freedesktop.Sdk.Extension.rust-stable",
"org.freedesktop.Sdk.Extension.llvm14" "org.freedesktop.Sdk.Extension.llvm16"
], ],
"command": "helvum", "command": "helvum",
"finish-args": [ "finish-args": [
@@ -16,8 +16,8 @@
"--filesystem=xdg-run/pipewire-0" "--filesystem=xdg-run/pipewire-0"
], ],
"build-options": { "build-options": {
"append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm14/bin", "append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm16/bin",
"prepend-ld-library-path": "/usr/lib/sdk/llvm14/lib", "prepend-ld-library-path": "/usr/lib/sdk/llvm16/lib",
"build-args": [ "build-args": [
"--share=network" "--share=network"
] ]

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,7 +1,7 @@
project( project(
'helvum', 'helvum',
'rust', 'rust',
version: '0.4.0', version: '0.5.1',
license: 'GPL-3.0', license: 'GPL-3.0',
meson_version: '>=0.59.0' meson_version: '>=0.59.0'
) )
@@ -10,8 +10,9 @@ gnome = import('gnome')
base_id = 'org.pipewire.Helvum' base_id = 'org.pipewire.Helvum'
dependency('glib-2.0', version: '>= 2.66') dependency('glib-2.0', version: '>= 2.66')
dependency('gtk4', version: '>= 4.4.0') dependency('gtk4', version: '>= 4.14.0')
dependency('libadwaita-1', version: '>= 1.4')
dependency('libpipewire-0.3') dependency('libpipewire-0.3')
desktop_file_validate = find_program('desktop-file-validate', required: false) desktop_file_validate = find_program('desktop-file-validate', required: false)

View File

@@ -14,97 +14,129 @@
// //
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
use std::cell::RefCell; use adw::{
use gtk::{
gio, gio,
glib::{self, clone, Continue, Receiver}, glib::{self, clone},
gtk,
prelude::*, prelude::*,
subclass::prelude::*, subclass::prelude::*,
}; };
use log::info; use pipewire::channel::Sender;
use pipewire::{channel::Sender, spa::Direction};
use crate::{ use crate::{graph_manager::GraphManager, ui, GtkMessage, PipewireMessage};
view::{self},
GtkMessage, MediaType, NodeType, PipewireLink, PipewireMessage,
};
static STYLE: &str = include_str!("style.css"); static STYLE: &str = include_str!("style.css");
static APP_ID: &str = "org.pipewire.Helvum";
static VERSION: &str = env!("CARGO_PKG_VERSION");
static AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
mod imp { mod imp {
use super::*; use super::*;
use once_cell::unsync::OnceCell; use std::cell::OnceCell;
use adw::subclass::prelude::AdwApplicationImpl;
#[derive(Default)] #[derive(Default)]
pub struct Application { pub struct Application {
pub(super) graphview: view::GraphView, pub(super) window: ui::Window,
pub(super) pw_sender: OnceCell<RefCell<Sender<GtkMessage>>>, pub(super) graph_manager: OnceCell<GraphManager>,
} }
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for Application { impl ObjectSubclass for Application {
const NAME: &'static str = "HelvumApplication"; const NAME: &'static str = "HelvumApplication";
type Type = super::Application; type Type = super::Application;
type ParentType = gtk::Application; type ParentType = adw::Application;
} }
impl ObjectImpl for Application {} impl ObjectImpl for Application {}
impl ApplicationImpl for Application { impl ApplicationImpl for Application {
fn activate(&self) { fn activate(&self) {
let app = &*self.obj(); 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);
headerbar.pack_end(&zoomentry);
let window = gtk::ApplicationWindow::builder() let graphview = self.window.graph();
.application(app)
.default_width(1280) self.window.set_application(Some(app));
.default_height(720)
.title("Helvum - Pipewire Patchbay")
.child(&scrollwindow)
.build();
window
.settings()
.set_gtk_application_prefer_dark_theme(true);
window.set_titlebar(Some(&headerbar));
let zoom_set_action = let zoom_set_action =
gio::SimpleAction::new("set-zoom", Some(&f64::static_variant_type())); gio::SimpleAction::new("set-zoom", Some(&f64::static_variant_type()));
zoom_set_action.connect_activate( zoom_set_action.connect_activate(clone!(@weak graphview => move|_, param| {
clone!(@weak self.graphview as graphview => move|_, param| { let zoom_factor = param.unwrap().get::<f64>().unwrap();
let zoom_factor = param.unwrap().get::<f64>().unwrap(); graphview.set_zoom_factor(zoom_factor, None)
graphview.set_zoom_factor(zoom_factor, None) }));
}), self.window.add_action(&zoom_set_action);
);
window.add_action(&zoom_set_action);
window.show(); self.window.show();
} }
fn startup(&self) { fn startup(&self) {
self.parent_startup(); self.parent_startup();
self.obj()
.style_manager()
.set_color_scheme(adw::ColorScheme::PreferDark);
// Load CSS from the STYLE variable. // Load CSS from the STYLE variable.
let provider = gtk::CssProvider::new(); let provider = gtk::CssProvider::new();
provider.load_from_data(STYLE); 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."), &gtk::gdk::Display::default().expect("Error initializing gtk css provider."),
&provider, &provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );
self.setup_actions();
} }
} }
impl GtkApplicationImpl for Application {} impl GtkApplicationImpl for Application {}
impl AdwApplicationImpl for Application {}
impl Application {
fn setup_actions(&self) {
let obj = &*self.obj();
// Add <Control-Q> shortcut for quitting the application.
let quit = gtk::gio::SimpleAction::new("quit", None);
quit.connect_activate(clone!(@weak obj => move |_, _| {
obj.quit();
}));
obj.set_accels_for_action("app.quit", &["<Control>Q"]);
obj.add_action(&quit);
let action_about = gio::ActionEntry::builder("about")
.activate(|obj: &super::Application, _, _| {
obj.imp().show_about_dialog();
})
.build();
obj.add_action_entries([action_about]);
}
fn show_about_dialog(&self) {
let obj = &*self.obj();
let window = obj.active_window().unwrap();
let authors: Vec<&str> = AUTHORS.split(':').collect();
let about_window = adw::AboutWindow::builder()
.transient_for(&window)
.application_icon(APP_ID)
.application_name("Helvum")
.developer_name("Tom Wagner")
.developers(authors)
.version(VERSION)
.website("https://gitlab.freedesktop.org/pipewire/helvum")
.issue_url("https://gitlab.freedesktop.org/pipewire/helvum/-/issues")
.license_type(gtk::License::Gpl30Only)
.build();
about_window.present();
}
}
} }
glib::wrapper! { glib::wrapper! {
pub struct Application(ObjectSubclass<imp::Application>) pub struct Application(ObjectSubclass<imp::Application>)
@extends gio::Application, gtk::Application, @extends gio::Application, gtk::Application, adw::Application,
@implements gio::ActionGroup, gio::ActionMap; @implements gio::ActionGroup, gio::ActionMap;
} }
@@ -112,160 +144,24 @@ impl Application {
/// Create the view. /// Create the view.
/// This will set up the entire user interface and prepare it for being run. /// This will set up the entire user interface and prepare it for being run.
pub(super) fn new( pub(super) fn new(
gtk_receiver: Receiver<PipewireMessage>, gtk_receiver: async_channel::Receiver<PipewireMessage>,
pw_sender: Sender<GtkMessage>, pw_sender: Sender<GtkMessage>,
) -> Self { ) -> Self {
let app: Application = glib::Object::builder() let app: Application = glib::Object::builder()
.property("application-id", &"org.pipewire.Helvum") .property("application-id", APP_ID)
.build(); .build();
let imp = app.imp(); 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");
// Add <Control-Q> shortcut for quitting the application. imp.graph_manager
let quit = gtk::gio::SimpleAction::new("quit", None); .set(GraphManager::new(
quit.connect_activate(clone!(@weak app => move |_, _| { &imp.window.graph(),
app.quit(); &imp.window.connection_banner(),
})); pw_sender,
app.set_accels_for_action("app.quit", &["<Control>Q"]); gtk_receiver,
app.add_action(&quit); ))
.expect("Should be able to set graph manager");
// 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 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);
}
} }

379
src/graph_manager.rs Normal file
View File

@@ -0,0 +1,379 @@
// 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 adw::{glib, prelude::*, subclass::prelude::*};
use pipewire::channel::Sender as PwSender;
use crate::{ui::graph::GraphView, GtkMessage, PipewireMessage};
mod imp {
use super::*;
use std::{cell::OnceCell, cell::RefCell, collections::HashMap};
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>,
#[property(get, set, construct_only)]
pub connection_banner: OnceCell<adw::Banner>,
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 async fn receive(&self, receiver: async_channel::Receiver<crate::PipewireMessage>) {
loop {
let Ok(msg) = receiver.recv().await else {
continue;
};
match msg {
PipewireMessage::NodeAdded {
id,
name,
node_type,
} => self.add_node(id, name.as_str(), node_type),
PipewireMessage::NodeNameChanged {
id,
name,
media_name,
} => self.node_name_changed(id, &name, &media_name),
PipewireMessage::PortAdded {
id,
node_id,
name,
direction,
} => self.add_port(id, name.as_str(), node_id, direction),
PipewireMessage::PortFormatChanged { id, media_type } => {
self.port_media_type_changed(id, media_type)
}
PipewireMessage::LinkAdded {
id,
port_from,
port_to,
active,
media_type,
} => self.add_link(id, port_from, port_to, active, media_type),
PipewireMessage::LinkStateChanged { id, active } => {
self.link_state_changed(id, active)
}
PipewireMessage::LinkFormatChanged { id, media_type } => {
self.link_format_changed(id, media_type)
}
PipewireMessage::NodeRemoved { id } => self.remove_node(id),
PipewireMessage::PortRemoved { id, node_id } => self.remove_port(id, node_id),
PipewireMessage::LinkRemoved { id } => self.remove_link(id),
PipewireMessage::Connecting => {
self.obj().connection_banner().set_revealed(true);
}
PipewireMessage::Connected => {
self.obj().connection_banner().set_revealed(false);
}
PipewireMessage::Disconnected => {
self.clear();
}
};
}
}
/// 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);
}
/// Update a node tooltip to the view.
fn node_name_changed(&self, id: u32, node_name: &str, media_name: &str) {
let items = self.items.borrow();
let Some(node) = items.get(&id) else {
log::warn!("Node (id: {id}) for changed name not found in graph manager");
return;
};
let Some(node) = node.dynamic_cast_ref::<graph::Node>() else {
log::warn!("Graph Manager item under node (id: {id}) is not a node");
return;
};
node.set_node_name(node_name);
node.set_media_name(media_name);
}
/// 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::utils::Direction,
) {
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);
// 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);
}
fn port_media_type_changed(&self, id: u32, media_type: MediaType) {
let items = self.items.borrow();
let Some(port) = items.get(&id) else {
log::warn!("Port (id: {id}) for changed media type not found in graph manager");
return;
};
let Some(port) = port.dynamic_cast_ref::<graph::Port>() else {
log::warn!("Graph Manager item under port id {id} is not a port");
return;
};
port.set_media_type(media_type.as_raw())
}
/// 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,
media_type: MediaType,
) {
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);
link.set_media_type(media_type);
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);
}
fn link_format_changed(
&self,
id: u32,
media_type: pipewire::spa::param::format::MediaType,
) {
let items = self.items.borrow();
let Some(link) = items.get(&id) else {
log::warn!("Link (id: {id}) for changed media type not found in graph manager");
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_media_type(media_type);
}
// 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);
}
fn clear(&self) {
self.items.borrow_mut().clear();
self.obj().graph().clear();
}
}
}
glib::wrapper! {
pub struct GraphManager(ObjectSubclass<imp::GraphManager>);
}
async fn receive(graph_manager: GraphManager, receiver: async_channel::Receiver<PipewireMessage>) {
graph_manager.imp().receive(receiver).await
}
impl GraphManager {
pub fn new(
graph: &GraphView,
connection_banner: &adw::Banner,
sender: PwSender<GtkMessage>,
receiver: async_channel::Receiver<PipewireMessage>,
) -> Self {
let res: Self = glib::Object::builder()
.property("graph", graph)
.property("connection-banner", connection_banner)
.build();
glib::MainContext::default().spawn_local(receive(res.clone(), 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 // SPDX-License-Identifier: GPL-3.0-only
mod application; mod application;
mod graph_manager;
mod pipewire_connection; mod pipewire_connection;
mod view; mod ui;
use glib::PRIORITY_DEFAULT; use adw::{gtk, prelude::*};
use gtk::prelude::*; use pipewire::spa::{param::format::MediaType, utils::Direction};
use pipewire::spa::Direction;
/// Messages sent by the GTK thread to notify the pipewire thread. /// Messages sent by the GTK thread to notify the pipewire thread.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum GtkMessage { pub enum GtkMessage {
/// Toggle a link between the two specified ports. /// Toggle a link between the two specified ports.
ToggleLink { port_from: u32, port_to: u32 }, ToggleLink { port_from: u32, port_to: u32 },
/// Quit the event loop and let the thread finish. /// Quit the event loop and let the thread finish.
@@ -33,31 +33,42 @@ enum GtkMessage {
/// Messages sent by the pipewire thread to notify the GTK thread. /// Messages sent by the pipewire thread to notify the GTK thread.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum PipewireMessage { pub enum PipewireMessage {
NodeAdded { NodeAdded {
id: u32, id: u32,
name: String, name: String,
node_type: Option<NodeType>, node_type: Option<NodeType>,
}, },
NodeNameChanged {
id: u32,
name: String,
media_name: String,
},
PortAdded { PortAdded {
id: u32, id: u32,
node_id: u32, node_id: u32,
name: String, name: String,
direction: Direction, direction: Direction,
media_type: Option<MediaType>, },
PortFormatChanged {
id: u32,
media_type: MediaType,
}, },
LinkAdded { LinkAdded {
id: u32, id: u32,
node_from: u32,
port_from: u32, port_from: u32,
node_to: u32,
port_to: u32, port_to: u32,
active: bool, active: bool,
media_type: MediaType,
}, },
LinkStateChanged { LinkStateChanged {
id: u32, id: u32,
active: bool, active: bool,
}, },
LinkFormatChanged {
id: u32,
media_type: MediaType,
},
NodeRemoved { NodeRemoved {
id: u32, id: u32,
}, },
@@ -68,6 +79,9 @@ enum PipewireMessage {
LinkRemoved { LinkRemoved {
id: u32, id: u32,
}, },
Connecting,
Connected,
Disconnected,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -76,13 +90,6 @@ pub enum NodeType {
Output, Output,
} }
#[derive(Debug, Copy, Clone)]
pub enum MediaType {
Audio,
Video,
Midi,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PipewireLink { pub struct PipewireLink {
pub node_from: u32, pub node_from: u32,
@@ -112,7 +119,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _guard = ctx.acquire().unwrap(); let _guard = ctx.acquire().unwrap();
// Start the pipewire thread with channels in both directions. // Start the pipewire thread with channels in both directions.
let (gtk_sender, gtk_receiver) = glib::MainContext::channel(PRIORITY_DEFAULT);
let (gtk_sender, gtk_receiver) = async_channel::unbounded();
let (pw_sender, pw_receiver) = pipewire::channel::channel(); let (pw_sender, pw_receiver) = pipewire::channel::channel();
let pw_thread = let pw_thread =
std::thread::spawn(move || pipewire_connection::thread_main(gtk_sender, pw_receiver)); std::thread::spawn(move || pipewire_connection::thread_main(gtk_sender, pw_receiver));

View File

@@ -1,318 +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
mod state;
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use gtk::glib::{self, clone};
use log::{debug, info, warn};
use pipewire::{
link::{Link, LinkChangeMask, LinkListener, LinkState},
prelude::*,
properties,
registry::{GlobalObject, Registry},
spa::{Direction, ForeignDict},
types::ObjectType,
Context, Core, MainLoop,
};
use crate::{GtkMessage, MediaType, NodeType, PipewireMessage};
use state::{Item, State};
enum ProxyItem {
Link {
_proxy: Link,
_listener: LinkListener,
},
}
/// The "main" function of the pipewire thread.
pub(super) fn thread_main(
gtk_sender: glib::Sender<PipewireMessage>,
pw_receiver: pipewire::channel::Receiver<GtkMessage>,
) {
let mainloop = MainLoop::new().expect("Failed to create mainloop");
let context = Context::new(&mainloop).expect("Failed to create context");
let core = Rc::new(context.connect(None).expect("Failed to connect to remote"));
let registry = Rc::new(core.get_registry().expect("Failed to get registry"));
// Keep proxies and their listeners alive so that we can receive info events.
let proxies = Rc::new(RefCell::new(HashMap::new()));
let state = Rc::new(RefCell::new(State::new()));
let _receiver = pw_receiver.attach(&mainloop, {
clone!(@strong mainloop, @weak core, @weak registry, @strong state => move |msg| match msg {
GtkMessage::ToggleLink { port_from, port_to } => toggle_link(port_from, port_to, &core, &registry, &state),
GtkMessage::Terminate => mainloop.quit(),
})
});
let _listener = registry
.add_listener_local()
.global(clone!(@strong gtk_sender, @weak registry, @strong proxies, @strong state =>
move |global| match global.type_ {
ObjectType::Node => handle_node(global, &gtk_sender, &state),
ObjectType::Port => handle_port(global, &gtk_sender, &state),
ObjectType::Link => handle_link(global, &gtk_sender, &registry, &proxies, &state),
_ => {
// Other objects are not interesting to us
}
}
))
.global_remove(clone!(@strong proxies, @strong state => move |id| {
if let Some(item) = state.borrow_mut().remove(id) {
gtk_sender.send(match item {
Item::Node { .. } => PipewireMessage::NodeRemoved {id},
Item::Port { node_id } => PipewireMessage::PortRemoved {id, node_id},
Item::Link { .. } => PipewireMessage::LinkRemoved {id},
}).expect("Failed to send message");
} else {
warn!(
"Attempted to remove item with id {} that is not saved in state",
id
);
}
proxies.borrow_mut().remove(&id);
}))
.register();
mainloop.run();
}
/// Handle a new node being added
fn handle_node(
node: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
state: &Rc<RefCell<State>>,
) {
let props = node
.props
.as_ref()
.expect("Node object is missing properties");
// Get the nicest possible name for the node, using a fallback chain of possible name attributes.
let name = String::from(
props
.get("node.description")
.or_else(|| props.get("node.nick"))
.or_else(|| props.get("node.name"))
.unwrap_or_default(),
);
// FIXME: Instead of checking these props, the "EnumFormat" parameter should be checked instead.
let media_type = props.get("media.class").and_then(|class| {
if class.contains("Audio") {
Some(MediaType::Audio)
} else if class.contains("Video") {
Some(MediaType::Video)
} else if class.contains("Midi") {
Some(MediaType::Midi)
} else {
None
}
});
let media_class = |class: &str| {
if class.contains("Sink") || class.contains("Input") {
Some(NodeType::Input)
} else if class.contains("Source") || class.contains("Output") {
Some(NodeType::Output)
} else {
None
}
};
let node_type = props
.get("media.category")
.and_then(|class| {
if class.contains("Duplex") {
None
} else {
props.get("media.class").and_then(media_class)
}
})
.or_else(|| props.get("media.class").and_then(media_class));
state.borrow_mut().insert(
node.id,
Item::Node {
// widget: node_widget,
media_type,
},
);
sender
.send(PipewireMessage::NodeAdded {
id: node.id,
name,
node_type,
})
.expect("Failed to send message");
}
/// Handle a new port being added
fn handle_port(
port: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
state: &Rc<RefCell<State>>,
) {
let props = port
.props
.as_ref()
.expect("Port object is missing properties");
let name = props.get("port.name").unwrap_or_default().to_string();
let node_id: u32 = props
.get("node.id")
.expect("Port has no node.id property!")
.parse()
.expect("Could not parse node.id property");
let direction = if matches!(props.get("port.direction"), Some("in")) {
Direction::Input
} else {
Direction::Output
};
// Find out the nodes media type so that the port can be colored.
let media_type = if let Some(Item::Node { media_type, .. }) = state.borrow().get(node_id) {
media_type.to_owned()
} else {
warn!("Node not found for Port {}", port.id);
None
};
// Save node_id so we can delete this port easily.
state.borrow_mut().insert(port.id, Item::Port { node_id });
sender
.send(PipewireMessage::PortAdded {
id: port.id,
node_id,
name,
direction,
media_type,
})
.expect("Failed to send message");
}
/// Handle a new link being added
fn handle_link(
link: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
debug!(
"New link (id:{}) appeared, setting up info listener.",
link.id
);
let proxy: Link = registry.bind(link).expect("Failed to bind to link proxy");
let listener = proxy
.add_listener_local()
.info(clone!(@strong state, @strong sender => move |info| {
debug!("Received link info: {:?}", info);
let id = info.id();
let mut state = state.borrow_mut();
if let Some(Item::Link { .. }) = state.get(id) {
// Info was an update - figure out if we should notify the gtk thread
if info.change_mask().contains(LinkChangeMask::STATE) {
sender.send(PipewireMessage::LinkStateChanged {
id,
active: matches!(info.state(), LinkState::Active)
}).expect("Failed to send message");
}
// 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 {
port_from, port_to
});
sender.send(PipewireMessage::LinkAdded {
id,
node_from,
port_from,
node_to,
port_to,
active: matches!(info.state(), LinkState::Active)
}).expect(
"Failed to send message"
);
}
}))
.register();
proxies.borrow_mut().insert(
link.id,
ProxyItem::Link {
_proxy: proxy,
_listener: listener,
},
);
}
/// Toggle a link between the two specified ports.
fn toggle_link(
port_from: u32,
port_to: u32,
core: &Rc<Core>,
registry: &Rc<Registry>,
state: &Rc<RefCell<State>>,
) {
let state = state.borrow_mut();
if let Some(id) = state.get_link_id(port_from, port_to) {
info!("Requesting removal of link with id {}", id);
// FIXME: Handle error
registry.destroy_global(id);
} else {
info!(
"Requesting creation of link from port id:{} to port id:{}",
port_from, port_to
);
let node_from = state
.get_node_of_port(port_from)
.expect("Requested port not in state");
let node_to = state
.get_node_of_port(port_to)
.expect("Requested port not in state");
if let Err(e) = core.create_object::<Link, _>(
"link-factory",
&properties! {
"link.output.node" => node_from.to_string(),
"link.output.port" => port_from.to_string(),
"link.input.node" => node_to.to_string(),
"link.input.port" => port_to.to_string(),
"object.linger" => "1"
},
) {
warn!("Failed to create link: {}", e);
}
}
}

View File

@@ -0,0 +1,525 @@
// 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 state;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
time::Duration,
};
use adw::glib::{self, clone};
use log::{debug, error, info, warn};
use pipewire::{
context::Context,
core::{Core, PW_ID_CORE},
keys,
link::{Link, LinkChangeMask, LinkInfoRef, LinkListener, LinkState},
main_loop::MainLoop,
node::{Node, NodeInfoRef, NodeListener},
port::{Port, PortChangeMask, PortInfoRef, PortListener},
properties::properties,
registry::{GlobalObject, Registry},
spa::{
param::{ParamInfoFlags, ParamType},
utils::dict::DictRef,
utils::result::SpaResult,
},
types::ObjectType,
};
use crate::{GtkMessage, MediaType, NodeType, PipewireMessage};
use state::{Item, State};
enum ProxyItem {
Node {
_proxy: Node,
_listener: NodeListener,
},
Port {
proxy: Port,
_listener: PortListener,
},
Link {
_proxy: Link,
_listener: LinkListener,
},
}
/// The "main" function of the pipewire thread.
pub(super) fn thread_main(
gtk_sender: async_channel::Sender<PipewireMessage>,
mut pw_receiver: pipewire::channel::Receiver<GtkMessage>,
) {
let mainloop = MainLoop::new(None).expect("Failed to create mainloop");
let context = Rc::new(Context::new(&mainloop).expect("Failed to create context"));
let is_stopped = Rc::new(Cell::new(false));
let mut is_connecting = false;
while !is_stopped.get() {
// Try to connect
let core = match context.connect(Some(properties! {
"media.category" => "Manager"
})) {
Ok(core) => Rc::new(core),
Err(_) => {
if !is_connecting {
is_connecting = true;
gtk_sender
.send_blocking(PipewireMessage::Connecting)
.expect("Failed to send message");
}
// If connection is failed, try to connect again in 200ms
let interval = Some(Duration::from_millis(200));
let timer = mainloop
.loop_()
.add_timer(clone!(@strong mainloop => move |_| {
mainloop.quit();
}));
timer.update_timer(interval, None).into_result().unwrap();
let receiver = pw_receiver.attach(mainloop.loop_(), {
clone!(@strong mainloop, @strong is_stopped => move |msg|
if let GtkMessage::Terminate = msg {
// main thread requested stop
is_stopped.set(true);
mainloop.quit();
}
)
});
mainloop.run();
pw_receiver = receiver.deattach();
continue;
}
};
if is_connecting {
is_connecting = false;
gtk_sender
.send_blocking(PipewireMessage::Connected)
.expect("Failed to send message");
}
let registry = Rc::new(core.get_registry().expect("Failed to get registry"));
// Keep proxies and their listeners alive so that we can receive info events.
let proxies = Rc::new(RefCell::new(HashMap::new()));
let state = Rc::new(RefCell::new(State::new()));
let receiver = pw_receiver.attach(mainloop.loop_(), {
clone!(@strong mainloop, @weak core, @weak registry, @strong state, @strong is_stopped => move |msg| match msg {
GtkMessage::ToggleLink { port_from, port_to } => toggle_link(port_from, port_to, &core, &registry, &state),
GtkMessage::Terminate => {
// main thread requested stop
is_stopped.set(true);
mainloop.quit();
}
})
});
let gtk_sender = gtk_sender.clone();
let _listener = core.add_listener_local()
.error(clone!(@strong mainloop, @strong gtk_sender, @strong is_stopped => move |id, _seq, res, message| {
if id != PW_ID_CORE {
return;
}
if res == -libc::EPIPE {
gtk_sender.send_blocking(PipewireMessage::Disconnected)
.expect("Failed to send message");
mainloop.quit();
} else {
let serr = SpaResult::from_c(res).into_result().unwrap_err();
error!("Pipewire Core received error {serr}: {message}");
}
}))
.register();
let _listener = registry
.add_listener_local()
.global(clone!(@strong gtk_sender, @weak registry, @strong proxies, @strong state =>
move |global| match global.type_ {
ObjectType::Node => handle_node(global, &gtk_sender, &registry, &proxies, &state),
ObjectType::Port => handle_port(global, &gtk_sender, &registry, &proxies, &state),
ObjectType::Link => handle_link(global, &gtk_sender, &registry, &proxies, &state),
_ => {
// Other objects are not interesting to us
}
}
))
.global_remove(clone!(@strong proxies, @strong state => move |id| {
if let Some(item) = state.borrow_mut().remove(id) {
gtk_sender.send_blocking(match item {
Item::Node { .. } => PipewireMessage::NodeRemoved {id},
Item::Port { node_id } => PipewireMessage::PortRemoved {id, node_id},
Item::Link { .. } => PipewireMessage::LinkRemoved {id},
}).expect("Failed to send message");
} else {
warn!(
"Attempted to remove item with id {} that is not saved in state",
id
);
}
proxies.borrow_mut().remove(&id);
}))
.register();
mainloop.run();
pw_receiver = receiver.deattach();
}
}
/// Get the nicest possible name for the node, using a fallback chain of possible name attributes
fn get_node_name(props: &DictRef) -> &str {
props
.get(&keys::NODE_DESCRIPTION)
.or_else(|| props.get(&keys::NODE_NICK))
.or_else(|| props.get(&keys::NODE_NAME))
.unwrap_or_default()
}
/// Handle a new node being added
fn handle_node(
node: &GlobalObject<&DictRef>,
sender: &async_channel::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
let props = node
.props
.as_ref()
.expect("Node object is missing properties");
let name = get_node_name(props).to_string();
let media_class = |class: &str| {
if class.contains("Sink") || class.contains("Input") {
Some(NodeType::Input)
} else if class.contains("Source") || class.contains("Output") {
Some(NodeType::Output)
} else {
None
}
};
let node_type = props
.get("media.category")
.and_then(|class| {
if class.contains("Duplex") {
None
} else {
props.get("media.class").and_then(media_class)
}
})
.or_else(|| props.get("media.class").and_then(media_class));
state.borrow_mut().insert(node.id, Item::Node);
sender
.send_blocking(PipewireMessage::NodeAdded {
id: node.id,
name,
node_type,
})
.expect("Failed to send message");
let proxy: Node = registry.bind(node).expect("Failed to bind to node proxy");
let listener = proxy
.add_listener_local()
.info(clone!(@strong sender, @strong proxies => move |info| {
handle_node_info(info, &sender, &proxies);
}))
.register();
proxies.borrow_mut().insert(
node.id,
ProxyItem::Node {
_proxy: proxy,
_listener: listener,
},
);
}
fn handle_node_info(
info: &NodeInfoRef,
sender: &async_channel::Sender<PipewireMessage>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
) {
debug!("Received node info: {:?}", info);
let id = info.id();
let proxies = proxies.borrow();
let Some(ProxyItem::Node { .. }) = proxies.get(&id) else {
error!("Received info on unknown node with id {id}");
return;
};
let props = info.props().expect("NodeInfo object is missing properties");
if let Some(media_name) = props.get(&keys::MEDIA_NAME) {
let name = get_node_name(props).to_string();
sender
.send_blocking(PipewireMessage::NodeNameChanged {
id,
name,
media_name: media_name.to_string(),
})
.expect("Failed to send message");
}
}
/// Handle a new port being added
fn handle_port(
port: &GlobalObject<&DictRef>,
sender: &async_channel::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
let port_id = port.id;
let proxy: Port = registry.bind(port).expect("Failed to bind to port proxy");
let listener = proxy
.add_listener_local()
.info(
clone!(@strong proxies, @strong state, @strong sender => move |info| {
handle_port_info(info, &proxies, &state, &sender);
}),
)
.param(clone!(@strong sender => move |_, param_id, _, _, param| {
if param_id == ParamType::EnumFormat {
handle_port_enum_format(port_id, param, &sender)
}
}))
.register();
proxies.borrow_mut().insert(
port.id,
ProxyItem::Port {
proxy,
_listener: listener,
},
);
}
fn handle_port_info(
info: &PortInfoRef,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
sender: &async_channel::Sender<PipewireMessage>,
) {
debug!("Received port info: {:?}", info);
let id = info.id();
let proxies = proxies.borrow();
let Some(ProxyItem::Port { proxy, .. }) = proxies.get(&id) else {
log::error!("Received info on unknown port with id {id}");
return;
};
let mut state = state.borrow_mut();
if let Some(Item::Port { .. }) = state.get(id) {
// Info was an update, figure out if we should notify the GTK thread
if info.change_mask().contains(PortChangeMask::PARAMS) {
// TODO: React to param changes
}
} else {
// First time we get info. We can now notify the gtk thread of a new link.
let props = info.props().expect("Port object is missing properties");
let name = props.get("port.name").unwrap_or_default().to_string();
let node_id: u32 = props
.get("node.id")
.expect("Port has no node.id property!")
.parse()
.expect("Could not parse node.id property");
state.insert(id, Item::Port { node_id });
let params = info.params();
let enum_format_info = params
.iter()
.find(|param| param.id() == ParamType::EnumFormat);
if let Some(enum_format_info) = enum_format_info {
if enum_format_info.flags().contains(ParamInfoFlags::READ) {
proxy.enum_params(0, Some(ParamType::EnumFormat), 0, u32::MAX);
}
}
sender
.send_blocking(PipewireMessage::PortAdded {
id,
node_id,
name,
direction: info.direction(),
})
.expect("Failed to send message");
}
}
fn handle_port_enum_format(
port_id: u32,
param: Option<&pipewire::spa::pod::Pod>,
sender: &async_channel::Sender<PipewireMessage>,
) {
let media_type = param
.and_then(|param| pipewire::spa::param::format_utils::parse_format(param).ok())
.map(|(media_type, _media_subtype)| media_type)
.unwrap_or(MediaType::Unknown);
sender
.send_blocking(PipewireMessage::PortFormatChanged {
id: port_id,
media_type,
})
.expect("Failed to send message")
}
/// Handle a new link being added
fn handle_link(
link: &GlobalObject<&DictRef>,
sender: &async_channel::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
debug!(
"New link (id:{}) appeared, setting up info listener.",
link.id
);
let proxy: Link = registry.bind(link).expect("Failed to bind to link proxy");
let listener = proxy
.add_listener_local()
.info(clone!(@strong state, @strong sender => move |info| {
handle_link_info(info, &state, &sender);
}))
.register();
proxies.borrow_mut().insert(
link.id,
ProxyItem::Link {
_proxy: proxy,
_listener: listener,
},
);
}
fn handle_link_info(
info: &LinkInfoRef,
state: &Rc<RefCell<State>>,
sender: &async_channel::Sender<PipewireMessage>,
) {
debug!("Received link info: {:?}", info);
let id = info.id();
let mut state = state.borrow_mut();
if let Some(Item::Link { .. }) = state.get(id) {
// Info was an update - figure out if we should notify the gtk thread
if info.change_mask().contains(LinkChangeMask::STATE) {
sender
.send_blocking(PipewireMessage::LinkStateChanged {
id,
active: matches!(info.state(), LinkState::Active),
})
.expect("Failed to send message");
}
if info.change_mask().contains(LinkChangeMask::FORMAT) {
sender
.send_blocking(PipewireMessage::LinkFormatChanged {
id,
media_type: get_link_media_type(info),
})
.expect("Failed to send message");
}
} else {
// First time we get info. We can now notify the gtk thread of a new link.
let port_from = info.output_port_id();
let port_to = info.input_port_id();
state.insert(id, Item::Link { port_from, port_to });
sender
.send_blocking(PipewireMessage::LinkAdded {
id,
port_from,
port_to,
active: matches!(info.state(), LinkState::Active),
media_type: get_link_media_type(info),
})
.expect("Failed to send message");
}
}
/// Toggle a link between the two specified ports.
fn toggle_link(
port_from: u32,
port_to: u32,
core: &Rc<Core>,
registry: &Rc<Registry>,
state: &Rc<RefCell<State>>,
) {
let state = state.borrow_mut();
if let Some(id) = state.get_link_id(port_from, port_to) {
info!("Requesting removal of link with id {}", id);
// FIXME: Handle error
registry.destroy_global(id);
} else {
info!(
"Requesting creation of link from port id:{} to port id:{}",
port_from, port_to
);
let node_from = state
.get_node_of_port(port_from)
.expect("Requested port not in state");
let node_to = state
.get_node_of_port(port_to)
.expect("Requested port not in state");
if let Err(e) = core.create_object::<Link>(
"link-factory",
&properties! {
"link.output.node" => node_from.to_string(),
"link.output.port" => port_from.to_string(),
"link.input.node" => node_to.to_string(),
"link.input.port" => port_to.to_string(),
"object.linger" => "1"
},
) {
warn!("Failed to create link: {}", e);
}
}
}
fn get_link_media_type(link_info: &LinkInfoRef) -> MediaType {
let media_type = link_info
.format()
.and_then(|format| pipewire::spa::param::format_utils::parse_format(format).ok())
.map(|(media_type, _media_subtype)| media_type)
.unwrap_or(MediaType::Unknown);
media_type
}

View File

@@ -16,15 +16,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::MediaType;
/// Any pipewire item we need to keep track of. /// Any pipewire item we need to keep track of.
/// These will be saved in the `State` struct associated with their id. /// These will be saved in the `State` struct associated with their id.
pub(super) enum Item { pub(super) enum Item {
Node { Node,
// Keep track of the nodes media type to color ports on it.
media_type: Option<MediaType>,
},
Port { Port {
// Save the id of the node this is on so we can remove the port from it // Save the id of the node this is on so we can remove the port from it
// when it is deleted. // when it is deleted.

View File

@@ -15,26 +15,58 @@
SPDX-License-Identifier: GPL-3.0-only SPDX-License-Identifier: GPL-3.0-only
*/ */
@define-color audio rgb(50,100,240); @define-color media-type-audio rgb( 50, 100, 240);
@define-color video rgb(200,200,0); @define-color media-type-video rgb(200, 200, 0);
@define-color midi rgb(200,0,50); @define-color media-type-midi rgb(200, 0, 50);
@define-color graphview-link #808080; @define-color media-type-unknown rgb(128, 128, 128);
.audio { .audio {
background: @audio; background: @media-type-audio;
color: black; color: black;
} }
.video { .video {
background: @video; background: @media-type-video;
color: black; color: black;
} }
.midi { .midi {
background: @midi; background: @media-type-midi;
color: black; color: black;
} }
graphview { node {
background-color: @text_view_bg; /* Compared to the default card color, this is not transparent in dark-mode
and provides a better contrast to the background in light mode */
background-color: @headerbar_bg_color;
}
node .node-title {
padding: 4px 7px;
}
port label {
padding: 4px 6px;
}
port-handle {
border-radius: 50%;
background-color: @media-type-unknown;
}
button.rounded {
padding: 6px;
border-radius: 9999px;
}
entry.rounded {
border-radius: 9999px;
}
entry.rounded > :first-child {
padding-left: 12px;
}
entry.rounded > :nth-child(2) {
padding-right: 12px;
} }

View File

@@ -14,20 +14,21 @@
// //
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
use super::{Node, Port}; use adw::{
gio,
use gtk::{
glib::{self, clone}, glib::{self, clone},
graphene, gtk::{
graphene::Point, self, cairo,
gsk, graphene::{self, Point},
gsk,
},
prelude::*, prelude::*,
subclass::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; use crate::NodeType;
const CANVAS_SIZE: f64 = 5000.0; const CANVAS_SIZE: f64 = 5000.0;
@@ -36,14 +37,31 @@ mod imp {
use super::*; use super::*;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use gtk::{ use adw::gtk::gdk::{self};
gdk::{self, RGBA},
graphene::Rect,
gsk::ColorStop,
};
use log::warn; use log::warn;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use pipewire::spa::param::format::MediaType;
use pipewire::spa::utils::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 { pub struct DragState {
node: glib::WeakRef<Node>, node: glib::WeakRef<Node>,
@@ -54,25 +72,53 @@ mod imp {
offset: Point, offset: Point,
} }
#[derive(Default)]
pub struct GraphView { pub struct GraphView {
/// Stores nodes and their positions. /// Stores nodes and their positions.
pub(super) nodes: RefCell<HashMap<u32, (Node, Point)>>, pub(super) nodes: RefCell<HashMap<Node, Point>>,
/// Stores the link and whether it is currently active. /// Stores the links and whether they are currently active.
pub(super) links: RefCell<HashMap<u32, (crate::PipewireLink, bool)>>, pub(super) links: RefCell<HashSet<Link>>,
// Properties for zooming and scrolling the hraph
pub hadjustment: RefCell<Option<gtk::Adjustment>>, pub hadjustment: RefCell<Option<gtk::Adjustment>>,
pub vadjustment: RefCell<Option<gtk::Adjustment>>, pub vadjustment: RefCell<Option<gtk::Adjustment>>,
pub zoom_factor: Cell<f64>, pub zoom_factor: Cell<f64>,
/// This keeps track of an ongoing node drag operation. /// This keeps track of an ongoing node drag operation.
pub dragged_node: RefCell<Option<DragState>>, 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 // Memorized data for an in-progress zoom gesture
pub zoom_gesture_initial_zoom: Cell<Option<f64>>, pub zoom_gesture_initial_zoom: Cell<Option<f64>>,
pub zoom_gesture_anchor: Cell<Option<(f64, f64)>>, pub zoom_gesture_anchor: Cell<Option<(f64, f64)>>,
// This keeps track of an ongoing move view gesture.
pub move_view_state: Cell<(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(),
move_view_state: Default::default(),
}
}
} }
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for GraphView { impl ObjectSubclass for GraphView {
const NAME: &'static str = "GraphView"; const NAME: &'static str = "HelvumGraphView";
type Type = super::GraphView; type Type = super::GraphView;
type ParentType = gtk::Widget; type ParentType = gtk::Widget;
type Interfaces = (gtk::Scrollable,); type Interfaces = (gtk::Scrollable,);
@@ -86,17 +132,21 @@ mod imp {
fn constructed(&self) { fn constructed(&self) {
self.parent_constructed(); self.parent_constructed();
self.obj().add_css_class("view");
self.obj().set_overflow(gtk::Overflow::Hidden); self.obj().set_overflow(gtk::Overflow::Hidden);
self.setup_node_dragging(); self.setup_node_dragging();
self.setup_port_drag_and_drop();
self.setup_scroll_zooming(); self.setup_scroll_zooming();
self.setup_zoom_gesture(); self.setup_zoom_gesture();
self.setup_move_view();
} }
fn dispose(&self) { fn dispose(&self) {
self.nodes self.nodes
.borrow() .borrow()
.values() .iter()
.for_each(|(node, _)| node.unparent()) .for_each(|(node, _)| node.unparent())
} }
@@ -153,9 +203,7 @@ mod imp {
fn size_allocate(&self, _width: i32, _height: i32, baseline: i32) { fn size_allocate(&self, _width: i32, _height: i32, baseline: i32) {
let widget = &*self.obj(); let widget = &*self.obj();
let zoom_factor = self.zoom_factor.get(); for (node, point) in self.nodes.borrow().iter() {
for (node, point) in self.nodes.borrow().values() {
let (_, natural_size) = node.preferred_size(); let (_, natural_size) = node.preferred_size();
let transform = self let transform = self
@@ -163,8 +211,8 @@ mod imp {
.translate(point); .translate(point);
node.allocate( node.allocate(
(natural_size.width() as f64 / zoom_factor).ceil() as i32, natural_size.width(),
(natural_size.height() as f64 / zoom_factor).ceil() as i32, natural_size.height(),
baseline, baseline,
Some(transform), Some(transform),
); );
@@ -182,12 +230,10 @@ mod imp {
let widget = &*self.obj(); let widget = &*self.obj();
let alloc = widget.allocation(); let alloc = widget.allocation();
self.snapshot_background(widget, snapshot);
// Draw all visible children // Draw all visible children
self.nodes self.nodes
.borrow() .borrow()
.values() .iter()
// Cull nodes from rendering when they are outside the visible canvas area // Cull nodes from rendering when they are outside the visible canvas area
.filter(|(node, _)| alloc.intersect(&node.allocation()).is_some()) .filter(|(node, _)| alloc.intersect(&node.allocation()).is_some())
.for_each(|(node, _)| widget.snapshot_child(node, snapshot)); .for_each(|(node, _)| widget.snapshot_child(node, snapshot));
@@ -247,7 +293,9 @@ mod imp {
// Drag the Node around the screen. // Drag the Node around the screen.
let node = target.dynamic_cast_ref::<Node>().unwrap(); let node = target.dynamic_cast_ref::<Node>().unwrap();
let Some(canvas_node_pos) = widget.node_position(node) else { return }; let Some(canvas_node_pos) = widget.node_position(node) else {
return;
};
let canvas_cursor_pos = widget let canvas_cursor_pos = widget
.imp() .imp()
.screen_space_to_canvas_space_transform() .screen_space_to_canvas_space_transform()
@@ -270,7 +318,9 @@ mod imp {
.dynamic_cast::<super::GraphView>() .dynamic_cast::<super::GraphView>()
.expect("drag-update event is not on the GraphView"); .expect("drag-update event is not on the GraphView");
let dragged_node = widget.imp().dragged_node.borrow(); let dragged_node = widget.imp().dragged_node.borrow();
let Some(DragState { node, offset }) = dragged_node.as_ref() else { return }; let Some(DragState { node, offset }) = dragged_node.as_ref() else {
return;
};
let Some(node) = node.upgrade() else { return }; let Some(node) = node.upgrade() else { return };
let (start_x, start_y) = drag_controller let (start_x, start_y) = drag_controller
@@ -292,6 +342,78 @@ mod imp {
self.obj().add_controller(drag_controller); 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) { fn setup_scroll_zooming(&self) {
// We're only interested in the vertical axis, but for devices like touchpads, // 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 // not capturing a small accidental horizontal move may cause the scroll to be disrupted if a widget
@@ -312,9 +434,9 @@ mod imp {
.unwrap(); .unwrap();
widget.set_zoom_factor(widget.zoom_factor() + (0.1 * -delta_y), None); widget.set_zoom_factor(widget.zoom_factor() + (0.1 * -delta_y), None);
gtk::Inhibit(true) glib::Propagation::Stop
} else { } else {
gtk::Inhibit(false) glib::Propagation::Proceed
} }
}); });
self.obj().add_controller(scroll_controller); self.obj().add_controller(scroll_controller);
@@ -348,165 +470,190 @@ mod imp {
self.obj().add_controller(zoom_gesture); self.obj().add_controller(zoom_gesture);
} }
fn snapshot_background(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) { fn setup_move_view(&self) {
// Grid size and line width during neutral zoom (factor 1.0). let drag_controller = gtk::GestureDrag::new();
const NORMAL_GRID_SIZE: f32 = 20.0;
const NORMAL_GRID_LINE_WIDTH: f32 = 1.0;
let zoom_factor = self.zoom_factor.get(); drag_controller.set_button(gtk::gdk::BUTTON_MIDDLE);
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(); // TODO: set `all-scroll` cursor while dragging view
// We need to offset the lines between 0 and (excluding) `grid_size` so the grid moves with drag_controller.connect_drag_begin(|drag_controller, _, _| {
// the rest of the view when scrolling. let widget = drag_controller
// The offset is rounded so the grid is always aligned to a row of pixels. .widget()
let hadj = self .downcast::<super::GraphView>()
.hadjustment .unwrap();
.borrow()
.as_ref()
.map(|hadjustment| hadjustment.value())
.unwrap_or(0.0);
let hoffset = (grid_size - (hadj as f32 % grid_size)) % grid_size;
let vadj = self
.vadjustment
.borrow()
.as_ref()
.map(|vadjustment| vadjustment.value())
.unwrap_or(0.0);
let voffset = (grid_size - (vadj as f32 % grid_size)) % grid_size;
snapshot.push_repeat( widget.imp().move_view_state.set((0.0, 0.0));
&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)),
drag_controller.connect_drag_update(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.downcast::<super::GraphView>()
.unwrap();
let imp = widget.imp();
let state = imp.move_view_state.replace((x, y));
let delta_x = state.0 - x;
let delta_y = state.1 - y;
let hadjustment_ref = imp.hadjustment.borrow();
let vadjustment_ref = imp.vadjustment.borrow();
let hadjustment = hadjustment_ref.as_ref().unwrap();
let vadjustment = vadjustment_ref.as_ref().unwrap();
let new_hadjustment = hadjustment.value() + delta_x;
let new_vadjustment = vadjustment.value() + delta_y;
hadjustment.set_value(new_hadjustment);
vadjustment.set_value(new_vadjustment);
});
self.obj().add_controller(drag_controller);
}
fn snapshot_link(
&self,
snapshot: &gtk::Snapshot,
output_anchor: &Point,
input_anchor: &Point,
active: bool,
color: &gdk::RGBA,
) {
let output_x = output_anchor.x();
let output_y = output_anchor.y();
let input_x = input_anchor.x();
let input_y = input_anchor.y();
// 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 {
f32::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 = f32::abs(output_x - input_x) / 2.0;
let path_builder = gsk::PathBuilder::new();
path_builder.move_to(output_x, output_y);
path_builder.cubic_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,
); );
let grid_color = RGBA::new(0.137, 0.137, 0.137, 1.0); let path = path_builder.to_path();
snapshot.append_linear_gradient(
&Rect::new(0.0, voffset, alloc.width() as f32, grid_line_width),
&Point::new(0.0, 0.0),
&Point::new(alloc.width() as f32, 0.0),
&[
ColorStop::new(0.0, grid_color),
ColorStop::new(1.0, grid_color),
],
);
snapshot.pop();
snapshot.push_repeat( let stroke = gsk::Stroke::new(2.0 * (self.zoom_factor.get() as f32));
&Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32), // Use dashed line for inactive links, full line otherwise.
Some(&Rect::new(hoffset, 0.0, grid_size, alloc.height() as f32)), if active {
); stroke.set_dash(&[]);
snapshot.append_linear_gradient( } else {
&Rect::new(hoffset, 0.0, grid_line_width, alloc.height() as f32), stroke.set_dash(&[10.0, 5.0]);
&Point::new(0.0, 0.0), }
&Point::new(0.0, alloc.height() as f32),
&[ snapshot.append_stroke(&path, &stroke, color);
ColorStop::new(0.0, grid_color), }
ColorStop::new(1.0, grid_color),
], fn snapshot_dragged_link(&self, snapshot: &gtk::Snapshot, port: &Port, colors: &Colors) {
); let Some(port_anchor) = port.compute_point(&*self.obj(), &port.link_anchor()) else {
snapshot.pop(); 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 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.snapshot_link(snapshot, output_anchor, input_anchor, false, color);
} }
fn snapshot_links(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) { fn snapshot_links(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
let alloc = widget.allocation(); 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"),
};
let link_cr = snapshot.append_cairo(&graphene::Rect::new( for link in self.links.borrow().iter() {
0.0, let color = &colors.color_for_media_type(link.media_type());
0.0,
alloc.width() as f32,
alloc.height() as f32,
));
link_cr.set_line_width(2.0 * self.zoom_factor.get());
let rgba = widget
.style_context()
.lookup_color("graphview-link")
.unwrap_or(gtk::gdk::RGBA::BLACK);
link_cr.set_source_rgba(
rgba.red().into(),
rgba.green().into(),
rgba.blue().into(),
rgba.alpha().into(),
);
for (link, active) in self.links.borrow().values() {
// TODO: Do not draw links when they are outside the view // 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) { let Some((output_anchor, input_anchor)) = self.get_link_coordinates(link) else {
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 {
warn!("Could not get allocation of ports of link: {:?}", link); warn!("Could not get allocation of ports of link: {:?}", link);
} continue;
};
self.snapshot_link(
snapshot,
&output_anchor,
&input_anchor,
link.active(),
color,
);
}
if let Some(port) = self.dragged_port.upgrade() {
self.snapshot_dragged_link(&snapshot, &port, &colors);
} }
} }
/// Get coordinates for the drawn link to start at and to end at. /// Get coordinates for the drawn link to start at and to end at.
/// ///
/// # Returns /// # Returns
/// `Some((from_x, from_y, to_x, to_y))` if all objects the links refers to exist as widgets. /// `Some((output_anchor, input_anchor))` if all objects the links refers to exist as widgets
fn get_link_coordinates(&self, link: &crate::PipewireLink) -> Option<(f64, f64, f64, f64)> { /// 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 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 = let input_port = link.input_port()?;
(output_port.allocated_width() - output_port.width()) as f64 / 2.0; let input_anchor = input_port.compute_point(widget, &input_port.link_anchor())?;
let (from_x, from_y) = output_port.translate_coordinates( Some((output_anchor, input_anchor))
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))
} }
fn set_adjustment( fn set_adjustment(
@@ -609,7 +756,7 @@ impl GraphView {
self.set_property("zoom-factor", zoom_factor); 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(); let imp = self.imp();
node.set_parent(self); node.set_parent(self);
@@ -626,7 +773,7 @@ impl GraphView {
let y = imp let y = imp
.nodes .nodes
.borrow() .borrow()
.values() .iter()
.map(|node| { .map(|node| {
// Map nodes to their locations // Map nodes to their locations
let point = self.node_position(&node.0.clone().upcast()).unwrap(); let point = self.node_position(&node.0.clone().upcast()).unwrap();
@@ -642,57 +789,48 @@ impl GraphView {
}) })
.map_or(20_f32, |(_x, y)| y + 120.0); .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(); let mut nodes = self.imp().nodes.borrow_mut();
if let Some((node, _)) = nodes.remove(&id) {
if nodes.remove(node).is_some() {
node.unparent(); node.unparent();
} else { } 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) { pub fn add_link(&self, link: Link) {
if let Some((node, _)) = self.imp().nodes.borrow_mut().get_mut(&node_id) { link.connect_notify_local(
node.add_port(port_id, port); Some("active"),
} else { glib::clone!(@weak self as graph => move |_, _| {
error!( graph.queue_draw();
"Node with id {} not found when trying to add port with id {} to graph", }),
node_id, port_id );
); link.connect_notify_local(
} Some("media-type"),
} 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) { self.imp().links.borrow_mut().insert(link);
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.queue_draw(); self.queue_draw();
} }
pub fn set_link_state(&self, link_id: u32, active: bool) { pub fn remove_link(&self, link: &Link) {
if let Some((_, state)) = self.imp().links.borrow_mut().get_mut(&link_id) { let mut links = self.imp().links.borrow_mut();
*state = active; links.remove(link);
self.queue_draw();
} else { self.queue_draw();
warn!("Link state changed on unknown link (id={})", link_id);
}
} }
pub fn remove_link(&self, id: u32) { pub fn clear(&mut self) {
let mut links = self.imp().links.borrow_mut(); self.imp().links.borrow_mut().clear();
links.remove(&id); for (node, _) in self.imp().nodes.borrow_mut().drain() {
node.unparent();
}
self.queue_draw(); self.queue_draw();
} }
@@ -700,30 +838,22 @@ impl GraphView {
/// ///
/// The returned position is in canvas-space (non-zoomed, (0, 0) fixed in the middle of the canvas). /// 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> { pub(super) fn node_position(&self, node: &Node) -> Option<Point> {
self.imp() self.imp().nodes.borrow().get(node).copied()
.nodes
.borrow()
.get(&node.pipewire_id())
.map(|(_, point)| *point)
} }
pub(super) fn move_node(&self, widget: &Node, point: &Point) { 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 node_point = nodes.get_mut(widget).expect("Node is not on the graph");
.get_mut(&widget.pipewire_id())
.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. // 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_point.set_x(point.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, ));
), node_point.set_y(point.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, ));
),
);
self.queue_allocate(); self.queue_allocate();
} }

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

@@ -0,0 +1,148 @@
// 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 adw::{glib, prelude::*, subclass::prelude::*};
use pipewire::spa::param::format::MediaType;
use super::Port;
mod imp {
use super::*;
use std::cell::Cell;
use once_cell::sync::Lazy;
pub struct Link {
pub output_port: glib::WeakRef<Port>,
pub input_port: glib::WeakRef<Port>,
pub active: Cell<bool>,
pub media_type: Cell<MediaType>,
}
impl Default for Link {
fn default() -> Self {
Self {
output_port: glib::WeakRef::default(),
input_port: glib::WeakRef::default(),
active: Cell::default(),
media_type: Cell::new(MediaType::Unknown),
}
}
}
#[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(),
glib::ParamSpecUInt::builder("media-type")
.default_value(MediaType::Unknown.as_raw())
.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(),
"media-type" => self.media_type.get().as_raw().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()),
"media-type" => self
.media_type
.set(MediaType::from_raw(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);
}
pub fn media_type(&self) -> MediaType {
MediaType::from_raw(self.property("media-type"))
}
pub fn set_media_type(&self, media_type: MediaType) {
self.set_property("media-type", media_type.as_raw())
}
}
impl Default for Link {
fn default() -> Self {
Self::new()
}
}

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

@@ -0,0 +1,28 @@
// 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 port_handle;
pub use port_handle::*;
mod link;
pub use link::*;
mod zoomentry;
pub use zoomentry::*;

177
src/ui/graph/node.rs Normal file
View File

@@ -0,0 +1,177 @@
// 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 adw::{glib, gtk, prelude::*, subclass::prelude::*};
use pipewire::spa::utils::Direction;
use super::Port;
mod imp {
use super::*;
use std::{
cell::{Cell, RefCell},
collections::HashSet,
};
#[derive(glib::Properties, gtk::CompositeTemplate, Default)]
#[properties(wrapper_type = super::Node)]
#[template(file = "node.ui")]
pub struct Node {
#[property(get, set, construct_only)]
pub(super) pipewire_id: Cell<u32>,
#[property(
name = "node-name", type = String,
get = |this: &Self| this.node_name.text().to_string(),
set = |this: &Self, val| {
this.node_name.set_text(val);
this.node_name.set_tooltip_text(Some(val));
}
)]
#[template_child]
pub(super) node_name: TemplateChild<gtk::Label>,
#[property(
name = "media-name", type = String,
get = |this: &Self| this.media_name.text().to_string(),
set = |this: &Self, val| {
this.media_name.set_text(val);
this.media_name.set_tooltip_text(Some(val));
this.media_name.set_visible(!val.is_empty());
}
)]
#[template_child]
pub(super) media_name: TemplateChild<gtk::Label>,
#[template_child]
pub(super) separator: TemplateChild<gtk::Separator>,
#[template_child]
pub(super) port_grid: TemplateChild<gtk::Grid>,
pub(super) ports: RefCell<HashSet<Port>>,
}
#[glib::object_subclass]
impl ObjectSubclass for Node {
const NAME: &'static str = "HelvumNode";
type Type = super::Node;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BoxLayout>();
klass.bind_template();
klass.set_css_name("node");
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for Node {
fn constructed(&self) {
self.parent_constructed();
// Force left-to-right direction for the ports grid to avoid messed up UI when defaulting to right-to-left
self.port_grid.set_direction(gtk::TextDirection::Ltr);
// Display a grab cursor when the mouse is over the label so the user knows the node can be dragged.
self.node_name
.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
}
fn dispose(&self) {
if let Some(child) = self.obj().first_child() {
child.unparent();
}
}
}
impl WidgetImpl for Node {}
impl Node {
/// Update the internal ports grid to reflect the ports stored in the ports set.
pub fn update_ports(&self) {
// We first remove all ports from the grid, then re-add them all, so that
// ports that have been removed do not leave gaps in the grid.
while let Some(ref child) = self.port_grid.first_child() {
self.port_grid.remove(child);
}
let ports = self.ports.borrow();
let mut ports_out = Vec::new();
let mut ports_in = Vec::new();
ports
.iter()
.for_each(|port| match Direction::from_raw(port.direction()) {
Direction::Output => {
ports_out.push(port);
}
Direction::Input => {
ports_in.push(port);
}
_ => unreachable!(),
});
ports_out.sort_unstable_by_key(|port| port.name());
ports_in.sort_unstable_by_key(|port| port.name());
// In case no ports have been added to the port, hide the seperator as it is not needed
self.separator
.set_visible(!ports_out.is_empty() || !ports_in.is_empty());
for (i, port) in ports_in.into_iter().enumerate() {
self.port_grid.attach(port, 0, i.try_into().unwrap(), 1, 1);
}
for (i, port) in ports_out.into_iter().enumerate() {
self.port_grid.attach(port, 1, i.try_into().unwrap(), 1, 1);
}
}
}
}
glib::wrapper! {
pub struct Node(ObjectSubclass<imp::Node>)
@extends gtk::Widget;
}
impl Node {
pub fn new(name: &str, pipewire_id: u32) -> Self {
glib::Object::builder()
.property("node-name", name)
.property("pipewire-id", pipewire_id)
.build()
}
pub fn add_port(&self, port: Port) {
let imp = self.imp();
imp.ports.borrow_mut().insert(port);
imp.update_ports();
}
pub fn remove_port(&self, port: &Port) {
let imp = self.imp();
if imp.ports.borrow_mut().remove(port) {
imp.update_ports();
} else {
log::warn!("Tried to remove non-existant port widget from node");
}
}
}

56
src/ui/graph/node.ui Normal file
View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<template class="HelvumNode" parent="GtkWidget">
<style>
<class name="card"></class>
</style>>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<style>
<class name="node-title"></class>
</style>
<property name="orientation">vertical</property>
<property name="spacing">1</property>
<child>
<object class="GtkLabel" id="node_name">
<style>
<class name="heading"></class>
</style>
<property name="wrap">true</property>
<property name="ellipsize">PANGO_ELLIPSIZE_END</property>
<property name="lines">2</property>
<property name="max-width-chars">20</property>
</object>
</child>
<child>
<object class="GtkLabel" id="media_name">
<style>
<class name="dim-label"></class>
<class name="caption"></class>
</style>
<property name="visible">false</property>
<property name="wrap">true</property>
<property name="ellipsize">PANGO_ELLIPSIZE_END</property>
<property name="lines">2</property>
<property name="max-width-chars">20</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkSeparator" id="separator">
<!-- The node will show the seperator only once ports are added to it -->
<property name="visible">false</property>
</object>
</child>
<child>
<object class="GtkGrid" id="port_grid"></object>
</child>
</object>
</child>
</template>
</interface>

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

@@ -0,0 +1,364 @@
// 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 adw::{
gdk,
glib::{self, subclass::Signal},
gtk::{self, graphene},
prelude::*,
subclass::prelude::*,
};
use pipewire::spa::utils::Direction;
use super::PortHandle;
mod imp {
use super::*;
use std::cell::{Cell, OnceCell};
use once_cell::sync::Lazy;
use pipewire::spa::{param::format::MediaType, utils::Direction};
/// Graphical representation of a pipewire port.
#[derive(gtk::CompositeTemplate, glib::Properties)]
#[properties(wrapper_type = super::Port)]
#[template(file = "port.ui")]
pub struct Port {
#[property(get, set, construct_only)]
pub(super) pipewire_id: OnceCell<u32>,
#[property(
type = u32,
get = |_| self.media_type.get().as_raw(),
set = Self::set_media_type
)]
pub(super) media_type: Cell<MediaType>,
#[property(
type = u32,
get = |_| self.direction.get().as_raw(),
set = Self::set_direction,
construct_only
)]
pub(super) direction: Cell<Direction>,
#[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));
}
)]
#[template_child]
pub(super) label: TemplateChild<gtk::Label>,
#[template_child]
pub(super) handle: TemplateChild<PortHandle>,
}
impl Default for Port {
fn default() -> Self {
Self {
pipewire_id: OnceCell::default(),
media_type: Cell::new(MediaType::Unknown),
direction: Cell::new(Direction::Output),
label: TemplateChild::default(),
handle: TemplateChild::default(),
}
}
}
#[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_css_name("port");
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for Port {
fn constructed(&self) {
self.parent_constructed();
// Force left-to-right direction for the ports grid to avoid messed up UI when defaulting to right-to-left
self.obj().set_direction(gtk::TextDirection::Ltr);
// Display a grab cursor when the mouse is over the port so the user knows it can be dragged to another port.
self.obj()
.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
self.setup_port_drag_and_drop();
}
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 {
fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) {
match orientation {
gtk::Orientation::Horizontal => {
let (min_handle_width, nat_handle_width, _, _) =
self.handle.measure(orientation, for_size);
let (min_label_width, nat_label_width, _, _) = self
.label
.measure(orientation, i32::max(for_size - (nat_handle_width / 2), -1));
(
(min_handle_width / 2) + min_label_width,
(nat_handle_width / 2) + nat_label_width,
-1,
-1,
)
}
gtk::Orientation::Vertical => {
let (min_label_height, nat_label_height, _, _) =
self.label.measure(orientation, for_size);
let (min_handle_height, nat_handle_height, _, _) =
self.handle.measure(orientation, for_size);
(
i32::max(min_label_height, min_handle_height),
i32::max(nat_label_height, nat_handle_height),
-1,
-1,
)
}
_ => unimplemented!(),
}
}
fn size_allocate(&self, width: i32, height: i32, _baseline: i32) {
let (_, nat_handle_height, _, _) =
self.handle.measure(gtk::Orientation::Vertical, height);
let (_, nat_handle_width, _, _) =
self.handle.measure(gtk::Orientation::Horizontal, width);
match Direction::from_raw(self.obj().direction()) {
Direction::Input => {
let alloc = gtk::Allocation::new(
-nat_handle_width / 2,
(height - nat_handle_height) / 2,
nat_handle_width,
nat_handle_height,
);
self.handle.size_allocate(&alloc, -1);
let alloc = gtk::Allocation::new(
nat_handle_width / 2,
0,
width - (nat_handle_width / 2),
height,
);
self.label.size_allocate(&alloc, -1);
}
Direction::Output => {
let alloc = gtk::Allocation::new(
width - (nat_handle_width / 2),
(height - nat_handle_height) / 2,
nat_handle_width,
nat_handle_height,
);
self.handle.size_allocate(&alloc, -1);
let alloc = gtk::Allocation::new(0, 0, width - (nat_handle_width / 2), height);
self.label.size_allocate(&alloc, -1);
}
_ => unreachable!(),
}
}
}
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 Direction::from_raw(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);
}
fn set_media_type(&self, media_type: u32) {
let media_type = MediaType::from_raw(media_type);
self.media_type.set(media_type);
for css_class in ["video", "audio", "midi"] {
self.handle.remove_css_class(css_class)
}
// Color the port according to its media type.
match media_type {
MediaType::Video => self.handle.add_css_class("video"),
MediaType::Audio => self.handle.add_css_class("audio"),
MediaType::Application | MediaType::Stream => self.handle.add_css_class("midi"),
_ => {}
}
}
fn set_direction(&self, direction: u32) {
let direction = Direction::from_raw(direction);
self.direction.set(direction);
match direction {
Direction::Input => {
self.obj().set_halign(gtk::Align::Start);
self.label.set_halign(gtk::Align::Start);
}
Direction::Output => {
self.obj().set_halign(gtk::Align::End);
self.label.set_halign(gtk::Align::End);
}
_ => unreachable!(),
}
}
}
}
glib::wrapper! {
pub struct Port(ObjectSubclass<imp::Port>)
@extends gtk::Widget;
}
impl Port {
pub fn new(id: u32, name: &str, direction: Direction) -> Self {
glib::Object::builder()
.property("pipewire-id", id)
.property("direction", direction.as_raw())
.property("name", name)
.build()
}
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();
let direction = Direction::from_raw(self.direction());
graphene::Point::new(
match 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()
}
}

19
src/ui/graph/port.ui Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<template class="HelvumPort" parent="GtkWidget">
<property name="hexpand">true</property>
<child>
<object class="GtkLabel" id="label">
<property name="wrap">true</property>
<property name="ellipsize">PANGO_ELLIPSIZE_END</property>
<property name="lines">2</property>
<property name="max-width-chars">20</property>
</object>
</child>
<child>
<object class="HelvumPortHandle" id="handle"></object>
</child>
</template>
</interface>

View File

@@ -0,0 +1,84 @@
// 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 adw::{glib, gtk, prelude::*, subclass::prelude::*};
mod imp {
use super::*;
#[derive(Default)]
pub struct PortHandle {}
#[glib::object_subclass]
impl ObjectSubclass for PortHandle {
const NAME: &'static str = "HelvumPortHandle";
type Type = super::PortHandle;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_css_name("port-handle");
}
}
impl ObjectImpl for PortHandle {
fn constructed(&self) {
self.parent_constructed();
let obj = &*self.obj();
obj.set_halign(gtk::Align::Center);
obj.set_valign(gtk::Align::Center);
}
}
impl WidgetImpl for PortHandle {
fn request_mode(&self) -> gtk::SizeRequestMode {
gtk::SizeRequestMode::ConstantSize
}
fn measure(&self, _orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) {
(Self::HANDLE_SIZE, Self::HANDLE_SIZE, -1, -1)
}
}
impl PortHandle {
pub const HANDLE_SIZE: i32 = 14;
}
}
glib::wrapper! {
pub struct PortHandle(ObjectSubclass<imp::PortHandle>)
@extends gtk::Widget;
}
impl PortHandle {
pub fn new() -> Self {
glib::Object::new()
}
pub fn get_link_anchor(&self) -> gtk::graphene::Point {
gtk::graphene::Point::new(
imp::PortHandle::HANDLE_SIZE as f32 / 2.0,
imp::PortHandle::HANDLE_SIZE as f32 / 2.0,
)
}
}
impl Default for PortHandle {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,6 +1,6 @@
use gtk::{glib, prelude::*, subclass::prelude::*}; use adw::{glib, gtk, prelude::*, subclass::prelude::*};
use crate::view; use super::GraphView;
mod imp { mod imp {
use std::cell::RefCell; use std::cell::RefCell;
@@ -13,7 +13,7 @@ mod imp {
#[derive(gtk::CompositeTemplate)] #[derive(gtk::CompositeTemplate)]
#[template(file = "zoomentry.ui")] #[template(file = "zoomentry.ui")]
pub struct ZoomEntry { pub struct ZoomEntry {
pub graphview: RefCell<Option<view::GraphView>>, pub graphview: RefCell<Option<GraphView>>,
#[template_child] #[template_child]
pub zoom_out_button: TemplateChild<gtk::Button>, pub zoom_out_button: TemplateChild<gtk::Button>,
#[template_child] #[template_child]
@@ -34,6 +34,7 @@ mod imp {
menu.append(Some("200%"), Some("win.set-zoom(2.0)")); menu.append(Some("200%"), Some("win.set-zoom(2.0)"));
menu.append(Some("300%"), Some("win.set-zoom(3.0)")); menu.append(Some("300%"), Some("win.set-zoom(3.0)"));
let popover = gtk::PopoverMenu::from_model(Some(&menu)); let popover = gtk::PopoverMenu::from_model(Some(&menu));
popover.set_position(gtk::PositionType::Top);
ZoomEntry { ZoomEntry {
graphview: Default::default(), graphview: Default::default(),
@@ -109,11 +110,9 @@ mod imp {
fn properties() -> &'static [glib::ParamSpec] { fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![ vec![glib::ParamSpecObject::builder::<GraphView>("zoomed-widget")
glib::ParamSpecObject::builder::<view::GraphView>("zoomed-widget") .flags(glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT)
.flags(glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT) .build()]
.build(),
]
}); });
PROPERTIES.as_ref() PROPERTIES.as_ref()
@@ -129,15 +128,17 @@ mod imp {
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() { match pspec.name() {
"zoomed-widget" => { "zoomed-widget" => {
let widget: view::GraphView = value.get().unwrap(); let widget: Option<GraphView> = value.get().unwrap();
widget.connect_notify_local( if let Some(ref widget) = widget {
Some("zoom-factor"), widget.connect_notify_local(
clone!(@weak self as imp => move |graphview, _| { Some("zoom-factor"),
imp.update_zoom_factor_text(graphview.zoom_factor()); clone!(@weak self as imp => move |graphview, _| {
}), imp.update_zoom_factor_text(graphview.zoom_factor());
); }),
self.update_zoom_factor_text(widget.zoom_factor()); );
*self.graphview.borrow_mut() = Some(widget); self.update_zoom_factor_text(widget.zoom_factor());
}
*self.graphview.borrow_mut() = widget;
} }
_ => unimplemented!(), _ => unimplemented!(),
} }
@@ -149,11 +150,11 @@ mod imp {
impl ZoomEntry { impl ZoomEntry {
/// Update the text contained in the combobox's entry to reflect the provided zoom factor. /// 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) { fn update_zoom_factor_text(&self, zoom_factor: f64) {
self.entry self.entry
.buffer() .buffer()
.set_text(&format!("{factor:.0}%", factor = zoom_factor * 100.)); .set_text(format!("{factor:.0}%", factor = zoom_factor * 100.));
} }
} }
} }
@@ -164,7 +165,7 @@ glib::wrapper! {
} }
impl ZoomEntry { impl ZoomEntry {
pub fn new(zoomed_widget: &view::GraphView) -> Self { pub fn new(zoomed_widget: &GraphView) -> Self {
glib::Object::builder() glib::Object::builder()
.property("zoomed-widget", zoomed_widget) .property("zoomed-widget", zoomed_widget)
.build() .build()

View File

@@ -1,26 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<interface> <interface>
<template class="HelvumZoomEntry" parent="GtkBox"> <template class="HelvumZoomEntry" parent="GtkBox">
<child> <property name="spacing">12</property>
<object class="GtkButton" id="zoom_out_button">
<property name="icon-name">zoom-out-symbolic</property>
<property name="tooltip-text">Zoom out</property>
</object>
</child>
<child> <child>
<object class="GtkEntry" id="entry"> <object class="GtkEntry" id="entry">
<property name="secondary-icon-name">go-down-symbolic</property> <property name="secondary-icon-name">go-down-symbolic</property>
<property name="input-purpose">digits</property> <property name="input-purpose">digits</property>
<property name="max-width-chars">5</property>
<style>
<class name="osd"/>
<class name="rounded"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="zoom_out_button">
<property name="icon-name">zoom-out-symbolic</property>
<property name="tooltip-text">Zoom out</property>
<style>
<class name="osd"/>
<class name="rounded"/>
</style>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkButton" id="zoom_in_button"> <object class="GtkButton" id="zoom_in_button">
<property name="icon-name">zoom-in-symbolic</property> <property name="icon-name">zoom-in-symbolic</property>
<property name="tooltip-text">Zoom in</property> <property name="tooltip-text">Zoom in</property>
<style>
<class name="osd"/>
<class name="rounded"/>
</style>
</object> </object>
</child> </child>
<style>
<class name="linked"/>
</style>
</template> </template>
</interface> </interface>

View File

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

65
src/ui/window.rs Normal file
View File

@@ -0,0 +1,65 @@
use adw::{gio, gtk, prelude::*, subclass::prelude::*};
use super::graph;
mod imp {
use super::*;
#[derive(Default, gtk::CompositeTemplate, glib::Properties)]
#[properties(wrapper_type = super::Window)]
#[template(file = "window.ui")]
pub struct Window {
#[template_child]
pub header_bar: TemplateChild<adw::HeaderBar>,
#[template_child]
#[property(type = adw::Banner, get = |_| self.connection_banner.clone())]
pub connection_banner: TemplateChild<adw::Banner>,
#[template_child]
#[property(type = graph::GraphView, get = |_| self.graph.clone())]
pub graph: TemplateChild<graph::GraphView>,
}
#[glib::object_subclass]
impl ObjectSubclass for Window {
const NAME: &'static str = "HelvumWindow";
type Type = super::Window;
type ParentType = adw::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
// Ensure custom types are registered
graph::GraphView::ensure_type();
graph::ZoomEntry::ensure_type();
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for Window {}
impl WidgetImpl for Window {}
impl WindowImpl for Window {}
impl ApplicationWindowImpl for Window {}
impl AdwApplicationWindowImpl for Window {}
}
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap;
}
impl Window {
pub fn new() -> Self {
glib::Object::new()
}
}
impl Default for Window {
fn default() -> Self {
Self::new()
}
}

66
src/ui/window.ui Normal file
View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="Adw" version="1.4"/>
<menu id="primary_menu">
<section>
<item>
<attribute name="label">_About Helvum</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
</menu>
<template class="HelvumWindow" parent="AdwApplicationWindow">
<property name="default-width">1280</property>
<property name="default-height">720</property>
<property name="title">Helvum - Pipewire Patchbay</property>
<child>
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar" id="header_bar">
<child type="end">
<object class="GtkMenuButton">
<property name="icon-name">open-menu-symbolic</property>
<property name="menu-model">primary_menu</property>
</object>
</child>
</object>
</child>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwBanner" id="connection_banner">
<property name="title" translatable="yes">Disconnected</property>
<property name="revealed">false</property>
</object>
</child>
<child>
<object class="GtkOverlay">
<child>
<object class="GtkScrolledWindow">
<child>
<object class="HelvumGraphView" id="graph">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="HelvumZoomEntry">
<property name="zoomed-widget">graph</property>
<property name="halign">end</property>
<property name="valign">end</property>
<property name="margin-end">24</property>
<property name="margin-bottom">24</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</template>
</interface>

View File

@@ -1,179 +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::{glib, prelude::*, subclass::prelude::*};
use pipewire::spa::Direction;
use std::collections::HashMap;
mod imp {
use glib::ParamFlags;
use once_cell::sync::Lazy;
use super::*;
use std::cell::{Cell, RefCell};
pub struct Node {
pub(super) pipewire_id: Cell<u32>,
pub(super) grid: gtk::Grid,
pub(super) label: gtk::Label,
pub(super) ports: RefCell<HashMap<u32, crate::view::port::Port>>,
pub(super) num_ports_in: Cell<i32>,
pub(super) num_ports_out: Cell<i32>,
}
#[glib::object_subclass]
impl ObjectSubclass for Node {
const NAME: &'static str = "HelvumNode";
type Type = super::Node;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BinLayout>();
}
fn new() -> Self {
let grid = gtk::Grid::new();
let label = gtk::Label::new(None);
label.set_wrap(true);
label.set_lines(2);
label.set_max_width_chars(20);
label.set_ellipsize(gtk::pango::EllipsizeMode::End);
grid.attach(&label, 0, 0, 2, 1);
// Display a grab cursor when the mouse is over the label so the user knows the node can be dragged.
label.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
Self {
pipewire_id: Cell::new(0),
grid,
label,
ports: RefCell::new(HashMap::new()),
num_ports_in: Cell::new(0),
num_ports_out: Cell::new(0),
}
}
}
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();
}
}
impl WidgetImpl for Node {}
}
glib::wrapper! {
pub struct Node(ObjectSubclass<imp::Node>)
@extends gtk::Widget;
}
impl Node {
pub fn new(name: &str, pipewire_id: u32) -> Self {
glib::Object::builder()
.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) {
let imp = self.imp();
match port.direction() {
Direction::Input => {
imp.grid.attach(&port, 0, imp.num_ports_in.get() + 1, 1, 1);
imp.num_ports_in.set(imp.num_ports_in.get() + 1);
}
Direction::Output => {
imp.grid.attach(&port, 1, imp.num_ports_out.get() + 1, 1, 1);
imp.num_ports_out.set(imp.num_ports_out.get() + 1);
}
}
imp.ports.borrow_mut().insert(id, 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) {
let imp = self.imp();
if let Some(port) = imp.ports.borrow_mut().remove(&id) {
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),
}
port.unparent();
}
}
}

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")
}
}