This commit is contained in:
2026-09-01 16:34:58 +01:00
parent 6e82d0a175
commit 7aa1550f41
8 changed files with 500 additions and 212 deletions
+131 -211
View File
@@ -7,75 +7,79 @@
//! Clicking any button spawns a separate LayerShell popup surface anchored
//! to that button's position on the bar.
use iced::advanced::widget as advanced_widget;
use iced::widget::{column, container, mouse_area, text};
use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme};
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, PopupAnchor, PopupGravity};
use iced_layershell::settings::{LayerShellSettings, StartMode, Settings};
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;
/// 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)]
/// Application state. Tracks which popup is open and owns the module registry.
struct Bar {
/// Which button triggered the open popup? None = closed.
active_popup: Option<Module>,
/// 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>,
/// Example workspace list populated when workspace menu opens.
workspaces: Vec<String>,
/// 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)]
enum Message {
pub(crate) enum Message {
Noop,
/// A bar module was clicked; opens or toggles a popup.
ModuleClicked(Module),
/// 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(Module, Rectangle),
BoundsFound(String, Rectangle),
/// Closes the currently open popup.
ClosePopup(window::Id),
}
@@ -94,8 +98,9 @@ fn main() -> Result<(), iced_layershell::Error> {
None => StartMode::Active,
};
daemon(Bar::default, namespace, update, view)
daemon(Bar::new, namespace, update, view)
.style(style)
.subscription(gather_subscriptions)
.settings(Settings {
layer_settings: LayerShellSettings {
size: Some((0, BAR_HEIGHT)),
@@ -109,54 +114,84 @@ fn main() -> Result<(), iced_layershell::Error> {
.run()
}
fn gather_subscriptions(bar: &Bar) -> Subscription<Message> {
// Only spawn a runner if some module actually wants it. `bar` is passed
// precisely so we can check routes and avoid idle tickers.
let wants_clock = bar
.routes
.values()
.any(|kinds| kinds.iter().any(|k| matches!(k, ModuleSub::Clock(_))));
if !wants_clock {
return Subscription::none();
}
// A single 1s clock stream drives ALL modules that want a clock kind.
// The stream emits a `SubscriptionMsg` with kind+payload; `update` fans
// out to every module in `routes` that wants that kind.
// The builder must be non-capturing (fn pointer), so the Clock state
// lives inside the stream body; identity `()` keeps it alive forever.
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::ModuleClicked(module) => {
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 == Some(module) {
return maybe_close_popup(bar);
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)
}
// Query the widget tree for the module's actual laid-out bounds.
// The resulting `BoundsFound` message opens the popup there.
capture_bounds(module)
}
Message::BoundsFound(module, bounds) => {
bar.active_popup = Some(module);
// 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);
// Seed example data for the workspace menu.
if module == Module::Workspace {
bar.workspaces = vec!["1".into(), "2".into(), "3".into(), "4".into()];
}
task
}
Message::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds),
Message::ClosePopup(id) => {
if bar.popup_id == Some(id) {
@@ -169,75 +204,10 @@ fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
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(),
_ => Task::none(),
}
}
/// 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;
return Task::done(Message::RemoveWindow(id));
}
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
// ---------------------------------------------------------------------------
@@ -249,20 +219,22 @@ fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> {
// known ID (stored implicitly as "first window").
if bar.popup_id == Some(id) {
// This is the popup surface.
popup_view(bar)
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> {
/// 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 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.view(None).map(Message::Module));
if i < bar.modules.len() - 1 {
children.push(text("|").size(14).into());
}
}
@@ -277,58 +249,6 @@ fn bar_view(_bar: &Bar) -> Element<'static, Message> {
.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();
// 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
// ---------------------------------------------------------------------------