Will change this commit message to mean something later
Some checks failed
dev release / build (push) Failing after 2s

This commit is contained in:
Breadway 2026-07-22 19:53:51 +08:00
parent 905d91580d
commit 175af5d483
7 changed files with 611 additions and 28 deletions

105
Cargo.lock generated
View file

@ -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",
]

View file

@ -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

View file

@ -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<String, gtk4::Button>,
// ── 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<bread_shared::widget::WidgetPlacement, gtk4::Box>,
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<bread_shared::widget::WidgetSpec>),
}
#[relm4::component(pub)]
@ -125,17 +136,6 @@ impl SimpleComponent for App {
#[name = "center_box"]
gtk::CenterBox {
#[wrap(Some)]
set_start_widget = &gtk::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(&center_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<bread_shared::widget::WidgetSpec>) {
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),

View file

@ -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,

106
src/widgets/client.rs Normal file
View file

@ -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<App>) {
// 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<App>) {
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<WidgetSpec>` 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::Value> = serde_json::from_value(result).unwrap_or_default();
let specs: Vec<WidgetSpec> = 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::<WidgetSpec>(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 }),
);
}

7
src/widgets/mod.rs Normal file
View file

@ -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;

212
src/widgets/render.rs Normal file
View file

@ -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<gtk4::gdk::Texture> {
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: &gtk4::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 `<module>.<id>`, 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
}