63 Commits
0.3.0 ... 0.4.0

Author SHA1 Message Date
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
Tom A. Wagner
c1ec56e115 Release 0.3.2 2021-11-30 18:25:58 +01:00
Tom A. Wagner
110e9ef67f meson: Add dist script to vendor cargo dependencies for offline builds 2021-11-30 09:59:56 +01:00
Jan Beich
3c507683b7 build-aux: relax shebang in cargo.sh after 7b1b5ea336
/bin/sh: /bin/bash: not found
2021-11-27 21:57:03 +00:00
Thomas Rosendal
1d1f8bd3d7 Add instruction to add the flatpak remote 'flathub' 2021-11-25 07:59:45 +00:00
Sebastian Grabowski
b25f6f9abb Update the extension versions in flatpak build instructions to 21.08
The extensions need to match the SDK version, that was updated to
org.gnome.Sdk//41 in Commit e5e02b13. (Version 41 is based on
org.freedesktop.Sdk//21.08)
2021-11-24 17:03:23 +01:00
Tom A. Wagner
2d51ea677e ci: use more recent fd.o ci-templates 2021-11-24 16:11:16 +01:00
Tom A. Wagner
beb03d8b09 Update CI container for rust 1.56 2021-11-24 16:11:16 +01:00
Tom A. Wagner
502cf4476b gtk4dep: bump to 4.4 for fixed gtk_pick when using affine transform
This will be needed in a later commit for zooming in on the canvas using an affine (scaling) transformation matrix
2021-11-24 16:11:16 +01:00
Tom A. Wagner
eac973da15 Swap to rust 2021 edition and move rustc version check from meson.build to Cargo.toml 2021-11-24 16:11:16 +01:00
Tom A. Wagner
82a3e4f900 graphview: draw background automatically
This removes the manual painting of the background via cairo and adds the correct color to CSS instead,
which should hopefully improve performance as we do less cpu painting like this.
2021-11-24 16:11:16 +01:00
Suchipi
2cfc8e2e6f Update build instructions to match changes in e5e02b1387 2021-11-19 01:10:14 +00:00
Tom A. Wagner
e5e02b1387 flatpak: Update to gnome runtime 41 2021-11-17 19:49:52 +01:00
Tom A. Wagner
396363cef1 README: Update screenshot 2021-11-17 19:49:52 +01:00
Tom A. Wagner
c887d77f64 graphview: Use #808080 as link color
For good contrast in both dark and light mode, the link color is now a semi-light gray instead of complete black, which had bad contrast in dark mode and good constrast in light mode.

Later, we can seperate color palettes for light and dark mode, but only together with a dark mode toggle button or system-wide darkmode toggle.
2021-11-13 20:32:40 +01:00
Tom A. Wagner
54d7ca83ae graphview: Define link and grid colors in style.css
Previously, these were defined directly in the code,
but defining them in the css helps seperating theming and behaviour
and makes the colors easier to tweak.
2021-11-13 20:08:47 +01:00
Tom A. Wagner
7b1b5ea336 build: fix and improve cargo.sh
cargo.sh previously used bash features but only used `sh` in the shebang,
and also did not properly quote some variables to avoid splitting/globbing from happening.

Also, `-euo pilefail` is now set to avoid other errors that might come up.
2021-10-26 10:04:07 +02:00
Tom A. Wagner
729d4e1555 CI: Update container 2021-10-13 12:11:06 +02:00
Tom A. Wagner
ce6cab8134 Run rustfmt 2021-10-13 12:01:22 +02:00
Tom A. Wagner
8a552d0712 graphview: Add missing }; to fix build. 2021-10-13 11:56:46 +02:00
halfbro
f76235674c Add small control point offset to links that connect from right to left (loopbacks) 2021-10-13 09:11:10 +00:00
Tom A. Wagner
92dcfd61a1 Add gpl snippets to the start of each rust source file. 2021-10-03 11:27:36 +02:00
Tom A. Wagner
02e58e9bfa LICENSE: Replace outdated and edited GPL with the lastest stock version.
The old license file contained project specific edits, but editing the GPL text is disallowed by the fsf.
The new file also contains updated links.
2021-10-03 11:14:30 +02:00
Tom A. Wagner
958fa15230 Release 0.3.1 2021-09-30 08:45:11 +02:00
Tom A. Wagner
e9753dd078 Update dependencies 2021-09-30 08:45:00 +02:00
Tom A. Wagner
dfb1b754c7 Add and install icons.
This adds one new app icon and one new symbolic icon, and makes meson install these to the appropriate
directories, and adds an appropriate entry in the .desktop file.
2021-09-30 08:28:06 +02:00
Tom A. Wagner
497da8b953 graphview: Do not crash when the position of a node not on the graph is requested
While the position of a node not on the graph should never be requested, this seems to occur sometimes,
so instead of panicking, we only log an error now, or ignore that node if it wasn't
important.
2021-09-11 15:03:03 +02:00
Tom A. Wagner
da5da90352 Add .desktop file
This adds a .desktop file (without icon for now), which will automatically be installed when
`meson install` is run.
2021-08-29 18:41:09 +02:00
Tom A. Wagner
a8bfd8383e buildsystem: Move to meson for building the project
Meson will allow us to:
- Verify the used rust compiler is recent enough
- Install ressources such as a .desktop files, icons, etc.
2021-08-29 17:28:55 +02:00
Tom A. Wagner
7ef8677c4c readme: move screenshot.png into docs/ folder 2021-08-29 16:56:56 +02:00
Roger Roger
487dc3b2d3 Group nodes into columns by major type 2021-08-18 07:23:50 +00:00
32 changed files with 5206 additions and 820 deletions

View File

@@ -1,6 +1,6 @@
include: include:
- project: 'freedesktop/ci-templates' # the project to include from - project: 'freedesktop/ci-templates' # the project to include from
ref: '98f557799157ebb0395cf11d40f01f61fbbace20' # git ref of that project ref: '34f4ade99434043f88e164933f570301fd18b125' # git ref of that project
file: '/templates/fedora.yml' # the actual file to include file: '/templates/fedora.yml' # the actual file to include
stages: stages:
@@ -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: '34' FDO_DISTRIBUTION_VERSION: '36'
# Update this to trigger a container rebuild # Update this to trigger a container rebuild
FDO_DISTRIBUTION_TAG: '2021-05-06.0' FDO_DISTRIBUTION_TAG: '2022-11-09.0'
build-fedora-container: build-fedora-container:
extends: extends:

758
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
[package] [package]
name = "helvum" name = "helvum"
version = "0.3.0" version = "0.4.0"
authors = ["Tom A. Wagner <tom.a.wagner@protonmail.com>"] authors = ["Tom A. Wagner <tom.a.wagner@protonmail.com>"]
edition = "2018" edition = "2021"
rust-version = "1.56"
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"]
@@ -13,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.6"
gtk = { version = "0.2", package = "gtk4" } gtk = { version = "0.6", package = "gtk4" }
glib = { version = "0.17", 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"

14
LICENSE
View File

@@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. of this license document, but changing it is not allowed.
@@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
Helvum <one line to give the program's name and a brief idea of what it does.>
Copyright (C) 2020 Ryuukyu Copyright (C) <year> <name of author>
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 as published by
@@ -645,14 +645,14 @@ the "copyright" line and a pointer to where the full notice is found.
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode: notice like this when it starts in an interactive mode:
Helvum Copyright (C) 2020 Ryuukyu <program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. under certain conditions; type `show c' for details.
@@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>. <https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -1,9 +1,10 @@
Helvum is a GTK-based patchbay for pipewire, inspired by the JACK tool [catia](https://kx.studio/Applications:Catia). Helvum is a GTK-based patchbay for pipewire, inspired by the JACK tool [catia](https://kx.studio/Applications:Catia).
![Screenshot](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,38 +15,49 @@ 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:
First, install the required flatpak platform and SDK, if you dont have them already:
```shell ```shell
$ flatpak install org.gnome.{Platform,Sdk}//40 org.freedesktop.Sdk.Extension.rust-stable//20.08 $ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
``` ```
To compile and install as a flatpak, run Then install the required flatpak platform and SDK, if you dont have them already:
```shell ```shell
$ flatpak-builder --install flatpak-build/ org.freedesktop.ryuukyu.Helvum.json $ 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, clone the project, change to the project directory, and run:
```shell
$ 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
For compilation, you will need: For compilation, you will need:
- Meson
- An up-to-date rust toolchain - An up-to-date rust toolchain
- `libclang-3.7` or higher - `libclang-3.7` or higher
- `gtk-4.0` and `pipewire-0.3` development headers - `gtk-4.0` and `pipewire-0.3` development headers
To compile, run To compile and install, run
$ cargo build --release ```shell
$ meson setup build && cd build
$ meson compile
$ meson install
```
in the repository root. in the repository root.
The resulting binary will be at `target/release/helvum`. 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).

12
build-aux/dist-vendor.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
export DIST="$1"
export SOURCE_ROOT="$2"
cd "$SOURCE_ROOT"
mkdir "$DIST"/.cargo
cargo vendor > $DIST/.cargo/config
# Move vendor into dist tarball directory
mv vendor "$DIST"

View File

@@ -0,0 +1,40 @@
{
"app-id": "org.pipewire.Helvum",
"runtime": "org.gnome.Platform",
"runtime-version": "43",
"sdk": "org.gnome.Sdk",
"sdk-extensions": [
"org.freedesktop.Sdk.Extension.rust-stable",
"org.freedesktop.Sdk.Extension.llvm14"
],
"command": "helvum",
"finish-args": [
"--socket=fallback-x11",
"--socket=wayland",
"--device=dri",
"--share=ipc",
"--filesystem=xdg-run/pipewire-0"
],
"build-options": {
"append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm14/bin",
"prepend-ld-library-path": "/usr/lib/sdk/llvm14/lib",
"build-args": [
"--share=network"
]
},
"modules": [
{
"name": "Helvum",
"buildsystem": "meson",
"sources": [
{
"type": "dir",
"path": "../"
}
],
"config-opts": [
"-Dprofile=development"
]
}
]
}

9
data/icons/meson.build Normal file
View File

@@ -0,0 +1,9 @@
install_data(
'@0@.svg'.format(base_id),
install_dir: iconsdir / 'hicolor' / 'scalable' / 'apps'
)
install_data(
'@0@-symbolic.svg'.format(base_id),
install_dir: iconsdir / 'hicolor' / 'symbolic' / 'apps',
)

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 3.5 2.5 h 9 c 1.378906 0 2.5 1.121094 2.5 2.5 v 5 c 0 1.378906 -1.121094 2.5 -2.5 2.5 h -9 c -1.378906 0 -2.5 -1.121094 -2.5 -2.5 v -5 c 0 -1.378906 1.121094 -2.5 2.5 -2.5 z m 0 0" fill="#241f31" fill-rule="evenodd"/>
<g fill="none" stroke="#8a8891">
<path d="m 11 7.5 h -6"/>
<path d="m 5 7.5 c 0 -4.15625 -1.382812 -10.855469 -3.90625 -13.25" stroke-linecap="round" stroke-linejoin="bevel" stroke-width="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 589 B

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#9a9996"/>
<stop offset="0.5" stop-color="#c0bfbc"/>
<stop offset="1" stop-color="#deddda"/>
</linearGradient>
<linearGradient id="b" x1="26.263471" x2="26.263586" xlink:href="#a" y1="24.848538" y2="37.1125"/>
<linearGradient id="c" gradientUnits="userSpaceOnUse" x1="7.39555839647" x2="120.60350567947" y1="82.86737386462" y2="82.86737386462">
<stop offset="0" stop-color="#5e5c64"/>
<stop offset="0.0384615" stop-color="#77767b"/>
<stop offset="0.0768555" stop-color="#5e5c64"/>
<stop offset="0.923077" stop-color="#5e5c64"/>
<stop offset="0.961538" stop-color="#77767b"/>
<stop offset="1" stop-color="#5e5c64"/>
</linearGradient>
<linearGradient id="d" gradientTransform="matrix(2.571428 0 0 2.454545 22.856596 -228.048061)" x1="16" x2="16" xlink:href="#a" y1="121.582512" y2="127.082512"/>
<linearGradient id="e" gradientTransform="matrix(2.571428 0 0 2.454545 22.856596 -253.563246)" x1="16" x2="16" xlink:href="#a" y1="121.582512" y2="127.082512"/>
<linearGradient id="f" gradientTransform="matrix(2.571428 0 0 2.454545 60.592569 -253.563246)" x1="16" x2="16" xlink:href="#a" y1="121.582512" y2="127.082512"/>
<linearGradient id="g" gradientTransform="matrix(2.571428 0 0 2.454545 60.592569 -228.048061)" x1="16" x2="16" xlink:href="#a" y1="121.582512" y2="127.082512"/>
<linearGradient id="h" gradientTransform="matrix(2.358499 0 0 2.251294 -11.472502 -204.652927)" gradientUnits="userSpaceOnUse" x1="12.5" x2="19.5" y1="113.832512" y2="113.832512">
<stop offset="0" stop-color="#c0bfbc"/>
<stop offset="0.5" stop-color="#9a9996"/>
<stop offset="1" stop-color="#c0bfbc"/>
</linearGradient>
<linearGradient id="i" gradientTransform="matrix(2.571428 0 0 2.454545 -14.879853 -228.048061)" x1="16" x2="16" xlink:href="#a" y1="121.582512" y2="127.082512"/>
<path d="m 34.519531 30.980469 c 0 3.386719 -3.695312 6.132812 -8.257812 6.132812 c -4.558594 0 -8.253907 -2.746093 -8.253907 -6.132812 s 3.695313 -6.132813 8.253907 -6.132813 c 4.5625 0 8.257812 2.746094 8.257812 6.132813 z m 0 0" fill="url(#b)" fill-rule="evenodd"/>
<path d="m 120.601562 82.867188 v 18.867187 c 0 5.226563 -4.207031 9.433594 -9.433593 9.433594 h -94.339844 c -5.226563 0 -9.433594 -4.207031 -9.433594 -9.433594 v -18.867187 z m 0 0" fill="url(#c)" fill-rule="evenodd"/>
<path d="m 16.828125 35.699219 c -5.226563 0 -9.433594 4.207031 -9.433594 9.433593 v 37.734376 c 0 5.226562 4.207031 9.433593 9.433594 9.433593 h 94.339844 c 5.226562 0 9.4375 -4.207031 9.4375 -9.433593 v -37.734376 c 0 -5.226562 -4.210938 -9.433593 -9.4375 -9.433593 h -76.648438 v 2.355469 h -16.511719 v -2.355469 z m 0 0" fill="#77767b" fill-rule="evenodd"/>
<path d="m 93.480469 76.378906 l -30.660157 -24.761718" fill="none" stroke="#77767b" stroke-linecap="square" stroke-width="3.74412"/>
<path d="m 64 58.691406 v 10.613282" fill="none" stroke="#999999" stroke-width="1.5"/>
<g fill-rule="evenodd">
<path d="m 75 77.132812 c 0 4.554688 -4.925781 8.25 -11 8.25 s -11 -3.695312 -11 -8.25 c 0 -4.558593 4.925781 -8.25 11 -8.25 s 11 3.691407 11 8.25 z m 0 0" fill="#e01b24"/>
<path d="m 73 77.132812 c 0 3.726563 -4.03125 6.75 -9 6.75 c -4.972656 0 -9 -3.023437 -9 -6.75 c 0 -3.730468 4.027344 -6.75 9 -6.75 c 4.96875 0 9 3.019532 9 6.75 z m 0 0" fill="url(#d)"/>
<path d="m 71 77.132812 c 0 2.898438 -3.132812 5.25 -7 5.25 s -7 -2.351562 -7 -5.25 c 0 -2.902343 3.132812 -5.25 7 -5.25 s 7 2.347657 7 5.25 z m 0 0" fill="#3d3846"/>
<path d="m 75 51.617188 c 0 4.554687 -4.925781 8.25 -11 8.25 s -11 -3.695313 -11 -8.25 c 0 -4.558594 4.925781 -8.25 11 -8.25 s 11 3.691406 11 8.25 z m 0 0" fill="#1c71d8"/>
<path d="m 73 51.617188 c 0 3.726562 -4.03125 6.75 -9 6.75 c -4.972656 0 -9 -3.023438 -9 -6.75 c 0 -3.730469 4.027344 -6.75 9 -6.75 c 4.96875 0 9 3.019531 9 6.75 z m 0 0" fill="url(#e)"/>
<path d="m 71 51.617188 c 0 2.898437 -3.132812 5.25 -7 5.25 s -7 -2.351563 -7 -5.25 c 0 -2.898438 3.132812 -5.25 7 -5.25 s 7 2.351562 7 5.25 z m 0 0" fill="#3d3846"/>
<path d="m 112.734375 51.617188 c 0 4.554687 -4.921875 8.25 -11 8.25 c -6.074219 0 -11 -3.695313 -11 -8.25 c 0 -4.558594 4.925781 -8.25 11 -8.25 c 6.078125 0 11 3.691406 11 8.25 z m 0 0" fill="#1c71d8"/>
<path d="m 110.734375 51.617188 c 0 3.726562 -4.027344 6.75 -9 6.75 c -4.96875 0 -9 -3.023438 -9 -6.75 c 0 -3.730469 4.03125 -6.75 9 -6.75 c 4.972656 0 9 3.019531 9 6.75 z m 0 0" fill="url(#f)"/>
<path d="m 108.734375 51.617188 c 0 2.898437 -3.132813 5.25 -7 5.25 c -3.863281 0 -7 -2.351563 -7 -5.25 c 0 -2.898438 3.136719 -5.25 7 -5.25 c 3.867187 0 7 2.351562 7 5.25 z m 0 0" fill="#3d3846"/>
</g>
<path d="m 101.734375 58.691406 v 10.613282" fill="none" stroke="#999999" stroke-width="1.5"/>
<path d="m 112.734375 77.132812 c 0 4.554688 -4.921875 8.25 -11 8.25 c -6.074219 0 -11 -3.695312 -11 -8.25 c 0 -4.558593 4.925781 -8.25 11 -8.25 c 6.078125 0 11 3.691407 11 8.25 z m 0 0" fill="#e01b24" fill-rule="evenodd"/>
<path d="m 110.734375 77.132812 c 0 3.726563 -4.027344 6.75 -9 6.75 c -4.96875 0 -9 -3.023437 -9 -6.75 c 0 -3.730468 4.03125 -6.75 9 -6.75 c 4.972656 0 9 3.019532 9 6.75 z m 0 0" fill="url(#g)" fill-rule="evenodd"/>
<path d="m 108.734375 77.132812 c 0 2.898438 -3.132813 5.25 -7 5.25 c -3.863281 0 -7 -2.351562 -7 -5.25 c 0 -2.902343 3.136719 -5.25 7 -5.25 c 3.867187 0 7 2.347657 7 5.25 z m 0 0" fill="#3d3846" fill-rule="evenodd"/>
<path d="m 26.261719 69.339844 v -10.648438" fill="none" stroke="#999999" stroke-width="1.5"/>
<path d="m 37.261719 52.515625 c 0 4.238281 -4.921875 7.671875 -11 7.671875 c -6.074219 0 -11 -3.433594 -11 -7.671875 c 0 -4.234375 4.925781 -7.671875 11 -7.671875 c 6.078125 0 11 3.4375 11 7.671875 z m 0 0" fill="#1c71d8" fill-rule="evenodd"/>
<path d="m 18.007812 30.980469 v 20.636719 c 0.003907 3.417968 3.699219 6.191406 8.253907 6.191406 c 4.554687 0 8.25 -2.765625 8.253906 -6.183594 c 0 0 0 -0.003906 0 -0.007812 v -20.636719 c -1.308594 2.597656 -4.589844 6.132812 -8.253906 6.132812 c -3.660157 0 -6.941407 -3.535156 -8.253907 -6.132812 z m 0 0" fill="url(#h)" fill-rule="evenodd"/>
<path d="m 37.261719 77.132812 c 0 4.554688 -4.921875 8.25 -11 8.25 c -6.074219 0 -11 -3.695312 -11 -8.25 c 0 -4.558593 4.925781 -8.25 11 -8.25 c 6.078125 0 11 3.691407 11 8.25 z m 0 0" fill="#e01b24" fill-rule="evenodd"/>
<path d="m 35.261719 77.132812 c 0 3.726563 -4.027344 6.75 -9 6.75 c -4.96875 0 -9 -3.023437 -9 -6.75 c 0 -3.730468 4.03125 -6.75 9 -6.75 c 4.972656 0 9 3.019532 9 6.75 z m 0 0" fill="url(#i)" fill-rule="evenodd"/>
<path d="m 33.261719 77.132812 c 0 2.898438 -3.132813 5.25 -7 5.25 c -3.863281 0 -7 -2.351562 -7 -5.25 c 0 -2.902343 3.136719 -5.25 7 -5.25 c 3.867187 0 7 2.347657 7 5.25 z m 0 0" fill="#3d3846" fill-rule="evenodd"/>
<path d="m 34.519531 30.980469 c 0 3.386719 -3.695312 6.132812 -8.257812 6.132812 c -4.558594 0 -8.253907 -2.746093 -8.253907 -6.132812 s 3.695313 -6.132813 8.253907 -6.132813 c 4.5625 0 8.257812 2.746094 8.257812 6.132813 z m 0 0" fill="#c0bfbc" fill-rule="evenodd"/>
<path d="m 30.980469 30.980469 c 0 1.953125 -2.113281 3.539062 -4.71875 3.539062 c -2.601563 0 -4.714844 -1.585937 -4.714844 -3.539062 s 2.113281 -3.539063 4.714844 -3.539063 c 2.605469 0 4.71875 1.585938 4.71875 3.539063 z m 0 0" fill="#1a5fb4" fill-rule="evenodd"/>
<path d="m 26.261719 30.980469 c 0 -7.074219 0.628906 -18.371094 -10.285157 -21.847657 c -11.828124 -3.765624 -33.882812 3 -33.882812 3" fill="none" stroke="#1a5fb4" stroke-width="9.434"/>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

49
data/meson.build Normal file
View File

@@ -0,0 +1,49 @@
subdir('icons')
desktop_conf = configuration_data()
desktop_conf.set('icon', base_id)
desktop_file = configure_file(
input: '@0@.desktop.in'.format(base_id),
output: '@BASENAME@',
configuration: desktop_conf
)
if desktop_file_validate.found()
test(
'validate-desktop',
desktop_file_validate,
args: [
desktop_file
],
)
endif
install_data(
desktop_file,
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,9 @@
[Desktop Entry]
Name=Helvum
GenericName=Patchbay
Comment=A patchbay for pipewire
Type=Application
Exec=helvum
Terminal=false
Categories=AudioVideo;Audio;Video;Midi;Settings;GNOME;GTK;
Icon=@icon@

View File

@@ -0,0 +1,42 @@
<?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.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.

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

38
meson.build Normal file
View File

@@ -0,0 +1,38 @@
project(
'helvum',
'rust',
version: '0.4.0',
license: 'GPL-3.0',
meson_version: '>=0.59.0'
)
gnome = import('gnome')
base_id = 'org.pipewire.Helvum'
dependency('glib-2.0', version: '>= 2.66')
dependency('gtk4', version: '>= 4.4.0')
dependency('libpipewire-0.3')
desktop_file_validate = find_program('desktop-file-validate', required: false)
appstream_util = find_program('appstream-util', required: false)
cargo = find_program('cargo', required: true)
prefix = get_option('prefix')
bindir = prefix / get_option('bindir')
datadir = prefix / get_option('datadir')
iconsdir = datadir / 'icons'
meson.add_dist_script(
'build-aux/dist-vendor.sh',
meson.project_build_root() / 'meson-dist' / meson.project_name() + '-' + meson.project_version(),
meson.project_source_root()
)
subdir('src')
subdir('data')
gnome.post_install(
gtk_update_icon_cache: true,
update_desktop_database: true,
)

11
meson_options.txt Normal file
View File

@@ -0,0 +1,11 @@
option(
'profile',
type: 'combo',
choices: [
'default',
'development'
],
value: 'default',
description: 'The build profile for Helvum. One of "default" or "development".'
)

View File

@@ -1,36 +0,0 @@
{
"app-id": "org.freedesktop.ryuukyu.Helvum",
"runtime": "org.gnome.Platform",
"runtime-version": "40",
"sdk": "org.gnome.Sdk",
"sdk-extensions": ["org.freedesktop.Sdk.Extension.rust-stable"],
"command": "helvum",
"finish-args" : [
"--socket=fallback-x11",
"--socket=wayland",
"--device=dri",
"--share=ipc",
"--filesystem=xdg-run/pipewire-0"
],
"build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin",
"build-args" : [
"--share=network"
]
},
"modules": [
{
"name": "Helvum",
"buildsystem": "simple",
"build-commands": [
"cargo install --path . --root /app --no-track"
],
"sources": [
{
"type": "dir",
"path": "./"
}
]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,3 +1,19 @@
// 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 std::cell::RefCell; use std::cell::RefCell;
use gtk::{ use gtk::{
@@ -6,12 +22,12 @@ use gtk::{
prelude::*, prelude::*,
subclass::prelude::*, subclass::prelude::*,
}; };
use log::{info, warn}; use log::info;
use pipewire::{channel::Sender, spa::Direction}; use pipewire::{channel::Sender, spa::Direction};
use crate::{ use crate::{
view::{self}, view::{self},
GtkMessage, MediaType, PipewireLink, PipewireMessage, GtkMessage, MediaType, NodeType, PipewireLink, PipewireMessage,
}; };
static STYLE: &str = include_str!("style.css"); static STYLE: &str = include_str!("style.css");
@@ -36,11 +52,16 @@ 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 = view::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)
@@ -50,15 +71,27 @@ 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::StyleContext::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,
@@ -82,11 +115,11 @@ 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 imp.pw_sender
.set(RefCell::new(pw_sender)) .set(RefCell::new(pw_sender))
// Discard the returned sender, as it does not implement `Debug`. // Discard the returned sender, as it does not implement `Debug`.
@@ -108,7 +141,7 @@ impl Application {
@weak app => @default-return Continue(true), @weak app => @default-return Continue(true),
move |msg| { move |msg| {
match msg { match msg {
PipewireMessage::NodeAdded{ id, name } => app.add_node(id, name.as_str()), 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::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::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::LinkStateChanged { id, active } => app.link_state_changed(id, active), // TODO
@@ -125,12 +158,12 @@ impl Application {
} }
/// Add a new node to the view. /// Add a new node to the view.
fn add_node(&self, id: u32, name: &str) { fn add_node(&self, id: u32, name: &str, node_type: Option<NodeType>) {
info!("Adding node to graph: id {}", id); info!("Adding node to graph: id {}", id);
imp::Application::from_instance(self) self.imp()
.graphview .graphview
.add_node(id, view::Node::new(name)); .add_node(id, view::Node::new(name, id), node_type);
} }
/// Add a new port to the view. /// Add a new port to the view.
@@ -144,12 +177,10 @@ impl Application {
) { ) {
info!("Adding port to graph: id {}", id); info!("Adding port to graph: id {}", id);
let imp = imp::Application::from_instance(self);
let port = view::Port::new(id, name, direction, media_type); let port = view::Port::new(id, name, direction, media_type);
// Create or delete a link if the widget emits the "port-toggled" signal. // Create or delete a link if the widget emits the "port-toggled" signal.
if let Err(e) = port.connect_local( port.connect_local(
"port_toggled", "port_toggled",
false, false,
clone!(@weak self as app => @default-return None, move |args| { clone!(@weak self as app => @default-return None, move |args| {
@@ -161,11 +192,9 @@ impl Application {
None None
}), }),
) { );
warn!("Failed to connect to \"port-toggled\" signal: {}", e);
}
imp.graphview.add_port(node_id, id, port); self.imp().graphview.add_port(node_id, id, port);
} }
/// Add a new link to the view. /// Add a new link to the view.
@@ -183,7 +212,7 @@ impl Application {
// FIXME: Links should be colored depending on the data they carry (video, audio, midi) like ports are. // FIXME: Links should be colored depending on the data they carry (video, audio, midi) like ports are.
// Update graph to contain the new link. // Update graph to contain the new link.
imp::Application::from_instance(self).graphview.add_link( self.imp().graphview.add_link(
id, id,
PipewireLink { PipewireLink {
node_from, node_from,
@@ -202,15 +231,17 @@ impl Application {
if active { "active" } else { "inactive" } if active { "active" } else { "inactive" }
); );
imp::Application::from_instance(self) self.imp().graphview.set_link_state(id, active);
.graphview
.set_link_state(id, active);
} }
// Toggle a link between the two specified ports on the remote pipewire server. // Toggle a link between the two specified ports on the remote pipewire server.
fn toggle_link(&self, port_from: u32, port_to: u32) { fn toggle_link(&self, port_from: u32, port_to: u32) {
let imp = imp::Application::from_instance(self); let sender = self
let sender = imp.pw_sender.get().expect("pw_sender not set").borrow_mut(); .imp()
.pw_sender
.get()
.expect("pw_sender not set")
.borrow_mut();
sender sender
.send(GtkMessage::ToggleLink { port_from, port_to }) .send(GtkMessage::ToggleLink { port_from, port_to })
.expect("Failed to send message"); .expect("Failed to send message");
@@ -220,8 +251,7 @@ impl Application {
fn remove_node(&self, id: u32) { fn remove_node(&self, id: u32) {
info!("Removing node from graph: id {}", id); info!("Removing node from graph: id {}", id);
let imp = imp::Application::from_instance(self); self.imp().graphview.remove_node(id);
imp.graphview.remove_node(id);
} }
/// Remove the port with the id `id` from the node with the id `node_id` /// Remove the port with the id `id` from the node with the id `node_id`
@@ -229,15 +259,13 @@ impl Application {
fn remove_port(&self, id: u32, node_id: u32) { fn remove_port(&self, id: u32, node_id: u32) {
info!("Removing port from graph: id {}, node_id: {}", id, node_id); info!("Removing port from graph: id {}, node_id: {}", id, node_id);
let imp = imp::Application::from_instance(self); self.imp().graphview.remove_port(id, node_id);
imp.graphview.remove_port(id, node_id);
} }
/// Remove the link with the specified id from the view. /// Remove the link with the specified id from the view.
fn remove_link(&self, id: u32) { fn remove_link(&self, id: u32) {
info!("Removing link from graph: id {}", id); info!("Removing link from graph: id {}", id);
let imp = imp::Application::from_instance(self); self.imp().graphview.remove_link(id);
imp.graphview.remove_link(id);
} }
} }

View File

@@ -1,11 +1,25 @@
// 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 application; mod application;
mod pipewire_connection; mod pipewire_connection;
mod view; mod view;
use gtk::{ use glib::PRIORITY_DEFAULT;
glib::{self, PRIORITY_DEFAULT}, use gtk::prelude::*;
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.
@@ -23,6 +37,7 @@ enum PipewireMessage {
NodeAdded { NodeAdded {
id: u32, id: u32,
name: String, name: String,
node_type: Option<NodeType>,
}, },
PortAdded { PortAdded {
id: u32, id: u32,
@@ -55,6 +70,12 @@ enum PipewireMessage {
}, },
} }
#[derive(Debug, Clone)]
pub enum NodeType {
Input,
Output,
}
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
pub enum MediaType { pub enum MediaType {
Audio, Audio,
@@ -70,8 +91,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.

31
src/meson.build Normal file
View File

@@ -0,0 +1,31 @@
cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ]
cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ]
if get_option('profile') == 'default'
cargo_options += [ '--release' ]
rust_target = 'release'
message('Building in release mode')
else
rust_target = 'debug'
message('Building in debug mode')
endif
cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ]
custom_target(
'cargo-build',
build_by_default: true,
build_always_stale: true,
output: meson.project_name(),
console: true,
install: true,
install_dir: bindir,
command: [
'env',
cargo_env,
cargo, 'build',
cargo_options,
'&&',
'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@',
],
)

View File

@@ -1,3 +1,19 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
mod state; mod state;
use std::{cell::RefCell, collections::HashMap, rc::Rc}; use std::{cell::RefCell, collections::HashMap, rc::Rc};
@@ -14,7 +30,7 @@ use pipewire::{
Context, Core, MainLoop, Context, Core, MainLoop,
}; };
use crate::{GtkMessage, MediaType, PipewireMessage}; use crate::{GtkMessage, MediaType, NodeType, PipewireMessage};
use state::{Item, State}; use state::{Item, State};
enum ProxyItem { enum ProxyItem {
@@ -93,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(),
); );
@@ -112,6 +128,27 @@ fn handle_node(
} }
}); });
let media_class = |class: &str| {
if class.contains("Sink") || class.contains("Input") {
Some(NodeType::Input)
} else if class.contains("Source") || class.contains("Output") {
Some(NodeType::Output)
} else {
None
}
};
let node_type = props
.get("media.category")
.and_then(|class| {
if class.contains("Duplex") {
None
} else {
props.get("media.class").and_then(media_class)
}
})
.or_else(|| props.get("media.class").and_then(media_class));
state.borrow_mut().insert( state.borrow_mut().insert(
node.id, node.id,
Item::Node { Item::Node {
@@ -121,7 +158,11 @@ fn handle_node(
); );
sender sender
.send(PipewireMessage::NodeAdded { id: node.id, name }) .send(PipewireMessage::NodeAdded {
id: node.id,
name,
node_type,
})
.expect("Failed to send message"); .expect("Failed to send message");
} }

View File

@@ -1,3 +1,19 @@
// 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 std::collections::HashMap; use std::collections::HashMap;
use crate::MediaType; use crate::MediaType;

View File

@@ -1,14 +1,40 @@
/* 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 video rgb(200,200,0);
@define-color midi rgb(200,0,50);
@define-color graphview-link #808080;
.audio { .audio {
background: rgb(50,100,240); background: @audio;
color: black; color: black;
} }
.video { .video {
background: rgb(200,200,0); background: @video;
color: black; color: black;
} }
.midi { .midi {
background: rgb(200,0,50); background: @midi;
color: black; color: black;
} }
graphview {
background-color: @text_view_bg;
}

View File

@@ -1,27 +1,73 @@
// 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 super::{Node, Port}; use super::{Node, Port};
use gtk::{ use gtk::{
glib::{self, clone}, glib::{self, clone},
graphene, gsk, graphene,
graphene::Point,
gsk,
prelude::*, prelude::*,
subclass::prelude::*, subclass::prelude::*,
}; };
use log::{error, warn}; use log::{error, warn};
use std::collections::HashMap; use std::{cmp::Ordering, collections::HashMap};
use crate::NodeType;
const CANVAS_SIZE: f64 = 5000.0;
mod imp { mod imp {
use super::*; use super::*;
use std::{cell::RefCell, rc::Rc}; use std::cell::{Cell, RefCell};
use gtk::{
gdk::{self, RGBA},
graphene::Rect,
gsk::ColorStop,
};
use log::warn; use log::warn;
use once_cell::sync::Lazy;
pub struct DragState {
node: glib::WeakRef<Node>,
/// This stores the offset of the pointer to the origin of the node,
/// so that we can keep the pointer over the same position when moving the node
///
/// The offset is normalized to the default zoom-level of 1.0.
offset: Point,
}
#[derive(Default)] #[derive(Default)]
pub struct GraphView { pub struct GraphView {
pub(super) nodes: RefCell<HashMap<u32, Node>>, /// Stores nodes and their positions.
pub(super) nodes: RefCell<HashMap<u32, (Node, Point)>>,
/// Stores the link and whether it is currently active. /// Stores the link and whether it is currently active.
pub(super) links: RefCell<HashMap<u32, (crate::PipewireLink, bool)>>, pub(super) links: RefCell<HashMap<u32, (crate::PipewireLink, bool)>>,
pub hadjustment: RefCell<Option<gtk::Adjustment>>,
pub 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>>,
// Memorized data for an in-progress zoom gesture
pub zoom_gesture_initial_zoom: Cell<Option<f64>>,
pub zoom_gesture_anchor: Cell<Option<(f64, f64)>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@@ -29,129 +75,366 @@ mod imp {
const NAME: &'static str = "GraphView"; const NAME: &'static str = "GraphView";
type Type = super::GraphView; type Type = super::GraphView;
type ParentType = gtk::Widget; type ParentType = gtk::Widget;
type Interfaces = (gtk::Scrollable,);
fn class_init(klass: &mut Self::Class) { fn class_init(klass: &mut Self::Class) {
// The layout manager determines how child widgets are laid out. klass.set_css_name("graphview");
klass.set_layout_manager_type::<gtk::FixedLayout>();
} }
} }
impl ObjectImpl for GraphView { impl ObjectImpl for GraphView {
fn constructed(&self, obj: &Self::Type) { fn constructed(&self) {
self.parent_constructed(obj); self.parent_constructed();
let drag_state = Rc::new(RefCell::new(None)); self.obj().set_overflow(gtk::Overflow::Hidden);
self.setup_node_dragging();
self.setup_scroll_zooming();
self.setup_zoom_gesture();
}
fn dispose(&self) {
self.nodes
.borrow()
.values()
.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();
let zoom_factor = self.zoom_factor.get();
for (node, point) in self.nodes.borrow().values() {
let (_, natural_size) = node.preferred_size();
let transform = self
.canvas_space_to_screen_space_transform()
.translate(point);
node.allocate(
(natural_size.width() as f64 / zoom_factor).ceil() as i32,
(natural_size.height() as f64 / zoom_factor).ceil() as i32,
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()
.values()
// 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(); let drag_controller = gtk::GestureDrag::new();
drag_controller.connect_drag_begin( drag_controller.connect_drag_begin(|drag_controller, x, y| {
clone!(@strong drag_state => move |drag_controller, x, y| {
let mut drag_state = drag_state.borrow_mut();
let widget = drag_controller let widget = drag_controller
.widget() .widget()
.expect("drag-begin event has no widget") .dynamic_cast::<super::GraphView>()
.dynamic_cast::<Self::Type>()
.expect("drag-begin event is not on the 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. // 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"); let target = widget
*drag_state = if target.ancestor(Port::static_type()).is_some() { .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 // The user targeted a port, so the dragging should be handled by the Port
// component instead of here. // component instead of here.
None None
} else if let Some(target) = target.ancestor(Node::static_type()) { } else if let Some(target) = target.ancestor(Node::static_type()) {
// The user targeted a Node without targeting a specific Port. // The user targeted a Node without targeting a specific Port.
// Drag the Node around the screen. // Drag the Node around the screen.
let (x, y) = widget.get_node_position(&target); let node = target.dynamic_cast_ref::<Node>().unwrap();
Some((target, x, y))
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 { } else {
None None
} }
})); });
drag_controller.connect_drag_update( drag_controller.connect_drag_update(|drag_controller, x, y| {
clone!(@strong drag_state => move |drag_controller, x, y| {
let widget = drag_controller let widget = drag_controller
.widget() .widget()
.expect("drag-update event has no widget") .dynamic_cast::<super::GraphView>()
.dynamic_cast::<Self::Type>()
.expect("drag-update event is not on the GraphView"); .expect("drag-update event is not on the GraphView");
let drag_state = drag_state.borrow(); let dragged_node = widget.imp().dragged_node.borrow();
if let Some((ref node, x1, y1)) = *drag_state { let Some(DragState { node, offset }) = dragged_node.as_ref() else { return };
widget.move_node(node, x1 + x as f32, y1 + y as f32); let Some(node) = node.upgrade() else { return };
}
} let (start_x, start_y) = drag_controller
.start_point()
.expect("Drag has no start point");
let onscreen_node_origin = Point::new((start_x + x) as f32, (start_y + y) as f32);
let transform = widget.imp().screen_space_to_canvas_space_transform();
let canvas_node_origin = transform.transform_point(&onscreen_node_origin);
widget.move_node(
&node,
&Point::new(
canvas_node_origin.x() - offset.x(),
canvas_node_origin.y() - offset.y(),
), ),
); );
obj.add_controller(&drag_controller); });
self.obj().add_controller(drag_controller);
} }
fn dispose(&self, _obj: &Self::Type) { fn setup_scroll_zooming(&self) {
self.nodes // We're only interested in the vertical axis, but for devices like touchpads,
.borrow() // not capturing a small accidental horizontal move may cause the scroll to be disrupted if a widget
.values() // higher up captures it instead.
.for_each(|node| node.unparent()) 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);
gtk::Inhibit(true)
} else {
gtk::Inhibit(false)
} }
});
self.obj().add_controller(scroll_controller);
} }
impl WidgetImpl for GraphView { fn setup_zoom_gesture(&self) {
fn snapshot(&self, widget: &Self::Type, snapshot: &gtk::Snapshot) { let zoom_gesture = gtk::GestureZoom::new();
/* FIXME: A lot of hardcoded values in here. zoom_gesture.connect_begin(|gesture, _| {
Try to use relative units (em) and colours from the theme as much as possible. */ 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(); let alloc = widget.allocation();
let background_cr = snapshot // We need to offset the lines between 0 and (excluding) `grid_size` so the grid moves with
.append_cairo(&graphene::Rect::new( // the rest of the view when scrolling.
0.0, // The offset is rounded so the grid is always aligned to a row of pixels.
0.0, let hadj = self
alloc.width as f32, .hadjustment
alloc.height as f32,
))
.expect("Failed to get cairo context");
// Try to replace the background color with a darker one from the theme.
if let Some(rgba) = widget.style_context().lookup_color("text_view_bg") {
background_cr.set_source_rgb(rgba.red.into(), rgba.green.into(), rgba.blue.into());
if let Err(e) = background_cr.paint() {
warn!("Failed to paint graphview background: {}", e);
};
} // TODO: else log colour not found
// 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() .borrow()
.values() .as_ref()
.for_each(|node| self.instance().snapshot_child(node, snapshot)); .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;
// Draw all links snapshot.push_repeat(
let link_cr = snapshot &Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32),
.append_cairo(&graphene::Rect::new( 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 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,
0.0, 0.0,
alloc.width as f32, alloc.width() as f32,
alloc.height as f32, alloc.height() as f32,
)) ));
.expect("Failed to get cairo context");
link_cr.set_line_width(2.0); link_cr.set_line_width(2.0 * self.zoom_factor.get());
link_cr.set_source_rgb(0.0, 0.0, 0.0);
let rgba = widget
.style_context()
.lookup_color("graphview-link")
.unwrap_or(gtk::gdk::RGBA::BLACK);
link_cr.set_source_rgba(
rgba.red().into(),
rgba.green().into(),
rgba.blue().into(),
rgba.alpha().into(),
);
for (link, active) in self.links.borrow().values() { for (link, active) in self.links.borrow().values() {
// TODO: Do not draw links when they are outside the view
if let Some((from_x, from_y, to_x, to_y)) = self.get_link_coordinates(link) { if let Some((from_x, from_y, to_x, to_y)) = self.get_link_coordinates(link) {
link_cr.move_to(from_x, from_y); link_cr.move_to(from_x, from_y);
@@ -162,15 +445,24 @@ mod imp {
link_cr.set_dash(&[10.0, 5.0], 0.0); 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. // 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, // 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. // especially when the output port is farther right than the input port.
let half_x_dist = f64::abs(from_x - to_x) / 2.0; let half_x_dist = f64::abs(from_x - to_x) / 2.0;
link_cr.curve_to( link_cr.curve_to(
from_x + half_x_dist, from_x + half_x_dist,
from_y, from_y - y_control_offset,
to_x - half_x_dist, to_x - half_x_dist,
to_y, to_y - y_control_offset,
to_x, to_x,
to_y, to_y,
); );
@@ -183,48 +475,81 @@ mod imp {
} }
} }
} }
}
impl GraphView {
/// Get coordinates for the drawn link to start at and to end at. /// Get coordinates for the drawn link to start at and to end at.
/// ///
/// # Returns /// # Returns
/// `Some((from_x, from_y, to_x, to_y))` if all objects the links refers to exist as widgets. /// `Some((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)> { fn get_link_coordinates(&self, link: &crate::PipewireLink) -> Option<(f64, f64, f64, f64)> {
let widget = &*self.obj();
let nodes = self.nodes.borrow(); let nodes = self.nodes.borrow();
// For some reason, gtk4::WidgetExt::translate_coordinates gives me incorrect values, let output_port = &nodes.get(&link.node_from)?.0.get_port(link.port_from)?;
// so we manually calculate the needed offsets here.
let from_port = &nodes.get(&link.node_from)?.get_port(link.port_from)?; let output_port_padding =
let gtk::Allocation { (output_port.allocated_width() - output_port.width()) as f64 / 2.0;
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 (from_x, from_y) = output_port.translate_coordinates(
let gtk::Allocation { widget,
x: mut tx, output_port.width() as f64 + output_port_padding,
y: mut ty, (output_port.height() / 2) as f64,
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())) let input_port = &nodes.get(&link.node_to)?.0.get_port(link.port_to)?;
let input_port_padding =
(input_port.allocated_width() - input_port.width()) as f64 / 2.0;
let (to_x, to_y) = input_port.translate_coordinates(
widget,
-input_port_padding,
(input_port.height() / 2) as f64,
)?;
Some((from_x, from_y, to_x, to_y))
}
fn set_adjustment(
&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,
);
} }
} }
} }
@@ -235,28 +560,94 @@ glib::wrapper! {
} }
impl GraphView { impl GraphView {
pub const ZOOM_MIN: f64 = 0.3;
pub const ZOOM_MAX: f64 = 4.0;
pub fn new() -> Self { pub fn new() -> Self {
glib::Object::new(&[]).expect("Failed to create GraphView") glib::Object::new()
} }
pub fn add_node(&self, id: u32, node: Node) { pub fn zoom_factor(&self) -> f64 {
let private = imp::GraphView::from_instance(self); 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, id: u32, node: Node, node_type: Option<NodeType>) {
let imp = self.imp();
node.set_parent(self); node.set_parent(self);
// Place widgets in colums of 4, growing down, then right. // Place widgets in colums of 3, growing down
// TODO: Make a better positioning algorithm. let x = if let Some(node_type) = node_type {
let x = ((private.nodes.borrow().len() / 4) as f32 * 400.0) + 20.0; // This relies on integer division rounding down. match node_type {
let y = (private.nodes.borrow().len() as f32 % 4.0 * 100.0) + 20.0; NodeType::Output => 20.0,
NodeType::Input => 820.0,
}
} else {
420.0
};
self.move_node(&node.clone().upcast(), x, y); let y = imp
.nodes
.borrow()
.values()
.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);
private.nodes.borrow_mut().insert(id, node); imp.nodes.borrow_mut().insert(id, (node, Point::new(x, y)));
} }
pub fn remove_node(&self, id: u32) { pub fn remove_node(&self, id: u32) {
let private = imp::GraphView::from_instance(self); let mut nodes = self.imp().nodes.borrow_mut();
let mut nodes = private.nodes.borrow_mut(); if let Some((node, _)) = nodes.remove(&id) {
if let Some(node) = nodes.remove(&id) {
node.unparent(); node.unparent();
} else { } else {
warn!("Tried to remove non-existant node (id={}) from graph", id); warn!("Tried to remove non-existant node (id={}) from graph", id);
@@ -264,9 +655,7 @@ impl GraphView {
} }
pub fn add_port(&self, node_id: u32, port_id: u32, port: crate::view::port::Port) { 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, _)) = self.imp().nodes.borrow_mut().get_mut(&node_id) {
if let Some(node) = private.nodes.borrow_mut().get_mut(&node_id) {
node.add_port(port_id, port); node.add_port(port_id, port);
} else { } else {
error!( error!(
@@ -277,22 +666,22 @@ impl GraphView {
} }
pub fn remove_port(&self, id: u32, node_id: u32) { pub fn remove_port(&self, id: u32, node_id: u32) {
let private = imp::GraphView::from_instance(self); let nodes = self.imp().nodes.borrow();
let nodes = private.nodes.borrow(); if let Some((node, _)) = nodes.get(&node_id) {
if let Some(node) = nodes.get(&node_id) {
node.remove_port(id); node.remove_port(id);
} }
} }
pub fn add_link(&self, link_id: u32, link: crate::PipewireLink, active: bool) { pub fn add_link(&self, link_id: u32, link: crate::PipewireLink, active: bool) {
let private = imp::GraphView::from_instance(self); self.imp()
private.links.borrow_mut().insert(link_id, (link, active)); .links
.borrow_mut()
.insert(link_id, (link, active));
self.queue_draw(); self.queue_draw();
} }
pub fn set_link_state(&self, link_id: u32, active: bool) { pub fn set_link_state(&self, link_id: u32, active: bool) {
let private = imp::GraphView::from_instance(self); if let Some((_, state)) = self.imp().links.borrow_mut().get_mut(&link_id) {
if let Some((_, state)) = private.links.borrow_mut().get_mut(&link_id) {
*state = active; *state = active;
self.queue_draw(); self.queue_draw();
} else { } else {
@@ -301,51 +690,42 @@ impl GraphView {
} }
pub fn remove_link(&self, id: u32) { pub fn remove_link(&self, id: u32) {
let private = imp::GraphView::from_instance(self); let mut links = self.imp().links.borrow_mut();
let mut links = private.links.borrow_mut();
links.remove(&id); links.remove(&id);
self.queue_draw(); self.queue_draw();
} }
pub(super) fn get_node_position(&self, node: &gtk::Widget) -> (f32, f32) { /// Get the position of the specified node inside the graphview.
let layout_manager = self ///
.layout_manager() /// The returned position is in canvas-space (non-zoomed, (0, 0) fixed in the middle of the canvas).
.expect("Failed to get layout manager") pub(super) fn node_position(&self, node: &Node) -> Option<Point> {
.dynamic_cast::<gtk::FixedLayout>() self.imp()
.expect("Failed to cast to FixedLayout"); .nodes
.borrow()
let node = layout_manager .get(&node.pipewire_id())
.layout_child(node) .map(|(_, point)| *point)
.expect("Could not get layout child")
.dynamic_cast::<gtk::FixedLayoutChild>()
.expect("Could not cast to FixedLayoutChild");
let transform = node.transform().unwrap_or_default();
transform.to_translate()
} }
pub(super) fn move_node(&self, node: &gtk::Widget, x: f32, y: f32) { pub(super) fn move_node(&self, widget: &Node, point: &Point) {
let layout_manager = self let mut nodes = self.imp().nodes.borrow_mut();
.layout_manager() let mut node = nodes
.expect("Failed to get layout manager") .get_mut(&widget.pipewire_id())
.dynamic_cast::<gtk::FixedLayout>() .expect("Node is not on the graph");
.expect("Failed to cast to FixedLayout");
let transform = gsk::Transform::new() // Clamp the new position to within the graph, so a node can't be moved outside it and be lost.
// Nodes should not be able to be dragged out of the view, so we use `max(coordinate, 0.0)` to prevent that. node.1 = Point::new(
.translate(&graphene::Point::new(f32::max(x, 0.0), f32::max(y, 0.0))) point.x().clamp(
.unwrap(); -(CANVAS_SIZE / 2.0) as f32,
(CANVAS_SIZE / 2.0) as f32 - widget.width() as f32,
),
point.y().clamp(
-(CANVAS_SIZE / 2.0) as f32,
(CANVAS_SIZE / 2.0) as f32 - widget.height() as f32,
),
);
layout_manager self.queue_allocate();
.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();
} }
} }

View File

@@ -1,3 +1,19 @@
// 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
//! The view presented to the user. //! The view presented to the user.
//! //!
//! This module contains gtk widgets needed to present the graphical user interface. //! This module contains gtk widgets needed to present the graphical user interface.
@@ -5,7 +21,9 @@
mod graph_view; mod graph_view;
mod node; mod node;
mod port; mod port;
mod zoomentry;
pub use graph_view::GraphView; pub use graph_view::GraphView;
pub use node::Node; pub use node::Node;
pub use port::Port; pub use port::Port;
pub use zoomentry::ZoomEntry;

View File

@@ -1,14 +1,34 @@
// 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 gtk::{glib, prelude::*, subclass::prelude::*};
use pipewire::spa::Direction; use pipewire::spa::Direction;
use std::collections::HashMap; use std::collections::HashMap;
mod imp { mod imp {
use glib::ParamFlags;
use once_cell::sync::Lazy;
use super::*; use super::*;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
pub struct Node { pub struct Node {
pub(super) pipewire_id: Cell<u32>,
pub(super) grid: gtk::Grid, pub(super) grid: gtk::Grid,
pub(super) label: gtk::Label, pub(super) label: gtk::Label,
pub(super) ports: RefCell<HashMap<u32, crate::view::port::Port>>, pub(super) ports: RefCell<HashMap<u32, crate::view::port::Port>>,
@@ -18,7 +38,7 @@ mod imp {
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for Node { impl ObjectSubclass for Node {
const NAME: &'static str = "Node"; const NAME: &'static str = "HelvumNode";
type Type = super::Node; type Type = super::Node;
type ParentType = gtk::Widget; type ParentType = gtk::Widget;
@@ -28,7 +48,12 @@ mod imp {
fn new() -> Self { fn new() -> Self {
let grid = gtk::Grid::new(); let grid = gtk::Grid::new();
let label = gtk::Label::new(None); 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); grid.attach(&label, 0, 0, 2, 1);
@@ -36,6 +61,7 @@ mod imp {
label.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref()); label.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
Self { Self {
pipewire_id: Cell::new(0),
grid, grid,
label, label,
ports: RefCell::new(HashMap::new()), ports: RefCell::new(HashMap::new()),
@@ -46,12 +72,44 @@ mod imp {
} }
impl ObjectImpl for Node { impl ObjectImpl for Node {
fn constructed(&self, obj: &Self::Type) { fn constructed(&self) {
self.parent_constructed(obj); self.parent_constructed();
self.grid.set_parent(obj); self.grid.set_parent(&*self.obj());
} }
fn dispose(&self, _obj: &Self::Type) { fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecUInt::builder("pipewire-id")
.flags(ParamFlags::READWRITE | ParamFlags::CONSTRUCT_ONLY)
.build(),
glib::ParamSpecString::builder("name").build(),
]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"pipewire-id" => self.pipewire_id.get().to_value(),
"name" => self.label.text().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"name" => {
self.label.set_text(value.get().unwrap());
self.label.set_tooltip_text(value.get().ok());
}
"pipewire-id" => self.pipewire_id.set(value.get().unwrap()),
_ => unimplemented!(),
}
}
fn dispose(&self) {
self.grid.unparent(); self.grid.unparent();
} }
} }
@@ -65,47 +123,54 @@ glib::wrapper! {
} }
impl Node { impl Node {
pub fn new(name: &str) -> Self { pub fn new(name: &str, pipewire_id: u32) -> Self {
let res: Self = glib::Object::new(&[]).expect("Failed to create Node"); glib::Object::builder()
let private = imp::Node::from_instance(&res); .property("name", &name)
.property("pipewire-id", &pipewire_id)
.build()
}
private.label.set_text(name); pub fn pipewire_id(&self) -> u32 {
self.property("pipewire-id")
}
res /// Get the nodes `name` property, which represents the displayed name.
pub fn name(&self) -> String {
self.property("name")
}
/// Set the nodes `name` property, which represents the displayed name.
pub fn set_name(&self, name: &str) {
self.set_property("name", name);
} }
pub fn add_port(&mut self, id: u32, port: super::port::Port) { pub fn add_port(&mut self, id: u32, port: super::port::Port) {
let private = imp::Node::from_instance(self); let imp = self.imp();
match port.direction() { match port.direction() {
Direction::Input => { Direction::Input => {
private imp.grid.attach(&port, 0, imp.num_ports_in.get() + 1, 1, 1);
.grid imp.num_ports_in.set(imp.num_ports_in.get() + 1);
.attach(&port, 0, private.num_ports_in.get() + 1, 1, 1);
private.num_ports_in.set(private.num_ports_in.get() + 1);
} }
Direction::Output => { Direction::Output => {
private imp.grid.attach(&port, 1, imp.num_ports_out.get() + 1, 1, 1);
.grid imp.num_ports_out.set(imp.num_ports_out.get() + 1);
.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); imp.ports.borrow_mut().insert(id, port);
} }
pub fn get_port(&self, id: u32) -> Option<super::port::Port> { pub fn get_port(&self, id: u32) -> Option<super::port::Port> {
let private = imp::Node::from_instance(self); self.imp().ports.borrow_mut().get(&id).cloned()
private.ports.borrow_mut().get(&id).cloned()
} }
pub fn remove_port(&self, id: u32) { pub fn remove_port(&self, id: u32) {
let private = imp::Node::from_instance(self); let imp = self.imp();
if let Some(port) = private.ports.borrow_mut().remove(&id) { if let Some(port) = imp.ports.borrow_mut().remove(&id) {
match port.direction() { match port.direction() {
Direction::Input => private.num_ports_in.set(private.num_ports_in.get() - 1), Direction::Input => imp.num_ports_in.set(imp.num_ports_in.get() - 1),
Direction::Output => private.num_ports_in.set(private.num_ports_out.get() - 1), Direction::Output => imp.num_ports_in.set(imp.num_ports_out.get() - 1),
} }
port.unparent(); port.unparent();

View File

@@ -1,3 +1,19 @@
// 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::{ use gtk::{
gdk, gdk,
glib::{self, clone, subclass::Signal}, glib::{self, clone, subclass::Signal},
@@ -11,17 +27,18 @@ use crate::MediaType;
/// A helper struct for linking a output port to an input port. /// A helper struct for linking a output port to an input port.
/// It carries the output ports id. /// It carries the output ports id.
#[derive(Clone, Debug, glib::GBoxed)] #[derive(Clone, Debug, glib::Boxed)]
#[gboxed(type_name = "HelvumForwardLink")] #[boxed_type(name = "HelvumForwardLink")]
struct ForwardLink(u32); struct ForwardLink(u32);
/// A helper struct for linking an input to an output port. /// A helper struct for linking an input to an output port.
/// It carries the input ports id. /// It carries the input ports id.
#[derive(Clone, Debug, glib::GBoxed)] #[derive(Clone, Debug, glib::Boxed)]
#[gboxed(type_name = "HelvumReversedLink")] #[boxed_type(name = "HelvumReversedLink")]
struct ReversedLink(u32); struct ReversedLink(u32);
mod imp { mod imp {
use glib::ParamFlags;
use once_cell::{sync::Lazy, unsync::OnceCell}; use once_cell::{sync::Lazy, unsync::OnceCell};
use pipewire::spa::Direction; use pipewire::spa::Direction;
@@ -30,14 +47,14 @@ mod imp {
/// Graphical representation of a pipewire port. /// Graphical representation of a pipewire port.
#[derive(Default)] #[derive(Default)]
pub struct Port { pub struct Port {
pub(super) label: OnceCell<gtk::Label>, pub(super) pipewire_id: OnceCell<u32>,
pub(super) id: OnceCell<u32>, pub(super) label: gtk::Label,
pub(super) direction: OnceCell<Direction>, pub(super) direction: OnceCell<Direction>,
} }
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for Port { impl ObjectSubclass for Port {
const NAME: &'static str = "Port"; const NAME: &'static str = "HelvumPort";
type Type = super::Port; type Type = super::Port;
type ParentType = gtk::Widget; type ParentType = gtk::Widget;
@@ -50,21 +67,57 @@ mod imp {
} }
impl ObjectImpl for Port { impl ObjectImpl for Port {
fn dispose(&self, _obj: &Self::Type) { fn constructed(&self) {
if let Some(label) = self.label.get() { self.parent_constructed();
label.unparent()
self.label.set_parent(&*self.obj());
self.label.set_wrap(true);
self.label.set_lines(2);
self.label.set_max_width_chars(20);
self.label.set_ellipsize(gtk::pango::EllipsizeMode::End);
}
fn dispose(&self) {
self.label.unparent()
}
fn properties() -> &'static [glib::ParamSpec] {
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpecUInt::builder("pipewire-id")
.flags(ParamFlags::READWRITE | ParamFlags::CONSTRUCT_ONLY)
.build(),
glib::ParamSpecString::builder("name").build(),
]
});
PROPERTIES.as_ref()
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"pipewire-id" => self.pipewire_id.get().unwrap().to_value(),
"name" => self.label.text().to_value(),
_ => unimplemented!(),
}
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"name" => {
self.label.set_text(value.get().unwrap());
self.label.set_tooltip_text(value.get().ok());
}
"pipewire-id" => self.pipewire_id.set(value.get().unwrap()).unwrap(),
_ => unimplemented!(),
} }
} }
fn signals() -> &'static [Signal] { fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| { static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
vec![Signal::builder( vec![Signal::builder("port-toggled")
"port-toggled",
// Provide id of output port and input port to signal handler. // Provide id of output port and input port to signal handler.
&[<u32>::static_type().into(), <u32>::static_type().into()], .param_types([<u32>::static_type(), <u32>::static_type()])
// signal handler sends back nothing.
<()>::static_type().into(),
)
.build()] .build()]
}); });
@@ -82,29 +135,24 @@ glib::wrapper! {
impl Port { impl Port {
pub fn new(id: u32, name: &str, direction: Direction, media_type: Option<MediaType>) -> Self { pub fn new(id: u32, name: &str, direction: Direction, media_type: Option<MediaType>) -> Self {
// Create the widget and initialize needed fields // Create the widget and initialize needed fields
let res: Self = glib::Object::new(&[]).expect("Failed to create Port"); let res: Self = glib::Object::builder()
.property("pipewire-id", &id)
.property("name", &name)
.build();
let private = imp::Port::from_instance(&res); let imp = res.imp();
private.id.set(id).expect("Port id already set");
private imp.direction
.direction
.set(direction) .set(direction)
.expect("Port direction already set"); .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, // 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. // 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. // 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. // The port will simply provide its pipewire id to the drag target.
let drag_src = gtk::DragSourceBuilder::new() let drag_src = gtk::DragSource::builder()
.content(&gdk::ContentProvider::for_value(&match direction { .content(&gdk::ContentProvider::for_value(&match direction {
Direction::Input => ReversedLink(id).to_value(), Direction::Input => ReversedLink(id).to_value(),
Direction::Output => ForwardLink(id).to_value(), Direction::Output => ForwardLink(id).to_value(),
@@ -117,7 +165,7 @@ impl Port {
trace!("Drag from port {} was cancelled", id); trace!("Drag from port {} was cancelled", id);
false false
}); });
res.add_controller(&drag_src); res.add_controller(drag_src);
// The drop target will accept either a `ForwardLink` or `ReversedLink` depending in its own direction, // The drop target will accept either a `ForwardLink` or `ReversedLink` depending in its own direction,
// and use it to emit its `port-toggled` signal. // and use it to emit its `port-toggled` signal.
@@ -136,9 +184,7 @@ impl Port {
// Get the callback registered in the widget and call it // Get the callback registered in the widget and call it
drop_target drop_target
.widget() .widget()
.expect("Drop target has no widget") .emit_by_name::<()>("port-toggled", &[&source_id, &this.pipewire_id()]);
.emit_by_name("port-toggled", &[&source_id, &this.id()])
.expect("Failed to send signal");
} else { } else {
warn!("Invalid type dropped on ingoing port"); warn!("Invalid type dropped on ingoing port");
} }
@@ -154,9 +200,7 @@ impl Port {
// Get the callback registered in the widget and call it // Get the callback registered in the widget and call it
drop_target drop_target
.widget() .widget()
.expect("Drop target has no widget") .emit_by_name::<()>("port-toggled", &[&this.pipewire_id(), &target_id]);
.emit_by_name("port-toggled", &[&this.id(), &target_id])
.expect("Failed to send signal");
} else { } else {
warn!("Invalid type dropped on outgoing port"); warn!("Invalid type dropped on outgoing port");
} }
@@ -166,7 +210,7 @@ impl Port {
); );
} }
} }
res.add_controller(&drop_target); 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. // 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()); res.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
@@ -182,13 +226,24 @@ impl Port {
res res
} }
pub fn id(&self) -> u32 { pub fn pipewire_id(&self) -> u32 {
let private = imp::Port::from_instance(self); self.property("pipewire-id")
private.id.get().copied().expect("Port id is not set") }
/// Get the nodes `name` property, which represents the displayed name.
pub fn name(&self) -> String {
self.property("name")
}
/// Set the nodes `name` property, which represents the displayed name.
pub fn set_name(&self, name: &str) {
self.set_property("name", name);
} }
pub fn direction(&self) -> &Direction { pub fn direction(&self) -> &Direction {
let private = imp::Port::from_instance(self); self.imp()
private.direction.get().expect("Port direction is not set") .direction
.get()
.expect("Port direction is not set")
} }
} }

172
src/view/zoomentry.rs Normal file
View File

@@ -0,0 +1,172 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
use crate::view;
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<view::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::<view::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: view::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 [`view::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: &view::GraphView) -> Self {
glib::Object::builder()
.property("zoomed-widget", zoomed_widget)
.build()
}
}

26
src/view/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>