bullshit
This commit is contained in:
Generated
+38
@@ -102,6 +102,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|||||||
name = "barbar"
|
name = "barbar"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"iced",
|
"iced",
|
||||||
"iced_layershell",
|
"iced_layershell",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -266,6 +267,19 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -981,6 +995,30 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iced"
|
name = "iced"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
|
|||||||
+2
-1
@@ -6,4 +6,5 @@ edition = "2021"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced", "tokio"] }
|
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"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||||
|
chrono = "0.4"
|
||||||
+131
-211
@@ -7,75 +7,79 @@
|
|||||||
//! Clicking any button spawns a separate LayerShell popup surface anchored
|
//! Clicking any button spawns a separate LayerShell popup surface anchored
|
||||||
//! to that button's position on the bar.
|
//! to that button's position on the bar.
|
||||||
|
|
||||||
use iced::advanced::widget as advanced_widget;
|
use std::collections::BTreeMap;
|
||||||
use iced::widget::{column, container, mouse_area, text};
|
|
||||||
use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme};
|
use iced::widget::{column, container, text};
|
||||||
|
use iced::{window, Alignment, Element, Length, Rectangle, Subscription, Task, Theme};
|
||||||
use iced_layershell::daemon;
|
use iced_layershell::daemon;
|
||||||
use iced_layershell::reexport::{Anchor, PopupAnchor, PopupGravity};
|
use iced_layershell::reexport::Anchor;
|
||||||
use iced_layershell::settings::{LayerShellSettings, StartMode, Settings};
|
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
|
||||||
use iced_layershell::to_layer_message;
|
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.
|
/// 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_H: u32 = 100;
|
|
||||||
|
|
||||||
/// Vertical gap (logical px) between the bar's bottom edge and the popup.
|
|
||||||
const POPUP_GAP: i32 = 32;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Identifies which bar module triggered an action.
|
/// Application state. Tracks which popup is open and owns the module registry.
|
||||||
#[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.
|
/// Which module's button triggered the open popup? None = closed.
|
||||||
active_popup: Option<Module>,
|
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).
|
/// Surface ID of the currently open popup (for removal).
|
||||||
popup_id: Option<window::Id>,
|
popup_id: Option<window::Id>,
|
||||||
/// Example workspace list populated when workspace menu opens.
|
/// Fan-out routing table: module id -> the subscription kinds it wants.
|
||||||
workspaces: Vec<String>,
|
/// 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.
|
/// Messages produced by user input, events and subscriptions.
|
||||||
#[to_layer_message(multi)]
|
#[to_layer_message(multi)]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Message {
|
pub(crate) enum Message {
|
||||||
Noop,
|
Noop,
|
||||||
/// A bar module was clicked; opens or toggles a popup.
|
/// A routed module message; dispatch by `id` without naming its type.
|
||||||
ModuleClicked(Module),
|
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
|
/// Internal: the widget-tree layout pass reported the laid-out bounds of
|
||||||
/// a module's clickable area; the popup is anchored there.
|
/// a module's clickable area; the popup is anchored there.
|
||||||
BoundsFound(Module, Rectangle),
|
BoundsFound(String, Rectangle),
|
||||||
/// Closes the currently open popup.
|
/// Closes the currently open popup.
|
||||||
ClosePopup(window::Id),
|
ClosePopup(window::Id),
|
||||||
}
|
}
|
||||||
@@ -94,8 +98,9 @@ fn main() -> Result<(), iced_layershell::Error> {
|
|||||||
None => StartMode::Active,
|
None => StartMode::Active,
|
||||||
};
|
};
|
||||||
|
|
||||||
daemon(Bar::default, namespace, update, view)
|
daemon(Bar::new, namespace, update, view)
|
||||||
.style(style)
|
.style(style)
|
||||||
|
.subscription(gather_subscriptions)
|
||||||
.settings(Settings {
|
.settings(Settings {
|
||||||
layer_settings: LayerShellSettings {
|
layer_settings: LayerShellSettings {
|
||||||
size: Some((0, BAR_HEIGHT)),
|
size: Some((0, BAR_HEIGHT)),
|
||||||
@@ -109,54 +114,84 @@ fn main() -> Result<(), iced_layershell::Error> {
|
|||||||
.run()
|
.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
|
// Update
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
|
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
|
||||||
match msg {
|
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.
|
// Toggle: close if already open, else open.
|
||||||
if bar.active_popup == Some(module) {
|
if bar.active_popup.as_deref() == Some(module_id.as_str()) {
|
||||||
return maybe_close_popup(bar);
|
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) => {
|
Message::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, 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::ClosePopup(id) => {
|
Message::ClosePopup(id) => {
|
||||||
if bar.popup_id == Some(id) {
|
if bar.popup_id == Some(id) {
|
||||||
@@ -169,75 +204,10 @@ fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
|
|||||||
Message::Noop => Task::none(),
|
Message::Noop => Task::none(),
|
||||||
|
|
||||||
// Forward multi-window mutations to their internal handlers.
|
// Forward multi-window mutations to their internal handlers.
|
||||||
Message::AnchorChange { .. }
|
_ => Task::none(),
|
||||||
| 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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
// 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").
|
// known ID (stored implicitly as "first window").
|
||||||
if bar.popup_id == Some(id) {
|
if bar.popup_id == Some(id) {
|
||||||
// This is the popup surface.
|
// This is the popup surface.
|
||||||
popup_view(bar)
|
popup::view(bar)
|
||||||
} else {
|
} else {
|
||||||
// This is the main bar surface.
|
// This is the main bar surface.
|
||||||
bar_view(bar)
|
bar_view(bar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Main bar widget tree.
|
/// Main bar widget tree. Each module renders itself; the app only lays the
|
||||||
fn bar_view(_bar: &Bar) -> Element<'static, Message> {
|
/// 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();
|
let mut children: Vec<Element<Message>> = Vec::new();
|
||||||
|
|
||||||
for (i, module) in Module::ALL.iter().enumerate() {
|
for (i, (_, module)) in bar.modules.iter().enumerate() {
|
||||||
children.push(button(*module));
|
children.push(module.view(None).map(Message::Module));
|
||||||
if i < Module::ALL.len() - 1 {
|
if i < bar.modules.len() - 1 {
|
||||||
children.push(text("|").size(14).into());
|
children.push(text("|").size(14).into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,58 +249,6 @@ fn bar_view(_bar: &Bar) -> Element<'static, Message> {
|
|||||||
.into()
|
.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
|
// Style
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use iced::widget::{button, text};
|
||||||
|
use iced::{Element, Task};
|
||||||
|
|
||||||
|
use crate::module::{BarModule, ModuleMsg};
|
||||||
|
use crate::subscriptions::clock::{ClockKind, ClockPayload};
|
||||||
|
use crate::subscriptions::Subscription;
|
||||||
|
use crate::Message;
|
||||||
|
|
||||||
|
/// A dummy clock module: subscribes to second ticks, stores the latest
|
||||||
|
/// value, renders it as text in the bar. Clicking toggles between showing
|
||||||
|
/// seconds (HH:MM:SS) and hiding them (HH:MM) — purely a display choice,
|
||||||
|
/// the subscription always ticks seconds.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ClockMsg {
|
||||||
|
/// The bar was clicked; toggle the seconds suffix.
|
||||||
|
ToggleSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Clock {
|
||||||
|
value: String,
|
||||||
|
show_seconds: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clock {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
value: "--:--:--".to_string(),
|
||||||
|
show_seconds: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BarModule for Clock {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"clock"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, ModuleMsg> {
|
||||||
|
let _ = window_id;
|
||||||
|
// Derive the display string from the stored value each render, so a
|
||||||
|
// click just flips `show_seconds` and the text updates.
|
||||||
|
let shown = if self.show_seconds {
|
||||||
|
self.value.clone()
|
||||||
|
} else {
|
||||||
|
self.value
|
||||||
|
.split(':') // [hh, mm, ss]
|
||||||
|
.take(2)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(":")
|
||||||
|
};
|
||||||
|
button(text(shown).size(16))
|
||||||
|
.padding(0) // kill default 5/10 asymmetric padding; row centers it
|
||||||
|
.on_press(ModuleMsg::new(self.id(), ClockMsg::ToggleSeconds))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, msg: ModuleMsg) -> Task<Message> {
|
||||||
|
// The subscription fan-out delivers a raw `ClockPayload` (never
|
||||||
|
// wrapped in `ClockMsg`) — handle it first.
|
||||||
|
if let Some(payload) = msg.downcast::<ClockPayload>() {
|
||||||
|
self.value = payload.value.clone();
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
// Module-local messages (button clicks) come as `ClockMsg`.
|
||||||
|
if let Some(ClockMsg::ToggleSeconds) = msg.downcast::<ClockMsg>() {
|
||||||
|
self.show_seconds = !self.show_seconds;
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscriptions(&self) -> Vec<Subscription> {
|
||||||
|
vec![Subscription::Clock(ClockKind::Seconds)]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use std::any::Any;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use iced::{Element, Task};
|
||||||
|
|
||||||
|
use crate::subscriptions::Subscription;
|
||||||
|
use crate::Message;
|
||||||
|
|
||||||
|
pub mod clock;
|
||||||
|
|
||||||
|
/// A module-local message, boxed with its owner's `id`.
|
||||||
|
///
|
||||||
|
/// The app routes purely by `id` and never names a module's message type;
|
||||||
|
/// only the owning module downcasts the payload back to its own enum.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ModuleMsg {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub payload: Arc<dyn Any + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModuleMsg {
|
||||||
|
/// Wraps a module's own message. `id` must match the module's `BarModule::id`.
|
||||||
|
pub fn new<T: Any + Send + Sync>(id: &'static str, msg: T) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
payload: Arc::new(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downcasts to the module's own message type. Returns `None` if the
|
||||||
|
/// payload belongs to another module.
|
||||||
|
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&T> {
|
||||||
|
self.payload.downcast_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ModuleMsg {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ModuleMsg")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("payload", &self.payload.type_id())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bar module. Object-safe so modules live in
|
||||||
|
/// `BTreeMap<String, Box<dyn BarModule>>`. The `ModuleMsg` boundary keeps the
|
||||||
|
/// app ignorant of each module's message enum.
|
||||||
|
pub trait BarModule: Send {
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Returns this module's bar contents. When `window_id` is `Some`, the
|
||||||
|
/// popup surface for this module is being rendered and the module should
|
||||||
|
/// return its popup contents instead.
|
||||||
|
fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, ModuleMsg>;
|
||||||
|
|
||||||
|
/// Handles a routed message. Implementations downcast the payload to
|
||||||
|
/// their own message type and return their own app-level tasks (e.g. a
|
||||||
|
/// `RequestPopup` task to open a popup for themselves).
|
||||||
|
fn update(&mut self, msg: ModuleMsg) -> Task<Message>;
|
||||||
|
|
||||||
|
/// Declarative: what subscription kinds this module wants.
|
||||||
|
fn subscriptions(&self) -> Vec<Subscription>;
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
//! Popup surfaces: bounds capture, open/close, and rendering.
|
||||||
|
//!
|
||||||
|
//! Popups are separate LayerShell surfaces anchored to the triggering module's
|
||||||
|
//! button. The bar captures the button's laid-out bounds via a widget-tree
|
||||||
|
//! operation, then opens a popup anchored there; the active module renders the
|
||||||
|
//! popup's contents through its own `view(Some(id))`.
|
||||||
|
|
||||||
|
use iced::advanced::widget as advanced_widget;
|
||||||
|
use iced::widget::{column, mouse_area, text};
|
||||||
|
use iced::{Alignment, Element, Length, Rectangle, Task};
|
||||||
|
use iced_layershell::actions::IcedNewPopupSettings;
|
||||||
|
use iced_layershell::reexport::{PopupAnchor, PopupGravity};
|
||||||
|
|
||||||
|
use crate::{Bar, Message};
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// Opens a popup for `module_id`, anchored below its laid-out `bounds`.
|
||||||
|
pub fn open_popup(bar: &mut Bar, module_id: String, bounds: Rectangle) -> Task<Message> {
|
||||||
|
bar.active_popup = Some(module_id);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the popup if one is open.
|
||||||
|
pub fn 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 element, then reports them via `BoundsFound` for the module.
|
||||||
|
pub fn capture_bounds(module_id: String, element_id: String) -> Task<Message> {
|
||||||
|
let target = iced::widget::Id::from(element_id);
|
||||||
|
|
||||||
|
struct FindBounds {
|
||||||
|
target: iced::widget::Id,
|
||||||
|
found: Option<Rectangle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl advanced_widget::Operation<Rectangle> for FindBounds {
|
||||||
|
/// Delegate traversal so the `container` hook below sees every
|
||||||
|
/// container in the tree, matching the target id.
|
||||||
|
fn traverse(
|
||||||
|
&mut self,
|
||||||
|
operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>),
|
||||||
|
) {
|
||||||
|
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_id.clone(), bounds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Popup widget tree, rendered on its own floating LayerShell surface. The
|
||||||
|
/// active module renders its own popup contents via `view(Some(id))`.
|
||||||
|
pub fn view(bar: &Bar) -> Element<'_, Message> {
|
||||||
|
let module_id = bar.active_popup.as_deref().unwrap();
|
||||||
|
let popup_id = bar.popup_id.unwrap();
|
||||||
|
let content = bar
|
||||||
|
.modules
|
||||||
|
.get(module_id)
|
||||||
|
.map(|module| module.view(Some(popup_id)).map(Message::Module))
|
||||||
|
.unwrap_or_else(|| text("unknown module").into());
|
||||||
|
|
||||||
|
column![
|
||||||
|
text(format!("{} menu", module_id))
|
||||||
|
.size(14)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(Alignment::Center),
|
||||||
|
content,
|
||||||
|
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
|
||||||
|
]
|
||||||
|
.spacing(1)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// What a clock subscription wants. Carried in `Subscription::Clock(kind)`
|
||||||
|
/// so the runner knows the tick interval and the module knows the payload.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
|
pub enum ClockKind {
|
||||||
|
Mins,
|
||||||
|
Seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event payload for a clock subscription: the dynamic value plus the
|
||||||
|
/// kind that produced it, so a module can re-render without extra state.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct ClockPayload {
|
||||||
|
pub kind: ClockKind,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the last displayed value per kind and only emits a payload when
|
||||||
|
/// the value changes — minutes when the minute rolls over, seconds when the
|
||||||
|
/// second rolls over. Drive `tick` once per second.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ClockTicker {
|
||||||
|
last: HashMap<ClockKind, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClockTicker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks all kinds in one per-second call and returns a payload for
|
||||||
|
/// every kind whose displayed value changed since the last tick. Batch:
|
||||||
|
/// the second that rolls into a new minute yields `[Seconds, Mins]`.
|
||||||
|
pub fn tick(&mut self) -> Vec<ClockPayload> {
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
[ClockKind::Seconds, ClockKind::Mins]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|kind| {
|
||||||
|
let value = now
|
||||||
|
.format(match kind {
|
||||||
|
ClockKind::Mins => "%H:%M",
|
||||||
|
ClockKind::Seconds => "%H:%M:%S",
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
match self.last.get(&kind) {
|
||||||
|
Some(prev) if *prev == value => None,
|
||||||
|
_ => {
|
||||||
|
self.last.insert(kind, value.clone());
|
||||||
|
Some(ClockPayload { kind, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use crate::subscriptions::clock::{ClockKind, ClockPayload};
|
||||||
|
|
||||||
|
pub mod clock;
|
||||||
|
|
||||||
|
/// What a module wants from a runner. `Clock(kind)` is both the route key
|
||||||
|
/// (which ticker to spawn) and the typed event selector.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Subscription {
|
||||||
|
Clock(ClockKind),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A subscription event carrying a typed payload; `update` matches on this
|
||||||
|
/// to fan out to every module whose route requests the kind.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum SubscriptionPayloadKind {
|
||||||
|
Clock(ClockPayload),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user