This commit is contained in:
2026-08-31 21:12:05 +01:00
parent f77cd4d815
commit 81f242419e
7 changed files with 359 additions and 179 deletions
+62 -179
View File
@@ -1,89 +1,56 @@
//! 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.
//! Wayland layer-shell status bar built on `iced` + `iced_layershell`.
use iced::advanced::widget as advanced_widget;
use iced::widget::{column, container, mouse_area, text};
mod bounds;
mod module;
mod modules;
use iced::widget::{container, text, Row};
use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme};
use iced_layershell::daemon;
use iced_layershell::reexport::{Anchor, PopupAnchor, PopupGravity};
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
use iced_layershell::to_layer_message;
use crate::module::{ModuleMsg, ModuleState, MODULES};
/// Height of the bar in logical pixels.
const BAR_HEIGHT: u32 = 36;
/// Popup dimensions.
const POPUP_W: u32 = 150;
const POPUP_H: u32 = 100;
/// Vertical gap (logical px) between the bar's bottom edge and the popup.
const POPUP_GAP: i32 = 32;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// Identifies which bar module triggered an action.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Module {
Workspace,
Clock,
System,
}
impl Module {
const ALL: &'static [Self] = &[Self::Workspace, Self::Clock, Self::System];
fn label(&self) -> &'static str {
match self {
Self::Workspace => "workspaces",
Self::Clock => "clock",
Self::System => "system",
}
}
/// Stable widget-tree id used to look up the laid-out bounds of the
/// module's clickable area.
fn id(&self) -> iced::widget::Id {
iced::widget::Id::new(self.label())
}
}
/// Application state. Tracks which popup is open and shared module data.
#[derive(Debug, Default)]
struct Bar {
/// Which button triggered the open popup? None = closed.
active_popup: Option<Module>,
/// Which button triggered the open popup? None = closed. Keyed by module label.
active_popup: Option<String>,
/// Surface ID of the currently open popup (for removal).
popup_id: Option<window::Id>,
/// Example workspace list populated when workspace menu opens.
workspaces: Vec<String>,
/// Live module states, in registry order.
modules: Vec<Box<dyn ModuleState>>,
}
impl Default for Bar {
fn default() -> Self {
Self {
active_popup: None,
popup_id: None,
modules: MODULES.iter().map(|m| m.new_state()).collect(),
}
}
}
/// Messages produced by user input, events and subscriptions.
#[to_layer_message(multi)]
#[derive(Debug, Clone)]
enum Message {
Noop,
/// A bar module was clicked; opens or toggles a popup.
ModuleClicked(Module),
/// Internal: the widget-tree layout pass reported the laid-out bounds of
/// a module's clickable area; the popup is anchored there.
BoundsFound(Module, Rectangle),
/// Closes the currently open popup.
ModuleClicked(String),
BoundsFound(String, Rectangle),
ClosePopup(window::Id),
Module(String, Box<dyn ModuleMsg>),
}
// ---------------------------------------------------------------------------
// Application wiring
// ---------------------------------------------------------------------------
fn namespace() -> String {
String::from("barbar")
}
@@ -96,6 +63,7 @@ fn main() -> Result<(), iced_layershell::Error> {
daemon(Bar::default, namespace, update, view)
.style(style)
.subscription(subscription)
.settings(Settings {
layer_settings: LayerShellSettings {
size: Some((0, BAR_HEIGHT)),
@@ -109,25 +77,26 @@ fn main() -> Result<(), iced_layershell::Error> {
.run()
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
/// Folds module subscriptions into one daemon subscription.
fn subscription(bar: &Bar) -> iced::Subscription<Message> {
iced::Subscription::batch(bar.modules.iter().flat_map(|m| m.subscriptions()))
}
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
match msg {
Message::ModuleClicked(module) => {
Message::ModuleClicked(label) => {
// Toggle: close if already open, else open.
if bar.active_popup == Some(module) {
if bar.active_popup.as_deref() == Some(label.as_str()) {
return maybe_close_popup(bar);
}
// Query the widget tree for the module's actual laid-out bounds.
// The resulting `BoundsFound` message opens the popup there.
capture_bounds(module)
bounds::capture_bounds(label)
}
Message::BoundsFound(module, bounds) => {
bar.active_popup = Some(module);
Message::BoundsFound(label, bounds) => {
bar.active_popup = Some(label.clone());
// Anchor rect = the button's bounds, extended below the bar so the
// `Bottom` anchor point (bottom-center) sits POPUP_GAP below the
@@ -150,14 +119,18 @@ fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
let (id, task) = Message::popup_open(settings);
bar.popup_id = Some(id);
// Seed example data for the workspace menu.
if module == Module::Workspace {
bar.workspaces = vec!["1".into(), "2".into(), "3".into(), "4".into()];
}
task
}
Message::Module(label, msg) => {
let state = bar
.modules
.iter_mut()
.find(|m| m.label() == label)
.expect("unknown module label");
state.handle(msg)
}
Message::ClosePopup(id) => {
if bar.popup_id == Some(id) {
bar.popup_id = None;
@@ -188,7 +161,6 @@ fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
}
}
/// Helper: closes the popup if one is open.
fn maybe_close_popup(bar: &mut Bar) -> Task<Message> {
if let Some(id) = bar.popup_id.take() {
bar.active_popup = None;
@@ -197,142 +169,53 @@ fn maybe_close_popup(bar: &mut Bar) -> Task<Message> {
Task::none()
}
/// Runs a widget-tree [`Operation`] that captures the laid-out bounds of the
/// given module's clickable area, then reports them via `BoundsFound`.
fn capture_bounds(module: Module) -> Task<Message> {
let target = module.id();
struct FindBounds {
target: iced::widget::Id,
found: Option<Rectangle>,
}
impl advanced_widget::Operation<Rectangle> for FindBounds {
fn traverse(
&mut self,
operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>),
) {
// Delegate traversal so widgets can visit their children; the
// container id is matched in `container` above.
operate(self);
}
fn container(&mut self, id: Option<&iced::widget::Id>, bounds: Rectangle) {
if self.found.is_none() && id == Some(&self.target) {
self.found = Some(bounds);
}
}
fn finish(&self) -> advanced_widget::operation::Outcome<Rectangle> {
match self.found {
Some(bounds) => advanced_widget::operation::Outcome::Some(bounds),
None => advanced_widget::operation::Outcome::None,
}
}
}
advanced_widget::operate(FindBounds {
target,
found: None,
})
.map(move |bounds| Message::BoundsFound(module, bounds))
}
// ---------------------------------------------------------------------------
// 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.
fn bar_view(_bar: &Bar) -> Element<'static, Message> {
fn bar_view(bar: &Bar) -> Element<'static, Message> {
let mut children: Vec<Element<Message>> = Vec::new();
for (i, module) in Module::ALL.iter().enumerate() {
children.push(button(*module));
if i < Module::ALL.len() - 1 {
for (i, module) in bar.modules.iter().enumerate() {
children.push(module.button());
if i < bar.modules.len() - 1 {
children.push(text("|").size(14).into());
}
}
container(
column(children)
Row::from_vec(children)
.width(Length::Fill)
.align_x(Alignment::Center),
.align_y(Alignment::Center),
)
.padding(8)
.align_x(Alignment::Center)
.into()
}
/// Standalone button wrapped in MouseArea for click capture. The container
/// carries the module's id so a widget-tree [`Operation`] can find its
/// laid-out bounds for popup anchoring.
fn button(module: Module) -> Element<'static, Message> {
container(mouse_area(text(module.label()).size(14)).on_press(Message::ModuleClicked(module)))
.id(module.id())
.into()
}
/// Popup widget tree. Rendered on its own floating LayerShell surface.
fn popup_view<'a>(bar: &'a Bar) -> Element<'a, Message> {
let module = bar.active_popup.unwrap();
let popup_id = bar.popup_id.unwrap();
let label = module.label();
fn popup_view(bar: &Bar) -> Element<'static, Message> {
let label = bar
.active_popup
.as_ref()
.expect("popup open without active module");
let popup_id = bar.popup_id.expect("popup open without surface id");
// Build list items based on which module was clicked.
let items: Vec<Element<Message>> = match module {
Module::Workspace => bar
.workspaces
.iter()
.map(|ws| popup_item(ws.clone(), popup_id))
.collect(),
Module::Clock => vec![
popup_item("Time format", popup_id),
popup_item("Date display", popup_id),
],
Module::System => vec![
popup_item("Brightness", popup_id),
popup_item("Volume", popup_id),
popup_item("Power", popup_id),
],
};
let module = bar
.modules
.iter()
.find(|m| m.label() == label)
.expect("unknown module label");
column![
text(format!("{} menu", label))
.size(14)
.width(Length::Fill)
.align_x(Alignment::Center),
column(items).spacing(1),
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
]
.spacing(1)
.into()
module.popup(popup_id)
}
/// Generic clickable item inside a popup.
fn popup_item(label: impl Into<String>, popup_id: window::Id) -> Element<'static, Message> {
mouse_area(text(label.into()).size(13))
.on_press(Message::ClosePopup(popup_id))
.into()
}
// ---------------------------------------------------------------------------
// Style
// ---------------------------------------------------------------------------
fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
use iced::theme::Style;
Style {