24 Commits
0.4.1 ... 0.5.0

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

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

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

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

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

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

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

2
.gitignore vendored
View File

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

View File

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

231
Cargo.lock generated
View File

@@ -4,9 +4,9 @@ version = 3
[[package]]
name = "aho-corasick"
version = "1.0.4"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a"
checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab"
dependencies = [
"memchr",
]
@@ -40,7 +40,7 @@ dependencies = [
"regex",
"rustc-hash",
"shlex",
"syn 2.0.29",
"syn 2.0.37",
]
[[package]]
@@ -57,9 +57,9 @@ checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
[[package]]
name = "cairo-rs"
version = "0.18.0"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d859b656775a6b1dd078d3e5924884e6ea88aa649a7fdde03d5b2ec56ffcc10b"
checksum = "1c0466dfa8c0ee78deef390c274ad756801e0a6dbb86c5ef0924a298c5761c4d"
dependencies = [
"bitflags 2.4.0",
"cairo-sys-rs",
@@ -71,9 +71,9 @@ dependencies = [
[[package]]
name = "cairo-sys-rs"
version = "0.18.0"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd4d115132e01c0165e3bf5f56aedee8980b0b96ede4eb000b693c05a8adb8ff"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys",
"libc",
@@ -82,9 +82,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.0.82"
version = "1.0.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "305fe645edc1442a0fa8b6726ba61d422798d37a52e12eaecf4b022ebbb88f01"
checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
dependencies = [
"libc",
]
@@ -100,9 +100,9 @@ dependencies = [
[[package]]
name = "cfg-expr"
version = "0.15.4"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b40ccee03b5175c18cde8f37e7d2a33bcef6f8ec8f7cc0d81090d1bb380949c9"
checksum = "03915af431787e6ffdcc74c645077518c6b6e01f80b761e0fbbfa288536311b3"
dependencies = [
"smallvec",
"target-lexicon",
@@ -122,6 +122,7 @@ checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
@@ -195,7 +196,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.29",
"syn 2.0.37",
]
[[package]]
@@ -246,9 +247,9 @@ dependencies = [
[[package]]
name = "gdk4"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6982d9815ed6ac95b0467b189e81f29dea26d08a732926ec113e65744ed3f96c"
checksum = "7edb019ad581f8ecf8ea8e4baa6df7c483a95b5a59be3140be6a9c3b0c632af6"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
@@ -278,9 +279,9 @@ dependencies = [
[[package]]
name = "gio"
version = "0.18.1"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7884cba6b1c5db1607d970cadf44b14a43913d42bc68766eea6a5e2fe0891524"
checksum = "57052f84e8e5999b258e8adf8f5f2af0ac69033864936b8b6838321db2f759b1"
dependencies = [
"futures-channel",
"futures-core",
@@ -310,9 +311,9 @@ dependencies = [
[[package]]
name = "glib"
version = "0.18.1"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331156127e8166dd815cf8d2db3a5beb492610c716c03ee6db4f2d07092af0a7"
checksum = "1c316afb01ce8067c5eaab1fc4f2cd47dc21ce7b6296358605e2ffab23ccbd19"
dependencies = [
"bitflags 2.4.0",
"futures-channel",
@@ -334,16 +335,16 @@ dependencies = [
[[package]]
name = "glib-macros"
version = "0.18.0"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "179643c50bf28d20d2f6eacd2531a88f2f5d9747dd0b86b8af1e8bb5dd0de3c0"
checksum = "f8da903822b136d42360518653fcf154455defc437d3e7a81475bf9a95ff1e47"
dependencies = [
"heck",
"proc-macro-crate",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.29",
"syn 2.0.37",
]
[[package]]
@@ -398,9 +399,9 @@ dependencies = [
[[package]]
name = "gsk4"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc25855255120f294d874acd6eaf4fbed7ce1cdc550e2d8415ea57fafbe816d5"
checksum = "0d958e351d2f210309b32d081c832d7de0aca0b077aa10d88336c6379bd01f7e"
dependencies = [
"cairo-rs",
"gdk4",
@@ -413,9 +414,9 @@ dependencies = [
[[package]]
name = "gsk4-sys"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1ecf3a63bf1223d68f80f72cc896c4d8c80482fbce1c9a12c66d3de7290ee46"
checksum = "12bd9e3effea989f020e8f1ff3fa3b8c63ba93d43b899c11a118868853a56d55"
dependencies = [
"cairo-sys-rs",
"gdk4-sys",
@@ -429,9 +430,9 @@ dependencies = [
[[package]]
name = "gtk4"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3b095b26f2a2df70be1805d3590eeb9d7a05ecb5be9649b82defc72dc56228c"
checksum = "5aeb51aa3e9728575a053e1f43543cd9992ac2477e1b186ad824fd4adfb70842"
dependencies = [
"cairo-rs",
"field-offset",
@@ -464,9 +465,9 @@ dependencies = [
[[package]]
name = "gtk4-sys"
version = "0.7.2"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b0bdde87c50317b4f355bcbb4a9c2c414ece1b7c824fb4ad4ba8f3bdb2c6603"
checksum = "54d8c4aa23638ce9faa2caf7e2a27d4a1295af2155c8e8d28c4d4eeca7a65eb8"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
@@ -495,10 +496,11 @@ checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "helvum"
version = "0.4.1"
version = "0.5.0"
dependencies = [
"glib",
"gtk4",
"libadwaita",
"libc",
"log",
"once_cell",
"pipewire",
@@ -506,9 +508,9 @@ dependencies = [
[[package]]
name = "indexmap"
version = "2.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
checksum = "ad227c3af19d4914570ad36d30409928b75967c298feb9ea1969db3a610bb14e"
dependencies = [
"equivalent",
"hashbrown",
@@ -527,16 +529,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.147"
name = "libadwaita"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
checksum = "2fe7e70c06507ed10a16cda707f358fbe60fe0dc237498f78c686ade92fd979c"
dependencies = [
"gdk-pixbuf",
"gdk4",
"gio",
"glib",
"gtk4",
"libadwaita-sys",
"libc",
"pango",
]
[[package]]
name = "libadwaita-sys"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e10aaa38de1d53374f90deeb4535209adc40cc5dba37f9704724169bceec69a"
dependencies = [
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gtk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "libc"
version = "0.2.148"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
[[package]]
name = "libloading"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
dependencies = [
"cfg-if",
"winapi",
]
[[package]]
name = "libspa"
version = "0.7.0"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86675feca9b040cd26cc97c41f6af3c6875d6c3f22dd80f15e6a30fa439fa72c"
checksum = "0434617020ddca18b86067912970c55410ca654cdafd775480322f50b857a8c4"
dependencies = [
"bitflags 2.4.0",
"cc",
@@ -551,9 +595,9 @@ dependencies = [
[[package]]
name = "libspa-sys"
version = "0.7.0"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36edb2771327e3908cdcccbea7c20a7285179ba0322a34621d494cb5a9ca979f"
checksum = "b3e70ca3f3e70f858ef363046d06178c427b4e0b63d210c95fd87d752679d345"
dependencies = [
"bindgen",
"cc",
@@ -568,9 +612,9 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "memchr"
version = "2.5.0"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c"
[[package]]
name = "memoffset"
@@ -598,16 +642,15 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nix"
version = "0.26.2"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
"memoffset 0.7.1",
"pin-utils",
"static_assertions",
]
[[package]]
@@ -659,9 +702,9 @@ checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
[[package]]
name = "pin-project-lite"
version = "0.2.12"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05"
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
[[package]]
name = "pin-utils"
@@ -671,9 +714,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pipewire"
version = "0.7.0"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b9de7cc82b710b1453f630999a1d2a473718e6056762900c0b009309aba4f9"
checksum = "a2d009c8dd65e890b515a71950f7e4c801523b8894ff33863a40830bf762e9e9"
dependencies = [
"anyhow",
"bitflags 2.4.0",
@@ -688,9 +731,9 @@ dependencies = [
[[package]]
name = "pipewire-sys"
version = "0.7.0"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66790b3815389bf04ce54f3972809183364795a0a9e8bc979932d918d9f1405"
checksum = "890c084e7b737246cb4799c86b71a0e4da536031ff7473dd639eba9f95039f64"
dependencies = [
"bindgen",
"libspa-sys",
@@ -739,9 +782,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.66"
version = "1.0.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328"
dependencies = [
"unicode-ident",
]
@@ -757,9 +800,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.9.3"
version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a"
checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47"
dependencies = [
"aho-corasick",
"memchr",
@@ -769,9 +812,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.3.6"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69"
checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795"
dependencies = [
"aho-corasick",
"memchr",
@@ -780,9 +823,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.7.4"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
[[package]]
name = "rustc-hash"
@@ -801,15 +844,29 @@ dependencies = [
[[package]]
name = "semver"
version = "1.0.18"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
checksum = "ad977052201c6de01a8ef2aa3378c4bd23217a056337d1d6da40468d267a4fb0"
[[package]]
name = "serde"
version = "1.0.183"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32ac8da02677876d532745a130fc9d8e6edfa81a269b107c5b00829b91d8eb3c"
checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.37",
]
[[package]]
name = "serde_spanned"
@@ -822,30 +879,24 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.1.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3"
checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380"
[[package]]
name = "slab"
version = "0.4.8"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "smallvec"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a"
[[package]]
name = "syn"
@@ -860,9 +911,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.29"
version = "2.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a"
checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8"
dependencies = [
"proc-macro2",
"quote",
@@ -890,29 +941,29 @@ checksum = "9d0e916b1148c8e263850e1ebcbd046f333e0683c724876bb0da63ea4373dc8a"
[[package]]
name = "thiserror"
version = "1.0.47"
version = "1.0.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f"
checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.47"
version = "1.0.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b"
checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.29",
"syn 2.0.37",
]
[[package]]
name = "toml"
version = "0.7.6"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17e963a819c331dcacd7ab957d80bc2b9a9c1e71c804826d2f283dd65306542"
checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
dependencies = [
"serde",
"serde_spanned",
@@ -931,9 +982,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.19.14"
version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8123f27e969974a3dfba720fdb560be359f57b44302d280ba72e76a74480e8a"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap",
"serde",
@@ -944,9 +995,9 @@ dependencies = [
[[package]]
name = "unicode-ident"
version = "1.0.11"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-segmentation"
@@ -990,9 +1041,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winnow"
version = "0.5.12"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83817bbecf72c73bad717ee86820ebf286203d2e04c3951f3cd538869c897364"
checksum = "7c2e3184b9c4e92ad5167ca73039d0c42476302ab603e2fec4487511f38ccefc"
dependencies = [
"memchr",
]

View File

@@ -1,7 +1,7 @@
[package]
name = "helvum"
version = "0.4.1"
authors = ["Tom A. Wagner <tom.a.wagner@protonmail.com>"]
version = "0.5.0"
authors = ["Tom Wagner <tom.a.wagner@protonmail.com>"]
edition = "2021"
rust-version = "1.70"
license = "GPL-3.0-only"
@@ -14,10 +14,12 @@ categories = ["gui", "multimedia"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
pipewire = "0.7"
gtk = { version = "0.7", package = "gtk4" }
pipewire = "0.7.1"
adw = { version = "0.5", package = "libadwaita", features = ["v1_3"] }
glib = { version = "0.18", features = ["log"] }
log = "0.4.11"
once_cell = "1.7.2"
libc = "0.2"

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,7 +1,7 @@
project(
'helvum',
'rust',
version: '0.4.1',
version: '0.5.0',
license: 'GPL-3.0',
meson_version: '>=0.59.0'
)
@@ -12,6 +12,7 @@ base_id = 'org.pipewire.Helvum'
dependency('glib-2.0', version: '>= 2.66')
dependency('gtk4', version: '>= 4.4.0')
dependency('libadwaita-1', version: '>= 1.3')
dependency('libpipewire-0.3')
desktop_file_validate = find_program('desktop-file-validate', required: false)

View File

@@ -14,9 +14,10 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{
use adw::{
gio,
glib::{self, clone, Receiver},
gtk,
prelude::*,
subclass::prelude::*,
};
@@ -25,15 +26,19 @@ use pipewire::channel::Sender;
use crate::{graph_manager::GraphManager, ui, GtkMessage, PipewireMessage};
static STYLE: &str = include_str!("style.css");
static APP_ID: &str = "org.pipewire.Helvum";
static VERSION: &str = env!("CARGO_PKG_VERSION");
static AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
mod imp {
use super::*;
use adw::subclass::prelude::AdwApplicationImpl;
use once_cell::unsync::OnceCell;
#[derive(Default)]
pub struct Application {
pub(super) graphview: ui::graph::GraphView,
pub(super) window: ui::Window,
pub(super) graph_manager: OnceCell<GraphManager>,
}
@@ -41,7 +46,7 @@ mod imp {
impl ObjectSubclass for Application {
const NAME: &'static str = "HelvumApplication";
type Type = super::Application;
type ParentType = gtk::Application;
type ParentType = adw::Application;
}
impl ObjectImpl for Application {}
@@ -49,41 +54,28 @@ mod imp {
fn activate(&self) {
let app = &*self.obj();
let scrollwindow = gtk::ScrolledWindow::builder()
.child(&self.graphview)
.build();
let headerbar = gtk::HeaderBar::new();
let zoomentry = ui::graph::ZoomEntry::new(&self.graphview);
headerbar.pack_end(&zoomentry);
let graphview = self.window.graph();
let window = gtk::ApplicationWindow::builder()
.application(app)
.default_width(1280)
.default_height(720)
.title("Helvum - Pipewire Patchbay")
.child(&scrollwindow)
.build();
window
.settings()
.set_gtk_application_prefer_dark_theme(true);
window.set_titlebar(Some(&headerbar));
self.window.set_application(Some(app));
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| {
zoom_set_action.connect_activate(clone!(@weak graphview => move|_, param| {
let zoom_factor = param.unwrap().get::<f64>().unwrap();
graphview.set_zoom_factor(zoom_factor, None)
}),
);
window.add_action(&zoom_set_action);
}));
self.window.add_action(&zoom_set_action);
window.show();
self.window.show();
}
fn startup(&self) {
self.parent_startup();
self.obj()
.style_manager()
.set_color_scheme(adw::ColorScheme::PreferDark);
// Load CSS from the STYLE variable.
let provider = gtk::CssProvider::new();
provider.load_from_data(STYLE);
@@ -92,14 +84,55 @@ mod imp {
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
self.setup_actions();
}
}
impl GtkApplicationImpl for Application {}
impl AdwApplicationImpl for Application {}
impl Application {
fn setup_actions(&self) {
let obj = &*self.obj();
// Add <Control-Q> shortcut for quitting the application.
let quit = gtk::gio::SimpleAction::new("quit", None);
quit.connect_activate(clone!(@weak obj => move |_, _| {
obj.quit();
}));
obj.set_accels_for_action("app.quit", &["<Control>Q"]);
obj.add_action(&quit);
let action_about = gio::ActionEntry::builder("about")
.activate(|obj: &super::Application, _, _| {
obj.imp().show_about_dialog();
})
.build();
obj.add_action_entries([action_about]);
}
fn show_about_dialog(&self) {
let authors: Vec<&str> = AUTHORS.split(':').collect();
let about_window = adw::AboutWindow::builder()
.application_icon(APP_ID)
.application_name("Helvum")
.developer_name("Tom Wagner")
.developers(authors)
.version(VERSION)
.website("https://gitlab.freedesktop.org/pipewire/helvum")
.issue_url("https://gitlab.freedesktop.org/pipewire/helvum/-/issues")
.license_type(gtk::License::Gpl30Only)
.build();
about_window.present();
}
}
}
glib::wrapper! {
pub struct Application(ObjectSubclass<imp::Application>)
@extends gio::Application, gtk::Application,
@extends gio::Application, gtk::Application, adw::Application,
@implements gio::ActionGroup, gio::ActionMap;
}
@@ -111,23 +144,20 @@ impl Application {
pw_sender: Sender<GtkMessage>,
) -> Self {
let app: Application = glib::Object::builder()
.property("application-id", "org.pipewire.Helvum")
.property("application-id", APP_ID)
.build();
let imp = app.imp();
imp.graph_manager
.set(GraphManager::new(&imp.graphview, pw_sender, gtk_receiver))
.set(GraphManager::new(
&imp.window.graph(),
&imp.window.connection_banner(),
pw_sender,
gtk_receiver,
))
.expect("Should be able to set graph manager");
// Add <Control-Q> shortcut for quitting the application.
let quit = gtk::gio::SimpleAction::new("quit", None);
quit.connect_activate(clone!(@weak app => move |_, _| {
app.quit();
}));
app.set_accels_for_action("app.quit", &["<Control>Q"]);
app.add_action(&quit);
app
}
}

View File

@@ -14,7 +14,7 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{glib, prelude::*, subclass::prelude::*};
use adw::{glib, prelude::*, subclass::prelude::*};
use pipewire::channel::Sender as PwSender;
@@ -35,6 +35,9 @@ mod imp {
#[property(get, set, construct_only)]
pub graph: OnceCell<crate::ui::graph::GraphView>,
#[property(get, set, construct_only)]
pub connection_banner: OnceCell<adw::Banner>,
pub pw_sender: OnceCell<PwSender<crate::GtkMessage>>,
pub items: RefCell<HashMap<u32, glib::Object>>,
}
@@ -55,13 +58,26 @@ mod imp {
@weak self as imp => @default-return glib::ControlFlow::Continue,
move |msg| {
match msg {
PipewireMessage::NodeAdded{ id, name, node_type } => imp.add_node(id, name.as_str(), node_type),
PipewireMessage::PortAdded{ id, node_id, name, direction, media_type } => imp.add_port(id, name.as_str(), node_id, direction, media_type),
PipewireMessage::LinkAdded{ id, port_from, port_to, active} => imp.add_link(id, port_from, port_to, active),
PipewireMessage::NodeAdded { id, name, node_type } => imp.add_node(id, name.as_str(), node_type),
PipewireMessage::PortAdded { id, node_id, name, direction } => imp.add_port(id, name.as_str(), node_id, direction),
PipewireMessage::PortFormatChanged { id, media_type } => imp.port_media_type_changed(id, media_type),
PipewireMessage::LinkAdded {
id, port_from, port_to, active, media_type
} => imp.add_link(id, port_from, port_to, active, media_type),
PipewireMessage::LinkStateChanged { id, active } => imp.link_state_changed(id, active),
PipewireMessage::LinkFormatChanged { id, media_type } => imp.link_format_changed(id, media_type),
PipewireMessage::NodeRemoved { id } => imp.remove_node(id),
PipewireMessage::PortRemoved { id, node_id } => imp.remove_port(id, node_id),
PipewireMessage::LinkRemoved { id } => imp.remove_link(id)
PipewireMessage::LinkRemoved { id } => imp.remove_link(id),
PipewireMessage::Connecting => {
imp.obj().connection_banner().set_revealed(true);
}
PipewireMessage::Connected => {
imp.obj().connection_banner().set_revealed(false);
},
PipewireMessage::Disconnected => {
imp.clear();
},
};
glib::ControlFlow::Continue
}
@@ -96,14 +112,7 @@ mod imp {
}
/// Add a new port to the view.
fn add_port(
&self,
id: u32,
name: &str,
node_id: u32,
direction: pipewire::spa::Direction,
media_type: Option<MediaType>,
) {
fn add_port(&self, id: u32, name: &str, node_id: u32, direction: pipewire::spa::Direction) {
log::info!("Adding port to graph: id {}", id);
let mut items = self.items.borrow_mut();
@@ -117,7 +126,7 @@ mod imp {
return;
};
let port = graph::Port::new(id, name, direction, media_type);
let port = graph::Port::new(id, name, direction);
// Create or delete a link if the widget emits the "port-toggled" signal.
port.connect_local(
@@ -139,6 +148,21 @@ mod imp {
node.add_port(port);
}
fn port_media_type_changed(&self, id: u32, media_type: MediaType) {
let items = self.items.borrow();
let Some(port) = items.get(&id) else {
log::warn!("Port (id: {id}) for changed media type not found in graph manager");
return;
};
let Some(port) = port.dynamic_cast_ref::<graph::Port>() else {
log::warn!("Graph Manager item under port id {id} is not a port");
return;
};
port.set_media_type(media_type.as_raw())
}
/// Remove the port with the id `id` from the node with the id `node_id`
/// from the view.
fn remove_port(&self, id: u32, node_id: u32) {
@@ -167,7 +191,14 @@ mod imp {
}
/// Add a new link to the view.
fn add_link(&self, id: u32, output_port_id: u32, input_port_id: u32, active: bool) {
fn add_link(
&self,
id: u32,
output_port_id: u32,
input_port_id: u32,
active: bool,
media_type: MediaType,
) {
log::info!("Adding link to graph: id {}", id);
let mut items = self.items.borrow_mut();
@@ -193,6 +224,7 @@ mod imp {
link.set_output_port(Some(&output_port));
link.set_input_port(Some(&input_port));
link.set_active(active);
link.set_media_type(media_type);
items.insert(id, link.clone().upcast());
@@ -223,6 +255,20 @@ mod imp {
link.set_active(active);
}
fn link_format_changed(&self, id: u32, media_type: pipewire::spa::format::MediaType) {
let items = self.items.borrow();
let Some(link) = items.get(&id) else {
log::warn!("Link (id: {id}) for changed media type not found in graph manager");
return;
};
let Some(link) = link.dynamic_cast_ref::<graph::Link>() else {
log::warn!("Graph Manager item under link id {id} is not a link");
return;
};
link.set_media_type(media_type);
}
// Toggle a link between the two specified ports on the remote pipewire server.
fn toggle_link(&self, port_from: u32, port_to: u32) {
let sender = self.pw_sender.get().expect("pw_sender shoud be set");
@@ -246,6 +292,11 @@ mod imp {
self.obj().graph().remove_link(&link);
}
fn clear(&self) {
self.items.borrow_mut().clear();
self.obj().graph().clear();
}
}
}
@@ -256,10 +307,14 @@ glib::wrapper! {
impl GraphManager {
pub fn new(
graph: &GraphView,
connection_banner: &adw::Banner,
sender: PwSender<GtkMessage>,
receiver: glib::Receiver<PipewireMessage>,
) -> Self {
let res: Self = glib::Object::builder().property("graph", graph).build();
let res: Self = glib::Object::builder()
.property("graph", graph)
.property("connection-banner", connection_banner)
.build();
res.imp().attach_receiver(receiver);
assert!(

View File

@@ -19,8 +19,8 @@ mod graph_manager;
mod pipewire_connection;
mod ui;
use gtk::prelude::*;
use pipewire::spa::Direction;
use adw::{gtk, prelude::*};
use pipewire::spa::{format::MediaType, Direction};
/// Messages sent by the GTK thread to notify the pipewire thread.
#[derive(Debug, Clone)]
@@ -44,18 +44,26 @@ pub enum PipewireMessage {
node_id: u32,
name: String,
direction: Direction,
media_type: Option<MediaType>,
},
PortFormatChanged {
id: u32,
media_type: MediaType,
},
LinkAdded {
id: u32,
port_from: u32,
port_to: u32,
active: bool,
media_type: MediaType,
},
LinkStateChanged {
id: u32,
active: bool,
},
LinkFormatChanged {
id: u32,
media_type: MediaType,
},
NodeRemoved {
id: u32,
},
@@ -66,6 +74,9 @@ pub enum PipewireMessage {
LinkRemoved {
id: u32,
},
Connecting,
Connected,
Disconnected,
}
#[derive(Debug, Clone)]
@@ -74,13 +85,6 @@ pub enum NodeType {
Output,
}
#[derive(Debug, Copy, Clone)]
pub enum MediaType {
Audio,
Video,
Midi,
}
#[derive(Debug, Clone)]
pub struct PipewireLink {
pub node_from: u32,

View File

@@ -1,314 +0,0 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
mod state;
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use gtk::glib::{self, clone};
use log::{debug, info, warn};
use pipewire::{
link::{Link, LinkChangeMask, LinkListener, LinkState},
prelude::*,
properties,
registry::{GlobalObject, Registry},
spa::{Direction, ForeignDict},
types::ObjectType,
Context, Core, MainLoop,
};
use crate::{GtkMessage, MediaType, NodeType, PipewireMessage};
use state::{Item, State};
enum ProxyItem {
Link {
_proxy: Link,
_listener: LinkListener,
},
}
/// The "main" function of the pipewire thread.
pub(super) fn thread_main(
gtk_sender: glib::Sender<PipewireMessage>,
pw_receiver: pipewire::channel::Receiver<GtkMessage>,
) {
let mainloop = MainLoop::new().expect("Failed to create mainloop");
let context = Context::new(&mainloop).expect("Failed to create context");
let core = Rc::new(context.connect(None).expect("Failed to connect to remote"));
let registry = Rc::new(core.get_registry().expect("Failed to get registry"));
// Keep proxies and their listeners alive so that we can receive info events.
let proxies = Rc::new(RefCell::new(HashMap::new()));
let state = Rc::new(RefCell::new(State::new()));
let _receiver = pw_receiver.attach(&mainloop, {
clone!(@strong mainloop, @weak core, @weak registry, @strong state => move |msg| match msg {
GtkMessage::ToggleLink { port_from, port_to } => toggle_link(port_from, port_to, &core, &registry, &state),
GtkMessage::Terminate => mainloop.quit(),
})
});
let _listener = registry
.add_listener_local()
.global(clone!(@strong gtk_sender, @weak registry, @strong proxies, @strong state =>
move |global| match global.type_ {
ObjectType::Node => handle_node(global, &gtk_sender, &state),
ObjectType::Port => handle_port(global, &gtk_sender, &state),
ObjectType::Link => handle_link(global, &gtk_sender, &registry, &proxies, &state),
_ => {
// Other objects are not interesting to us
}
}
))
.global_remove(clone!(@strong proxies, @strong state => move |id| {
if let Some(item) = state.borrow_mut().remove(id) {
gtk_sender.send(match item {
Item::Node { .. } => PipewireMessage::NodeRemoved {id},
Item::Port { node_id } => PipewireMessage::PortRemoved {id, node_id},
Item::Link { .. } => PipewireMessage::LinkRemoved {id},
}).expect("Failed to send message");
} else {
warn!(
"Attempted to remove item with id {} that is not saved in state",
id
);
}
proxies.borrow_mut().remove(&id);
}))
.register();
mainloop.run();
}
/// Handle a new node being added
fn handle_node(
node: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
state: &Rc<RefCell<State>>,
) {
let props = node
.props
.as_ref()
.expect("Node object is missing properties");
// Get the nicest possible name for the node, using a fallback chain of possible name attributes.
let name = String::from(
props
.get("node.description")
.or_else(|| props.get("node.nick"))
.or_else(|| props.get("node.name"))
.unwrap_or_default(),
);
// FIXME: Instead of checking these props, the "EnumFormat" parameter should be checked instead.
let media_type = props.get("media.class").and_then(|class| {
if class.contains("Audio") {
Some(MediaType::Audio)
} else if class.contains("Video") {
Some(MediaType::Video)
} else if class.contains("Midi") {
Some(MediaType::Midi)
} else {
None
}
});
let media_class = |class: &str| {
if class.contains("Sink") || class.contains("Input") {
Some(NodeType::Input)
} else if class.contains("Source") || class.contains("Output") {
Some(NodeType::Output)
} else {
None
}
};
let node_type = props
.get("media.category")
.and_then(|class| {
if class.contains("Duplex") {
None
} else {
props.get("media.class").and_then(media_class)
}
})
.or_else(|| props.get("media.class").and_then(media_class));
state.borrow_mut().insert(
node.id,
Item::Node {
// widget: node_widget,
media_type,
},
);
sender
.send(PipewireMessage::NodeAdded {
id: node.id,
name,
node_type,
})
.expect("Failed to send message");
}
/// Handle a new port being added
fn handle_port(
port: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
state: &Rc<RefCell<State>>,
) {
let props = port
.props
.as_ref()
.expect("Port object is missing properties");
let name = props.get("port.name").unwrap_or_default().to_string();
let node_id: u32 = props
.get("node.id")
.expect("Port has no node.id property!")
.parse()
.expect("Could not parse node.id property");
let direction = if matches!(props.get("port.direction"), Some("in")) {
Direction::Input
} else {
Direction::Output
};
// Find out the nodes media type so that the port can be colored.
let media_type = if let Some(Item::Node { media_type, .. }) = state.borrow().get(node_id) {
media_type.to_owned()
} else {
warn!("Node not found for Port {}", port.id);
None
};
// Save node_id so we can delete this port easily.
state.borrow_mut().insert(port.id, Item::Port { node_id });
sender
.send(PipewireMessage::PortAdded {
id: port.id,
node_id,
name,
direction,
media_type,
})
.expect("Failed to send message");
}
/// Handle a new link being added
fn handle_link(
link: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
debug!(
"New link (id:{}) appeared, setting up info listener.",
link.id
);
let proxy: Link = registry.bind(link).expect("Failed to bind to link proxy");
let listener = proxy
.add_listener_local()
.info(clone!(@strong state, @strong sender => move |info| {
debug!("Received link info: {:?}", info);
let id = info.id();
let mut state = state.borrow_mut();
if let Some(Item::Link { .. }) = state.get(id) {
// Info was an update - figure out if we should notify the gtk thread
if info.change_mask().contains(LinkChangeMask::STATE) {
sender.send(PipewireMessage::LinkStateChanged {
id,
active: matches!(info.state(), LinkState::Active)
}).expect("Failed to send message");
}
// TODO -- check other values that might have changed
} else {
// First time we get info. We can now notify the gtk thread of a new link.
let port_from = info.output_port_id();
let port_to = info.input_port_id();
state.insert(id, Item::Link {
port_from, port_to
});
sender.send(PipewireMessage::LinkAdded {
id,
port_from,
port_to,
active: matches!(info.state(), LinkState::Active)
}).expect(
"Failed to send message"
);
}
}))
.register();
proxies.borrow_mut().insert(
link.id,
ProxyItem::Link {
_proxy: proxy,
_listener: listener,
},
);
}
/// Toggle a link between the two specified ports.
fn toggle_link(
port_from: u32,
port_to: u32,
core: &Rc<Core>,
registry: &Rc<Registry>,
state: &Rc<RefCell<State>>,
) {
let state = state.borrow_mut();
if let Some(id) = state.get_link_id(port_from, port_to) {
info!("Requesting removal of link with id {}", id);
// FIXME: Handle error
registry.destroy_global(id);
} else {
info!(
"Requesting creation of link from port id:{} to port id:{}",
port_from, port_to
);
let node_from = state
.get_node_of_port(port_from)
.expect("Requested port not in state");
let node_to = state
.get_node_of_port(port_to)
.expect("Requested port not in state");
if let Err(e) = core.create_object::<Link, _>(
"link-factory",
&properties! {
"link.output.node" => node_from.to_string(),
"link.output.port" => port_from.to_string(),
"link.input.node" => node_to.to_string(),
"link.input.port" => port_to.to_string(),
"object.linger" => "1"
},
) {
warn!("Failed to create link: {}", e);
}
}
}

View File

@@ -0,0 +1,466 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
mod state;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
time::Duration,
};
use adw::glib::{self, clone};
use log::{debug, error, info, warn};
use pipewire::{
link::{Link, LinkChangeMask, LinkInfo, LinkListener, LinkState},
port::{Port, PortChangeMask, PortInfo, PortListener},
prelude::*,
properties,
registry::{GlobalObject, Registry},
spa::{
param::{ParamInfoFlags, ParamType},
ForeignDict, SpaResult,
},
types::ObjectType,
Context, Core, MainLoop,
};
use crate::{GtkMessage, MediaType, NodeType, PipewireMessage};
use state::{Item, State};
enum ProxyItem {
Port {
proxy: Port,
_listener: PortListener,
},
Link {
_proxy: Link,
_listener: LinkListener,
},
}
/// The "main" function of the pipewire thread.
pub(super) fn thread_main(
gtk_sender: glib::Sender<PipewireMessage>,
mut pw_receiver: pipewire::channel::Receiver<GtkMessage>,
) {
let mainloop = MainLoop::new().expect("Failed to create mainloop");
let context = Rc::new(Context::new(&mainloop).expect("Failed to create context"));
let is_stopped = Rc::new(Cell::new(false));
let mut is_connecting = false;
while !is_stopped.get() {
// Try to connect
let core = match context.connect(None) {
Ok(core) => Rc::new(core),
Err(_) => {
if !is_connecting {
is_connecting = true;
gtk_sender
.send(PipewireMessage::Connecting)
.expect("Failed to send message");
}
// If connection is failed, try to connect again in 200ms
let interval = Some(Duration::from_millis(200));
let timer = mainloop.add_timer(clone!(@strong mainloop => move |_| {
mainloop.quit();
}));
timer.update_timer(interval, None).into_result().unwrap();
let receiver = pw_receiver.attach(&mainloop, {
clone!(@strong mainloop, @strong is_stopped => move |msg|
if let GtkMessage::Terminate = msg {
// main thread requested stop
is_stopped.set(true);
mainloop.quit();
}
)
});
mainloop.run();
pw_receiver = receiver.deattach();
continue;
}
};
if is_connecting {
is_connecting = false;
gtk_sender
.send(PipewireMessage::Connected)
.expect("Failed to send message");
}
let registry = Rc::new(core.get_registry().expect("Failed to get registry"));
// Keep proxies and their listeners alive so that we can receive info events.
let proxies = Rc::new(RefCell::new(HashMap::new()));
let state = Rc::new(RefCell::new(State::new()));
let receiver = pw_receiver.attach(&mainloop, {
clone!(@strong mainloop, @weak core, @weak registry, @strong state, @strong is_stopped => move |msg| match msg {
GtkMessage::ToggleLink { port_from, port_to } => toggle_link(port_from, port_to, &core, &registry, &state),
GtkMessage::Terminate => {
// main thread requested stop
is_stopped.set(true);
mainloop.quit();
}
})
});
let gtk_sender = gtk_sender.clone();
let _listener = core.add_listener_local()
.error(clone!(@strong mainloop, @strong gtk_sender, @strong is_stopped => move |id, _seq, res, message| {
if id != pipewire::PW_ID_CORE {
return;
}
if res == -libc::EPIPE {
gtk_sender.send(PipewireMessage::Disconnected)
.expect("Failed to send message");
mainloop.quit();
} else {
let serr = SpaResult::from_c(res).into_result().unwrap_err();
error!("Pipewire Core received error {serr}: {message}");
}
}))
.register();
let _listener = registry
.add_listener_local()
.global(clone!(@strong gtk_sender, @weak registry, @strong proxies, @strong state =>
move |global| match global.type_ {
ObjectType::Node => handle_node(global, &gtk_sender, &state),
ObjectType::Port => handle_port(global, &gtk_sender, &registry, &proxies, &state),
ObjectType::Link => handle_link(global, &gtk_sender, &registry, &proxies, &state),
_ => {
// Other objects are not interesting to us
}
}
))
.global_remove(clone!(@strong proxies, @strong state => move |id| {
if let Some(item) = state.borrow_mut().remove(id) {
gtk_sender.send(match item {
Item::Node { .. } => PipewireMessage::NodeRemoved {id},
Item::Port { node_id } => PipewireMessage::PortRemoved {id, node_id},
Item::Link { .. } => PipewireMessage::LinkRemoved {id},
}).expect("Failed to send message");
} else {
warn!(
"Attempted to remove item with id {} that is not saved in state",
id
);
}
proxies.borrow_mut().remove(&id);
}))
.register();
mainloop.run();
pw_receiver = receiver.deattach();
}
}
/// Handle a new node being added
fn handle_node(
node: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
state: &Rc<RefCell<State>>,
) {
let props = node
.props
.as_ref()
.expect("Node object is missing properties");
// Get the nicest possible name for the node, using a fallback chain of possible name attributes.
let name = String::from(
props
.get("node.description")
.or_else(|| props.get("node.nick"))
.or_else(|| props.get("node.name"))
.unwrap_or_default(),
);
let media_class = |class: &str| {
if class.contains("Sink") || class.contains("Input") {
Some(NodeType::Input)
} else if class.contains("Source") || class.contains("Output") {
Some(NodeType::Output)
} else {
None
}
};
let node_type = props
.get("media.category")
.and_then(|class| {
if class.contains("Duplex") {
None
} else {
props.get("media.class").and_then(media_class)
}
})
.or_else(|| props.get("media.class").and_then(media_class));
state.borrow_mut().insert(node.id, Item::Node);
sender
.send(PipewireMessage::NodeAdded {
id: node.id,
name,
node_type,
})
.expect("Failed to send message");
}
/// Handle a new port being added
fn handle_port(
port: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
let port_id = port.id;
let proxy: Port = registry.bind(port).expect("Failed to bind to port proxy");
let listener = proxy
.add_listener_local()
.info(
clone!(@strong proxies, @strong state, @strong sender => move |info| {
handle_port_info(info, &proxies, &state, &sender);
}),
)
.param(clone!(@strong sender => move |_, param_id, _, _, param| {
if param_id == ParamType::EnumFormat {
handle_port_enum_format(port_id, param, &sender)
}
}))
.register();
proxies.borrow_mut().insert(
port.id,
ProxyItem::Port {
proxy,
_listener: listener,
},
);
}
fn handle_port_info(
info: &PortInfo,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
sender: &glib::Sender<PipewireMessage>,
) {
debug!("Received port info: {:?}", info);
let id = info.id();
let proxies = proxies.borrow();
let Some(ProxyItem::Port { proxy, .. }) = proxies.get(&id) else {
log::error!("Received info on unknown port with id {id}");
return;
};
let mut state = state.borrow_mut();
if let Some(Item::Port { .. }) = state.get(id) {
// Info was an update, figure out if we should notify the GTK thread
if info.change_mask().contains(PortChangeMask::PARAMS) {
// TODO: React to param changes
}
} else {
// First time we get info. We can now notify the gtk thread of a new link.
let props = info.props().expect("Port object is missing properties");
let name = props.get("port.name").unwrap_or_default().to_string();
let node_id: u32 = props
.get("node.id")
.expect("Port has no node.id property!")
.parse()
.expect("Could not parse node.id property");
state.insert(id, Item::Port { node_id });
let params = info.params();
let enum_format_info = params
.iter()
.find(|param| param.id() == ParamType::EnumFormat);
if let Some(enum_format_info) = enum_format_info {
if enum_format_info.flags().contains(ParamInfoFlags::READ) {
proxy.enum_params(0, Some(ParamType::EnumFormat), 0, u32::MAX);
}
}
sender
.send(PipewireMessage::PortAdded {
id,
node_id,
name,
direction: info.direction(),
})
.expect("Failed to send message");
}
}
fn handle_port_enum_format(
port_id: u32,
param: Option<&pipewire::spa::pod::Pod>,
sender: &glib::Sender<PipewireMessage>,
) {
let media_type = param
.and_then(|param| pipewire::spa::param::format_utils::parse_format(param).ok())
.map(|(media_type, _media_subtype)| media_type)
.unwrap_or(MediaType::Unknown);
sender
.send(PipewireMessage::PortFormatChanged {
id: port_id,
media_type,
})
.expect("Failed to send message")
}
/// Handle a new link being added
fn handle_link(
link: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
debug!(
"New link (id:{}) appeared, setting up info listener.",
link.id
);
let proxy: Link = registry.bind(link).expect("Failed to bind to link proxy");
let listener = proxy
.add_listener_local()
.info(clone!(@strong state, @strong sender => move |info| {
handle_link_info(info, &state, &sender);
}))
.register();
proxies.borrow_mut().insert(
link.id,
ProxyItem::Link {
_proxy: proxy,
_listener: listener,
},
);
}
fn handle_link_info(
info: &LinkInfo,
state: &Rc<RefCell<State>>,
sender: &glib::Sender<PipewireMessage>,
) {
debug!("Received link info: {:?}", info);
let id = info.id();
let mut state = state.borrow_mut();
if let Some(Item::Link { .. }) = state.get(id) {
// Info was an update - figure out if we should notify the gtk thread
if info.change_mask().contains(LinkChangeMask::STATE) {
sender
.send(PipewireMessage::LinkStateChanged {
id,
active: matches!(info.state(), LinkState::Active),
})
.expect("Failed to send message");
}
if info.change_mask().contains(LinkChangeMask::FORMAT) {
sender
.send(PipewireMessage::LinkFormatChanged {
id,
media_type: get_link_media_type(info),
})
.expect("Failed to send message");
}
} else {
// First time we get info. We can now notify the gtk thread of a new link.
let port_from = info.output_port_id();
let port_to = info.input_port_id();
state.insert(id, Item::Link { port_from, port_to });
sender
.send(PipewireMessage::LinkAdded {
id,
port_from,
port_to,
active: matches!(info.state(), LinkState::Active),
media_type: get_link_media_type(info),
})
.expect("Failed to send message");
}
}
/// Toggle a link between the two specified ports.
fn toggle_link(
port_from: u32,
port_to: u32,
core: &Rc<Core>,
registry: &Rc<Registry>,
state: &Rc<RefCell<State>>,
) {
let state = state.borrow_mut();
if let Some(id) = state.get_link_id(port_from, port_to) {
info!("Requesting removal of link with id {}", id);
// FIXME: Handle error
registry.destroy_global(id);
} else {
info!(
"Requesting creation of link from port id:{} to port id:{}",
port_from, port_to
);
let node_from = state
.get_node_of_port(port_from)
.expect("Requested port not in state");
let node_to = state
.get_node_of_port(port_to)
.expect("Requested port not in state");
if let Err(e) = core.create_object::<Link, _>(
"link-factory",
&properties! {
"link.output.node" => node_from.to_string(),
"link.output.port" => port_from.to_string(),
"link.input.node" => node_to.to_string(),
"link.input.port" => port_to.to_string(),
"object.linger" => "1"
},
) {
warn!("Failed to create link: {}", e);
}
}
}
fn get_link_media_type(link_info: &LinkInfo) -> MediaType {
let media_type = link_info
.format()
.and_then(|format| pipewire::spa::param::format_utils::parse_format(format).ok())
.map(|(media_type, _media_subtype)| media_type)
.unwrap_or(MediaType::Unknown);
media_type
}

View File

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

View File

@@ -15,26 +15,41 @@
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;
@define-color media-type-audio rgb( 50, 100, 240);
@define-color media-type-video rgb(200, 200, 0);
@define-color media-type-midi rgb(200, 0, 50);
@define-color media-type-unknown rgb(128, 128, 128);
.audio {
background: @audio;
background: @media-type-audio;
color: black;
}
.video {
background: @video;
background: @media-type-video;
color: black;
}
.midi {
background: @midi;
background: @media-type-midi;
color: black;
}
graphview {
background-color: @text_view_bg;
node {
/* Compared to the default card color, this is not transparent in dark-mode
and provides a better contrast to the background in light mode */
background-color: @headerbar_bg_color;
}
node label.heading {
padding: 4px 7px;
}
port label {
padding: 4px 6px;
}
port-handle {
border-radius: 50%;
background-color: @media-type-unknown;
}

View File

@@ -14,11 +14,14 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{
cairo, gio,
use adw::{
gio,
glib::{self, clone},
gtk::{
self, cairo,
graphene::{self, Point},
gsk,
},
prelude::*,
subclass::prelude::*,
};
@@ -36,15 +39,30 @@ mod imp {
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use gtk::{
gdk::{self, RGBA},
graphene::Rect,
gsk::ColorStop,
};
use adw::gtk::gdk::{self};
use log::warn;
use once_cell::sync::Lazy;
use pipewire::spa::format::MediaType;
use pipewire::spa::Direction;
pub struct Colors {
audio: gdk::RGBA,
video: gdk::RGBA,
midi: gdk::RGBA,
unknown: gdk::RGBA,
}
impl Colors {
pub fn color_for_media_type(&self, media_type: MediaType) -> &gdk::RGBA {
match media_type {
MediaType::Audio => &self.audio,
MediaType::Video => &self.video,
MediaType::Stream | MediaType::Application => &self.midi,
_ => &self.unknown,
}
}
}
pub struct DragState {
node: glib::WeakRef<Node>,
/// This stores the offset of the pointer to the origin of the node,
@@ -96,7 +114,7 @@ mod imp {
#[glib::object_subclass]
impl ObjectSubclass for GraphView {
const NAME: &'static str = "GraphView";
const NAME: &'static str = "HelvumGraphView";
type Type = super::GraphView;
type ParentType = gtk::Widget;
type Interfaces = (gtk::Scrollable,);
@@ -110,6 +128,8 @@ mod imp {
fn constructed(&self) {
self.parent_constructed();
self.obj().add_css_class("view");
self.obj().set_overflow(gtk::Overflow::Hidden);
self.setup_node_dragging();
@@ -205,8 +225,6 @@ mod imp {
let widget = &*self.obj();
let alloc = widget.allocation();
self.snapshot_background(widget, snapshot);
// Draw all visible children
self.nodes
.borrow()
@@ -270,7 +288,9 @@ mod imp {
// Drag the Node around the screen.
let node = target.dynamic_cast_ref::<Node>().unwrap();
let Some(canvas_node_pos) = widget.node_position(node) else { return };
let Some(canvas_node_pos) = widget.node_position(node) else {
return;
};
let canvas_cursor_pos = widget
.imp()
.screen_space_to_canvas_space_transform()
@@ -293,7 +313,9 @@ mod imp {
.dynamic_cast::<super::GraphView>()
.expect("drag-update event is not on the GraphView");
let dragged_node = widget.imp().dragged_node.borrow();
let Some(DragState { node, offset }) = dragged_node.as_ref() else { return };
let Some(DragState { node, offset }) = dragged_node.as_ref() else {
return;
};
let Some(node) = node.upgrade() else { return };
let (start_x, start_y) = drag_controller
@@ -443,73 +465,13 @@ mod imp {
self.obj().add_controller(zoom_gesture);
}
fn snapshot_background(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
// Grid size and line width during neutral zoom (factor 1.0).
const NORMAL_GRID_SIZE: f32 = 20.0;
const NORMAL_GRID_LINE_WIDTH: f32 = 1.0;
let zoom_factor = self.zoom_factor.get();
let grid_size = NORMAL_GRID_SIZE * zoom_factor as f32;
let grid_line_width = NORMAL_GRID_LINE_WIDTH * zoom_factor as f32;
let alloc = widget.allocation();
// We need to offset the lines between 0 and (excluding) `grid_size` so the grid moves with
// the rest of the view when scrolling.
// The offset is rounded so the grid is always aligned to a row of pixels.
let hadj = self
.hadjustment
.borrow()
.as_ref()
.map(|hadjustment| hadjustment.value())
.unwrap_or(0.0);
let hoffset = (grid_size - (hadj as f32 % grid_size)) % grid_size;
let vadj = self
.vadjustment
.borrow()
.as_ref()
.map(|vadjustment| vadjustment.value())
.unwrap_or(0.0);
let voffset = (grid_size - (vadj as f32 % grid_size)) % grid_size;
snapshot.push_repeat(
&Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32),
Some(&Rect::new(0.0, voffset, alloc.width() as f32, grid_size)),
);
let grid_color = RGBA::new(0.137, 0.137, 0.137, 1.0);
snapshot.append_linear_gradient(
&Rect::new(0.0, voffset, alloc.width() as f32, grid_line_width),
&Point::new(0.0, 0.0),
&Point::new(alloc.width() as f32, 0.0),
&[
ColorStop::new(0.0, grid_color),
ColorStop::new(1.0, grid_color),
],
);
snapshot.pop();
snapshot.push_repeat(
&Rect::new(0.0, 0.0, alloc.width() as f32, alloc.height() as f32),
Some(&Rect::new(hoffset, 0.0, grid_size, alloc.height() as f32)),
);
snapshot.append_linear_gradient(
&Rect::new(hoffset, 0.0, grid_line_width, alloc.height() as f32),
&Point::new(0.0, 0.0),
&Point::new(0.0, alloc.height() as f32),
&[
ColorStop::new(0.0, grid_color),
ColorStop::new(1.0, grid_color),
],
);
snapshot.pop();
}
fn draw_link(
&self,
link_cr: &cairo::Context,
output_anchor: &Point,
input_anchor: &Point,
active: bool,
color: &gdk::RGBA,
) {
let output_x: f64 = output_anchor.x().into();
let output_y: f64 = output_anchor.y().into();
@@ -523,6 +485,13 @@ mod imp {
link_cr.set_dash(&[10.0, 5.0], 0.0);
}
link_cr.set_source_rgba(
color.red().into(),
color.green().into(),
color.blue().into(),
color.alpha().into(),
);
// If the output port is farther right than the input port and they have
// a similar y coordinate, apply a y offset to the control points
// so that the curve sticks out a bit.
@@ -551,7 +520,7 @@ mod imp {
};
}
fn draw_dragged_link(&self, port: &Port, link_cr: &cairo::Context) {
fn draw_dragged_link(&self, port: &Port, link_cr: &cairo::Context, colors: &Colors) {
let Some(port_anchor) = port.compute_point(&*self.obj(), &port.link_anchor()) else {
return;
};
@@ -573,13 +542,15 @@ mod imp {
});
let other_anchor = picked_port_anchor.unwrap_or(drag_cursor);
let (output_anchor, input_anchor) = match port.direction() {
let (output_anchor, input_anchor) = match Direction::from_raw(port.direction()) {
Direction::Output => (&port_anchor, &other_anchor),
Direction::Input => (&other_anchor, &port_anchor),
_ => unreachable!(),
};
self.draw_link(link_cr, output_anchor, input_anchor, false);
let color = &colors.color_for_media_type(MediaType::from_raw(port.media_type()));
self.draw_link(link_cr, output_anchor, input_anchor, false, color);
}
fn snapshot_links(&self, widget: &super::GraphView, snapshot: &gtk::Snapshot) {
@@ -594,30 +565,45 @@ mod imp {
link_cr.set_line_width(2.0 * self.zoom_factor.get());
let rgba = widget
let colors = Colors {
audio: 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(),
);
.lookup_color("media-type-audio")
.expect("color not found"),
video: widget
.style_context()
.lookup_color("media-type-video")
.expect("color not found"),
midi: widget
.style_context()
.lookup_color("media-type-midi")
.expect("color not found"),
unknown: widget
.style_context()
.lookup_color("media-type-unknown")
.expect("color not found"),
};
for link in self.links.borrow().iter() {
let color = &colors.color_for_media_type(link.media_type());
// TODO: Do not draw links when they are outside the view
let Some((output_anchor, input_anchor)) = self.get_link_coordinates(link) else {
warn!("Could not get allocation of ports of link: {:?}", link);
continue;
};
self.draw_link(&link_cr, &output_anchor, &input_anchor, link.active());
self.draw_link(
&link_cr,
&output_anchor,
&input_anchor,
link.active(),
color,
);
}
if let Some(port) = self.dragged_port.upgrade() {
self.draw_dragged_link(&port, &link_cr);
self.draw_dragged_link(&port, &link_cr, &colors);
}
}
@@ -793,6 +779,12 @@ impl GraphView {
graph.queue_draw();
}),
);
link.connect_notify_local(
Some("media-type"),
glib::clone!(@weak self as graph => move |_, _| {
graph.queue_draw();
}),
);
self.imp().links.borrow_mut().insert(link);
self.queue_draw();
}
@@ -804,6 +796,14 @@ impl GraphView {
self.queue_draw();
}
pub fn clear(&mut self) {
self.imp().links.borrow_mut().clear();
for (node, _) in self.imp().nodes.borrow_mut().drain() {
node.unparent();
}
self.queue_draw();
}
/// Get the position of the specified node inside the graphview.
///
/// The returned position is in canvas-space (non-zoomed, (0, 0) fixed in the middle of the canvas).

View File

@@ -14,7 +14,8 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{glib, prelude::*, subclass::prelude::*};
use adw::{glib, prelude::*, subclass::prelude::*};
use pipewire::spa::format::MediaType;
use super::Port;
@@ -25,11 +26,22 @@ mod imp {
use once_cell::sync::Lazy;
#[derive(Default)]
pub struct Link {
pub output_port: glib::WeakRef<Port>,
pub input_port: glib::WeakRef<Port>,
pub active: Cell<bool>,
pub media_type: Cell<MediaType>,
}
impl Default for Link {
fn default() -> Self {
Self {
output_port: glib::WeakRef::default(),
input_port: glib::WeakRef::default(),
active: Cell::default(),
media_type: Cell::new(MediaType::Unknown),
}
}
}
#[glib::object_subclass]
@@ -53,6 +65,10 @@ mod imp {
.default_value(false)
.flags(glib::ParamFlags::READWRITE)
.build(),
glib::ParamSpecUInt::builder("media-type")
.default_value(MediaType::Unknown.as_raw())
.flags(glib::ParamFlags::READWRITE)
.build(),
]
});
@@ -64,6 +80,7 @@ mod imp {
"output-port" => self.output_port.upgrade().to_value(),
"input-port" => self.input_port.upgrade().to_value(),
"active" => self.active.get().to_value(),
"media-type" => self.media_type.get().as_raw().to_value(),
_ => unimplemented!(),
}
}
@@ -73,6 +90,9 @@ mod imp {
"output-port" => self.output_port.set(value.get().unwrap()),
"input-port" => self.input_port.set(value.get().unwrap()),
"active" => self.active.set(value.get().unwrap()),
"media-type" => self
.media_type
.set(MediaType::from_raw(value.get().unwrap())),
_ => unimplemented!(),
}
}
@@ -111,6 +131,14 @@ impl Link {
pub fn set_active(&self, active: bool) {
self.set_property("active", active);
}
pub fn media_type(&self) -> MediaType {
MediaType::from_raw(self.property("media-type"))
}
pub fn set_media_type(&self, media_type: MediaType) {
self.set_property("media-type", media_type.as_raw())
}
}
impl Default for Link {

View File

@@ -20,6 +20,8 @@ mod node;
pub use node::*;
mod port;
pub use port::*;
mod port_handle;
pub use port_handle::*;
mod link;
pub use link::*;
mod zoomentry;

View File

@@ -14,7 +14,7 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{glib, prelude::*, subclass::prelude::*};
use adw::{glib, gtk, prelude::*, subclass::prelude::*};
use pipewire::spa::Direction;
use super::Port;
@@ -27,12 +27,12 @@ mod imp {
collections::HashSet,
};
#[derive(glib::Properties)]
#[derive(glib::Properties, gtk::CompositeTemplate, Default)]
#[properties(wrapper_type = super::Node)]
#[template(file = "node.ui")]
pub struct Node {
#[property(get, set, construct_only)]
pub(super) pipewire_id: Cell<u32>,
pub(super) grid: gtk::Grid,
#[property(
name = "name", type = String,
get = |this: &Self| this.label.text().to_string(),
@@ -41,10 +41,13 @@ mod imp {
this.label.set_tooltip_text(Some(val));
}
)]
pub(super) label: gtk::Label,
#[template_child]
pub(super) label: TemplateChild<gtk::Label>,
#[template_child]
pub(super) separator: TemplateChild<gtk::Separator>,
#[template_child]
pub(super) port_grid: TemplateChild<gtk::Grid>,
pub(super) ports: RefCell<HashSet<Port>>,
pub(super) num_ports_in: Cell<i32>,
pub(super) num_ports_out: Cell<i32>,
}
#[glib::object_subclass]
@@ -54,31 +57,15 @@ mod imp {
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BinLayout>();
klass.set_layout_manager_type::<gtk::BoxLayout>();
klass.bind_template();
klass.set_css_name("node");
}
fn new() -> Self {
let grid = gtk::Grid::new();
let label = gtk::Label::new(None);
label.set_wrap(true);
label.set_lines(2);
label.set_max_width_chars(20);
label.set_ellipsize(gtk::pango::EllipsizeMode::End);
grid.attach(&label, 0, 0, 2, 1);
// Display a grab cursor when the mouse is over the label so the user knows the node can be dragged.
label.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
Self {
pipewire_id: Cell::new(0),
grid,
label,
ports: RefCell::new(HashSet::new()),
num_ports_in: Cell::new(0),
num_ports_out: Cell::new(0),
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
@@ -86,15 +73,64 @@ mod imp {
impl ObjectImpl for Node {
fn constructed(&self) {
self.parent_constructed();
self.grid.set_parent(&*self.obj());
// Display a grab cursor when the mouse is over the label so the user knows the node can be dragged.
self.label
.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
}
fn dispose(&self) {
self.grid.unparent();
if let Some(child) = self.obj().first_child() {
child.unparent();
}
}
}
impl WidgetImpl for Node {}
impl Node {
/// Update the internal ports grid to reflect the ports stored in the ports set.
pub fn update_ports(&self) {
// We first remove all ports from the grid, then re-add them all, so that
// ports that have been removed do not leave gaps in the grid.
while let Some(ref child) = self.port_grid.first_child() {
self.port_grid.remove(child);
}
let ports = self.ports.borrow();
let mut ports_out = Vec::new();
let mut ports_in = Vec::new();
ports
.iter()
.for_each(|port| match Direction::from_raw(port.direction()) {
Direction::Output => {
ports_out.push(port);
}
Direction::Input => {
ports_in.push(port);
}
_ => unreachable!(),
});
ports_out.sort_unstable_by_key(|port| port.name());
ports_in.sort_unstable_by_key(|port| port.name());
// In case no ports have been added to the port, hide the seperator as it is not needed
self.separator
.set_visible(!ports_out.is_empty() || !ports_in.is_empty());
for (i, port) in ports_in.into_iter().enumerate() {
self.port_grid.attach(port, 0, i.try_into().unwrap(), 1, 1);
}
for (i, port) in ports_out.into_iter().enumerate() {
self.port_grid.attach(port, 1, i.try_into().unwrap(), 1, 1);
}
}
}
}
glib::wrapper! {
@@ -112,32 +148,14 @@ impl Node {
pub fn add_port(&self, port: Port) {
let imp = self.imp();
match port.direction() {
Direction::Input => {
imp.grid.attach(&port, 0, imp.num_ports_in.get() + 1, 1, 1);
imp.num_ports_in.set(imp.num_ports_in.get() + 1);
}
Direction::Output => {
imp.grid.attach(&port, 1, imp.num_ports_out.get() + 1, 1, 1);
imp.num_ports_out.set(imp.num_ports_out.get() + 1);
}
_ => unreachable!(),
}
imp.ports.borrow_mut().insert(port);
imp.update_ports();
}
pub fn remove_port(&self, port: &Port) {
let imp = self.imp();
if imp.ports.borrow_mut().remove(port) {
match port.direction() {
Direction::Input => imp.num_ports_in.set(imp.num_ports_in.get() - 1),
Direction::Output => imp.num_ports_in.set(imp.num_ports_out.get() - 1),
_ => unreachable!(),
}
port.unparent();
imp.update_ports();
} else {
log::warn!("Tried to remove non-existant port widget from node");
}

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

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

View File

@@ -14,29 +14,45 @@
//
// SPDX-License-Identifier: GPL-3.0-only
use gtk::{
use adw::{
gdk,
glib::{self, subclass::Signal},
graphene,
gtk::{self, graphene},
prelude::*,
subclass::prelude::*,
};
use pipewire::spa::Direction;
use crate::MediaType;
use super::PortHandle;
mod imp {
use super::*;
use std::cell::Cell;
use once_cell::{sync::Lazy, unsync::OnceCell};
use pipewire::spa::Direction;
use pipewire::spa::{format::MediaType, Direction};
/// Graphical representation of a pipewire port.
#[derive(Default, glib::Properties)]
#[derive(gtk::CompositeTemplate, glib::Properties)]
#[properties(wrapper_type = super::Port)]
#[template(file = "port.ui")]
pub struct Port {
#[property(get, set, construct_only)]
pub(super) pipewire_id: OnceCell<u32>,
#[property(
type = u32,
get = |_| self.media_type.get().as_raw(),
set = Self::set_media_type
)]
pub(super) media_type: Cell<MediaType>,
#[property(
type = u32,
get = |_| self.direction.get().as_raw(),
set = Self::set_direction,
construct_only
)]
pub(super) direction: Cell<Direction>,
#[property(
name = "name", type = String,
get = |this: &Self| this.label.text().to_string(),
@@ -45,8 +61,22 @@ mod imp {
this.label.set_tooltip_text(Some(val));
}
)]
pub(super) label: gtk::Label,
pub(super) direction: OnceCell<Direction>,
#[template_child]
pub(super) label: TemplateChild<gtk::Label>,
#[template_child]
pub(super) handle: TemplateChild<PortHandle>,
}
impl Default for Port {
fn default() -> Self {
Self {
pipewire_id: OnceCell::default(),
media_type: Cell::new(MediaType::Unknown),
direction: Cell::new(Direction::Output),
label: TemplateChild::default(),
handle: TemplateChild::default(),
}
}
}
#[glib::object_subclass]
@@ -56,10 +86,13 @@ mod imp {
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BinLayout>();
klass.set_css_name("port");
// Make it look like a GTK button.
klass.set_css_name("button");
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
@@ -68,19 +101,13 @@ mod imp {
fn constructed(&self) {
self.parent_constructed();
self.label.set_parent(&*self.obj());
self.label.set_wrap(true);
self.label.set_lines(2);
self.label.set_max_width_chars(20);
self.label.set_ellipsize(gtk::pango::EllipsizeMode::End);
// Display a grab cursor when the mouse is over the port so the user knows it can be dragged to another port.
self.obj()
.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
self.setup_port_drag_and_drop();
}
fn dispose(&self) {
self.label.unparent()
}
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
vec![Signal::builder("port-toggled")
@@ -92,7 +119,81 @@ mod imp {
SIGNALS.as_ref()
}
}
impl WidgetImpl for Port {}
impl WidgetImpl for Port {
fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) {
match orientation {
gtk::Orientation::Horizontal => {
let (min_handle_width, nat_handle_width, _, _) =
self.handle.measure(orientation, for_size);
let (min_label_width, nat_label_width, _, _) = self
.label
.measure(orientation, i32::max(for_size - (nat_handle_width / 2), -1));
(
(min_handle_width / 2) + min_label_width,
(nat_handle_width / 2) + nat_label_width,
-1,
-1,
)
}
gtk::Orientation::Vertical => {
let (min_label_height, nat_label_height, _, _) =
self.label.measure(orientation, for_size);
let (min_handle_height, nat_handle_height, _, _) =
self.handle.measure(orientation, for_size);
(
i32::max(min_label_height, min_handle_height),
i32::max(nat_label_height, nat_handle_height),
-1,
-1,
)
}
_ => unimplemented!(),
}
}
fn size_allocate(&self, width: i32, height: i32, _baseline: i32) {
let (_, nat_handle_height, _, _) =
self.handle.measure(gtk::Orientation::Vertical, height);
let (_, nat_handle_width, _, _) =
self.handle.measure(gtk::Orientation::Horizontal, width);
match Direction::from_raw(self.obj().direction()) {
Direction::Input => {
let alloc = gtk::Allocation::new(
-nat_handle_width / 2,
(height - nat_handle_height) / 2,
nat_handle_width,
nat_handle_height,
);
self.handle.size_allocate(&alloc, -1);
let alloc = gtk::Allocation::new(
nat_handle_width / 2,
0,
width - (nat_handle_width / 2),
height,
);
self.label.size_allocate(&alloc, -1);
}
Direction::Output => {
let alloc = gtk::Allocation::new(
width - (nat_handle_width / 2),
(height - nat_handle_height) / 2,
nat_handle_width,
nat_handle_height,
);
self.handle.size_allocate(&alloc, -1);
let alloc = gtk::Allocation::new(0, 0, width - (nat_handle_width / 2), height);
self.label.size_allocate(&alloc, -1);
}
_ => unreachable!(),
}
}
}
impl Port {
fn setup_port_drag_and_drop(&self) {
@@ -168,7 +269,7 @@ mod imp {
return false;
}
let (output_port, input_port) = match port.direction() {
let (output_port, input_port) = match Direction::from_raw(port.direction()) {
Direction::Output => (&port, &other_port),
Direction::Input => (&other_port, &port),
_ => unreachable!(),
@@ -183,6 +284,42 @@ mod imp {
});
obj.add_controller(drop_target);
}
fn set_media_type(&self, media_type: u32) {
let media_type = MediaType::from_raw(media_type);
self.media_type.set(media_type);
for css_class in ["video", "audio", "midi"] {
self.handle.remove_css_class(css_class)
}
// Color the port according to its media type.
match media_type {
MediaType::Video => self.handle.add_css_class("video"),
MediaType::Audio => self.handle.add_css_class("audio"),
MediaType::Application | MediaType::Stream => self.handle.add_css_class("midi"),
_ => {}
}
}
fn set_direction(&self, direction: u32) {
let direction = Direction::from_raw(direction);
self.direction.set(direction);
match direction {
Direction::Input => {
self.obj().set_halign(gtk::Align::Start);
self.label.set_halign(gtk::Align::Start);
}
Direction::Output => {
self.obj().set_halign(gtk::Align::End);
self.label.set_halign(gtk::Align::End);
}
_ => unreachable!(),
}
}
}
}
@@ -192,39 +329,12 @@ glib::wrapper! {
}
impl Port {
pub fn new(id: u32, name: &str, direction: Direction, media_type: Option<MediaType>) -> Self {
// Create the widget and initialize needed fields
let res: Self = glib::Object::builder()
pub fn new(id: u32, name: &str, direction: Direction) -> Self {
glib::Object::builder()
.property("pipewire-id", id)
.property("direction", direction.as_raw())
.property("name", name)
.build();
let imp = res.imp();
imp.direction
.set(direction)
.expect("Port direction already set");
// Display a grab cursor when the mouse is over the port so the user knows it can be dragged to another port.
res.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
// Color the port according to its media type.
match media_type {
Some(MediaType::Video) => res.add_css_class("video"),
Some(MediaType::Audio) => res.add_css_class("audio"),
Some(MediaType::Midi) => res.add_css_class("midi"),
None => {}
}
res
}
pub fn direction(&self) -> Direction {
*self
.imp()
.direction
.get()
.expect("Port direction is not set")
.build()
}
pub fn link_anchor(&self) -> graphene::Point {
@@ -234,8 +344,9 @@ impl Port {
let padding_left: f32 = style_context.padding().left().into();
let border_left: f32 = style_context.border().left().into();
let direction = Direction::from_raw(self.direction());
graphene::Point::new(
match self.direction() {
match direction {
Direction::Output => self.width() as f32 + padding_right + border_right,
Direction::Input => 0.0 - padding_left - border_left,
_ => unreachable!(),

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

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

View File

@@ -0,0 +1,84 @@
// Copyright 2021 Tom A. Wagner <tom.a.wagner@protonmail.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as published by
// the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-only
use adw::{glib, gtk, prelude::*, subclass::prelude::*};
mod imp {
use super::*;
#[derive(Default)]
pub struct PortHandle {}
#[glib::object_subclass]
impl ObjectSubclass for PortHandle {
const NAME: &'static str = "HelvumPortHandle";
type Type = super::PortHandle;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
klass.set_css_name("port-handle");
}
}
impl ObjectImpl for PortHandle {
fn constructed(&self) {
self.parent_constructed();
let obj = &*self.obj();
obj.set_halign(gtk::Align::Center);
obj.set_valign(gtk::Align::Center);
}
}
impl WidgetImpl for PortHandle {
fn request_mode(&self) -> gtk::SizeRequestMode {
gtk::SizeRequestMode::ConstantSize
}
fn measure(&self, _orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) {
(Self::HANDLE_SIZE, Self::HANDLE_SIZE, -1, -1)
}
}
impl PortHandle {
pub const HANDLE_SIZE: i32 = 14;
}
}
glib::wrapper! {
pub struct PortHandle(ObjectSubclass<imp::PortHandle>)
@extends gtk::Widget;
}
impl PortHandle {
pub fn new() -> Self {
glib::Object::new()
}
pub fn get_link_anchor(&self) -> gtk::graphene::Point {
gtk::graphene::Point::new(
imp::PortHandle::HANDLE_SIZE as f32 / 2.0,
imp::PortHandle::HANDLE_SIZE as f32 / 2.0,
)
}
}
impl Default for PortHandle {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,4 +1,4 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
use adw::{glib, gtk, prelude::*, subclass::prelude::*};
use super::GraphView;
@@ -127,7 +127,8 @@ mod imp {
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"zoomed-widget" => {
let widget: GraphView = value.get().unwrap();
let widget: Option<GraphView> = value.get().unwrap();
if let Some(ref widget) = widget {
widget.connect_notify_local(
Some("zoom-factor"),
clone!(@weak self as imp => move |graphview, _| {
@@ -135,7 +136,8 @@ mod imp {
}),
);
self.update_zoom_factor_text(widget.zoom_factor());
*self.graphview.borrow_mut() = Some(widget);
}
*self.graphview.borrow_mut() = widget;
}
_ => unimplemented!(),
}

View File

@@ -19,3 +19,6 @@
//! This module contains gtk widgets needed to present the graphical user interface.
pub mod graph;
mod window;
pub use window::*;

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

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

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

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