shelly/src/main.rs
2025-06-04 11:03:24 -05:00

523 lines
18 KiB
Rust

use std::cmp;
use std::collections::HashMap;
use std::fmt::Display;
use std::fs::read_to_string;
use std::path::PathBuf;
use chrono::prelude::*;
use freedesktop_icons::lookup;
use hyprland::data::{Client, Workspace, Workspaces};
use hyprland::event_listener::{self, AsyncEventListener};
use hyprland::shared::{HyprData, HyprDataActiveOptional, HyprDataVec};
use iced::font::Weight;
use iced::futures::SinkExt;
use iced::stream;
use iced::widget::image::Handle;
use iced::widget::{container, horizontal_space, image, row, svg, text};
use iced::{
time, Background, Border, Color, Element, Event, Font, Length, Shadow, Subscription, Task,
Vector,
};
use iced_layershell::build_pattern::{application, MainSettings};
use iced_layershell::reexport::{Anchor, KeyboardInteractivity};
use iced_layershell::settings::LayerShellSettings;
use iced_layershell::to_layer_message;
use iced_runtime::futures::event;
use miette::{IntoDiagnostic, Result};
use sysinfo::{Disks, System};
use system_tray::item::StatusNotifierItem;
use system_tray::menu::TrayMenu;
use tracing::level_filters::LevelFilter;
use tracing::{debug, error};
use tracing_subscriber::EnvFilter;
fn main() -> iced_layershell::Result {
let timer =
tracing_subscriber::fmt::time::ChronoLocal::new("%Y-%m-%d_%I:%M:%S%.6f %P".to_owned());
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.parse_lossy("shelly=debug");
tracing_subscriber::FmtSubscriber::builder()
.pretty()
.with_line_number(true)
.with_level(true)
.with_target(true)
.with_env_filter(filter)
.with_target(true)
.with_timer(timer)
.init();
debug!("Starting shelly");
let mut font = Font::with_name("VictorMono Nerd Font");
font.weight = Weight::Bold;
let settings = MainSettings {
layer_settings: LayerShellSettings {
size: Some((1400, 60)),
anchor: Anchor::Bottom | Anchor::Left | Anchor::Right,
margin: (0, 0, 0, 0),
exclusive_zone: 60,
keyboard_interactivity: KeyboardInteractivity::None,
..Default::default()
},
default_font: font,
// antialiasing: true,
..Default::default()
};
application(Panel::namespace, Panel::update, Panel::view)
.subscription(Panel::subscription)
.settings(settings)
.style(Panel::style)
.run_with(Panel::new)
}
#[derive(Debug)]
struct Panel {
time: String,
cpu: String,
memory: String,
battery: Option<Battery>,
disk: String,
system: System,
disks: Disks,
workspaces: Vec<Workspace>,
active_workspace: i32,
active_window: String,
active_window_icon_svg: Option<svg::Handle>,
active_window_icon_raster: Option<Handle>,
system_tray: Option<SystemTray>,
apps: Vec<String>,
}
#[to_layer_message]
#[derive(Debug, Clone)]
enum Message {
Launch(usize),
Time(DateTime<Local>),
IcedEvent(Event),
WorkspaceChange(Vec<Workspace>),
ActiveWorkspaceChange(event_listener::WorkspaceEventData),
ActiveWindowChange((String, String)),
}
impl Panel {
fn new() -> (Self, Task<Message>) {
let workspaces = Workspaces::get().map_err(|e| {
error!("Couldn't get hyprland info: {}", e);
e
});
let client = Client::get_active().unwrap().unwrap();
let icon = lookup(&client.class)
.with_theme("Papirus Dark")
.with_cache()
.find();
let active_window_icon_svg = icon
.clone()
.filter(|icon| icon.ends_with("svg"))
.map(|icon| svg::Handle::from_path(icon));
let active_window_icon_raster = icon.map(|icon| image::Handle::from_path(icon));
let active_window = client.title;
let battery = match Battery::get() {
Ok(b) => Some(b),
Err(e) => {
error!("{e}");
None
}
};
(
Self {
system: System::new_all(),
disks: Disks::new_with_refreshed_list(),
workspaces: workspaces.unwrap().to_vec(),
active_workspace: 1,
time: String::new(),
cpu: String::new(),
memory: String::new(),
disk: String::new(),
apps: vec![String::new()],
active_window,
active_window_icon_svg,
active_window_icon_raster,
battery,
system_tray: None,
},
Task::none(),
)
}
fn namespace(&self) -> String {
String::from("panel")
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Launch(index) => {}
Message::Time(instant) => {
self.system.refresh_memory();
self.system.refresh_cpu_usage();
// disk.refresh();
// let used_space = disk.available_space() - disk.total_space();
let disk = self.disks.first_mut();
if let Some(disk) = disk {
disk.refresh();
self.disk = format!(
"󰋊 {}",
convert((disk.total_space() - disk.available_space()) as f64)
);
};
self.battery = match Battery::get() {
Ok(b) => Some(b),
Err(e) => {
error!("{e}");
None
}
};
// let used_space = 393;
// self.disk = format!("󰋊 {}", used_space);
self.cpu = format!("{}%", self.system.global_cpu_usage().round());
let memory = ((self.system.used_memory() as f64
/ self.system.total_memory() as f64)
.fract()
* 100.0)
.trunc();
self.memory = format!("{}%", memory);
self.time = instant.format("%a %b %d, %I:%M %p").to_string();
}
Message::AnchorChange(anchor) => todo!(),
Message::AnchorSizeChange(anchor, _) => todo!(),
Message::LayerChange(layer) => todo!(),
Message::MarginChange(_) => todo!(),
Message::SizeChange(_) => todo!(),
Message::VirtualKeyboardPressed { time, key } => todo!(),
Message::IcedEvent(event) => {
debug!(?event)
}
Message::WorkspaceChange(workspaces) => {
// debug!(?workspaces);
self.workspaces = workspaces;
}
Message::ActiveWorkspaceChange(data) => {
// debug!(?data);
self.active_workspace = data.id;
}
Message::ActiveWindowChange(w) => {
debug!(?w);
self.active_window = w.0;
let icon = lookup(&w.1)
.with_theme("Papirus-Dark")
.force_svg()
.with_size(16)
.find();
debug!(?icon);
self.active_window_icon_raster = icon
.clone()
.filter(|icon| !icon.ends_with("svg"))
.map(|icon| image::Handle::from_path(icon));
let svg = icon.map(|icon| svg::Handle::from_path(icon));
debug!(?svg);
self.active_window_icon_svg = svg;
}
_ => unreachable!(),
}
Task::none()
}
fn subscription(&self) -> Subscription<Message> {
let iced_events = event::listen().map(Message::IcedEvent);
let timed_events =
time::every(time::Duration::from_millis(1000)).map(|_| Message::Time(Local::now()));
let hyprland_events = self.hyprland_subscription();
Subscription::batch([iced_events, timed_events, hyprland_events])
}
fn hyprland_subscription(&self) -> Subscription<Message> {
Subscription::run(|| {
stream::channel(1, |mut sender| async move {
let mut workspaces = Workspaces::get_async()
.await
.map_err(|e| {
error!("Couldn't get hyprland info: {}", e);
e
})
.unwrap()
.to_vec();
workspaces.sort_by(|a, b| a.id.cmp(&b.id));
sender
.send(Message::WorkspaceChange(workspaces))
.await
.unwrap_or_else(|err| {
eprintln!("Trying to send workspaces failed with err: {err}");
});
if let Ok(window) = Client::get_active_async().await {
if let Some(w) = window {
sender
.send(Message::ActiveWindowChange((
w.title.trim().to_owned(),
w.initial_class.trim().to_owned(),
)))
.await
.unwrap_or_else(|e| {
error!("Trying to send window failed with err: {e}");
});
}
}
let mut listener = AsyncEventListener::new();
let senderx = sender.clone();
listener.add_active_window_changed_handler(move |data| {
let mut sender = senderx.clone();
Box::pin(async move {
if let Some(data) = data {
let client = Client::get_active().unwrap().unwrap();
sender
.send(Message::ActiveWindowChange((
client.title,
client.initial_class,
)))
.await
.unwrap();
}
})
});
let senderx = sender.clone();
listener.add_workspace_changed_handler(move |data| {
let mut sender = senderx.clone();
Box::pin(async move {
sender
.send(Message::ActiveWorkspaceChange(data))
.await
.unwrap_or_else(|e| error!("Error in sending: {e}"));
let mut workspaces = Workspaces::get_async()
.await
.map_err(|e| {
error!("Couldn't get hyprland info: {}", e);
e
})
.unwrap()
.to_vec();
workspaces.sort_by(|a, b| a.id.cmp(&b.id));
sender
.send(Message::WorkspaceChange(workspaces))
.await
.unwrap_or_else(|err| {
eprintln!("Trying to send workspaces failed with err: {err}");
});
})
});
listener
.start_listener_async()
.await
.expect("Failed to listen for hyprland events");
})
})
}
fn view(&self) -> Element<Message> {
let workspaces: Vec<Element<'_, Message>> = self
.workspaces
.iter()
.filter(|w| w.name != "special:special")
.map(|w| {
text!("{}", {
match w.id {
1 => "".to_owned(),
2 => "".to_owned(),
3 => "󰈙".to_owned(),
4 => "󰭹".to_owned(),
5 => "".to_owned(),
6 => "".to_owned(),
7 => "󰕧".to_owned(),
8 => "󰭹".to_owned(),
9 => "".to_owned(),
_ => w.name.clone(),
}
})
.color({
if w.id == self.active_workspace {
Color::parse("#ff6ac1").unwrap()
} else {
Color::parse("#57c7ff").unwrap()
}
})
.into()
})
.collect();
let workspaces = row(workspaces).spacing(20).padding([0, 5]);
let icon: Element<Message>;
// if let Some(handle) = &self.active_window_icon_raster {
// icon = image(handle).into();
// } else {
// if let Some(handle) = &self.active_window_icon_svg {
// icon = svg(handle.clone()).width(24).height(24).into();
// } else {
// icon = image("/home/chris/pics/wojaks/reddit_the_xenomorph_s bigass wojak folder/Chads/ChristianChad.png").into();
// }
// }
if let Some(handle) = &self.active_window_icon_svg {
icon = svg(handle.clone()).width(16).height(16).into();
} else {
icon = image("/home/chris/pics/wojaks/reddit_the_xenomorph_s bigass wojak folder/Chads/ChristianChad.png").into();
}
let icon = container(icon).center(Length::Shrink).padding(4);
let window = text!("{}", self.active_window).color(Color::parse("#57c7ff").unwrap());
let disk = text!("{}", self.disk).color(Color::parse("#ff9f43").unwrap());
let mem = text!("{}", self.memory).color(Color::parse("#f3f99d").unwrap());
let cpu = text!("{}", self.cpu).color(Color::parse("#57c7ff").unwrap());
let clock = text!("{}", self.time);
let clock = clock.color(Color::parse("#5af78e").unwrap());
let battery_icon = if let Some(battery) = &self.battery {
match battery.status {
BatteryStatus::Charging => "󱟦",
BatteryStatus::Charged => "󱟢",
BatteryStatus::Draining => "󱟤",
}
} else {
""
};
let battery = text!(
"{} {}",
battery_icon,
if let Some(battery) = &self.battery {
battery.capacity.to_string()
} else {
"".to_string()
}
)
.color(Color::parse("#ff6ac1").unwrap());
let row = row!(
container(row!(workspaces, icon, window, horizontal_space()).spacing(10)),
container(clock),
container(row!(horizontal_space(), disk, cpu, mem, battery).spacing(5))
)
.spacing(5)
.padding([0, 20]);
let mut shadow = Shadow::default();
shadow.color = Color::BLACK;
shadow.offset = Vector::new(4.5, 4.5);
shadow.blur_radius = 18.5;
container(
container(row)
.style(move |t: &iced::Theme| {
container::rounded_box(t)
.border(Border::default().rounded(20))
.background(
Background::Color(Color::parse("#282a36").unwrap()).scale_alpha(0.95),
)
.shadow(shadow)
})
.center(Length::Fill),
)
.padding([15, 20])
.center(Length::Fill)
.style(move |t: &iced::Theme| {
container::rounded_box(t)
.border(Border::default().rounded(20))
.background(Background::Color(Color::TRANSPARENT))
})
.into()
}
fn style(&self, theme: &iced::Theme) -> iced_layershell::Appearance {
use iced_layershell::Appearance;
Appearance {
background_color: Color::TRANSPARENT,
text_color: theme.palette().text,
}
}
}
pub fn convert(num: f64) -> String {
let negative = if num.is_sign_positive() { "" } else { "-" };
let num = num.abs();
let units = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
if num < 1_f64 {
return format!("{}{} {}", negative, num, "B");
}
let delimiter = 1000_f64;
let exponent = cmp::min(
(num.ln() / delimiter.ln()).floor() as i32,
(units.len() - 1) as i32,
);
let pretty_bytes = format!("{:.2}", num / delimiter.powi(exponent))
.parse::<f64>()
.unwrap()
* 1_f64;
let unit = units[exponent as usize];
format!("{}{} {}", negative, pretty_bytes, unit)
}
#[derive(Debug)]
struct Battery {
capacity: u8,
status: BatteryStatus,
}
impl Battery {
fn get() -> Result<Battery> {
let capacity = read_to_string("/sys/class/power_supply/BAT1/capacity")
.into_diagnostic()?
.trim()
.parse()
.into_diagnostic()?;
let status = match read_to_string("/sys/class/power_supply/BAT1/status")
.into_diagnostic()?
.trim()
{
"Not charging" => BatteryStatus::Charged,
"Discharging" => BatteryStatus::Draining,
"Charging" => BatteryStatus::Charging,
_ => BatteryStatus::Draining,
};
Ok(Self { capacity, status })
}
}
#[derive(Debug)]
enum BatteryStatus {
Charging,
Charged,
Draining,
}
#[derive(Debug)]
struct SystemTray {
client: system_tray::client::Client,
items: HashMap<String, (StatusNotifierItem, Option<TrayMenu>)>,
}
impl SystemTray {
async fn new() -> Result<Self> {
let client = system_tray::client::Client::new().await.into_diagnostic()?;
let items = client.items();
let items = items
.lock()
.map_err(|_| SystemTrayError::SomeError)
.into_diagnostic()?
.clone();
Ok(Self { client, items })
}
}
#[derive(Debug)]
enum SystemTrayError {
SomeError,
}
impl std::error::Error for SystemTrayError {}
impl Display for SystemTrayError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SystemTrayError::SomeError")
}
}