Will change this commit message to mean something later
Some checks failed
dev release / build (push) Failing after 0s
Some checks failed
dev release / build (push) Failing after 0s
This commit is contained in:
parent
f3905d8114
commit
8d3f55b607
14 changed files with 1491 additions and 40 deletions
135
Documentation.md
135
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 `"<module>.<id>"`. |
|
||||
| `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.<app_id>.*` 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.<path>` | 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.<app>.*`** — 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)).
|
||||
|
|
|
|||
72
Examples.md
72
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)`
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
pub mod apps;
|
||||
pub mod glob;
|
||||
pub mod widget;
|
||||
|
||||
/// Identifies which adapter produced an event.
|
||||
///
|
||||
|
|
|
|||
580
bread-shared/src/widget.rs
Normal file
580
bread-shared/src/widget.rs
Normal file
|
|
@ -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<i32>,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
#[serde(default)]
|
||||
children: Vec<WidgetNode>,
|
||||
},
|
||||
Label {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
},
|
||||
Icon {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<i32>,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
},
|
||||
Progress {
|
||||
value: f64,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
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<SemanticColor>,
|
||||
pub weight: Option<FontWeight>,
|
||||
pub size: Option<TextSize>,
|
||||
pub align: Option<Align>,
|
||||
pub background: Option<Background>,
|
||||
pub radius: Option<Radius>,
|
||||
pub padding: Option<Padding>,
|
||||
}
|
||||
|
||||
/// 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: `"<module>.<local_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<String>,
|
||||
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 {
|
||||
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<WidgetNode> = (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<WidgetNode> = (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::<WidgetNode>(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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ModuleStatus>,
|
||||
pub workflows: Vec<WorkflowStatus>,
|
||||
/// Widgets registered via `bread.widget.register` from any Lua module,
|
||||
/// keyed implicitly by `WidgetSpec.id` (fully-qualified `<module>.<local_id>`).
|
||||
/// Surfaced via the `widgets.list` IPC method for breadbar to render.
|
||||
pub widgets: Vec<WidgetSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<String>)> {
|
||||
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<String>)> {
|
||||
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<RwLock<RuntimeState>>) -> 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<String>,
|
||||
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<WidgetNode>,
|
||||
#[serde(default)]
|
||||
tooltip: Option<String>,
|
||||
#[serde(default)]
|
||||
visible: Option<bool>,
|
||||
#[serde(default)]
|
||||
order: Option<i32>,
|
||||
}
|
||||
|
||||
fn default_widget_visible() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn widget_register(
|
||||
state_arc: &Arc<RwLock<RuntimeState>>,
|
||||
module: &str,
|
||||
args: WidgetRegisterArgs,
|
||||
) -> std::result::Result<WidgetSpec, bread_shared::widget::WidgetValidationError> {
|
||||
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<RwLock<RuntimeState>>,
|
||||
module: &str,
|
||||
local_id: &str,
|
||||
args: WidgetUpdateArgs,
|
||||
) -> std::result::Result<Option<WidgetSpec>, 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<RwLock<RuntimeState>>, 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<RwLock<RuntimeState>>, 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<ModuleDecl>) -> (Vec<ModuleDecl>, Vec<(String, String)>) {
|
||||
let mut errors = Vec::new();
|
||||
let mut map: HashMap<String, ModuleDecl> = 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 <sentinel>` 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<Value<'lua>>
|
||||
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<RwLock<RuntimeState>>,
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
examples/modules/active-window-widget.lua
Normal file
46
examples/modules/active-window-widget.lua
Normal file
|
|
@ -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
|
||||
66
examples/modules/bluetooth-toggle-widget.lua
Normal file
66
examples/modules/bluetooth-toggle-widget.lua
Normal file
|
|
@ -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
|
||||
64
examples/modules/cpu-temp-widget.lua
Normal file
64
examples/modules/cpu-temp-widget.lua
Normal file
|
|
@ -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
|
||||
66
examples/modules/focus-mode-widget.lua
Normal file
66
examples/modules/focus-mode-widget.lua
Normal file
|
|
@ -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
|
||||
83
examples/modules/workflow-status-widget.lua
Normal file
83
examples/modules/workflow-status-widget.lua
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue