123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 | //! Tauri commands invoked by widgets for user-initiated controls.
//! Commands that change volume/brightness return the fresh state so the
//! widget can update immediately instead of waiting for the next stats poll.
use crate::readers;
use serde_json::Value;
use std::process::Command;
#[tauri::command]
pub async fn hlwm_use_tag(tag: String) -> Result<(), String> {
readers::run("herbstclient", &["use", &tag])
.map(|_| ())
.ok_or_else(|| format!("failed to switch to tag {tag}"))
}
#[tauri::command]
pub async fn set_volume(percent: u32) -> Result<Value, String> {
let level = format!("{}%", percent.min(100));
readers::run("wpctl", &["set-volume", "@DEFAULT_AUDIO_SINK@", &level])
.ok_or("wpctl set-volume failed")?;
Ok(readers::read_volume())
}
#[tauri::command]
pub async fn toggle_mute() -> Result<Value, String> {
readers::run("wpctl", &["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"])
.ok_or("wpctl set-mute failed")?;
Ok(readers::read_volume())
}
#[tauri::command]
pub async fn set_brightness(percent: u32) -> Result<Value, String> {
let level = format!("{}%", percent.min(100));
readers::run("brightnessctl", &["-m", "set", &level])
.ok_or("brightnessctl set failed")?;
Ok(readers::read_brightness())
}
/// Puts the display to sleep via DPMS; any input wakes it back up.
#[tauri::command]
pub async fn display_off() -> Result<(), String> {
readers::run("xset", &["dpms", "force", "off"])
.map(|_| ())
.ok_or_else(|| "xset dpms force off failed".to_string())
}
/// The open popup's bounding box in frontend CSS pixels.
#[derive(serde::Deserialize)]
pub struct PopupRect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
/// Toggles the window's interactive region when a popup opens/closes.
/// With a compositor: input region only (bar strip, or the full window
/// while open so click-outside closes). Without one: the same region is
/// also the visible bounding shape, so it must be exactly the bar strip
/// plus the popup's rectangle — anything else would render black.
#[tauri::command]
pub async fn set_popup_open(
window: tauri::WebviewWindow,
geometry: tauri::State<'_, std::sync::Mutex<crate::Geometry>>,
open: bool,
rect: Option<PopupRect>,
) -> Result<(), String> {
let (width, bar, total, composited, s) = {
let g = geometry.lock().unwrap();
(
g.width_px as i32,
g.bar_px as i32,
g.total_px as i32,
g.composited,
g.css_scale,
)
};
let mut rects = vec![(0, 0, width, bar)];
if open {
if composited {
rects = vec![(0, 0, width, total)];
} else if let Some(r) = rect {
// Matches the .dpopup border-radius in src/lib/slider-popup.js.
let radius = 14.0 * s;
push_bottom_rounded_rect(
&mut rects,
r.x * s,
r.y * s,
r.width * s,
r.height * s,
radius,
);
} else {
rects.push((0, bar, width, total - bar));
}
}
let win = window.clone();
window
.run_on_main_thread(move || {
#[cfg(target_os = "linux")]
{
crate::linux::set_region(&win, &rects, !composited);
// A popup's controls (e.g. the weather widget's location
// field) need real X keyboard focus, which our dock-type
// window doesn't get automatically. Grabbed while any
// popup is open — harmless for pointer-only ones — and
// handed back to the WM's own focused client on close.
if open {
crate::linux::grab_input_focus(&win);
}
}
#[cfg(not(target_os = "linux"))]
let _ = (&win, rects, composited);
})
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
if !open {
crate::hlwm::restore_focus();
}
Ok(())
}
/// Approximates a rectangle with rounded bottom corners as a stack of
/// scanline rectangles (X11 shape regions are rectangle unions). The top
/// corners stay square — the popup sits flush against the bar.
fn push_bottom_rounded_rect(
rects: &mut Vec<(i32, i32, i32, i32)>,
x: f64,
y: f64,
w: f64,
h: f64,
radius: f64,
) {
let xi = x.floor() as i32;
let yi = y.floor() as i32;
let wi = w.ceil() as i32;
let hi = h.ceil() as i32;
let r = (radius.round() as i32).min(wi / 2).min(hi);
rects.push((xi, yi, wi, hi - r));
for i in 0..r {
let dy = (i + 1) as f64; // distance below the corner-circle center
let dx = (r as f64 - ((r as f64).powi(2) - dy * dy).max(0.0).sqrt()) as i32;
rects.push((xi + dx, yi + hi - r + i, wi - 2 * dx, 1));
}
}
/// Environment info the frontend adapts to (e.g. squared-off popups
/// without a compositor, since shaped windows can't have soft shadows or
/// rounded transparent corners).
#[tauri::command]
pub async fn get_env(
geometry: tauri::State<'_, std::sync::Mutex<crate::Geometry>>,
) -> Result<Value, String> {
let composited = geometry.lock().unwrap().composited;
Ok(serde_json::json!({ "composited": composited }))
}
/// Latest payload of every event channel, keyed by channel name — replayed
/// by the frontend at startup (the initial emits fire before it loads).
#[tauri::command]
pub async fn get_latest_states() -> Result<Value, String> {
Ok(crate::events::latest_states())
}
/// Sends a playback command ("playPause" | "next" | "previous") to the
/// active MPRIS player.
#[tauri::command]
pub async fn media_control(action: String) -> Result<(), String> {
crate::media::control(&action).await
}
/// Forwards a click on a tray icon to the owning application.
#[tauri::command]
pub async fn tray_activate(
address: String,
secondary: bool,
x: i32,
y: i32,
) -> Result<(), String> {
crate::tray::activate(address, secondary, x, y).await
}
/// Forwards a tray context-menu item click to the owning application.
#[tauri::command]
pub async fn tray_menu_click(address: String, menu_path: String, id: i32) -> Result<(), String> {
crate::tray::menu_click(address, menu_path, id).await
}
#[tauri::command]
pub async fn get_config() -> Result<Value, String> {
Ok(crate::config::load())
}
#[tauri::command]
pub async fn get_default_config() -> Result<Value, String> {
Ok(crate::config::default_config())
}
/// Persists the config and broadcasts it so the bar re-renders live. Also
/// re-applies the window geometry if the bar height setting changed.
#[tauri::command]
pub async fn save_config(app: tauri::AppHandle, config: Value) -> Result<(), String> {
use tauri::Emitter;
if !config.get("widgets").is_some_and(Value::is_array) {
return Err("config must have a widgets array".into());
}
crate::config::save(&config)?;
app.emit("divebar://config", &config)
.map_err(|e| e.to_string())?;
apply_bar_height(&app, crate::bar_height_from(&config));
// In case the weather widget's location option changed.
crate::weather::request_refresh();
Ok(())
}
/// Resizes the bar window, strut, and shapes for a new logical bar height.
fn apply_bar_height(app: &tauri::AppHandle, height_logical: f64) {
use tauri::Manager;
let state = app.state::<std::sync::Mutex<crate::Geometry>>();
let (width, bar_px, total_px, composited, origin_x) = {
let mut g = state.lock().unwrap();
let new_bar = (height_logical * g.css_scale).round() as u32;
if new_bar == g.bar_px {
return;
}
g.bar_px = new_bar;
g.total_px = ((height_logical + crate::POPUP_SPACE) * g.css_scale).round() as u32;
(g.width_px, g.bar_px, g.total_px, g.composited, g.origin_x)
};
let Some(win) = app.get_webview_window("main") else {
return;
};
let _ = win.set_size(tauri::PhysicalSize::new(width, total_px));
#[cfg(target_os = "linux")]
{
let win2 = win.clone();
let _ = win.run_on_main_thread(move || {
crate::linux::set_strut(&win2, bar_px, width, origin_x);
crate::linux::set_region(&win2, &[(0, 0, width as i32, bar_px as i32)], !composited);
});
}
#[cfg(not(target_os = "linux"))]
let _ = (bar_px, composited, origin_x);
}
/// Opens (or focuses) the settings window: a normal floating dialog, not
/// part of the bar's dock window.
#[tauri::command]
pub async fn open_settings(
app: tauri::AppHandle,
geometry: tauri::State<'_, std::sync::Mutex<crate::Geometry>>,
) -> Result<(), String> {
use tauri::Manager;
if let Some(win) = app.get_webview_window("settings") {
let _ = win.show();
let _ = win.set_focus();
return Ok(());
}
let zoom = geometry.lock().unwrap().zoom;
let win = tauri::WebviewWindowBuilder::new(
&app,
"settings",
tauri::WebviewUrl::App("settings.html".into()),
)
.title("divebar settings")
.inner_size(560.0 * zoom, 580.0 * zoom)
.center()
.visible(false)
.build()
.map_err(|e| e.to_string())?;
if zoom != 1.0 {
let _ = win.set_zoom(zoom);
}
// Show only after the dialog type hint is set: mapping first and
// hinting after left the WM briefly managing it as a normal tiled
// window, which delayed its appearance by ~1s under herbstluftwm.
let show_win = win.clone();
let _ = win.run_on_main_thread(move || {
#[cfg(target_os = "linux")]
crate::linux::make_dialog(&show_win);
let _ = show_win.show();
let _ = show_win.set_focus();
});
Ok(())
}
/// Lists installed font families (via fontconfig) for font-picker settings.
#[tauri::command]
pub async fn list_fonts() -> Result<Vec<String>, String> {
let output = readers::run("fc-list", &[":", "family"]).ok_or("fc-list failed")?;
let mut families = std::collections::BTreeSet::new();
for line in output.lines() {
// Lines are comma-separated family aliases; the first is canonical.
if let Some(first) = line.split(',').next() {
let name = first.trim();
if !name.is_empty() {
families.insert(name.to_string());
}
}
}
Ok(families.into_iter().collect())
}
/// Resolves a weather location string (empty = IP auto-detect) to the
/// area wttr.in will actually report on — the settings window's live
/// location preview.
#[tauri::command]
pub async fn resolve_location(location: String) -> Result<Value, String> {
crate::weather::resolve(&location)
.ok_or_else(|| "couldn't resolve this location".to_string())
}
#[tauri::command]
pub async fn close_settings(app: tauri::AppHandle) -> Result<(), String> {
use tauri::Manager;
if let Some(win) = app.get_webview_window("settings") {
win.close().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn launch_wifi_gui() -> Result<(), String> {
let child = Command::new("/usr/local/bin/wifi-gui")
.spawn()
.map_err(|e| format!("failed to launch wifi-gui: {e}"))?;
// Reap the child in the background so it doesn't linger as a zombie.
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(())
}
/// Locks the screen by launching `slock`.
#[tauri::command]
pub async fn lock_screen() -> Result<(), String> {
let child = Command::new("slock")
.spawn()
.map_err(|e| format!("failed to launch slock: {e}"))?;
// Reap the child in the background so it doesn't linger as a zombie
// once the user unlocks the screen.
std::thread::spawn(move || {
let mut child = child;
let _ = child.wait();
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn radius_zero_is_one_full_rect() {
let mut rects = Vec::new();
push_bottom_rounded_rect(&mut rects, 10.0, 20.0, 100.0, 50.0, 0.0);
assert_eq!(rects, vec![(10, 20, 100, 50)]);
}
#[test]
fn rounded_corners_cut_monotonically_and_stay_in_bounds() {
let mut rects = Vec::new();
push_bottom_rounded_rect(&mut rects, 0.0, 0.0, 100.0, 50.0, 14.0);
// Body rect + one scanline per radius row.
assert_eq!(rects.len(), 15);
assert_eq!(rects[0], (0, 0, 100, 36));
let mut last_width = i32::MAX;
for (i, &(x, y, w, h)) in rects[1..].iter().enumerate() {
assert_eq!(h, 1);
assert_eq!(y, 36 + i as i32);
assert!(w <= last_width, "scanlines must narrow toward the bottom");
assert!(x >= 0 && x + w <= 100, "scanline must stay inside the rect");
assert!(w > 0);
last_width = w;
}
// The bottom row of a 14px-radius corner is meaningfully narrower.
assert!(rects.last().unwrap().2 < 100 - 14);
}
#[test]
fn radius_clamped_to_half_width() {
let mut rects = Vec::new();
push_bottom_rounded_rect(&mut rects, 0.0, 0.0, 10.0, 50.0, 14.0);
// r clamps to w/2 = 5: one body rect + 5 scanlines.
assert_eq!(rects.len(), 6);
assert_eq!(rects[0], (0, 0, 10, 45));
}
}
|