From f05d6ba602793fa64a9003799fa17f9da2e47532 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 21:54:43 +0800 Subject: [PATCH] Update README and add documentation and examples for Bread automation --- Documentation.md | 496 +++++++++++++++++++++++++++++++++++++++++++++++ Examples.md | 187 ++++++++++++++++++ README.md | 84 +++++++- 3 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 Documentation.md create mode 100644 Examples.md diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 0000000..c2ad50c --- /dev/null +++ b/Documentation.md @@ -0,0 +1,496 @@ +# Bread Documentation + +## Contents + +- [Overview](#overview) +- [Getting started](#getting-started) +- [Your first module](#your-first-module) +- [Run, reload, and watch](#run-reload-and-watch) +- [Debugging tips](#debugging-tips) +- [Dictionary: Lua API](#dictionary-lua-api) +- [Dictionary: Built-in modules](#dictionary-built-in-modules) +- [Dictionary: Event reference](#dictionary-event-reference) +- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema) +- [Dictionary: IPC protocol](#dictionary-ipc-protocol) + +## Overview + +Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. + +- Daemon: long-running Rust process, source of truth for runtime state +- Lua runtime: dedicated thread inside the daemon; automation logic lives here +- CLI: talks to the daemon over a Unix socket + +If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details. + +## Getting started + +### 1) Create a minimal config + +- Daemon config: `~/.config/bread/breadd.toml` +- Lua entry point: `~/.config/bread/init.lua` +- Lua modules: `~/.config/bread/modules/` + +### 2) Minimal `init.lua` + +```lua +require("modules.devices") +require("modules.workspaces") + +bread.on("bread.system.startup", function() + bread.profile.activate("default") +end) +``` + +### 3) Add your first module + +Create a Lua file under your modules directory and load it from `init.lua`. + +## Your first module + +```lua +local M = bread.module({ name = "hello", version = "0.1.0" }) + +function M.on_load() + bread.log("hello from bread") + + bread.on("bread.device.*", function(event) + bread.log(event.event) + end) +end + +return M +``` + +Why this shape? + +- Every module must call `bread.module` once. +- `on_load` is a good place to register subscriptions. +- Use `bread.log` early to verify handlers are firing. + +## Run, reload, and watch + +- Start the daemon, then use `bread reload` after editing Lua. +- `bread reload --watch` will keep reloading on changes. +- See [Examples.md](Examples.md) for real-world ports. + +## Debugging tips + +- Log event payloads with `bread.log(event.data.raw)` when matching devices. +- Use `bread.events` in the CLI to see live normalized events. +- Use `bread state` to see runtime state as JSON. + +## Lua module system + +### Entry point and module scanning + +- `init.lua` is executed first. +- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files. +- Every module must call `bread.module` exactly once at top-level. +- Modules are ordered by the `after` dependency list. + +### Module declaration + +```lua +local M = bread.module({ + name = "my.module", + version = "0.1.0", + after = { "bread.devices" }, +}) + +return M +``` + +If a module does not call `bread.module`, it fails to load and is marked as a load error. + +### Require loader + +`require("bread.")` resolves to a Lua file under the module path. For example: + +```lua +local utils = require("bread.lib.utils") +``` + +This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior. + +### Lifecycle hooks + +Modules may export any of the following hooks. All are optional. + +```lua +function M.on_load() + -- register subscriptions, initialize module state +end + +function M.on_reload() + -- called after a hot reload completes +end + +function M.on_unload() + -- called before the Lua instance is dropped +end + +function M.on_error(err) + -- called when a handler throws + -- return true to keep the subscription, false to cancel it + return true +end +``` + +### Module storage + +Each module has a scoped key-value store that survives reloads: + +```lua +M.store.set("last_profile", "docked") +local value = M.store.get("last_profile") +``` + +The store lives in the daemon runtime state and is not shared across modules. + +## Dictionary: Lua API + +Every API is exposed through the `bread` global table. + +### Events + +#### `bread.on(pattern, fn) -> id` +Subscribe to matching events. Returns a numeric subscription ID. + +#### `bread.once(pattern, fn) -> id` +Subscribe once. The handler is removed after the first match. + +#### `bread.filter(pattern, fn, opts) -> id` +Subscribe with a predicate filter. `opts` must contain `filter`: + +```lua +bread.filter("bread.device.*", function(event) + bread.exec("xset r rate 200 40") +end, { + filter = function(event) + return event.data and event.data.class == "keyboard" + end, +}) +``` + +#### `bread.off(id)` +Unsubscribe an event or state watch by ID. + +#### `bread.emit(event, data)` +Emit a custom event into the system pipeline. + +#### `bread.wait(pattern, opts) -> event | nil` +Coroutine-only helper that waits for a matching event. + +```lua +bread.spawn(function() + local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) + if event then + bread.log("dock arrived") + end +end) +``` + +#### `bread.spawn(fn)` +Spawn a coroutine and surface errors if the coroutine fails. + +### State + +#### `bread.state.get(path)` +Read a state subtree by dotted path (e.g. `"network.online"`). + +#### Convenience helpers + +- `bread.state.monitors()` +- `bread.state.active_workspace()` +- `bread.state.active_window()` +- `bread.state.devices()` +- `bread.state.power()` +- `bread.state.network()` +- `bread.state.profile()` + +#### `bread.state.watch(path, fn) -> id` +Watch a state path. The callback receives `(new, old)`. + +```lua +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.exec("notify-send 'AC connected'") + end +end) +``` + +### Profiles + +#### `bread.profile.activate(name)` +Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own. + +### Execution + +#### `bread.exec(cmd)` +Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async). + +### Notifications + +#### `bread.notify(message, opts)` +Sends a desktop notification via `notify-send`. + +Options: + +- `title` (string, default: `"bread"`) +- `urgency` (string, default from config) +- `timeout` (ms, default from config) +- `icon` (string, optional) + +Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`. + +### Timers + +#### `bread.after(delay_ms, fn) -> id` +Run once after delay. + +#### `bread.every(interval_ms, fn) -> id` +Run repeatedly on an interval. + +#### `bread.cancel(id)` +Cancel a timer created by `after` or `every`. + +### Utilities + +#### `bread.debounce(delay_ms, fn) -> wrapped_fn` +Returns a wrapper that only fires after quiet time. + +#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)` +Log helpers that accept any Lua value. + +### Hyprland + +The `bread.hyprland` namespace provides compositor bindings: + +- `bread.hyprland.dispatch(cmd, args)` +- `bread.hyprland.keyword(key, value)` +- `bread.hyprland.active_window()` +- `bread.hyprland.monitors()` +- `bread.hyprland.workspaces()` +- `bread.hyprland.clients()` +- `bread.hyprland.on_raw(kind, fn) -> id` + +`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string). + +## Dictionary: Built-in modules + +Built-ins are enabled by default. Disable them via `[modules].disable` in the config. + +### `bread.monitors` + +```lua +local monitors = require("bread.monitors") + +monitors.layout("dock", function() + bread.exec("~/.config/bread/scripts/layout-dock.sh") +end) + +monitors.on({ + when = "connected", + monitors = { "HDMI-A-1" }, + run = monitors.apply("dock"), +}) +``` + +- `monitors.on({ when, monitors, run })` +- `monitors.layout(name, fn)` +- `monitors.apply(name) -> fn` + +`when` is one of `connected`, `disconnected`, `changed`. `run` may be a function or a shell command string. + +### `bread.devices` + +```lua +local devices = require("bread.devices") + +devices.register("Keychron", "keyboard") + +devices.on({ + when = "connected", + class = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) +``` + +- `devices.on({ when, class, name, run })` +- `devices.register(pattern, class)` + +`class` may be `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. + +### `bread.workspaces` + +```lua +local workspaces = require("bread.workspaces") + +workspaces.assign("1", "HDMI-A-1") +workspaces.pin({ app = "Firefox", workspace = "2" }) +``` + +- `workspaces.assign(workspace, monitor)` +- `workspaces.pin({ app, workspace })` +- `workspaces.apply_assignments()` + +### `bread.binds` + +```lua +local binds = require("bread.binds") + +binds.add({ + mods = { "SUPER" }, + key = "Return", + dispatch = "exec", + args = "kitty", +}) +``` + +- `binds.add({ mods, key, dispatch, args })` +- `binds.remove(key)` +- `binds.replace(key, opts)` + +## Dictionary: Event reference + +Events are delivered as a `BreadEvent`: + +```json +{ + "event": "bread.device.dock.connected", + "timestamp": 1710000000000, + "source": "Udev", + "data": {} +} +``` + +### Pattern matching + +Patterns match event names with glob-style syntax: + +- Exact match: `bread.device.dock.connected` +- `*` matches within a single segment (does not cross `.`) +- `**` matches across segments (recursive) +- `?` matches a single character within a segment + +Examples: + +```lua +bread.on("bread.device.*", handler) +bread.on("bread.device.**", handler) +bread.on("bread.monitor.?", handler) +``` + +### Normalized events + +#### System + +- `bread.system.startup` (data: `{}`) + +#### Devices (udev) + +- `bread.device.connected` +- `bread.device.disconnected` +- `bread.device.changed` +- `bread.device..connected` +- `bread.device..disconnected` +- `bread.device..changed` + +Payload notes: + +- Device events include `id` and `class`; the generic event also includes `raw`. +- `` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. + +#### Hyprland + +- `bread.workspace.changed` (raw payload) +- `bread.workspace.created` (`{ "workspace": "..." }`) +- `bread.workspace.destroyed` (`{ "workspace": "..." }`) +- `bread.monitor.connected` (raw payload) +- `bread.monitor.disconnected` (raw payload) +- `bread.window.focus.changed` (raw payload) +- `bread.window.focused` (`{ "address": "..." }`) +- `bread.window.opened` (`{ "address", "workspace", "class", "title" }`) +- `bread.window.closed` (`{ "address": "..." }`) +- `bread.window.moved` (`{ "address", "workspace" }`) +- `bread.hyprland.event` (raw payload for unhandled kinds) + +Raw Hyprland payloads contain `kind`, `raw`, and `data` fields. + +#### Power + +- `bread.power.ac.connected` +- `bread.power.ac.disconnected` +- `bread.power.battery.low` +- `bread.power.battery.very_low` +- `bread.power.battery.critical` +- `bread.power.battery.full` +- `bread.power.changed` (fallback) + +Payload includes `ac_connected` and `battery_percent`. + +#### Network + +- `bread.network.connected` +- `bread.network.disconnected` + +Payload includes `online` and `interfaces`. + +#### Other system events + +- `bread.profile.activated` (emitted by IPC profile activation) +- `bread.notify.sent` (emitted by `bread.notify`) +- `bread.state.changed.` (emitted when a state watch fires) + +## Dictionary: Runtime state schema + +`bread.state.get("")` returns the full `RuntimeState`: + +```json +{ + "monitors": [ { "name": "HDMI-A-1", "connected": true } ], + "workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ], + "active_workspace": "1", + "active_window": "Firefox", + "devices": { "connected": [] }, + "network": { "interfaces": {}, "online": false }, + "power": { "ac_connected": false, "battery_percent": null, "battery_low": false }, + "profile": { "active": "default", "history": [], "profiles": {} }, + "modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ] +} +``` + +## Dictionary: IPC protocol + +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. + +Request: + +```json +{ "id": "1", "method": "state.get", "params": { "key": "monitors" } } +``` + +Response: + +```json +{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } +``` + +Available methods: + +- `ping` +- `health` +- `state.get` +- `state.dump` +- `modules.list` +- `modules.reload` +- `profile.list` +- `profile.activate` +- `events.subscribe` +- `events.replay` +- `emit` + +`events.subscribe` upgrades the socket to streaming mode and sends events as they occur. diff --git a/Examples.md b/Examples.md new file mode 100644 index 0000000..77b9eb1 --- /dev/null +++ b/Examples.md @@ -0,0 +1,187 @@ +# Bread Examples + +These examples show how to translate existing Hyprland automation into Bread's event-driven Lua runtime. + +Each snippet is designed to be drop-in friendly for a `~/.config/bread/modules/*.lua` file. Start with a new module file and `require` it from `~/.config/bread/init.lua`. + +## Example 1: Porting keyboard_and_display_watcher.sh (system script) + +Source inspiration: `~/.config/hypr/scripts/system/keyboard_and_display_watcher.sh`. + +This example covers two parts that port cleanly to Bread: + +- Start/stop the Redox layout viewer when the keyboard appears +- Start/stop a display sync service when an external monitor appears + +```lua +-- ~/.config/bread/modules/redox_and_display.lua +local M = bread.module({ name = "redox_and_display", version = "1.0.0" }) + +local PREVIEW_CMD = "/home/breadway/redox-layout-viewer/target/release/redox-layout-viewer" +local APP_NAME = "redox-layout-vi" + +local function start_viewer() + bread.exec("pgrep -f '" .. APP_NAME .. "' >/dev/null || " .. PREVIEW_CMD .. " >/dev/null 2>&1 &") +end + +local function stop_viewer() + bread.exec("pkill -f '" .. APP_NAME .. "' >/dev/null 2>&1 || true") +end + +local function is_redox(event) + -- Inspect event.data.raw once to find stable identifiers in your environment. + -- Typical udev fields include id_vendor, id_model, id_vendor_id, id_model_id, and name. + local raw = event.data and event.data.raw or {} + local name = tostring(raw.name or "") + local vendor = tostring(raw.id_vendor or "") + local model = tostring(raw.id_model or "") + + return name:lower():find("redox", 1, true) + or vendor:lower():find("redox", 1, true) + or model:lower():find("redox", 1, true) +end + +local external_monitors = 0 + +local function update_display_service() + if external_monitors > 0 then + bread.exec("systemctl --user start hypr-display-sync.service") + else + bread.exec("systemctl --user stop hypr-display-sync.service") + end +end + +function M.on_load() + bread.on("bread.device.keyboard.connected", function(event) + if is_redox(event) then + start_viewer() + end + end) + + bread.on("bread.device.keyboard.disconnected", function(event) + if is_redox(event) then + stop_viewer() + end + end) + + bread.on("bread.monitor.connected", function(event) + local name = event.data and (event.data.name or event.data.raw) or "" + -- ignore internal panel (eDP-1) and count only externals + if not tostring(name):match("eDP%-1") then + external_monitors = external_monitors + 1 + update_display_service() + end + end) + + bread.on("bread.monitor.disconnected", function(event) + local name = event.data and (event.data.name or event.data.raw) or "" + if not tostring(name):match("eDP%-1") then + external_monitors = math.max(0, external_monitors - 1) + update_display_service() + end + end) +end + +return M +``` + +Notes: + +- Use `bread.log(event.data.raw)` once to see your exact udev fields for matching. +- This drops polling and relies on udev/Hyprland events. + +## Example 2: Porting autostart.lua + +Source inspiration: `~/.config/hypr/scripts/system/autostart.lua`. + +```lua +-- ~/.config/bread/modules/autostart.lua +local M = bread.module({ name = "autostart", version = "1.0.0" }) + +local home = os.getenv("HOME") or "/home/breadway" +local startup_commands = { + "wal -R", + home .. "/colorshell/build/colorshell", + "awww-daemon", + "awww restore", + home .. "/.config/hypr/scripts/system/keyboard_and_display_watcher.sh", + home .. "/.config/hypr/watch_hypr_scripts.sh", + "systemctl --user daemon-reload", + "systemctl --user start hypr-display-sync.service", + "systemctl --user start hyprpolkitagent", + "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP", + "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", + "flatpak run dev.deedles.Trayscale", + "wificonf init", + "pkill -f hyprpaper", +} + +function M.on_load() + bread.once("bread.system.startup", function() + for _, cmd in ipairs(startup_commands) do + bread.exec(cmd) + end + end) +end + +return M +``` + +## Example 3: Porting display/monitors.lua + +Source inspiration: `~/.config/hypr/scripts/display/monitors.lua`. + +This uses Bread events and Hyprland keywords to update monitor layout when external displays change. + +```lua +-- ~/.config/bread/modules/monitors.lua +local M = bread.module({ name = "monitors", version = "1.0.0" }) + +local function apply_internal_mode(has_external) + local mode = has_external and "1920x1080@60" or "1920x1200@60" + bread.hyprland.keyword("monitor", "eDP-1, " .. mode .. ", 0x0, 1") +end + +local function apply_external() + bread.hyprland.keyword("monitor", "DP-3, 1920x1080@60, auto, 1, mirror, eDP-1") +end + +local externals = 0 +local function update() + apply_internal_mode(externals > 0) + if externals > 0 then + apply_external() + end +end + +function M.on_load() + bread.on("bread.monitor.connected", function(event) + local name = tostring((event.data and (event.data.name or event.data.raw)) or "") + if not name:match("eDP%-1") then + externals = externals + 1 + update() + end + end) + + bread.on("bread.monitor.disconnected", function(event) + local name = tostring((event.data and (event.data.name or event.data.raw)) or "") + if not name:match("eDP%-1") then + externals = math.max(0, externals - 1) + update() + end + end) + + bread.once("bread.system.startup", function() + update() + end) +end + +return M +``` + +## Tips for porting your own scripts + +- Start by logging the event payload: `bread.log(event.data.raw)` +- Replace polling loops with event subscriptions +- Use `bread.exec` for shell commands and systemd operations +- Use `bread.state.watch` for data that already lives in the runtime state diff --git a/README.md b/README.md index 73512df..ec62008 100644 --- a/README.md +++ b/README.md @@ -191,29 +191,42 @@ Events follow the namespace convention `bread...`. | `bread.system.startup` | Daemon fully initialized | | `bread.device.connected` | Any device attached | | `bread.device.disconnected` | Any device removed | -| `bread.device.dock.connected` | Dock attached | -| `bread.device.dock.disconnected` | Dock removed | -| `bread.device.keyboard.connected` | Keyboard attached | +| `bread.device.changed` | Any device changed | +| `bread.device..connected` | Device attached by class | +| `bread.device..disconnected` | Device removed by class | +| `bread.device..changed` | Device changed by class | | `bread.monitor.connected` | Display connected | | `bread.monitor.disconnected` | Display disconnected | | `bread.workspace.changed` | Active workspace changed | +| `bread.workspace.created` | Workspace created | +| `bread.workspace.destroyed` | Workspace destroyed | | `bread.window.focus.changed` | Focused window changed | +| `bread.window.focused` | Focus moved (address only) | | `bread.window.opened` | Window opened | | `bread.window.closed` | Window closed | +| `bread.window.moved` | Window moved workspaces | | `bread.power.ac.connected` | AC adapter plugged in | | `bread.power.ac.disconnected` | AC adapter unplugged | | `bread.power.battery.low` | Battery ≤ 20% | | `bread.power.battery.very_low` | Battery ≤ 10% | | `bread.power.battery.critical` | Battery ≤ 5% | | `bread.power.battery.full` | Battery at 100% | -| `bread.network.connected` | Network interface came online | -| `bread.network.disconnected` | Network interface went offline | -| `bread.profile.activated` | Profile switched | +| `bread.power.changed` | Power state changed (fallback) | +| `bread.network.connected` | Network came online | +| `bread.network.disconnected` | Network went offline | +| `bread.profile.activated` | Profile switched via IPC | +| `bread.notify.sent` | Notification dispatched | +| `bread.state.changed.` | State watch fired | +| `bread.hyprland.event` | Raw Hyprland event (unhandled kind) | --- ## Lua API +Full reference and usage notes live in [documentation.md](documentation.md). This section is a compact quick-reference to every API that exists today. + +Practical walkthroughs and ports from existing Hyprland configs live in [Examples.md](Examples.md). + ### Events ```lua @@ -239,6 +252,14 @@ end) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) + +-- Wait for an event (coroutine-only) +bread.spawn(function() + local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) + if event then + bread.log("dock arrived") + end +end) ``` ### State @@ -254,6 +275,15 @@ local devices = bread.state.get("devices") bread.state.watch("active_workspace", function(new, old) print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) end) + +-- Convenience helpers +local monitors = bread.state.monitors() +local active_ws = bread.state.active_workspace() +local active_win = bread.state.active_window() +local devices = bread.state.devices() +local power = bread.state.power() +local network = bread.state.network() +local profile = bread.state.profile() ``` ### Profiles @@ -291,6 +321,10 @@ bread.cancel(id) local fn = bread.debounce(200, function(event) reconfigure_monitors() end) + +-- Cancel a timer +local timer_id = bread.after(500, function() bread.exec("echo ready") end) +bread.cancel(timer_id) ``` ### Logging @@ -299,6 +333,44 @@ end) bread.log("Module loaded") bread.warn("Unexpected state") bread.error("Something failed") + +### Hyprland + +```lua +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Raw Hyprland event filtering (kind matches hyprland event name) +bread.hyprland.on_raw("openwindow", function(event) + bread.log(event.data.raw) +end) +``` + +### Modules + +```lua +local M = bread.module({ name = "my.module", version = "0.1.0", after = { "bread.devices" } }) + +function M.on_load() + bread.on("bread.device.*", function(event) + bread.log(event.event) + end) +end + +function M.on_unload() + bread.log("unloaded") +end + +M.store.set("last_seen", os.time()) +local last = M.store.get("last_seen") + +return M +``` ``` ---