From 8d3f55b607fd95e12f97bb00be95bac94fbf3992 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:52:16 +0800 Subject: [PATCH] Will change this commit message to mean something later --- Documentation.md | 135 ++++- Examples.md | 72 +++ bread-shared/src/lib.rs | 1 + bread-shared/src/widget.rs | 580 +++++++++++++++++++ breadd/src/core/state_engine.rs | 8 + breadd/src/core/types.rs | 5 + breadd/src/ipc/mod.rs | 6 +- breadd/src/lua/mod.rs | 394 +++++++++++-- examples/modules/README.md | 5 + examples/modules/active-window-widget.lua | 46 ++ examples/modules/bluetooth-toggle-widget.lua | 66 +++ examples/modules/cpu-temp-widget.lua | 64 ++ examples/modules/focus-mode-widget.lua | 66 +++ examples/modules/workflow-status-widget.lua | 83 +++ 14 files changed, 1491 insertions(+), 40 deletions(-) create mode 100644 bread-shared/src/widget.rs create mode 100644 examples/modules/active-window-widget.lua create mode 100644 examples/modules/bluetooth-toggle-widget.lua create mode 100644 examples/modules/cpu-temp-widget.lua create mode 100644 examples/modules/focus-mode-widget.lua create mode 100644 examples/modules/workflow-status-widget.lua diff --git a/Documentation.md b/Documentation.md index 36fa768..af445c5 100644 --- a/Documentation.md +++ b/Documentation.md @@ -11,6 +11,7 @@ - [Debugging tips](#debugging-tips) - [Dictionary: Lua API](#dictionary-lua-api) - [Workflows](#workflows-since-v12) + - [Widgets](#widgets-since-v13) - [Bluetooth](#bluetooth) - [Dictionary: Built-in modules](#dictionary-built-in-modules) - [Dictionary: Event reference](#dictionary-event-reference) @@ -286,6 +287,105 @@ Returns the current status for `name`, or `nil` if no workflow with that name ha #### `bread.workflow.list() -> table` Returns an array of every workflow's current status, in the same shape as `bread.workflow.status`. +### Widgets *(Since: v1.3)* + +Declarative, live-updating widgets rendered by sibling `bread*` apps (breadbar) in their own bar/popover free space. A widget is a small tree of typed nodes — `box`, `label`, `icon`, `progress` — not raw markup: this keeps rendering generic across every consuming app and keeps a node's appearance confined to a bounded, typed `style` vocabulary the renderer already knows about (see `style` below), with no style/CSS injection surface from Lua. + +Widgets are registered per-module and are re-registered fresh on every hot reload (the whole registry is cleared right before the Lua VM resets, same as `bread.module`'s per-reload re-execution) — call `bread.widget.register` at module top level or in `on_load`, not somewhere that only runs once ever. + +#### `bread.widget.register(spec) -> ok, err` +Registers (or replaces, if `spec.id` already exists for this module) a widget. `spec`: + +| Key | Type | Description | +|-----|------|-------------| +| `id` | string | Local id, unique within your module. Stored/addressed elsewhere as `"."`. | +| `placement` | string | One of `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` — which fixed slot in the consuming app's layout this widget renders into. | +| `order` | number | Optional, default `0`. Sort priority within a placement; lower sorts first. | +| `visible` | bool | Optional, default `true`. | +| `tooltip` | string | Optional. | +| `root` | node | The render tree (see Node types below). | + +Returns `true` on success, or `false, err` if `root` fails validation (tree too deep, too many nodes, or an invalid `class`), `root` contains a `style` field with a value outside its enum (a deserialization error, reported the same way), or `bread.widget.register` was called outside a module. + +##### Node types + +Every node accepts an optional `style` (a bounded, typed vocabulary — see below; this is the primary way to control a node's appearance), an optional `class` (a small freeform escape hatch, see [Style vs. class](#style-vs-class) below), and an optional `on_click` (any Lua value, passed through opaquely — see Click events below). + +| `type` | Fields | +|--------|--------| +| `box` | `orientation` (`"horizontal"` \| `"vertical"`, default horizontal), `spacing`, `children` (array of nodes) | +| `label` | `text` | +| `icon` | `name` (bundled icon) or `path` (arbitrary SVG file) — exactly one; `size` | +| `progress` | `value` (0.0–1.0) | + +A tree is capped at depth 4 (root counts as depth 1) and 50 total nodes — comfortably enough for a status readout, not enough to build a full custom UI. + +```lua +bread.widget.register({ + id = "weather", + placement = "left_of_stats", + tooltip = "Sydney: Partly cloudy", + root = { + type = "box", + children = { + { type = "icon", name = "cloud" }, + { type = "label", text = "22°C", style = { color = "dim" }, on_click = "refresh" }, + }, + }, +}) +``` + +##### `style` *(Since: v1.4)* + +`style` is a bounded, typed vocabulary for a node's appearance — every field is a small closed enum, not a string, so a typo is a `bread.widget.register` validation failure at registration time, not a silently-ignored CSS class. There is deliberately **no raw CSS/style-string field** anywhere in this API: a module can only ever pick from the fixed set below, never inject arbitrary style. + +| Field | Type | Values | +|-------|------|--------| +| `color` | string | `fg`, `dim` (muted foreground), `accent`, `red`, `green`, `yellow`, `blue`, `pink`, `teal` | +| `weight` | string | `normal`, `bold` | +| `size` | string | `xs`, `sm`, `md`, `lg`, `xl` — text size in px (10/12/14/16/20); `sm`/`md` match the bread design system's own secondary/base font sizes | +| `align` | string | `start`, `center`, `end` | +| `background` | string | `none`, `surface`, `card` (surface + rounded corners + padding) | +| `radius` | string | `none`, `sm`, `md`, `full` (pill) | +| `padding` | string | `none`, `xs`, `sm`, `md` | + +Every field is optional and independent — set only what you need. Colors, font sizes, radii, and padding all reuse the exact same palette, font, and spacing scale every other `bread*` GUI (breadbar, bos-settings, breadpad, ...) is themed from, so a widget recolors with the rest of the desktop when pywal's palette changes instead of drifting out of sync. + +```lua +{ type = "label", text = "LOW BATTERY", style = { color = "yellow", weight = "bold" } } +``` + +##### Style vs. `class` + +`class` still exists as an escape hatch for a CSS class the *consuming app's own stylesheet* happens to define (restricted to `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) — useful if you're targeting a specific app you know the internals of, but undiscoverable and app-specific otherwise. As of this writing, breadbar's stylesheet only gives real meaning to `dim` this way (fades a node to 60% opacity) — everything else a module needs (color, weight, size, alignment, background, radius, padding) should go through `style` instead, which every renderer is expected to understand identically. + +#### `bread.widget.update(id, patch) -> ok, err` +Patches an already-registered widget (local `id`, not the fully-qualified form). Any of `root`, `tooltip`, `visible`, `order` may be given; omitted fields are left as-is. `root`, when given, replaces the whole tree — there is no node-level patching. Returns `false, "no such widget"` if `id` isn't registered. + +```lua +bread.widget.update("weather", { + root = { type = "box", children = { { type = "label", text = "23°C" } } }, +}) +``` + +#### `bread.widget.remove(id) -> bool` +Removes a widget registered by the calling module. Returns whether anything was removed. + +#### `bread.widget.list() -> table` +Returns an array of every widget the calling module currently has registered. + +##### Click events + +A clicked node's `on_click` value doesn't travel back through `breadd` directly — the rendering app (breadbar) emits `bread.bar.widget_clicked` with `{ widget_id, action }` (`action` being whatever you put in `on_click`), because a rendering app may only publish inside its own `bread..*` namespace (see [Namespaces](#namespaces)). React to it like any other event, filtering on `widget_id`: + +```lua +bread.on("bread.bar.widget_clicked", function(e) + if e.data.widget_id == "weather.weather" then + -- e.data.action == "refresh" + end +end) +``` + ### State #### `bread.state.get(path)` @@ -816,6 +916,17 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre | `bread.notify.sent` | `{ title, message, urgency }` | | `bread.state.changed.` | emitted by state watches | +#### Widgets *(Since: v1.3)* + +Emitted by `breadd` itself on every `bread.widget.*` mutation — see [Widgets](#widgets-since-v13). `data` is the full `WidgetSpec` for `registered`/`updated`; just `{ id }` for `removed`. + +| Event | Data | +|-------|------| +| `bread.widget.registered` | `{ id, module, placement, order, visible, tooltip, root, updated_at }` | +| `bread.widget.updated` | same shape as `registered` | +| `bread.widget.removed` | `{ id }` | +| `bread.widget.cleared` | `{}` — fired once at the end of every module reload (`bread reload`), whether or not the widget set actually changed. The registry itself is wiped and re-populated as modules re-run; this is a "go re-fetch" signal for consumers that only react to `bread.widget.*` events, so a module that stops registering widgets (e.g. gets disabled) is noticed even though nothing else fires. | + #### Terminal (shell precmd/preexec hooks) Requires `bread hooks install shell` and sourcing the generated script from your shell rc — see the CLI reference. Fires via the `bread-emit` helper, not the daemon reaching out. @@ -885,6 +996,8 @@ Rides the same shell-hook transport as Terminal events (`bread hooks install she *Since: v1.1 — the `AdapterSource::App` variant and the known-apps registry (`bread_shared::apps::KNOWN_APPS`). No sibling app emits through this path yet as of this writing except the breadclip pilot (see its own `EVENTS.md` once that lands); the daemon-side plumbing and the convention itself are what v1.1 adds.* +*Since: v1.3 — breadbar is now an active `bread-client` consumer under the `bar` app id (already present in `KNOWN_APPS`): it emits `bread.bar.widget_clicked` for widget clicks (see [Widgets](#widgets-since-v13)) and reads `bread.widget.*` to render the [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)'s `widgets` field.* + Two dotted-name segments are reserved, permanent parts of the schema — not one-off conventions: - **`bread..*`** — inbound events published *by* a sibling `bread*` application about its own state (e.g. `bread.clip.copied`). An app may only publish within its own segment; the daemon enforces this at the IPC boundary (a socket client claiming a `source` of an app id it doesn't own is rejected the same way spoofing `power`/`hyprland` is rejected today). @@ -969,11 +1082,30 @@ This is the checklist for adding a new sibling `bread*` application to the fabri "updated_at": 1710000001500, "error": null } + ], + "widgets": [ + { + "id": "weather.weather", + "module": "weather", + "placement": "left_of_stats", + "order": 0, + "visible": true, + "tooltip": "Sydney: Partly cloudy", + "root": { + "type": "box", + "orientation": "horizontal", + "children": [ + { "type": "icon", "name": "cloud" }, + { "type": "label", "text": "22°C" } + ] + }, + "updated_at": 1710000001500 + } ] } ``` -`modules[].status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. `workflows[].state` values: `running`, `done`, `failed`, `timed_out` *(Since: v1.2 — see [Workflows](#workflows-since-v12))*. +`modules[].status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. `workflows[].state` values: `running`, `done`, `failed`, `timed_out` *(Since: v1.2 — see [Workflows](#workflows-since-v12))*. `widgets[].placement` values: `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` *(Since: v1.3 — see [Widgets](#widgets-since-v13))*. --- @@ -1009,5 +1141,6 @@ Available methods: | `events.replay` | `since_ms` | Replay buffered events from the last N ms | | `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly tagged `System` (legacy path). With `source` set to `terminal`/`git`/`remote`, or a registered sibling-app id (see [Namespaces](#namespaces)), builds a real `RawEvent` (requires `kind` too) that goes through the normalizer like any adapter. Any other `source` value is rejected — this is the anti-spoofing boundary that stops a socket client from forging e.g. `power`/`hyprland` events. | | `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* | +| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* | The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)). diff --git a/Examples.md b/Examples.md index 01da058..c610455 100644 --- a/Examples.md +++ b/Examples.md @@ -238,6 +238,78 @@ Check on a running (or finished) workflow via the IPC method directly (there's n echo '{"id":"1","method":"workflows.list","params":{}}' | nc -U -q0 "$XDG_RUNTIME_DIR/bread/breadd.sock" ``` +## Example 5: A live widget in breadbar + +The examples above all react to something; they don't put anything on +screen. `bread.widget` *(Since: v1.3)* does — a module declares a small node +tree and breadbar (or any sibling app that renders `bread.widget.*`) shows +it in one of five fixed layout slots, live-updated from Lua. + +Full source: `examples/modules/cpu-temp-widget.lua`. + +```lua +-- ~/.config/bread/modules/cpu-temp-widget.lua +local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" }) + +local TEMP_PATH = "/sys/class/hwmon/hwmon6/temp1_input" +local HOT_THRESHOLD_C = 80 + +local function read_temp_c() + local raw = bread.fs.read(TEMP_PATH) + return raw and (tonumber(raw) / 1000) or nil +end + +local function widget_root(temp_c) + local text = temp_c and string.format("%.0f°C", temp_c) or "—" + local hot = temp_c ~= nil and temp_c >= HOT_THRESHOLD_C + return { + type = "box", + children = { + { + type = "label", + text = text, + style = hot and { color = "red", weight = "bold" } or { color = "dim" }, + }, + { + type = "progress", + value = temp_c and math.min(temp_c / 100, 1.0) or 0, + style = hot and { color = "red" } or nil, + }, + }, + } +end + +function M.on_load() + bread.widget.register({ + id = "cpu-temp", + placement = "left_of_stats", + tooltip = "CPU package temperature (Tctl)", + root = widget_root(read_temp_c()), + }) + + bread.every(5000, function() + bread.widget.update("cpu-temp", { root = widget_root(read_temp_c()) }) + end) +end + +return M +``` + +Walking through what each piece buys you: + +- **`root` is a small typed tree, not markup.** `box`/`label`/`icon`/`progress` map directly onto GTK primitives, so any renderer can draw it without interpreting a DSL — see [Widgets](Documentation.md#widgets-since-v13) for the full node reference and the size/depth caps. +- **`bread.widget.update(id, { root = ... })` replaces the whole tree.** There's no node-level patching — for something this small, rebuilding the tree on every tick (here, every 5s) is simpler than diffing, and it's cheap enough that it doesn't matter. +- **`style` is a bounded, typed vocabulary, not a style string.** `color = "red"` here maps to one fixed CSS class the rendering app defines, resolved from the real pywal-derived palette — see [Widgets §style](Documentation.md#style-since-v14) for the full field list. There's also a freeform `class` escape hatch, but a module can't inject arbitrary CSS through either path. +- **Clicks come back as events, not callbacks.** A node's `on_click` value isn't invoked directly — the rendering app emits `bread.bar.widget_clicked` with `{ widget_id, action }`, and your module reacts with a normal `bread.on` handler. See `examples/modules/bluetooth-toggle-widget.lua` for a widget that uses this to drive a real action (`bread.bluetooth.power`) instead of just displaying something — or `examples/modules/focus-mode-widget.lua` for one that drives `bread.profile.activate` and stays in sync when the profile changes from somewhere else entirely (the CLI, another module), not just from its own click. +- **Placement is one of five fixed slots** (`tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats`) — see `examples/modules/active-window-widget.lua` for `right_of_workspaces` driven by `bread.state.watch` instead of a timer. +- **A widget doesn't have to read hardware.** `examples/modules/workflow-status-widget.lua` polls `bread.workflow.list()` instead — the same engine from Example 4 — and sets `visible = false` to disappear entirely when there's nothing to report, rather than showing a stale or empty readout. + +Check what's currently registered over IPC (there's no dedicated `bread` subcommand for this yet — see `widgets.list` in the [IPC protocol dictionary](Documentation.md#dictionary-ipc-protocol)): + +```bash +echo '{"id":"1","method":"widgets.list","params":{}}' | nc -U -q0 "$XDG_RUNTIME_DIR/bread/breadd.sock" +``` + ## Tips for porting your own scripts - Start by logging the event payload: `bread.log(event.data.raw)` diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index f5982d8..50a2f25 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; pub mod apps; pub mod glob; +pub mod widget; /// Identifies which adapter produced an event. /// diff --git a/bread-shared/src/widget.rs b/bread-shared/src/widget.rs new file mode 100644 index 0000000..50d148b --- /dev/null +++ b/bread-shared/src/widget.rs @@ -0,0 +1,580 @@ +//! Wire types for Lua-declared bar widgets. +//! +//! A module running in breadd's Lua runtime can register a small declarative +//! node tree (see [`WidgetNode`]) that gets rendered generically by a +//! sibling `bread*` app (breadbar) in its bar or hamburger-popover free +//! space. These types are the shared contract between `breadd` (which +//! stores/validates/emits them from `bread.widget.*`) and any renderer. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Maximum nesting depth of a widget's node tree (the root counts as depth 1). +pub const MAX_NODE_DEPTH: usize = 4; +/// Maximum total number of nodes (root + all descendants) in a widget's tree. +pub const MAX_NODE_COUNT: usize = 50; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Orientation { + Horizontal, + Vertical, +} + +/// One node in a widget's declarative render tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum WidgetNode { + Box { + #[serde(default = "default_orientation")] + orientation: Orientation, + #[serde(default)] + spacing: Option, + #[serde(default)] + class: Option, + #[serde(default)] + style: Option, + #[serde(default)] + on_click: Option, + #[serde(default)] + children: Vec, + }, + Label { + text: String, + #[serde(default)] + class: Option, + #[serde(default)] + style: Option, + #[serde(default)] + on_click: Option, + }, + Icon { + #[serde(default)] + name: Option, + #[serde(default)] + path: Option, + #[serde(default)] + size: Option, + #[serde(default)] + class: Option, + #[serde(default)] + style: Option, + #[serde(default)] + on_click: Option, + }, + Progress { + value: f64, + #[serde(default)] + class: Option, + #[serde(default)] + style: Option, + #[serde(default)] + on_click: Option, + }, +} + +fn default_orientation() -> Orientation { + Orientation::Horizontal +} + +/// A bounded, typed vocabulary for a node's appearance — the alternative to +/// letting Lua hand the renderer a raw CSS/style string. Every field is a +/// small closed enum, so an invalid value is simply a deserialization error, +/// the same as any other malformed field; there is no free-text surface here +/// for a module to smuggle style-string injection through. +/// +/// Fields left `None` mean "renderer default" (see `render.rs`'s `build_node` +/// for what that default looks like), not "no style" — a node with no +/// `style` at all is fully equivalent to one whose every field is `None`. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct WidgetStyle { + pub color: Option, + pub weight: Option, + pub size: Option, + pub align: Option, + pub background: Option, + pub radius: Option, + pub padding: Option, +} + +/// Foreground/text colors, one per name `bread-theme`'s shared stylesheet +/// defines via `@define-color` (see that crate's `color_pairs()`). Deliberately +/// excludes `bg`/`surface`/`overlay`, which only make sense as backgrounds. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SemanticColor { + /// Foreground / body text color. + Fg, + /// Muted foreground — the existing `.dim` look, formalized. + Dim, + Accent, + Red, + Green, + Yellow, + Blue, + Pink, + Teal, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum FontWeight { + Normal, + Bold, +} + +/// Text size scale (10/12/14/16/20px) — `sm`/`md` match `bread-theme::tokens`' +/// existing `FONT_SIZE_SECONDARY`/`FONT_SIZE_BASE` rather than inventing a +/// second set of magic numbers; `xs`/`lg`/`xl` fill out the rest of the scale +/// (the spacing scale's 4/8/12/16/20px is for padding/radius, not text — +/// applying it directly to `font-size` renders illegibly at the small end). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TextSize { + Xs, + Sm, + Md, + Lg, + Xl, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Align { + Start, + Center, + End, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Background { + None, + Surface, + Card, +} + +/// Reuses `bread-theme::tokens`' radius scale: `sm` = tertiary (4px, small +/// interactive elements), `md` = primary (8px), `full` = pill (999px). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Radius { + None, + Sm, + Md, + Full, +} + +/// Reuses `bread-theme::tokens`' spacing scale (4/8/12px). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Padding { + None, + Xs, + Sm, + Md, +} + +/// Where a widget renders in breadbar. Each variant names a fixed slot in +/// breadbar's existing `CenterBox` layout (workspaces | clock | stats), plus +/// the hamburger control-panel popover's tray section. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum WidgetPlacement { + /// Tucked inside the hamburger control-panel popover, alongside the + /// existing SNI tray icons. + Tray, + /// In the center section, immediately left of the clock label. + LeftOfClock, + /// In the center section, immediately right of the clock label. + RightOfClock, + /// In the start section, immediately right of the workspace buttons. + RightOfWorkspaces, + /// In the end section, immediately left of the CPU/RAM/power/battery group. + LeftOfStats, +} + +/// A full widget declaration, as stored in `RuntimeState.widgets` and +/// returned by the `widgets.list` IPC method. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WidgetSpec { + /// Fully-qualified id: `"."`. Unique across all widgets. + pub id: String, + /// Name of the module that registered this widget. + pub module: String, + pub placement: WidgetPlacement, + /// Sort priority within a placement; lower sorts first. + #[serde(default)] + pub order: i32, + #[serde(default = "default_visible")] + pub visible: bool, + #[serde(default)] + pub tooltip: Option, + pub root: WidgetNode, + /// Unix epoch milliseconds of the last register/update. + pub updated_at: u64, +} + +fn default_visible() -> bool { + true +} + +/// Why a [`WidgetNode`] tree failed validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WidgetValidationError { + TooDeep { max: usize }, + TooManyNodes { max: usize }, + InvalidClass { class: String }, +} + +impl std::fmt::Display for WidgetValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooDeep { max } => write!(f, "widget node tree exceeds max depth of {max}"), + Self::TooManyNodes { max } => { + write!(f, "widget node tree exceeds max node count of {max}") + } + Self::InvalidClass { class } => write!( + f, + "invalid css class '{class}': must match ^[a-zA-Z][a-zA-Z0-9_-]{{0,63}}$" + ), + } + } +} + +impl std::error::Error for WidgetValidationError {} + +/// A CSS class is restricted to a conservative identifier shape so widget +/// styling can only opt into classes predefined in breadbar's stylesheet — +/// there is no raw style/CSS injection surface from Lua. +fn is_valid_class(class: &str) -> bool { + let mut chars = class.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_alphabetic() { + return false; + } + class.len() <= 64 + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +impl WidgetNode { + /// The node's CSS class hook, if any — a renderer should apply this via + /// its GTK equivalent of `add_css_class` rather than injecting raw style. + pub fn class(&self) -> Option<&str> { + match self { + Self::Box { class, .. } + | Self::Label { class, .. } + | Self::Icon { class, .. } + | Self::Progress { class, .. } => class.as_deref(), + } + } + + /// The node's typed style vocabulary, if any — a renderer maps each + /// `Some` field to a predefined CSS class (see `render.rs`'s + /// `apply_style`), never to raw injected CSS. + pub fn style(&self) -> Option<&WidgetStyle> { + match self { + Self::Box { style, .. } + | Self::Label { style, .. } + | Self::Icon { style, .. } + | Self::Progress { style, .. } => style.as_ref(), + } + } + + /// The node's opaque click payload, if any — a renderer attaches a click + /// handler that reports this value back verbatim (see the Lua API's + /// "Click events" contract in `Documentation.md`), it never interprets it. + pub fn on_click(&self) -> Option<&Value> { + match self { + Self::Box { on_click, .. } + | Self::Label { on_click, .. } + | Self::Icon { on_click, .. } + | Self::Progress { on_click, .. } => on_click.as_ref(), + } + } + + fn children(&self) -> &[WidgetNode] { + match self { + Self::Box { children, .. } => children, + _ => &[], + } + } + + /// Validate depth, total node count, and every `class` field. Call this + /// on any tree received from Lua before storing or broadcasting it. + pub fn validate(&self) -> Result<(), WidgetValidationError> { + let mut total = 0usize; + self.validate_inner(1, &mut total) + } + + fn validate_inner( + &self, + depth: usize, + total: &mut usize, + ) -> Result<(), WidgetValidationError> { + if depth > MAX_NODE_DEPTH { + return Err(WidgetValidationError::TooDeep { max: MAX_NODE_DEPTH }); + } + *total += 1; + if *total > MAX_NODE_COUNT { + return Err(WidgetValidationError::TooManyNodes { max: MAX_NODE_COUNT }); + } + if let Some(class) = self.class() { + if !is_valid_class(class) { + return Err(WidgetValidationError::InvalidClass { + class: class.to_string(), + }); + } + } + for child in self.children() { + child.validate_inner(depth + 1, total)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn label(class: Option<&str>) -> WidgetNode { + WidgetNode::Label { + text: "x".to_string(), + class: class.map(str::to_string), + style: None, + on_click: None, + } + } + + fn box_of(children: Vec) -> WidgetNode { + WidgetNode::Box { + orientation: Orientation::Horizontal, + spacing: None, + class: None, + style: None, + on_click: None, + children, + } + } + + #[test] + fn simple_label_is_valid() { + assert!(label(Some("dim")).validate().is_ok()); + } + + #[test] + fn rejects_invalid_class_characters() { + assert_eq!( + label(Some("dim; color: red")).validate(), + Err(WidgetValidationError::InvalidClass { + class: "dim; color: red".to_string() + }) + ); + } + + #[test] + fn rejects_class_starting_with_digit() { + assert!(label(Some("1dim")).validate().is_err()); + } + + #[test] + fn rejects_empty_class() { + assert!(label(Some("")).validate().is_err()); + } + + #[test] + fn accepts_class_with_underscore_and_hyphen() { + assert!(label(Some("my_class-2")).validate().is_ok()); + } + + #[test] + fn rejects_tree_deeper_than_max() { + // depth 1 (root box) -> 2 -> 3 -> 4 -> 5 (label), exceeds MAX_NODE_DEPTH=4 + let tree = box_of(vec![box_of(vec![box_of(vec![box_of(vec![label(None)])])])]); + assert_eq!( + tree.validate(), + Err(WidgetValidationError::TooDeep { max: MAX_NODE_DEPTH }) + ); + } + + #[test] + fn accepts_tree_at_max_depth() { + // depth 1 -> 2 -> 3 -> 4 (label), exactly MAX_NODE_DEPTH + let tree = box_of(vec![box_of(vec![box_of(vec![label(None)])])]); + assert!(tree.validate().is_ok()); + } + + #[test] + fn rejects_too_many_nodes() { + let children: Vec = (0..MAX_NODE_COUNT).map(|_| label(None)).collect(); + let tree = box_of(children); + assert_eq!( + tree.validate(), + Err(WidgetValidationError::TooManyNodes { max: MAX_NODE_COUNT }) + ); + } + + #[test] + fn accepts_node_count_at_max() { + let children: Vec = (0..MAX_NODE_COUNT - 1).map(|_| label(None)).collect(); + let tree = box_of(children); + assert!(tree.validate().is_ok()); + } + + #[test] + fn widget_spec_round_trips_through_json() { + let spec = WidgetSpec { + id: "weather.temp".to_string(), + module: "weather".to_string(), + placement: WidgetPlacement::LeftOfStats, + order: 10, + visible: true, + tooltip: Some("Sydney".to_string()), + root: box_of(vec![ + WidgetNode::Icon { + name: Some("cloud".to_string()), + path: None, + size: Some(16), + class: None, + style: None, + on_click: None, + }, + label(Some("dim")), + WidgetNode::Progress { + value: 0.5, + class: None, + style: Some(WidgetStyle { + color: Some(SemanticColor::Accent), + ..Default::default() + }), + on_click: Some(json!({ "action": "refresh" })), + }, + ]), + updated_at: 1_700_000_000_000, + }; + let raw = serde_json::to_string(&spec).unwrap(); + let decoded: WidgetSpec = serde_json::from_str(&raw).unwrap(); + assert_eq!(decoded.id, spec.id); + assert_eq!(decoded.placement, spec.placement); + } + + #[test] + fn placement_serializes_as_snake_case() { + assert_eq!( + serde_json::to_string(&WidgetPlacement::Tray).unwrap(), + "\"tray\"" + ); + assert_eq!( + serde_json::to_string(&WidgetPlacement::LeftOfClock).unwrap(), + "\"left_of_clock\"" + ); + assert_eq!( + serde_json::to_string(&WidgetPlacement::RightOfClock).unwrap(), + "\"right_of_clock\"" + ); + assert_eq!( + serde_json::to_string(&WidgetPlacement::RightOfWorkspaces).unwrap(), + "\"right_of_workspaces\"" + ); + assert_eq!( + serde_json::to_string(&WidgetPlacement::LeftOfStats).unwrap(), + "\"left_of_stats\"" + ); + } + + #[test] + fn node_serializes_with_type_tag() { + let value = serde_json::to_value(label(Some("dim"))).unwrap(); + assert_eq!(value["type"], "label"); + assert_eq!(value["text"], "x"); + assert_eq!(value["class"], "dim"); + } + + #[test] + fn node_without_style_omits_it_when_serialized_and_back() { + let node = label(None); + assert!(node.style().is_none()); + let raw = serde_json::to_string(&node).unwrap(); + let decoded: WidgetNode = serde_json::from_str(&raw).unwrap(); + assert!(decoded.style().is_none()); + } + + #[test] + fn style_field_is_optional_when_absent_from_json() { + let raw = json!({ "type": "label", "text": "x" }); + let node: WidgetNode = serde_json::from_value(raw).unwrap(); + assert!(node.style().is_none()); + } + + #[test] + fn style_round_trips_through_json() { + let node = WidgetNode::Label { + text: "x".to_string(), + class: None, + style: Some(WidgetStyle { + color: Some(SemanticColor::Red), + weight: Some(FontWeight::Bold), + size: Some(TextSize::Lg), + align: Some(Align::Center), + background: Some(Background::Card), + radius: Some(Radius::Sm), + padding: Some(Padding::Xs), + }), + on_click: None, + }; + let raw = serde_json::to_string(&node).unwrap(); + let decoded: WidgetNode = serde_json::from_str(&raw).unwrap(); + let style = decoded.style().expect("style should round-trip"); + assert_eq!(style.color, Some(SemanticColor::Red)); + assert_eq!(style.weight, Some(FontWeight::Bold)); + assert_eq!(style.size, Some(TextSize::Lg)); + assert_eq!(style.align, Some(Align::Center)); + assert_eq!(style.background, Some(Background::Card)); + assert_eq!(style.radius, Some(Radius::Sm)); + assert_eq!(style.padding, Some(Padding::Xs)); + } + + #[test] + fn style_enums_serialize_as_snake_case() { + assert_eq!(serde_json::to_string(&SemanticColor::Dim).unwrap(), "\"dim\""); + assert_eq!(serde_json::to_string(&Background::None).unwrap(), "\"none\""); + assert_eq!(serde_json::to_string(&Radius::Full).unwrap(), "\"full\""); + assert_eq!(serde_json::to_string(&Padding::Xs).unwrap(), "\"xs\""); + assert_eq!(serde_json::to_string(&Align::End).unwrap(), "\"end\""); + assert_eq!(serde_json::to_string(&FontWeight::Bold).unwrap(), "\"bold\""); + } + + #[test] + fn invalid_style_color_fails_to_deserialize() { + let raw = json!({ "type": "label", "text": "x", "style": { "color": "bg" } }); + assert!(serde_json::from_value::(raw).is_err()); + } + + #[test] + fn style_with_all_fields_none_is_equivalent_to_default() { + assert_eq!( + serde_json::to_value(WidgetStyle::default()).unwrap(), + json!({ + "color": null, "weight": null, "size": null, + "align": null, "background": null, "radius": null, "padding": null + }) + ); + } + + #[test] + fn style_does_not_affect_validation() { + let node = WidgetNode::Label { + text: "x".to_string(), + class: None, + style: Some(WidgetStyle { + color: Some(SemanticColor::Accent), + ..Default::default() + }), + on_click: None, + }; + assert!(node.validate().is_ok()); + } +} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 5301a9d..a88fd70 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -38,6 +38,7 @@ pub enum StateCommand { }, ClearSubscriptions, ClearModules, + ClearWidgets, SetModuleStatus { name: String, status: ModuleLoadState, @@ -117,6 +118,10 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::ClearModules); } + pub fn clear_widgets(&self) { + let _ = self.command_tx.send(StateCommand::ClearWidgets); + } + pub fn set_module_status( &self, name: String, @@ -290,6 +295,9 @@ async fn handle_command( StateCommand::ClearModules => { state.write().await.modules.clear(); } + StateCommand::ClearWidgets => { + state.write().await.widgets.clear(); + } StateCommand::SetModuleStatus { name, status, diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index a521cf6..93db94d 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, HashMap}; +use bread_shared::widget::WidgetSpec; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -15,6 +16,10 @@ pub struct RuntimeState { pub profile: ProfileState, pub modules: Vec, pub workflows: Vec, + /// Widgets registered via `bread.widget.register` from any Lua module, + /// keyed implicitly by `WidgetSpec.id` (fully-qualified `.`). + /// Surfaced via the `widgets.list` IPC method for breadbar to render. + pub widgets: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index df0afa4..e132770 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -27,7 +27,7 @@ use crate::lua::RuntimeHandle; /// something new-but-additive (a binding, an event, an IPC param); bump the /// major version only for a breaking change, which should not happen inside /// this daemon's v1 lifetime per that section's stated policy. -const API_VERSION: &str = "1.2.0"; +const API_VERSION: &str = "1.4.0"; #[derive(Clone)] pub struct Server { @@ -222,6 +222,10 @@ impl Server { let full = self.state_handle.state_dump().await; Ok(full.get("workflows").cloned().unwrap_or_else(|| json!([]))) } + "widgets.list" => { + let full = self.state_handle.state_dump().await; + Ok(full.get("widgets").cloned().unwrap_or_else(|| json!([]))) + } "modules.reload" => { let started = Instant::now(); if let Err(err) = self.lua_runtime.reload().await { diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 43ed598..0f8a049 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -8,9 +8,10 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{anyhow, Result}; +use bread_shared::widget::{WidgetNode, WidgetPlacement, WidgetSpec}; use bread_shared::{AdapterSource, BreadEvent}; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use tokio::sync::{mpsc, oneshot, watch, RwLock}; use tokio::task; @@ -181,6 +182,11 @@ struct TimerEntry { callback: RegistryKey, repeating: bool, cancel_tx: watch::Sender, + /// The module active when `bread.after`/`bread.every` registered this + /// timer, so `handle_timer` can restore module context for the callback + /// (needed by module-scoped APIs like `bread.widget.*`) — same + /// `current_module` capture used for `bread.on`'s `HandlerEntry.module`. + module: Option, } #[derive(Clone)] @@ -253,6 +259,7 @@ impl LuaEngine { self.cancel_all_timers(); self.state_handle.clear_subscriptions(); self.state_handle.clear_modules(); + self.state_handle.clear_widgets(); self.lua = Lua::new(); self.handlers .lock() @@ -280,6 +287,21 @@ impl LuaEngine { self.load_profiles()?; self.load_init_and_modules()?; self.run_on_reload(); + + // clear_widgets() above is silent (no event) since it's just a state + // wipe ahead of modules re-registering. That's a problem when a + // module goes from "registered some widgets" to "disabled and + // skipped" across this reload: nothing re-registers, so no + // bread.widget.registered fires, and a renderer that only refetches + // on bread.widget.* events (see breadbar's widgets::client) never + // learns the registry emptied out. One definitive signal per reload, + // regardless of whether anything actually changed, closes that gap. + let _ = self.emit_tx.send(BreadEvent::new( + "bread.widget.cleared", + AdapterSource::System, + serde_json::json!({}), + )); + info!("lua runtime reloaded"); Ok(()) } @@ -618,12 +640,17 @@ impl LuaEngine { let timers = self.timers.clone(); let next_timer_id = self.next_timer_id.clone(); let lua_tx = self.lua_tx.clone(); + let current_module = self.current_module.clone(); let after_fn = self.lua .create_function(move |lua, (delay_ms, callback): (u64, Function)| { let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed)); let key = lua.create_registry_value(callback)?; let (cancel_tx, mut cancel_rx) = watch::channel(false); + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); timers .lock() .map_err(|_| LuaError::external("timer lock poisoned"))? @@ -633,6 +660,7 @@ impl LuaEngine { callback: key, repeating: false, cancel_tx, + module, }, ); let lua_tx = lua_tx.clone(); @@ -653,12 +681,17 @@ impl LuaEngine { let timers = self.timers.clone(); let next_timer_id = self.next_timer_id.clone(); let lua_tx = self.lua_tx.clone(); + let current_module = self.current_module.clone(); let every_fn = self.lua .create_function(move |lua, (interval_ms, callback): (u64, Function)| { let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed)); let key = lua.create_registry_value(callback)?; let (cancel_tx, mut cancel_rx) = watch::channel(false); + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); timers .lock() .map_err(|_| LuaError::external("timer lock poisoned"))? @@ -668,6 +701,7 @@ impl LuaEngine { callback: key, repeating: true, cancel_tx, + module, }, ); let lua_tx = lua_tx.clone(); @@ -737,7 +771,7 @@ impl LuaEngine { .map_err(|e| LuaError::external(e.to_string()))?; let json: JsonValue = serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; - lua.to_value(&json) + json_to_lua(lua, &json) .map_err(|e| LuaError::external(e.to_string())) })?; hyprland_tbl.set("active_window", active_window_fn)?; @@ -747,7 +781,7 @@ impl LuaEngine { hyprland_request("j/monitors").map_err(|e| LuaError::external(e.to_string()))?; let json: JsonValue = serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; - lua.to_value(&json) + json_to_lua(lua, &json) .map_err(|e| LuaError::external(e.to_string())) })?; hyprland_tbl.set("monitors", monitors_fn)?; @@ -757,7 +791,7 @@ impl LuaEngine { hyprland_request("j/workspaces").map_err(|e| LuaError::external(e.to_string()))?; let json: JsonValue = serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; - lua.to_value(&json) + json_to_lua(lua, &json) .map_err(|e| LuaError::external(e.to_string())) })?; hyprland_tbl.set("workspaces", workspaces_fn)?; @@ -767,7 +801,7 @@ impl LuaEngine { hyprland_request("j/clients").map_err(|e| LuaError::external(e.to_string()))?; let json: JsonValue = serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; - lua.to_value(&json) + json_to_lua(lua, &json) .map_err(|e| LuaError::external(e.to_string())) })?; hyprland_tbl.set("clients", clients_fn)?; @@ -840,9 +874,7 @@ impl LuaEngine { let state_arc_get = state_arc.clone(); let get_fn = lua.create_function(move |lua, key: String| { if let Some(value) = module_store_get(&state_arc_get, &module_name, &key) { - return lua - .to_value(&value) - .map_err(|e| LuaError::external(e.to_string())); + return json_to_lua(lua, &value).map_err(|e| LuaError::external(e.to_string())); } Ok(Value::Nil) })?; @@ -875,6 +907,122 @@ impl LuaEngine { })?; bread.set("module", module_fn)?; + // bread.widget — declarative, live-updating widgets rendered by + // sibling bread* apps (breadbar) in their bar/popover free space. + // Follows the same Rust-backed-registry pattern as bread.workflow + // (see install_workflow_helpers / workflow_register below): + // mutate RuntimeState.widgets via the try_write spin-lock, then + // emit a bread.widget.* event so subscribers (and, indirectly, + // breadbar's events.subscribe stream) observe the change. + let widget_tbl = self.lua.create_table()?; + + let state_arc = self.state_handle.state_arc(); + let current_module = self.current_module.clone(); + let emit_tx = self.emit_tx.clone(); + let widget_register_fn = self.lua.create_function( + move |lua, spec_table: Table| -> mlua::Result<(bool, Option)> { + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone() + .ok_or_else(|| { + LuaError::external("bread.widget.register must be called from within a module") + })?; + let args: WidgetRegisterArgs = match lua.from_value(Value::Table(spec_table)) { + Ok(a) => a, + Err(e) => return Ok((false, Some(e.to_string()))), + }; + Ok(match widget_register(&state_arc, &module, args) { + Ok(spec) => { + let data = serde_json::to_value(&spec).unwrap_or_default(); + let _ = emit_tx.send(BreadEvent::new( + "bread.widget.registered", + AdapterSource::System, + data, + )); + (true, None) + } + Err(e) => (false, Some(e.to_string())), + }) + }, + )?; + widget_tbl.set("register", widget_register_fn)?; + + let state_arc = self.state_handle.state_arc(); + let current_module = self.current_module.clone(); + let emit_tx = self.emit_tx.clone(); + let widget_update_fn = self.lua.create_function( + move |lua, (local_id, patch): (String, Table)| -> mlua::Result<(bool, Option)> { + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone() + .ok_or_else(|| { + LuaError::external("bread.widget.update must be called from within a module") + })?; + let args: WidgetUpdateArgs = match lua.from_value(Value::Table(patch)) { + Ok(a) => a, + Err(e) => return Ok((false, Some(e.to_string()))), + }; + Ok(match widget_update(&state_arc, &module, &local_id, args) { + Ok(Some(spec)) => { + let data = serde_json::to_value(&spec).unwrap_or_default(); + let _ = emit_tx.send(BreadEvent::new( + "bread.widget.updated", + AdapterSource::System, + data, + )); + (true, None) + } + Ok(None) => (false, Some("no such widget".to_string())), + Err(e) => (false, Some(e.to_string())), + }) + }, + )?; + widget_tbl.set("update", widget_update_fn)?; + + let state_arc = self.state_handle.state_arc(); + let current_module = self.current_module.clone(); + let emit_tx = self.emit_tx.clone(); + let widget_remove_fn = self.lua.create_function(move |_lua, local_id: String| { + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone() + .ok_or_else(|| { + LuaError::external("bread.widget.remove must be called from within a module") + })?; + let full_id = format!("{module}.{local_id}"); + let removed = widget_remove(&state_arc, &module, &local_id); + if removed { + let _ = emit_tx.send(BreadEvent::new( + "bread.widget.removed", + AdapterSource::System, + serde_json::json!({ "id": full_id }), + )); + } + Ok(removed) + })?; + widget_tbl.set("remove", widget_remove_fn)?; + + let state_arc = self.state_handle.state_arc(); + let current_module = self.current_module.clone(); + let widget_list_fn = self.lua.create_function(move |lua, ()| { + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone() + .ok_or_else(|| { + LuaError::external("bread.widget.list must be called from within a module") + })?; + let json = widget_list_json(&state_arc, &module); + json_to_lua(lua, &json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + widget_tbl.set("list", widget_list_fn)?; + + bread.set("widget", widget_tbl)?; + // bread.machine — hostname/tags; reads an optional, externally-managed // ~/.config/bread/sync.toml if present (bread does not create it) let machine_tbl = self.lua.create_table()?; @@ -1122,10 +1270,22 @@ impl LuaEngine { .into_iter() .filter(|p| !is_lib_path(&self.module_path, p)) { + let name = module_name_from_path(&self.module_path, &path); + // bos-settings' module picker writes filenames (e.g. + // "widget.lua") into `disable`, not the bare module name a file + // registers under via bread.module({name=...}) — match either + // form so both conventions work rather than requiring the UI + // and the daemon to agree on one exact string. + let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or(""); + if disabled.contains(&name) || disabled.contains(filename) { + self.state_handle + .set_module_status(name, ModuleLoadState::Disabled, None, false); + continue; + } + match self.scan_module_decl(&path) { Ok(decl) => decls.push(decl), Err(err) => { - let name = module_name_from_path(&self.module_path, &path); self.state_handle.set_module_status( name, ModuleLoadState::LoadError, @@ -1192,8 +1352,7 @@ impl LuaEngine { return Err(anyhow!("module did not call bread.module")); } - self.run_on_load(&decl.name); - Ok(()) + self.run_on_load(&decl.name) } fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> { @@ -1257,26 +1416,28 @@ impl LuaEngine { } if let Some(filter) = filter { - let event_value = self.lua.to_value(&event)?; + let event_value = json_to_lua(&self.lua, &event)?; let allowed = filter.call::<_, bool>(event_value).unwrap_or(false); if !allowed { return Ok(()); } } + self.set_current_module(module.clone()); let result = match kind { HandlerKind::Event => { - let event_value = self.lua.to_value(&event)?; + let event_value = json_to_lua(&self.lua, &event)?; callback.call::<_, ()>(event_value) } HandlerKind::StateWatch => { let new_val = event.data.get("new").cloned().unwrap_or(JsonValue::Null); let old_val = event.data.get("old").cloned().unwrap_or(JsonValue::Null); - let new_lua = self.lua.to_value(&new_val)?; - let old_lua = self.lua.to_value(&old_val)?; + let new_lua = json_to_lua(&self.lua, &new_val)?; + let old_lua = json_to_lua(&self.lua, &old_val)?; callback.call::<_, ()>((new_lua, old_lua)) } }; + self.set_current_module(None); if let Err(err) = result { error!(subscription = id.0, error = %err, "lua callback failed"); @@ -1286,15 +1447,18 @@ impl LuaEngine { } fn handle_timer(&self, id: TimerId) -> Result<()> { - let (callback, repeating) = { + let (callback, repeating, module) = { let timers = self.timers.lock().unwrap_or_else(|e| e.into_inner()); let Some(entry) = timers.get(&id) else { return Ok(()); }; let callback: Function = self.lua.registry_value(&entry.callback)?; - (callback, entry.repeating) + (callback, entry.repeating, entry.module.clone()) }; - if let Err(err) = callback.call::<_, ()>(()) { + self.set_current_module(module); + let result = callback.call::<_, ()>(()); + self.set_current_module(None); + if let Err(err) = result { error!(timer = id.0, error = %err, "lua timer callback failed"); } @@ -1312,19 +1476,24 @@ impl LuaEngine { } } - fn run_on_load(&self, name: &str) { + fn run_on_load(&self, name: &str) -> Result<()> { if let Some(hook) = self.get_module_hook(name, "on_load") { - if let Err(err) = hook.call::<_, ()>(()) { + self.set_current_module(Some(name.to_string())); + let result = hook.call::<_, ()>(()); + self.set_current_module(None); + if let Err(err) = result { error!(module = %name, error = %err, "module on_load failed"); - let builtin = self.module_is_builtin(name); - self.state_handle.set_module_status( - name.to_string(), - ModuleLoadState::LoadError, - Some(err.to_string()), - builtin, - ); + // Propagate rather than setting LoadError here directly: the + // caller (load_module, via load_init_and_modules) is the + // single place that decides Loaded vs LoadError for a + // module, so this failure isn't immediately clobbered back + // to Loaded by that outer Ok(()) branch — which is exactly + // what used to happen when this function swallowed the + // error and always returned successfully. + return Err(anyhow!(err.to_string())); } } + Ok(()) } fn run_on_reload(&self) { @@ -1335,7 +1504,10 @@ impl LuaEngine { .clone(); for name in order { if let Some(hook) = self.get_module_hook(&name, "on_reload") { - if let Err(err) = hook.call::<_, ()>(()) { + self.set_current_module(Some(name.clone())); + let result = hook.call::<_, ()>(()); + self.set_current_module(None); + if let Err(err) = result { error!(module = %name, error = %err, "module on_reload failed"); let builtin = self.module_is_builtin(&name); self.state_handle.set_module_status( @@ -1357,7 +1529,10 @@ impl LuaEngine { .clone(); for name in order.into_iter().rev() { if let Some(hook) = self.get_module_hook(&name, "on_unload") { - if let Err(err) = hook.call::<_, ()>(()) { + self.set_current_module(Some(name.clone())); + let result = hook.call::<_, ()>(()); + self.set_current_module(None); + if let Err(err) = result { error!(module = %name, error = %err, "module on_unload failed"); let builtin = self.module_is_builtin(&name); self.state_handle.set_module_status( @@ -1741,9 +1916,7 @@ impl LuaEngine { let state_arc = self.state_handle.state_arc(); let status_fn = self.lua.create_function(move |lua, name: String| { match workflow_status_json(&state_arc, &name) { - Some(json) => lua - .to_value(&json) - .map_err(|e| LuaError::external(e.to_string())), + Some(json) => json_to_lua(lua, &json).map_err(|e| LuaError::external(e.to_string())), None => Ok(Value::Nil), } })?; @@ -1752,7 +1925,7 @@ impl LuaEngine { let state_arc = self.state_handle.state_arc(); let list_fn = self.lua.create_function(move |lua, ()| { let json = workflow_list_json(&state_arc); - lua.to_value(&json) + json_to_lua(lua, &json) .map_err(|e| LuaError::external(e.to_string())) })?; bread.set("__workflow_list", list_fn)?; @@ -2037,6 +2210,133 @@ fn workflow_list_json(state_arc: &Arc>) -> JsonValue { serde_json::to_value(&guard.workflows).unwrap_or_else(|_| JsonValue::Array(vec![])) } +/// Table shape accepted by `bread.widget.register`. Parsed directly from the +/// Lua table via mlua's serde bridge, so `root`'s nested `children` tables +/// deserialize straight into a `WidgetNode` tree without a manual walker. +#[derive(Debug, Deserialize)] +struct WidgetRegisterArgs { + id: String, + placement: WidgetPlacement, + #[serde(default)] + order: i32, + #[serde(default = "default_widget_visible")] + visible: bool, + #[serde(default)] + tooltip: Option, + root: WidgetNode, +} + +/// Table shape accepted by `bread.widget.update` — every field optional, so +/// a caller can patch just what changed (typically just `root` on a timer). +#[derive(Debug, Default, Deserialize)] +struct WidgetUpdateArgs { + #[serde(default)] + root: Option, + #[serde(default)] + tooltip: Option, + #[serde(default)] + visible: Option, + #[serde(default)] + order: Option, +} + +fn default_widget_visible() -> bool { + true +} + +fn widget_register( + state_arc: &Arc>, + module: &str, + args: WidgetRegisterArgs, +) -> std::result::Result { + args.root.validate()?; + let spec = WidgetSpec { + id: format!("{module}.{}", args.id), + module: module.to_string(), + placement: args.placement, + order: args.order, + visible: args.visible, + tooltip: args.tooltip, + root: args.root, + updated_at: now_unix_ms(), + }; + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + if let Some(existing) = guard.widgets.iter_mut().find(|w| w.id == spec.id) { + *existing = spec.clone(); + } else { + guard.widgets.push(spec.clone()); + } + Ok(spec) +} + +fn widget_update( + state_arc: &Arc>, + module: &str, + local_id: &str, + args: WidgetUpdateArgs, +) -> std::result::Result, bread_shared::widget::WidgetValidationError> { + if let Some(root) = &args.root { + root.validate()?; + } + let full_id = format!("{module}.{local_id}"); + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + let Some(entry) = guard.widgets.iter_mut().find(|w| w.id == full_id) else { + return Ok(None); + }; + if let Some(root) = args.root { + entry.root = root; + } + if let Some(tooltip) = args.tooltip { + entry.tooltip = Some(tooltip); + } + if let Some(visible) = args.visible { + entry.visible = visible; + } + if let Some(order) = args.order { + entry.order = order; + } + entry.updated_at = now_unix_ms(); + Ok(Some(entry.clone())) +} + +fn widget_remove(state_arc: &Arc>, module: &str, local_id: &str) -> bool { + let full_id = format!("{module}.{local_id}"); + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + let before = guard.widgets.len(); + guard.widgets.retain(|w| w.id != full_id); + before != guard.widgets.len() +} + +fn widget_list_json(state_arc: &Arc>, module: &str) -> JsonValue { + let guard = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + std::thread::yield_now(); + }; + let mine: Vec<&WidgetSpec> = guard.widgets.iter().filter(|w| w.module == module).collect(); + serde_json::to_value(&mine).unwrap_or_else(|_| JsonValue::Array(vec![])) +} + fn order_module_decls(decls: Vec) -> (Vec, Vec<(String, String)>) { let mut errors = Vec::new(); let mut map: HashMap = HashMap::new(); @@ -2129,6 +2429,27 @@ fn is_lib_path(module_root: &Path, path: &Path) -> bool { .unwrap_or(false) } +/// `lua.to_value()`'s default `Options` map JSON null / Rust `Option::None` +/// to a distinct `lua.null()` sentinel rather than real Lua `nil`, to +/// preserve JSON round-trip fidelity — but bread never round-trips a Lua +/// value back into JSON through mlua, so that distinction buys nothing here +/// and only sets a trap for the ordinary Lua idiom `if not value then ...`, +/// which silently doesn't catch the sentinel (found via a module crashing +/// on `#value` when `active_window` was null: `not ` is `false`, +/// same as any other non-nil value). Every JSON/state value handed to Lua +/// goes through this instead of a bare `to_value` call. +fn json_to_lua<'lua, T>(lua: &'lua Lua, value: &T) -> mlua::Result> +where + T: Serialize + ?Sized, +{ + lua.to_value_with( + value, + mlua::SerializeOptions::new() + .serialize_none_to_null(false) + .serialize_unit_to_null(false), + ) +} + fn state_value_to_lua<'lua>( lua: &'lua Lua, state_arc: &Arc>, @@ -2147,9 +2468,7 @@ fn state_value_to_lua<'lua>( let mut value = serde_json::to_value(&*snapshot).map_err(|e| LuaError::external(e.to_string()))?; if path.is_empty() { - return lua - .to_value(&value) - .map_err(|e| LuaError::external(e.to_string())); + return json_to_lua(lua, &value).map_err(|e| LuaError::external(e.to_string())); } for part in path.split('.') { value = value @@ -2157,8 +2476,7 @@ fn state_value_to_lua<'lua>( .cloned() .ok_or_else(|| LuaError::external("state path not found"))?; } - lua.to_value(&value) - .map_err(|e| LuaError::external(e.to_string())) + json_to_lua(lua, &value).map_err(|e| LuaError::external(e.to_string())) } fn module_store_get( diff --git a/examples/modules/README.md b/examples/modules/README.md index e9636ec..05eaa67 100644 --- a/examples/modules/README.md +++ b/examples/modules/README.md @@ -21,6 +21,11 @@ bread reload | `low-battery-warning.lua` | Critical notification once when the battery runs low; resets on AC. | none | | `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) | | `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions | +| `active-window-widget.lua` | Shows the focused window next to the workspace pills in breadbar, via `bread.widget` + `bread.state.watch`. | none | +| `cpu-temp-widget.lua` | Live CPU temperature readout in breadbar's stats area, via `bread.widget` + `bread.fs.read` on a timer. | edit `TEMP_PATH` for your hwmon layout | +| `bluetooth-toggle-widget.lua` | One-click Bluetooth power toggle in breadbar's tray, via `bread.widget` + a click handler. | none | +| `focus-mode-widget.lua` | Click-to-toggle "Focus" profile that mutes audio; a widget as an action launcher, not just a readout, and stays in sync with profile changes triggered elsewhere. | none (needs `wpctl`) | +| `workflow-status-widget.lua` | Surfaces `bread.workflow.list()` in breadbar's tray — shows whichever workflow (e.g. `dock-workflow.lua`, below) is currently running or failed, hidden otherwise. | none | Each module is the standard skeleton — `bread.module{...}`, an `on_load` that registers subscriptions, `return M` — so they double as references for writing diff --git a/examples/modules/active-window-widget.lua b/examples/modules/active-window-widget.lua new file mode 100644 index 0000000..4ed340d --- /dev/null +++ b/examples/modules/active-window-widget.lua @@ -0,0 +1,46 @@ +-- active-window-widget — shows the focused app right next to the +-- workspace pills, live-updated via bread.state.watch (no polling, no +-- bread.on needed). +-- +-- Demonstrates: WidgetPlacement "right_of_workspaces", a state-watch-driven +-- widget (as opposed to a timer or event handler), and calling +-- bread.widget.update from inside the watch callback. breadbar's own bar +-- doesn't show the focused window anywhere today — this adds that for free. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Zero configuration. + +local M = bread.module({ name = "active-window-widget", version = "1.0.0" }) + +local function label_for(window) + -- A JSON null (no window focused) doesn't necessarily arrive as Lua nil + -- through bread.state.* — mlua's serde bridge can hand back a distinct + -- null sentinel instead, which `not window` won't catch. Guard on the + -- type directly so any non-string value (nil, the sentinel, ...) falls + -- through to the placeholder rather than crashing on #window below. + if type(window) ~= "string" or window == "" then + return "—" + end + if #window > 24 then + return window:sub(1, 24) .. "…" + end + return window +end + +local function widget_root(window) + return { type = "label", text = label_for(window), style = { color = "dim" } } +end + +function M.on_load() + bread.widget.register({ + id = "active-window", + placement = "right_of_workspaces", + tooltip = "Currently focused window", + root = widget_root(bread.state.active_window()), + }) + + bread.state.watch("active_window", function(new_val) + bread.widget.update("active-window", { root = widget_root(new_val) }) + end) +end + +return M diff --git a/examples/modules/bluetooth-toggle-widget.lua b/examples/modules/bluetooth-toggle-widget.lua new file mode 100644 index 0000000..934a54b --- /dev/null +++ b/examples/modules/bluetooth-toggle-widget.lua @@ -0,0 +1,66 @@ +-- bluetooth-toggle-widget — a real one-click Bluetooth power toggle in the +-- hamburger popover's tray section, showing power state and connected +-- device count. breadbar's native bar only shows a passive BT icon; this +-- adds an actual control surface for it. +-- +-- Demonstrates: WidgetPlacement "tray", a bread.every-polled read of +-- bread.bluetooth.devices()/.powered(), and bread.bar.widget_clicked +-- driving a real action (bread.bluetooth.power) rather than just display. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Zero configuration. + +local M = bread.module({ name = "bluetooth-toggle-widget", version = "1.0.0" }) + +local function widget_root() + local powered = bread.bluetooth.powered() + local devices = bread.bluetooth.devices() or {} + local connected = 0 + for _, d in ipairs(devices) do + if d.connected then + connected = connected + 1 + end + end + + local text, style + if powered == nil then + text, style = "BT n/a", { color = "dim" } + elseif not powered then + text, style = "BT off", { color = "dim" } + elseif connected > 0 then + text, style = "BT (" .. connected .. ")", { color = "accent", weight = "bold" } + else + text, style = "BT on", { color = "fg" } + end + + return { + type = "label", + text = text, + style = style, + on_click = "toggle", + } +end + +function M.on_load() + bread.widget.register({ + id = "toggle", + placement = "tray", + tooltip = "Click to toggle Bluetooth power", + root = widget_root(), + }) + + bread.every(5000, function() + bread.widget.update("toggle", { root = widget_root() }) + end) + + bread.on("bread.bar.widget_clicked", function(e) + if e.data.widget_id == "bluetooth-toggle-widget.toggle" and e.data.action == "toggle" then + bread.bluetooth.power(not bread.bluetooth.powered()) + -- Give BlueZ a moment to apply before refreshing the label. + bread.after(500, function() + bread.widget.update("toggle", { root = widget_root() }) + end) + end + end) +end + +return M diff --git a/examples/modules/cpu-temp-widget.lua b/examples/modules/cpu-temp-widget.lua new file mode 100644 index 0000000..5399483 --- /dev/null +++ b/examples/modules/cpu-temp-widget.lua @@ -0,0 +1,64 @@ +-- cpu-temp-widget — live CPU package temperature, read straight from the +-- k10temp hwmon sysfs node via bread.fs.read. +-- +-- Demonstrates: WidgetPlacement "left_of_stats", a bread.every-polled +-- widget reading real hardware state (the same category of readout +-- breadbar's native CPU%/RAM stats already do in Rust — this shows it's +-- just as easy from a drop-in Lua module), and the typed `style` vocabulary +-- (color + weight) swapping based on a threshold so the widget visually +-- flags when something's hot — no CSS, no guessing which class names the +-- rendering app happens to define. +-- +-- Drop-in: copy into ~/.config/bread/modules/. TEMP_PATH is specific to +-- this machine (AMD, k10temp) — find yours with: +-- grep -l k10temp /sys/class/hwmon/hwmon*/name +-- and adjust below; a missing/unreadable path just shows "—" rather than +-- erroring, since bread.fs.read returns nil (not an error) for that case. + +local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" }) + +local TEMP_PATH = "/sys/class/hwmon/hwmon6/temp1_input" +local HOT_THRESHOLD_C = 80 + +local function read_temp_c() + local raw = bread.fs.read(TEMP_PATH) + if not raw then + return nil + end + return tonumber(raw) / 1000 +end + +local function widget_root(temp_c) + local text = temp_c and string.format("%.0f°C", temp_c) or "—" + local hot = temp_c ~= nil and temp_c >= HOT_THRESHOLD_C + return { + type = "box", + children = { + { + type = "label", + text = text, + style = hot and { color = "red", weight = "bold" } or { color = "dim" }, + }, + { + type = "progress", + value = temp_c and math.min(temp_c / 100, 1.0) or 0, + style = hot and { color = "red" } or nil, + }, + }, + } +end + +function M.on_load() + bread.widget.register({ + id = "cpu-temp", + placement = "left_of_stats", + tooltip = "CPU package temperature (Tctl)", + root = widget_root(read_temp_c()), + }) + + bread.every(5000, function() + bread.widget.update("cpu-temp", { root = widget_root(read_temp_c()) }) + end) +end + +return M diff --git a/examples/modules/focus-mode-widget.lua b/examples/modules/focus-mode-widget.lua new file mode 100644 index 0000000..d2631b7 --- /dev/null +++ b/examples/modules/focus-mode-widget.lua @@ -0,0 +1,66 @@ +-- focus-mode-widget — a widget that does something, not just shows +-- something: click to toggle a "focus" bread profile, which mutes audio +-- output as an observable effect. Stays in sync if the profile changes +-- from elsewhere too — the CLI (`bread profile-activate default`), another +-- module, another widget — not just from its own click, by reacting to +-- bread.profile.activated rather than tracking its own local state. +-- +-- Demonstrates: a widget as an action launcher wired to bread's actual +-- profile primitive (bread.profile.activate), not just a passive readout; +-- staying in sync with state that can change from other sources; combining +-- bread.exec with a profile switch for a real, checkable effect. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Needs `wpctl` (pipewire — +-- already a dependency of breadbar's own volume slider, so if the bar's +-- volume control works, this will too). + +local M = bread.module({ name = "focus-mode-widget", version = "1.0.0" }) + +local FOCUS_PROFILE = "focus" +local DEFAULT_PROFILE = "default" + +local function is_focused() + return bread.state.profile().active == FOCUS_PROFILE +end + +local function widget_root() + return { + type = "label", + text = "Focus", + style = is_focused() and { color = "accent", weight = "bold" } or { color = "dim" }, + on_click = "toggle", + } +end + +function M.on_load() + bread.widget.register({ + id = "toggle", + placement = "left_of_clock", + tooltip = "Click to toggle Focus mode (mutes audio, activates the 'focus' profile)", + root = widget_root(), + }) + + -- Not just self.click -> self.update: any profile change, from any + -- source, is reflected here. Try `bread profile-activate default` from + -- a terminal while this is in "focus" state to see it flip on its own. + bread.on("bread.profile.activated", function() + bread.widget.update("toggle", { root = widget_root() }) + end) + + bread.on("bread.bar.widget_clicked", function(e) + if e.data.widget_id ~= "focus-mode-widget.toggle" or e.data.action ~= "toggle" then + return + end + if is_focused() then + bread.profile.activate(DEFAULT_PROFILE) + bread.exec("wpctl set-mute @DEFAULT_AUDIO_SINK@ 0") + bread.notify("Focus mode off", { title = "bread" }) + else + bread.profile.activate(FOCUS_PROFILE) + bread.exec("wpctl set-mute @DEFAULT_AUDIO_SINK@ 1") + bread.notify("Focus mode on — audio muted", { title = "bread" }) + end + end) +end + +return M diff --git a/examples/modules/workflow-status-widget.lua b/examples/modules/workflow-status-widget.lua new file mode 100644 index 0000000..c9f4840 --- /dev/null +++ b/examples/modules/workflow-status-widget.lua @@ -0,0 +1,83 @@ +-- workflow-status-widget — surfaces bread's workflow engine (bread.workflow, +-- see Examples.md's "Example 4" and dock-workflow.lua in this directory) in +-- the bar, which had zero visibility anywhere in the UI before this. Shows +-- whichever non-done workflow was most recently updated, with its current +-- step if it's set one via bread.workflow.step(); hides entirely when +-- nothing is running/failed/timed_out, so it stays out of the way until +-- there's actually something to look at. +-- +-- Demonstrates: WidgetPlacement "tray", polling an existing bread subsystem +-- (bread.workflow.list()) instead of raw hardware or a single module's own +-- state, and a widget that disappears (visible = false) rather than +-- showing a stale or empty readout. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Zero configuration — it +-- reflects whatever workflow any other loaded module starts, including +-- dock-workflow.lua in this same directory. + +local M = bread.module({ name = "workflow-status-widget", version = "1.0.0" }) + +local function most_relevant() + local workflows = bread.workflow.list() + local best = nil + for _, w in ipairs(workflows) do + if w.state ~= "done" and (not best or w.updated_at > best.updated_at) then + best = w + end + end + return best +end + +local function widget_update() + local w = most_relevant() + if not w then + -- bread.widget.update leaves an omitted field unchanged, not + -- cleared — a Lua table can't distinguish "tooltip = nil" from + -- "no tooltip key at all", so an explicit "" is what actually wipes + -- the previous tooltip instead of leaving it stale under a hidden + -- widget (harmless in practice since GTK won't show a tooltip on + -- an invisible widget, but `bread state widgets` would otherwise + -- report it forever). + return { visible = false, tooltip = "", root = { type = "label", text = "" } } + end + + local color = "dim" + if w.state == "failed" or w.state == "timed_out" then + color = "red" + elseif w.state == "running" then + color = "accent" + end + + local text = w.name + if w.step then + text = text .. ": " .. w.step + end + + return { + visible = true, + tooltip = "Workflow " .. w.name .. " — " .. w.state, + root = { type = "label", text = text, style = { color = color } }, + } +end + +function M.on_load() + local u = widget_update() + bread.widget.register({ + id = "status", + placement = "tray", + visible = u.visible, + tooltip = u.tooltip, + root = u.root, + }) + + bread.every(3000, function() + local next_u = widget_update() + bread.widget.update("status", { + visible = next_u.visible, + tooltip = next_u.tooltip, + root = next_u.root, + }) + end) +end + +return M