9 Commits
0.5.1 ... main

Author SHA1 Message Date
Tom Wagner
eb3b3cf298 Mark project as unmaintained 2025-09-15 13:35:26 +02:00
Tom A. Wagner
4549ba6ff5 ui: Force LTR direction on the nodes port grid and on ports
Forces the direction on the nodes internal port grid and on the ports themselves
to be always left-to-right (LTR), to avoid the UI becoming messed up when defaulting
to right-to-left (RTL).

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

This moves the banner into the "content" widget of the AdwToolbarView to fix that issue.
2023-10-11 22:48:37 +02:00
Angelo Verlain
94d5e95695 use AdwToolbarView 2023-10-11 22:48:37 +02:00
Angelo Verlain Shema
e1f63ddd28 Use responsive design 2023-10-10 18:16:23 +00:00
Angelo Verlain
903df21ba3 attach about window 2023-10-06 17:46:59 +02:00
15 changed files with 273 additions and 66 deletions

View File

@@ -15,7 +15,7 @@ categories = ["gui", "multimedia"]
[dependencies]
pipewire = "0.7.1"
adw = { version = "0.5", package = "libadwaita", features = ["v1_3"] }
adw = { version = "0.5", package = "libadwaita", features = ["v1_4"] }
glib = { version = "0.18", features = ["log"] }
log = "0.4.11"

View File

@@ -1,5 +1,9 @@
Helvum is a GTK-based patchbay for pipewire, inspired by the JACK tool [catia](https://kx.studio/Applications:Catia).
> [!caution]
> Helvum is currently not actively maintained.
> If you are interested in maintaining the project, please see https://gitlab.freedesktop.org/pipewire/helvum/-/issues/137
![Screenshot](docs/screenshot.png)
<a href="https://flathub.org/apps/details/org.pipewire.Helvum"><img src="https://flathub.org/assets/badges/flathub-badge-en.png" width="300"/></a>

View File

@@ -12,7 +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('libadwaita-1', version: '>= 1.4')
dependency('libpipewire-0.3')
desktop_file_validate = find_program('desktop-file-validate', required: false)

View File

@@ -112,9 +112,12 @@ mod imp {
}
fn show_about_dialog(&self) {
let obj = &*self.obj();
let window = obj.active_window().unwrap();
let authors: Vec<&str> = AUTHORS.split(':').collect();
let about_window = adw::AboutWindow::builder()
.transient_for(&window)
.application_icon(APP_ID)
.application_name("Helvum")
.developer_name("Tom Wagner")

View File

@@ -59,6 +59,7 @@ mod imp {
move |msg| {
match msg {
PipewireMessage::NodeAdded { id, name, node_type } => imp.add_node(id, name.as_str(), node_type),
PipewireMessage::NodeNameChanged { id, name, media_name } => imp.node_name_changed(id, &name, &media_name),
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 {
@@ -95,6 +96,23 @@ mod imp {
self.obj().graph().add_node(node, node_type);
}
/// Update a node tooltip to the view.
fn node_name_changed(&self, id: u32, node_name: &str, media_name: &str) {
let items = self.items.borrow();
let Some(node) = items.get(&id) else {
log::warn!("Node (id: {id}) for changed name not found in graph manager");
return;
};
let Some(node) = node.dynamic_cast_ref::<graph::Node>() else {
log::warn!("Graph Manager item under node (id: {id}) is not a node");
return;
};
node.set_node_name(node_name);
node.set_media_name(media_name);
}
/// Remove the node with the specified id from the view.
fn remove_node(&self, id: u32) {
log::info!("Removing node from graph: id {}", id);

View File

@@ -39,6 +39,11 @@ pub enum PipewireMessage {
name: String,
node_type: Option<NodeType>,
},
NodeNameChanged {
id: u32,
name: String,
media_name: String,
},
PortAdded {
id: u32,
node_id: u32,

View File

@@ -26,7 +26,9 @@ use std::{
use adw::glib::{self, clone};
use log::{debug, error, info, warn};
use pipewire::{
keys,
link::{Link, LinkChangeMask, LinkInfo, LinkListener, LinkState},
node::{Node, NodeInfo, NodeListener},
port::{Port, PortChangeMask, PortInfo, PortListener},
prelude::*,
properties,
@@ -43,6 +45,10 @@ use crate::{GtkMessage, MediaType, NodeType, PipewireMessage};
use state::{Item, State};
enum ProxyItem {
Node {
_proxy: Node,
_listener: NodeListener,
},
Port {
proxy: Port,
_listener: PortListener,
@@ -65,7 +71,9 @@ pub(super) fn thread_main(
while !is_stopped.get() {
// Try to connect
let core = match context.connect(None) {
let core = match context.connect(Some(properties! {
"media.category" => "Manager"
})) {
Ok(core) => Rc::new(core),
Err(_) => {
if !is_connecting {
@@ -147,7 +155,7 @@ pub(super) fn thread_main(
.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::Node => handle_node(global, &gtk_sender, &registry, &proxies, &state),
ObjectType::Port => handle_port(global, &gtk_sender, &registry, &proxies, &state),
ObjectType::Link => handle_link(global, &gtk_sender, &registry, &proxies, &state),
_ => {
@@ -178,10 +186,21 @@ pub(super) fn thread_main(
}
}
/// Get the nicest possible name for the node, using a fallback chain of possible name attributes
fn get_node_name(props: &ForeignDict) -> &str {
props
.get(&keys::NODE_DESCRIPTION)
.or_else(|| props.get(&keys::NODE_NICK))
.or_else(|| props.get(&keys::NODE_NAME))
.unwrap_or_default()
}
/// Handle a new node being added
fn handle_node(
node: &GlobalObject<ForeignDict>,
sender: &glib::Sender<PipewireMessage>,
registry: &Rc<Registry>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
state: &Rc<RefCell<State>>,
) {
let props = node
@@ -189,15 +208,7 @@ fn handle_node(
.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 name = get_node_name(props).to_string();
let media_class = |class: &str| {
if class.contains("Sink") || class.contains("Input") {
Some(NodeType::Input)
@@ -228,6 +239,50 @@ fn handle_node(
node_type,
})
.expect("Failed to send message");
let proxy: Node = registry.bind(node).expect("Failed to bind to node proxy");
let listener = proxy
.add_listener_local()
.info(clone!(@strong sender, @strong proxies => move |info| {
handle_node_info(info, &sender, &proxies);
}))
.register();
proxies.borrow_mut().insert(
node.id,
ProxyItem::Node {
_proxy: proxy,
_listener: listener,
},
);
}
fn handle_node_info(
info: &NodeInfo,
sender: &glib::Sender<PipewireMessage>,
proxies: &Rc<RefCell<HashMap<u32, ProxyItem>>>,
) {
debug!("Received node info: {:?}", info);
let id = info.id();
let proxies = proxies.borrow();
let Some(ProxyItem::Node { .. }) = proxies.get(&id) else {
error!("Received info on unknown node with id {id}");
return;
};
let props = info.props().expect("NodeInfo object is missing properties");
if let Some(media_name) = props.get(&keys::MEDIA_NAME) {
let name = get_node_name(props).to_string();
sender
.send(PipewireMessage::NodeNameChanged {
id,
name,
media_name: media_name.to_string(),
})
.expect("Failed to send message");
}
}
/// Handle a new port being added

View File

@@ -41,7 +41,7 @@ node {
background-color: @headerbar_bg_color;
}
node label.heading {
node .node-title {
padding: 4px 7px;
}
@@ -53,3 +53,20 @@ port-handle {
border-radius: 50%;
background-color: @media-type-unknown;
}
button.rounded {
padding: 6px;
border-radius: 9999px;
}
entry.rounded {
border-radius: 9999px;
}
entry.rounded > :first-child {
padding-left: 12px;
}
entry.rounded > :nth-child(2) {
padding-right: 12px;
}

View File

@@ -93,6 +93,9 @@ mod imp {
// Memorized data for an in-progress zoom gesture
pub zoom_gesture_initial_zoom: Cell<Option<f64>>,
pub zoom_gesture_anchor: Cell<Option<(f64, f64)>>,
// This keeps track of an ongoing move view gesture.
pub move_view_state: Cell<(f64, f64)>,
}
impl Default for GraphView {
@@ -108,6 +111,7 @@ mod imp {
port_drag_cursor: Cell::new(Point::new(0.0, 0.0)),
zoom_gesture_initial_zoom: Default::default(),
zoom_gesture_anchor: Default::default(),
move_view_state: Default::default(),
}
}
}
@@ -136,6 +140,7 @@ mod imp {
self.setup_port_drag_and_drop();
self.setup_scroll_zooming();
self.setup_zoom_gesture();
self.setup_move_view();
}
fn dispose(&self) {
@@ -465,6 +470,48 @@ mod imp {
self.obj().add_controller(zoom_gesture);
}
fn setup_move_view(&self) {
let drag_controller = gtk::GestureDrag::new();
drag_controller.set_button(gtk::gdk::BUTTON_MIDDLE);
// TODO: set `all-scroll` cursor while dragging view
drag_controller.connect_drag_begin(|drag_controller, _, _| {
let widget = drag_controller
.widget()
.downcast::<super::GraphView>()
.unwrap();
widget.imp().move_view_state.set((0.0, 0.0));
});
drag_controller.connect_drag_update(|drag_controller, x, y| {
let widget = drag_controller
.widget()
.downcast::<super::GraphView>()
.unwrap();
let imp = widget.imp();
let state = imp.move_view_state.replace((x, y));
let delta_x = state.0 - x;
let delta_y = state.1 - y;
let hadjustment_ref = imp.hadjustment.borrow();
let vadjustment_ref = imp.vadjustment.borrow();
let hadjustment = hadjustment_ref.as_ref().unwrap();
let vadjustment = vadjustment_ref.as_ref().unwrap();
let new_hadjustment = hadjustment.value() + delta_x;
let new_vadjustment = vadjustment.value() + delta_y;
hadjustment.set_value(new_hadjustment);
vadjustment.set_value(new_vadjustment);
});
self.obj().add_controller(drag_controller);
}
fn draw_link(
&self,
link_cr: &cairo::Context,

View File

@@ -34,15 +34,26 @@ mod imp {
#[property(get, set, construct_only)]
pub(super) pipewire_id: Cell<u32>,
#[property(
name = "name", type = String,
get = |this: &Self| this.label.text().to_string(),
name = "node-name", type = String,
get = |this: &Self| this.node_name.text().to_string(),
set = |this: &Self, val| {
this.label.set_text(val);
this.label.set_tooltip_text(Some(val));
this.node_name.set_text(val);
this.node_name.set_tooltip_text(Some(val));
}
)]
#[template_child]
pub(super) label: TemplateChild<gtk::Label>,
pub(super) node_name: TemplateChild<gtk::Label>,
#[property(
name = "media-name", type = String,
get = |this: &Self| this.media_name.text().to_string(),
set = |this: &Self, val| {
this.media_name.set_text(val);
this.media_name.set_tooltip_text(Some(val));
this.media_name.set_visible(!val.is_empty());
}
)]
#[template_child]
pub(super) media_name: TemplateChild<gtk::Label>,
#[template_child]
pub(super) separator: TemplateChild<gtk::Separator>,
#[template_child]
@@ -74,8 +85,11 @@ mod imp {
fn constructed(&self) {
self.parent_constructed();
// Force left-to-right direction for the ports grid to avoid messed up UI when defaulting to right-to-left
self.port_grid.set_direction(gtk::TextDirection::Ltr);
// Display a grab cursor when the mouse is over the label so the user knows the node can be dragged.
self.label
self.node_name
.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());
}
@@ -141,7 +155,7 @@ glib::wrapper! {
impl Node {
pub fn new(name: &str, pipewire_id: u32) -> Self {
glib::Object::builder()
.property("name", name)
.property("node-name", name)
.property("pipewire-id", pipewire_id)
.build()
}

View File

@@ -9,14 +9,36 @@
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="label">
<object class="GtkBox">
<style>
<class name="heading"></class>
<class name="node-title"></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>
<property name="orientation">vertical</property>
<property name="spacing">1</property>
<child>
<object class="GtkLabel" id="node_name">
<style>
<class name="heading"></class>
</style>
<property name="wrap">true</property>
<property name="ellipsize">PANGO_ELLIPSIZE_END</property>
<property name="lines">2</property>
<property name="max-width-chars">20</property>
</object>
</child>
<child>
<object class="GtkLabel" id="media_name">
<style>
<class name="dim-label"></class>
<class name="caption"></class>
</style>
<property name="visible">false</property>
<property name="wrap">true</property>
<property name="ellipsize">PANGO_ELLIPSIZE_END</property>
<property name="lines">2</property>
<property name="max-width-chars">20</property>
</object>
</child>
</object>
</child>
<child>

View File

@@ -101,6 +101,9 @@ mod imp {
fn constructed(&self) {
self.parent_constructed();
// Force left-to-right direction for the ports grid to avoid messed up UI when defaulting to right-to-left
self.obj().set_direction(gtk::TextDirection::Ltr);
// Display a grab cursor when the mouse is over the port so the user knows it can be dragged to another port.
self.obj()
.set_cursor(gtk::gdk::Cursor::from_name("grab", None).as_ref());

View File

@@ -34,6 +34,7 @@ mod imp {
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));
popover.set_position(gtk::PositionType::Top);
ZoomEntry {
graphview: Default::default(),

View File

@@ -1,26 +1,37 @@
<?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>
<property name="spacing">12</property>
<child>
<object class="GtkEntry" id="entry">
<property name="secondary-icon-name">go-down-symbolic</property>
<property name="input-purpose">digits</property>
<property name="max-width-chars">5</property>
<style>
<class name="osd"/>
<class name="rounded"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="zoom_out_button">
<property name="icon-name">zoom-out-symbolic</property>
<property name="tooltip-text">Zoom out</property>
<style>
<class name="osd"/>
<class name="rounded"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="zoom_in_button">
<property name="icon-name">zoom-in-symbolic</property>
<property name="tooltip-text">Zoom in</property>
<style>
<class name="osd"/>
<class name="rounded"/>
</style>
</object>
</child>
<style>
<class name="linked"/>
</style>
</template>
</interface>
</interface>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="Adw" version="1.0"/>
<requires lib="Adw" version="1.4"/>
<menu id="primary_menu">
<section>
<item>
@@ -15,44 +15,51 @@
<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="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar" id="header_bar">
<child type="end">
<object class="GtkBox">
<property name="spacing">6</property>
<object class="GtkMenuButton">
<property name="icon-name">open-menu-symbolic</property>
<property name="menu-model">primary_menu</property>
</object>
</child>
</object>
</child>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwBanner" id="connection_banner">
<property name="title" translatable="yes">Disconnected</property>
<property name="revealed">false</property>
</object>
</child>
<child>
<object class="GtkOverlay">
<child>
<object class="GtkScrolledWindow">
<child>
<object class="HelvumGraphView" id="graph">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="HelvumZoomEntry">
<property name="zoomed-widget">graph</property>
</object>
</child>
<child>
<object class="GtkMenuButton">
<property name="icon-name">open-menu-symbolic</property>
<property name="menu-model">primary_menu</property>
<property name="halign">end</property>
<property name="valign">end</property>
<property name="margin-end">24</property>
<property name="margin-bottom">24</property>
</object>
</child>
</object>
</child>
</object>
</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>
</property>
</object>
</child>
</template>