Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
7df8124933 | |||
4bc439feaf | |||
c9b07c92dc | |||
2fefc6ec65 |
@ -4,8 +4,14 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
dirs = "6.0.0"
|
||||||
|
flume = "0.11.1"
|
||||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||||
gtk = { version = "0.9", package = "gtk4", features = ["gnome_47"] }
|
gtk = { version = "0.9", package = "gtk4", features = ["gnome_47"] }
|
||||||
|
lazy_static = "1.5.0"
|
||||||
|
libspa = "0.8.0"
|
||||||
|
pipewire = "0.8.0"
|
||||||
|
sled = "0.34.7"
|
||||||
|
|
||||||
[dependencies.adw]
|
[dependencies.adw]
|
||||||
package = "libadwaita"
|
package = "libadwaita"
|
||||||
|
97
UI Drafts.txt
Normal file
97
UI Drafts.txt
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
Pipewire Device Config Selection Screen
|
||||||
|
|
||||||
|
----------------------
|
||||||
|
| Select Config |
|
||||||
|
| ______________ |
|
||||||
|
| | search | |
|
||||||
|
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
|
||||||
|
| Device Config 1 |
|
||||||
|
| Device Config 2 |
|
||||||
|
| Device Config 3 |
|
||||||
|
| ... |
|
||||||
|
| |
|
||||||
|
| Select |
|
||||||
|
| Duplicate |
|
||||||
|
| Create New |
|
||||||
|
| Cancel |
|
||||||
|
| |
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
Duplicate Device Config Screen
|
||||||
|
|
||||||
|
-------------------------
|
||||||
|
| Duplicate Config Name |
|
||||||
|
| _________________ |
|
||||||
|
| | name | |
|
||||||
|
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
|
||||||
|
| |
|
||||||
|
| Create |
|
||||||
|
| Cancel |
|
||||||
|
| |
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
New Device Config Selection Screen
|
||||||
|
|
||||||
|
-----------------------------------
|
||||||
|
| New Config |
|
||||||
|
| |
|
||||||
|
| Use Pipewire Device as Template |
|
||||||
|
| Create From Scratch |
|
||||||
|
| Cancel |
|
||||||
|
| |
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
New Device Config fom Template Selection
|
||||||
|
|
||||||
|
__________________________
|
||||||
|
| Select Pipewire Device |
|
||||||
|
| __________________ |
|
||||||
|
| | search | |
|
||||||
|
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
|
||||||
|
| Pipewire Device 1 |
|
||||||
|
| Pipewire Device 2 |
|
||||||
|
| Pipewire Device 3 |
|
||||||
|
| ... |
|
||||||
|
| |
|
||||||
|
| Select |
|
||||||
|
| Cancel |
|
||||||
|
| |
|
||||||
|
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
|
||||||
|
|
||||||
|
Device Configuration Page
|
||||||
|
|
||||||
|
_______________________________________________________
|
||||||
|
| Devices | < Device Connfiguration 1 |Edit Symbol| |
|
||||||
|
| | __________________________ |
|
||||||
|
| Device1 | Profile | Profile 1 ⌄ | |
|
||||||
|
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
|
||||||
|
| | |
|
||||||
|
| | Properties |
|
||||||
|
| | Which properties should be used to |
|
||||||
|
| | identify the pipewire device |
|
||||||
|
| | |
|
||||||
|
| | Name Value + |
|
||||||
|
| | _______________________________________ |
|
||||||
|
| | | property.name1 Property Value | |
|
||||||
|
| | | property.name2 Property Value | |
|
||||||
|
| | | ... ... ... | |
|
||||||
|
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
|
||||||
|
| | |
|
||||||
|
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
|
||||||
|
|
||||||
|
Device Property Chooser
|
||||||
|
|
||||||
|
__________________________
|
||||||
|
| Select Device Property |
|
||||||
|
| __________________ |
|
||||||
|
| | search | |
|
||||||
|
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
|
||||||
|
| property.name1 |
|
||||||
|
| property.name1 |
|
||||||
|
| ... |
|
||||||
|
| |
|
||||||
|
| Select |
|
||||||
|
| Create Custom |
|
||||||
|
| Cancel |
|
||||||
|
| |
|
||||||
|
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
|
@ -4,7 +4,8 @@
|
|||||||
"runtime-version" : "master",
|
"runtime-version" : "master",
|
||||||
"sdk" : "org.gnome.Sdk",
|
"sdk" : "org.gnome.Sdk",
|
||||||
"sdk-extensions" : [
|
"sdk-extensions" : [
|
||||||
"org.freedesktop.Sdk.Extension.rust-stable"
|
"org.freedesktop.Sdk.Extension.rust-stable",
|
||||||
|
"org.freedesktop.Sdk.Extension.llvm20"
|
||||||
],
|
],
|
||||||
"command" : "audio-device-manager",
|
"command" : "audio-device-manager",
|
||||||
"finish-args" : [
|
"finish-args" : [
|
||||||
@ -16,12 +17,13 @@
|
|||||||
],
|
],
|
||||||
"build-options" : {
|
"build-options" : {
|
||||||
"append-path" : "/usr/lib/sdk/rust-stable/bin",
|
"append-path" : "/usr/lib/sdk/rust-stable/bin",
|
||||||
|
"prepend-ld-library-path" : "/usr/lib/sdk/llvm20/lib",
|
||||||
"build-args" : [
|
"build-args" : [
|
||||||
"--share=network"
|
"--share=network"
|
||||||
],
|
],
|
||||||
"env" : {
|
"env" : {
|
||||||
"RUST_BACKTRACE" : "1",
|
"RUST_BACKTRACE" : "1",
|
||||||
"RUST_LOG" : "audio-device-manager=debug"
|
"RUST_LOG" : "audio-device-manager=debug",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cleanup" : [
|
"cleanup" : [
|
||||||
@ -44,8 +46,11 @@
|
|||||||
{
|
{
|
||||||
"type" : "git",
|
"type" : "git",
|
||||||
"url" : "https://gitea.ada-baumann.de/ada/AudioDeviceManager",
|
"url" : "https://gitea.ada-baumann.de/ada/AudioDeviceManager",
|
||||||
"branch" : "main"
|
"branch" : "dev"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"config-opts" : [
|
||||||
|
"--libdir=lib"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
54
de.AdaLouBaumann.AudioDeviceManager.json~
Normal file
54
de.AdaLouBaumann.AudioDeviceManager.json~
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"id" : "de.AdaLouBaumann.AudioDeviceManager",
|
||||||
|
"runtime" : "org.freedesktop.Platform",
|
||||||
|
"runtime-version" : "21.08",
|
||||||
|
"sdk" : "org.gnome.Sdk",
|
||||||
|
"sdk-extensions" : [
|
||||||
|
"org.freedesktop.Sdk.Extension.rust-stable",
|
||||||
|
"org.freedesktop.Sdk.Extension.llvm13"
|
||||||
|
],
|
||||||
|
"command" : "audio-device-manager",
|
||||||
|
"finish-args" : [
|
||||||
|
"--share=network",
|
||||||
|
"--share=ipc",
|
||||||
|
"--socket=fallback-x11",
|
||||||
|
"--device=dri",
|
||||||
|
"--socket=wayland"
|
||||||
|
],
|
||||||
|
"build-options" : {
|
||||||
|
"append-path" : "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm13/bin",
|
||||||
|
"prepend-ld-library-path": "/usr/lib/sdk/llvm13/lib",
|
||||||
|
"build-args" : [
|
||||||
|
"--share=network"
|
||||||
|
],
|
||||||
|
"env" : {
|
||||||
|
"RUST_BACKTRACE" : "1",
|
||||||
|
"RUST_LOG" : "audio-device-manager=debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cleanup" : [
|
||||||
|
"/include",
|
||||||
|
"/lib/pkgconfig",
|
||||||
|
"/man",
|
||||||
|
"/share/doc",
|
||||||
|
"/share/gtk-doc",
|
||||||
|
"/share/man",
|
||||||
|
"/share/pkgconfig",
|
||||||
|
"*.la",
|
||||||
|
"*.a"
|
||||||
|
],
|
||||||
|
"modules" : [
|
||||||
|
{
|
||||||
|
"name" : "audio-device-manager",
|
||||||
|
"builddir" : true,
|
||||||
|
"buildsystem" : "meson",
|
||||||
|
"sources" : [
|
||||||
|
{
|
||||||
|
"type" : "git",
|
||||||
|
"url" : "https://gitea.ada-baumann.de/ada/AudioDeviceManager",
|
||||||
|
"branch" : "dev"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -4,6 +4,8 @@ project('audio-device-manager', 'rust',
|
|||||||
default_options: [ 'warning_level=2', 'werror=false', ],
|
default_options: [ 'warning_level=2', 'werror=false', ],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#inc_dir = include_directories('/usr/lib/sdk/llvm20/lib/clang/20/include')
|
||||||
|
|
||||||
i18n = import('i18n')
|
i18n = import('i18n')
|
||||||
gnome = import('gnome')
|
gnome = import('gnome')
|
||||||
|
|
||||||
|
17
run.sh
17
run.sh
@ -3,8 +3,15 @@
|
|||||||
SRC_DIR="$(dirname "$0")"
|
SRC_DIR="$(dirname "$0")"
|
||||||
cd "$SRC_DIR" || exit
|
cd "$SRC_DIR" || exit
|
||||||
|
|
||||||
git add .
|
#export DESTDIR=~/.local
|
||||||
git commit -m "dev-$(date -Iseconds)"
|
#export PKGDATADIR=~/.local/share
|
||||||
git push
|
|
||||||
flatpak-builder --force-clean --user --install --ccache --install-deps-from=gnome-nightly --install-deps-from=flathub --install-deps-from=flathub-beta build de.AdaLouBaumann.AudioDeviceManager.json
|
~/.local/bin/meson build || exit
|
||||||
flatpak run de.AdaLouBaumann.AudioDeviceManager
|
sudo ~/.local/bin/meson install -C build || exit
|
||||||
|
RUST_BACKTRACE=1 audio-device-manager || exit
|
||||||
|
|
||||||
|
#git add .
|
||||||
|
#git commit -m "dev-$(date -Iseconds)"
|
||||||
|
#git push
|
||||||
|
#flatpak-builder --force-clean --user --install --ccache --install-deps-from=gnome-nightly --install-deps-from=flathub --install-deps-from=flathub-beta build de.AdaLouBaumann.AudioDeviceManager.json
|
||||||
|
#flatpak run de.AdaLouBaumann.AudioDeviceManager
|
2
src/components.rs
Normal file
2
src/components.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
use adw::gdk::pango;
|
||||||
|
use gtk::ListBoxRow;
|
@ -1,4 +1,4 @@
|
|||||||
pub static VERSION: &str = "0.1.0";
|
pub static VERSION: &str = "0.1.0";
|
||||||
pub static GETTEXT_PACKAGE: &str = "audio-device-manager";
|
pub static GETTEXT_PACKAGE: &str = "audio-device-manager";
|
||||||
pub static LOCALEDIR: &str = "/app/share/locale";
|
pub static LOCALEDIR: &str = "/usr/local/share/locale";
|
||||||
pub static PKGDATADIR: &str = "/app/share/audio-device-manager";
|
pub static PKGDATADIR: &str = "/usr/local/share/audio-device-manager";
|
||||||
|
@ -22,6 +22,10 @@
|
|||||||
mod application;
|
mod application;
|
||||||
mod config;
|
mod config;
|
||||||
mod window;
|
mod window;
|
||||||
|
mod components;
|
||||||
|
mod pw_manager;
|
||||||
|
mod utils;
|
||||||
|
mod state_manager;
|
||||||
|
|
||||||
use self::application::AudioDeviceManagerApplication;
|
use self::application::AudioDeviceManagerApplication;
|
||||||
use self::window::AudioDeviceManagerWindow;
|
use self::window::AudioDeviceManagerWindow;
|
||||||
|
410
src/pw_manager.rs
Normal file
410
src/pw_manager.rs
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::{Debug, Formatter};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::thread;
|
||||||
|
use std::thread::{sleep, JoinHandle};
|
||||||
|
use std::time::Duration;
|
||||||
|
use gtk::glib::ExitCode;
|
||||||
|
use pipewire::{context::Context, main_loop::MainLoop};
|
||||||
|
use pipewire::properties::Properties;
|
||||||
|
use pipewire::registry::{GlobalObject, Registry};
|
||||||
|
use pipewire::spa::param::ParamType;
|
||||||
|
|
||||||
|
use crate::pw_manager::deserialize::deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Command {
|
||||||
|
GetDeviceNames,
|
||||||
|
GetDeviceProperties(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Data {
|
||||||
|
DeviceNames(HashMap<usize, String>),
|
||||||
|
DeviceProperties(usize, HashMap<String, String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PipewireManager {
|
||||||
|
handle: JoinHandle<ExitCode>,
|
||||||
|
tx: pipewire::channel::Sender<Command>,
|
||||||
|
rx: flume::Receiver<Data>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PipewireManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
let (handle, tx, rx) = spawn_pipewire_thread();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
handle,
|
||||||
|
tx,
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipewireManager {
|
||||||
|
pub(crate) fn get_device_names(&self) -> HashMap<usize, String> {
|
||||||
|
self.tx.send(Command::GetDeviceNames).unwrap();
|
||||||
|
match self.rx.recv().unwrap() {
|
||||||
|
Data::DeviceNames(names) => names,
|
||||||
|
_ => panic!("Invalid data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_device_properties(&self, id: usize) -> HashMap<String, String> {
|
||||||
|
self.tx.send(Command::GetDeviceProperties(id)).unwrap();
|
||||||
|
match self.rx.recv().unwrap() {
|
||||||
|
Data::DeviceProperties(_, properties) => properties,
|
||||||
|
_ => panic!("Invalid data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceAvailability {
|
||||||
|
Available,
|
||||||
|
Unavailable,
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeviceProfile {
|
||||||
|
id: usize,
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
priority: usize,
|
||||||
|
available: DeviceAvailability,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices don't have to be connected to current system
|
||||||
|
// Relevant device data has to be stored
|
||||||
|
// User is supposed to be able to pick out which device data is relevant and identifying
|
||||||
|
// default is device.bus-path or device.product.id
|
||||||
|
// could also be device.product.id + device.product.name + device.vendor.name
|
||||||
|
// Interface: List of all device properties and next to them a checkbox for "relevant identifier"
|
||||||
|
// Maybe also allow regex
|
||||||
|
// also maybe allow custom script
|
||||||
|
struct Device {
|
||||||
|
active_profile: Rc<RefCell<Option<usize>>>,
|
||||||
|
profiles: Rc<RefCell<HashMap<usize, DeviceProfile>>>,
|
||||||
|
name: Rc<RefCell<String>>,
|
||||||
|
id: usize,
|
||||||
|
properties: Rc<RefCell<HashMap<String, String>>>,
|
||||||
|
pipewire_device: pipewire::device::Device,
|
||||||
|
device_listener: pipewire::device::DeviceListener,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device {
|
||||||
|
fn new(pipewire_device: pipewire::device::Device, id: usize) -> Self {
|
||||||
|
|
||||||
|
let active_profile = Rc::new(RefCell::new(None));
|
||||||
|
let profiles = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
let name = Rc::new(RefCell::new(String::new()));
|
||||||
|
let properties = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
|
||||||
|
let mut listener_builder = pipewire_device.add_listener_local();
|
||||||
|
|
||||||
|
listener_builder= Self::listen_for_params(listener_builder, &active_profile, &profiles);
|
||||||
|
listener_builder= Self::listen_for_info(listener_builder, &name, &properties);
|
||||||
|
|
||||||
|
|
||||||
|
let device_listener = listener_builder.register();
|
||||||
|
|
||||||
|
|
||||||
|
pipewire_device.subscribe_params(&[
|
||||||
|
ParamType::Profile,
|
||||||
|
ParamType::EnumProfile
|
||||||
|
]);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
active_profile,
|
||||||
|
profiles,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
properties,
|
||||||
|
pipewire_device,
|
||||||
|
device_listener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen_for_params<'a>(
|
||||||
|
listener_builder: pipewire::device::DeviceListenerLocalBuilder<'a>,
|
||||||
|
active_profile: &Rc<RefCell<Option<usize>>>,
|
||||||
|
profiles: &Rc<RefCell<HashMap<usize, DeviceProfile>>>,
|
||||||
|
) -> pipewire::device::DeviceListenerLocalBuilder<'a> {
|
||||||
|
listener_builder
|
||||||
|
.param({
|
||||||
|
let active_profile_weak = Rc::downgrade(active_profile);
|
||||||
|
let profiles_weak = Rc::downgrade(profiles);
|
||||||
|
|
||||||
|
move |seq, param_type, index, next, param| {
|
||||||
|
|
||||||
|
let Some(active_profile) = active_profile_weak.upgrade() else { return };
|
||||||
|
let Some(profiles) = profiles_weak.upgrade() else { return };
|
||||||
|
|
||||||
|
let Some(param) = deserialize(param) else { return };
|
||||||
|
|
||||||
|
Self::on_profile_discovered(
|
||||||
|
param,
|
||||||
|
param_type,
|
||||||
|
active_profile,
|
||||||
|
profiles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listen_for_info<'a>(
|
||||||
|
listener_builder: pipewire::device::DeviceListenerLocalBuilder<'a>,
|
||||||
|
name: &Rc<RefCell<String>>,
|
||||||
|
properties: &Rc<RefCell<HashMap<String, String>>>,
|
||||||
|
) -> pipewire::device::DeviceListenerLocalBuilder<'a> {
|
||||||
|
listener_builder
|
||||||
|
.info({
|
||||||
|
|
||||||
|
let name_weak = Rc::downgrade(name);
|
||||||
|
let properties_weak = Rc::downgrade(properties);
|
||||||
|
|
||||||
|
move |info| {
|
||||||
|
|
||||||
|
let Some(name) = name_weak.upgrade() else { return };
|
||||||
|
let Some(properties) = properties_weak.upgrade() else { return };
|
||||||
|
|
||||||
|
Self::on_info_discovered(
|
||||||
|
info,
|
||||||
|
name,
|
||||||
|
properties
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_profile_discovered(
|
||||||
|
param: libspa::pod::Object,
|
||||||
|
param_type: ParamType,
|
||||||
|
active_profile: Rc<RefCell<Option<usize>>>,
|
||||||
|
profiles: Rc<RefCell<HashMap<usize, DeviceProfile>>>,
|
||||||
|
) {
|
||||||
|
let mut profile_id = None;
|
||||||
|
let mut profile_name = None;
|
||||||
|
let mut profile_description = None;
|
||||||
|
let mut profile_priority = None;
|
||||||
|
let mut profile_availibility = None;
|
||||||
|
|
||||||
|
for property in param.properties {
|
||||||
|
match (property.key, property.value) {
|
||||||
|
(1, libspa::pod::Value::Int(id)) => { profile_id = Some(id as usize); },
|
||||||
|
(2, libspa::pod::Value::String(name)) => { profile_name = Some(name); },
|
||||||
|
(3, libspa::pod::Value::String(description)) => { profile_description = Some(description); },
|
||||||
|
(4, libspa::pod::Value::Int(priority)) => { profile_priority = Some(priority as usize); },
|
||||||
|
(5, libspa::pod::Value::Id(libspa::utils::Id(0))) => { profile_availibility = Some(DeviceAvailability::Unknown); },
|
||||||
|
(5, libspa::pod::Value::Id(libspa::utils::Id(1))) => { profile_availibility = Some(DeviceAvailability::Unavailable); },
|
||||||
|
(5, libspa::pod::Value::Id(libspa::utils::Id(2))) => { profile_availibility = Some(DeviceAvailability::Available); },
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Proper error handling
|
||||||
|
|
||||||
|
match param_type {
|
||||||
|
ParamType::Profile => {
|
||||||
|
active_profile.borrow_mut().replace(profile_id.unwrap());
|
||||||
|
},
|
||||||
|
ParamType::EnumProfile => {
|
||||||
|
let device_profile = DeviceProfile {
|
||||||
|
id: profile_id.unwrap(),
|
||||||
|
name: profile_name.unwrap(),
|
||||||
|
description: profile_description.unwrap(),
|
||||||
|
priority: profile_priority.unwrap(),
|
||||||
|
available: profile_availibility.unwrap(),
|
||||||
|
};
|
||||||
|
profiles.borrow_mut().insert(profile_id.unwrap(), device_profile);
|
||||||
|
},
|
||||||
|
_ => return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_info_discovered(
|
||||||
|
info: &pipewire::device::DeviceInfoRef,
|
||||||
|
name: Rc<RefCell<String>>,
|
||||||
|
properties: Rc<RefCell<HashMap<String, String>>>,
|
||||||
|
) {
|
||||||
|
info.props().unwrap().iter()
|
||||||
|
.for_each(|(k, v)| {
|
||||||
|
if k == "device.description" {
|
||||||
|
*name.borrow_mut() = v.to_string();
|
||||||
|
}
|
||||||
|
properties.borrow_mut().insert(k.to_string(), v.to_string());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalsManager {
|
||||||
|
globals: Vec<GlobalObject<Properties>>,
|
||||||
|
devices: HashMap<usize, Device>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalsManager {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
globals: Vec::new(),
|
||||||
|
devices: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_global(&mut self, global: GlobalObject<Properties>, registry: &Registry) {
|
||||||
|
self.globals.push(global);
|
||||||
|
|
||||||
|
let global_ref = self.globals.last().unwrap();
|
||||||
|
|
||||||
|
let props = match &global_ref.props {
|
||||||
|
Some(props) => props,
|
||||||
|
None => return
|
||||||
|
};
|
||||||
|
|
||||||
|
match props.get("media.class") {
|
||||||
|
Some("Audio/Device") => {}
|
||||||
|
_ => return
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_id = global_ref.id as usize;
|
||||||
|
|
||||||
|
let device = Device::new(registry.bind(global_ref).unwrap(), device_id);
|
||||||
|
|
||||||
|
self.devices.insert(device_id, device);
|
||||||
|
|
||||||
|
println!("ADDED DEVICE");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_device_names(&self) -> HashMap<usize, String> {
|
||||||
|
self.devices.iter()
|
||||||
|
.map(|(id, d)| (*id, d.name.borrow().clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_device_properties(&self, id: usize) -> HashMap<String, String> {
|
||||||
|
self.devices.get(&id)
|
||||||
|
.and_then(|d| Some(d.properties.borrow().clone()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn spawn_pipewire_thread() -> (JoinHandle<ExitCode>, pipewire::channel::Sender<Command>, flume::Receiver<Data>) {
|
||||||
|
|
||||||
|
let (tx, remote_rx) = flume::unbounded();
|
||||||
|
let (remote_tx, rx) = pipewire::channel::channel();
|
||||||
|
|
||||||
|
let pw_thread = thread::spawn(|| {
|
||||||
|
// Initialize PipeWire and run the main loop
|
||||||
|
// ...
|
||||||
|
let mainloop = MainLoop::new(None).expect("failed to get mail loop");
|
||||||
|
let context = Context::new(&mainloop).expect("failed to get context");
|
||||||
|
let core = context.connect(None).expect("failed to get core");
|
||||||
|
let registry = Rc::new(core.get_registry().expect("failed to get registry"));
|
||||||
|
|
||||||
|
let globals_manager = Rc::new(RefCell::new(GlobalsManager::new()));
|
||||||
|
|
||||||
|
|
||||||
|
let _listener = registry
|
||||||
|
.add_listener_local()
|
||||||
|
.global({
|
||||||
|
let registry_weak = Rc::downgrade(®istry);
|
||||||
|
let globals_manager_weak = Rc::downgrade(&globals_manager);
|
||||||
|
move |global|
|
||||||
|
{
|
||||||
|
|
||||||
|
let Some(registry) = registry_weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(globals_manager) = globals_manager_weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let owned_global = global.to_owned();
|
||||||
|
|
||||||
|
|
||||||
|
globals_manager.borrow_mut().add_global(owned_global, ®istry);
|
||||||
|
|
||||||
|
/*
|
||||||
|
if global.type_ == ObjectType::Port {
|
||||||
|
let props = global.props.as_ref().unwrap();
|
||||||
|
let port_name = props.get("port.name");
|
||||||
|
let port_alias = props.get("port.alias");
|
||||||
|
let object_path = props.get("object.path");
|
||||||
|
let format_dsp = props.get("format.dsp");
|
||||||
|
let audio_channel = props.get("audio.channel");
|
||||||
|
let port_id = props.get("port.id");
|
||||||
|
let port_direction = props.get("port.direction");
|
||||||
|
println!("Port: Name: {:?} Alias: {:?} Id: {:?} Direction: {:?} AudioChannel: {:?} Object Path: {:?} FormatDsp: {:?}",
|
||||||
|
port_name,
|
||||||
|
port_alias,
|
||||||
|
port_id,port_direction,audio_channel,object_path,format_dsp
|
||||||
|
);
|
||||||
|
} else if global.type_ == ObjectType::Device {
|
||||||
|
let props = global.props.as_ref().unwrap();
|
||||||
|
let device_name = props.get("device.name");
|
||||||
|
let device_nick = props.get("device.nick");
|
||||||
|
let device_description = props.get("device.description");
|
||||||
|
let device_api = props.get("device.api");
|
||||||
|
let media_class = props.get("media.class");
|
||||||
|
println!("Device: Name: {:?} Nick: {:?} Desc: {:?} Api: {:?} MediaClass: {:?}",
|
||||||
|
device_name, device_nick, device_description, device_api, media_class);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.register();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let _receiver = rx.attach(mainloop.loop_(), {
|
||||||
|
let mainloop = mainloop.clone();
|
||||||
|
{
|
||||||
|
let globals_manager_weak = Rc::downgrade(&globals_manager);
|
||||||
|
move |command| {
|
||||||
|
let Some(globals_manager) = globals_manager_weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Command::GetDeviceNames => {
|
||||||
|
let device_names = globals_manager.borrow().get_device_names();
|
||||||
|
tx.send(Data::DeviceNames(device_names)).unwrap();
|
||||||
|
}
|
||||||
|
Command::GetDeviceProperties(id) => {
|
||||||
|
let device_properties = globals_manager.borrow().get_device_properties(id);
|
||||||
|
tx.send(Data::DeviceProperties(id, device_properties)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calling the `destroy_global` method on the registry will destroy the object with the specified id on the remote.
|
||||||
|
// We don't have a specific object to destroy now, so this is commented out.
|
||||||
|
// registry.destroy_global(313).into_result()?;
|
||||||
|
|
||||||
|
|
||||||
|
mainloop.run();
|
||||||
|
return ExitCode::FAILURE; // not sure
|
||||||
|
});
|
||||||
|
|
||||||
|
(pw_thread, remote_tx, remote_rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod deserialize {
|
||||||
|
/// Taken from wiremix: https://github.com/tsowell/wiremix/blob/main/src/wirehose/deserialize.rs#L6
|
||||||
|
use libspa::pod::{deserialize::PodDeserializer, Object, Pod, Value};
|
||||||
|
pub fn deserialize(param: Option<&Pod>) -> Option<Object> {
|
||||||
|
param
|
||||||
|
.and_then(|pod| {
|
||||||
|
PodDeserializer::deserialize_any_from(pod.as_bytes()).ok()
|
||||||
|
})
|
||||||
|
.and_then(|(_, value)| match value {
|
||||||
|
Value::Object(obj) => Some(obj),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
377
src/state_manager.rs
Normal file
377
src/state_manager.rs
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
use std::cell::{Ref, RefCell, RefMut};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use adw::gdk::Device;
|
||||||
|
use crate::state_manager::simple_serde::SimpleSerde;
|
||||||
|
use crate::state_manager::structs::DeviceConfig;
|
||||||
|
|
||||||
|
trait StateBackend {
|
||||||
|
fn new(path: PathBuf) -> Self;
|
||||||
|
fn insert_option<T: SimpleSerde>(&mut self, key: &str, value: &Option<T>, namespace: &Option<String>) -> bool {
|
||||||
|
match value {
|
||||||
|
Some(v) => {
|
||||||
|
self.insert(key, v, namespace);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.remove(key, namespace);
|
||||||
|
false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn insert<T: SimpleSerde>(&mut self, key: &str, value: &T, namespace: &Option<String>);
|
||||||
|
fn remove(&mut self, key: &str, namespace: &Option<String>);
|
||||||
|
fn get<T: SimpleSerde>(&self, key: &str, namespace: &Option<String>) -> Option<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod backends {
|
||||||
|
use std::fmt::Debug;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use crate::state_manager::StateBackend;
|
||||||
|
use crate::state_manager::simple_serde::SimpleSerde;
|
||||||
|
|
||||||
|
pub(crate) struct SledBackend {
|
||||||
|
db: sled::Db,
|
||||||
|
db_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SledBackend {
|
||||||
|
fn resolve_key(key: &str, namespace: &Option<String>) -> String {
|
||||||
|
match namespace {
|
||||||
|
Some(ns) => format!("/{}/{}", ns, key),
|
||||||
|
None => format!("_{}", key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateBackend for SledBackend {
|
||||||
|
fn new(path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
db: sled::open(path.clone()).unwrap(),
|
||||||
|
db_path: path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert<T: SimpleSerde>(&mut self, key: &str, value: &T, namespace: &Option<String>) {
|
||||||
|
println!("insert {}", Self::resolve_key(key, namespace));
|
||||||
|
self.db.insert(Self::resolve_key(key, namespace), value.serialize()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&mut self, key: &str, namespace: &Option<String>) {
|
||||||
|
println!("remove {}", Self::resolve_key(key, namespace));
|
||||||
|
self.db.remove(Self::resolve_key(key, namespace)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get<T: SimpleSerde>(&self, key: &str, namespace: &Option<String>) -> Option<T> {
|
||||||
|
println!("get {}", Self::resolve_key(key, namespace));
|
||||||
|
self.db.get(Self::resolve_key(key, namespace))
|
||||||
|
.expect(format!("Error accessing db {}", self.db_path.display()).as_str())
|
||||||
|
.and_then(
|
||||||
|
|v: sled::IVec|
|
||||||
|
Some(SimpleSerde::deserialize(
|
||||||
|
Vec::from(v.as_ref())
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pub(crate) mod structs {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::state_manager::StateBackend;
|
||||||
|
|
||||||
|
pub(crate) struct DeviceConfig {
|
||||||
|
name: String,
|
||||||
|
identifying_properties: HashMap<String, String>,
|
||||||
|
available_profiles: Vec<String>,
|
||||||
|
selected_profile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceConfig {
|
||||||
|
pub(super) fn new<Backend: StateBackend>(
|
||||||
|
name: String,
|
||||||
|
identifying_properties: HashMap<String, String>,
|
||||||
|
available_profiles: Vec<String>,
|
||||||
|
selected_profile: Option<String>,
|
||||||
|
backend: &mut Backend,
|
||||||
|
namespace: &Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let instance = Self {
|
||||||
|
name,
|
||||||
|
identifying_properties,
|
||||||
|
available_profiles,
|
||||||
|
selected_profile
|
||||||
|
};
|
||||||
|
|
||||||
|
instance.save(backend, namespace).unwrap();
|
||||||
|
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
pub(super) fn load<Backend: StateBackend>(name: String, backend: &mut Backend, namespace: &Option<String>) -> Self {
|
||||||
|
let device_namespace_string = match namespace {
|
||||||
|
Some(ns) => ns.clone() + "/" + &name,
|
||||||
|
None => name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let properties_namespace = Some(device_namespace_string.clone() + "/" + "identifying_properties");
|
||||||
|
let device_namespace = Some(device_namespace_string);
|
||||||
|
|
||||||
|
let properties = backend.get("properties", &device_namespace).unwrap_or(Vec::new());
|
||||||
|
|
||||||
|
let identifying_properties = properties.into_iter()
|
||||||
|
.flat_map(|name: String| {
|
||||||
|
backend.get(&name, &properties_namespace)
|
||||||
|
.and_then(|v| Some((name, v)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
available_profiles: backend.get("available_profiles", &device_namespace).expect("available_profiles not found"),
|
||||||
|
selected_profile: backend.get("selected_profile", &device_namespace),
|
||||||
|
identifying_properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) fn save<Backend: StateBackend>(&self, backend: &mut Backend, namespace: &Option<String>) -> Result<(), String> {
|
||||||
|
let mut devices: Vec<String> = backend.get("devices", &namespace)
|
||||||
|
.unwrap_or(Vec::new());
|
||||||
|
|
||||||
|
if devices.contains(&self.name) {
|
||||||
|
return Err("Two devices with the same name cannot exist".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.push(self.name.clone());
|
||||||
|
|
||||||
|
backend.insert("devices", &devices, &namespace);
|
||||||
|
|
||||||
|
let device_namespace_string = match namespace {
|
||||||
|
Some(ns) => ns.clone() + "/" + &self.name,
|
||||||
|
None => self.name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let properties_namespace = Some(device_namespace_string.clone() + "/" + "identifying_properties");
|
||||||
|
let device_namespace = Some(device_namespace_string);
|
||||||
|
|
||||||
|
backend.insert("available_profiles", &self.available_profiles, &device_namespace);
|
||||||
|
backend.insert_option("selected_profile", &self.selected_profile, &device_namespace);
|
||||||
|
|
||||||
|
let property_names: Vec<String> = self.identifying_properties.iter()
|
||||||
|
.map(|(k, v)| k.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
backend.insert("properties", &property_names, &device_namespace);
|
||||||
|
|
||||||
|
for (name, value) in &self.identifying_properties {
|
||||||
|
backend.insert(name, value, &properties_namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_identifying_properties(&self) -> &HashMap<String, String> {
|
||||||
|
&self.identifying_properties
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_available_profiles(&self) -> &Vec<String> {
|
||||||
|
&self.available_profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_selected_profile(&self) -> &Option<String> {
|
||||||
|
&self.selected_profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod simple_serde {
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
pub(crate) trait SimpleSerde {
|
||||||
|
fn serialize(&self) -> Vec<u8>;
|
||||||
|
fn deserialize(bytes: Vec<u8>) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimpleSerde for u64 {
|
||||||
|
fn serialize(&self) -> Vec<u8> {
|
||||||
|
let mut res = [0u8; 8];
|
||||||
|
let mut num = *self;
|
||||||
|
|
||||||
|
for i in 0..8 {
|
||||||
|
res[i] = (num % 256) as u8;
|
||||||
|
num = num >> 8;
|
||||||
|
};
|
||||||
|
|
||||||
|
res.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize(bytes: Vec<u8>) -> Self {
|
||||||
|
let mut num: Self = 0;
|
||||||
|
|
||||||
|
for i in (0..8).rev() {
|
||||||
|
num = num << 8;
|
||||||
|
num = num + (bytes[i] as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SimpleSerde for String {
|
||||||
|
fn serialize(&self) -> Vec<u8> {
|
||||||
|
self.clone().into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize(bytes: Vec<u8>) -> Self {
|
||||||
|
String::from_utf8(bytes)
|
||||||
|
.expect("Couldn't deserialize string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> SimpleSerde for Vec<T>
|
||||||
|
where
|
||||||
|
T: SimpleSerde + Debug
|
||||||
|
{
|
||||||
|
fn serialize(&self) -> Vec<u8> {
|
||||||
|
let mut res = Vec::<u8>::new();
|
||||||
|
for el in self {
|
||||||
|
let mut el_ser = el.serialize();
|
||||||
|
let el_len = el_ser.len() as u64;
|
||||||
|
let mut len_ser = SimpleSerde::serialize(&el_len);
|
||||||
|
res.append(&mut len_ser);
|
||||||
|
res.append(&mut el_ser);
|
||||||
|
};
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize(bytes: Vec<u8>) -> Self {
|
||||||
|
let mut res = Vec::new();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < bytes.len() {
|
||||||
|
let el_len = <u64 as SimpleSerde>::deserialize(bytes[i..i+8].to_vec()) as usize;
|
||||||
|
i = i + 8;
|
||||||
|
res.push(
|
||||||
|
SimpleSerde::deserialize(bytes[i..i + el_len].to_vec())
|
||||||
|
);
|
||||||
|
i = i + el_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RootNamespaces {
|
||||||
|
None,
|
||||||
|
DeviceConfigs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RootNamespaces> for Option<String> {
|
||||||
|
fn from(value: RootNamespaces) -> Self {
|
||||||
|
match value {
|
||||||
|
RootNamespaces::None => None,
|
||||||
|
RootNamespaces::DeviceConfigs => Some("DeviceConfigs".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct StateManager<Backend: StateBackend> {
|
||||||
|
db: Backend,
|
||||||
|
device_configs: HashMap<String, DeviceConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Backend: StateBackend> StateManager<Backend> {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
let mut instance = Self {
|
||||||
|
db: Backend::new(crate::utils::CONFIG_DIR.clone().join("db")),
|
||||||
|
device_configs: HashMap::new(),
|
||||||
|
};
|
||||||
|
instance.load();
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load(&mut self) {
|
||||||
|
|
||||||
|
let device_config_names: Vec<String> = self.db.get("devices", &RootNamespaces::DeviceConfigs.into())
|
||||||
|
.unwrap_or(Vec::new());
|
||||||
|
|
||||||
|
println!("device_config_names: {:?}", device_config_names);
|
||||||
|
|
||||||
|
for device_config_name in device_config_names {
|
||||||
|
let device_config = DeviceConfig::load(device_config_name.clone(), &mut self.db, &RootNamespaces::DeviceConfigs.into());
|
||||||
|
self.device_configs.insert(device_config_name, device_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_device_config_names(&mut self) -> Vec<String> {
|
||||||
|
self.device_configs.iter()
|
||||||
|
.map(|(name, _)| name.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_device_config_properties(&mut self, name: &str) -> &HashMap<String, String> {
|
||||||
|
self.device_configs.get(name)
|
||||||
|
.unwrap()
|
||||||
|
.get_identifying_properties()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_device_config_profiles(&mut self, name: &str) -> &Vec<String> {
|
||||||
|
self.device_configs.get(name)
|
||||||
|
.unwrap()
|
||||||
|
.get_available_profiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_device_config_selected_profile(&mut self, name: &str) -> &Option<String> {
|
||||||
|
self.device_configs.get(name)
|
||||||
|
.unwrap()
|
||||||
|
.get_selected_profile()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn create_new_device_config(
|
||||||
|
&mut self,
|
||||||
|
name: String,
|
||||||
|
identifying_properties: HashMap<String, String>,
|
||||||
|
profiles: Vec<String>,
|
||||||
|
active_profile: Option<String>
|
||||||
|
) {
|
||||||
|
let config = DeviceConfig::new(
|
||||||
|
name.clone(),
|
||||||
|
identifying_properties,
|
||||||
|
profiles,
|
||||||
|
active_profile,
|
||||||
|
&mut self.db,
|
||||||
|
&RootNamespaces::DeviceConfigs.into()
|
||||||
|
);
|
||||||
|
|
||||||
|
self.device_configs.insert(name, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RcStateManager<Backend: StateBackend> {
|
||||||
|
state_manager: Rc<RefCell<StateManager<Backend>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Backend: StateBackend> Default for RcStateManager<Backend> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state_manager: Rc::new(RefCell::new(StateManager::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Backend: StateBackend> RcStateManager<Backend> {
|
||||||
|
pub(crate) fn borrow_mut(&self) -> RefMut<'_, StateManager<Backend>> {
|
||||||
|
self.state_manager.borrow_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn borrow(&self) -> Ref<'_, StateManager<Backend>> {
|
||||||
|
self.state_manager.borrow()
|
||||||
|
}
|
||||||
|
}
|
10
src/utils.rs
Normal file
10
src/utils.rs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use dirs::config_dir;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref CONFIG_DIR: PathBuf = {
|
||||||
|
let user_config_dir = config_dir().expect("Could not find user config dir");
|
||||||
|
user_config_dir.join("audio-device-manager")
|
||||||
|
};
|
||||||
|
}
|
@ -6,15 +6,24 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow {
|
|||||||
default-width: 800;
|
default-width: 800;
|
||||||
default-height: 600;
|
default-height: 600;
|
||||||
|
|
||||||
content: Adw.NavigationSplitView {
|
Adw.Breakpoint {
|
||||||
|
condition ("max-width: 500sp")
|
||||||
|
setters {
|
||||||
|
split_view.collapsed: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content: Adw.NavigationSplitView split_view {
|
||||||
|
min-sidebar-width: 200;
|
||||||
|
|
||||||
sidebar: Adw.NavigationPage {
|
sidebar: Adw.NavigationPage {
|
||||||
title: _("Sidebar");
|
title: _("Devices");
|
||||||
tag: "sidebar";
|
tag: "devices";
|
||||||
|
|
||||||
child: Adw.ToolbarView {
|
child: Adw.ToolbarView {
|
||||||
[top]
|
[top]
|
||||||
Adw.HeaderBar {
|
Adw.HeaderBar {
|
||||||
show-title: false;
|
show-title: true;
|
||||||
[start]
|
[start]
|
||||||
Gtk.ToggleButton {
|
Gtk.ToggleButton {
|
||||||
icon-name: "list-add-symbolic";
|
icon-name: "list-add-symbolic";
|
||||||
@ -33,14 +42,14 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
content: Adw.NavigationPage {
|
content: Adw.NavigationPage device_navigation_page {
|
||||||
title: _("Content");
|
title: _("");
|
||||||
tag: "content";
|
tag: "device navigation page";
|
||||||
|
|
||||||
child: Adw.ToolbarView {
|
child: Adw.ToolbarView {
|
||||||
[top]
|
[top]
|
||||||
Adw.HeaderBar {
|
Adw.HeaderBar {
|
||||||
show-title: false;
|
show-title: true;
|
||||||
[end]
|
[end]
|
||||||
MenuButton {
|
MenuButton {
|
||||||
primary: true;
|
primary: true;
|
||||||
@ -50,13 +59,11 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content: Adw.StatusPage {
|
content: Gtk.ScrolledWindow {
|
||||||
title: _("Content");
|
child: Adw.Clamp device_page_clamp {
|
||||||
|
maximum-size: 640;
|
||||||
|
|
||||||
LinkButton {
|
};
|
||||||
label: _("API Reference");
|
|
||||||
uri: "https://ada-baumann.de";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,70 +0,0 @@
|
|||||||
/* window.rs
|
|
||||||
*
|
|
||||||
* Copyright 2025 Ada
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation; either version 2 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program; if not, write to the Free Software Foundation, Inc.,
|
|
||||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
use gtk::prelude::*;
|
|
||||||
use adw::subclass::prelude::*;
|
|
||||||
use gtk::{gio, glib};
|
|
||||||
|
|
||||||
mod imp {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[derive(Debug, Default, gtk::CompositeTemplate)]
|
|
||||||
#[template(resource = "/de/AdaLouBaumann/AudioDeviceManager/window.ui")]
|
|
||||||
pub struct AudioDeviceManagerWindow {
|
|
||||||
// Template widgets
|
|
||||||
#[template_child]
|
|
||||||
pub devices_list: TemplateChild<gtk::ListBox>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[glib::object_subclass]
|
|
||||||
impl ObjectSubclass for AudioDeviceManagerWindow {
|
|
||||||
const NAME: &'static str = "AudioDeviceManagerWindow";
|
|
||||||
type Type = super::AudioDeviceManagerWindow;
|
|
||||||
type ParentType = adw::ApplicationWindow;
|
|
||||||
|
|
||||||
fn class_init(klass: &mut Self::Class) {
|
|
||||||
klass.bind_template();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
|
||||||
obj.init_template();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObjectImpl for AudioDeviceManagerWindow {}
|
|
||||||
impl WidgetImpl for AudioDeviceManagerWindow {}
|
|
||||||
impl WindowImpl for AudioDeviceManagerWindow {}
|
|
||||||
impl ApplicationWindowImpl for AudioDeviceManagerWindow {}
|
|
||||||
impl AdwApplicationWindowImpl for AudioDeviceManagerWindow {}
|
|
||||||
}
|
|
||||||
|
|
||||||
glib::wrapper! {
|
|
||||||
pub struct AudioDeviceManagerWindow(ObjectSubclass<imp::AudioDeviceManagerWindow>)
|
|
||||||
@extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, @implements gio::ActionGroup, gio::ActionMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioDeviceManagerWindow {
|
|
||||||
pub fn new<P: IsA<gtk::Application>>(application: &P) -> Self {
|
|
||||||
glib::Object::builder()
|
|
||||||
.property("application", application)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
248
src/window/mod.rs
Normal file
248
src/window/mod.rs
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
/* window.rs
|
||||||
|
*
|
||||||
|
* Copyright 2025 Ada
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 2 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along
|
||||||
|
* with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
mod popups;
|
||||||
|
mod subpages;
|
||||||
|
|
||||||
|
use std::cell::{Ref, RefCell, RefMut};
|
||||||
|
use std::cmp::min;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use adw::glib::clone;
|
||||||
|
use adw::prelude::{AlertDialogExt, AlertDialogExtManual, NavigationPageExt};
|
||||||
|
use adw::{PreferencesGroup, ResponseAppearance};
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use adw::subclass::prelude::*;
|
||||||
|
use gtk::{gio, glib};
|
||||||
|
use subpages::device_config;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProceduralChildManager {
|
||||||
|
device_configs_preferences_group: adw::PreferencesGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RcProceduralChildManager {
|
||||||
|
procedural_child_manager: Rc<RefCell<ProceduralChildManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RcProceduralChildManager {
|
||||||
|
fn borrow_mut(&self) -> RefMut<'_, ProceduralChildManager> {
|
||||||
|
self.procedural_child_manager.borrow_mut()
|
||||||
|
}
|
||||||
|
fn borrow(&self) -> Ref<'_, ProceduralChildManager> {
|
||||||
|
self.procedural_child_manager.borrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RcProceduralChildManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
procedural_child_manager: Rc::new(RefCell::new(ProceduralChildManager::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod imp {
|
||||||
|
use crate::pw_manager::PipewireManager;
|
||||||
|
use crate::state_manager::RcStateManager;
|
||||||
|
use crate::state_manager::backends::SledBackend;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default, gtk::CompositeTemplate)]
|
||||||
|
#[template(resource = "/de/AdaLouBaumann/AudioDeviceManager/window.ui")]
|
||||||
|
pub struct AudioDeviceManagerWindow {
|
||||||
|
pub pipewire_manager: PipewireManager,
|
||||||
|
pub state_manager: RcStateManager<SledBackend>,
|
||||||
|
|
||||||
|
pub procedural_child_manager: RcProceduralChildManager,
|
||||||
|
|
||||||
|
// Template widgets
|
||||||
|
#[template_child]
|
||||||
|
pub devices_list: TemplateChild<gtk::ListBox>,
|
||||||
|
#[template_child]
|
||||||
|
pub device_page_clamp: TemplateChild<adw::Clamp>,
|
||||||
|
#[template_child]
|
||||||
|
pub device_navigation_page: TemplateChild<adw::NavigationPage>,
|
||||||
|
#[template_child]
|
||||||
|
pub split_view: TemplateChild<adw::NavigationSplitView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[glib::object_subclass]
|
||||||
|
impl ObjectSubclass for AudioDeviceManagerWindow {
|
||||||
|
const NAME: &'static str = "AudioDeviceManagerWindow";
|
||||||
|
type Type = super::AudioDeviceManagerWindow;
|
||||||
|
type ParentType = adw::ApplicationWindow;
|
||||||
|
|
||||||
|
fn class_init(klass: &mut Self::Class) {
|
||||||
|
klass.bind_template();
|
||||||
|
|
||||||
|
// Create async action to create new device and add to action group "win"
|
||||||
|
klass.install_action_async(
|
||||||
|
"win.new-device",
|
||||||
|
None,
|
||||||
|
|window, _, _| async move {
|
||||||
|
popups::new_device::new_device(&window).await;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
klass.install_action_async(
|
||||||
|
"win.select-device-config",
|
||||||
|
None,
|
||||||
|
|window, _, _| async move {
|
||||||
|
popups::select_device_config::select_device_config(&window).await;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||||
|
obj.init_template();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectImpl for AudioDeviceManagerWindow {}
|
||||||
|
impl WidgetImpl for AudioDeviceManagerWindow {}
|
||||||
|
impl WindowImpl for AudioDeviceManagerWindow {}
|
||||||
|
impl ApplicationWindowImpl for AudioDeviceManagerWindow {}
|
||||||
|
impl AdwApplicationWindowImpl for AudioDeviceManagerWindow {}
|
||||||
|
}
|
||||||
|
|
||||||
|
glib::wrapper! {
|
||||||
|
pub struct AudioDeviceManagerWindow(ObjectSubclass<imp::AudioDeviceManagerWindow>)
|
||||||
|
@extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow,
|
||||||
|
@implements gio::ActionGroup, gio::ActionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDeviceManagerWindow {
|
||||||
|
pub fn new<P: IsA<gtk::Application>>(application: &P) -> Self {
|
||||||
|
let instance: AudioDeviceManagerWindow = glib::Object::builder()
|
||||||
|
.property("application", application)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
instance.bind_signals();
|
||||||
|
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_signals(&self) {
|
||||||
|
self.imp().devices_list.connect_row_activated(clone!(
|
||||||
|
#[weak(rename_to = window)]
|
||||||
|
self,
|
||||||
|
move |_, row| {
|
||||||
|
let label: gtk::Label = row
|
||||||
|
.child()
|
||||||
|
.and_downcast()
|
||||||
|
.expect("No Label in Row");
|
||||||
|
window.select_device(label.text().to_string());
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_device(&self, name: String) {
|
||||||
|
self.imp().device_navigation_page.set_title(&name);
|
||||||
|
|
||||||
|
let device_page = self.build_device_page(name);
|
||||||
|
|
||||||
|
self.imp().device_page_clamp.set_child(Some(&device_page));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_device_page(&self, name: String) -> adw::PreferencesPage {
|
||||||
|
let device_page = adw::PreferencesPage::builder()
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
self.init_device_configs_group();
|
||||||
|
|
||||||
|
device_page.add(&self.imp().procedural_child_manager.borrow().device_configs_preferences_group);
|
||||||
|
|
||||||
|
device_page
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_device_configs_group(&self) {
|
||||||
|
let add_profile_button = gtk::Button::builder()
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.css_classes(["flat"])
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
add_profile_button.set_action_name(Some("win.select-device-config"));
|
||||||
|
|
||||||
|
let preferences_group = &mut self.imp().procedural_child_manager.borrow_mut().device_configs_preferences_group;
|
||||||
|
|
||||||
|
*preferences_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Pipewire Device Configuration")
|
||||||
|
.description("Select audio profiles for specific pipewire devices")
|
||||||
|
.header_suffix(&add_profile_button)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/*
|
||||||
|
self.imp().devices_list.connect_row_activated(clone!(
|
||||||
|
#[weak(rename_to = window)]
|
||||||
|
self,
|
||||||
|
move |_, row| {
|
||||||
|
println!("Row selected {}, {:?}", row.index(), row);
|
||||||
|
let label: gtk::Label = row
|
||||||
|
.child()
|
||||||
|
.and_downcast()
|
||||||
|
.expect("No Label in Row");
|
||||||
|
window.select_device(label.text().to_string());
|
||||||
|
}
|
||||||
|
));
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO: Implement State management for Device page
|
||||||
|
for config_name in self.imp().state_manager.borrow_mut().get_device_config_names() {
|
||||||
|
let row = self.build_device_config_row(config_name);
|
||||||
|
preferences_group.add(&row);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_device_config(&self, config_name: &str) {
|
||||||
|
self.imp().procedural_child_manager.borrow_mut().device_configs_preferences_group.add(
|
||||||
|
&self.build_device_config_row(config_name.to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_device_config_row(&self, config_name: String) -> adw::ActionRow {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(config_name)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
row.connect_activated(
|
||||||
|
clone!(
|
||||||
|
#[weak(rename_to = window)]
|
||||||
|
self,
|
||||||
|
move |row| {
|
||||||
|
println!("Row selected {:?}", row);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
12
src/window/popups/mod.rs
Normal file
12
src/window/popups/mod.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use adw::glib::clone;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use adw::subclass::prelude::*;
|
||||||
|
use gtk::{glib, gio, ListBoxRow};
|
||||||
|
use crate::window::AudioDeviceManagerWindow;
|
||||||
|
|
||||||
|
pub(super) mod new_device;
|
||||||
|
pub(super) mod select_device_config;
|
||||||
|
mod new_device_config;
|
||||||
|
mod select_pipewire_device;
|
||||||
|
mod new_device_config_name;
|
74
src/window/popups/new_device.rs
Normal file
74
src/window/popups/new_device.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
use super::*;
|
||||||
|
use adw::gdk::pango;
|
||||||
|
use adw::ResponseAppearance;
|
||||||
|
|
||||||
|
pub(in crate::window) async fn new_device(main_window: &AudioDeviceManagerWindow) {
|
||||||
|
let entry = gtk::Entry::builder()
|
||||||
|
.placeholder_text("Name")
|
||||||
|
.activates_default(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let cancel_response = "cancel";
|
||||||
|
let create_response = "create";
|
||||||
|
|
||||||
|
// Create new dialog
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("New Device")
|
||||||
|
.close_response(cancel_response)
|
||||||
|
.default_response(create_response)
|
||||||
|
.extra_child(&entry)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
dialog.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
|
||||||
|
|
||||||
|
// Make the dialog button insensitive initially
|
||||||
|
dialog.set_response_enabled(create_response, false);
|
||||||
|
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
|
||||||
|
|
||||||
|
// Set entry's css class to "error", when there is no text in it
|
||||||
|
entry.connect_changed(clone!(
|
||||||
|
#[weak]
|
||||||
|
dialog,
|
||||||
|
move |entry| {
|
||||||
|
let text = entry.text();
|
||||||
|
let empty = text.is_empty();
|
||||||
|
|
||||||
|
dialog.set_response_enabled(create_response, !empty);
|
||||||
|
|
||||||
|
if empty {
|
||||||
|
entry.add_css_class("error");
|
||||||
|
} else {
|
||||||
|
entry.remove_css_class("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = dialog.choose_future(main_window).await;
|
||||||
|
|
||||||
|
// Return if the user chose 'cancel_response'
|
||||||
|
|
||||||
|
if response == cancel_response {
|
||||||
|
println!("Cancel");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if response == create_response {
|
||||||
|
let device = new_device_row(entry.text().to_string());
|
||||||
|
|
||||||
|
main_window.imp().devices_list.append(&device);
|
||||||
|
|
||||||
|
device.activate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_device_row(name: String) -> ListBoxRow {
|
||||||
|
let device_label = gtk::Label::builder()
|
||||||
|
.ellipsize(pango::EllipsizeMode::End)
|
||||||
|
.xalign(0.0)
|
||||||
|
.label(&name)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ListBoxRow::builder().child(&device_label).build()
|
||||||
|
}
|
51
src/window/popups/new_device_config.rs
Normal file
51
src/window/popups/new_device_config.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use super::*;
|
||||||
|
use adw::ResponseAppearance;
|
||||||
|
use crate::window::popups;
|
||||||
|
|
||||||
|
pub(in crate::window) async fn new_device_config(main_window: &AudioDeviceManagerWindow) {
|
||||||
|
let cancel_response = "cancel";
|
||||||
|
let create_from_template_response = "create-from-template";
|
||||||
|
let create_new_response = "create-new";
|
||||||
|
|
||||||
|
// Create new dialog
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("New Configuration")
|
||||||
|
.close_response(cancel_response)
|
||||||
|
.default_response(create_from_template_response)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
dialog.add_responses(&[
|
||||||
|
(cancel_response, "Cancel"),
|
||||||
|
(create_new_response, "Create From Scratch"),
|
||||||
|
(create_from_template_response, "Use Pipewire Device as Template"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Make the dialog button insensitive initially
|
||||||
|
dialog.set_response_appearance(create_from_template_response, ResponseAppearance::Suggested);
|
||||||
|
dialog.set_response_appearance(cancel_response, ResponseAppearance::Destructive);
|
||||||
|
|
||||||
|
let response = dialog.choose_future(main_window).await;
|
||||||
|
|
||||||
|
// Return if the user chose 'cancel_response'
|
||||||
|
if response == cancel_response {
|
||||||
|
println!("Cancel");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if response == create_from_template_response {
|
||||||
|
popups::select_pipewire_device::select_pipewire_device(main_window).await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if response == create_new_response {
|
||||||
|
popups::new_device_config_name::new_device_config_name(
|
||||||
|
main_window,
|
||||||
|
None,
|
||||||
|
HashMap::new(),
|
||||||
|
Vec::new(),
|
||||||
|
None
|
||||||
|
).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
93
src/window/popups/new_device_config_name.rs
Normal file
93
src/window/popups/new_device_config_name.rs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use adw::gdk::pango;
|
||||||
|
use adw::ResponseAppearance;
|
||||||
|
use crate::state_manager::structs::DeviceConfig;
|
||||||
|
use crate::window::subpages;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(in crate::window) async fn new_device_config_name(
|
||||||
|
main_window: &AudioDeviceManagerWindow,
|
||||||
|
name_suggestion: Option<String>,
|
||||||
|
properties: HashMap<String, String>,
|
||||||
|
profiles: Vec<String>,
|
||||||
|
selected_profile: Option<String>,
|
||||||
|
) {
|
||||||
|
let entry = gtk::Entry::builder()
|
||||||
|
.placeholder_text("Name")
|
||||||
|
.activates_default(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let cancel_response = "cancel";
|
||||||
|
let create_response = "create";
|
||||||
|
|
||||||
|
// Create new dialog
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Device Configuration Name")
|
||||||
|
.close_response(cancel_response)
|
||||||
|
.default_response(create_response)
|
||||||
|
.extra_child(&entry)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
dialog.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
|
||||||
|
|
||||||
|
// Make the dialog button insensitive initially
|
||||||
|
dialog.set_response_enabled(create_response, false);
|
||||||
|
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
|
||||||
|
|
||||||
|
let config_names = main_window.imp().state_manager.borrow_mut().get_device_config_names();
|
||||||
|
|
||||||
|
if let Some(init_name) = name_suggestion {
|
||||||
|
entry.set_text(&init_name);
|
||||||
|
if init_name.is_empty() || config_names.contains(&init_name) {
|
||||||
|
entry.add_css_class("error")
|
||||||
|
} else {
|
||||||
|
dialog.set_response_enabled(create_response, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set entry's css class to "error", when there is no text in it
|
||||||
|
entry.connect_changed(clone!(
|
||||||
|
#[weak]
|
||||||
|
dialog,
|
||||||
|
move |entry| {
|
||||||
|
let text = entry.text();
|
||||||
|
let empty = text.is_empty();
|
||||||
|
let exists = config_names.contains(&String::from(text));
|
||||||
|
|
||||||
|
let err = empty || exists;
|
||||||
|
|
||||||
|
dialog.set_response_enabled(create_response, !err);
|
||||||
|
|
||||||
|
if err {
|
||||||
|
entry.add_css_class("error");
|
||||||
|
} else {
|
||||||
|
entry.remove_css_class("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = dialog.choose_future(main_window).await;
|
||||||
|
|
||||||
|
// Return if the user chose 'cancel_response'
|
||||||
|
|
||||||
|
if response == cancel_response {
|
||||||
|
println!("Cancel");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if response == create_response {
|
||||||
|
let device_config_name = entry.text().to_string();
|
||||||
|
|
||||||
|
main_window.imp().state_manager.borrow_mut().create_new_device_config(
|
||||||
|
device_config_name.clone(),
|
||||||
|
properties,
|
||||||
|
profiles,
|
||||||
|
selected_profile
|
||||||
|
);
|
||||||
|
|
||||||
|
main_window.add_device_config(&device_config_name);
|
||||||
|
subpages::device_config::switch_to_device_config_window(main_window, device_config_name);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
178
src/window/popups/select_device_config.rs
Normal file
178
src/window/popups/select_device_config.rs
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::cmp::min;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use super::*;
|
||||||
|
use crate::window::popups;
|
||||||
|
|
||||||
|
use adw::ResponseAppearance;
|
||||||
|
|
||||||
|
pub(in crate::window) async fn select_device_config(main_window: &AudioDeviceManagerWindow) {
|
||||||
|
let device_config_names = main_window.imp().state_manager.borrow_mut().get_device_config_names();
|
||||||
|
|
||||||
|
if device_config_names.is_empty() {
|
||||||
|
popups::new_device_config::new_device_config(main_window).await;
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel_response = "cancel";
|
||||||
|
let create_new_response = "create-new";
|
||||||
|
let duplicate_response = "duplicate";
|
||||||
|
let select_response = "select";
|
||||||
|
|
||||||
|
let dialog_body = gtk::Box::builder()
|
||||||
|
.spacing(12)
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let entry = gtk::Entry::builder()
|
||||||
|
.placeholder_text("search")
|
||||||
|
.activates_default(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let device_configs_list = gtk::ListBox::builder()
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let selected_config_name = Rc::new(RefCell::new(String::new()));
|
||||||
|
|
||||||
|
let mut device_configs_list_len = 0;
|
||||||
|
for device_config_row in build_device_config_rows(main_window, &selected_config_name) {
|
||||||
|
device_configs_list_len += 1;
|
||||||
|
device_configs_list.append(&device_config_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialog_body_clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(400)
|
||||||
|
.tightening_threshold(300)
|
||||||
|
.child(&dialog_body)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let device_configs_list_scrollable = gtk::ScrolledWindow::builder()
|
||||||
|
.child(&device_configs_list)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
println!("{}", device_configs_list.height());
|
||||||
|
|
||||||
|
dialog_body.append(&entry);
|
||||||
|
dialog_body.append(&device_configs_list_scrollable);
|
||||||
|
|
||||||
|
// Create new dialog
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Select Configuration")
|
||||||
|
.close_response(cancel_response)
|
||||||
|
.default_response(select_response)
|
||||||
|
.extra_child(&dialog_body_clamp)
|
||||||
|
.height_request(
|
||||||
|
min(
|
||||||
|
min(809, main_window.height()),
|
||||||
|
383 + 55 * device_configs_list_len
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.width_request(min(500, main_window.width()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
dialog.add_responses(&[(cancel_response, "Cancel"), (create_new_response, "Create New"), (duplicate_response, "Duplicate"), (select_response, "Select")]);
|
||||||
|
|
||||||
|
// Make the dialog button insensitive initially
|
||||||
|
dialog.set_response_enabled(select_response, false);
|
||||||
|
dialog.set_response_appearance(select_response, ResponseAppearance::Suggested);
|
||||||
|
dialog.set_response_enabled(duplicate_response, false);
|
||||||
|
dialog.set_response_appearance(cancel_response, ResponseAppearance::Destructive);
|
||||||
|
|
||||||
|
device_configs_list.connect_row_selected(clone!(
|
||||||
|
#[weak]
|
||||||
|
dialog,
|
||||||
|
move |list_box, row_option| {
|
||||||
|
match row_option {
|
||||||
|
Some(row) => {
|
||||||
|
dialog.set_response_enabled(select_response, true);
|
||||||
|
dialog.set_response_enabled(duplicate_response, true);
|
||||||
|
row.activate();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
dialog.set_response_enabled(select_response, false);
|
||||||
|
dialog.set_response_enabled(duplicate_response, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
|
||||||
|
let response = dialog.choose_future(main_window).await;
|
||||||
|
|
||||||
|
// Return if the user chose 'cancel_response'
|
||||||
|
|
||||||
|
if response == cancel_response {
|
||||||
|
println!("Cancel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if response == create_new_response {
|
||||||
|
popups::new_device_config::new_device_config(main_window).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected_row = device_configs_list.selected_row().unwrap();
|
||||||
|
|
||||||
|
let config_name = selected_config_name.borrow().clone();
|
||||||
|
|
||||||
|
if response == duplicate_response {
|
||||||
|
let mut state_manager = main_window.imp().state_manager.borrow_mut();
|
||||||
|
|
||||||
|
let properties = state_manager.get_device_config_properties(&config_name).clone();
|
||||||
|
let profiles = state_manager.get_device_config_profiles(&config_name).clone();
|
||||||
|
let selected_profile = state_manager.get_device_config_selected_profile(&config_name).clone();
|
||||||
|
|
||||||
|
drop(state_manager);
|
||||||
|
|
||||||
|
popups::new_device_config_name::new_device_config_name(
|
||||||
|
main_window,
|
||||||
|
Some(selected_config_name.borrow().clone()),
|
||||||
|
properties,
|
||||||
|
profiles,
|
||||||
|
selected_profile
|
||||||
|
).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if response == select_response {
|
||||||
|
main_window.add_device_config(&config_name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_device_config_rows(
|
||||||
|
main_window: &AudioDeviceManagerWindow,
|
||||||
|
selected_config_name: &Rc<RefCell<String>>,
|
||||||
|
) -> Vec<adw::ActionRow> {
|
||||||
|
let mut row_vec = Vec::new();
|
||||||
|
|
||||||
|
let device_names = main_window.imp().state_manager.borrow_mut().get_device_config_names();
|
||||||
|
|
||||||
|
|
||||||
|
for config_name in device_names {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(config_name.clone())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
row.connect_activate(clone!(
|
||||||
|
#[weak]
|
||||||
|
selected_config_name,
|
||||||
|
move |row| {
|
||||||
|
*selected_config_name.borrow_mut() = config_name.clone();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
row_vec.push(
|
||||||
|
row
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
row_vec
|
||||||
|
}
|
166
src/window/popups/select_pipewire_device.rs
Normal file
166
src/window/popups/select_pipewire_device.rs
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use super::*;
|
||||||
|
use std::cmp::min;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use adw::ResponseAppearance;
|
||||||
|
use crate::window::popups;
|
||||||
|
use crate::window::subpages::device_config;
|
||||||
|
pub(in crate::window) async fn select_pipewire_device(main_window: &AudioDeviceManagerWindow) {
|
||||||
|
|
||||||
|
let cancel_response = "cancel";
|
||||||
|
let select_response = "select";
|
||||||
|
|
||||||
|
let dialog_body = gtk::Box::builder()
|
||||||
|
.spacing(12)
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let entry = gtk::Entry::builder()
|
||||||
|
.placeholder_text("search")
|
||||||
|
.activates_default(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let device_list = gtk::ListBox::builder()
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let selected_device_id = Rc::new(RefCell::new(usize::MAX));
|
||||||
|
|
||||||
|
let mut device_list_len = 0;
|
||||||
|
for device_row in build_pipewire_device_rows(main_window, &selected_device_id) {
|
||||||
|
device_list_len += 1;
|
||||||
|
device_list.append(&device_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialog_body_clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(400)
|
||||||
|
.tightening_threshold(300)
|
||||||
|
.child(&dialog_body)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let device_list_scrollable = gtk::ScrolledWindow::builder()
|
||||||
|
.child(&device_list)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
println!("{}", device_list.height());
|
||||||
|
|
||||||
|
dialog_body.append(&entry);
|
||||||
|
dialog_body.append(&device_list_scrollable);
|
||||||
|
|
||||||
|
// Create new dialog
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Select Pipewire Device")
|
||||||
|
.close_response(cancel_response)
|
||||||
|
.default_response(select_response)
|
||||||
|
.extra_child(&dialog_body_clamp)
|
||||||
|
.height_request(
|
||||||
|
min(
|
||||||
|
min(809, main_window.height()),
|
||||||
|
215 + 55 * device_list_len
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.width_request(min(500, main_window.width()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
dialog.add_responses(&[(cancel_response, "Cancel"), (select_response, "Select")]);
|
||||||
|
|
||||||
|
// Make the dialog button insensitive initially
|
||||||
|
dialog.set_response_enabled(select_response, false);
|
||||||
|
dialog.set_response_appearance(select_response, ResponseAppearance::Suggested);
|
||||||
|
dialog.set_response_appearance(cancel_response, ResponseAppearance::Destructive);
|
||||||
|
|
||||||
|
device_list.connect_row_selected(clone!(
|
||||||
|
#[weak]
|
||||||
|
dialog,
|
||||||
|
move |list_box, row_option| {
|
||||||
|
match row_option {
|
||||||
|
Some(row) => {
|
||||||
|
dialog.set_response_enabled(select_response, true);
|
||||||
|
row.activate();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
dialog.set_response_enabled(select_response, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
// Set entry's css class to "error", when there is no text in it
|
||||||
|
entry.connect_changed(clone!(
|
||||||
|
#[weak]
|
||||||
|
dialog,
|
||||||
|
move |entry| {
|
||||||
|
let text = entry.text();
|
||||||
|
let empty = text.is_empty();
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = dialog.choose_future(main_window).await;
|
||||||
|
|
||||||
|
// Return if the user chose 'cancel_response'
|
||||||
|
|
||||||
|
if response == cancel_response {
|
||||||
|
println!("Cancel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if response == select_response {
|
||||||
|
let device_properties = main_window.imp().pipewire_manager
|
||||||
|
.get_device_properties(
|
||||||
|
selected_device_id.borrow().clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut identifying_properties = HashMap::new();
|
||||||
|
|
||||||
|
identifying_properties.insert(
|
||||||
|
"device.name".to_string(),
|
||||||
|
device_properties.get("device.name").unwrap().clone()
|
||||||
|
);
|
||||||
|
|
||||||
|
popups::new_device_config_name::new_device_config_name(
|
||||||
|
main_window,
|
||||||
|
Some(device_properties.get("device.description").unwrap().clone()),
|
||||||
|
identifying_properties,
|
||||||
|
Vec::new(),
|
||||||
|
None
|
||||||
|
).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_pipewire_device_rows(
|
||||||
|
main_window: &AudioDeviceManagerWindow,
|
||||||
|
selected_device_id: &Rc<RefCell<usize>>
|
||||||
|
) -> Vec<adw::ActionRow> {
|
||||||
|
let mut row_vec = Vec::new();
|
||||||
|
|
||||||
|
let device_names = main_window.imp().pipewire_manager.get_device_names();
|
||||||
|
|
||||||
|
println!("{:?}", device_names);
|
||||||
|
|
||||||
|
for (device_id, device_name) in device_names {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(device_name)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
row.connect_activate(clone!(
|
||||||
|
#[weak]
|
||||||
|
selected_device_id,
|
||||||
|
move |row| {
|
||||||
|
*selected_device_id.borrow_mut() = device_id;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
row_vec.push(
|
||||||
|
row
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
row_vec
|
||||||
|
}
|
99
src/window/subpages/device_config.rs
Normal file
99
src/window/subpages/device_config.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
use adw::gdk::pango::EllipsizeMode;
|
||||||
|
use crate::window::AudioDeviceManagerWindow;
|
||||||
|
use adw::prelude::*;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use adw::subclass::prelude::*;
|
||||||
|
use adw::glib::clone;
|
||||||
|
use gtk::{glib, gio};
|
||||||
|
use crate::state_manager::structs::DeviceConfig;
|
||||||
|
|
||||||
|
pub(in crate::window) fn switch_to_device_config_window(main_window: &AudioDeviceManagerWindow, device_config_name: String) {
|
||||||
|
let device_config_window = build_device_config_window(main_window, device_config_name);
|
||||||
|
|
||||||
|
main_window.imp().split_view.set_content(Some(&device_config_window));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_device_config_window(main_window: &AudioDeviceManagerWindow, device_config_name: String) -> adw::NavigationPage {
|
||||||
|
|
||||||
|
let (navigation_page, clamp) = build_page_base(main_window, device_config_name);
|
||||||
|
|
||||||
|
navigation_page
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
[top]
|
||||||
|
Adw.HeaderBar {
|
||||||
|
show-title: true;
|
||||||
|
[end]
|
||||||
|
MenuButton {
|
||||||
|
primary: true;
|
||||||
|
icon-name: "open-menu-symbolic";
|
||||||
|
tooltip-text: _("Main Menu");
|
||||||
|
menu-model: primary_menu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content: Gtk.ScrolledWindow {
|
||||||
|
child: Adw.Clamp device_page_clamp {
|
||||||
|
maximum-size: 640;
|
||||||
|
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
fn build_page_base(main_window: &AudioDeviceManagerWindow, device_name: String) -> (adw::NavigationPage, adw::Clamp) {
|
||||||
|
let back_button= gtk::Button::builder()
|
||||||
|
.icon_name("go-previous")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
back_button.connect_clicked(clone!(
|
||||||
|
#[weak]
|
||||||
|
main_window,
|
||||||
|
move |button| {
|
||||||
|
main_window.imp().split_view.set_content(
|
||||||
|
Some(
|
||||||
|
&main_window.imp().device_navigation_page.get()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(640)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let scrolled_window = gtk::ScrolledWindow::builder()
|
||||||
|
.child(&clamp)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let header_bar = gtk::HeaderBar::builder()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
header_bar.pack_start(&back_button);
|
||||||
|
|
||||||
|
let title_label = gtk::Label::builder()
|
||||||
|
.label(&device_name)
|
||||||
|
.single_line_mode(true)
|
||||||
|
.ellipsize(EllipsizeMode::End)
|
||||||
|
.width_chars(5)
|
||||||
|
.css_classes(["title"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
header_bar.set_title_widget(Some(&title_label));
|
||||||
|
|
||||||
|
let toolbar_view = adw::ToolbarView::builder()
|
||||||
|
.content(&scrolled_window)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
toolbar_view.add_top_bar(&header_bar);
|
||||||
|
|
||||||
|
let navigation_page = adw::NavigationPage::builder()
|
||||||
|
.child(&toolbar_view)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
println!("device name: {}", device_name);
|
||||||
|
|
||||||
|
(navigation_page, clamp)
|
||||||
|
}
|
1
src/window/subpages/mod.rs
Normal file
1
src/window/subpages/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub(super) mod device_config;
|
Loading…
Reference in New Issue
Block a user