48 Commits
0.3.2 ... 0.4.1

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

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

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

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

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

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

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

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

The commit removes the extra manual scaling so nodes get allocated properly when zoomed.
2023-07-27 13:14:03 +02:00
Carlos Martín Nieto
84570f44bf port: add the dragged port as the drag icon
This makes it the port label follows your cursor around instead of the generic
text document icon that doesn't make a lot of sense here.
2023-03-14 19:52:54 +01:00
Tom A. Wagner
69257ffa09 Release v0.4.0 2023-02-12 21:23:12 +01:00
Tom A. Wagner
91d7e10bdc view: Improve layout of labels on nodes and ports
This sets a maximum width of 20 chars on labels on nodes and ports.
Longer labels will wrap to a second line.

For labels longer than two lines, the label is ellipsized at the end.
The full label can still be viewed via hovering for a tooltip.

Co-authored-by: Roger Roger <me@rogerrogert.de>
2023-02-12 20:58:34 +01:00
Roger Roger
fe05282f5a Prefer description over nick for node name 2023-02-12 20:38:18 +01:00
Tom A. Wagner
146fb65dc5 Update gtk and glib dependencies 2023-02-12 20:23:56 +01:00
Tom A. Wagner
4ed52bb00d flatpak: Update to gnome 43 2023-02-12 13:30:49 +01:00
Tom A. Wagner
24b1d0dff7 Update dependencies 2023-02-12 13:14:53 +01:00
Josh Veitch-Michaelis
b5f9a706b7 Update README.md for gnome 42 2022-12-03 02:36:15 +00:00
Tom A. Wagner
b115e6f50c zooming: Add support for zooming via zoom gesture (two finger touchpad/touchscreen zooming) 2022-11-09 19:21:32 +00:00
Tom A. Wagner
1d10c179cc zooming: Add support for zooming by scrolling while holding CTRL 2022-11-09 19:21:32 +00:00
Tom A. Wagner
727326aca4 zooming: Add a control widget that allows for changing the zoom level of the graph
The new widget sits in the headerbar, and allows for changing the zoom level with "+" and "-" buttons, via text entry, and a dropdown where a list of predefined levels can be clicked.
2022-11-09 19:21:32 +00:00
Tom A. Wagner
56e73d33c9 graphview: Make graph widget zoomable via a zoom-factor property.
This adds a `zoom-factor` property.

Changing the property zooms in the entire widget including background, nodes, links, etc.
2022-11-09 19:21:32 +00:00
Tom A. Wagner
bcef1300ca ci: Update to fedora 36 and latest rust 2022-11-09 17:50:04 +01:00
Tom A. Wagner
4bf586e66c view: graph: Implement gtk::Scrollable and do not render content outside the displayed area
The graphview widget now implements the gtk::Scrollable interface, so it is no longer wrapped inside a gtk::Viewport when used in a gtk::ScrollWindow anymore.
Instead, it repositions its content itself when scrolled, and also skips rendering any content that is not inside the visible area, which should improve performance
when the graph becomes big.

This commit also makes the canvas a fixed size, with much space to each side from the starting area.
This will hopefully improve user experience, as the view can now be moved around more freely, and nodes can be dragged left and above the starting area.
2022-08-25 13:07:37 +02:00
Tom A. Wagner
637ce104df view: Node,Port: Store pipewire Id as property on node, and make its name a property too. 2022-07-21 19:14:17 +02:00
Tom A. Wagner
df72a68815 graphview: draw the background grid via CSS instead of manually with cairo
This makes gtk draw the background grid for us via CSS, instead of manually drawing each line via cairo.

This improves performance, as the grid may now be drawn via GPU, and gets rid of the custom drawing code we had.
2022-05-03 14:04:54 +02:00
Tom A. Wagner
52e48cc0a7 Adjust license headers to reflect gpl-3.0-only license. 2022-04-19 10:14:01 +02:00
Tom A. Wagner
9f3754150a flatpak: Update runtime to gnome 42 2022-04-11 10:01:13 +02:00
Tom A. Wagner
6ce5b2e367 Update dependencies 2022-03-22 18:08:02 +01:00
Tom A. Wagner
094681637e Release v0.3.4 2022-02-02 10:08:12 +01:00
Tom A. Wagner
6f92fbdb8f readme: Add flathub badge 2022-01-31 13:32:03 +01:00
Tom A. Wagner
e38426c09f Update dependencies, fix segmentation fault
Updating glib to 0.15.4 fixes a segmentation fault that could occur when logging a message with glib structured logging.
2022-01-31 13:17:55 +01:00
Tom A. Wagner
85e249cb32 Release 0.3.3 2022-01-28 13:49:49 +01:00
Tom A. Wagner
c54aed2e14 Update dependencies 2022-01-28 13:49:49 +01:00
Tom A. Wagner
6da232debf meson: Fix incorrect output file name
This fixes a typo from 96c61e43 that caused the binary to be generated with the wrong name.
2022-01-28 13:48:46 +01:00
Tom A. Wagner
96c61e43d2 meson: Remove custom build scripts 2022-01-28 13:01:35 +01:00
Tom A. Wagner
872ef7890d readme: Credit gtk-rust-template 2022-01-28 12:11:32 +01:00
Tom A. Wagner
76ad8d11d7 Change application id to org.pipewire.Helvum 2022-01-28 10:14:21 +01:00
Tom A. Wagner
4075b66865 deps: Update gtk-rs to latest release 2022-01-17 12:00:00 +01:00
Tom A. Wagner
96182826e4 logging: Use glib as log backend instead of env_logger.
This makes log output use the same logger as gtk itself and as most other gtk applications
2022-01-11 12:15:14 +01:00
Mihai Fufezan
e1fbb0cf49 Use gtk4-update-icon-cache instead of gtk3 one 2021-12-18 19:50:39 +00:00
Alireza Haghshenas
3653f2bb11 Emphasized that the flatpak-builder command needs be run inside a local clone of the project 2021-12-10 04:34:49 +00:00
Tom A. Wagner
56523f1b30 docs: Add documentation for making a release 2021-12-01 19:56:17 +01:00
Tom A. Wagner
7818bed159 Add appstream metadata file 2021-12-01 19:56:17 +01:00
33 changed files with 2389 additions and 1582 deletions

View File

@@ -10,14 +10,14 @@ stages:
- extras - extras
variables: variables:
FDO_UPSTREAM_REPO: 'ryuukyu/helvum' FDO_UPSTREAM_REPO: 'pipewire/helvum'
# Version and tag for our current container # Version and tag for our current container
.fedora: .fedora:
variables: variables:
FDO_DISTRIBUTION_VERSION: '35' FDO_DISTRIBUTION_VERSION: '38'
# Update this to trigger a container rebuild # Update this to trigger a container rebuild
FDO_DISTRIBUTION_TAG: '2021-11-23.0' FDO_DISTRIBUTION_TAG: '2023-08-17.0'
build-fedora-container: build-fedora-container:
extends: extends:

810
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
[package] [package]
name = "helvum" name = "helvum"
version = "0.3.2" version = "0.4.1"
authors = ["Tom A. Wagner <tom.a.wagner@protonmail.com>"] authors = ["Tom A. 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/ryuukyu/helvum" repository = "https://gitlab.freedesktop.org/pipewire/helvum"
readme = "README.md" readme = "README.md"
keywords = ["pipewire", "gtk", "patchbay", "gui", "utility"] keywords = ["pipewire", "gtk", "patchbay", "gui", "utility"]
categories = ["gui", "multimedia"] categories = ["gui", "multimedia"]
@@ -14,10 +14,10 @@ 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.4" pipewire = "0.7"
gtk = { version = "0.3", package = "gtk4" } gtk = { version = "0.7", package = "gtk4" }
glib = { version = "0.18", features = ["log"] }
log = "0.4.11" log = "0.4.11"
env_logger = "0.9.0"
once_cell = "1.7.2" once_cell = "1.7.2"

View File

@@ -2,8 +2,9 @@ Helvum is a GTK-based patchbay for pipewire, inspired by the JACK tool [catia](h
![Screenshot](docs/screenshot.png) ![Screenshot](docs/screenshot.png)
[![Packaging status](https://repology.org/badge/vertical-allrepos/helvum.svg)](https://repology.org/project/helvum/versions) <a href="https://flathub.org/apps/details/org.pipewire.Helvum"><img src="https://flathub.org/assets/badges/flathub-badge-en.png" width="300"/></a>
<a href="https://repology.org/project/helvum/versions"><img src="https://repology.org/badge/vertical-allrepos/helvum.svg" width="300"/></a>
# Features planned # Features planned
@@ -14,9 +15,7 @@ More suggestions are welcome!
# Building # Building
## Via flatpak (recommended) ## Via flatpak
The recommended way to build is using flatpak, which will take care of all dependencies and avoid any problems that may come from different system configurations.
If you don't have the flathub repo in your remote-list for flatpak you will need to add that first: If you don't have the flathub repo in your remote-list for flatpak you will need to add that first:
```shell ```shell
$ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo $ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
@@ -24,17 +23,17 @@ $ 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}//41 org.freedesktop.Sdk.Extension.rust-stable//21.08 org.freedesktop.Sdk.Extension.llvm12//21.08 $ flatpak install org.gnome.{Platform,Sdk}//43 org.freedesktop.Sdk.Extension.rust-stable//22.08 org.freedesktop.Sdk.Extension.llvm14//22.08
``` ```
To compile and install as a flatpak, run To compile and install as a flatpak, clone the project, change to the project directory, and run:
```shell ```shell
$ flatpak-builder --install flatpak-build/ build-aux/org.freedesktop.ryuukyu.Helvum.json $ flatpak-builder --install flatpak-build/ build-aux/org.pipewire.Helvum.json
``` ```
You can then run the app via You can then run the app via
```shell ```shell
$ flatpak run org.freedesktop.ryuukyu.Helvum $ flatpak run org.pipewire.Helvum
``` ```
## Manually ## Manually
@@ -56,6 +55,9 @@ $ meson install
in the repository root. in the repository root.
This will install the compiled project files into `/usr/local`. This will install the compiled project files into `/usr/local`.
# License # License and Credits
Helvum is distributed under the terms of the GPL3 license. Helvum is distributed under the terms of the GPL3 license.
See LICENSE for more information. See LICENSE for more information.
Parts of the build system were taken from the [gtk-rust-template](https://gitlab.gnome.org/World/Rust/gtk-rust-template) project,
which is provided under the terms of the [MIT license](https://gitlab.gnome.org/World/Rust/gtk-rust-template/-/blob/master/LICENSE.md).

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
export MESON_BUILD_ROOT="$1"
export MESON_SOURCE_ROOT="$2"
export CARGO_TARGET_DIR="$MESON_BUILD_ROOT"/target
export CARGO_HOME="$MESON_BUILD_ROOT"/cargo-home
if [[ $4 = "development" ]]
then
echo "DEBUG MODE"
cargo build --manifest-path \
"$MESON_SOURCE_ROOT/Cargo.toml" && \
cp "$CARGO_TARGET_DIR/debug/$5" "$3"
else
echo "RELEASE MODE"
cargo build --manifest-path \
"$MESON_SOURCE_ROOT/Cargo.toml" --release && \
cp "$CARGO_TARGET_DIR/release/$5" "$3"
fi

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env python3
from os import environ, path
from subprocess import call
if not environ.get('DESTDIR', ''):
PREFIX = environ.get('MESON_INSTALL_PREFIX', '/usr/local')
DATA_DIR = path.join(PREFIX, 'share')
print('Updating icon cache...')
call(['gtk-update-icon-cache', '-qtf', path.join(DATA_DIR, 'icons/hicolor')])
print("Updating desktop database...")
call(["update-desktop-database", path.join(DATA_DIR, 'applications')])

View File

@@ -1,11 +1,11 @@
{ {
"app-id": "org.freedesktop.ryuukyu.Helvum", "app-id": "org.pipewire.Helvum",
"runtime": "org.gnome.Platform", "runtime": "org.gnome.Platform",
"runtime-version": "41", "runtime-version": "43",
"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.llvm12" "org.freedesktop.Sdk.Extension.llvm14"
], ],
"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/llvm12/bin", "append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm14/bin",
"prepend-ld-library-path": "/usr/lib/sdk/llvm12/lib", "prepend-ld-library-path": "/usr/lib/sdk/llvm14/lib",
"build-args": [ "build-args": [
"--share=network" "--share=network"
] ]

View File

Before

Width:  |  Height:  |  Size: 589 B

After

Width:  |  Height:  |  Size: 589 B

View File

@@ -10,7 +10,7 @@
id="svg11300" id="svg11300"
sodipodi:version="0.32" sodipodi:version="0.32"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)" inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
sodipodi:docname="org.freedesktop.ryuukyu.Helvum.Source.svg" sodipodi:docname="org.pipewire.Helvum.Source.svg"
inkscape:output_extension="org.inkscape.output.svg.inkscape" inkscape:output_extension="org.inkscape.output.svg.inkscape"
version="1.0" version="1.0"
style="display:inline;enable-background:new" style="display:inline;enable-background:new"

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -22,3 +22,28 @@ install_data(
desktop_file, desktop_file,
install_dir: datadir / 'applications' install_dir: datadir / 'applications'
) )
appdata_conf = configuration_data()
appdata_conf.set('app-id', base_id)
appdata_file = configure_file(
input: '@0@.metainfo.xml.in'.format(base_id),
output: '@BASENAME@',
configuration: appdata_conf
)
# Validate Appdata
if appstream_util.found()
test(
'validate-appdata',
appstream_util,
args: [
'validate', '--nonet', appdata_file
],
)
endif
install_data(
appdata_file,
install_dir: datadir / 'metainfo'
)

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com> -->
<component type="desktop-application">
<id>@app-id@</id>
<metadata_license>CC-BY-SA-4.0</metadata_license>
<project_license>GPL-3.0-only</project_license>
<name>Helvum</name>
<summary>Patchbay for PipeWire</summary>
<description>
<p>
Helvum is a graphical patchbay for PipeWire.
It allows creating and removing connections between applications and/or devices to reroute
flow of audio, video and MIDI data to where it is needed.
</p>
</description>
<screenshots>
<screenshot type="default">
<image>https://gitlab.freedesktop.org/pipewire/helvum/-/raw/main/docs/screenshot.png</image>
</screenshot>
</screenshots>
<launchable type="desktop-id">@app-id@.desktop</launchable>
<url type="homepage">https://gitlab.freedesktop.org/pipewire/helvum</url>
<url type="bugtracker">https://gitlab.freedesktop.org/pipewire/helvum/-/issues</url>
<content_rating type="oars-1.0" />
<releases>
<release version="0.4.1" date="2023-08-18" />
<release version="0.4.0" date="2023-02-12" />
<release version="0.3.4" date="2022-02-02" />
<release version="0.3.3" date="2022-01-28" />
<release version="0.3.2" date="2021-11-30" />
<release version="0.3.1" date="2021-09-30" />
<release version="0.3.0" date="2021-08-08" />
<release version="0.2.1" date="2021-06-06" />
<release version="0.2.0" date="2021-05-21" />
<release version="0.1.0" date="2021-01-12" />
</releases>
<kudos>
<kodu>HiDpiIcon</kodu>
<kudo>ModernToolkit</kudo>
</kudos>
<developer_name>Tom A. Wagner</developer_name>
<update_contact>tom.a.wagner@protonmail.com</update_contact>
</component>

22
docs/making_a_release.md Normal file
View File

@@ -0,0 +1,22 @@
# Making a release
The following describes the process of making a new release:
1. In `data/org.pipewire.Helvum.metainfo.xml.in`,
add a new `<release>` tag to the releases section with the appropriate version and date.
2. In `meson.build` and `Cargo.toml`, bumb the projects version to the new version.
3. Ensure cargo dependencies are up-to-date by running `cargo outdated` (may require running `cargo install cargo-outdated`) and updating outdated dependencies (including the versions specified in `Cargo.lock`).
4. Commit the changes with the a message of the format "Release x.y.z"
5. Add a tag to the release with the new version and a description from describing the changes as a message (run `git tag -a x.y.z`, then write the message)
6. Make a **new** meson build directory and run `meson dist`.
Two files should be created in a `meson-dist` subdirectory:
`helvum-x.y.z.tar.xz` and
`helvum-x.y.z.tar.xz.sha256sum`
7. Push the new commit and tag to upstream, then create a new release on gitlab from the new tag, the description from the tags message formatted as markdown, and also add the two files from step 6 to the description.

View File

@@ -1,20 +1,22 @@
project( project(
'helvum', 'helvum',
'rust', 'rust',
version: '0.3.2', version: '0.4.1',
license: 'GPL-3.0', license: 'GPL-3.0',
meson_version: '>=0.50.0' meson_version: '>=0.59.0'
) )
base_id = 'org.freedesktop.ryuukyu.Helvum' gnome = import('gnome')
dependency('glib-2.0', version: '>= 2.48') base_id = 'org.pipewire.Helvum'
dependency('glib-2.0', version: '>= 2.66')
dependency('gtk4', version: '>= 4.4.0') dependency('gtk4', version: '>= 4.4.0')
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)
appstream_util = find_program('appstream-util', required: false)
cargo = find_program('cargo', required: true) cargo = find_program('cargo', required: true)
cargo_script = find_program('build-aux/cargo.sh')
prefix = get_option('prefix') prefix = get_option('prefix')
bindir = prefix / get_option('bindir') bindir = prefix / get_option('bindir')
@@ -23,16 +25,14 @@ iconsdir = datadir / 'icons'
meson.add_dist_script( meson.add_dist_script(
'build-aux/dist-vendor.sh', 'build-aux/dist-vendor.sh',
meson.build_root() / 'meson-dist' / meson.project_name() + '-' + meson.project_version(), meson.project_build_root() / 'meson-dist' / meson.project_name() + '-' + meson.project_version(),
meson.source_root() meson.project_source_root()
)
cargo_sources = files(
'Cargo.toml',
'Cargo.lock',
) )
subdir('src') subdir('src')
subdir('data') subdir('data')
meson.add_install_script('build-aux/meson_post_install.py') gnome.post_install(
gtk_update_icon_cache: true,
update_desktop_database: true,
)

View File

@@ -1,11 +1,8 @@
// application.rs
//
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com> // Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation.
// (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -17,21 +14,15 @@
// //
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
use std::cell::RefCell;
use gtk::{ use gtk::{
gio, gio,
glib::{self, clone, Continue, Receiver}, glib::{self, clone, Receiver},
prelude::*, prelude::*,
subclass::prelude::*, subclass::prelude::*,
}; };
use log::{info, warn}; 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");
@@ -42,8 +33,8 @@ mod imp {
#[derive(Default)] #[derive(Default)]
pub struct Application { pub struct Application {
pub(super) graphview: view::GraphView, pub(super) graphview: ui::graph::GraphView,
pub(super) pw_sender: OnceCell<RefCell<Sender<GtkMessage>>>, pub(super) graph_manager: OnceCell<GraphManager>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@@ -55,11 +46,17 @@ mod imp {
impl ObjectImpl for Application {} impl ObjectImpl for Application {}
impl ApplicationImpl for Application { impl ApplicationImpl for Application {
fn activate(&self, app: &Self::Type) { fn activate(&self) {
let scrollwindow = gtk::ScrolledWindowBuilder::new() let app = &*self.obj();
let scrollwindow = gtk::ScrolledWindow::builder()
.child(&self.graphview) .child(&self.graphview)
.build(); .build();
let window = gtk::ApplicationWindowBuilder::new() let headerbar = gtk::HeaderBar::new();
let zoomentry = ui::graph::ZoomEntry::new(&self.graphview);
headerbar.pack_end(&zoomentry);
let window = gtk::ApplicationWindow::builder()
.application(app) .application(app)
.default_width(1280) .default_width(1280)
.default_height(720) .default_height(720)
@@ -69,16 +66,28 @@ mod imp {
window window
.settings() .settings()
.set_gtk_application_prefer_dark_theme(true); .set_gtk_application_prefer_dark_theme(true);
window.set_titlebar(Some(&headerbar));
let zoom_set_action =
gio::SimpleAction::new("set-zoom", Some(&f64::static_variant_type()));
zoom_set_action.connect_activate(
clone!(@weak self.graphview as graphview => move|_, param| {
let zoom_factor = param.unwrap().get::<f64>().unwrap();
graphview.set_zoom_factor(zoom_factor, None)
}),
);
window.add_action(&zoom_set_action);
window.show(); window.show();
} }
fn startup(&self, app: &Self::Type) { fn startup(&self) {
self.parent_startup(app); self.parent_startup();
// 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.as_bytes()); 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,
@@ -101,16 +110,15 @@ impl Application {
gtk_receiver: Receiver<PipewireMessage>, gtk_receiver: Receiver<PipewireMessage>,
pw_sender: Sender<GtkMessage>, pw_sender: Sender<GtkMessage>,
) -> Self { ) -> Self {
let app: Application = let app: Application = glib::Object::builder()
glib::Object::new(&[("application-id", &"org.freedesktop.ryuukyu.Helvum")]) .property("application-id", "org.pipewire.Helvum")
.expect("Failed to create new Application"); .build();
let imp = imp::Application::from_instance(&app); let imp = app.imp();
imp.pw_sender
.set(RefCell::new(pw_sender)) imp.graph_manager
// Discard the returned sender, as it does not implement `Debug`. .set(GraphManager::new(&imp.graphview, pw_sender, gtk_receiver))
.map_err(|_| ()) .expect("Should be able to set graph manager");
.expect("pw_sender field was already set");
// Add <Control-Q> shortcut for quitting the application. // Add <Control-Q> shortcut for quitting the application.
let quit = gtk::gio::SimpleAction::new("quit", None); let quit = gtk::gio::SimpleAction::new("quit", None);
@@ -120,145 +128,6 @@ impl Application {
app.set_accels_for_action("app.quit", &["<Control>Q"]); app.set_accels_for_action("app.quit", &["<Control>Q"]);
app.add_action(&quit); app.add_action(&quit);
// React to messages received from the pipewire thread.
gtk_receiver.attach(
None,
clone!(
@weak app => @default-return Continue(true),
move |msg| {
match msg {
PipewireMessage::NodeAdded{ id, name, node_type } => app.add_node(id, name.as_str(), node_type),
PipewireMessage::PortAdded{ id, node_id, name, direction, media_type } => app.add_port(id, name.as_str(), node_id, direction, media_type),
PipewireMessage::LinkAdded{ id, node_from, port_from, node_to, port_to, active} => app.add_link(id, node_from, port_from, node_to, port_to, active),
PipewireMessage::LinkStateChanged { id, active } => app.link_state_changed(id, active), // TODO
PipewireMessage::NodeRemoved { id } => app.remove_node(id),
PipewireMessage::PortRemoved { id, node_id } => app.remove_port(id, node_id),
PipewireMessage::LinkRemoved { id } => app.remove_link(id)
};
Continue(true)
}
),
);
app 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);
imp::Application::from_instance(self).graphview.add_node(
id,
view::Node::new(name),
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 imp = imp::Application::from_instance(self);
let port = view::Port::new(id, name, direction, media_type);
// Create or delete a link if the widget emits the "port-toggled" signal.
if let Err(e) = 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
}),
) {
warn!("Failed to connect to \"port-toggled\" signal: {}", e);
}
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.
imp::Application::from_instance(self).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" }
);
imp::Application::from_instance(self)
.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 imp = imp::Application::from_instance(self);
let sender = 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);
let imp = imp::Application::from_instance(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);
let imp = imp::Application::from_instance(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);
let imp = imp::Application::from_instance(self);
imp.graphview.remove_link(id);
}
} }

272
src/graph_manager.rs Normal file
View File

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

View File

@@ -1,11 +1,8 @@
// main.rs
//
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com> // Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation.
// (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -18,18 +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 gtk::{ use gtk::prelude::*;
glib::{self, PRIORITY_DEFAULT},
prelude::*,
};
use pipewire::spa::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.
@@ -38,7 +33,7 @@ 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,
@@ -53,9 +48,7 @@ enum PipewireMessage {
}, },
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,
}, },
@@ -96,8 +89,20 @@ pub struct PipewireLink {
pub port_to: u32, pub port_to: u32,
} }
static GLIB_LOGGER: glib::GlibLogger = glib::GlibLogger::new(
glib::GlibLoggerFormat::Structured,
glib::GlibLoggerDomain::CrateTarget,
);
fn init_glib_logger() {
log::set_logger(&GLIB_LOGGER).expect("Failed to set logger");
// Glib does not have a "Trace" log level, so only print messages "Debug" or higher priority.
log::set_max_level(log::LevelFilter::Debug);
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init(); init_glib_logger();
gtk::init()?; gtk::init()?;
// Aquire main context so that we can attach the gtk channel later. // Aquire main context so that we can attach the gtk channel later.
@@ -105,7 +110,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) = glib::MainContext::channel(glib::Priority::DEFAULT);
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,32 +1,31 @@
rust_sources = files( cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ]
'application.rs', cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ]
'main.rs',
'pipewire_connection.rs', if get_option('profile') == 'default'
'pipewire_connection/state.rs', cargo_options += [ '--release' ]
'style.css', rust_target = 'release'
'view/graph_view.rs', message('Building in release mode')
'view/mod.rs', else
'view/node.rs', rust_target = 'debug'
'view/port.rs', message('Building in debug mode')
) endif
cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ]
custom_target( custom_target(
'cargo-build', 'cargo-build',
build_by_default: true, build_by_default: true,
input: [ build_always_stale: true,
cargo_sources,
rust_sources
],
output: meson.project_name(), output: meson.project_name(),
console: true, console: true,
install: true, install: true,
install_dir: bindir, install_dir: bindir,
command: [ command: [
cargo_script, 'env',
meson.build_root(), cargo_env,
meson.source_root(), cargo, 'build',
'@OUTPUT@', cargo_options,
get_option('profile'), '&&',
meson.project_name(), 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@',
], ],
) )

View File

@@ -1,11 +1,8 @@
// pipewire_connection.rs
//
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com> // Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation.
// (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -112,8 +109,8 @@ fn handle_node(
// Get the nicest possible name for the node, using a fallback chain of possible name attributes. // Get the nicest possible name for the node, using a fallback chain of possible name attributes.
let name = String::from( let name = String::from(
props props
.get("node.nick") .get("node.description")
.or_else(|| props.get("node.description")) .or_else(|| props.get("node.nick"))
.or_else(|| props.get("node.name")) .or_else(|| props.get("node.name"))
.unwrap_or_default(), .unwrap_or_default(),
); );
@@ -246,9 +243,7 @@ fn handle_link(
// TODO -- check other values that might have changed // TODO -- check other values that might have changed
} else { } else {
// First time we get info. We can now notify the gtk thread of a new link. // 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 port_from = info.output_port_id();
let node_to = info.input_node_id();
let port_to = info.input_port_id(); let port_to = info.input_port_id();
state.insert(id, Item::Link { state.insert(id, Item::Link {
@@ -257,9 +252,7 @@ fn handle_link(
sender.send(PipewireMessage::LinkAdded { sender.send(PipewireMessage::LinkAdded {
id, id,
node_from,
port_from, port_from,
node_to,
port_to, port_to,
active: matches!(info.state(), LinkState::Active) active: matches!(info.state(), LinkState::Active)
}).expect( }).expect(

View File

@@ -1,11 +1,8 @@
// state.rs
//
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com> // Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation.
// (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of

View File

@@ -1,3 +1,20 @@
/* 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
*/
@define-color audio rgb(50,100,240); @define-color audio rgb(50,100,240);
@define-color video rgb(200,200,0); @define-color video rgb(200,200,0);
@define-color midi rgb(200,0,50); @define-color midi rgb(200,0,50);
@@ -19,5 +36,5 @@
} }
graphview { graphview {
background: @text_view_bg; background-color: @text_view_bg;
} }

836
src/ui/graph/graph_view.rs Normal file
View File

@@ -0,0 +1,836 @@
// 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::{
cairo, gio,
glib::{self, clone},
graphene::{self, Point},
gsk,
prelude::*,
subclass::prelude::*,
};
use std::cmp::Ordering;
use super::{Link, Node, Port};
use crate::NodeType;
const CANVAS_SIZE: f64 = 5000.0;
mod imp {
use super::*;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use gtk::{
gdk::{self, RGBA},
graphene::Rect,
gsk::ColorStop,
};
use log::warn;
use once_cell::sync::Lazy;
use pipewire::spa::Direction;
pub struct DragState {
node: glib::WeakRef<Node>,
/// This stores the offset of the pointer to the origin of the node,
/// so that we can keep the pointer over the same position when moving the node
///
/// The offset is normalized to the default zoom-level of 1.0.
offset: Point,
}
pub struct GraphView {
/// Stores nodes and their positions.
pub(super) nodes: RefCell<HashMap<Node, Point>>,
/// Stores the links and whether they are currently active.
pub(super) links: RefCell<HashSet<Link>>,
// Properties for zooming and scrolling the hraph
pub hadjustment: RefCell<Option<gtk::Adjustment>>,
pub vadjustment: RefCell<Option<gtk::Adjustment>>,
pub zoom_factor: Cell<f64>,
/// This keeps track of an ongoing node drag operation.
pub dragged_node: RefCell<Option<DragState>>,
// These keep track of an ongoing port drag operation
pub dragged_port: glib::WeakRef<Port>,
pub port_drag_cursor: Cell<Point>,
// Memorized data for an in-progress zoom gesture
pub zoom_gesture_initial_zoom: Cell<Option<f64>>,
pub zoom_gesture_anchor: Cell<Option<(f64, f64)>>,
}
impl Default for GraphView {
fn default() -> Self {
Self {
nodes: Default::default(),
links: Default::default(),
hadjustment: Default::default(),
vadjustment: Default::default(),
zoom_factor: Default::default(),
dragged_node: Default::default(),
dragged_port: Default::default(),
port_drag_cursor: Cell::new(Point::new(0.0, 0.0)),
zoom_gesture_initial_zoom: Default::default(),
zoom_gesture_anchor: Default::default(),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for GraphView {
const NAME: &'static str = "GraphView";
type Type = super::GraphView;
type ParentType = gtk::Widget;
type Interfaces = (gtk::Scrollable,);
fn class_init(klass: &mut Self::Class) {
klass.set_css_name("graphview");
}
}
impl ObjectImpl for GraphView {
fn constructed(&self) {
self.parent_constructed();
self.obj().set_overflow(gtk::Overflow::Hidden);
self.setup_node_dragging();
self.setup_port_drag_and_drop();
self.setup_scroll_zooming();
self.setup_zoom_gesture();
}
fn dispose(&self) {
self.nodes
.borrow()
.iter()
.for_each(|(node, _)| node.unparent())
}
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("hadjustment"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vadjustment"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("hscroll-policy"),
glib::ParamSpecOverride::for_interface::<gtk::Scrollable>("vscroll-policy"),
glib::ParamSpecDouble::builder("zoom-factor")
.minimum(0.3)
.maximum(4.0)
.default_value(1.0)
.flags(glib::ParamFlags::CONSTRUCT | glib::ParamFlags::READWRITE)
.build(),
]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"hadjustment" => self.hadjustment.borrow().to_value(),
"vadjustment" => self.vadjustment.borrow().to_value(),
"hscroll-policy" | "vscroll-policy" => gtk::ScrollablePolicy::Natural.to_value(),
"zoom-factor" => self.zoom_factor.get().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
let obj = self.obj();
match pspec.name() {
"hadjustment" => {
self.set_adjustment(&obj, value.get().ok(), gtk::Orientation::Horizontal)
}
"vadjustment" => {
self.set_adjustment(&obj, value.get().ok(), gtk::Orientation::Vertical)
}
"hscroll-policy" | "vscroll-policy" => {}
"zoom-factor" => {
self.zoom_factor.set(value.get().unwrap());
obj.queue_allocate();
}
_ => unimplemented!(),
}
}
}
impl WidgetImpl for GraphView {
fn size_allocate(&self, _width: i32, _height: i32, baseline: i32) {
let widget = &*self.obj();
for (node, point) in self.nodes.borrow().iter() {
let (_, natural_size) = node.preferred_size();
let transform = self
.canvas_space_to_screen_space_transform()
.translate(point);
node.allocate(
natural_size.width(),
natural_size.height(),
baseline,
Some(transform),
);
}
if let Some(ref hadjustment) = *self.hadjustment.borrow() {
self.set_adjustment_values(widget, hadjustment, gtk::Orientation::Horizontal);
}
if let Some(ref vadjustment) = *self.vadjustment.borrow() {
self.set_adjustment_values(widget, vadjustment, gtk::Orientation::Vertical);
}
}
fn snapshot(&self, snapshot: &gtk::Snapshot) {
let widget = &*self.obj();
let alloc = widget.allocation();
self.snapshot_background(widget, snapshot);
// Draw all visible children
self.nodes
.borrow()
.iter()
// Cull nodes from rendering when they are outside the visible canvas area
.filter(|(node, _)| alloc.intersect(&node.allocation()).is_some())
.for_each(|(node, _)| widget.snapshot_child(node, snapshot));
self.snapshot_links(widget, snapshot);
}
}
impl ScrollableImpl for GraphView {}
impl GraphView {
/// Returns a [`gsk::Transform`] matrix that can translate from canvas space to screen space.
///
/// Canvas space is non-zoomed, and (0, 0) is fixed at the middle of the graph. \
/// Screen space is zoomed and adjusted for scrolling, (0, 0) is at the top-left corner of the window.
///
/// This is the inverted form of [`Self::screen_space_to_canvas_space_transform()`].
fn canvas_space_to_screen_space_transform(&self) -> gsk::Transform {
let hadj = self.hadjustment.borrow().as_ref().unwrap().value();
let vadj = self.vadjustment.borrow().as_ref().unwrap().value();
let zoom_factor = self.zoom_factor.get();
gsk::Transform::new()
.translate(&Point::new(-hadj as f32, -vadj as f32))
.scale(zoom_factor as f32, zoom_factor as f32)
}
/// Returns a [`gsk::Transform`] matrix that can translate from screen space to canvas space.
///
/// This is the inverted form of [`Self::canvas_space_to_screen_space_transform()`], see that function for a more detailed explantion.
fn screen_space_to_canvas_space_transform(&self) -> gsk::Transform {
self.canvas_space_to_screen_space_transform()
.invert()
.unwrap()
}
fn setup_node_dragging(&self) {
let drag_controller = gtk::GestureDrag::new();
drag_controller.connect_drag_begin(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.dynamic_cast::<super::GraphView>()
.expect("drag-begin event is not on the GraphView");
let mut dragged_node = widget.imp().dragged_node.borrow_mut();
// pick() should at least return the widget itself.
let target = widget
.pick(x, y, gtk::PickFlags::DEFAULT)
.expect("drag-begin pick() did not return a widget");
*dragged_node = if target.ancestor(Port::static_type()).is_some() {
// The user targeted a port, so the dragging should be handled by the Port
// component instead of here.
None
} else if let Some(target) = target.ancestor(Node::static_type()) {
// The user targeted a Node without targeting a specific Port.
// Drag the Node around the screen.
let node = target.dynamic_cast_ref::<Node>().unwrap();
let Some(canvas_node_pos) = widget.node_position(node) else { return };
let canvas_cursor_pos = widget
.imp()
.screen_space_to_canvas_space_transform()
.transform_point(&Point::new(x as f32, y as f32));
Some(DragState {
node: node.clone().downgrade(),
offset: Point::new(
canvas_cursor_pos.x() - canvas_node_pos.x(),
canvas_cursor_pos.y() - canvas_node_pos.y(),
),
})
} else {
None
}
});
drag_controller.connect_drag_update(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.dynamic_cast::<super::GraphView>()
.expect("drag-update event is not on the GraphView");
let dragged_node = widget.imp().dragged_node.borrow();
let Some(DragState { node, offset }) = dragged_node.as_ref() else { return };
let Some(node) = node.upgrade() else { return };
let (start_x, start_y) = drag_controller
.start_point()
.expect("Drag has no start point");
let onscreen_node_origin = Point::new((start_x + x) as f32, (start_y + y) as f32);
let transform = widget.imp().screen_space_to_canvas_space_transform();
let canvas_node_origin = transform.transform_point(&onscreen_node_origin);
widget.move_node(
&node,
&Point::new(
canvas_node_origin.x() - offset.x(),
canvas_node_origin.y() - offset.y(),
),
);
});
self.obj().add_controller(drag_controller);
}
fn setup_port_drag_and_drop(&self) {
let controller = gtk::DropControllerMotion::new();
controller.connect_enter(|controller, x, y| {
let graph = controller
.widget()
.downcast::<super::GraphView>()
.expect("Widget should be a graphview");
graph.imp().port_drag_enter(controller, x, y)
});
controller.connect_motion(|controller, x, y| {
let graph = controller
.widget()
.downcast::<super::GraphView>()
.expect("Widget should be a graphview");
graph.imp().port_drag_motion(x, y)
});
controller.connect_leave(|controller| {
let graph = controller
.widget()
.downcast::<super::GraphView>()
.expect("Widget should be a graphview");
graph.imp().port_drag_leave()
});
self.obj().add_controller(controller);
}
fn port_drag_enter(&self, controller: &gtk::DropControllerMotion, x: f64, y: f64) {
let Some(drop) = controller.drop() else {
return;
};
self.port_drag_cursor.set(Point::new(x as f32, y as f32));
drop.read_value_async(
Port::static_type(),
glib::Priority::DEFAULT,
Option::<&gio::Cancellable>::None,
clone!(@weak self as imp => move|value| {
let Ok(value) = value else {
return;
};
let port: &Port = value.get().expect("Value should contain a port");
imp.dragged_port.set(Some(port));
}),
);
self.obj().queue_draw();
}
fn port_drag_motion(&self, x: f64, y: f64) {
if self.dragged_port.upgrade().is_some() {
self.port_drag_cursor.set(Point::new(x as f32, y as f32));
self.obj().queue_draw();
}
}
fn port_drag_leave(&self) {
if self.dragged_port.upgrade().is_some() {
self.dragged_port.set(None);
self.obj().queue_draw();
}
}
fn setup_scroll_zooming(&self) {
// We're only interested in the vertical axis, but for devices like touchpads,
// not capturing a small accidental horizontal move may cause the scroll to be disrupted if a widget
// higher up captures it instead.
let scroll_controller =
gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
scroll_controller.connect_scroll(|eventcontroller, _, delta_y| {
let event = eventcontroller.current_event().unwrap(); // We are inside the event handler, so it must have an event
if event
.modifier_state()
.contains(gdk::ModifierType::CONTROL_MASK)
{
let widget = eventcontroller
.widget()
.downcast::<super::GraphView>()
.unwrap();
widget.set_zoom_factor(widget.zoom_factor() + (0.1 * -delta_y), None);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
self.obj().add_controller(scroll_controller);
}
fn setup_zoom_gesture(&self) {
let zoom_gesture = gtk::GestureZoom::new();
zoom_gesture.connect_begin(|gesture, _| {
let widget = gesture.widget().downcast::<super::GraphView>().unwrap();
widget
.imp()
.zoom_gesture_initial_zoom
.set(Some(widget.zoom_factor()));
widget
.imp()
.zoom_gesture_anchor
.set(gesture.bounding_box_center());
});
zoom_gesture.connect_scale_changed(move |gesture, delta| {
let widget = gesture.widget().downcast::<super::GraphView>().unwrap();
let initial_zoom = widget
.imp()
.zoom_gesture_initial_zoom
.get()
.expect("Initial zoom not set during zoom gesture");
widget.set_zoom_factor(initial_zoom * delta, gesture.bounding_box_center());
});
self.obj().add_controller(zoom_gesture);
}
fn snapshot_background(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
// Grid size and line width during neutral zoom (factor 1.0).
const NORMAL_GRID_SIZE: f32 = 20.0;
const NORMAL_GRID_LINE_WIDTH: f32 = 1.0;
let zoom_factor = self.zoom_factor.get();
let grid_size = NORMAL_GRID_SIZE * zoom_factor as f32;
let grid_line_width = NORMAL_GRID_LINE_WIDTH * zoom_factor as f32;
let alloc = widget.allocation();
// We need to offset the lines between 0 and (excluding) `grid_size` so the grid moves with
// the rest of the view when scrolling.
// The offset is rounded so the grid is always aligned to a row of pixels.
let hadj = self
.hadjustment
.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(
&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)),
);
let grid_color = RGBA::new(0.137, 0.137, 0.137, 1.0);
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(
&Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32),
Some(&Rect::new(hoffset, 0.0, grid_size, alloc.height() as f32)),
);
snapshot.append_linear_gradient(
&Rect::new(hoffset, 0.0, grid_line_width, alloc.height() as f32),
&Point::new(0.0, 0.0),
&Point::new(0.0, alloc.height() as f32),
&[
ColorStop::new(0.0, grid_color),
ColorStop::new(1.0, grid_color),
],
);
snapshot.pop();
}
fn draw_link(
&self,
link_cr: &cairo::Context,
output_anchor: &Point,
input_anchor: &Point,
active: bool,
) {
let output_x: f64 = output_anchor.x().into();
let output_y: f64 = output_anchor.y().into();
let input_x: f64 = input_anchor.x().into();
let input_y: f64 = input_anchor.y().into();
// Use dashed line for inactive links, full line otherwise.
if active {
link_cr.set_dash(&[], 0.0);
} else {
link_cr.set_dash(&[10.0, 5.0], 0.0);
}
// If the output port is farther right than the input port and they have
// a similar y coordinate, apply a y offset to the control points
// so that the curve sticks out a bit.
let y_control_offset = if output_x > input_x {
f64::max(0.0, 25.0 - (output_y - input_y).abs())
} else {
0.0
};
// Place curve control offset by half the x distance between the two points.
// This makes the curve scale well for varying distances between the two ports,
// especially when the output port is farther right than the input port.
let half_x_dist = f64::abs(output_x - input_x) / 2.0;
link_cr.move_to(output_x, output_y);
link_cr.curve_to(
output_x + half_x_dist,
output_y - y_control_offset,
input_x - half_x_dist,
input_y - y_control_offset,
input_x,
input_y,
);
if let Err(e) = link_cr.stroke() {
warn!("Failed to draw graphview links: {}", e);
};
}
fn draw_dragged_link(&self, port: &Port, link_cr: &cairo::Context) {
let Some(port_anchor) = port.compute_point(&*self.obj(), &port.link_anchor()) else {
return;
};
let drag_cursor = self.port_drag_cursor.get();
/* If we can find a linkable port under the cursor, link to its anchor,
* otherwise link to the mouse cursor */
let picked_port = self
.obj()
.pick(
drag_cursor.x().into(),
drag_cursor.y().into(),
gtk::PickFlags::DEFAULT,
)
.and_then(|widget| widget.ancestor(Port::static_type()).and_downcast::<Port>())
.filter(|picked_port| port.is_linkable_to(picked_port));
let picked_port_anchor = picked_port.and_then(|picked_port| {
picked_port.compute_point(&*self.obj(), &picked_port.link_anchor())
});
let other_anchor = picked_port_anchor.unwrap_or(drag_cursor);
let (output_anchor, input_anchor) = match port.direction() {
Direction::Output => (&port_anchor, &other_anchor),
Direction::Input => (&other_anchor, &port_anchor),
_ => unreachable!(),
};
self.draw_link(link_cr, output_anchor, input_anchor, false);
}
fn snapshot_links(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
let alloc = widget.allocation();
let link_cr = snapshot.append_cairo(&graphene::Rect::new(
0.0,
0.0,
alloc.width() as f32,
alloc.height() as f32,
));
link_cr.set_line_width(2.0 * self.zoom_factor.get());
let 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 in self.links.borrow().iter() {
// TODO: Do not draw links when they are outside the view
let Some((output_anchor, input_anchor)) = self.get_link_coordinates(link) else {
warn!("Could not get allocation of ports of link: {:?}", link);
continue;
};
self.draw_link(&link_cr, &output_anchor, &input_anchor, link.active());
}
if let Some(port) = self.dragged_port.upgrade() {
self.draw_dragged_link(&port, &link_cr);
}
}
/// Get coordinates for the drawn link to start at and to end at.
///
/// # Returns
/// `Some((output_anchor, input_anchor))` if all objects the links refers to exist as widgets
/// and those widgets are contained by the graph.
///
/// The returned coordinates are in screen-space of the graph.
fn get_link_coordinates(&self, link: &Link) -> Option<(graphene::Point, graphene::Point)> {
let widget = &*self.obj();
let output_port = link.output_port()?;
let output_anchor = output_port.compute_point(widget, &output_port.link_anchor())?;
let input_port = link.input_port()?;
let input_anchor = input_port.compute_point(widget, &input_port.link_anchor())?;
Some((output_anchor, input_anchor))
}
fn set_adjustment(
&self,
obj: &super::GraphView,
adjustment: Option<&gtk::Adjustment>,
orientation: gtk::Orientation,
) {
match orientation {
gtk::Orientation::Horizontal => {
*self.hadjustment.borrow_mut() = adjustment.cloned()
}
gtk::Orientation::Vertical => *self.vadjustment.borrow_mut() = adjustment.cloned(),
_ => unimplemented!(),
}
if let Some(adjustment) = adjustment {
adjustment
.connect_value_changed(clone!(@weak obj => move |_| obj.queue_allocate() ));
}
}
fn set_adjustment_values(
&self,
obj: &super::GraphView,
adjustment: &gtk::Adjustment,
orientation: gtk::Orientation,
) {
let size = match orientation {
gtk::Orientation::Horizontal => obj.width(),
gtk::Orientation::Vertical => obj.height(),
_ => unimplemented!(),
};
let zoom_factor = self.zoom_factor.get();
adjustment.configure(
adjustment.value(),
-(CANVAS_SIZE / 2.0) * zoom_factor,
(CANVAS_SIZE / 2.0) * zoom_factor,
(f64::from(size) * 0.1) * zoom_factor,
(f64::from(size) * 0.9) * zoom_factor,
f64::from(size) * zoom_factor,
);
}
}
}
glib::wrapper! {
pub struct GraphView(ObjectSubclass<imp::GraphView>)
@extends gtk::Widget;
}
impl GraphView {
pub const ZOOM_MIN: f64 = 0.3;
pub const ZOOM_MAX: f64 = 4.0;
pub fn new() -> Self {
glib::Object::new()
}
pub fn zoom_factor(&self) -> f64 {
self.property("zoom-factor")
}
/// Set the scale factor.
///
/// A factor of 1.0 is equivalent to 100% zoom, 0.5 to 50% zoom etc.
///
/// An optional anchor (in canvas-space coordinates) can be specified, which will be used as the center of the zoom,
/// so that its position stays fixed.
/// If no anchor is specified, the middle of the screen is used instead.
///
/// Note that the zoom level is [clamped](`f64::clamp`) to between 30% and 300%.
/// See [`Self::ZOOM_MIN`] and [`Self::ZOOM_MAX`].
pub fn set_zoom_factor(&self, zoom_factor: f64, anchor: Option<(f64, f64)>) {
let zoom_factor = zoom_factor.clamp(Self::ZOOM_MIN, Self::ZOOM_MAX);
let (anchor_x_screen, anchor_y_screen) = anchor.unwrap_or_else(|| {
(
self.allocation().width() as f64 / 2.0,
self.allocation().height() as f64 / 2.0,
)
});
let old_zoom = self.imp().zoom_factor.get();
let hadjustment_ref = self.imp().hadjustment.borrow();
let vadjustment_ref = self.imp().vadjustment.borrow();
let hadjustment = hadjustment_ref.as_ref().unwrap();
let vadjustment = vadjustment_ref.as_ref().unwrap();
let x_total = (anchor_x_screen + hadjustment.value()) / old_zoom;
let y_total = (anchor_y_screen + vadjustment.value()) / old_zoom;
let new_hadjustment = x_total * zoom_factor - anchor_x_screen;
let new_vadjustment = y_total * zoom_factor - anchor_y_screen;
hadjustment.set_value(new_hadjustment);
vadjustment.set_value(new_vadjustment);
self.set_property("zoom-factor", zoom_factor);
}
pub fn add_node(&self, node: Node, node_type: Option<NodeType>) {
let imp = self.imp();
node.set_parent(self);
// Place widgets in colums of 3, growing down
let x = if let Some(node_type) = node_type {
match node_type {
NodeType::Output => 20.0,
NodeType::Input => 820.0,
}
} else {
420.0
};
let y = imp
.nodes
.borrow()
.iter()
.map(|node| {
// Map nodes to their locations
let point = self.node_position(&node.0.clone().upcast()).unwrap();
(point.x(), point.y())
})
.filter(|(x2, _)| {
// Only look for other nodes that have a similar x coordinate
(x - x2).abs() < 50.0
})
.max_by(|y1, y2| {
// Get max in column
y1.partial_cmp(y2).unwrap_or(Ordering::Equal)
})
.map_or(20_f32, |(_x, y)| y + 120.0);
imp.nodes.borrow_mut().insert(node, Point::new(x, y));
}
pub fn remove_node(&self, node: &Node) {
let mut nodes = self.imp().nodes.borrow_mut();
if nodes.remove(node).is_some() {
node.unparent();
} else {
log::warn!("Tried to remove non-existant node widget from graph");
}
}
pub fn add_link(&self, link: Link) {
link.connect_notify_local(
Some("active"),
glib::clone!(@weak self as graph => move |_, _| {
graph.queue_draw();
}),
);
self.imp().links.borrow_mut().insert(link);
self.queue_draw();
}
pub fn remove_link(&self, link: &Link) {
let mut links = self.imp().links.borrow_mut();
links.remove(link);
self.queue_draw();
}
/// Get the position of the specified node inside the graphview.
///
/// The returned position is in canvas-space (non-zoomed, (0, 0) fixed in the middle of the canvas).
pub(super) fn node_position(&self, node: &Node) -> Option<Point> {
self.imp().nodes.borrow().get(node).copied()
}
pub(super) fn move_node(&self, widget: &Node, point: &Point) {
let mut nodes = self.imp().nodes.borrow_mut();
let node_point = nodes.get_mut(widget).expect("Node is not on the graph");
// Clamp the new position to within the graph, so a node can't be moved outside it and be lost.
node_point.set_x(point.x().clamp(
-(CANVAS_SIZE / 2.0) as f32,
(CANVAS_SIZE / 2.0) as f32 - widget.width() as f32,
));
node_point.set_y(point.y().clamp(
-(CANVAS_SIZE / 2.0) as f32,
(CANVAS_SIZE / 2.0) as f32 - widget.height() as f32,
));
self.queue_allocate();
}
}
impl Default for GraphView {
fn default() -> Self {
Self::new()
}
}

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

@@ -0,0 +1,120 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{glib, prelude::*, subclass::prelude::*};
use super::Port;
mod imp {
use super::*;
use std::cell::Cell;
use once_cell::sync::Lazy;
#[derive(Default)]
pub struct Link {
pub output_port: glib::WeakRef<Port>,
pub input_port: glib::WeakRef<Port>,
pub active: Cell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for Link {
const NAME: &'static str = "HelvumLink";
type Type = super::Link;
type ParentType = glib::Object;
}
impl ObjectImpl for Link {
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecObject::builder::<Port>("output-port")
.flags(glib::ParamFlags::READWRITE)
.build(),
glib::ParamSpecObject::builder::<Port>("input-port")
.flags(glib::ParamFlags::READWRITE)
.build(),
glib::ParamSpecBoolean::builder("active")
.default_value(false)
.flags(glib::ParamFlags::READWRITE)
.build(),
]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"output-port" => self.output_port.upgrade().to_value(),
"input-port" => self.input_port.upgrade().to_value(),
"active" => self.active.get().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"output-port" => self.output_port.set(value.get().unwrap()),
"input-port" => self.input_port.set(value.get().unwrap()),
"active" => self.active.set(value.get().unwrap()),
_ => unimplemented!(),
}
}
}
}
glib::wrapper! {
pub struct Link(ObjectSubclass<imp::Link>);
}
impl Link {
pub fn new() -> Self {
glib::Object::new()
}
pub fn output_port(&self) -> Option<Port> {
self.property("output-port")
}
pub fn set_output_port(&self, port: Option<&Port>) {
self.set_property("output-port", port);
}
pub fn input_port(&self) -> Option<Port> {
self.property("input-port")
}
pub fn set_input_port(&self, port: Option<&Port>) {
self.set_property("input-port", port);
}
pub fn active(&self) -> bool {
self.property("active")
}
pub fn set_active(&self, active: bool) {
self.set_property("active", active);
}
}
impl Default for Link {
fn default() -> Self {
Self::new()
}
}

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

@@ -0,0 +1,26 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
mod graph_view;
pub use graph_view::*;
mod node;
pub use node::*;
mod port;
pub use port::*;
mod link;
pub use link::*;
mod zoomentry;
pub use zoomentry::*;

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

@@ -0,0 +1,145 @@
// 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 super::Port;
mod imp {
use super::*;
use std::{
cell::{Cell, RefCell},
collections::HashSet,
};
#[derive(glib::Properties)]
#[properties(wrapper_type = super::Node)]
pub struct Node {
#[property(get, set, construct_only)]
pub(super) pipewire_id: Cell<u32>,
pub(super) grid: gtk::Grid,
#[property(
name = "name", type = String,
get = |this: &Self| this.label.text().to_string(),
set = |this: &Self, val| {
this.label.set_text(val);
this.label.set_tooltip_text(Some(val));
}
)]
pub(super) label: gtk::Label,
pub(super) ports: RefCell<HashSet<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(HashSet::new()),
num_ports_in: Cell::new(0),
num_ports_out: Cell::new(0),
}
}
}
#[glib::derived_properties]
impl ObjectImpl for Node {
fn constructed(&self) {
self.parent_constructed();
self.grid.set_parent(&*self.obj());
}
fn 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 add_port(&self, 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);
}
_ => unreachable!(),
}
imp.ports.borrow_mut().insert(port);
}
pub fn remove_port(&self, port: &Port) {
let imp = self.imp();
if imp.ports.borrow_mut().remove(port) {
match port.direction() {
Direction::Input => imp.num_ports_in.set(imp.num_ports_in.get() - 1),
Direction::Output => imp.num_ports_in.set(imp.num_ports_out.get() - 1),
_ => unreachable!(),
}
port.unparent();
} else {
log::warn!("Tried to remove non-existant port widget from node");
}
}
}

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

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

170
src/ui/graph/zoomentry.rs Normal file
View File

@@ -0,0 +1,170 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
use super::GraphView;
mod imp {
use std::cell::RefCell;
use super::*;
use gtk::{gio, glib::clone};
use once_cell::sync::Lazy;
#[derive(gtk::CompositeTemplate)]
#[template(file = "zoomentry.ui")]
pub struct ZoomEntry {
pub graphview: RefCell<Option<GraphView>>,
#[template_child]
pub zoom_out_button: TemplateChild<gtk::Button>,
#[template_child]
pub zoom_in_button: TemplateChild<gtk::Button>,
#[template_child]
pub entry: TemplateChild<gtk::Entry>,
pub popover: gtk::PopoverMenu,
}
impl Default for ZoomEntry {
fn default() -> Self {
let menu = gio::Menu::new();
menu.append(Some("30%"), Some("win.set-zoom(0.30)"));
menu.append(Some("50%"), Some("win.set-zoom(0.50)"));
menu.append(Some("75%"), Some("win.set-zoom(0.75)"));
menu.append(Some("100%"), Some("win.set-zoom(1.0)"));
menu.append(Some("150%"), Some("win.set-zoom(1.5)"));
menu.append(Some("200%"), Some("win.set-zoom(2.0)"));
menu.append(Some("300%"), Some("win.set-zoom(3.0)"));
let popover = gtk::PopoverMenu::from_model(Some(&menu));
ZoomEntry {
graphview: Default::default(),
zoom_out_button: Default::default(),
zoom_in_button: Default::default(),
entry: Default::default(),
popover,
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for ZoomEntry {
const NAME: &'static str = "HelvumZoomEntry";
type Type = super::ZoomEntry;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ZoomEntry {
fn constructed(&self) {
self.parent_constructed();
self.zoom_out_button
.connect_clicked(clone!(@weak self as imp => move |_| {
let graphview = imp.graphview.borrow();
if let Some(ref graphview) = *graphview {
graphview.set_zoom_factor(graphview.zoom_factor() - 0.1, None);
}
}));
self.zoom_in_button
.connect_clicked(clone!(@weak self as imp => move |_| {
let graphview = imp.graphview.borrow();
if let Some(ref graphview) = *graphview {
graphview.set_zoom_factor(graphview.zoom_factor() + 0.1, None);
}
}));
self.entry
.connect_activate(clone!(@weak self as imp => move |entry| {
if let Ok(zoom_factor) = entry.text().trim_matches('%').parse::<f64>() {
let graphview = imp.graphview.borrow();
if let Some(ref graphview) = *graphview {
graphview.set_zoom_factor(zoom_factor / 100.0, None);
}
}
}));
self.entry
.connect_icon_press(clone!(@weak self as imp => move |_, pos| {
if pos == gtk::EntryIconPosition::Secondary {
imp.popover.show();
}
}));
self.popover.set_parent(&self.entry.get());
}
fn dispose(&self) {
self.popover.unparent();
while let Some(child) = self.obj().first_child() {
child.unparent();
}
}
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![glib::ParamSpecObject::builder::<GraphView>("zoomed-widget")
.flags(glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT)
.build()]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"zoomed-widget" => self.graphview.borrow().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"zoomed-widget" => {
let widget: GraphView = value.get().unwrap();
widget.connect_notify_local(
Some("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);
}
_ => unimplemented!(),
}
}
}
impl WidgetImpl for ZoomEntry {}
impl BoxImpl for ZoomEntry {}
impl ZoomEntry {
/// Update the text contained in the combobox's entry to reflect the provided zoom factor.
///
/// This does not update the associated [`GraphView`]s zoom level.
fn update_zoom_factor_text(&self, zoom_factor: f64) {
self.entry
.buffer()
.set_text(format!("{factor:.0}%", factor = zoom_factor * 100.));
}
}
}
glib::wrapper! {
pub struct ZoomEntry(ObjectSubclass<imp::ZoomEntry>)
@extends gtk::Box, gtk::Widget;
}
impl ZoomEntry {
pub fn new(zoomed_widget: &GraphView) -> Self {
glib::Object::builder()
.property("zoomed-widget", zoomed_widget)
.build()
}
}

26
src/ui/graph/zoomentry.ui Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="HelvumZoomEntry" parent="GtkBox">
<child>
<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>
<object class="GtkEntry" id="entry">
<property name="secondary-icon-name">go-down-symbolic</property>
<property name="input-purpose">digits</property>
</object>
</child>
<child>
<object class="GtkButton" id="zoom_in_button">
<property name="icon-name">zoom-in-symbolic</property>
<property name="tooltip-text">Zoom in</property>
</object>
</child>
<style>
<class name="linked"/>
</style>
</template>
</interface>

View File

@@ -1,11 +1,8 @@
// mod.rs
//
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com> // Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation.
// (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -21,10 +18,4 @@
//! //!
//! 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;
pub use graph_view::GraphView;
pub use node::Node;
pub use port::Port;

View File

@@ -1,425 +0,0 @@
// graph_view.rs
//
// 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 as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 super::{Node, Port};
use gtk::{
glib::{self, clone},
graphene, gsk,
prelude::*,
subclass::prelude::*,
};
use log::{error, warn};
use std::{cmp::Ordering, collections::HashMap};
use crate::NodeType;
mod imp {
use super::*;
use std::{cell::RefCell, rc::Rc};
use log::warn;
#[derive(Default)]
pub struct GraphView {
pub(super) nodes: RefCell<HashMap<u32, Node>>,
/// Stores the link and whether it is currently active.
pub(super) links: RefCell<HashMap<u32, (crate::PipewireLink, bool)>>,
}
#[glib::object_subclass]
impl ObjectSubclass for GraphView {
const NAME: &'static str = "GraphView";
type Type = super::GraphView;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
// The layout manager determines how child widgets are laid out.
klass.set_layout_manager_type::<gtk::FixedLayout>();
klass.set_css_name("graphview");
}
}
impl ObjectImpl for GraphView {
fn constructed(&self, obj: &Self::Type) {
self.parent_constructed(obj);
let drag_state = Rc::new(RefCell::new(None));
let drag_controller = gtk::GestureDrag::new();
drag_controller.connect_drag_begin(
clone!(@strong drag_state => move |drag_controller, x, y| {
let mut drag_state = drag_state.borrow_mut();
let widget = drag_controller
.widget()
.expect("drag-begin event has no widget")
.dynamic_cast::<Self::Type>()
.expect("drag-begin event is not on the GraphView");
// pick() should at least return the widget itself.
let target = widget.pick(x, y, gtk::PickFlags::DEFAULT).expect("drag-begin pick() did not return a widget");
*drag_state = if target.ancestor(Port::static_type()).is_some() {
// The user targeted a port, so the dragging should be handled by the Port
// component instead of here.
None
} else if let Some(target) = target.ancestor(Node::static_type()) {
// The user targeted a Node without targeting a specific Port.
// Drag the Node around the screen.
if let Some((x, y)) = widget.get_node_position(&target) {
Some((target, x, y))
} else {
error!("Failed to obtain position of dragged node, drag aborted.");
None
}
} else {
None
}
}
));
drag_controller.connect_drag_update(
clone!(@strong drag_state => move |drag_controller, x, y| {
let widget = drag_controller
.widget()
.expect("drag-update event has no widget")
.dynamic_cast::<Self::Type>()
.expect("drag-update event is not on the GraphView");
let drag_state = drag_state.borrow();
if let Some((ref node, x1, y1)) = *drag_state {
widget.move_node(node, x1 + x as f32, y1 + y as f32);
}
}
),
);
obj.add_controller(&drag_controller);
}
fn dispose(&self, _obj: &Self::Type) {
self.nodes
.borrow()
.values()
.for_each(|node| node.unparent())
}
}
impl WidgetImpl for GraphView {
fn snapshot(&self, widget: &Self::Type, snapshot: &gtk::Snapshot) {
/* FIXME: A lot of hardcoded values in here.
Try to use relative units (em) and colours from the theme as much as possible. */
let alloc = widget.allocation();
let widget_bounds =
graphene::Rect::new(0.0, 0.0, alloc.width as f32, alloc.height as f32);
let background_cr = snapshot
.append_cairo(&widget_bounds)
.expect("Failed to get cairo context");
// Draw a nice grid on the background.
background_cr.set_source_rgb(0.18, 0.18, 0.18);
background_cr.set_line_width(0.2); // TODO: Set to 1px
let mut y = 0.0;
while y < alloc.height.into() {
background_cr.move_to(0.0, y);
background_cr.line_to(alloc.width.into(), y);
y += 20.0; // TODO: Change to em;
}
let mut x = 0.0;
while x < alloc.width.into() {
background_cr.move_to(x, 0.0);
background_cr.line_to(x, alloc.height.into());
x += 20.0; // TODO: Change to em;
}
if let Err(e) = background_cr.stroke() {
warn!("Failed to draw graphview grid: {}", e);
};
// Draw all children
self.nodes
.borrow()
.values()
.for_each(|node| self.instance().snapshot_child(node, snapshot));
// Draw all links
let link_cr = snapshot
.append_cairo(&graphene::Rect::new(
0.0,
0.0,
alloc.width as f32,
alloc.height as f32,
))
.expect("Failed to get cairo context");
link_cr.set_line_width(2.0);
let gtk::gdk::RGBA {
red,
green,
blue,
alpha,
} = widget
.style_context()
.lookup_color("graphview-link")
.unwrap_or(gtk::gdk::RGBA {
red: 0.0,
green: 0.0,
blue: 0.0,
alpha: 0.0,
});
link_cr.set_source_rgba(red.into(), green.into(), blue.into(), alpha.into());
for (link, active) in self.links.borrow().values() {
if let Some((from_x, from_y, to_x, to_y)) = self.get_link_coordinates(link) {
link_cr.move_to(from_x, from_y);
// Use dashed line for inactive links, full line otherwise.
if *active {
link_cr.set_dash(&[], 0.0);
} else {
link_cr.set_dash(&[10.0, 5.0], 0.0);
}
// If the output port is farther right than the input port and they have
// a similar y coordinate, apply a y offset to the control points
// so that the curve sticks out a bit.
let y_control_offset = if from_x > to_x {
f64::max(0.0, 25.0 - (from_y - to_y).abs())
} else {
0.0
};
// Place curve control offset by half the x distance between the two points.
// This makes the curve scale well for varying distances between the two ports,
// especially when the output port is farther right than the input port.
let half_x_dist = f64::abs(from_x - to_x) / 2.0;
link_cr.curve_to(
from_x + half_x_dist,
from_y - y_control_offset,
to_x - half_x_dist,
to_y - y_control_offset,
to_x,
to_y,
);
if let Err(e) = link_cr.stroke() {
warn!("Failed to draw graphview links: {}", e);
};
} else {
warn!("Could not get allocation of ports of link: {:?}", link);
}
}
}
}
impl GraphView {
/// Get coordinates for the drawn link to start at and to end at.
///
/// # Returns
/// `Some((from_x, from_y, to_x, to_y))` if all objects the links refers to exist as widgets.
fn get_link_coordinates(&self, link: &crate::PipewireLink) -> Option<(f64, f64, f64, f64)> {
let nodes = self.nodes.borrow();
// For some reason, gtk4::WidgetExt::translate_coordinates gives me incorrect values,
// so we manually calculate the needed offsets here.
let from_port = &nodes.get(&link.node_from)?.get_port(link.port_from)?;
let gtk::Allocation {
x: mut fx,
y: mut fy,
width: fw,
height: fh,
} = from_port.allocation();
let from_node = from_port
.ancestor(Node::static_type())
.expect("Port is not a child of a node");
let gtk::Allocation { x: fnx, y: fny, .. } = from_node.allocation();
fx += fnx + fw;
fy += fny + (fh / 2);
let to_port = &nodes.get(&link.node_to)?.get_port(link.port_to)?;
let gtk::Allocation {
x: mut tx,
y: mut ty,
height: th,
..
} = to_port.allocation();
let to_node = to_port
.ancestor(Node::static_type())
.expect("Port is not a child of a node");
let gtk::Allocation { x: tnx, y: tny, .. } = to_node.allocation();
tx += tnx;
ty += tny + (th / 2);
Some((fx.into(), fy.into(), tx.into(), ty.into()))
}
}
}
glib::wrapper! {
pub struct GraphView(ObjectSubclass<imp::GraphView>)
@extends gtk::Widget;
}
impl GraphView {
pub fn new() -> Self {
glib::Object::new(&[]).expect("Failed to create GraphView")
}
pub fn add_node(&self, id: u32, node: Node, node_type: Option<NodeType>) {
let private = imp::GraphView::from_instance(self);
node.set_parent(self);
// Place widgets in colums of 3, growing down
let x = if let Some(node_type) = node_type {
match node_type {
NodeType::Output => 20.0,
NodeType::Input => 820.0,
}
} else {
420.0
};
let y = private
.nodes
.borrow()
.values()
.filter_map(|node| {
// Map nodes to locations, discard nodes without location
self.get_node_position(&node.clone().upcast())
})
.filter(|(x2, _)| {
// Only look for other nodes that have a similar x coordinate
(x - x2).abs() < 50.0
})
.max_by(|y1, y2| {
// Get max in column
y1.partial_cmp(y2).unwrap_or(Ordering::Equal)
})
.map_or(20_f32, |(_x, y)| y + 100.0);
self.move_node(&node.clone().upcast(), x, y);
private.nodes.borrow_mut().insert(id, node);
}
pub fn remove_node(&self, id: u32) {
let private = imp::GraphView::from_instance(self);
let mut nodes = private.nodes.borrow_mut();
if let Some(node) = nodes.remove(&id) {
node.unparent();
} else {
warn!("Tried to remove non-existant node (id={}) from graph", id);
}
}
pub fn add_port(&self, node_id: u32, port_id: u32, port: crate::view::port::Port) {
let private = imp::GraphView::from_instance(self);
if let Some(node) = private.nodes.borrow_mut().get_mut(&node_id) {
node.add_port(port_id, port);
} else {
error!(
"Node with id {} not found when trying to add port with id {} to graph",
node_id, port_id
);
}
}
pub fn remove_port(&self, id: u32, node_id: u32) {
let private = imp::GraphView::from_instance(self);
let nodes = private.nodes.borrow();
if let Some(node) = nodes.get(&node_id) {
node.remove_port(id);
}
}
pub fn add_link(&self, link_id: u32, link: crate::PipewireLink, active: bool) {
let private = imp::GraphView::from_instance(self);
private.links.borrow_mut().insert(link_id, (link, active));
self.queue_draw();
}
pub fn set_link_state(&self, link_id: u32, active: bool) {
let private = imp::GraphView::from_instance(self);
if let Some((_, state)) = private.links.borrow_mut().get_mut(&link_id) {
*state = active;
self.queue_draw();
} else {
warn!("Link state changed on unknown link (id={})", link_id);
}
}
pub fn remove_link(&self, id: u32) {
let private = imp::GraphView::from_instance(self);
let mut links = private.links.borrow_mut();
links.remove(&id);
self.queue_draw();
}
/// Get the position of the specified node inside the graphview.
///
/// Returns `None` if the node is not in the graphview.
pub(super) fn get_node_position(&self, node: &gtk::Widget) -> Option<(f32, f32)> {
let layout_manager = self
.layout_manager()
.expect("Failed to get layout manager")
.dynamic_cast::<gtk::FixedLayout>()
.expect("Failed to cast to FixedLayout");
let node = layout_manager
.layout_child(node)?
.dynamic_cast::<gtk::FixedLayoutChild>()
.expect("Could not cast to FixedLayoutChild");
let transform = node
.transform()
.expect("Failed to obtain transform from layout child");
Some(transform.to_translate())
}
pub(super) fn move_node(&self, node: &gtk::Widget, x: f32, y: f32) {
let layout_manager = self
.layout_manager()
.expect("Failed to get layout manager")
.dynamic_cast::<gtk::FixedLayout>()
.expect("Failed to cast to FixedLayout");
let transform = gsk::Transform::new()
// Nodes should not be able to be dragged out of the view, so we use `max(coordinate, 0.0)` to prevent that.
.translate(&graphene::Point::new(f32::max(x, 0.0), f32::max(y, 0.0)))
.unwrap();
layout_manager
.layout_child(node)
.expect("Could not get layout child")
.dynamic_cast::<gtk::FixedLayoutChild>()
.expect("Could not cast to FixedLayoutChild")
.set_transform(&transform);
// FIXME: If links become proper widgets,
// we don't need to redraw the full graph everytime.
self.queue_draw();
}
}
impl Default for GraphView {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,133 +0,0 @@
// node.rs
//
// 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 as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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 super::*;
use std::cell::{Cell, RefCell};
pub struct Node {
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 = "Node";
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);
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 {
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, obj: &Self::Type) {
self.parent_constructed(obj);
self.grid.set_parent(obj);
}
fn dispose(&self, _obj: &Self::Type) {
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) -> Self {
let res: Self = glib::Object::new(&[]).expect("Failed to create Node");
let private = imp::Node::from_instance(&res);
private.label.set_text(name);
res
}
pub fn add_port(&mut self, id: u32, port: super::port::Port) {
let private = imp::Node::from_instance(self);
match port.direction() {
Direction::Input => {
private
.grid
.attach(&port, 0, private.num_ports_in.get() + 1, 1, 1);
private.num_ports_in.set(private.num_ports_in.get() + 1);
}
Direction::Output => {
private
.grid
.attach(&port, 1, private.num_ports_out.get() + 1, 1, 1);
private.num_ports_out.set(private.num_ports_out.get() + 1);
}
}
private.ports.borrow_mut().insert(id, port);
}
pub fn get_port(&self, id: u32) -> Option<super::port::Port> {
let private = imp::Node::from_instance(self);
private.ports.borrow_mut().get(&id).cloned()
}
pub fn remove_port(&self, id: u32) {
let private = imp::Node::from_instance(self);
if let Some(port) = private.ports.borrow_mut().remove(&id) {
match port.direction() {
Direction::Input => private.num_ports_in.set(private.num_ports_in.get() - 1),
Direction::Output => private.num_ports_in.set(private.num_ports_out.get() - 1),
}
port.unparent();
}
}
}

View File

@@ -1,213 +0,0 @@
// port.rs
//
// 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 as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// 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::GBoxed)]
#[gboxed(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::GBoxed)]
#[gboxed(type_name = "HelvumReversedLink")]
struct ReversedLink(u32);
mod imp {
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) label: OnceCell<gtk::Label>,
pub(super) id: OnceCell<u32>,
pub(super) direction: OnceCell<Direction>,
}
#[glib::object_subclass]
impl ObjectSubclass for Port {
const NAME: &'static str = "Port";
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 dispose(&self, _obj: &Self::Type) {
if let Some(label) = self.label.get() {
label.unparent()
}
}
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
vec![Signal::builder(
"port-toggled",
// Provide id of output port and input port to signal handler.
&[<u32>::static_type().into(), <u32>::static_type().into()],
// signal handler sends back nothing.
<()>::static_type().into(),
)
.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::new(&[]).expect("Failed to create Port");
let private = imp::Port::from_instance(&res);
private.id.set(id).expect("Port id already set");
private
.direction
.set(direction)
.expect("Port direction already set");
let label = gtk::Label::new(Some(name));
label.set_parent(&res);
private
.label
.set(label)
.expect("Port label was 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::DragSourceBuilder::new()
.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()
.expect("Drop target has no widget")
.emit_by_name("port-toggled", &[&source_id, &this.id()])
.expect("Failed to send signal");
} 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()
.expect("Drop target has no widget")
.emit_by_name("port-toggled", &[&this.id(), &target_id])
.expect("Failed to send signal");
} 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 id(&self) -> u32 {
let private = imp::Port::from_instance(self);
private.id.get().copied().expect("Port id is not set")
}
pub fn direction(&self) -> &Direction {
let private = imp::Port::from_instance(self);
private.direction.get().expect("Port direction is not set")
}
}