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

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
}