Bind each bar to its output palette and finish the island chrome
Some checks failed
dev release / build (push) Failing after 3m37s
Some checks failed
dev release / build (push) Failing after 3m37s
One layer-shell window per Hyprland output now loads that output's bread-theme palette. Notifications, history, and OSD follow the monitor they appear on. Pin bread-theme to v0.7.4.
This commit is contained in:
parent
ae1fee3591
commit
1806c6f912
14 changed files with 1652 additions and 754 deletions
|
|
@ -1,11 +1,21 @@
|
|||
use crate::{App, AppInput};
|
||||
use relm4::ComponentSender;
|
||||
|
||||
pub fn now() -> gtk4::glib::DateTime {
|
||||
gtk4::glib::DateTime::now_local().expect("local time")
|
||||
}
|
||||
|
||||
pub fn time() -> String {
|
||||
let dt = now();
|
||||
format!("{:02}:{:02}", dt.hour(), dt.minute())
|
||||
}
|
||||
|
||||
pub fn date() -> String {
|
||||
now().format("%a %d/%m").expect("date format").to_string()
|
||||
}
|
||||
|
||||
pub fn current() -> String {
|
||||
let dt = gtk4::glib::DateTime::now_local().expect("local time");
|
||||
let date = dt.format("%a %d/%m").expect("date format");
|
||||
let time = format!("{:02}:{:02}", dt.hour(), dt.minute());
|
||||
format!("{} {}", date, time)
|
||||
format!("{} {}", date(), time())
|
||||
}
|
||||
|
||||
pub fn spawn_ticker(sender: ComponentSender<App>) {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ pub fn spawn_set_brightness(v: f64) {
|
|||
});
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn spawn_set_sink(name: String) {
|
||||
relm4::spawn(async move {
|
||||
let _ = tokio::process::Command::new("pactl")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ pub const WIFI_MEDIUM: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "
|
|||
pub const WIFI_WEAK: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Weak.svg"));
|
||||
pub const WIFI_OFF: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/WiFi Disconnect.svg"));
|
||||
|
||||
/// Adwaita symbolic names — these are drawn for 16px status bars, not our
|
||||
/// hand-cropped Lucide arcs.
|
||||
pub const WIFI_ICON_EXCELLENT: &str = "network-wireless-signal-excellent-symbolic";
|
||||
pub const WIFI_ICON_GOOD: &str = "network-wireless-signal-good-symbolic";
|
||||
pub const WIFI_ICON_OK: &str = "network-wireless-signal-ok-symbolic";
|
||||
pub const WIFI_ICON_WEAK: &str = "network-wireless-signal-weak-symbolic";
|
||||
pub const WIFI_ICON_OFF: &str = "network-wireless-offline-symbolic";
|
||||
|
||||
pub const BAT_HIGH: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 3 Bars.svg"));
|
||||
pub const BAT_MID: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 2 Bars.svg"));
|
||||
pub const BAT_LOW: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/Battery 1 Bar.svg"));
|
||||
|
|
@ -65,6 +73,7 @@ pub struct Stats {
|
|||
pub gpu_temp: Option<f32>,
|
||||
pub net_rx_kbs: f32,
|
||||
pub net_tx_kbs: f32,
|
||||
pub volume_pct: u8,
|
||||
}
|
||||
|
||||
struct CpuSnapshot {
|
||||
|
|
@ -76,7 +85,7 @@ static PREV_CPU: OnceLock<Mutex<CpuSnapshot>> = OnceLock::new();
|
|||
static BAT_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
static AC_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
static WIFI_CACHE: LazyLock<Mutex<(String, &'static str)>> =
|
||||
LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_OFF)));
|
||||
LazyLock::new(|| Mutex::new(("—".to_string(), WIFI_ICON_OFF)));
|
||||
static WIFI_TICK: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
fn read_cpu() -> f32 {
|
||||
|
|
@ -271,7 +280,7 @@ fn wifi_iface() -> Option<&'static str> {
|
|||
|
||||
async fn read_wifi() -> (String, &'static str) {
|
||||
let Some(iface) = wifi_iface() else {
|
||||
return ("—".into(), WIFI_OFF);
|
||||
return ("—".into(), WIFI_ICON_OFF);
|
||||
};
|
||||
|
||||
let link_out = tokio::process::Command::new("iw")
|
||||
|
|
@ -281,7 +290,7 @@ async fn read_wifi() -> (String, &'static str) {
|
|||
.ok();
|
||||
let link_stdout = match link_out {
|
||||
Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
|
||||
_ => return ("—".into(), WIFI_OFF),
|
||||
_ => return ("—".into(), WIFI_ICON_OFF),
|
||||
};
|
||||
|
||||
let mut ssid = None;
|
||||
|
|
@ -296,13 +305,14 @@ async fn read_wifi() -> (String, &'static str) {
|
|||
}
|
||||
|
||||
let Some(ssid) = ssid else {
|
||||
return ("—".into(), WIFI_OFF);
|
||||
return ("—".into(), WIFI_ICON_OFF);
|
||||
};
|
||||
|
||||
let icon = match rssi {
|
||||
Some(r) if r >= -55 => WIFI_STRONG,
|
||||
Some(r) if r >= -70 => WIFI_MEDIUM,
|
||||
_ => WIFI_WEAK,
|
||||
Some(r) if r >= -55 => WIFI_ICON_EXCELLENT,
|
||||
Some(r) if r >= -70 => WIFI_ICON_GOOD,
|
||||
Some(r) if r >= -80 => WIFI_ICON_OK,
|
||||
_ => WIFI_ICON_WEAK,
|
||||
};
|
||||
|
||||
(ssid, icon)
|
||||
|
|
@ -413,7 +423,8 @@ pub async fn poll() -> Stats {
|
|||
let power_watts = read_power();
|
||||
let power = power_watts.map_or_else(|| "—W".into(), |w| format!("{w:.1}W"));
|
||||
let pct = read_battery();
|
||||
let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}%"));
|
||||
// Demo bar prints the bare number ("83"), not "83%".
|
||||
let bat = pct.map_or_else(|| "—".into(), |p| format!("{p}"));
|
||||
let bat_icon = pct.map_or(BAT_MID, bat_level_icon);
|
||||
let ac_connected = read_ac();
|
||||
// BT and WiFi both refresh every 8 cycles (~16 s); cache in between.
|
||||
|
|
@ -442,6 +453,7 @@ pub async fn poll() -> Stats {
|
|||
let gpu_usage = read_gpu_usage();
|
||||
let gpu_temp = read_gpu_temp();
|
||||
let (net_rx_kbs, net_tx_kbs) = read_net_throughput();
|
||||
let volume_pct = read_volume_pct();
|
||||
Stats {
|
||||
cpu: format!("{cpu:.0}%"),
|
||||
cpu_pct: cpu,
|
||||
|
|
@ -465,9 +477,30 @@ pub async fn poll() -> Stats {
|
|||
gpu_temp,
|
||||
net_rx_kbs,
|
||||
net_tx_kbs,
|
||||
volume_pct,
|
||||
}
|
||||
}
|
||||
|
||||
/// `wpctl get-volume` prints `Volume: 0.44 [MUTED]`. Scale to a 0–150 percent
|
||||
/// for the bar chip. Missing pipewire / wpctl degrades to 0 rather than
|
||||
/// blocking the rest of the poll.
|
||||
fn read_volume_pct() -> u8 {
|
||||
let out = std::process::Command::new("wpctl")
|
||||
.args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
|
||||
.output()
|
||||
.ok();
|
||||
let Some(o) = out.filter(|o| o.status.success()) else {
|
||||
return 0;
|
||||
};
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.strip_prefix("Volume:")
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|v| (v * 100.0).round().clamp(0.0, 150.0) as u8)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn spawn_poller(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
loop {
|
||||
|
|
|
|||
111
src/bar/wifi.rs
111
src/bar/wifi.rs
|
|
@ -24,6 +24,8 @@ pub struct ScanEntry {
|
|||
pub struct WifiPopoverData {
|
||||
pub profiles: Vec<(String, bool)>, // (name, is_active)
|
||||
pub scan: Vec<ScanEntry>,
|
||||
/// False while nmcli is still listing APs — profiles must still be usable.
|
||||
pub scan_ready: bool,
|
||||
}
|
||||
|
||||
async fn fetch_status() -> Option<CrumbsStatus> {
|
||||
|
|
@ -73,31 +75,57 @@ async fn fetch_profile_list() -> Vec<(String, bool)> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
async fn saved_ssids() -> std::collections::HashSet<String> {
|
||||
let out = tokio::process::Command::new("nmcli")
|
||||
.args(["-t", "-f", "NAME,TYPE", "connection", "show"])
|
||||
.output()
|
||||
.await;
|
||||
let Ok(o) = out else {
|
||||
return std::collections::HashSet::new();
|
||||
};
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (name, ty) = line.rsplit_once(':')?;
|
||||
if ty == "802-11-wireless" || ty == "wifi" {
|
||||
Some(name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cached AP list (no rescan). Fast enough to paint next to profiles.
|
||||
async fn fetch_scan() -> Vec<ScanEntry> {
|
||||
let Ok(Ok(out)) = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::process::Command::new("breadcrumbs")
|
||||
.args(["scan-list", "--json"])
|
||||
let out = tokio::time::timeout(
|
||||
Duration::from_secs(4),
|
||||
tokio::process::Command::new("nmcli")
|
||||
.args(["-t", "-f", "SSID,SIGNAL,IN-USE", "device", "wifi", "list"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
.await;
|
||||
let Ok(Ok(o)) = out else {
|
||||
return vec![];
|
||||
};
|
||||
let arr: Vec<serde_json::Value> =
|
||||
serde_json::from_slice(&out.stdout).unwrap_or_default();
|
||||
arr.into_iter()
|
||||
.filter_map(|v| {
|
||||
let ssid = v["ssid"].as_str()?.to_string();
|
||||
if ssid.is_empty() {
|
||||
let saved = saved_ssids().await;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.rsplitn(3, ':');
|
||||
let _in_use = parts.next()?;
|
||||
let signal = parts.next()?.parse::<u8>().ok().unwrap_or(0);
|
||||
let ssid = parts.next()?.replace("\\:", ":");
|
||||
if ssid.is_empty() || ssid == "--" || !seen.insert(ssid.clone()) {
|
||||
return None;
|
||||
}
|
||||
let signal = v["signal"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
let saved = v["saved"].as_bool().unwrap_or(false);
|
||||
Some(ScanEntry { ssid, signal, saved })
|
||||
let saved = saved.contains(&ssid);
|
||||
Some(ScanEntry {
|
||||
ssid,
|
||||
signal,
|
||||
saved,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -114,11 +142,32 @@ pub fn spawn_status_poller(sender: ComponentSender<App>) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Called when the popover opens — loads profiles + scan in parallel.
|
||||
/// Profiles first (so you can switch Home/Away immediately), then the
|
||||
/// cached AP list. A background rescan refreshes the list if it finds more.
|
||||
pub fn spawn_popover_load(sender: ComponentSender<App>) {
|
||||
relm4::spawn(async move {
|
||||
let (profiles, scan) = tokio::join!(fetch_profile_list(), fetch_scan());
|
||||
sender.input(AppInput::WifiPopoverData(WifiPopoverData { profiles, scan }));
|
||||
let profiles = fetch_profile_list().await;
|
||||
sender.input(AppInput::WifiPopoverData(WifiPopoverData {
|
||||
profiles: profiles.clone(),
|
||||
scan: vec![],
|
||||
scan_ready: false,
|
||||
}));
|
||||
let scan = fetch_scan().await;
|
||||
sender.input(AppInput::WifiPopoverData(WifiPopoverData {
|
||||
profiles: profiles.clone(),
|
||||
scan: scan.clone(),
|
||||
scan_ready: true,
|
||||
}));
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["device", "wifi", "rescan"])
|
||||
.output()
|
||||
.await;
|
||||
let scan = fetch_scan().await;
|
||||
sender.input(AppInput::WifiPopoverData(WifiPopoverData {
|
||||
profiles,
|
||||
scan,
|
||||
scan_ready: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -132,29 +181,27 @@ pub fn spawn_profile_set(name: String) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget: connect to a specific saved SSID via `breadcrumbs join`.
|
||||
/// Fire-and-forget: connect to a known SSID via NetworkManager.
|
||||
pub fn spawn_join(ssid: String) {
|
||||
relm4::spawn(async move {
|
||||
let _ = tokio::process::Command::new("breadcrumbs")
|
||||
.args(["join", &ssid])
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["device", "wifi", "connect", &ssid])
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Fire-and-forget: save a new network with its password, then join it.
|
||||
/// Save in breadcrumbs (if the CLI still accepts `add`) and connect with nmcli.
|
||||
pub fn spawn_add_and_join(ssid: String, password: String) {
|
||||
relm4::spawn(async move {
|
||||
let added = tokio::process::Command::new("breadcrumbs")
|
||||
let _ = tokio::process::Command::new("breadcrumbs")
|
||||
.args(["add", &ssid, &password])
|
||||
.output()
|
||||
.await;
|
||||
if matches!(added, Ok(o) if o.status.success()) {
|
||||
let _ = tokio::process::Command::new("breadcrumbs")
|
||||
.args(["join", &ssid])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
let _ = tokio::process::Command::new("nmcli")
|
||||
.args(["device", "wifi", "connect", &ssid, "password", &password])
|
||||
.output()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use futures_lite::StreamExt;
|
||||
use gtk4::glib::ControlFlow;
|
||||
use gtk4::prelude::*;
|
||||
use hyprland::{
|
||||
data::{Workspace, Workspaces},
|
||||
data::{Monitors, Workspaces},
|
||||
event_listener::{Event, EventStream},
|
||||
prelude::*,
|
||||
shared::WorkspaceId,
|
||||
|
|
@ -10,16 +15,62 @@ use relm4::ComponentSender;
|
|||
|
||||
use crate::AppInput;
|
||||
|
||||
/// Fetches the current workspace list + active workspace and pushes both to
|
||||
/// the app — used both for the initial state and to re-sync after the event
|
||||
/// stream reconnects (state may have changed while we were disconnected).
|
||||
/// Stock Hyprland accepts `hyprctl dispatch workspace N`. Lua-config
|
||||
/// Hyprland (BOS) rewrites that as `hl.dispatch(workspace N)`, which is
|
||||
/// a syntax error — the working form is `hl.dsp.focus({workspace=N})`.
|
||||
async fn switch_workspace(id: hyprland::shared::WorkspaceId) {
|
||||
let arg = id.to_string();
|
||||
let stock = tokio::process::Command::new("hyprctl")
|
||||
.args(["dispatch", "workspace", &arg])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = &stock {
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
let out = String::from_utf8_lossy(&o.stdout);
|
||||
if o.status.success() && !err.contains("hl.dispatch") && !out.contains("hl.dispatch") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let expr = format!("hl.dispatch(hl.dsp.focus({{workspace={arg}}}))");
|
||||
let lua = tokio::process::Command::new("hyprctl")
|
||||
.args(["eval", &expr])
|
||||
.output()
|
||||
.await;
|
||||
match lua {
|
||||
Ok(o) if o.status.success() => {}
|
||||
Ok(o) => eprintln!(
|
||||
"breadbar: workspace {arg}: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
),
|
||||
Err(e) => eprintln!("breadbar: workspace {arg}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stretch to the old→new span, then snap onto the destination — CSS
|
||||
/// transitions cannot widen a pill across two buttons, so the trail's
|
||||
/// Fixed allocation is interpolated on the frame clock instead.
|
||||
const STRETCH_MS: f64 = 220.0;
|
||||
const SNAP_MS: f64 = 380.0;
|
||||
|
||||
/// Full workspace + per-monitor active snapshot. Each bar filters this to
|
||||
/// its own output so a second display does not inherit the laptop's set.
|
||||
async fn sync_state(sender: &ComponentSender<crate::App>) {
|
||||
if let Ok(ws) = Workspaces::get_async().await {
|
||||
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
||||
}
|
||||
if let Ok(active) = Workspace::get_active_async().await {
|
||||
sender.input(AppInput::ActiveWorkspace(active.id));
|
||||
let workspaces = Workspaces::get_async()
|
||||
.await
|
||||
.map(|w| w.to_vec())
|
||||
.unwrap_or_default();
|
||||
let mut actives = std::collections::HashMap::new();
|
||||
if let Ok(mons) = Monitors::get_async().await {
|
||||
for m in mons {
|
||||
if !m.disabled {
|
||||
actives.insert(m.name, m.active_workspace.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
sender.input(AppInput::WorkspaceSync {
|
||||
workspaces,
|
||||
actives,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
||||
|
|
@ -41,13 +92,21 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
|||
while let Some(Ok(event)) = stream.next().await {
|
||||
backoff = std::time::Duration::from_millis(500);
|
||||
match event {
|
||||
Event::WorkspaceChanged(data) => {
|
||||
sender.input(AppInput::ActiveWorkspace(data.id));
|
||||
Event::WorkspaceChanged(_)
|
||||
| Event::WorkspaceAdded(_)
|
||||
| Event::WorkspaceDeleted(_) => {
|
||||
sync_state(&sender).await;
|
||||
}
|
||||
Event::WorkspaceAdded(_) | Event::WorkspaceDeleted(_) => {
|
||||
if let Ok(ws) = Workspaces::get_async().await {
|
||||
sender.input(AppInput::WorkspaceList(ws.to_vec()));
|
||||
}
|
||||
Event::MonitorAdded(data) => {
|
||||
sender.input(AppInput::MonitorAdded(data.name));
|
||||
sync_state(&sender).await;
|
||||
}
|
||||
Event::MonitorRemoved(name) => {
|
||||
sender.input(AppInput::MonitorRemoved(name));
|
||||
sync_state(&sender).await;
|
||||
}
|
||||
Event::ActiveWindowChanged(_) => {
|
||||
sender.input(AppInput::DismissPanels);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -65,17 +124,233 @@ pub fn spawn_watcher(sender: ComponentSender<crate::App>) {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn make_button(id: WorkspaceId, name: &str, active: WorkspaceId) -> gtk4::Button {
|
||||
pub fn make_button(
|
||||
id: WorkspaceId,
|
||||
name: &str,
|
||||
active: WorkspaceId,
|
||||
occupied: bool,
|
||||
) -> gtk4::Button {
|
||||
let btn = gtk4::Button::with_label(name);
|
||||
btn.add_css_class("workspace-btn");
|
||||
if occupied {
|
||||
btn.add_css_class("occupied");
|
||||
}
|
||||
if id == active {
|
||||
btn.add_css_class("active");
|
||||
}
|
||||
btn.set_valign(gtk4::Align::Center);
|
||||
btn.set_vexpand(false);
|
||||
btn.set_size_request(-1, crate::CHIP_HEIGHT);
|
||||
btn.connect_clicked(move |_| {
|
||||
use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial};
|
||||
let _ = Dispatch::call(DispatchType::Workspace(WorkspaceIdentifierWithSpecial::Id(
|
||||
id,
|
||||
)));
|
||||
relm4::spawn(async move {
|
||||
switch_workspace(id).await;
|
||||
});
|
||||
});
|
||||
btn
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Geom {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
}
|
||||
|
||||
struct TrailInner {
|
||||
tick: Option<gtk4::TickCallbackId>,
|
||||
geom: Geom,
|
||||
}
|
||||
|
||||
/// Overlay + Fixed pill sitting *behind* the workspace buttons. The
|
||||
/// Overlay's measured size comes from the button row; the pill is the
|
||||
/// main child so it paints underneath and never steals clicks.
|
||||
pub struct WorkspaceTrail {
|
||||
pub overlay: gtk4::Overlay,
|
||||
pub buttons: gtk4::Box,
|
||||
host: gtk4::Fixed,
|
||||
pill: gtk4::Box,
|
||||
inner: Rc<RefCell<TrailInner>>,
|
||||
}
|
||||
|
||||
impl WorkspaceTrail {
|
||||
pub fn new() -> Self {
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.add_css_class("workspace-overlay");
|
||||
overlay.set_valign(gtk4::Align::Center);
|
||||
overlay.set_vexpand(false);
|
||||
|
||||
let host = gtk4::Fixed::new();
|
||||
host.set_can_target(false);
|
||||
|
||||
let pill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
pill.add_css_class("workspace-trail");
|
||||
pill.set_can_target(false);
|
||||
pill.set_visible(false);
|
||||
host.put(&pill, 0.0, 0.0);
|
||||
|
||||
let buttons = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
|
||||
buttons.set_halign(gtk4::Align::Fill);
|
||||
buttons.set_valign(gtk4::Align::Center);
|
||||
buttons.set_vexpand(false);
|
||||
|
||||
overlay.set_child(Some(&host));
|
||||
overlay.add_overlay(&buttons);
|
||||
overlay.set_measure_overlay(&buttons, true);
|
||||
|
||||
let inner = Rc::new(RefCell::new(TrailInner {
|
||||
tick: None,
|
||||
geom: Geom {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 0.0,
|
||||
h: 0.0,
|
||||
},
|
||||
}));
|
||||
|
||||
Self {
|
||||
overlay,
|
||||
buttons,
|
||||
host,
|
||||
pill,
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
if let Some(id) = self.inner.borrow_mut().tick.take() {
|
||||
id.remove();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.cancel();
|
||||
self.pill.set_visible(false);
|
||||
self.inner.borrow_mut().geom.w = 0.0;
|
||||
}
|
||||
|
||||
pub fn place(&self, btn: >k4::Button) {
|
||||
self.cancel();
|
||||
if let Some(g) = button_geom(btn, &self.overlay) {
|
||||
self.apply(&g);
|
||||
return;
|
||||
}
|
||||
// First map: the button exists but has no allocation yet.
|
||||
let pill = self.pill.clone();
|
||||
let host = self.host.clone();
|
||||
let btn = btn.clone();
|
||||
let inner = self.inner.clone();
|
||||
let id = self.overlay.add_tick_callback(move |ov, _| {
|
||||
let Some(g) = button_geom(&btn, ov) else {
|
||||
return ControlFlow::Continue;
|
||||
};
|
||||
apply_geom(&host, &pill, &inner, &g);
|
||||
inner.borrow_mut().tick = None;
|
||||
ControlFlow::Break
|
||||
});
|
||||
self.inner.borrow_mut().tick = Some(id);
|
||||
}
|
||||
|
||||
pub fn stretch(&self, from: Option<>k4::Button>, to: >k4::Button) {
|
||||
let from_g = self.from_geom(from);
|
||||
if let Some(to_g) = button_geom(to, &self.overlay) {
|
||||
self.stretch_geom(from_g, to_g);
|
||||
return;
|
||||
}
|
||||
// Destination just appeared (empty workspace becoming active) and
|
||||
// has no allocation yet. Wait one layout pass, then stretch from
|
||||
// the last pill — don't snap via place().
|
||||
self.cancel();
|
||||
let overlay = self.overlay.clone();
|
||||
let pill = self.pill.clone();
|
||||
let host = self.host.clone();
|
||||
let inner = self.inner.clone();
|
||||
let btn = to.clone();
|
||||
let id = self.overlay.add_tick_callback(move |ov, _| {
|
||||
let Some(to_g) = button_geom(&btn, ov) else {
|
||||
return ControlFlow::Continue;
|
||||
};
|
||||
inner.borrow_mut().tick = None;
|
||||
stretch_geom_on(&overlay, &host, &pill, &inner, from_g, to_g);
|
||||
ControlFlow::Break
|
||||
});
|
||||
self.inner.borrow_mut().tick = Some(id);
|
||||
}
|
||||
|
||||
fn from_geom(&self, from: Option<>k4::Button>) -> Option<Geom> {
|
||||
let st = self.inner.borrow();
|
||||
if self.pill.is_visible() && st.geom.w > 0.5 {
|
||||
return Some(st.geom);
|
||||
}
|
||||
drop(st);
|
||||
from.and_then(|b| button_geom(b, &self.overlay))
|
||||
}
|
||||
|
||||
fn stretch_geom(&self, from_g: Option<Geom>, to_g: Geom) {
|
||||
stretch_geom_on(
|
||||
&self.overlay,
|
||||
&self.host,
|
||||
&self.pill,
|
||||
&self.inner,
|
||||
from_g,
|
||||
to_g,
|
||||
);
|
||||
}
|
||||
|
||||
fn apply(&self, g: &Geom) {
|
||||
apply_geom(&self.host, &self.pill, &self.inner, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn button_geom(btn: >k4::Button, overlay: >k4::Overlay) -> Option<Geom> {
|
||||
let r = btn.compute_bounds(overlay)?;
|
||||
let w = f64::from(r.width());
|
||||
let h = f64::from(r.height());
|
||||
if w < 1.0 || h < 1.0 {
|
||||
return None;
|
||||
}
|
||||
Some(Geom {
|
||||
x: f64::from(r.x()),
|
||||
y: f64::from(r.y()),
|
||||
w,
|
||||
h,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_geom(host: >k4::Fixed, pill: >k4::Box, inner: &Rc<RefCell<TrailInner>>, g: &Geom) {
|
||||
inner.borrow_mut().geom = Geom {
|
||||
x: g.x,
|
||||
y: g.y,
|
||||
w: g.w,
|
||||
h: g.h,
|
||||
};
|
||||
pill.set_size_request(g.w.max(1.0).round() as i32, g.h.max(1.0).round() as i32);
|
||||
host.move_(pill, g.x, g.y);
|
||||
pill.set_visible(true);
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||
a + (b - a) * t
|
||||
}
|
||||
|
||||
fn lerp_geom(a: &Geom, b: &Geom, t: f64) -> Geom {
|
||||
Geom {
|
||||
x: lerp(a.x, b.x, t),
|
||||
y: lerp(a.y, b.y, t),
|
||||
w: lerp(a.w, b.w, t),
|
||||
h: lerp(a.h, b.h, t),
|
||||
}
|
||||
}
|
||||
|
||||
fn ease(t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Approximates the demo's cubic-bezier(.22, 1.4, .36, 1) snap.
|
||||
fn ease_overshoot(t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let c = 1.4;
|
||||
let t1 = t - 1.0;
|
||||
1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue