diff --git a/Cargo.lock b/Cargo.lock index 4d73507..2ecdfeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,16 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bread-shared" +version = "0.7.0" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" version = "0.2.3" @@ -111,11 +121,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", +] + [[package]] name = "breadbar" version = "0.3.0" dependencies = [ + "bread-shared", "bread-theme", + "bread-utils", "futures-lite", "gtk4", "gtk4-layer-shell", @@ -1115,7 +1137,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -1316,6 +1338,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1426,7 +1457,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -1523,6 +1554,18 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -1531,11 +1574,20 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -1547,6 +1599,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -1554,9 +1620,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1565,9 +1631,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" @@ -1814,6 +1886,15 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -1865,7 +1946,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -1893,7 +1974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] @@ -1912,7 +1993,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", "zvariant_derive", "zvariant_utils", ] @@ -1940,5 +2021,5 @@ dependencies = [ "quote", "serde", "syn", - "winnow", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 49ddb62..673a8c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,12 @@ categories = ["gui"] [dependencies] bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } +# Widget rendering client: bread-utils::BreadClient (emit/request/subscribe) +# for talking to breadd's IPC socket, and bread-shared purely for the +# WidgetSpec/WidgetNode wire types so we deserialize into real structs +# instead of hand-parsing serde_json::Value. See src/widgets/. +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.1", features = ["bread-client"] } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0" } gtk4 = { version = "0.11", features = ["v4_12"] } gtk4-layer-shell = "0.8" relm4 = { version = "0.11", features = ["macros"] } @@ -24,6 +30,15 @@ serde_json = "1" # are vector-only). Needed because librsvg dropped its gdk-pixbuf SVG loader. resvg = { version = "0.44", default-features = false } +# TEMPORARY local-dev override: the widget feature's bread-shared/bread-utils +# changes aren't tagged/released yet, so point the git deps above at the +# local checkouts instead. Remove this section (and bump the tags above) +# once bread-ecosystem and bread have real release tags for this work. +[patch."https://git.breadway.dev/Breadway/bread-ecosystem"] +bread-utils = { path = "../bread-ecosystem/bread-utils" } +[patch."https://git.breadway.dev/Breadway/bread"] +bread-shared = { path = "../bread/bread-shared" } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/src/main.rs b/src/main.rs index 7e2c756..4bdb45e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod bar; mod notifications; mod osd; mod theme; +mod widgets; /// Thresholds above which the bar's CPU/RAM/power-draw readouts appear at /// all — see `AppInput::StatsUpdate`. Below these, the bar stays quiet. @@ -94,6 +95,15 @@ pub struct App { tray_sep: gtk4::Separator, tray_box: gtk4::Box, tray_items: std::collections::HashMap, + + // ── Lua-declared widgets ───────────────────────────────────────────── + // One container per WidgetPlacement (see bread_shared::widget), fully + // rebuilt on every AppInput::WidgetsUpdate — see widgets::client's + // module doc for why that's simpler than incremental patching here. + widget_containers: + std::collections::HashMap, + widget_tray_section: gtk4::Box, + widget_tray_sep: gtk4::Separator, } #[derive(Debug)] @@ -109,6 +119,7 @@ pub enum AppInput { BtPopoverData(bar::bluetooth::BtPopoverData), MediaUpdate(bar::media::MediaState), ControlPanelData(bar::control::ControlPanelData), + WidgetsUpdate(Vec), } #[relm4::component(pub)] @@ -125,17 +136,6 @@ impl SimpleComponent for App { #[name = "center_box"] gtk::CenterBox { - #[wrap(Some)] - set_start_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 0, - - #[name = "workspace_box"] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 4, - } - }, } } } @@ -153,6 +153,32 @@ impl SimpleComponent for App { root.set_anchor(Edge::Right, true); root.set_exclusive_zone(32); + // ── Workspace row (left) ──────────────────────────────────────── + // Built imperatively (not via the view! macro) so a widget + // container can sit as a plain sibling of workspace_box — see + // WidgetPlacement::RightOfWorkspaces below. + let workspace_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + let workspace_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + workspace_row.append(&workspace_box); + + // ── Lua-declared widget containers ────────────────────────────── + // One per WidgetPlacement; positioned into the layout below as each + // surrounding section (workspace row / center area / stats box / + // control popover) is built. Populated by widgets::client's + // events.subscribe-driven refresh loop, started at the end of init. + use bread_shared::widget::WidgetPlacement; + let widget_right_of_workspaces = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_workspaces.add_css_class("bread-widget-slot"); + workspace_row.append(&widget_right_of_workspaces); + + let widget_left_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_clock.add_css_class("bread-widget-slot"); + let widget_right_of_clock = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_right_of_clock.add_css_class("bread-widget-slot"); + + let widget_left_of_stats = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_left_of_stats.add_css_class("bread-widget-slot"); + // ── SVG icon sets ──────────────────────────────────────────────── use bar::stats::{ AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_CONNECTED, BT_OFF, BT_ON, WIFI_MEDIUM, @@ -278,15 +304,18 @@ impl SimpleComponent for App { let clock_lbl = gtk4::Label::new(Some(&bar::clock::current())); clock_lbl.add_css_class("clock-label"); - // Center area: [media_widget · clock] + // Center area: [media_widget · widgets · clock · widgets] let center_area = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); center_area.add_css_class("center-area"); center_area.append(&media_widget); + center_area.append(&widget_left_of_clock); center_area.append(&clock_lbl); + center_area.append(&widget_right_of_clock); // ── Stats box (right side) ─────────────────────────────────────── let stats_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); stats_box.add_css_class("stats-box"); + stats_box.append(&widget_left_of_stats); // CPU/RAM/power draw: hidden by default (see StatsUpdate), so this // whole sub-group — plus its separator — collapses away when quiet. @@ -509,6 +538,24 @@ impl SimpleComponent for App { tray_sep.set_visible(false); panel_inner.append(&tray_sep); + // Widgets section — Lua-declared widgets with placement = "tray". + // Same collapse-when-empty idiom as the Apps section above. + let widget_tray_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + widget_tray_section.add_css_class("control-panel-section"); + let widget_tray_header = gtk4::Label::new(Some("Widgets")); + widget_tray_header.add_css_class("control-panel-section-header"); + widget_tray_header.set_xalign(0.0); + let widget_tray_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + widget_tray_box.add_css_class("tray-box"); + widget_tray_section.append(&widget_tray_header); + widget_tray_section.append(&widget_tray_box); + widget_tray_section.set_visible(false); + panel_inner.append(&widget_tray_section); + + let widget_tray_sep = gtk4::Separator::new(gtk4::Orientation::Horizontal); + widget_tray_sep.set_visible(false); + panel_inner.append(&widget_tray_sep); + // Power section let power_section = gtk4::Box::new(gtk4::Orientation::Vertical, 4); power_section.add_css_class("control-panel-section"); @@ -583,15 +630,24 @@ impl SimpleComponent for App { stats_box.append(&hamburger_btn); + let widget_containers = std::collections::HashMap::from([ + (WidgetPlacement::RightOfWorkspaces, widget_right_of_workspaces), + (WidgetPlacement::LeftOfClock, widget_left_of_clock), + (WidgetPlacement::RightOfClock, widget_right_of_clock), + (WidgetPlacement::LeftOfStats, widget_left_of_stats), + (WidgetPlacement::Tray, widget_tray_box), + ]); + // ── Assemble ───────────────────────────────────────────────────── let widgets = view_output!(); + widgets.center_box.set_start_widget(Some(&workspace_row)); widgets.center_box.set_center_widget(Some(¢er_area)); widgets.center_box.set_end_widget(Some(&stats_box)); - let mut model = App { + let model = App { workspaces: vec![], active_ws: 1, - workspace_box: gtk4::Box::new(gtk4::Orientation::Horizontal, 4), + workspace_box, button_map: std::collections::HashMap::new(), time_str: bar::clock::current(), clock_lbl, @@ -641,8 +697,10 @@ impl SimpleComponent for App { tray_sep, tray_box, tray_items: std::collections::HashMap::new(), + widget_containers, + widget_tray_section, + widget_tray_sep, }; - model.workspace_box = widgets.workspace_box.clone(); theme::apply(); bar::workspaces::spawn_watcher(sender.clone()); @@ -651,6 +709,7 @@ impl SimpleComponent for App { bar::tray::spawn_watcher(sender.clone()); bar::wifi::spawn_status_poller(sender.clone()); bar::media::spawn_poller(sender.clone()); + widgets::client::spawn(sender.clone()); notifications::spawn(); osd::spawn(); @@ -872,11 +931,64 @@ impl SimpleComponent for App { }); self.panel_sink_signal = Some(id); } + AppInput::WidgetsUpdate(specs) => { + self.reconcile_widgets(specs); + } } } } impl App { + fn reconcile_widgets(&mut self, specs: Vec) { + for container in self.widget_containers.values() { + while let Some(child) = container.first_child() { + container.remove(&child); + } + } + + let mut by_placement: std::collections::HashMap< + bread_shared::widget::WidgetPlacement, + Vec<&bread_shared::widget::WidgetSpec>, + > = std::collections::HashMap::new(); + for spec in &specs { + by_placement.entry(spec.placement).or_default().push(spec); + } + + for (placement, mut group) in by_placement { + let Some(container) = self.widget_containers.get(&placement) else { + continue; + }; + group.sort_by_key(|s| s.order); + for spec in group { + if !spec.visible { + continue; + } + let node = widgets::build_node(&spec.root, &spec.id); + if let Some(tooltip) = &spec.tooltip { + node.set_tooltip_text(Some(tooltip)); + } + container.append(&node); + } + } + + // The Tray placement has its own section/separator (handled below, + // same as the existing SNI tray items) — an empty inline slot has no + // such wrapper, so it must hide itself to stop contributing to + // center_area's `spacing` gap. + for (placement, container) in &self.widget_containers { + if *placement == bread_shared::widget::WidgetPlacement::Tray { + continue; + } + container.set_visible(container.first_child().is_some()); + } + + let has_tray_widgets = specs.iter().any(|s| { + s.visible && s.placement == bread_shared::widget::WidgetPlacement::Tray + }); + self.widget_tray_section.set_visible(has_tray_widgets); + self.widget_tray_sep.set_visible(has_tray_widgets); + } + fn apply_wifi_label(&self) { let label = match &self.wifi_profile { Some(p) => format!("{p} · {}", self.current_ssid), diff --git a/src/theme.rs b/src/theme.rs index ea20c89..5bb20b1 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -89,7 +89,57 @@ fn load_css() -> String { .control-panel-sink-dropdown {{ }}\ .power-row {{ margin-top: 2px; }}\ .power-btn {{ min-width: 40px; padding: 8px; border-radius: {radius_sm}; }}\ - separator {{ margin: 4px 0; }}", + separator {{ margin: 4px 0; }}\ + /* Lua-declared widgets (see Documentation.md's Widgets §style): the\ + slot rule below is what the four inline `.bread-widget-slot`\ + containers in main.rs rely on for the same 12px stat-pair rhythm\ + everything else in the bar uses (they carried the class with no\ + rule defining it until now). Everything after that is the fixed,\ + closed `style` vocabulary a `WidgetNode` can opt into — one class\ + per enum variant, so a module can only ever pick from this set,\ + never inject arbitrary CSS. The progress-bar rules give an\ + unstyled Progress node an intentional accent-colored fill instead\ + of Adwaita's default blue-on-gray, and let `style.color` retint\ + that fill the same way it retints label/icon text. */\ + .bread-widget-slot {{ margin-right: 12px; }}\ + progressbar.bread-widget-node trough {{ background-image: none; background-color: alpha(@accent, 0.25); border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node trough progress {{ background-image: none; background-color: @accent; border-radius: 3px; min-height: 6px; }}\ + progressbar.bread-widget-node.bread-color-fg trough progress {{ background-color: @fg; }}\ + progressbar.bread-widget-node.bread-color-dim trough progress {{ background-color: alpha(@fg, 0.6); }}\ + progressbar.bread-widget-node.bread-color-accent trough progress {{ background-color: @accent; }}\ + progressbar.bread-widget-node.bread-color-red trough progress {{ background-color: @red; }}\ + progressbar.bread-widget-node.bread-color-green trough progress {{ background-color: @green; }}\ + progressbar.bread-widget-node.bread-color-yellow trough progress {{ background-color: @yellow; }}\ + progressbar.bread-widget-node.bread-color-blue trough progress {{ background-color: @blue; }}\ + progressbar.bread-widget-node.bread-color-pink trough progress {{ background-color: @pink; }}\ + progressbar.bread-widget-node.bread-color-teal trough progress {{ background-color: @teal; }}\ + .bread-color-fg {{ color: @fg; }}\ + .bread-color-dim {{ color: @fg; opacity: 0.6; }}\ + .bread-color-accent {{ color: @accent; }}\ + .bread-color-red {{ color: @red; }}\ + .bread-color-green {{ color: @green; }}\ + .bread-color-yellow {{ color: @yellow; }}\ + .bread-color-blue {{ color: @blue; }}\ + .bread-color-pink {{ color: @pink; }}\ + .bread-color-teal {{ color: @teal; }}\ + .bread-weight-normal {{ font-weight: normal; }}\ + .bread-weight-bold {{ font-weight: bold; }}\ + .bread-size-xs {{ font-size: 10px; }}\ + .bread-size-sm {{ font-size: 12px; }}\ + .bread-size-md {{ font-size: 14px; }}\ + .bread-size-lg {{ font-size: 16px; }}\ + .bread-size-xl {{ font-size: 20px; }}\ + .bread-bg-none {{ background-color: transparent; }}\ + .bread-bg-surface {{ background-color: @surface; color: @on-surface; }}\ + .bread-bg-card {{ background-color: @surface; color: @on-surface; border-radius: 8px; padding: 12px; }}\ + .bread-radius-none {{ border-radius: 0; }}\ + .bread-radius-sm {{ border-radius: 4px; }}\ + .bread-radius-md {{ border-radius: 8px; }}\ + .bread-radius-full {{ border-radius: 999px; }}\ + .bread-padding-none {{ padding: 0; }}\ + .bread-padding-xs {{ padding: 4px; }}\ + .bread-padding-sm {{ padding: 8px; }}\ + .bread-padding-md {{ padding: 12px; }}", bg_plain = p.background, bg_rgba = hex_to_rgba(&p.background, 0.92), surface = p.color0, diff --git a/src/widgets/client.rs b/src/widgets/client.rs new file mode 100644 index 0000000..84d043b --- /dev/null +++ b/src/widgets/client.rs @@ -0,0 +1,106 @@ +//! Connects to breadd's IPC socket and keeps the bar's widget set in sync. +//! +//! breadbar is level-triggered here, not edge-triggered: `bread.widget.*` +//! events are used purely as a "something changed, go re-fetch" signal, not +//! applied as incremental patches. Every dirty signal (and the initial +//! connect) re-requests the complete widget list and hands it to `update()` +//! as one `AppInput::WidgetsUpdate`, which reconciles the bar's containers +//! from scratch. This sidesteps event-ordering/drop concerns entirely, and +//! widget registries are small enough that re-fetching the full list on +//! every change is not a real cost. + +use crate::{App, AppInput}; +use bread_shared::widget::WidgetSpec; +use bread_utils::bread_client::BreadClient; +use relm4::ComponentSender; +use std::time::Duration; + +/// breadbar's own registered app id — already reserved in +/// `bread_shared::apps::KNOWN_APPS` (see `Documentation.md`'s Namespaces +/// section). Used both to fetch widgets and to publish click events. +pub const APP_ID: &str = "bar"; + +/// Safety-net poll interval. `bread.widget.cleared` (emitted once per +/// daemon reload, including a full restart — see breadd's `reload_internal`) +/// is meant to catch the case where a module stops registering widgets +/// without anything else re-triggering a fetch, but a *restart* (as opposed +/// to a live `bread reload`) drops the subscription entirely; if that one +/// event fires before `BreadClient::subscribe`'s reconnect-with-backoff +/// finishes re-establishing the stream, it's missed and there's no second +/// chance from the event side. This poll is the backstop for that race — +/// infrequent enough that it's not a real cost, frequent enough that a missed +/// event self-heals well within a session rather than needing a manual +/// breadbar restart to clear stale widgets. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Start the widget subsystem: an initial fetch, a live subscription that +/// re-fetches on every `bread.widget.*` change, and a low-frequency poll as +/// a backstop against the reconnect race described above. Call once from +/// `init`. +pub fn spawn(sender: ComponentSender) { + // BreadClient::request is blocking std I/O; run it off the tokio + // runtime breadbar's other pollers rely on, same as the reasoning in + // `BreadClient::subscribe`'s own background-thread design. + let initial = sender.clone(); + std::thread::spawn(move || fetch_and_send(&initial)); + + // `subscribe` already reconnects with backoff on its own background + // thread for the lifetime of the process — there is no natural point to + // stop it before the app exits, so the handle is intentionally leaked + // rather than threaded through App just to be dropped at shutdown. + let live = sender.clone(); + let client = BreadClient::connect(APP_ID); + let subscription = client.subscribe("bread.widget.**", move |_event| { + fetch_and_send(&live); + }); + std::mem::forget(subscription); + + let polled = sender.clone(); + relm4::spawn(async move { + loop { + tokio::time::sleep(POLL_INTERVAL).await; + let polled = polled.clone(); + std::thread::spawn(move || fetch_and_send(&polled)); + } + }); +} + +fn fetch_and_send(sender: &ComponentSender) { + let client = BreadClient::connect(APP_ID); + let Some(result) = client.request("widgets.list", serde_json::Value::Null) else { + return; + }; + // Decode element-wise rather than `Vec` in one shot — one + // malformed entry from any module (a bad `class`, an unknown enum value, + // ...) must not blank out every other module's widgets. + let raw: Vec = serde_json::from_value(result).unwrap_or_default(); + let specs: Vec = raw + .into_iter() + .filter_map(|v| { + // `id`/`module` are read before the value is consumed by the + // failed parse below, so a malformed spec still names itself in + // the warning instead of just printing a bare serde error. + let id = v.get("id").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + let module = v.get("module").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + match serde_json::from_value::(v) { + Ok(spec) => Some(spec), + Err(e) => { + eprintln!( + "breadbar: dropping malformed widget spec (id={id}, module={module}): {e}" + ); + None + } + } + }) + .collect(); + sender.input(AppInput::WidgetsUpdate(specs)); +} + +/// Publish a widget click back to breadd. `action` is whatever opaque value +/// the Lua module put in the clicked node's `on_click`. +pub fn emit_click(widget_id: &str, action: &serde_json::Value) { + BreadClient::connect(APP_ID).emit( + "bread.bar.widget_clicked", + serde_json::json!({ "widget_id": widget_id, "action": action }), + ); +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..019f434 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,7 @@ +//! Lua-declared, live-updating widgets (see `Documentation.md`'s "Widgets" +//! section in the `bread` repo) rendered into breadbar's fixed layout slots. + +pub mod client; +mod render; + +pub use render::build_node; diff --git a/src/widgets/render.rs b/src/widgets/render.rs new file mode 100644 index 0000000..53de47b --- /dev/null +++ b/src/widgets/render.rs @@ -0,0 +1,212 @@ +//! Turns a `WidgetNode` tree into a live GTK4 widget tree. +//! +//! There is no diffing at the node level — see `client.rs`'s module doc for +//! why the whole thing is simply rebuilt whenever a widget's spec changes. +//! This keeps the renderer a pure, stateless `WidgetNode -> gtk4::Widget` +//! function. + +use super::client; +use bread_shared::widget::{ + Align as StyleAlign, Background, FontWeight, Orientation as NodeOrientation, Padding, Radius, + SemanticColor, TextSize, WidgetNode, WidgetStyle, +}; +use gtk4::prelude::*; + +/// Default max width for a Label node, in characters, absent an explicit +/// `size`/other override — the `style` vocabulary (see Documentation.md's +/// Widgets §style) has no dedicated width field yet, so this stays fixed for +/// every label rather than becoming a half-exposed knob. +const DEFAULT_LABEL_MAX_WIDTH_CHARS: i32 = 32; + +/// Curated bundled icons a widget can reference by name, so module authors +/// don't need to ship an SVG just to show a battery or bluetooth glyph. +/// Anything else goes through `icon.path` instead (see `bundled_or_path_icon`). +fn bundled_icon(name: &str) -> Option<&'static str> { + use crate::bar::stats::{ + AC_POWER, BAT_HIGH, BAT_LOW, BAT_MID, BT_OFF, BT_ON, ICON_BRIGHTNESS, ICON_LOCK, + ICON_VOLUME, WIFI_OFF, WIFI_STRONG, + }; + Some(match name { + "ac-power" => AC_POWER, + "battery-high" => BAT_HIGH, + "battery-mid" => BAT_MID, + "battery-low" => BAT_LOW, + "bluetooth-on" => BT_ON, + "bluetooth-off" => BT_OFF, + "wifi-strong" => WIFI_STRONG, + "wifi-off" => WIFI_OFF, + "lock" => ICON_LOCK, + "volume" => ICON_VOLUME, + "brightness" => ICON_BRIGHTNESS, + _ => return None, + }) +} + +fn icon_texture( + widget_id: &str, + name: Option<&str>, + path: Option<&str>, + px: u32, +) -> Option { + if let Some(n) = name { + return match bundled_icon(n) { + Some(svg) => Some(crate::svg_texture_sized(svg, px)), + None => { + eprintln!("breadbar: widget {widget_id}: unknown bundled icon name '{n}'"); + None + } + }; + } + let Some(path) = path else { + eprintln!("breadbar: widget {widget_id}: icon node has neither 'name' nor 'path'"); + return None; + }; + let expanded = bread_shared::expand_path(path); + match std::fs::read_to_string(&expanded) { + Ok(svg) => Some(crate::svg_texture_sized(&svg, px)), + Err(e) => { + eprintln!("breadbar: widget {widget_id}: failed to read icon path '{path}': {e}"); + None + } + } +} + +/// Map a node's typed `style` onto predefined CSS classes (see `theme.rs` for +/// the class definitions) — this is the only path from Lua's `style` field to +/// the widget, kept as narrow `Some(field) -> one class` mappings so there is +/// no way for it to become raw style injection. +fn apply_style(widget: >k4::Widget, style: &WidgetStyle) { + if let Some(color) = style.color { + widget.add_css_class(match color { + SemanticColor::Fg => "bread-color-fg", + SemanticColor::Dim => "bread-color-dim", + SemanticColor::Accent => "bread-color-accent", + SemanticColor::Red => "bread-color-red", + SemanticColor::Green => "bread-color-green", + SemanticColor::Yellow => "bread-color-yellow", + SemanticColor::Blue => "bread-color-blue", + SemanticColor::Pink => "bread-color-pink", + SemanticColor::Teal => "bread-color-teal", + }); + } + if let Some(weight) = style.weight { + widget.add_css_class(match weight { + FontWeight::Normal => "bread-weight-normal", + FontWeight::Bold => "bread-weight-bold", + }); + } + if let Some(size) = style.size { + widget.add_css_class(match size { + TextSize::Xs => "bread-size-xs", + TextSize::Sm => "bread-size-sm", + TextSize::Md => "bread-size-md", + TextSize::Lg => "bread-size-lg", + TextSize::Xl => "bread-size-xl", + }); + } + if let Some(background) = style.background { + widget.add_css_class(match background { + Background::None => "bread-bg-none", + Background::Surface => "bread-bg-surface", + Background::Card => "bread-bg-card", + }); + } + if let Some(radius) = style.radius { + widget.add_css_class(match radius { + Radius::None => "bread-radius-none", + Radius::Sm => "bread-radius-sm", + Radius::Md => "bread-radius-md", + Radius::Full => "bread-radius-full", + }); + } + if let Some(padding) = style.padding { + widget.add_css_class(match padding { + Padding::None => "bread-padding-none", + Padding::Xs => "bread-padding-xs", + Padding::Sm => "bread-padding-sm", + Padding::Md => "bread-padding-md", + }); + } + // GTK CSS has no text-align/justify-content equivalent — alignment is a + // widget property, not a stylesheet rule, so it's set directly instead + // of routing through an inert CSS class like the fields above. + if let Some(align) = style.align { + widget.set_halign(match align { + StyleAlign::Start => gtk4::Align::Start, + StyleAlign::Center => gtk4::Align::Center, + StyleAlign::End => gtk4::Align::End, + }); + } +} + +/// Build (or rebuild) the GTK widget tree for `node`, belonging to widget +/// `widget_id` (fully-qualified `.`, used to tag any click). +pub fn build_node(node: &WidgetNode, widget_id: &str) -> gtk4::Widget { + let widget: gtk4::Widget = match node { + WidgetNode::Box { + orientation, + spacing, + children, + .. + } => { + let gtk_orientation = match orientation { + NodeOrientation::Horizontal => gtk4::Orientation::Horizontal, + NodeOrientation::Vertical => gtk4::Orientation::Vertical, + }; + let container = gtk4::Box::new(gtk_orientation, spacing.unwrap_or(4)); + for child in children { + container.append(&build_node(child, widget_id)); + } + container.upcast() + } + WidgetNode::Label { text, .. } => { + let label = gtk4::Label::new(Some(text)); + // Unbounded, this is a bar-width-blowout waiting to happen from + // any buggy or malicious module — see Documentation.md issue #6. + label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + label.set_max_width_chars(DEFAULT_LABEL_MAX_WIDTH_CHARS); + label.upcast() + } + WidgetNode::Icon { name, path, size, .. } => { + let px = size.unwrap_or(16).max(1) as u32; + let texture = icon_texture(widget_id, name.as_deref(), path.as_deref(), px); + let image = gtk4::Image::from_paintable(texture.as_ref()); + image.upcast() + } + WidgetNode::Progress { value, .. } => { + let bar = gtk4::ProgressBar::new(); + bar.set_fraction(value.clamp(0.0, 1.0)); + // GtkProgressBar's natural expand behavior is to fill all + // available width, which — unlike Label/Box/Image, which hug + // their content by default — propagates up through every + // ancestor Box that doesn't set hexpand explicitly, all the way + // to the bar's end_widget. Pin it to a small fixed footprint so + // it reads as an inline meter instead of swallowing the bar. + bar.set_hexpand(false); + bar.set_valign(gtk4::Align::Center); + bar.set_size_request(40, 6); + bar.upcast() + } + }; + + widget.add_css_class("bread-widget-node"); + if let Some(class) = node.class() { + widget.add_css_class(class); + } + if let Some(style) = node.style() { + apply_style(&widget, style); + } + + if let Some(action) = node.on_click() { + widget.add_css_class("clickable"); + let widget_id = widget_id.to_string(); + let action = action.clone(); + let gesture = gtk4::GestureClick::new(); + gesture.connect_released(move |_, _, _, _| { + client::emit_click(&widget_id, &action); + }); + widget.add_controller(gesture); + } + + widget +}