fresh-start

This commit is contained in:
2026-08-31 22:51:49 +01:00
parent 81f242419e
commit cc5db9089a
8 changed files with 44 additions and 481 deletions
+1 -1
View File
@@ -4,5 +4,5 @@ version = "0.1.0"
edition = "2021"
[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"] }
iced_layershell = { version = "0.19", default-features = false }
-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))
}
+42 -182
View File
@@ -1,72 +1,34 @@
//! 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.
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::widget::{row, text};
use iced::{Alignment, Element, Length, Task, Theme};
use iced_layershell::application;
use iced_layershell::reexport::Anchor;
use iced_layershell::settings::{LayerShellSettings, StartMode, Settings};
use iced_layershell::to_layer_message;
use crate::module::{ModuleMsg, ModuleState, MODULES};
/// Height of the bar in logical pixels.
const BAR_HEIGHT: u32 = 36;
const POPUP_W: u32 = 150;
const POPUP_H: u32 = 100;
/// Optional width of the bar; `None` means "as wide as the output".
const BAR_WIDTH: Option<u32> = None;
/// Vertical gap (logical px) between the bar's bottom edge and the popup.
const POPUP_GAP: i32 = 32;
struct Bar {
/// 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>,
/// 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(),
}
}
}
#[to_layer_message(multi)]
#[derive(Debug, Clone)]
enum Message {
Noop,
ModuleClicked(String),
BoundsFound(String, Rectangle),
ClosePopup(window::Id),
Module(String, Box<dyn ModuleMsg>),
}
fn namespace() -> String {
String::from("barbar")
}
fn main() -> Result<(), iced_layershell::Error> {
pub fn main() -> Result<(), iced_layershell::Error> {
// Target a specific output if one was given, otherwise the active one.
let start_mode = match std::env::args().nth(1) {
Some(output) => StartMode::TargetScreen(output),
None => StartMode::Active,
};
daemon(Bar::default, namespace, update, view)
application(Bar::default, namespace, update, view)
.style(style)
.subscription(subscription)
.settings(Settings {
layer_settings: LayerShellSettings {
size: Some((0, BAR_HEIGHT)),
size: Some((BAR_WIDTH.unwrap_or(0), BAR_HEIGHT)),
exclusive_zone: BAR_HEIGHT as i32,
anchor: Anchor::Top | Anchor::Left | Anchor::Right,
start_mode,
@@ -77,145 +39,43 @@ fn main() -> Result<(), iced_layershell::Error> {
.run()
}
/// 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()))
/// Application state. Empty for now; placeholders will evolve into
/// workspace/clock/system modules.
#[derive(Debug, Default)]
struct Bar;
/// Messages produced by user input, events and subscriptions.
#[to_layer_message]
#[derive(Debug, Clone)]
enum Message {
/// No-op used as a fallback target for pending subscriptions.
Noop,
}
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
match msg {
Message::ModuleClicked(label) => {
// Toggle: close if already open, else open.
if bar.active_popup.as_deref() == Some(label.as_str()) {
return maybe_close_popup(bar);
fn namespace() -> String {
String::from("barbar")
}
// Query the widget tree for the module's actual laid-out bounds.
// The resulting `BoundsFound` message opens the popup there.
bounds::capture_bounds(label)
}
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
// button's bottom edge. With `Bottom` gravity the popup grows
// downward from there, leaving a gap under the bar.
let (x, y, w, h) = (
bounds.x.round() as i32,
bounds.y.round() as i32,
bounds.width.round() as i32,
bounds.height.round() as i32,
);
let anchor_rect = (x, y, w, h + POPUP_GAP);
let settings = iced_layershell::actions::IcedNewPopupSettings::on_current_surface(
(POPUP_W, POPUP_H),
anchor_rect,
)
.anchor(PopupAnchor::Bottom)
.gravity(PopupGravity::Bottom);
let (id, task) = Message::popup_open(settings);
bar.popup_id = Some(id);
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;
bar.active_popup = None;
}
Task::done(Message::RemoveWindow(id))
}
Message::Noop => Task::none(),
// Forward multi-window mutations to their internal handlers.
Message::AnchorChange { .. }
| Message::SetInputRegion { .. }
| Message::AnchorSizeChange { .. }
| Message::LayerChange { .. }
| Message::MarginChange { .. }
| Message::SizeChange { .. }
| Message::ExclusiveZoneChange { .. }
| Message::KeyboardInteractivityChange { .. }
| Message::NewBaseWindow { .. }
| Message::NewInputPanel { .. }
| Message::NewLayerShell { .. }
| Message::NewMenu { .. }
| Message::NewPopUp { .. }
| Message::RemoveWindow(_)
| Message::ForgetLastOutput
| Message::VirtualKeyboardPressed { .. } => Task::none(),
}
}
fn maybe_close_popup(bar: &mut Bar) -> Task<Message> {
if let Some(id) = bar.popup_id.take() {
bar.active_popup = None;
return Task::done(Message::RemoveWindow(id));
}
fn update(_bar: &mut Bar, _message: Message) -> Task<Message> {
Task::none()
}
/// Dispatch view by which surface is being rendered.
fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> {
if bar.popup_id == Some(id) {
popup_view(bar)
} else {
bar_view(bar)
}
}
/// Main bar widget tree.
fn bar_view(bar: &Bar) -> Element<'static, Message> {
let mut children: Vec<Element<Message>> = Vec::new();
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(
Row::from_vec(children)
.width(Length::Fill)
.align_y(Alignment::Center),
)
fn view(_bar: &Bar) -> Element<'_, Message> {
row![
text("workspaces").size(14),
text("|").size(14),
text("clock").size(14),
text("|").size(14),
text("system").size(14),
]
.spacing(12)
.padding(8)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// Popup widget tree. Rendered on its own floating LayerShell surface.
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");
let module = bar
.modules
.iter()
.find(|m| m.label() == label)
.expect("unknown module label");
module.popup(popup_id)
}
fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
use iced::theme::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()
}
}