Files
barbar/src/main.rs
T
2026-09-01 16:43:05 +01:00

261 lines
9.1 KiB
Rust

//! barbar — a Wayland layer-shell status bar built on `iced` + `iced_layershell`.
//!
//! Renders a full-width bar pinned to the top of the screen via the
//! `wlr-layer-shell` protocol. Optionally target a specific output by
//! passing its name as the first CLI argument.
//!
//! Clicking any button spawns a separate LayerShell popup surface anchored
//! to that button's position on the bar.
use std::collections::BTreeMap;
use iced::widget::{column, container, text};
use iced::{window, Alignment, Element, Length, Rectangle, Subscription, Task, Theme};
use iced_layershell::daemon;
use iced_layershell::reexport::Anchor;
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
use iced_layershell::to_layer_message;
use iced::futures::sink::SinkExt;
use crate::module::{BarModule, ModuleMsg};
use crate::subscriptions::clock::ClockTicker;
use crate::subscriptions::Subscription as ModuleSub;
use crate::subscriptions::SubscriptionPayloadKind;
mod module;
mod popup;
mod subscriptions;
/// Height of the bar in logical pixels.
const BAR_HEIGHT: u32 = 36;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// Application state. Tracks which popup is open and owns the module registry.
struct Bar {
/// Which module's button triggered the open popup? None = closed.
active_popup: Option<String>,
/// Module registry keyed by stable module id.
modules: BTreeMap<&'static str, Box<dyn BarModule>>,
/// Surface ID of the currently open popup (for removal).
popup_id: Option<window::Id>,
/// Fan-out routing table: module id -> the subscription kinds it wants.
/// Built once at boot from `BarModule::subscriptions()`.
routes: BTreeMap<&'static str, Vec<ModuleSub>>,
}
impl Bar {
/// Builds the module registry. The daemon calls this once at boot.
fn new() -> Self {
let mut modules: BTreeMap<&'static str, Box<dyn BarModule>> = BTreeMap::new();
modules.insert("clock", Box::new(crate::module::clock::Clock::new()));
let routes = modules
.iter()
.map(|(id, module)| (*id, module.subscriptions()))
.collect();
Self {
active_popup: None,
modules,
popup_id: None,
routes,
}
}
}
/// Messages produced by user input, events and subscriptions.
#[to_layer_message(multi)]
#[derive(Debug, Clone)]
pub(crate) enum Message {
Noop,
/// A routed module message; dispatch by `id` without naming its type.
Module(ModuleMsg),
/// A subscription event, pre-fanned by kind; `update` routes it.
Subscription(SubscriptionPayloadKind),
/// A bar module was clicked; requests a popup for that module.
/// Carries the module to run `view` from, and the element to stick to.
RequestPopup(String, String),
/// Internal: the widget-tree layout pass reported the laid-out bounds of
/// a module's clickable area; the popup is anchored there.
BoundsFound(String, Rectangle),
/// Closes the currently open popup.
ClosePopup(window::Id),
}
// ---------------------------------------------------------------------------
// Application wiring
// ---------------------------------------------------------------------------
fn namespace() -> String {
String::from("barbar")
}
fn main() -> Result<(), iced_layershell::Error> {
let start_mode = match std::env::args().nth(1) {
Some(output) => StartMode::TargetScreen(output),
None => StartMode::Active,
};
daemon(Bar::new, namespace, update, view)
.style(style)
.subscription(gather_subscriptions)
.settings(Settings {
layer_settings: LayerShellSettings {
size: Some((0, BAR_HEIGHT)),
exclusive_zone: BAR_HEIGHT as i32,
anchor: Anchor::Top | Anchor::Left | Anchor::Right,
start_mode,
..Default::default()
},
..Default::default()
})
.run()
}
fn gather_subscriptions(bar: &Bar) -> Subscription<Message> {
//TODO: Get a vec of subscription enum 'bar_wants' and spawn a batch depending on what the
//modules want.
let wants_clock = bar
.routes
.values()
.any(|kinds| kinds.iter().any(|k| matches!(k, ModuleSub::Clock(_))));
if !wants_clock {
return Subscription::none();
}
Subscription::run_with((), |_| {
iced::stream::channel(
0,
|mut sender: iced::futures::channel::mpsc::Sender<Message>| async move {
let mut clock = ClockTicker::new();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
loop {
interval.tick().await;
for payload in clock.tick() {
if sender
.send(Message::Subscription(SubscriptionPayloadKind::Clock(
payload,
)))
.await
.is_err()
{
return;
}
}
}
},
)
})
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
match msg {
Message::Module(m) => match bar.modules.get_mut(m.id) {
// The module produces its own (app-level) tasks, e.g. opening a
// popup for itself — nothing to map here.
Some(module) => module.update(m),
None => Task::none(),
},
Message::Subscription(kind) => match kind {
// Fan out by kind to every module whose route requests it. The
// payload kind is typed, so no downcast needed.
SubscriptionPayloadKind::Clock(payload) => bar
.modules
.iter_mut()
.filter(|(id, _)| {
bar.routes
.get(*id)
.is_some_and(|kinds| kinds.contains(&ModuleSub::Clock(payload.kind)))
})
.map(|(_, module)| module.update(ModuleMsg::new(module.id(), payload.clone())))
.fold(Task::none(), |acc, t| acc.chain(t)),
},
Message::RequestPopup(module_id, element_id) => {
// Toggle: close if already open, else open.
if bar.active_popup.as_deref() == Some(module_id.as_str()) {
popup::close_popup(bar)
} else {
// Query the widget tree for the element's laid-out bounds;
// the resulting `BoundsFound` opens the popup there.
popup::capture_bounds(module_id, element_id)
}
}
Message::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds),
Message::ClosePopup(id) => {
if bar.popup_id == Some(id) {
bar.popup_id = None;
bar.active_popup = None;
}
Task::done(Message::RemoveWindow(id))
}
Message::Noop => Task::none(),
// Forward multi-window mutations to their internal handlers.
_ => Task::none(),
}
}
// ---------------------------------------------------------------------------
// View — routed per-surface by window ID
// ---------------------------------------------------------------------------
/// Dispatch view by which surface is being rendered.
fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> {
// The bar is always created first. Any subsequently opened popup has a
// different IcedId we can distinguish. We compare against the bar's
// known ID (stored implicitly as "first window").
if bar.popup_id == Some(id) {
// This is the popup surface.
popup::view(bar)
} else {
// This is the main bar surface.
bar_view(bar)
}
}
/// Main bar widget tree. Each module renders itself; the app only lays the
/// modules out in a row. Popup requests are the module's own business: its
/// `update` returns the task that asks the app for a popup.
fn bar_view(bar: &Bar) -> Element<'_, Message> {
let mut children: Vec<Element<Message>> = Vec::new();
for (i, (_, module)) in bar.modules.iter().enumerate() {
children.push(module.view(None).map(Message::Module));
if i < bar.modules.len() - 1 {
children.push(text("|").size(14).into());
}
}
container(
column(children)
.width(Length::Fill)
.align_x(Alignment::Center),
)
.padding(8)
.align_x(Alignment::Center)
.into()
}
// ---------------------------------------------------------------------------
// Style
// ---------------------------------------------------------------------------
fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
use iced::theme::Style;
Style {
background_color: theme.palette().background,
text_color: theme.palette().text,
}
}