Compare commits

..
2 Commits
Author SHA1 Message Date
doloro 6e82d0a175 pop up 2026-08-31 23:03:24 +01:00
doloro cc5db9089a fresh-start 2026-08-31 22:51:49 +01:00
9 changed files with 208 additions and 364 deletions
Generated
+23
View File
@@ -104,6 +104,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"iced", "iced",
"iced_layershell", "iced_layershell",
"tokio",
] ]
[[package]] [[package]]
@@ -1061,6 +1062,7 @@ dependencies = [
"iced_core", "iced_core",
"log", "log",
"rustc-hash 2.1.3", "rustc-hash 2.1.3",
"tokio",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasmtimer", "wasmtimer",
] ]
@@ -2667,6 +2669,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
+2 -1
View File
@@ -4,5 +4,6 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced"] } iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced", "tokio"] }
iced_layershell = { version = "0.19", default-features = false } iced_layershell = { version = "0.19", default-features = false }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
-45
View File
@@ -1,45 +0,0 @@
//! 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<crate::Message> {
let target = Id::from(label.clone());
struct FindBounds {
target: 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>),
) {
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<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| crate::Message::BoundsFound(label.clone(), bounds))
}
+182 -65
View File
@@ -1,56 +1,89 @@
//! Wayland layer-shell status bar built on `iced` + `iced_layershell`. //! 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.
mod bounds; use iced::advanced::widget as advanced_widget;
mod module; use iced::widget::{column, container, mouse_area, text};
mod modules;
use iced::widget::{container, text, Row};
use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme}; use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme};
use iced_layershell::daemon; use iced_layershell::daemon;
use iced_layershell::reexport::{Anchor, PopupAnchor, PopupGravity}; use iced_layershell::reexport::{Anchor, PopupAnchor, PopupGravity};
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode}; use iced_layershell::settings::{LayerShellSettings, StartMode, Settings};
use iced_layershell::to_layer_message; use iced_layershell::to_layer_message;
use crate::module::{ModuleMsg, ModuleState, MODULES};
/// Height of the bar in logical pixels. /// Height of the bar in logical pixels.
const BAR_HEIGHT: u32 = 36; const BAR_HEIGHT: u32 = 36;
/// Popup dimensions.
const POPUP_W: u32 = 150; const POPUP_W: u32 = 150;
const POPUP_H: u32 = 100; const POPUP_H: u32 = 100;
/// Vertical gap (logical px) between the bar's bottom edge and the popup. /// Vertical gap (logical px) between the bar's bottom edge and the popup.
const POPUP_GAP: i32 = 32; 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 { struct Bar {
/// Which button triggered the open popup? None = closed. Keyed by module label. /// Which button triggered the open popup? None = closed.
active_popup: Option<String>, active_popup: Option<Module>,
/// Surface ID of the currently open popup (for removal). /// Surface ID of the currently open popup (for removal).
popup_id: Option<window::Id>, popup_id: Option<window::Id>,
/// Live module states, in registry order. /// Example workspace list populated when workspace menu opens.
modules: Vec<Box<dyn ModuleState>>, workspaces: Vec<String>,
}
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)] #[to_layer_message(multi)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Message { enum Message {
Noop, Noop,
ModuleClicked(String), /// A bar module was clicked; opens or toggles a popup.
BoundsFound(String, Rectangle), 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.
ClosePopup(window::Id), ClosePopup(window::Id),
Module(String, Box<dyn ModuleMsg>),
} }
// ---------------------------------------------------------------------------
// Application wiring
// ---------------------------------------------------------------------------
fn namespace() -> String { fn namespace() -> String {
String::from("barbar") String::from("barbar")
} }
@@ -63,7 +96,6 @@ fn main() -> Result<(), iced_layershell::Error> {
daemon(Bar::default, namespace, update, view) daemon(Bar::default, namespace, update, view)
.style(style) .style(style)
.subscription(subscription)
.settings(Settings { .settings(Settings {
layer_settings: LayerShellSettings { layer_settings: LayerShellSettings {
size: Some((0, BAR_HEIGHT)), size: Some((0, BAR_HEIGHT)),
@@ -77,26 +109,25 @@ fn main() -> Result<(), iced_layershell::Error> {
.run() .run()
} }
/// Folds module subscriptions into one daemon subscription. // ---------------------------------------------------------------------------
fn subscription(bar: &Bar) -> iced::Subscription<Message> { // Update
iced::Subscription::batch(bar.modules.iter().flat_map(|m| m.subscriptions())) // ---------------------------------------------------------------------------
}
fn update(bar: &mut Bar, msg: Message) -> Task<Message> { fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
match msg { match msg {
Message::ModuleClicked(label) => { Message::ModuleClicked(module) => {
// Toggle: close if already open, else open. // Toggle: close if already open, else open.
if bar.active_popup.as_deref() == Some(label.as_str()) { if bar.active_popup == Some(module) {
return maybe_close_popup(bar); return maybe_close_popup(bar);
} }
// Query the widget tree for the module's actual laid-out bounds. // Query the widget tree for the module's actual laid-out bounds.
// The resulting `BoundsFound` message opens the popup there. // The resulting `BoundsFound` message opens the popup there.
bounds::capture_bounds(label) capture_bounds(module)
} }
Message::BoundsFound(label, bounds) => { Message::BoundsFound(module, bounds) => {
bar.active_popup = Some(label.clone()); bar.active_popup = Some(module);
// Anchor rect = the button's bounds, extended below the bar so the // Anchor rect = the button's bounds, extended below the bar so the
// `Bottom` anchor point (bottom-center) sits POPUP_GAP below the // `Bottom` anchor point (bottom-center) sits POPUP_GAP below the
@@ -119,16 +150,12 @@ fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
let (id, task) = Message::popup_open(settings); let (id, task) = Message::popup_open(settings);
bar.popup_id = Some(id); bar.popup_id = Some(id);
task // Seed example data for the workspace menu.
if module == Module::Workspace {
bar.workspaces = vec!["1".into(), "2".into(), "3".into(), "4".into()];
} }
Message::Module(label, msg) => { task
let state = bar
.modules
.iter_mut()
.find(|m| m.label() == label)
.expect("unknown module label");
state.handle(msg)
} }
Message::ClosePopup(id) => { Message::ClosePopup(id) => {
@@ -161,6 +188,7 @@ 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> { fn maybe_close_popup(bar: &mut Bar) -> Task<Message> {
if let Some(id) = bar.popup_id.take() { if let Some(id) = bar.popup_id.take() {
bar.active_popup = None; bar.active_popup = None;
@@ -169,53 +197,142 @@ fn maybe_close_popup(bar: &mut Bar) -> Task<Message> {
Task::none() 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. /// Dispatch view by which surface is being rendered.
fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> { 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) { if bar.popup_id == Some(id) {
// This is the popup surface.
popup_view(bar) popup_view(bar)
} else { } else {
// This is the main bar surface.
bar_view(bar) bar_view(bar)
} }
} }
/// Main bar widget tree. /// 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(); let mut children: Vec<Element<Message>> = Vec::new();
for (i, module) in bar.modules.iter().enumerate() { for (i, module) in Module::ALL.iter().enumerate() {
children.push(module.button()); children.push(button(*module));
if i < bar.modules.len() - 1 { if i < Module::ALL.len() - 1 {
children.push(text("|").size(14).into()); children.push(text("|").size(14).into());
} }
} }
container( container(
Row::from_vec(children) column(children)
.width(Length::Fill) .width(Length::Fill)
.align_y(Alignment::Center), .align_x(Alignment::Center),
) )
.padding(8) .padding(8)
.align_x(Alignment::Center) .align_x(Alignment::Center)
.into() .into()
} }
/// Popup widget tree. Rendered on its own floating LayerShell surface. /// Standalone button wrapped in MouseArea for click capture. The container
fn popup_view(bar: &Bar) -> Element<'static, Message> { /// carries the module's id so a widget-tree [`Operation`] can find its
let label = bar /// laid-out bounds for popup anchoring.
.active_popup fn button(module: Module) -> Element<'static, Message> {
.as_ref() container(mouse_area(text(module.label()).size(14)).on_press(Message::ModuleClicked(module)))
.expect("popup open without active module"); .id(module.id())
let popup_id = bar.popup_id.expect("popup open without surface id"); .into()
let module = bar
.modules
.iter()
.find(|m| m.label() == label)
.expect("unknown module label");
module.popup(popup_id)
} }
/// 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();
// 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),
],
};
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()
}
/// 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 { fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
use iced::theme::Style; use iced::theme::Style;
Style { Style {
-84
View File
@@ -1,84 +0,0 @@
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<dyn ModuleMsg>;
}
impl Clone for Box<dyn ModuleMsg> {
fn clone(&self) -> Self {
self.clone_box()
}
}
impl<T: Any + Debug + Clone + Send + 'static> ModuleMsg for T {
fn clone_box(&self) -> Box<dyn ModuleMsg> {
Box::new((*self).clone())
}
}
/// Downcast a boxed module message to a concrete type.
pub fn downcast<T: Any>(boxed: Box<dyn ModuleMsg>) -> Result<Box<T>, ()> {
let erased: Box<dyn Any> = boxed;
match erased.downcast::<T>() {
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<dyn ModuleState>;
}
/// 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<dyn ModuleMsg>) -> Task<Message>;
/// 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<Subscription<Message>> {
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,
];
-50
View File
@@ -1,50 +0,0 @@
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<dyn ModuleState> {
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<dyn ModuleMsg>) -> iced::Task<Message> {
iced::Task::none()
}
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
let items: Vec<Element<'static, Message>> = ["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()
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod clock;
pub mod system;
pub mod workspaces;
-49
View File
@@ -1,49 +0,0 @@
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<dyn ModuleState> {
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<dyn ModuleMsg>) -> iced::Task<Message> {
iced::Task::none()
}
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
let items: Vec<Element<'static, Message>> = ["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()
}
}
-66
View File
@@ -1,66 +0,0 @@
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<String>,
}
#[derive(Debug, Clone)]
pub enum Msg {
Select(String),
}
impl ModuleDef for Workspaces {
fn new_state(&self) -> Box<dyn ModuleState> {
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<dyn ModuleMsg>) -> Task<Message> {
let msg = *downcast::<Msg>(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<Element<'static, Message>> = 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()
}
}