From 81f242419e6f25e4518b5e0ec08f9af6a47ac197 Mon Sep 17 00:00:00 2001 From: Doloro1978 Date: Mon, 31 Aug 2026 21:12:05 +0100 Subject: [PATCH] slop 3 --- src/bounds.rs | 45 +++++++ src/main.rs | 241 ++++++++++---------------------------- src/module.rs | 84 +++++++++++++ src/modules/clock.rs | 50 ++++++++ src/modules/mod.rs | 3 + src/modules/system.rs | 49 ++++++++ src/modules/workspaces.rs | 66 +++++++++++ 7 files changed, 359 insertions(+), 179 deletions(-) create mode 100644 src/bounds.rs create mode 100644 src/module.rs create mode 100644 src/modules/clock.rs create mode 100644 src/modules/mod.rs create mode 100644 src/modules/system.rs create mode 100644 src/modules/workspaces.rs diff --git a/src/bounds.rs b/src/bounds.rs new file mode 100644 index 0000000..7894bce --- /dev/null +++ b/src/bounds.rs @@ -0,0 +1,45 @@ +//! Widget-tree operation that captures the laid-out bounds of a clickable +//! bar module so a popup can be anchored to it. + +use iced::advanced::widget as advanced_widget; +use iced::widget::Id; +use iced::{Rectangle, Task}; + +/// Captures the laid-out bounds of the module with the given id, reported via +/// `BoundsFound`. +pub fn capture_bounds(label: String) -> Task { + let target = Id::from(label.clone()); + + struct FindBounds { + target: Id, + found: Option, + } + + impl advanced_widget::Operation for FindBounds { + fn traverse( + &mut self, + operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation), + ) { + operate(self); + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + if self.found.is_none() && id == Some(&self.target) { + self.found = Some(bounds); + } + } + + fn finish(&self) -> advanced_widget::operation::Outcome { + 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| crate::Message::BoundsFound(label.clone(), bounds)) +} diff --git a/src/main.rs b/src/main.rs index 87898f0..7335447 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + /// Which button triggered the open popup? None = closed. Keyed by module label. + active_popup: Option, /// Surface ID of the currently open popup (for removal). popup_id: Option, - /// Example workspace list populated when workspace menu opens. - workspaces: Vec, + /// Live module states, in registry order. + modules: Vec>, +} + +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), } -// --------------------------------------------------------------------------- -// 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 { + iced::Subscription::batch(bar.modules.iter().flat_map(|m| m.subscriptions())) +} fn update(bar: &mut Bar, msg: Message) -> Task { 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 { 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 { } } -/// Helper: closes the popup if one is open. fn maybe_close_popup(bar: &mut Bar) -> Task { if let Some(id) = bar.popup_id.take() { bar.active_popup = None; @@ -197,142 +169,53 @@ fn maybe_close_popup(bar: &mut Bar) -> Task { 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 { - let target = module.id(); - - struct FindBounds { - target: iced::widget::Id, - found: Option, - } - - impl advanced_widget::Operation for FindBounds { - fn traverse( - &mut self, - operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation), - ) { - // 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 { - 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> = 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> = 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, 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 { diff --git a/src/module.rs b/src/module.rs new file mode 100644 index 0000000..eb88920 --- /dev/null +++ b/src/module.rs @@ -0,0 +1,84 @@ +use std::any::Any; +use std::fmt::Debug; + +use iced::widget::{container, mouse_area, text, Id}; +use iced::{window, Alignment, Element, Length, Subscription, Task}; + +use crate::Message; + +/// A module's own message type, boxed for transport through [`Message::Module`]. +pub trait ModuleMsg: Any + Debug + Send + 'static { + fn clone_box(&self) -> Box; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} + +impl ModuleMsg for T { + fn clone_box(&self) -> Box { + Box::new((*self).clone()) + } +} + +/// Downcast a boxed module message to a concrete type. +pub fn downcast(boxed: Box) -> Result, ()> { + let erased: Box = boxed; + match erased.downcast::() { + Ok(t) => Ok(t), + Err(_) => Err(()), + } +} + +/// Static module definition: how to construct the live state. +pub trait ModuleDef: Send + Sync { + fn new_state(&self) -> Box; +} + +/// Live, object-safe module state held by the bar. +pub trait ModuleState: Send + 'static { + /// Stable label used as widget-tree id and message routing key. + fn label(&self) -> &'static str; + + /// Stable widget-tree id used for popup bounds capture. + fn id(&self) -> Id { + Id::from(self.label().to_string()) + } + + /// Render the module's clickable bar segment. + fn button(&self) -> Element<'static, Message> { + container( + mouse_area(text(self.label()).size(14)) + .on_press(Message::ModuleClicked(self.label().to_string())), + ) + .id(self.id()) + .into() + } + + /// Handle a message routed to this module. + fn handle(&mut self, msg: Box) -> Task; + + /// Render the popup body when this module's popup is open. + fn popup(&self, popup_id: window::Id) -> Element<'static, Message> { + let _ = popup_id; + text(format!("{} menu", self.label())) + .size(14) + .width(Length::Fill) + .align_x(Alignment::Center) + .into() + } + + /// Subscriptions this module wants to run. + fn subscriptions(&self) -> Vec> { + Vec::new() + } +} + +/// Registry of all bar modules, in display order. +pub static MODULES: &[&'static dyn ModuleDef] = &[ + &crate::modules::workspaces::Workspaces, + &crate::modules::clock::Clock, + &crate::modules::system::System, +]; diff --git a/src/modules/clock.rs b/src/modules/clock.rs new file mode 100644 index 0000000..57ede6e --- /dev/null +++ b/src/modules/clock.rs @@ -0,0 +1,50 @@ +use iced::widget::{column, mouse_area, text}; +use iced::{window, Alignment, Element, Length}; + +use crate::module::{ModuleDef, ModuleMsg, ModuleState}; +use crate::Message; + +pub struct Clock; + +pub struct State; + +impl ModuleDef for Clock { + fn new_state(&self) -> Box { + Box::new(State) + } +} + +impl ModuleState for State { + fn label(&self) -> &'static str { + "clock" + } + + // ponytail: clock has no internal messages yet; the tick subscription + // that gives it live time is the upgrade path (add `Msg` + `update` + // here when needed). + fn handle(&mut self, _msg: Box) -> iced::Task { + iced::Task::none() + } + + fn popup(&self, popup_id: window::Id) -> Element<'static, Message> { + let items: Vec> = ["Time format", "Date display"] + .into_iter() + .map(|label| { + mouse_area(text(label).size(13)) + .on_press(Message::ClosePopup(popup_id)) + .into() + }) + .collect(); + + column![ + text("clock menu") + .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() + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..308b27a --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1,3 @@ +pub mod clock; +pub mod system; +pub mod workspaces; diff --git a/src/modules/system.rs b/src/modules/system.rs new file mode 100644 index 0000000..bc647bc --- /dev/null +++ b/src/modules/system.rs @@ -0,0 +1,49 @@ +use iced::widget::{column, mouse_area, text}; +use iced::{window, Alignment, Element, Length}; + +use crate::module::{ModuleDef, ModuleMsg, ModuleState}; +use crate::Message; + +pub struct System; + +pub struct State; + +impl ModuleDef for System { + fn new_state(&self) -> Box { + Box::new(State) + } +} + +impl ModuleState for State { + fn label(&self) -> &'static str { + "system" + } + + // ponytail: no internal messages yet; real brightness/volume/power + // actions (each with its own `Msg` variant) are the upgrade path. + fn handle(&mut self, _msg: Box) -> iced::Task { + iced::Task::none() + } + + fn popup(&self, popup_id: window::Id) -> Element<'static, Message> { + let items: Vec> = ["Brightness", "Volume", "Power"] + .into_iter() + .map(|label| { + mouse_area(text(label).size(13)) + .on_press(Message::ClosePopup(popup_id)) + .into() + }) + .collect(); + + column![ + text("system menu") + .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() + } +} diff --git a/src/modules/workspaces.rs b/src/modules/workspaces.rs new file mode 100644 index 0000000..ce17002 --- /dev/null +++ b/src/modules/workspaces.rs @@ -0,0 +1,66 @@ +use iced::widget::{column, mouse_area, text, Row}; +use iced::{window, Alignment, Element, Length, Task}; + +use crate::module::{downcast, ModuleDef, ModuleMsg, ModuleState}; +use crate::Message; + +pub struct Workspaces; + +pub struct State { + workspaces: Vec, +} + +#[derive(Debug, Clone)] +pub enum Msg { + Select(String), +} + +impl ModuleDef for Workspaces { + fn new_state(&self) -> Box { + Box::new(State { + workspaces: vec!["1".into(), "2".into(), "3".into(), "4".into()], + }) + } +} + +impl ModuleState for State { + fn label(&self) -> &'static str { + "workspaces" + } + + fn handle(&mut self, msg: Box) -> Task { + let msg = *downcast::(msg).expect("workspaces module received foreign message"); + match msg { + Msg::Select(ws) => { + self.workspaces.sort_by_key(|w| w != &ws); + Task::none() + } + } + } + + fn popup(&self, popup_id: window::Id) -> Element<'static, Message> { + let items: Vec> = self + .workspaces + .iter() + .map(|ws| { + mouse_area(text(ws.clone()).size(13)) + .on_press(Message::Module( + "workspaces".into(), + Box::new(Msg::Select(ws.clone())), + )) + .into() + }) + .collect(); + + column![ + text("workspaces menu") + .size(14) + .width(Length::Fill) + .align_x(Alignment::Center), + Row::from_vec(items).spacing(1), + mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)), + ] + .spacing(1) + .into() + } +}