mirror of
https://github.com/JakeStanger/ironbar.git
synced 2024-09-11 06:25:42 +03:00
chore: initial commit
This commit is contained in:
commit
e37d8f2b14
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
15
.idea/git_toolbox_prj.xml
Normal file
15
.idea/git_toolbox_prj.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GitToolBoxProjectSettings">
|
||||||
|
<option name="commitMessageIssueKeyValidationOverride">
|
||||||
|
<BoolValueOverride>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</BoolValueOverride>
|
||||||
|
</option>
|
||||||
|
<option name="commitMessageValidationEnabledOverride">
|
||||||
|
<BoolValueOverride>
|
||||||
|
<option name="enabled" value="true" />
|
||||||
|
</BoolValueOverride>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
6
.idea/inspectionProfiles/Project_Default.xml
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
6
.idea/inspectionProfiles/profiles_settings.xml
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
12
.idea/ironbar.iml
Normal file
12
.idea/ironbar.iml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="module" module-name="ironbar.wiki" />
|
||||||
|
</component>
|
||||||
|
</module>
|
4
.idea/misc.xml
Normal file
4
.idea/misc.xml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (dbus-test)" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
9
.idea/modules.xml
Normal file
9
.idea/modules.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/ironbar.iml" filepath="$PROJECT_DIR$/.idea/ironbar.iml" />
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/../ironbar.wiki/.idea/ironbar.wiki.iml" filepath="$PROJECT_DIR$/../ironbar.wiki/.idea/ironbar.wiki.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
19
.idea/runConfigurations/Clippy.xml
Normal file
19
.idea/runConfigurations/Clippy.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Clippy" type="CargoCommandRunConfiguration" factoryName="Cargo Command" nameIsGenerated="true">
|
||||||
|
<option name="command" value="clippy -- -W clippy::pedantic -W clippy::nursery -W clippy::unwrap_used -W clippy::expect_used" />
|
||||||
|
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||||
|
<option name="channel" value="DEFAULT" />
|
||||||
|
<option name="requiredFeatures" value="false" />
|
||||||
|
<option name="allFeatures" value="false" />
|
||||||
|
<option name="emulateTerminal" value="false" />
|
||||||
|
<option name="withSudo" value="false" />
|
||||||
|
<option name="buildTarget" value="REMOTE" />
|
||||||
|
<option name="backtrace" value="SHORT" />
|
||||||
|
<envs />
|
||||||
|
<option name="isRedirectInput" value="false" />
|
||||||
|
<option name="redirectInputPath" value="" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
21
.idea/runConfigurations/Run.xml
Normal file
21
.idea/runConfigurations/Run.xml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Run" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||||
|
<option name="command" value="run --package ironbar --bin ironbar" />
|
||||||
|
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||||
|
<option name="channel" value="DEFAULT" />
|
||||||
|
<option name="requiredFeatures" value="true" />
|
||||||
|
<option name="allFeatures" value="false" />
|
||||||
|
<option name="emulateTerminal" value="false" />
|
||||||
|
<option name="withSudo" value="false" />
|
||||||
|
<option name="buildTarget" value="REMOTE" />
|
||||||
|
<option name="backtrace" value="SHORT" />
|
||||||
|
<envs>
|
||||||
|
<env name="PATH" value="/usr/local/bin:/usr/bin:$USER_HOME$/.local/share/npm/bin" />
|
||||||
|
</envs>
|
||||||
|
<option name="isRedirectInput" value="false" />
|
||||||
|
<option name="redirectInputPath" value="" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
21
.idea/runConfigurations/Run__Debug_.xml
Normal file
21
.idea/runConfigurations/Run__Debug_.xml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Run (Debug)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
|
||||||
|
<option name="command" value="run --package ironbar --bin ironbar" />
|
||||||
|
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
|
||||||
|
<option name="channel" value="DEFAULT" />
|
||||||
|
<option name="requiredFeatures" value="true" />
|
||||||
|
<option name="allFeatures" value="false" />
|
||||||
|
<option name="emulateTerminal" value="false" />
|
||||||
|
<option name="withSudo" value="false" />
|
||||||
|
<option name="buildTarget" value="REMOTE" />
|
||||||
|
<option name="backtrace" value="SHORT" />
|
||||||
|
<envs>
|
||||||
|
<env name="GTK_DEBUG" value="interactive" />
|
||||||
|
</envs>
|
||||||
|
<option name="isRedirectInput" value="false" />
|
||||||
|
<option name="redirectInputPath" value="" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="CARGO.BUILD_TASK_PROVIDER" enabled="true" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
2355
Cargo.lock
generated
Normal file
2355
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
Normal file
27
Cargo.toml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "ironbar"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
gtk = "0.15.5"
|
||||||
|
gtk-layer-shell = "0.4.1"
|
||||||
|
glib = "0.15.12"
|
||||||
|
stray = "0.1.1"
|
||||||
|
tokio = { version = "1.20.1", features = ["macros", "rt-multi-thread", "time"] }
|
||||||
|
futures-util = "0.3.21"
|
||||||
|
chrono = "0.4.19"
|
||||||
|
serde = { version = "1.0.141", features = ["derive"] }
|
||||||
|
serde_json = "1.0.82"
|
||||||
|
serde_yaml = "0.9.4"
|
||||||
|
toml = "0.5.9"
|
||||||
|
cornfig = "0.2.0"
|
||||||
|
mpd_client = "0.7.5"
|
||||||
|
regex = "1.6.0"
|
||||||
|
ksway = "0.1.0"
|
||||||
|
sysinfo = "0.25.1"
|
||||||
|
dirs = "4.0.0"
|
||||||
|
walkdir = "2.3.2"
|
||||||
|
notify = "4.0.17"
|
114
README.md
Normal file
114
README.md
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# Ironbar
|
||||||
|
|
||||||
|
Ironbar is a customisable and feature-rich bar targeting the Sway compositor, written in Rust.
|
||||||
|
It uses GTK3 and gtk-layer-shell.
|
||||||
|
|
||||||
|
The bar can be styled to your liking using CSS and hot-loads style changes.
|
||||||
|
For information and examples on styling please see the [wiki](https://github.com/JakeStanger/ironbar/wiki).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install with cargo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install ironbar
|
||||||
|
```
|
||||||
|
|
||||||
|
Then just run with `ironbar`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
By default, running will get you a blank bar. To start, you will need a configuration file in `.config/ironbar`.
|
||||||
|
Ironbar supports a range of file formats so pick your favourite:
|
||||||
|
|
||||||
|
- JSON
|
||||||
|
- TOML
|
||||||
|
- YAML
|
||||||
|
- [Corn](https://github.com/jakestanger/corn) (Experimental. JSON/Nix like config lang. Supports variables.)
|
||||||
|
|
||||||
|
For a full list of modules and their configuration options, please see the [wiki](https://github.com/JakeStanger/ironbar/wiki).
|
||||||
|
|
||||||
|
There are two different approaches to configuring the bar:
|
||||||
|
|
||||||
|
### Same configuration across all monitors
|
||||||
|
|
||||||
|
> If you have a single monitor, or want the same bar to appear across each of your monitors, choose this option.
|
||||||
|
|
||||||
|
The top-level object takes any combination of `left`, `center`, and `right`. These each take a list of modules and determine where they are positioned.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"left": [],
|
||||||
|
"center": [],
|
||||||
|
"right": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Different configuration across monitors
|
||||||
|
|
||||||
|
> If you have multiple monitors and want them to differ in configuration, choose this option.
|
||||||
|
|
||||||
|
The top-level object takes a single key called `monitors`. This takes an array where each entry is an object with a configuration for each monitor.
|
||||||
|
The monitor's config object takes any combination of `left`, `center`, and `right`. These each take a list of modules and determine where they are positioned.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"monitors": [
|
||||||
|
{
|
||||||
|
"left": [],
|
||||||
|
"center": [],
|
||||||
|
"right": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": [],
|
||||||
|
"center": [],
|
||||||
|
"right": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
To get started, create a stylesheet at `.config/ironbar/style.css`. Changes will be hot-reloaded every time you save the file.
|
||||||
|
|
||||||
|
An example stylesheet and information about each module's styling information can be found on the [wiki](https://github.com/JakeStanger/ironbar/wiki).
|
||||||
|
|
||||||
|
## Project Status
|
||||||
|
|
||||||
|
This project is in very early stages:
|
||||||
|
|
||||||
|
- Error handling is barely implemented - expect crashes
|
||||||
|
- There will be bugs!
|
||||||
|
- Lots of modules need more configuration options
|
||||||
|
- There's room for lots of modules
|
||||||
|
- The code is messy and quite prototypal in places
|
||||||
|
- Config options aren't set in stone - expect breaking changes
|
||||||
|
- Documentation is probably missing in lots of places
|
||||||
|
|
||||||
|
That said, it will be *actively developed* as I am using it on my daily driver.
|
||||||
|
Bugs will be fixed, features will be added, code will be refactored.
|
||||||
|
|
||||||
|
## Contribution Guidelines
|
||||||
|
|
||||||
|
I welcome contributions of any kind with open arms. That said, please do stick to some basics:
|
||||||
|
|
||||||
|
- For code contributions:
|
||||||
|
- Fix any `cargo clippy` warnings, using at least the default configuration.
|
||||||
|
- Make sure your code is formatted using `cargo fmt`.
|
||||||
|
- Keep any documentation up to date.
|
||||||
|
- I won't enforce it, but preferably stick to [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) messages.
|
||||||
|
|
||||||
|
|
||||||
|
- For PRs:
|
||||||
|
- Please open an issue or discussion beforehand.
|
||||||
|
I'll accept most contributions, but it's best to make sure you're not working on something that won't get accepted :)
|
||||||
|
|
||||||
|
|
||||||
|
- For issues:
|
||||||
|
- Please provide as much information as you can - share your config, any logs, steps to reproduce...
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- [Waybar](https://github.com/Alexays/Waybar) - A lot of the initial inspiration, and a pretty great bar.
|
||||||
|
- [Rustbar](https://github.com/zeroeightysix/rustbar) - Served as a good demo for writing a basic GTK bar in Rust
|
137
src/bar.rs
Normal file
137
src/bar.rs
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
use crate::config::ModuleConfig;
|
||||||
|
use crate::modules::{Module, ModuleInfo, ModuleLocation};
|
||||||
|
use crate::Config;
|
||||||
|
use gtk::gdk::Monitor;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Application, ApplicationWindow, Orientation};
|
||||||
|
|
||||||
|
pub fn create_bar(app: &Application, monitor: &Monitor, config: Config) {
|
||||||
|
let win = ApplicationWindow::builder().application(app).build();
|
||||||
|
|
||||||
|
setup_layer_shell(&win, monitor);
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(Orientation::Horizontal)
|
||||||
|
.spacing(0)
|
||||||
|
.hexpand(false)
|
||||||
|
.height_request(42)
|
||||||
|
.name("bar")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let left = gtk::Box::builder().spacing(0).name("left").build();
|
||||||
|
let center = gtk::Box::builder().spacing(0).name("center").build();
|
||||||
|
let right = gtk::Box::builder().spacing(0).name("right").build();
|
||||||
|
|
||||||
|
content.style_context().add_class("container");
|
||||||
|
left.style_context().add_class("container");
|
||||||
|
center.style_context().add_class("container");
|
||||||
|
right.style_context().add_class("container");
|
||||||
|
|
||||||
|
content.add(&left);
|
||||||
|
content.set_center_widget(Some(¢er));
|
||||||
|
content.pack_end(&right, false, false, 0);
|
||||||
|
|
||||||
|
load_modules(&left, ¢er, &right, app, config);
|
||||||
|
win.add(&content);
|
||||||
|
|
||||||
|
win.connect_destroy_event(|_, _| {
|
||||||
|
gtk::main_quit();
|
||||||
|
Inhibit(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
win.show_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_modules(
|
||||||
|
left: >k::Box,
|
||||||
|
center: >k::Box,
|
||||||
|
right: >k::Box,
|
||||||
|
app: &Application,
|
||||||
|
config: Config,
|
||||||
|
) {
|
||||||
|
if let Some(modules) = config.left {
|
||||||
|
let info = ModuleInfo {
|
||||||
|
app,
|
||||||
|
location: ModuleLocation::Left,
|
||||||
|
};
|
||||||
|
|
||||||
|
add_modules(left, modules, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(modules) = config.center {
|
||||||
|
let info = ModuleInfo {
|
||||||
|
app,
|
||||||
|
location: ModuleLocation::Center,
|
||||||
|
};
|
||||||
|
|
||||||
|
add_modules(center, modules, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(modules) = config.right {
|
||||||
|
let info = ModuleInfo {
|
||||||
|
app,
|
||||||
|
location: ModuleLocation::Right,
|
||||||
|
};
|
||||||
|
|
||||||
|
add_modules(right, modules, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_modules(content: >k::Box, modules: Vec<ModuleConfig>, info: ModuleInfo) {
|
||||||
|
for config in modules {
|
||||||
|
match config {
|
||||||
|
ModuleConfig::Clock(module) => {
|
||||||
|
let widget = module.into_widget(&info);
|
||||||
|
widget.set_widget_name("clock");
|
||||||
|
content.add(&widget);
|
||||||
|
}
|
||||||
|
ModuleConfig::Mpd(module) => {
|
||||||
|
let widget = module.into_widget(&info);
|
||||||
|
widget.set_widget_name("mpd");
|
||||||
|
content.add(&widget);
|
||||||
|
}
|
||||||
|
ModuleConfig::Tray(module) => {
|
||||||
|
let widget = module.into_widget(&info);
|
||||||
|
widget.set_widget_name("tray");
|
||||||
|
content.add(&widget);
|
||||||
|
}
|
||||||
|
ModuleConfig::Workspaces(module) => {
|
||||||
|
let widget = module.into_widget(&info);
|
||||||
|
widget.set_widget_name("workspaces");
|
||||||
|
content.add(&widget);
|
||||||
|
}
|
||||||
|
ModuleConfig::SysInfo(module) => {
|
||||||
|
let widget = module.into_widget(&info);
|
||||||
|
widget.set_widget_name("sysinfo");
|
||||||
|
content.add(&widget);
|
||||||
|
}
|
||||||
|
ModuleConfig::Launcher(module) => {
|
||||||
|
let widget = module.into_widget(&info);
|
||||||
|
widget.set_widget_name("launcher");
|
||||||
|
content.add(&widget);
|
||||||
|
}
|
||||||
|
ModuleConfig::Script(module) => {
|
||||||
|
let widget = module.into_widget(&info);
|
||||||
|
widget.set_widget_name("script");
|
||||||
|
content.add(&widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_layer_shell(win: &ApplicationWindow, monitor: &Monitor) {
|
||||||
|
gtk_layer_shell::init_for_window(win);
|
||||||
|
gtk_layer_shell::set_monitor(win, monitor);
|
||||||
|
gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top);
|
||||||
|
gtk_layer_shell::auto_exclusive_zone_enable(win);
|
||||||
|
|
||||||
|
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Top, 0);
|
||||||
|
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Bottom, 0);
|
||||||
|
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Left, 0);
|
||||||
|
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Right, 0);
|
||||||
|
|
||||||
|
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Top, false);
|
||||||
|
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Bottom, true);
|
||||||
|
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Left, true);
|
||||||
|
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Right, true);
|
||||||
|
}
|
131
src/collection.rs
Normal file
131
src/collection.rs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::slice::{Iter, IterMut};
|
||||||
|
|
||||||
|
/// An ordered map.
|
||||||
|
/// Internally this is just two vectors -
|
||||||
|
/// one for keys and one for values.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Collection<TKey, TData> {
|
||||||
|
keys: Vec<TKey>,
|
||||||
|
values: Vec<TData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<TKey: PartialEq, TData> Collection<TKey, TData> {
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
keys: vec![],
|
||||||
|
values: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, key: TKey, value: TData) {
|
||||||
|
self.keys.push(key);
|
||||||
|
self.values.push(value);
|
||||||
|
|
||||||
|
assert_eq!(self.keys.len(), self.values.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &TKey) -> Option<&TData> {
|
||||||
|
let index = self.keys.iter().position(|k| k == key);
|
||||||
|
match index {
|
||||||
|
Some(index) => self.values.get(index),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut(&mut self, key: &TKey) -> Option<&mut TData> {
|
||||||
|
let index = self.keys.iter().position(|k| k == key);
|
||||||
|
match index {
|
||||||
|
Some(index) => self.values.get_mut(index),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, key: &TKey) -> Option<TData> {
|
||||||
|
assert_eq!(self.keys.len(), self.values.len());
|
||||||
|
|
||||||
|
let index = self.keys.iter().position(|k| k == key);
|
||||||
|
if let Some(index) = index {
|
||||||
|
self.keys.remove(index);
|
||||||
|
Some(self.values.remove(index))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.keys.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn first(&self) -> Option<&TData> {
|
||||||
|
self.values.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_slice(&self) -> &[TData] {
|
||||||
|
self.values.as_slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.keys.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> Iter<'_, TData> {
|
||||||
|
self.values.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter_mut(&mut self) -> IterMut<'_, TData> {
|
||||||
|
self.values.iter_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<TKey: PartialEq, TData> From<(TKey, TData)> for Collection<TKey, TData> {
|
||||||
|
fn from((key, value): (TKey, TData)) -> Self {
|
||||||
|
let mut collection = Self::new();
|
||||||
|
collection.insert(key, value);
|
||||||
|
collection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<TKey: PartialEq, TData> FromIterator<(TKey, TData)> for Collection<TKey, TData> {
|
||||||
|
fn from_iter<T: IntoIterator<Item = (TKey, TData)>>(iter: T) -> Self {
|
||||||
|
let mut collection = Self::new();
|
||||||
|
for (key, value) in iter {
|
||||||
|
collection.insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
collection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, TKey: PartialEq, TData> IntoIterator for &'a Collection<TKey, TData> {
|
||||||
|
type Item = &'a TData;
|
||||||
|
type IntoIter = CollectionIntoIterator<'a, TKey, TData>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
CollectionIntoIterator {
|
||||||
|
collection: self,
|
||||||
|
index: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CollectionIntoIterator<'a, TKey, TData> {
|
||||||
|
collection: &'a Collection<TKey, TData>,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, TKey: PartialEq, TData> Iterator for CollectionIntoIterator<'a, TKey, TData> {
|
||||||
|
type Item = &'a TData;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
let res = self.collection.values.get(self.index);
|
||||||
|
self.index += 1;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<TKey: PartialEq, TData> Default for Collection<TKey, TData> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
65
src/config.rs
Normal file
65
src/config.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use crate::modules::clock::ClockModule;
|
||||||
|
use crate::modules::launcher::LauncherModule;
|
||||||
|
use crate::modules::mpd::MpdModule;
|
||||||
|
use crate::modules::script::ScriptModule;
|
||||||
|
use crate::modules::sysinfo::SysInfoModule;
|
||||||
|
use crate::modules::tray::TrayModule;
|
||||||
|
use crate::modules::workspaces::WorkspacesModule;
|
||||||
|
use dirs::config_dir;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||||
|
pub enum ModuleConfig {
|
||||||
|
Clock(ClockModule),
|
||||||
|
Mpd(MpdModule),
|
||||||
|
Tray(TrayModule),
|
||||||
|
Workspaces(WorkspacesModule),
|
||||||
|
SysInfo(SysInfoModule),
|
||||||
|
Launcher(LauncherModule),
|
||||||
|
Script(ScriptModule),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub left: Option<Vec<ModuleConfig>>,
|
||||||
|
pub center: Option<Vec<ModuleConfig>>,
|
||||||
|
pub right: Option<Vec<ModuleConfig>>,
|
||||||
|
|
||||||
|
pub monitors: Option<Vec<Config>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Option<Self> {
|
||||||
|
let config_dir = config_dir().expect("Failed to locate user config dir");
|
||||||
|
|
||||||
|
let extensions = vec!["json", "toml", "yaml", "yml", "corn"];
|
||||||
|
|
||||||
|
extensions.into_iter().find_map(|extension| {
|
||||||
|
let full_path = config_dir
|
||||||
|
.join("ironbar")
|
||||||
|
.join(format!("config.{extension}"));
|
||||||
|
|
||||||
|
if full_path.exists() {
|
||||||
|
let file = fs::read(full_path).expect("Failed to read config file");
|
||||||
|
Some(match extension {
|
||||||
|
"json" => serde_json::from_slice(&file).expect("Invalid JSON config"),
|
||||||
|
"toml" => toml::from_slice(&file).expect("Invalid TOML config"),
|
||||||
|
"yaml" | "yml" => serde_yaml::from_slice(&file).expect("Invalid YAML config"),
|
||||||
|
"corn" => {
|
||||||
|
// corn doesn't support deserialization yet
|
||||||
|
// so serialize the interpreted result then deserialize that
|
||||||
|
let file =
|
||||||
|
String::from_utf8(file).expect("Config file contains invalid UTF-8");
|
||||||
|
let config = cornfig::parse(&file).expect("Invalid corn config").value;
|
||||||
|
serde_json::from_str(&serde_json::to_string(&config).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
52
src/main.rs
Normal file
52
src/main.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
mod bar;
|
||||||
|
mod collection;
|
||||||
|
mod config;
|
||||||
|
mod modules;
|
||||||
|
mod popup;
|
||||||
|
mod style;
|
||||||
|
|
||||||
|
use crate::bar::create_bar;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::style::load_css;
|
||||||
|
use dirs::config_dir;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{gdk, Application};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let app = Application::builder()
|
||||||
|
.application_id("dev.jstanger.waylandbar")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
app.connect_activate(|app| {
|
||||||
|
let config = Config::load().unwrap_or_default();
|
||||||
|
|
||||||
|
// TODO: Better logging (https://crates.io/crates/tracing)
|
||||||
|
// TODO: error handling (https://crates.io/crates/color-eyre)
|
||||||
|
|
||||||
|
// TODO: Embedded Deno/lua - build custom modules via script???
|
||||||
|
|
||||||
|
let display = gdk::Display::default().expect("Failed to get default GDK display");
|
||||||
|
let num_monitors = display.n_monitors();
|
||||||
|
for i in 0..num_monitors {
|
||||||
|
let monitor = display.monitor(i).unwrap();
|
||||||
|
|
||||||
|
let config = config.monitors.as_ref().map_or(&config, |monitor_config| {
|
||||||
|
monitor_config.get(i as usize).unwrap_or(&config)
|
||||||
|
});
|
||||||
|
|
||||||
|
create_bar(app, &monitor, config.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let style_path = config_dir()
|
||||||
|
.expect("Failed to locate user config dir")
|
||||||
|
.join("ironbar")
|
||||||
|
.join("style.css");
|
||||||
|
|
||||||
|
if style_path.exists() {
|
||||||
|
load_css(style_path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.run();
|
||||||
|
}
|
71
src/modules/clock/mod.rs
Normal file
71
src/modules/clock/mod.rs
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
mod popup;
|
||||||
|
|
||||||
|
use self::popup::Popup;
|
||||||
|
use crate::modules::{Module, ModuleInfo};
|
||||||
|
use crate::popup::PopupAlignment;
|
||||||
|
use chrono::Local;
|
||||||
|
use glib::Continue;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Button, Orientation};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ClockModule {
|
||||||
|
/// Date/time format string.
|
||||||
|
/// Default: `%d/%m/%Y %H:%M`
|
||||||
|
///
|
||||||
|
/// Detail on available tokens can be found here:
|
||||||
|
/// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html>
|
||||||
|
#[serde(default = "default_format")]
|
||||||
|
pub(crate) format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_format() -> String {
|
||||||
|
String::from("%d/%m/%Y %H:%M")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module<Button> for ClockModule {
|
||||||
|
fn into_widget(self, info: &ModuleInfo) -> Button {
|
||||||
|
let button = Button::new();
|
||||||
|
|
||||||
|
let popup = Popup::new("popup-clock", info.app, Orientation::Vertical);
|
||||||
|
popup.add_clock_widgets();
|
||||||
|
|
||||||
|
button.show_all();
|
||||||
|
|
||||||
|
button.connect_clicked(move |button| {
|
||||||
|
let button_w = button.allocation().width();
|
||||||
|
|
||||||
|
let (button_x, _) = button
|
||||||
|
.translate_coordinates(&button.toplevel().unwrap(), 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
popup.show();
|
||||||
|
popup.set_pos(f64::from(button_x + button_w), PopupAlignment::Right);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
spawn(async move {
|
||||||
|
let format = self.format.as_str();
|
||||||
|
loop {
|
||||||
|
let date = Local::now();
|
||||||
|
let date_string = format!("{}", date.format(format));
|
||||||
|
|
||||||
|
tx.send(date_string).unwrap();
|
||||||
|
sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let button = button.clone();
|
||||||
|
rx.attach(None, move |s| {
|
||||||
|
button.set_label(s.as_str());
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
button
|
||||||
|
}
|
||||||
|
}
|
39
src/modules/clock/popup.rs
Normal file
39
src/modules/clock/popup.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
pub use crate::popup::Popup;
|
||||||
|
use chrono::Local;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Align, Calendar, Label};
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
impl Popup {
|
||||||
|
pub fn add_clock_widgets(&self) {
|
||||||
|
let clock = Label::builder()
|
||||||
|
.name("calendar-clock")
|
||||||
|
.halign(Align::Center)
|
||||||
|
.build();
|
||||||
|
let format = "%H:%M:%S";
|
||||||
|
|
||||||
|
self.container.add(&clock);
|
||||||
|
|
||||||
|
let calendar = Calendar::builder().name("calendar").build();
|
||||||
|
self.container.add(&calendar);
|
||||||
|
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
spawn(async move {
|
||||||
|
loop {
|
||||||
|
let date = Local::now();
|
||||||
|
let date_string = format!("{}", date.format(format));
|
||||||
|
|
||||||
|
tx.send(date_string).unwrap();
|
||||||
|
sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
rx.attach(None, move |s| {
|
||||||
|
clock.set_label(s.as_str());
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
142
src/modules/launcher/icon.rs
Normal file
142
src/modules/launcher/icon.rs
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
use gtk::gdk_pixbuf::Pixbuf;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{IconLookupFlags, IconTheme};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::io::BufRead;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// Gets directories that should contain `.desktop` files
|
||||||
|
/// and exist on the filesystem.
|
||||||
|
fn find_application_dirs() -> Vec<PathBuf> {
|
||||||
|
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
|
||||||
|
let user_dir = dirs::data_local_dir();
|
||||||
|
|
||||||
|
if let Some(mut user_dir) = user_dir {
|
||||||
|
user_dir.push("applications");
|
||||||
|
dirs.push(user_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs.into_iter().filter(|dir| dir.exists()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to locate a `.desktop` file for an app id
|
||||||
|
/// (or app class).
|
||||||
|
///
|
||||||
|
/// A simple case-insensitive check is performed on filename == `app_id`.
|
||||||
|
pub fn find_desktop_file(app_id: &str) -> Option<PathBuf> {
|
||||||
|
let dirs = find_application_dirs();
|
||||||
|
|
||||||
|
for dir in dirs {
|
||||||
|
let mut walker = WalkDir::new(dir).max_depth(5).into_iter();
|
||||||
|
|
||||||
|
let entry = walker.find(|entry| match entry {
|
||||||
|
Ok(entry) => {
|
||||||
|
let file_name = entry.file_name().to_string_lossy().to_lowercase();
|
||||||
|
let test_name = format!("{}.desktop", app_id.to_lowercase());
|
||||||
|
file_name == test_name
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(Ok(entry)) = entry {
|
||||||
|
let path = entry.path().to_owned();
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a desktop file into a flat hashmap of keys/values.
|
||||||
|
fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let lines = io::BufReader::new(file).lines();
|
||||||
|
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
for line in lines.flatten() {
|
||||||
|
let is_pair = line.contains('=');
|
||||||
|
if is_pair {
|
||||||
|
let (key, value) = line.split_once('=').unwrap();
|
||||||
|
map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to get the icon name from the app's `.desktop` file.
|
||||||
|
fn get_desktop_icon_name(app_id: &str) -> Option<String> {
|
||||||
|
match find_desktop_file(app_id) {
|
||||||
|
Some(file) => {
|
||||||
|
let map = parse_desktop_file(file);
|
||||||
|
|
||||||
|
match map {
|
||||||
|
Ok(map) => map.get("Icon").map(std::string::ToString::to_string),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IconLocation {
|
||||||
|
Theme(String),
|
||||||
|
File(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_icon_location(theme: &IconTheme, app_id: &str, size: i32) -> Option<IconLocation> {
|
||||||
|
let has_icon = theme
|
||||||
|
.lookup_icon(app_id, size, IconLookupFlags::empty())
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if has_icon {
|
||||||
|
return Some(IconLocation::Theme(app_id.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_steam_game = app_id.starts_with("steam_app_");
|
||||||
|
if is_steam_game {
|
||||||
|
let steam_id: String = app_id.chars().skip("steam_app_".len()).collect();
|
||||||
|
let home_dir = dirs::data_dir().unwrap();
|
||||||
|
let path = home_dir.join(format!(
|
||||||
|
"icons/hicolor/32x32/apps/steam_icon_{}.png",
|
||||||
|
steam_id
|
||||||
|
));
|
||||||
|
|
||||||
|
return Some(IconLocation::File(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon_name = get_desktop_icon_name(app_id);
|
||||||
|
if let Some(icon_name) = icon_name {
|
||||||
|
let is_path = PathBuf::from(&icon_name).exists();
|
||||||
|
|
||||||
|
return if is_path {
|
||||||
|
Some(IconLocation::File(PathBuf::from(icon_name)))
|
||||||
|
} else {
|
||||||
|
return Some(IconLocation::Theme(icon_name));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the icon associated with an app.
|
||||||
|
pub fn get_icon(theme: &IconTheme, app_id: &str, size: i32) -> Option<Pixbuf> {
|
||||||
|
let icon_location = get_icon_location(theme, app_id, size);
|
||||||
|
|
||||||
|
match icon_location {
|
||||||
|
Some(IconLocation::Theme(icon_name)) => {
|
||||||
|
let icon = theme.load_icon(&icon_name, size, IconLookupFlags::empty());
|
||||||
|
|
||||||
|
match icon {
|
||||||
|
Ok(icon) => icon,
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(IconLocation::File(path)) => Pixbuf::from_file_at_scale(path, size, size, true).ok(),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
256
src/modules/launcher/item.rs
Normal file
256
src/modules/launcher/item.rs
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
use crate::collection::Collection;
|
||||||
|
use crate::modules::launcher::icon::{find_desktop_file, get_icon};
|
||||||
|
use crate::modules::launcher::node::SwayNode;
|
||||||
|
use crate::modules::launcher::popup::Popup;
|
||||||
|
use crate::modules::launcher::FocusEvent;
|
||||||
|
use crate::popup::PopupAlignment;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Button, IconTheme, Image};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LauncherItem {
|
||||||
|
pub app_id: String,
|
||||||
|
pub favorite: bool,
|
||||||
|
pub windows: Rc<Mutex<Collection<i32, LauncherWindow>>>,
|
||||||
|
pub state: Arc<RwLock<State>>,
|
||||||
|
pub button: Button,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LauncherWindow {
|
||||||
|
pub con_id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub is_xwayland: bool,
|
||||||
|
pub open: bool,
|
||||||
|
pub focused: bool,
|
||||||
|
pub urgent: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ButtonConfig {
|
||||||
|
pub icon_theme: IconTheme,
|
||||||
|
pub show_names: bool,
|
||||||
|
pub show_icons: bool,
|
||||||
|
pub popup: Popup,
|
||||||
|
pub tx: mpsc::Sender<FocusEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LauncherItem {
|
||||||
|
pub fn new(app_id: String, favorite: bool, config: &ButtonConfig) -> Self {
|
||||||
|
let button = Button::new();
|
||||||
|
button.style_context().add_class("item");
|
||||||
|
|
||||||
|
let state = State {
|
||||||
|
open: false,
|
||||||
|
focused: false,
|
||||||
|
urgent: false,
|
||||||
|
is_xwayland: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = Self {
|
||||||
|
app_id,
|
||||||
|
favorite,
|
||||||
|
windows: Rc::new(Mutex::new(Collection::new())),
|
||||||
|
state: Arc::new(RwLock::new(state)),
|
||||||
|
button,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.configure_button(config);
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_node(node: &SwayNode, config: &ButtonConfig) -> Self {
|
||||||
|
let button = Button::new();
|
||||||
|
button.style_context().add_class("item");
|
||||||
|
|
||||||
|
let windows = Collection::from((
|
||||||
|
node.id,
|
||||||
|
LauncherWindow {
|
||||||
|
con_id: node.id,
|
||||||
|
name: node.name.clone(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let state = State {
|
||||||
|
open: true,
|
||||||
|
focused: node.focused,
|
||||||
|
urgent: node.urgent,
|
||||||
|
is_xwayland: node.is_xwayland(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = Self {
|
||||||
|
app_id: node.get_id().to_string(),
|
||||||
|
favorite: false,
|
||||||
|
windows: Rc::new(Mutex::new(windows)),
|
||||||
|
state: Arc::new(RwLock::new(state)),
|
||||||
|
button,
|
||||||
|
};
|
||||||
|
|
||||||
|
item.configure_button(config);
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_button(&self, config: &ButtonConfig) {
|
||||||
|
let button = &self.button;
|
||||||
|
|
||||||
|
let windows = self.windows.lock().unwrap();
|
||||||
|
|
||||||
|
let name = if windows.len() == 1 {
|
||||||
|
windows.first().unwrap().name.as_ref()
|
||||||
|
} else {
|
||||||
|
Some(&self.app_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(name) = name {
|
||||||
|
self.set_title(name, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.show_icons {
|
||||||
|
let icon = get_icon(&config.icon_theme, &self.app_id, 32);
|
||||||
|
if icon.is_some() {
|
||||||
|
let image = Image::from_pixbuf(icon.as_ref());
|
||||||
|
button.set_image(Some(&image));
|
||||||
|
button.set_always_show_image(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_id = self.app_id.clone();
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
let tx_click = config.tx.clone();
|
||||||
|
|
||||||
|
let (focus_tx, mut focus_rx) = mpsc::channel(32);
|
||||||
|
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
let state = state.read().unwrap();
|
||||||
|
if state.open {
|
||||||
|
focus_tx.try_send(()).unwrap();
|
||||||
|
} else {
|
||||||
|
// attempt to find desktop file and launch
|
||||||
|
match find_desktop_file(&app_id) {
|
||||||
|
Some(file) => {
|
||||||
|
Command::new("gtk-launch")
|
||||||
|
.arg(file.file_name().unwrap())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
None => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app_id = self.app_id.clone();
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
while focus_rx.recv().await == Some(()) {
|
||||||
|
let state = state.read().unwrap();
|
||||||
|
if state.is_xwayland {
|
||||||
|
tx_click
|
||||||
|
.try_send(FocusEvent::Class(app_id.clone()))
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
tx_click
|
||||||
|
.try_send(FocusEvent::AppId(app_id.clone()))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let popup = config.popup.clone();
|
||||||
|
let popup2 = config.popup.clone();
|
||||||
|
let windows = Rc::clone(&self.windows);
|
||||||
|
let tx_hover = config.tx.clone();
|
||||||
|
|
||||||
|
button.connect_enter_notify_event(move |button, _| {
|
||||||
|
let windows = windows.lock().unwrap();
|
||||||
|
if windows.len() > 1 {
|
||||||
|
let button_w = button.allocation().width();
|
||||||
|
|
||||||
|
let (button_x, _) = button
|
||||||
|
.translate_coordinates(&button.toplevel().unwrap(), 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let button_center = f64::from(button_x) + f64::from(button_w) / 2.0;
|
||||||
|
|
||||||
|
popup.set_windows(windows.as_slice(), &tx_hover);
|
||||||
|
popup.show();
|
||||||
|
|
||||||
|
// TODO: Pass through module location
|
||||||
|
popup.set_pos(button_center, PopupAlignment::Center);
|
||||||
|
}
|
||||||
|
|
||||||
|
Inhibit(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
button.connect_leave_notify_event(move |_, e| {
|
||||||
|
let (_, y) = e.position();
|
||||||
|
// hover boundary
|
||||||
|
if y > 2.0 {
|
||||||
|
popup2.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
Inhibit(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let style = button.style_context();
|
||||||
|
|
||||||
|
style.add_class("launcher-item");
|
||||||
|
self.update_button_classes(&self.state.read().unwrap());
|
||||||
|
|
||||||
|
button.show_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_title(&self, title: &str, config: &ButtonConfig) {
|
||||||
|
if config.show_names {
|
||||||
|
self.button.set_label(title);
|
||||||
|
} else {
|
||||||
|
self.button.set_tooltip_text(Some(title));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the classnames on the GTK button
|
||||||
|
/// based on its current state.
|
||||||
|
///
|
||||||
|
/// State must be passed as an arg here rather than
|
||||||
|
/// using `self.state` to avoid a weird `RwLock` issue.
|
||||||
|
pub fn update_button_classes(&self, state: &State) {
|
||||||
|
let style = self.button.style_context();
|
||||||
|
|
||||||
|
if self.favorite {
|
||||||
|
style.add_class("favorite");
|
||||||
|
} else {
|
||||||
|
style.remove_class("favorite");
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.open {
|
||||||
|
style.add_class("open");
|
||||||
|
} else {
|
||||||
|
style.remove_class("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.focused {
|
||||||
|
style.add_class("focused");
|
||||||
|
} else {
|
||||||
|
style.remove_class("focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.urgent {
|
||||||
|
style.add_class("urgent");
|
||||||
|
} else {
|
||||||
|
style.remove_class("urgent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
271
src/modules/launcher/mod.rs
Normal file
271
src/modules/launcher/mod.rs
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
mod icon;
|
||||||
|
mod item;
|
||||||
|
mod node;
|
||||||
|
mod popup;
|
||||||
|
|
||||||
|
use crate::collection::Collection;
|
||||||
|
use crate::modules::launcher::item::{ButtonConfig, LauncherItem, LauncherWindow};
|
||||||
|
use crate::modules::launcher::node::{get_open_windows, SwayNode};
|
||||||
|
use crate::modules::launcher::popup::Popup;
|
||||||
|
use crate::modules::{Module, ModuleInfo};
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{IconTheme, Orientation};
|
||||||
|
use ksway::{Client, IpcEvent};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::spawn_blocking;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct LauncherModule {
|
||||||
|
favorites: Option<Vec<String>>,
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
show_names: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
show_icons: bool,
|
||||||
|
|
||||||
|
icon_theme: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_false() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WindowEvent {
|
||||||
|
change: String,
|
||||||
|
container: SwayNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FocusEvent {
|
||||||
|
AppId(String),
|
||||||
|
Class(String),
|
||||||
|
ConId(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppId = String;
|
||||||
|
|
||||||
|
struct Launcher {
|
||||||
|
items: Collection<AppId, LauncherItem>,
|
||||||
|
container: gtk::Box,
|
||||||
|
button_config: ButtonConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Launcher {
|
||||||
|
fn new(favorites: Vec<String>, container: gtk::Box, button_config: ButtonConfig) -> Self {
|
||||||
|
let items = favorites
|
||||||
|
.into_iter()
|
||||||
|
.map(|app_id| {
|
||||||
|
(
|
||||||
|
app_id.clone(),
|
||||||
|
LauncherItem::new(app_id, true, &button_config),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Collection<_, _>>();
|
||||||
|
|
||||||
|
for item in &items {
|
||||||
|
container.add(&item.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
container,
|
||||||
|
button_config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a new window to the launcher.
|
||||||
|
/// This gets added to an existing group
|
||||||
|
/// if an instance of the program is already open.
|
||||||
|
fn add_window(&mut self, window: SwayNode) {
|
||||||
|
let id = window.get_id().to_string();
|
||||||
|
|
||||||
|
if let Some(item) = self.items.get_mut(&id) {
|
||||||
|
let mut state = item.state.write().unwrap();
|
||||||
|
state.open = true;
|
||||||
|
state.focused = window.focused || state.focused;
|
||||||
|
state.urgent = window.urgent || state.urgent;
|
||||||
|
state.is_xwayland = window.is_xwayland();
|
||||||
|
|
||||||
|
item.update_button_classes(&state);
|
||||||
|
|
||||||
|
let mut windows = item.windows.lock().unwrap();
|
||||||
|
|
||||||
|
windows.insert(
|
||||||
|
window.id,
|
||||||
|
LauncherWindow {
|
||||||
|
con_id: window.id,
|
||||||
|
name: window.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let item = LauncherItem::from_node(&window, &self.button_config);
|
||||||
|
|
||||||
|
self.container.add(&item.button);
|
||||||
|
self.items.insert(id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a window from the launcher.
|
||||||
|
/// This removes it from the group if multiple instances were open.
|
||||||
|
/// The button will remain on the launcher if it is favourited.
|
||||||
|
fn remove_window(&mut self, window: &SwayNode) {
|
||||||
|
let id = window.get_id().to_string();
|
||||||
|
|
||||||
|
let item = self.items.get_mut(&id);
|
||||||
|
|
||||||
|
let remove = if let Some(item) = item {
|
||||||
|
let windows = Rc::clone(&item.windows);
|
||||||
|
let mut windows = windows.lock().unwrap();
|
||||||
|
|
||||||
|
windows.remove(&window.id);
|
||||||
|
|
||||||
|
if windows.is_empty() {
|
||||||
|
let mut state = item.state.write().unwrap();
|
||||||
|
state.open = false;
|
||||||
|
item.update_button_classes(&state);
|
||||||
|
|
||||||
|
if item.favorite {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.container.remove(&item.button);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if remove {
|
||||||
|
self.items.remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_window_focused(&mut self, window: &SwayNode) {
|
||||||
|
let id = window.get_id().to_string();
|
||||||
|
|
||||||
|
let currently_focused = self
|
||||||
|
.items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|item| item.state.read().unwrap().focused);
|
||||||
|
if let Some(currently_focused) = currently_focused {
|
||||||
|
let mut state = currently_focused.state.write().unwrap();
|
||||||
|
state.focused = false;
|
||||||
|
currently_focused.update_button_classes(&state);
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = self.items.get_mut(&id);
|
||||||
|
if let Some(item) = item {
|
||||||
|
let mut state = item.state.write().unwrap();
|
||||||
|
state.focused = true;
|
||||||
|
item.update_button_classes(&state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_window_title(&mut self, window: SwayNode) {
|
||||||
|
let id = window.get_id().to_string();
|
||||||
|
let item = self.items.get_mut(&id);
|
||||||
|
|
||||||
|
if let (Some(item), Some(name)) = (item, window.name) {
|
||||||
|
item.set_title(&name, &self.button_config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_window_urgent(&mut self, window: &SwayNode) {
|
||||||
|
let id = window.get_id().to_string();
|
||||||
|
let item = self.items.get_mut(&id);
|
||||||
|
|
||||||
|
if let Some(item) = item {
|
||||||
|
let mut state = item.state.write().unwrap();
|
||||||
|
state.urgent = window.urgent;
|
||||||
|
item.update_button_classes(&state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module<gtk::Box> for LauncherModule {
|
||||||
|
fn into_widget(self, info: &ModuleInfo) -> gtk::Box {
|
||||||
|
let icon_theme = IconTheme::new();
|
||||||
|
|
||||||
|
if let Some(theme) = self.icon_theme {
|
||||||
|
icon_theme.set_custom_theme(Some(&theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sway = Client::connect().unwrap();
|
||||||
|
|
||||||
|
let popup = Popup::new("popup-launcher", info.app, Orientation::Vertical);
|
||||||
|
let container = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
|
||||||
|
let (ui_tx, mut ui_rx) = mpsc::channel(32);
|
||||||
|
|
||||||
|
let button_config = ButtonConfig {
|
||||||
|
icon_theme,
|
||||||
|
show_names: self.show_names,
|
||||||
|
show_icons: self.show_icons,
|
||||||
|
popup,
|
||||||
|
tx: ui_tx,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut launcher = Launcher::new(
|
||||||
|
self.favorites.unwrap_or_default(),
|
||||||
|
container.clone(),
|
||||||
|
button_config,
|
||||||
|
);
|
||||||
|
|
||||||
|
let open_windows = get_open_windows(&mut sway);
|
||||||
|
|
||||||
|
for window in open_windows {
|
||||||
|
launcher.add_window(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
let srx = sway.subscribe(vec![IpcEvent::Window]).unwrap();
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
|
||||||
|
spawn_blocking(move || loop {
|
||||||
|
while let Ok((_, payload)) = srx.try_recv() {
|
||||||
|
let payload: WindowEvent = serde_json::from_slice(&payload).unwrap();
|
||||||
|
|
||||||
|
tx.send(payload).unwrap();
|
||||||
|
}
|
||||||
|
sway.poll().unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
rx.attach(None, move |event| {
|
||||||
|
match event.change.as_str() {
|
||||||
|
"new" => launcher.add_window(event.container),
|
||||||
|
"close" => launcher.remove_window(&event.container),
|
||||||
|
"focus" => launcher.set_window_focused(&event.container),
|
||||||
|
"title" => launcher.set_window_title(event.container),
|
||||||
|
"urgent" => launcher.set_window_urgent(&event.container),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let mut sway = Client::connect().unwrap();
|
||||||
|
while let Some(event) = ui_rx.recv().await {
|
||||||
|
let selector = match event {
|
||||||
|
FocusEvent::AppId(app_id) => format!("[app_id={}]", app_id),
|
||||||
|
FocusEvent::Class(class) => format!("[class={}]", class),
|
||||||
|
FocusEvent::ConId(id) => format!("[con_id={}]", id),
|
||||||
|
};
|
||||||
|
|
||||||
|
sway.run(format!("{} focus", selector)).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
}
|
65
src/modules/launcher/node.rs
Normal file
65
src/modules/launcher/node.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use ksway::{Client, IpcCommand};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SwayNode {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub node_type: String,
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub app_id: Option<String>,
|
||||||
|
pub focused: bool,
|
||||||
|
pub urgent: bool,
|
||||||
|
pub nodes: Vec<SwayNode>,
|
||||||
|
pub floating_nodes: Vec<SwayNode>,
|
||||||
|
pub shell: Option<String>,
|
||||||
|
pub window_properties: Option<WindowProperties>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct WindowProperties {
|
||||||
|
pub class: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SwayNode {
|
||||||
|
pub fn get_id(&self) -> &str {
|
||||||
|
self.app_id.as_ref().map_or_else(
|
||||||
|
|| {
|
||||||
|
&self
|
||||||
|
.window_properties
|
||||||
|
.as_ref()
|
||||||
|
.expect("cannot find node name")
|
||||||
|
.class
|
||||||
|
},
|
||||||
|
|app_id| app_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_xwayland(&self) -> bool {
|
||||||
|
self.shell == Some(String::from("xwayland"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_node(node: SwayNode, window_nodes: &mut Vec<SwayNode>) {
|
||||||
|
if node.name.is_some() && (node.node_type == "con" || node.node_type == "floating_con") {
|
||||||
|
window_nodes.push(node);
|
||||||
|
} else {
|
||||||
|
node.nodes.into_iter().for_each(|node| {
|
||||||
|
check_node(node, window_nodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
node.floating_nodes.into_iter().for_each(|node| {
|
||||||
|
check_node(node, window_nodes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_open_windows(sway: &mut Client) -> Vec<SwayNode> {
|
||||||
|
let raw = sway.ipc(IpcCommand::GetTree).unwrap();
|
||||||
|
let root_node = serde_json::from_slice::<SwayNode>(&raw).unwrap();
|
||||||
|
|
||||||
|
let mut window_nodes = vec![];
|
||||||
|
check_node(root_node, &mut window_nodes);
|
||||||
|
|
||||||
|
window_nodes
|
||||||
|
}
|
35
src/modules/launcher/popup.rs
Normal file
35
src/modules/launcher/popup.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use crate::modules::launcher::item::LauncherWindow;
|
||||||
|
use crate::modules::launcher::FocusEvent;
|
||||||
|
pub use crate::popup::Popup;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::Button;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
impl Popup {
|
||||||
|
pub fn set_windows(&self, windows: &[LauncherWindow], tx: &mpsc::Sender<FocusEvent>) {
|
||||||
|
// clear
|
||||||
|
for child in self.container.children() {
|
||||||
|
self.container.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
for window in windows {
|
||||||
|
let mut button_builder = Button::builder().height_request(40);
|
||||||
|
|
||||||
|
if let Some(name) = &window.name {
|
||||||
|
button_builder = button_builder.label(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let button = button_builder.build();
|
||||||
|
|
||||||
|
let con_id = window.con_id;
|
||||||
|
let window = self.window.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
tx.try_send(FocusEvent::ConId(con_id)).unwrap();
|
||||||
|
window.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.container.add(&button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
48
src/modules/mod.rs
Normal file
48
src/modules/mod.rs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
/// Displays the current date and time.
|
||||||
|
///
|
||||||
|
/// A custom date/time format string can be set in the config.
|
||||||
|
///
|
||||||
|
/// Clicking the widget opens a popup containing the current time
|
||||||
|
/// with second-level precision and a calendar.
|
||||||
|
pub mod clock;
|
||||||
|
pub mod launcher;
|
||||||
|
pub mod mpd;
|
||||||
|
pub mod script;
|
||||||
|
pub mod sysinfo;
|
||||||
|
pub mod tray;
|
||||||
|
pub mod workspaces;
|
||||||
|
|
||||||
|
/// Shamelessly stolen from here:
|
||||||
|
/// <https://github.com/zeroeightysix/rustbar/blob/master/src/modules/module.rs>
|
||||||
|
use glib::IsA;
|
||||||
|
use gtk::{Application, Widget};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ModuleLocation {
|
||||||
|
Left,
|
||||||
|
Center,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ModuleInfo<'a> {
|
||||||
|
pub app: &'a Application,
|
||||||
|
pub location: ModuleLocation,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Module<W>
|
||||||
|
where
|
||||||
|
W: IsA<Widget>,
|
||||||
|
{
|
||||||
|
/// Consumes the module config
|
||||||
|
/// and produces a GTK widget of type `W`
|
||||||
|
fn into_widget(self, info: &ModuleInfo) -> W;
|
||||||
|
|
||||||
|
fn from_value(v: &Value) -> Box<Self>
|
||||||
|
where
|
||||||
|
Self: DeserializeOwned,
|
||||||
|
{
|
||||||
|
serde_json::from_value(v.clone()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
58
src/modules/mpd/client.rs
Normal file
58
src/modules/mpd/client.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
use mpd_client::commands::responses::Status;
|
||||||
|
use mpd_client::raw::MpdProtocolError;
|
||||||
|
use mpd_client::{Client, Connection};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::net::{TcpStream, UnixStream};
|
||||||
|
|
||||||
|
fn is_unix_socket(host: &String) -> bool {
|
||||||
|
PathBuf::from(host).is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_connection(host: &String) -> Result<Connection, MpdProtocolError> {
|
||||||
|
if is_unix_socket(host) {
|
||||||
|
connect_unix(host).await
|
||||||
|
} else {
|
||||||
|
connect_tcp(host).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_unix(host: &String) -> Result<Connection, MpdProtocolError> {
|
||||||
|
let connection = UnixStream::connect(host)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("Error connecting to unix socket: {}", host));
|
||||||
|
|
||||||
|
Client::connect(connection).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_tcp(host: &String) -> Result<Connection, MpdProtocolError> {
|
||||||
|
let connection = TcpStream::connect(host)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("Error connecting to unix socket: {}", host));
|
||||||
|
|
||||||
|
Client::connect(connection).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// /// Gets MPD server status.
|
||||||
|
// /// Panics on error.
|
||||||
|
// pub async fn get_status(client: &Client) -> Status {
|
||||||
|
// client
|
||||||
|
// .command(commands::Status)
|
||||||
|
// .await
|
||||||
|
// .expect("Failed to get MPD server status")
|
||||||
|
// }
|
||||||
|
|
||||||
|
/// Gets the duration of the current song
|
||||||
|
pub fn get_duration(status: &Status) -> u64 {
|
||||||
|
status
|
||||||
|
.duration
|
||||||
|
.expect("Failed to get duration from MPD status")
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the elapsed time of the current song
|
||||||
|
pub fn get_elapsed(status: &Status) -> u64 {
|
||||||
|
status
|
||||||
|
.elapsed
|
||||||
|
.expect("Failed to get elapsed time from MPD status")
|
||||||
|
.as_secs()
|
||||||
|
}
|
232
src/modules/mpd/mod.rs
Normal file
232
src/modules/mpd/mod.rs
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
mod client;
|
||||||
|
mod popup;
|
||||||
|
|
||||||
|
use self::popup::Popup;
|
||||||
|
use crate::modules::mpd::client::{get_connection, get_duration, get_elapsed};
|
||||||
|
use crate::modules::mpd::popup::{MpdPopup, PopupEvent};
|
||||||
|
use crate::modules::{Module, ModuleInfo};
|
||||||
|
use crate::popup::PopupAlignment;
|
||||||
|
use dirs::home_dir;
|
||||||
|
use glib::Continue;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Button, Orientation};
|
||||||
|
use mpd_client::commands::responses::{PlayState, Song, Status};
|
||||||
|
use mpd_client::{commands, Tag};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct MpdModule {
|
||||||
|
#[serde(default = "default_socket")]
|
||||||
|
host: String,
|
||||||
|
#[serde(default = "default_format")]
|
||||||
|
format: String,
|
||||||
|
#[serde(default = "default_icon_play")]
|
||||||
|
icon_play: Option<String>,
|
||||||
|
#[serde(default = "default_icon_pause")]
|
||||||
|
icon_pause: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_music_dir")]
|
||||||
|
music_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_socket() -> String {
|
||||||
|
String::from("localhost:6600")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_format() -> String {
|
||||||
|
String::from("{icon} {title} / {artist}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_icon_play() -> Option<String> {
|
||||||
|
Some(String::from(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_icon_pause() -> Option<String> {
|
||||||
|
Some(String::from(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_music_dir() -> PathBuf {
|
||||||
|
home_dir().unwrap().join("Music")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to read the first value for a tag
|
||||||
|
/// (since the MPD client returns a vector of tags, or None)
|
||||||
|
pub fn try_get_first_tag(vec: Option<&Vec<String>>) -> Option<&str> {
|
||||||
|
match vec {
|
||||||
|
Some(vec) => vec.first().map(String::as_str),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a duration given in seconds
|
||||||
|
/// in hh:mm format
|
||||||
|
fn format_time(time: u64) -> String {
|
||||||
|
let minutes = (time / 60) % 60;
|
||||||
|
let seconds = time % 60;
|
||||||
|
|
||||||
|
format!("{:0>2}:{:0>2}", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the formatting tokens from a formatting string
|
||||||
|
fn get_tokens(re: &Regex, format_string: &str) -> Vec<String> {
|
||||||
|
re.captures_iter(format_string)
|
||||||
|
.map(|caps| caps[1].to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Event {
|
||||||
|
Open(f64),
|
||||||
|
Update(Box<Option<(Song, Status, String)>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module<Button> for MpdModule {
|
||||||
|
fn into_widget(self, info: &ModuleInfo) -> Button {
|
||||||
|
let re = Regex::new(r"\{([\w-]+)}").unwrap();
|
||||||
|
let tokens = get_tokens(&re, self.format.as_str());
|
||||||
|
|
||||||
|
let button = Button::new();
|
||||||
|
|
||||||
|
let (ui_tx, mut ui_rx) = mpsc::channel(32);
|
||||||
|
|
||||||
|
let popup = Popup::new("popup-mpd", info.app, Orientation::Horizontal);
|
||||||
|
let mpd_popup = MpdPopup::new(popup, ui_tx);
|
||||||
|
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
let click_tx = tx.clone();
|
||||||
|
|
||||||
|
let music_dir = self.music_dir.clone();
|
||||||
|
|
||||||
|
button.connect_clicked(move |button| {
|
||||||
|
let button_w = button.allocation().width();
|
||||||
|
|
||||||
|
let (button_x, _) = button
|
||||||
|
.translate_coordinates(&button.toplevel().unwrap(), 0, 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
click_tx
|
||||||
|
.send(Event::Open(f64::from(button_x + button_w)))
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let host = self.host.clone();
|
||||||
|
let host2 = self.host.clone();
|
||||||
|
spawn(async move {
|
||||||
|
let (client, _) = get_connection(&host).await.unwrap(); // TODO: Handle connecting properly
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let current_song = client.command(commands::CurrentSong).await;
|
||||||
|
let status = client.command(commands::Status).await;
|
||||||
|
|
||||||
|
if let (Ok(Some(song)), Ok(status)) = (current_song, status) {
|
||||||
|
let string = self
|
||||||
|
.replace_tokens(self.format.as_str(), &tokens, &song.song, &status)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tx.send(Event::Update(Box::new(Some((song.song, status, string)))))
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
tx.send(Event::Update(Box::new(None))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let (client, _) = get_connection(&host2).await.unwrap(); // TODO: Handle connecting properly
|
||||||
|
|
||||||
|
while let Some(event) = ui_rx.recv().await {
|
||||||
|
match event {
|
||||||
|
PopupEvent::Previous => client.command(commands::Previous).await,
|
||||||
|
PopupEvent::Toggle => {
|
||||||
|
let status = client.command(commands::Status).await.unwrap();
|
||||||
|
match status.state {
|
||||||
|
PlayState::Playing => client.command(commands::SetPause(true)).await,
|
||||||
|
PlayState::Paused => client.command(commands::SetPause(false)).await,
|
||||||
|
PlayState::Stopped => Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PopupEvent::Next => client.command(commands::Next).await
|
||||||
|
}.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let button = button.clone();
|
||||||
|
|
||||||
|
rx.attach(None, move |event| {
|
||||||
|
match event {
|
||||||
|
Event::Open(pos) => {
|
||||||
|
mpd_popup.popup.show();
|
||||||
|
mpd_popup.popup.set_pos(pos, PopupAlignment::Right);
|
||||||
|
}
|
||||||
|
Event::Update(mut msg) => {
|
||||||
|
if let Some((song, status, string)) = msg.take() {
|
||||||
|
mpd_popup.update(&song, &status, music_dir.as_path());
|
||||||
|
|
||||||
|
button.set_label(&string);
|
||||||
|
button.show();
|
||||||
|
} else {
|
||||||
|
button.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdModule {
|
||||||
|
/// Replaces each of the formatting tokens in the formatting string
|
||||||
|
/// with actual data pulled from MPD
|
||||||
|
async fn replace_tokens(
|
||||||
|
&self,
|
||||||
|
format_string: &str,
|
||||||
|
tokens: &Vec<String>,
|
||||||
|
song: &Song,
|
||||||
|
status: &Status,
|
||||||
|
) -> String {
|
||||||
|
let mut compiled_string = format_string.to_string();
|
||||||
|
for token in tokens {
|
||||||
|
let value = self.get_token_value(song, status, token).await;
|
||||||
|
compiled_string =
|
||||||
|
compiled_string.replace(format!("{{{}}}", token).as_str(), value.as_str());
|
||||||
|
}
|
||||||
|
compiled_string
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a string format token value
|
||||||
|
/// into its respective MPD value.
|
||||||
|
pub async fn get_token_value(&self, song: &Song, status: &Status, token: &str) -> String {
|
||||||
|
let s = match token {
|
||||||
|
"icon" => {
|
||||||
|
let icon = match status.state {
|
||||||
|
PlayState::Stopped => None,
|
||||||
|
PlayState::Playing => self.icon_play.as_ref(),
|
||||||
|
PlayState::Paused => self.icon_pause.as_ref(),
|
||||||
|
};
|
||||||
|
icon.map(|i| i.as_str())
|
||||||
|
}
|
||||||
|
"title" => song.title(),
|
||||||
|
"album" => try_get_first_tag(song.tags.get(&Tag::Album)),
|
||||||
|
"artist" => try_get_first_tag(song.tags.get(&Tag::Artist)),
|
||||||
|
"date" => try_get_first_tag(song.tags.get(&Tag::Date)),
|
||||||
|
"disc" => try_get_first_tag(song.tags.get(&Tag::Disc)),
|
||||||
|
"genre" => try_get_first_tag(song.tags.get(&Tag::Genre)),
|
||||||
|
"track" => try_get_first_tag(song.tags.get(&Tag::Track)),
|
||||||
|
"duration" => return format_time(get_duration(status)),
|
||||||
|
"elapsed" => return format_time(get_elapsed(status)),
|
||||||
|
_ => return token.to_string(),
|
||||||
|
};
|
||||||
|
s.unwrap_or_default().to_string()
|
||||||
|
}
|
||||||
|
}
|
164
src/modules/mpd/popup.rs
Normal file
164
src/modules/mpd/popup.rs
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
pub use crate::popup::Popup;
|
||||||
|
use gtk::gdk_pixbuf::Pixbuf;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Button, Image, Label, Orientation};
|
||||||
|
use mpd_client::commands::responses::{PlayState, Song, Status};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct IconLabel {
|
||||||
|
label: Label,
|
||||||
|
container: gtk::Box,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IconLabel {
|
||||||
|
fn new(icon: &str, label: Option<&str>) -> Self {
|
||||||
|
let container = gtk::Box::new(Orientation::Horizontal, 5);
|
||||||
|
|
||||||
|
let icon = Label::new(Some(icon));
|
||||||
|
let label = Label::new(label);
|
||||||
|
|
||||||
|
icon.style_context().add_class("icon");
|
||||||
|
label.style_context().add_class("label");
|
||||||
|
|
||||||
|
container.add(&icon);
|
||||||
|
container.add(&label);
|
||||||
|
|
||||||
|
Self { label, container }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MpdPopup {
|
||||||
|
pub popup: Popup,
|
||||||
|
|
||||||
|
cover: Image,
|
||||||
|
|
||||||
|
title: IconLabel,
|
||||||
|
album: IconLabel,
|
||||||
|
artist: IconLabel,
|
||||||
|
|
||||||
|
btn_prev: Button,
|
||||||
|
btn_play_pause: Button,
|
||||||
|
btn_next: Button,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PopupEvent {
|
||||||
|
Previous,
|
||||||
|
Toggle,
|
||||||
|
Next,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdPopup {
|
||||||
|
pub fn new(popup: Popup, tx: mpsc::Sender<PopupEvent>) -> Self {
|
||||||
|
let album_image = Image::builder()
|
||||||
|
.width_request(128)
|
||||||
|
.height_request(128)
|
||||||
|
.name("album-art")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let info_box = gtk::Box::new(Orientation::Vertical, 10);
|
||||||
|
|
||||||
|
let title_label = IconLabel::new("\u{f886}", None);
|
||||||
|
let album_label = IconLabel::new("\u{f524}", None);
|
||||||
|
let artist_label = IconLabel::new("\u{fd01}", None);
|
||||||
|
|
||||||
|
title_label.container.set_widget_name("title");
|
||||||
|
album_label.container.set_widget_name("album");
|
||||||
|
artist_label.container.set_widget_name("label");
|
||||||
|
|
||||||
|
info_box.add(&title_label.container);
|
||||||
|
info_box.add(&album_label.container);
|
||||||
|
info_box.add(&artist_label.container);
|
||||||
|
|
||||||
|
let controls_box = gtk::Box::builder().name("controls").build();
|
||||||
|
|
||||||
|
let btn_prev = Button::builder().label("\u{f9ad}").name("btn-prev").build();
|
||||||
|
let btn_play_pause = Button::builder().label("").name("btn-play-pause").build();
|
||||||
|
let btn_next = Button::builder().label("\u{f9ac}").name("btn-next").build();
|
||||||
|
|
||||||
|
controls_box.add(&btn_prev);
|
||||||
|
controls_box.add(&btn_play_pause);
|
||||||
|
controls_box.add(&btn_next);
|
||||||
|
|
||||||
|
info_box.add(&controls_box);
|
||||||
|
|
||||||
|
popup.container.add(&album_image);
|
||||||
|
popup.container.add(&info_box);
|
||||||
|
|
||||||
|
let tx_prev = tx.clone();
|
||||||
|
btn_prev.connect_clicked(move |_| {
|
||||||
|
tx_prev.try_send(PopupEvent::Previous).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let tx_toggle = tx.clone();
|
||||||
|
btn_play_pause.connect_clicked(move |_| {
|
||||||
|
tx_toggle.try_send(PopupEvent::Toggle).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let tx_next = tx;
|
||||||
|
btn_next.connect_clicked(move |_| {
|
||||||
|
tx_next.try_send(PopupEvent::Next).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
popup,
|
||||||
|
cover: album_image,
|
||||||
|
artist: artist_label,
|
||||||
|
album: album_label,
|
||||||
|
title: title_label,
|
||||||
|
btn_prev,
|
||||||
|
btn_play_pause,
|
||||||
|
btn_next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, song: &Song, status: &Status, path: &Path) {
|
||||||
|
let prev_album = self.album.label.text();
|
||||||
|
let curr_album = song.album().unwrap_or_default();
|
||||||
|
|
||||||
|
// only update art when album changes
|
||||||
|
if prev_album != curr_album {
|
||||||
|
let cover_path = path.join(song.file_path().parent().unwrap().join("cover.jpg"));
|
||||||
|
|
||||||
|
if let Ok(pixbuf) = Pixbuf::from_file_at_scale(cover_path, 128, 128, true) {
|
||||||
|
self.cover.set_from_pixbuf(Some(&pixbuf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.title.label.set_text(song.title().unwrap_or_default());
|
||||||
|
self.album.label.set_text(song.album().unwrap_or_default());
|
||||||
|
self.artist
|
||||||
|
.label
|
||||||
|
.set_text(song.artists().first().unwrap_or(&String::new()));
|
||||||
|
|
||||||
|
match status.state {
|
||||||
|
PlayState::Stopped => {
|
||||||
|
self.btn_play_pause.set_sensitive(false);
|
||||||
|
}
|
||||||
|
PlayState::Playing => {
|
||||||
|
self.btn_play_pause.set_sensitive(true);
|
||||||
|
self.btn_play_pause.set_label("");
|
||||||
|
}
|
||||||
|
PlayState::Paused => {
|
||||||
|
self.btn_play_pause.set_sensitive(true);
|
||||||
|
self.btn_play_pause.set_label("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let enable_prev = match status.current_song {
|
||||||
|
Some((pos, _)) => pos.0 > 0,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let enable_next = match status.current_song {
|
||||||
|
Some((pos, _)) => pos.0 < status.playlist_length,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.btn_prev.set_sensitive(enable_prev);
|
||||||
|
self.btn_next.set_sensitive(enable_next);
|
||||||
|
}
|
||||||
|
}
|
51
src/modules/script.rs
Normal file
51
src/modules/script.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use crate::modules::{Module, ModuleInfo};
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::Label;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::process::Command;
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ScriptModule {
|
||||||
|
path: String,
|
||||||
|
#[serde(default = "default_interval")]
|
||||||
|
interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5000ms
|
||||||
|
const fn default_interval() -> u64 {
|
||||||
|
5000
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module<Label> for ScriptModule {
|
||||||
|
fn into_widget(self, _info: &ModuleInfo) -> Label {
|
||||||
|
let label = Label::new(None);
|
||||||
|
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
spawn(async move {
|
||||||
|
loop {
|
||||||
|
let output = Command::new("sh").arg("-c").arg(&self.path).output();
|
||||||
|
if let Ok(output) = output {
|
||||||
|
let stdout = String::from_utf8(output.stdout)
|
||||||
|
.map(|output| output.trim().to_string())
|
||||||
|
.expect("Script output not valid UTF-8");
|
||||||
|
|
||||||
|
tx.send(stdout).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(tokio::time::Duration::from_millis(self.interval)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let label = label.clone();
|
||||||
|
rx.attach(None, move |s| {
|
||||||
|
label.set_label(s.as_str());
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
74
src/modules/sysinfo.rs
Normal file
74
src/modules/sysinfo.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
use crate::modules::{Module, ModuleInfo};
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Label, Orientation};
|
||||||
|
use regex::{Captures, Regex};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use sysinfo::{CpuExt, System, SystemExt};
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct SysInfoModule {
|
||||||
|
format: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module<gtk::Box> for SysInfoModule {
|
||||||
|
fn into_widget(self, _info: &ModuleInfo) -> gtk::Box {
|
||||||
|
let re = Regex::new(r"\{([\w-]+)}").unwrap();
|
||||||
|
|
||||||
|
let container = gtk::Box::new(Orientation::Horizontal, 10);
|
||||||
|
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
|
||||||
|
for format in &self.format {
|
||||||
|
let label = Label::builder().label(format).name("item").build();
|
||||||
|
container.add(&label);
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
spawn(async move {
|
||||||
|
let mut sys = System::new_all();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sys.refresh_all();
|
||||||
|
|
||||||
|
let mut format_info = HashMap::new();
|
||||||
|
|
||||||
|
let actual_used_memory = sys.total_memory() - sys.available_memory();
|
||||||
|
let memory_percent = actual_used_memory as f64 / sys.total_memory() as f64 * 100.0;
|
||||||
|
|
||||||
|
let cpu_percent = sys.global_cpu_info().cpu_usage();
|
||||||
|
|
||||||
|
// TODO: Add remaining format info
|
||||||
|
|
||||||
|
format_info.insert("memory-percent", format!("{:0>2.0}", memory_percent));
|
||||||
|
format_info.insert("cpu-percent", format!("{:0>2.0}", cpu_percent));
|
||||||
|
|
||||||
|
tx.send(format_info).unwrap();
|
||||||
|
|
||||||
|
sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let formats = self.format;
|
||||||
|
rx.attach(None, move |info| {
|
||||||
|
for (format, label) in formats.iter().zip(labels.clone()) {
|
||||||
|
let format_compiled = re.replace(format, |caps: &Captures| {
|
||||||
|
info.get(&caps[1])
|
||||||
|
.unwrap_or(&caps[0].to_string())
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
label.set_text(format_compiled.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
}
|
168
src/modules/tray.rs
Normal file
168
src/modules/tray.rs
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
use crate::modules::{Module, ModuleInfo};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{IconLookupFlags, IconTheme, Image, Menu, MenuBar, MenuItem, SeparatorMenuItem};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use stray::message::menu::{MenuItem as MenuItemInfo, MenuType, TrayMenu};
|
||||||
|
use stray::message::tray::StatusNotifierItem;
|
||||||
|
use stray::message::{NotifierItemCommand, NotifierItemMessage};
|
||||||
|
use stray::SystemTray;
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct TrayModule;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum TrayUpdate {
|
||||||
|
Update(String, Box<StatusNotifierItem>, Option<TrayMenu>),
|
||||||
|
Remove(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a GTK `Image` component
|
||||||
|
/// for the status notifier item's icon.
|
||||||
|
fn get_icon(item: &StatusNotifierItem) -> Option<Image> {
|
||||||
|
item.icon_theme_path.as_ref().and_then(|path| {
|
||||||
|
let theme = IconTheme::new();
|
||||||
|
theme.append_search_path(&path);
|
||||||
|
let icon_name = item.icon_name.as_ref().unwrap();
|
||||||
|
let icon_info = theme.lookup_icon(icon_name, 16, IconLookupFlags::empty());
|
||||||
|
|
||||||
|
icon_info.map(|icon_info| Image::from_pixbuf(icon_info.load_icon().ok().as_ref()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively gets GTK `MenuItem` components
|
||||||
|
/// for the provided submenu array.
|
||||||
|
fn get_menu_items(
|
||||||
|
menu: &[MenuItemInfo],
|
||||||
|
tx: &mpsc::Sender<NotifierItemCommand>,
|
||||||
|
id: String,
|
||||||
|
path: String,
|
||||||
|
) -> Vec<MenuItem> {
|
||||||
|
menu.iter()
|
||||||
|
.map(|item_info| {
|
||||||
|
let item: Box<dyn AsRef<MenuItem>> = match item_info.menu_type {
|
||||||
|
MenuType::Separator => Box::new(SeparatorMenuItem::new()),
|
||||||
|
MenuType::Standard => {
|
||||||
|
let mut builder = MenuItem::builder()
|
||||||
|
.label(item_info.label.as_str())
|
||||||
|
.visible(item_info.visible)
|
||||||
|
.sensitive(item_info.enabled);
|
||||||
|
|
||||||
|
if !item_info.submenu.is_empty() {
|
||||||
|
let menu = Menu::new();
|
||||||
|
get_menu_items(&item_info.submenu, &tx.clone(), id.clone(), path.clone())
|
||||||
|
.iter()
|
||||||
|
.for_each(|item| menu.add(item));
|
||||||
|
|
||||||
|
builder = builder.submenu(&menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = builder.build();
|
||||||
|
|
||||||
|
let info = item_info.clone();
|
||||||
|
let id = id.clone();
|
||||||
|
let path = path.clone();
|
||||||
|
|
||||||
|
{
|
||||||
|
let tx = tx.clone();
|
||||||
|
item.connect_activate(move |_item| {
|
||||||
|
tx.try_send(NotifierItemCommand::MenuItemClicked {
|
||||||
|
submenu_id: info.id,
|
||||||
|
menu_path: path.clone(),
|
||||||
|
notifier_address: id.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::new(item)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(*item).as_ref().clone()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module<MenuBar> for TrayModule {
|
||||||
|
fn into_widget(self, _info: &ModuleInfo) -> MenuBar {
|
||||||
|
let container = MenuBar::new();
|
||||||
|
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
let (ui_tx, ui_rx) = mpsc::channel(32);
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
// FIXME: Can only spawn one of these at a time - means cannot have tray on multiple bars
|
||||||
|
let mut tray = SystemTray::new(ui_rx).await;
|
||||||
|
|
||||||
|
// listen for tray updates & send message to update UI
|
||||||
|
while let Some(message) = tray.next().await {
|
||||||
|
match message {
|
||||||
|
NotifierItemMessage::Update {
|
||||||
|
address: id,
|
||||||
|
item,
|
||||||
|
menu,
|
||||||
|
} => {
|
||||||
|
tx.send(TrayUpdate::Update(id, Box::new(item), menu))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
NotifierItemMessage::Remove { address: id } => {
|
||||||
|
tx.send(TrayUpdate::Remove(id)).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let container = container.clone();
|
||||||
|
let mut widgets = HashMap::new();
|
||||||
|
|
||||||
|
// listen for UI updates
|
||||||
|
rx.attach(None, move |update| {
|
||||||
|
match update {
|
||||||
|
TrayUpdate::Update(id, item, menu) => {
|
||||||
|
let menu_item = widgets.remove(id.as_str()).unwrap_or_else(|| {
|
||||||
|
let menu_item = MenuItem::new();
|
||||||
|
menu_item.style_context().add_class("item");
|
||||||
|
if let Some(image) = get_icon(&item) {
|
||||||
|
image.set_widget_name(id.as_str());
|
||||||
|
menu_item.add(&image);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.add(&menu_item);
|
||||||
|
menu_item.show_all();
|
||||||
|
|
||||||
|
menu_item
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(menu_opts) = menu {
|
||||||
|
let menu_path = item.menu.as_ref().unwrap().to_string();
|
||||||
|
|
||||||
|
let submenus = menu_opts.submenus;
|
||||||
|
if !submenus.is_empty() {
|
||||||
|
let menu = Menu::new();
|
||||||
|
get_menu_items(&submenus, &ui_tx.clone(), id.clone(), menu_path)
|
||||||
|
.iter()
|
||||||
|
.for_each(|item| menu.add(item));
|
||||||
|
menu_item.set_submenu(Some(&menu));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets.insert(id, menu_item);
|
||||||
|
}
|
||||||
|
TrayUpdate::Remove(id) => {
|
||||||
|
let widget = widgets.get(&id).unwrap();
|
||||||
|
container.remove(widget);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
}
|
131
src/modules/workspaces.rs
Normal file
131
src/modules/workspaces.rs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
use crate::modules::{Module, ModuleInfo};
|
||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Button, Orientation};
|
||||||
|
use ksway::client::Client;
|
||||||
|
use ksway::{IpcCommand, IpcEvent};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::spawn;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::spawn_blocking;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct WorkspacesModule {
|
||||||
|
pub(crate) name_map: Option<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct Workspace {
|
||||||
|
name: String,
|
||||||
|
focused: bool,
|
||||||
|
// num: i32,
|
||||||
|
// output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
fn as_button(&self, name_map: &HashMap<String, String>, tx: &mpsc::Sender<String>) -> Button {
|
||||||
|
let button = Button::builder()
|
||||||
|
.label(name_map.get(self.name.as_str()).unwrap_or(&self.name))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let style_context = button.style_context();
|
||||||
|
style_context.add_class("item");
|
||||||
|
|
||||||
|
if self.focused {
|
||||||
|
style_context.add_class("focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let tx = tx.clone();
|
||||||
|
let name = self.name.clone();
|
||||||
|
button.connect_clicked(move |_item| tx.try_send(name.clone()).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct WorkspaceEvent {
|
||||||
|
change: String,
|
||||||
|
old: Option<Workspace>,
|
||||||
|
current: Option<Workspace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module<gtk::Box> for WorkspacesModule {
|
||||||
|
fn into_widget(self, _info: &ModuleInfo) -> gtk::Box {
|
||||||
|
let mut sway = Client::connect().unwrap();
|
||||||
|
|
||||||
|
let container = gtk::Box::new(Orientation::Horizontal, 0);
|
||||||
|
|
||||||
|
let workspaces = {
|
||||||
|
let raw = sway.ipc(IpcCommand::GetWorkspaces).unwrap();
|
||||||
|
serde_json::from_slice::<Vec<Workspace>>(&raw).unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let name_map = self.name_map.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut button_map: HashMap<String, Button> = HashMap::new();
|
||||||
|
|
||||||
|
let (ui_tx, mut ui_rx) = mpsc::channel(32);
|
||||||
|
|
||||||
|
for workspace in workspaces {
|
||||||
|
let item = workspace.as_button(&name_map, &ui_tx);
|
||||||
|
container.add(&item);
|
||||||
|
button_map.insert(workspace.name, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
let srx = sway.subscribe(vec![IpcEvent::Workspace]).unwrap();
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
|
||||||
|
spawn_blocking(move || loop {
|
||||||
|
while let Ok((_, payload)) = srx.try_recv() {
|
||||||
|
let payload: WorkspaceEvent = serde_json::from_slice(&payload).unwrap();
|
||||||
|
tx.send(payload).unwrap();
|
||||||
|
}
|
||||||
|
sway.poll().unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
let menubar = container.clone();
|
||||||
|
rx.attach(None, move |event| {
|
||||||
|
match event.change.as_str() {
|
||||||
|
"focus" => {
|
||||||
|
let old = event.old.unwrap();
|
||||||
|
let old_button = button_map.get(&old.name).unwrap();
|
||||||
|
old_button.style_context().remove_class("focused");
|
||||||
|
|
||||||
|
let new = event.current.unwrap();
|
||||||
|
let new_button = button_map.get(&new.name).unwrap();
|
||||||
|
new_button.style_context().add_class("focused");
|
||||||
|
}
|
||||||
|
"init" => {
|
||||||
|
let workspace = event.current.unwrap();
|
||||||
|
let item = workspace.as_button(&name_map, &ui_tx);
|
||||||
|
|
||||||
|
item.show();
|
||||||
|
menubar.add(&item);
|
||||||
|
button_map.insert(workspace.name, item);
|
||||||
|
}
|
||||||
|
"empty" => {
|
||||||
|
let current = event.current.unwrap();
|
||||||
|
let item = button_map.get(¤t.name).unwrap();
|
||||||
|
menubar.remove(item);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let mut sway = Client::connect().unwrap();
|
||||||
|
while let Some(name) = ui_rx.recv().await {
|
||||||
|
sway.run(format!("workspace {}", name)).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
|
}
|
87
src/popup.rs
Normal file
87
src/popup.rs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
use gtk::prelude::*;
|
||||||
|
use gtk::{Application, ApplicationWindow, Orientation};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Popup {
|
||||||
|
pub window: ApplicationWindow,
|
||||||
|
pub container: gtk::Box,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum PopupAlignment {
|
||||||
|
Left,
|
||||||
|
Center,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Popup {
|
||||||
|
pub fn new(name: &str, app: &Application, orientation: Orientation) -> Self {
|
||||||
|
let win = ApplicationWindow::builder().application(app).build();
|
||||||
|
|
||||||
|
gtk_layer_shell::init_for_window(&win);
|
||||||
|
gtk_layer_shell::set_layer(&win, gtk_layer_shell::Layer::Overlay);
|
||||||
|
|
||||||
|
gtk_layer_shell::set_margin(&win, gtk_layer_shell::Edge::Top, 0);
|
||||||
|
gtk_layer_shell::set_margin(&win, gtk_layer_shell::Edge::Bottom, 5);
|
||||||
|
gtk_layer_shell::set_margin(&win, gtk_layer_shell::Edge::Left, 0);
|
||||||
|
gtk_layer_shell::set_margin(&win, gtk_layer_shell::Edge::Right, 0);
|
||||||
|
|
||||||
|
gtk_layer_shell::set_anchor(&win, gtk_layer_shell::Edge::Top, false);
|
||||||
|
gtk_layer_shell::set_anchor(&win, gtk_layer_shell::Edge::Bottom, true);
|
||||||
|
gtk_layer_shell::set_anchor(&win, gtk_layer_shell::Edge::Left, true);
|
||||||
|
gtk_layer_shell::set_anchor(&win, gtk_layer_shell::Edge::Right, false);
|
||||||
|
|
||||||
|
let content = gtk::Box::builder()
|
||||||
|
.orientation(orientation)
|
||||||
|
.spacing(0)
|
||||||
|
.hexpand(false)
|
||||||
|
.name(name)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
content.style_context().add_class("popup");
|
||||||
|
|
||||||
|
win.add(&content);
|
||||||
|
|
||||||
|
win.connect_leave_notify_event(|win, ev| {
|
||||||
|
let (w, _h) = win.size();
|
||||||
|
let (x, y) = ev.position();
|
||||||
|
|
||||||
|
const THRESHOLD: f64 = 3.0;
|
||||||
|
|
||||||
|
// some child widgets trigger this event
|
||||||
|
// so check we're actually outside the window
|
||||||
|
if x < THRESHOLD || y < THRESHOLD || x > f64::from(w) - THRESHOLD {
|
||||||
|
win.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
Inhibit(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
window: win,
|
||||||
|
container: content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the popover's X position relative to the left border of the screen
|
||||||
|
pub fn set_pos(&self, pos: f64, alignment: PopupAlignment) {
|
||||||
|
let width = self.window.allocated_width();
|
||||||
|
|
||||||
|
let offset = match alignment {
|
||||||
|
PopupAlignment::Left => pos,
|
||||||
|
PopupAlignment::Center => (pos - (f64::from(width) / 2.0)).round(),
|
||||||
|
PopupAlignment::Right => pos - f64::from(width),
|
||||||
|
};
|
||||||
|
|
||||||
|
gtk_layer_shell::set_margin(&self.window, gtk_layer_shell::Edge::Left, offset as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the popover
|
||||||
|
pub fn show(&self) {
|
||||||
|
self.window.show_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides the popover
|
||||||
|
pub fn hide(&self) {
|
||||||
|
self.window.hide();
|
||||||
|
}
|
||||||
|
}
|
47
src/style.rs
Normal file
47
src/style.rs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
use glib::Continue;
|
||||||
|
use gtk::prelude::CssProviderExt;
|
||||||
|
use gtk::{gdk, gio, CssProvider, StyleContext};
|
||||||
|
use notify::{DebouncedEvent, RecursiveMode, Watcher};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::spawn;
|
||||||
|
|
||||||
|
pub fn load_css(style_path: PathBuf) {
|
||||||
|
let provider = CssProvider::new();
|
||||||
|
provider
|
||||||
|
.load_from_file(&gio::File::for_path(&style_path))
|
||||||
|
.expect("Couldn't load custom style");
|
||||||
|
StyleContext::add_provider_for_screen(
|
||||||
|
&gdk::Screen::default().expect("Couldn't get default GDK screen"),
|
||||||
|
&provider,
|
||||||
|
800,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (watcher_tx, watcher_rx) = mpsc::channel::<DebouncedEvent>();
|
||||||
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let mut watcher = notify::watcher(watcher_tx, Duration::from_millis(500)).unwrap();
|
||||||
|
watcher
|
||||||
|
.watch(&style_path, RecursiveMode::NonRecursive)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Ok(DebouncedEvent::Write(path)) = watcher_rx.recv() {
|
||||||
|
tx.send(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
rx.attach(None, move |path| {
|
||||||
|
println!("Reloading CSS");
|
||||||
|
provider
|
||||||
|
.load_from_file(&gio::File::for_path(path))
|
||||||
|
.expect("Couldn't load custom style");
|
||||||
|
|
||||||
|
Continue(true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user