diff --git a/.github/workflows/quickshell-lint.yml b/.github/workflows/quickshell-lint.yml new file mode 100644 index 00000000..bf83b075 --- /dev/null +++ b/.github/workflows/quickshell-lint.yml @@ -0,0 +1,71 @@ +--- +name: Lint Quickshell + +on: + push: + branches: + - distro/arch-omarchy + paths: + - '**/*.qml' + - '.github/workflows/quickshell-lint.yml' + pull_request: + paths: + - '**/*.qml' + - '.github/workflows/quickshell-lint.yml' + +permissions: + contents: read + +env: + # Version of the Arch `quickshell` package this lint runs against. The + # Omarchy shell runs on Arch's quickshell (which pulls qt6-declarative, i.e. + # qmllint), so this tracks the Arch package - not Qt and not upstream tags. + # Renovate keeps it in sync via the repology datasource (see the + # customManagers entry in renovate.json); the version check below fails if + # the container's quickshell has drifted from this pin, which is the signal + # to merge the Renovate bump. + QUICKSHELL_VERSION: "0.3.0" + +jobs: + qmllint: + runs-on: ubuntu-latest + container: archlinux:latest + steps: + - name: Install quickshell and qmllint + run: | + # Refresh the keyring first (the base image's can lag and break + # signature checks), then install quickshell (pulls qt6-declarative = + # qmllint) and git (needed by actions/checkout in this container). + pacman -Sy --noconfirm --needed archlinux-keyring + pacman -Su --noconfirm --needed quickshell qt6-declarative git + + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Trust the checkout (container git ownership) + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Verify quickshell matches the pinned version + run: | + installed="$(pacman -Q quickshell | awk '{print $2}' | cut -d- -f1)" + echo "Installed quickshell: $installed (pinned QUICKSHELL_VERSION: $QUICKSHELL_VERSION)" + if [ "$installed" != "$QUICKSHELL_VERSION" ]; then + echo "::error::Arch quickshell is $installed but QUICKSHELL_VERSION is pinned to $QUICKSHELL_VERSION. Merge the Renovate bump so the pin matches the package being linted against." >&2 + exit 1 + fi + + - name: Lint QML + run: | + export PATH="/usr/lib/qt6/bin:$PATH" + mapfile -t qml_files < <(git ls-files '*.qml') + if [ "${#qml_files[@]}" -eq 0 ]; then + echo 'No QML files found.' + exit 0 + fi + printf 'Linting %s QML file(s) with %s:\n' "${#qml_files[@]}" "$(qmllint --version)" + printf ' %s\n' "${qml_files[@]}" + # quickshell installs its QML modules under /usr/lib/qt6/qml, so `-I` + # lets qmllint resolve `Quickshell.*`. The Omarchy shell's own `qs.*` + # modules ship in the (unpackaged) shell source, so the import + # category stays disabled; qmllint still fails on real syntax errors. + qmllint -I /usr/lib/qt6/qml --import disable --unqualified disable "${qml_files[@]}" diff --git a/.gitignore b/.gitignore index 70bba14c..152b40cf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ dot/.fallow/ scripts/.local/bin/dot scripts/.local/bin/dot* zsh/.local/share/zsh/chpwd-recent-dirs +zsh/.local/share/zsh/site-functions/_mise +zsh/.local/share/zsh/site-functions/_op /session-*.md + diff --git a/.opencode/skills/omarchy-shell-quickshell/SKILL.md b/.opencode/skills/omarchy-shell-quickshell/SKILL.md new file mode 100644 index 00000000..15a7a6d8 --- /dev/null +++ b/.opencode/skills/omarchy-shell-quickshell/SKILL.md @@ -0,0 +1,68 @@ +--- +name: omarchy-shell-quickshell +description: "Customise and reload the Omarchy shell (omarchy-shell) - the Quickshell process behind the bar, notifications, OSD, launcher, and settings - in this dotfiles repo. Use when working with the Omarchy shell or Quickshell: editing omarchy/.config/omarchy/plugins/, the shell.json generator dot/src/lib/omarchyShellConfig.ts, shell.json, BarWidget/WidgetButton or other Quickshell QML, running omarchy plugin or omarchy restart shell, referencing upstream Quickshell, or when a shell or bar change is not showing up." +--- + +# Omarchy Shell (Quickshell) + +## Background / upstream + +- The Omarchy shell is a single long-running **Quickshell** process (`omarchy-shell`) that hosts the top bar, notification daemon, on-screen display, launcher, and settings panel. Restarting "the shell" restarts all of them together. +- **Quickshell** is the upstream framework: a QML/Qt6 toolkit for Wayland desktop shells (layer-shell surfaces via `WlrLayershell`, an IPC bus, and live QML reload). Upstream is self-hosted Forgejo at `git.outfoxxed.me/quickshell/quickshell`; the opencode `quickshell` reference clones the official GitHub mirror `quickshell-mirror/quickshell`. Use it for QML types, layer-shell, `IpcHandler`, reloadable config, and the `qs` CLI. +- Omarchy's shell source lives in `~/.local/share/omarchy/shell/` - **READ-ONLY** (reading is useful: base classes `BarWidget`/`WidgetButton` in `shell/Ui/`, the bar host in `shell/plugins/bar/Bar.qml`). Edits are lost on `omarchy update`. +- This work targets the `omarchy-shell` head of `basecamp/omarchy#5856`. A stable Omarchy checkout may still contain Waybar and Walker, so verify its branch before treating it as the source for Omarchy 4 shell behaviour. + +## Source of truth (never edit live) + +- Custom shell content ships as **user plugins**. Edit the stow source `omarchy/.config/omarchy/plugins//` (stows to `~/.config/omarchy/plugins//`). +- `~/.config/omarchy/shell.json` is **generated, not hand-edited**. It is rendered by `dot` from `dot/src/lib/omarchyShellConfig.ts` (`mergeOmarchyShellConfig`), starting from Omarchy's default and inserting personal modules. Edit the generator, rebuild `dot`, then `dot stow` regenerates the file. The live file is mode `0600` and tracked by neither dotfiles repo. + +## Plugin layout + +A plugin is a folder with `manifest.json` + entry-point QML: + +- `manifest.json`: `schemaVersion: 1`, required `id`, `name`, `version`, non-empty `kinds`, and `entryPoints` mapping each kind to a safe relative QML path. A bar widget also has `barWidget` metadata such as `displayName`, `category`, and `allowMultiple`. Third-party ids must be namespaced and cannot start with `omarchy.`. +- Entry QML: a bar widget extends `BarWidget` (`import qs.Commons`, `import qs.Ui`), sets `moduleName` to the plugin id, reads per-instance `shell.json` settings via `setting(name, fallback)`, and uses `WidgetButton` for clickable text cells. + +Stow gotcha: `~/.config/omarchy/plugins/` is a **real directory with per-plugin symlinks**. A brand-new plugin needs `dot stow` to create its symlink before the shell sees it. Editing an existing plugin's files is already live (symlink -> dotfiles source). + +## Reload matrix (run after a change) + +| Change | Action | +| --- | --- | +| `shell.json` layout/settings, existing modules only | Hot-reloads on save - nothing to run | +| New plugin added or user plugin QML edited | `omarchy plugin rescan` | +| Omarchy's first-party shell QML edited, or rescan cannot recover | `omarchy restart shell` | + +`omarchy plugin rescan` calls `rescanPlugins`, which unloads user plugin instances, clears the QML component cache, rescans manifests, and reloads enabled plugins. `reloadConfig` only reloads `shell.json`. A full `omarchy restart shell` refuses to run while the session is locked, then stops Quickshell and launches a detached replacement. + +## The XCB trap (critical) + +`omarchy-restart-shell` inherits the caller's environment. If `QT_QPA_PLATFORM=xcb`, Quickshell starts under XWayland, `WlrLayershell` cannot attach, and the shell renders as a **floating window** instead of bar/overlay surfaces (no error). This only matters when doing a full restart; plugin rescans stay in the running Wayland process. + +- Interactive shells in this repo set `QT_QPA_PLATFORM="wayland;xcb"` (`zsh/.zshrc`) so a direct `omarchy restart shell` works. A plain `xcb` reintroduces the floating-window bug. +- From any non-interactive context (SSH, `systemd`, `cron`, an agent shell) force Wayland and make sure the session is reachable: + +```bash +QT_QPA_PLATFORM=wayland omarchy restart shell # needs WAYLAND_DISPLAY + XDG_RUNTIME_DIR +``` + +- `dot update` bakes this in: it reloads the shell **only when the generated `shell.json` changed**, forcing `QT_QPA_PLATFORM=wayland` on the restart (`reloadOmarchyShell` in `dot/src/commands/Update.ts`). Standalone `dot stow` does not reload. + +## Lint (required after every final QML change) + +After every final QML edit, lint the files you touched with the **Qt6** `qmllint`. On Arch the Qt6 binary is `/usr/lib/qt6/bin/qmllint` - the bare `/usr/bin/qmllint` may be an older Qt5 build that rejects these flags. `-I /usr/lib/qt6/qml` lets it resolve the installed `Quickshell.*` modules: + +```bash +/usr/lib/qt6/bin/qmllint -I /usr/lib/qt6/qml --import disable --unqualified disable .qml +``` + +The shell's own `qs.*` modules ship in the (unpackaged) shell source, so the `import`/`unqualified` categories stay disabled as noise. This is a syntax-focused gate: unresolved Omarchy types can still produce warnings, while parse errors fail with a non-zero exit. This mirrors `.github/workflows/quickshell-lint.yml`, which lints inside an Arch container against the `quickshell` package pinned by `QUICKSHELL_VERSION` (kept in sync with the Arch package by Renovate via repology, and asserted to match). Lint must exit successfully before a QML change is considered done. + +## Verify + +- `omarchy plugin list` - is the plugin registered and enabled? +- `omarchy-shell shell debugBarGeometry` - per-module `x`/`width`/`visible`; confirm a widget renders or collapses. +- `omarchy plugin validate ` rejects symlinked plugin folders - **expected** for stowed plugins, not a real error. +- `grim -g "0,0 360x32" out.png` - visual check of the bar's left edge; after a restart confirm `hyprctl layers | grep omarchy-bar`. +- After editing the generator: `mise run dot:check`. diff --git a/AGENTS.md b/AGENTS.md index 2631f112..2175fe94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,16 +59,17 @@ Keep shared cross-project agent behaviour in the global `~/.config/opencode/AGEN ## Repo-Specific Skills -- The repo-relevant skills are `effect` (Effect v4 code in `dot/`) and `opentui` (OpenTUI code in `dot/`); both self-document their triggers. +- The repo-relevant skills are `effect` (Effect v4 code in `dot/`), `opentui` (OpenTUI code in `dot/`), and `omarchy-shell-quickshell` (Omarchy shell / Quickshell work in this repo); all self-document their triggers. ## Omarchy Host Overrides -- Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`, conf-only), not a tracked Omarchy repo. -- `waybar` and `uwsm` are single-branch Omarchy repos expected on `main`. +- Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`), not a tracked Omarchy repo. +- `uwsm` is a single-branch Omarchy repo expected on `main`. - `ghostty` is a stowed package (`ghostty/.config/ghostty/`) with `config.$OMARCHY_HOST` overrides loaded by `ghostty-host-config`. - `bootstrap` is expected on `distro/omarchy`. - Hypr host-specific overrides live under `~/.config/hypr/hosts/$OMARCHY_HOST`, selected by the runtime `~/.config/hypr/host` symlink. - `dot stow` lays down the Hypr package with `--no-folding` and creates/repairs `~/.config/hypr/host`; `dot doctor` checks the host link and flags any leftover legacy `omarchy-hypr` clone. +- The Hypr package alone is stowed non-destructively: `dot stow` and `dot install` skip the usual unstow-then-restow for `hypr` so its symlinks (notably `hyprland.lua`) never vanish mid-stow, then reload Hyprland afterwards. This stops Hyprland's live-config autoreload from catching a missing config and dropping into emergency mode. Keep this behaviour if you touch the stow loop in `dot/src/commands/{Stow,Install}.ts`. - A machine still on the retired `~/.config/hypr` `omarchy-hypr` clone halts `dot update` until the clone is backed up and re-stowed. - If this host override layout changes, update the docs site (`docs/src/content/docs/`), `README.md`, `AGENTS.md`, and skill documentation together so repo instructions stay consistent. @@ -87,6 +88,7 @@ Keep shared cross-project agent behaviour in the global `~/.config/opencode/AGEN - Keep command and flag metadata in `dot/src/cli/spec.ts`; help and completion generation consume that registry. - When changing `dot` commands, subcommands, aliases, or flags, run `dot completions` for each supported shell after rebuilding so the stowed completion files stay in sync. - The `docs/` command reference (`docs/src/content/docs/dot/commands.md`) is generated from `dot/src/cli/spec.ts`. After changing commands, regenerate it with `mise run docs:gen:cli` (alongside shell completions) and commit the result. +- The Omarchy bar `shell.json` is generated by `dot/src/lib/omarchyShellConfig.ts`; the `dot update` loop reloads the running shell only when the rendered `shell.json` changes and forces `QT_QPA_PLATFORM=wayland` on the restart so the layer-shell bar attaches. Keep this behaviour if you touch `reloadOmarchyShell` in `dot/src/commands/Update.ts` or the shell-config return value. See the `omarchy-shell-quickshell` skill. ## Documentation Site diff --git a/README.md b/README.md index 7aeb1295..128944f0 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ My public [Omarchy](https://omarchy.org) dotfiles, managed with GNU Stow and the - Stow-based dotfiles rooted at `~/.config/dotfiles`, applied with the `dot` command - A single compiled binary at `scripts/.local/bin/dot` (Bun + Effect v4 + OpenTUI) with a TUI dashboard and a full CLI -- Git/GitHub tooling: diff, log, status, workflow runs, and a notification inbox across managed repos, with Waybar modules -- Managed Omarchy repos (`bootstrap`, `waybar`, `uwsm`) and stowed Hyprland/Ghostty config +- Git/GitHub tooling: diff, log, status, workflow runs, and a notification inbox across managed repos, surfaced in the Omarchy Quickshell status bar +- Managed Omarchy repos (`bootstrap`, `uwsm`) and stowed Hyprland/Ghostty config - Optional private overlay from `~/.config/dotfiles-private` - Shared OpenCode agents, commands, skills, and plugins, published to [`timmo001/opencode-config`](https://github.com/timmo001/opencode-config) diff --git a/agents/.agents/skills/dotfiles-stow/SKILL.md b/agents/.agents/skills/dotfiles-stow/SKILL.md index 38cc2558..b569dd70 100644 --- a/agents/.agents/skills/dotfiles-stow/SKILL.md +++ b/agents/.agents/skills/dotfiles-stow/SKILL.md @@ -83,12 +83,13 @@ When editing the public dotfiles repo (`~/.config/dotfiles`), treat a docs updat ## Omarchy Host Override Documentation Rules -- Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`, conf-only), not a tracked Omarchy repo. -- `waybar` and `uwsm` are single-branch Omarchy repos expected on `main`. +- Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`), not a tracked Omarchy repo. +- `uwsm` is a single-branch Omarchy repo expected on `main`. - `ghostty` is a stowed package (`ghostty/.config/ghostty/`) with host overrides loaded by `ghostty-host-config`. - `bootstrap` is expected on `distro/omarchy`. - Hypr host-specific overrides live under `~/.config/hypr/hosts/$OMARCHY_HOST`, selected by the runtime `~/.config/hypr/host` symlink. - `dot stow` lays down the Hypr package with `--no-folding` and creates/repairs `~/.config/hypr/host`; `dot doctor` checks it and flags any leftover legacy `omarchy-hypr` clone. +- The Hypr package alone is stowed non-destructively: `dot stow` and `dot install` skip the usual unstow-then-restow for `hypr` so its symlinks (notably `hyprland.lua`) never vanish mid-stow, then reload Hyprland afterwards, so Hyprland's autoreload never catches a missing config and trips emergency mode. - When changing host override layout or guidance, update the relevant `README.md`, `AGENTS.md`, and skill documentation together. - Repos that use host-specific overrides should have their own `README.md` and `AGENTS.md` that explicitly state the arrangement and the requirement to keep related documentation in sync when it changes. diff --git a/agents/.agents/skills/safe-process-signals/SKILL.md b/agents/.agents/skills/safe-process-signals/SKILL.md index 1fa7a2f9..b9f443ef 100644 --- a/agents/.agents/skills/safe-process-signals/SKILL.md +++ b/agents/.agents/skills/safe-process-signals/SKILL.md @@ -54,7 +54,7 @@ Omarchy's restart helpers use `pkill -x ` which matches the process name o ```bash # Best: exact process name (what omarchy-restart-* uses) -pkill -x "waybar" +pkill -x "quickshell" # Acceptable: bracket trick when -f is needed pkill -f "[n]ode.*server.js" @@ -66,7 +66,7 @@ pkill -f "node.*server.js" When you need to restart an Omarchy-managed app, prefer the built-in helper: ```bash -omarchy restart waybar +omarchy restart shell omarchy restart terminal omarchy restart-app [args...] ``` diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 7e040d2d..7410e782 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -91,7 +91,12 @@ export default defineConfig({ }, { label: 'Omarchy & Hyprland', - items: [{ autogenerate: { directory: 'omarchy' } }], + items: [ + { label: 'Overview', slug: 'omarchy' }, + { label: 'Host Overrides', slug: 'omarchy/host-overrides' }, + { label: 'Shell (Quickshell)', slug: 'omarchy/shell' }, + { label: 'Controls', slug: 'omarchy/controls' }, + ], }, { label: 'Bar Integrations', slug: 'bar-integrations' }, { label: 'Cleanup', slug: 'cleanup' }, diff --git a/docs/src/content/docs/bar-integrations.mdx b/docs/src/content/docs/bar-integrations.mdx index 6316ea54..1c062d14 100644 --- a/docs/src/content/docs/bar-integrations.mdx +++ b/docs/src/content/docs/bar-integrations.mdx @@ -13,7 +13,7 @@ export const commandCards = [ `--bar-json` is a shared status-bar contract rather than a single command. Tools emit one-line `--bar-json` output (with `text`, `tooltip`, and `class` fields) meant to be polled by a status bar module through its own short-lived cache. Left click opens the relevant TUI; right click refreshes the cache. -The JSON format is bar-agnostic: it works with [Waybar](https://github.com/Alexays/Waybar), [Quickshell](https://quickshell.outfoxxed.me), or any bar that can run a command and parse JSON. My own setup uses Waybar, so the bundled modules and `dot doctor` checks target Waybar, but the commands themselves do not depend on it. +The JSON format is bar-agnostic: it works with [Quickshell](https://quickshell.outfoxxed.me), [Waybar](https://github.com/Alexays/Waybar), or any bar that can run a command and parse JSON. My own setup uses the Omarchy 4 Quickshell bar, which runs these commands through `timmo.command` widgets, but the commands themselves do not depend on any particular bar. See [Shell (Quickshell)](/omarchy/shell/) for how that bar is configured. ## dot commands with bar output @@ -37,4 +37,4 @@ Which repos and which activity reach the bar is controlled by the private `dot-g ## Health checks -`dot doctor` verifies the active status-bar module wiring for `git-workflows` and `git-notifications`, alongside `dot-git.yml` and the absence of legacy `git-workflow-watch` leftovers. +`dot doctor` verifies `dot-git.yml`, GitHub notification API access, and the absence of legacy `git-workflow-watch` leftovers. diff --git a/docs/src/content/docs/cleanup.md b/docs/src/content/docs/cleanup.md index 336f5c87..279ff0e9 100644 --- a/docs/src/content/docs/cleanup.md +++ b/docs/src/content/docs/cleanup.md @@ -129,7 +129,7 @@ After the stowed links and system config are removed, delete cloned repos and ge ```bash rm -rf ~/.config/dotfiles-private -rm -rf ~/.config/bootstrap ~/.config/waybar ~/.config/uwsm +rm -rf ~/.config/bootstrap ~/.config/uwsm rm -rf ~/.local/state/dot ~/.cache/dot ``` @@ -138,7 +138,6 @@ Private package repos and other private Git clones are configured by the private If you removed Omarchy config directories that `dot init` replaces with managed repos, or stowed config directories that Omarchy should own again, refresh the stock Omarchy defaults afterwards. ```bash -omarchy refresh waybar omarchy refresh shell omarchy refresh hyprland omarchy refresh config ghostty/config diff --git a/docs/src/content/docs/configuration/private-dashboard.md b/docs/src/content/docs/configuration/private-dashboard.md index 0881728c..09ba6bbb 100644 --- a/docs/src/content/docs/configuration/private-dashboard.md +++ b/docs/src/content/docs/configuration/private-dashboard.md @@ -53,7 +53,7 @@ sources: Each `command` must behave like a status-bar poll, not a long-running watcher: - Print one JSON object on the first line of stdout. -- Use the same `--bar-json` shape as Waybar modules: `text`, `tooltip`, and `class` fields. See [Bar Integrations](/bar-integrations/). +- Use the shared `--bar-json` status-bar shape: `text`, `tooltip`, and `class` fields. See [Bar Integrations](/bar-integrations/). - Finish within eight seconds. `dot dashboard` kills overdue commands with `SIGTERM`. - Avoid unbounded stream helpers. Commands containing `ha-watch-singleton`, `singleton-stream`, or `doorbell` are rejected as unsafe. diff --git a/docs/src/content/docs/configuration/private-git.md b/docs/src/content/docs/configuration/private-git.md index 3356efc0..a1c54e54 100644 --- a/docs/src/content/docs/configuration/private-git.md +++ b/docs/src/content/docs/configuration/private-git.md @@ -26,4 +26,4 @@ The `notifications.bar.ignore_bot_activity` key controls status-bar bot noise. A ## Requirements - `dot git-notifications` requires `gh` authenticated with a classic token carrying the `notifications` or `repo` scope. -- `dot doctor` verifies `dot-git.yml`, the active status-bar module wiring, and the absence of legacy `git-workflow-watch` leftovers. +- `dot doctor` verifies `dot-git.yml` and the absence of legacy `git-workflow-watch` leftovers. diff --git a/docs/src/content/docs/dot/commands.md b/docs/src/content/docs/dot/commands.md index 2cffb9cf..de39a5bb 100644 --- a/docs/src/content/docs/dot/commands.md +++ b/docs/src/content/docs/dot/commands.md @@ -185,8 +185,8 @@ Origin HEAD Local origin/HEAD tracks the remote default branch (not sta Stow integrity Dry-run restow to detect drift OpenCode location Canonical paths, legacy remnants Git config Managed include is active -Workflow runs Repo list, status bar config, legacy watcher cleanup -Git notifications API scope and status bar notification module wiring +Workflow runs Repo list and legacy watcher cleanup +Git notifications API scope and notification access Doctor startup Startup notification timer Daily volume reset Laptop-only optional timer Omarchy repos Diff repos + worktree branch correctness diff --git a/docs/src/content/docs/getting-started/install.md b/docs/src/content/docs/getting-started/install.md index c5fa47fe..de4e633a 100644 --- a/docs/src/content/docs/getting-started/install.md +++ b/docs/src/content/docs/getting-started/install.md @@ -51,7 +51,7 @@ Or run `dot init` in an interactive shell to be prompted. `--noninteractive` skips only the Hypr host questionnaire; elevation and package tools may still prompt. `--confirm` remains accepted for compatibility but does not suppress prompts. Private overlay preflight is controlled by `DOT_ALLOW_PRIVATE`: `auto` skips without GitHub authentication and tolerates an existing-overlay pull failure, but a failed attempted clone is fatal; `always` requires the overlay to update or clone successfully; `never` skips it. :::note -If stock Omarchy directories already exist at `~/.config/bootstrap`, `~/.config/waybar`, or `~/.config/uwsm`, `dot init` backs them up with a `.dot-init-backup-*` suffix before cloning the managed repos. Hyprland and Ghostty config are stowed from the `hypr/` and `ghostty/` packages instead. +If stock Omarchy directories already exist at `~/.config/bootstrap` or `~/.config/uwsm`, `dot init` backs them up with a `.dot-init-backup-*` suffix before cloning the managed repos. Hyprland and Ghostty config are stowed from the `hypr/` and `ghostty/` packages instead. ::: ## Ongoing workflow diff --git a/docs/src/content/docs/getting-started/new-machine.mdx b/docs/src/content/docs/getting-started/new-machine.mdx index fcbc256f..c95ba6b2 100644 --- a/docs/src/content/docs/getting-started/new-machine.mdx +++ b/docs/src/content/docs/getting-started/new-machine.mdx @@ -29,7 +29,7 @@ A clean, end-to-end walkthrough for setting up a new machine. ``` 5. Run first-use setup using the mode that matches this machine. -6. If stock Omarchy config directories already exist at `~/.config/bootstrap`, `~/.config/waybar`, or `~/.config/uwsm`, `dot init` backs them up with a `.dot-init-backup-*` suffix before cloning the managed repos. Hyprland and Ghostty config are stowed from the `hypr/` and `ghostty/` packages instead. +6. If stock Omarchy config directories already exist at `~/.config/bootstrap` or `~/.config/uwsm`, `dot init` backs them up with a `.dot-init-backup-*` suffix before cloning the managed repos. Hyprland and Ghostty config are stowed from the `hypr/` and `ghostty/` packages instead. 7. `dot init` opens the managed [firewall rules](/dot/utilities/#firewall-rules) (KDE Connect, Home Assistant, the OpenCode server, LocalSend, and the libvirt NAT network) when `ufw` is installed. 8. Restart your shell and confirm `dot help` is on `PATH`. 9. Run `dot git-diff` and verify the expected repo state. @@ -64,7 +64,7 @@ dot init `--noninteractive` skips the Hypr host questionnaire; elevation and package tools may still prompt. The accepted `--confirm` compatibility flag does not suppress those prompts. :::tip[Hypr host and mise tools] -`dot init` selects the Hypr host early and creates `~/.config/hypr/host`. Reboot after init so Hyprland, Waybar, launchers, services, and new shells inherit `OMARCHY_HOST` from the stowed host config. Init runs `mise install` immediately after stowing dotfiles and before installing managed Arch/AUR package lists, so Bun, Node, pnpm, and similar tools come from the stowed mise config rather than global pacman packages. Later full `dot update` runs reconcile newly added public packages before MCP sync and stow, alongside repo pulls, rebuilds, and mise trust refreshes. +`dot init` selects the Hypr host early and creates `~/.config/hypr/host`. Reboot after init so Hyprland, the Omarchy shell, launchers, services, and new shells inherit `OMARCHY_HOST` from the stowed host config. Init runs `mise install` immediately after stowing dotfiles and before installing managed Arch/AUR package lists, so Bun, Node, pnpm, and similar tools come from the stowed mise config rather than global pacman packages. Later full `dot update` runs reconcile newly added public packages before MCP sync and stow, alongside repo pulls, rebuilds, and mise trust refreshes. ::: :::note[GNOME Boxes shared folders] diff --git a/docs/src/content/docs/git/notifications.md b/docs/src/content/docs/git/notifications.md index 3a846f57..c68c1aa3 100644 --- a/docs/src/content/docs/git/notifications.md +++ b/docs/src/content/docs/git/notifications.md @@ -42,4 +42,4 @@ The notification API requires `gh` authenticated with a classic token carrying t ## Status bar module -A status bar module refreshes `dot git-notifications --bar-json` through its own short-lived cache. Notification surfaces hide repos that are not enabled in `dot-git.yml`, while upstream notifications can match a managed fork's `remote.upstream.url`. Left click opens `dot git-notifications --bar-filter`; right click refreshes the cache. `dot doctor` verifies GitHub notification API access plus the active notification module wiring. +A status bar module refreshes `dot git-notifications --bar-json` through its own short-lived cache. Notification surfaces hide repos that are not enabled in `dot-git.yml`, while upstream notifications can match a managed fork's `remote.upstream.url`. Left click opens `dot git-notifications --bar-filter`; right click refreshes the cache. `dot doctor` verifies GitHub notification API access. diff --git a/docs/src/content/docs/git/workflows.md b/docs/src/content/docs/git/workflows.md index a5f7ccf7..5032a529 100644 --- a/docs/src/content/docs/git/workflows.md +++ b/docs/src/content/docs/git/workflows.md @@ -26,7 +26,7 @@ dot git-workflows --bar-json --since "$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M: ## Status bar module -A status bar module refreshes `dot git-workflows --bar-json --since ` through its own short-lived cache; left click opens the filtered TUI and right click refreshes the cache. `dot doctor` verifies `dot-git.yml`, the active workflow-runs module wiring, and the absence of legacy `git-workflow-watch` leftovers. +A status bar module refreshes `dot git-workflows --bar-json --since ` through its own short-lived cache; left click opens the filtered TUI and right click refreshes the cache. `dot doctor` verifies `dot-git.yml` and the absence of legacy `git-workflow-watch` leftovers. :::note The old `git-workflow-watch` hook and its user systemd timer are obsolete and should not be installed. diff --git a/docs/src/content/docs/omarchy/host-overrides.md b/docs/src/content/docs/omarchy/host-overrides.md index 3a743696..110778c9 100644 --- a/docs/src/content/docs/omarchy/host-overrides.md +++ b/docs/src/content/docs/omarchy/host-overrides.md @@ -9,10 +9,10 @@ sidebar: `dot` tracks a small set of Omarchy components as git repos and keeps them on the expected branch: -- `waybar` and `uwsm` — single-branch Omarchy repos expected on `main`. +- `uwsm` — single-branch Omarchy repo expected on `main`. - `bootstrap` — expected on `distro/omarchy`. -`dot init` clones these into `~/.config/{bootstrap,waybar,uwsm}`. If a stock Omarchy config directory already exists there and is not a git repo, init moves it aside with a `.dot-init-backup-*` suffix before cloning. `dot update` syncs them, and `dot doctor` verifies their worktree branches. +`dot init` clones these into `~/.config/{bootstrap,uwsm}`. If a stock Omarchy config directory already exists there and is not a git repo, init moves it aside with a `.dot-init-backup-*` suffix before cloning. `dot update` syncs them, and `dot doctor` verifies their worktree branches. ## Ghostty host overrides @@ -22,14 +22,14 @@ Ghostty config is a stowed dotfiles package (`ghostty/.config/ghostty/`), not a ## Hyprland host overrides -Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`, conf-only), not a tracked repo. Host-specific overrides live under `~/.config/hypr/hosts/$OMARCHY_HOST`, selected by the runtime `~/.config/hypr/host` symlink. +Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`), not a tracked repo. Host-specific Lua overrides live under `~/.config/hypr/hosts/$OMARCHY_HOST`, selected by the runtime `~/.config/hypr/host` symlink. - `dot stow` lays down the Hypr package with `--no-folding` and creates/repairs `~/.config/hypr/host` to point at the active host. - `dot init` selects the Hypr host early (via `--host `, defaulting to `OMARCHY_HOST` or `desktop`), and the stow phase creates the `host` symlink. - `dot doctor` checks the host link and flags any leftover legacy `omarchy-hypr` clone at `~/.config/hypr`. -- Shared Hyprland-loaded config files wrap host override `source = ~/.config/hypr/host/*.conf` lines in Hyprland's `hyprlang noerror` guard, so a missing host override during stow, update, or migration does not leave Hyprland in an error loop. +- Both host monitor overrides detect common virtual machines through DMI data and use unscaled toolkit and fallback monitor settings; bare-metal hosts keep their normal HiDPI scale. -Shared Hypr autostart lives in `~/.config/hypr/autostart.conf` and runs on every host before the selected host override is sourced. Host-only services stay in `~/.config/hypr/host/autostart.conf`. KDE Connect is shared, so `kdeconnect-indicator` starts on both desktop and laptop sessions. +Shared Hypr autostart lives in `~/.config/hypr/autostart.lua` and runs on every host before the selected host override is loaded. Host-only services stay in `~/.config/hypr/host/autostart.lua`. KDE Connect is shared, so `kdeconnect-indicator` starts on both desktop and laptop sessions. :::caution[Retired omarchy-hypr clone] A machine with the retired `~/.config/hypr` `omarchy-hypr` clone halts `dot update` until the clone is backed up and re-stowed. Hyprland config is a stowed package, not a cloned repo. diff --git a/docs/src/content/docs/omarchy/index.mdx b/docs/src/content/docs/omarchy/index.mdx index ba9cf28b..965d66e1 100644 --- a/docs/src/content/docs/omarchy/index.mdx +++ b/docs/src/content/docs/omarchy/index.mdx @@ -12,19 +12,24 @@ These dotfiles run on [Omarchy](https://omarchy.org). Some Omarchy components ar + ## Managed Omarchy repos -- `waybar` and `uwsm` are single-branch Omarchy repos expected on `main`. +- `uwsm` is a single-branch Omarchy repo expected on `main`. - `bootstrap` is expected on `distro/omarchy`. - `dot update` syncs these by default; `dot doctor` verifies their worktree branches. ## Hyprland -Hyprland config is **not** a tracked Omarchy repo. It is a stowed dotfiles package (`hypr/.config/hypr/`, conf-only) laid down with `--no-folding`, with host-specific overrides selected by a runtime symlink. See [Host Overrides](/omarchy/host-overrides/). +Hyprland config is **not** a tracked Omarchy repo. It is a stowed dotfiles package (`hypr/.config/hypr/`) laid down with `--no-folding`, with host-specific Lua overrides selected by a runtime symlink. See [Host Overrides](/omarchy/host-overrides/). ## Ghostty Ghostty config is also stowed from `ghostty/.config/ghostty/`. The stowed launcher loads `config.$OMARCHY_HOST` when a host override exists. + +## Shell + +Omarchy 4 runs a single [Quickshell](https://quickshell.outfoxxed.me) process (`omarchy-shell`) for the bar, notifications, OSD, launcher, and settings. These dotfiles extend it through a generated `shell.json` and a few custom bar plugins rather than forking it. See [Shell (Quickshell)](/omarchy/shell/). diff --git a/docs/src/content/docs/omarchy/shell.md b/docs/src/content/docs/omarchy/shell.md new file mode 100644 index 00000000..5e2cc046 --- /dev/null +++ b/docs/src/content/docs/omarchy/shell.md @@ -0,0 +1,74 @@ +--- +title: Shell (Quickshell) +description: The Omarchy 4 Quickshell shell, its generated shell.json, and the custom bar plugins. +--- + +Omarchy 4 replaces Waybar with a single long-running [Quickshell](https://quickshell.outfoxxed.me) process, `omarchy-shell`. That one process hosts the top bar, the notification daemon, the on-screen display, the launcher, and the settings panel. Restarting "the shell" restarts all of them together. + +These dotfiles do not fork the shell. They extend it in two supported ways: a generated `shell.json` that lays out the bar, and a small set of user plugins that the bar loads as extra widgets. + +## Source of truth + +Two things drive the bar, and neither is hand-edited live: + +- **`~/.config/omarchy/shell.json`** is generated, not stowed. `dot` renders it from Omarchy's shipped default and inserts the personal modules. The generator is `dot/src/lib/omarchyShellConfig.ts` (`mergeOmarchyShellConfig`). The live file is mode `0600` and tracked by neither dotfiles repo. +- **Bar plugins** live under `omarchy/.config/omarchy/plugins//` in this repo and stow to `~/.config/omarchy/plugins//`. Each plugin is a `manifest.json` plus an entry-point QML file. + +To change the bar, edit the generator (then rebuild `dot`) or edit a plugin's QML, never the live `shell.json`. + +:::caution[Omarchy's shell source is read-only] +The shell itself lives in `~/.local/share/omarchy/shell/`. Reading it is useful (the `BarWidget` / `WidgetButton` base classes live there), but edits are lost on `omarchy update`. Customisation belongs in plugins and the generated config. +::: + +## Generated `shell.json` + +`dot stow` regenerates `shell.json` for the active [host](/omarchy/host-overrides/), starting from Omarchy's default and adding personal modules around the stock ones ("add, not remove"). The merge is idempotent: it only rewrites the file when the rendered content changes. + +Per-host differences: + +- **Bar position**: `bottom` on `laptop`, `top` on every other host. +- **Idle timers**: screensaver at 30 minutes, lock at 60 minutes. +- **Home Assistant sensors**: temperature, CO2, doorbell, and VOC entities differ per host (desktop vs laptop). + +Layout changes applied on top of the default bar: + +- **Left**: Omarchy's persistent workspaces widget is swapped for `timmo.workspaces`, then a calendar module is appended. +- **Centre**: the clock stays as the centre anchor (the stock config gear only renders next to a centred clock), the weather is pulled out, personal status widgets are inserted before the system-update group, and the doorbell trigger goes last. Centre widgets get `revealOnHover`, so a class-hidden module fades in dimmed when the centre cluster is hovered. +- **Right**: the weather is pinned to the start of the right column, followed by the Home Assistant sensors, before the default tray cluster. + +The personal status widgets read from `dot` bar output and Home Assistant. See [Bar Integrations](/bar-integrations/) for the `--bar-json` commands behind the git, workflow, and notification cells. + +## Custom plugins + +A plugin is a folder with `manifest.json` (schema version 1, an `id` like `timmo.`, its `kinds`, and entry-point QML) plus the QML itself. A bar widget extends `BarWidget`, reads per-instance settings from `shell.json` via `setting(name, fallback)`, and uses `WidgetButton` for clickable cells. + +| Plugin | Kind | What it does | +| --- | --- | --- | +| `timmo.command` | bar-widget | Runs a shell command on an interval and renders its status-bar JSON (`text` / `tooltip` / `class`). The Waybar `custom/*` equivalent. | +| `timmo.stream-command` | bar-widget | Runs a long-running command that streams status-bar JSON lines and renders the latest line (for watchers like `ha-watch-singleton`). | +| `timmo.workspaces` | bar-widget | Workspace numbers without persistent workspaces: only existing workspaces show, the focused one at full opacity and the rest dimmed. | + +`timmo.command` and `timmo.stream-command` both support `classColors` (class-name to colour), `hideClasses`, `onClick` / `onClickRight`, and `revealOnHover`, so the generator can style and wire every cell without bespoke QML per module. + +:::note[New plugins need a stow] +`~/.config/omarchy/plugins/` is a real directory with per-plugin symlinks. A brand-new plugin needs `dot stow` to create its symlink before the shell sees it; editing an existing plugin's files is already live. +::: + +## Reloading the shell + +| Change | Action | +| --- | --- | +| `shell.json` layout or settings, existing modules only | Hot-reloads on save, nothing to run | +| New plugin added | `omarchy plugin rescan`, then the hot-reload picks it up | +| Plugin or shell QML edited | `omarchy restart shell` (full restart) | + +`dot update` bakes this in: it regenerates `shell.json` and reloads the running shell **only when the rendered config changed**. A standalone `dot stow` regenerates the file but does not reload. + +:::caution[Force Wayland on restart] +`omarchy restart shell` inherits the caller's environment. If `QT_QPA_PLATFORM=xcb`, Quickshell starts under XWayland, the layer-shell surface cannot attach, and the bar renders as a floating window with no error. Interactive shells here set `QT_QPA_PLATFORM="wayland;xcb"`, and `dot update` forces `QT_QPA_PLATFORM=wayland` on its reload. From any non-interactive context (SSH, systemd, an agent shell), force Wayland explicitly: + +```bash +QT_QPA_PLATFORM=wayland omarchy restart shell +``` + +::: diff --git a/dot/AGENTS.md b/dot/AGENTS.md index 071b9b61..d2463293 100644 --- a/dot/AGENTS.md +++ b/dot/AGENTS.md @@ -78,7 +78,7 @@ src/ GitHub.ts — Shared GitHub CLI/API wrapper with rate-limit checks and retries GitNotifications.ts — GitHub notification inbox state and thread actions GitStaging.ts — Git status/add/commit operations - RepoWatcher.ts — Hybrid poll loop (Waybar cache → 10s poll), PubSub state + RepoWatcher.ts — Hybrid poll loop (initial poll → 10s poll), PubSub state relativeTime.ts — Shared compact relative timestamp formatter WorkflowRuns.ts — Watched GitHub Actions run state for locally checked-out HEAD commits workflowStatus.ts — Shared GitHub Actions status classification helpers @@ -98,7 +98,6 @@ src/ OutputLog.ts — Scrollable output log service Renderer.ts — OpenTUI renderer service Toast.ts — Toast notification overlay service - WaybarCache.ts — Waybar cache JSON reader for fast startup tui/ App.ts — Top-level app shell, view stack, global keyboard, action routing MainMenu.ts — MenuList menu built from menu registry @@ -132,7 +131,7 @@ src/ 4. `App` manages a view stack (main menu ↔ diff view ↔ git log view ↔ workflows view ↔ notifications view ↔ omarchy menu) 5. Menu items have typed actions: `command` (suspend/resume), `silent` (background), `notify` (background + toast), `view` (navigate), `submenu` (nested) 6. `CommandRunner` handles suspend/resume for terminal commands, silent background execution, and notify-style commands with toast feedback -7. `RepoWatcher` loads Waybar cache for instant diff first paint, then polls every 10s +7. `RepoWatcher` runs an initial poll for first paint, then polls every 10s ### Menu Registry @@ -157,7 +156,7 @@ MenuItem action types: - **Services**: `Context.Service` + static `layer` property for Effect services - **Static layers**: Each service class exposes `ServiceName.layer` (not a separate `*Live` export). Layer is built with `Layer.effect(ServiceName, Effect.gen(...))` -- **Domain errors**: `Schema.TaggedErrorClass` per service (`DotDiffError`, `GitStagingError`). WaybarCache has no error type +- **Domain errors**: `Schema.TaggedErrorClass` per service (`DotDiffError`, `GitStagingError`) - **Error handling**: `Effect.catch` (v4 rename of `catchAll`) for recovery; tagged errors flow through the type channel - **Named spans**: `Effect.fn("Name")` for effectful functions with arguments; `Effect.gen` + `Effect.withSpan("Name")` for zero-arg named effects (since `Effect.fn` returns a function, not an Effect) - **Testable time**: `Clock.currentTimeMillis` for timestamps instead of `new Date()` @@ -281,11 +280,10 @@ After that bootstrap build, run the checked-out binary directly. If private dotf ## External Dependencies -- `~/.cache/waybar/git-diff-waybar.json` — Waybar cache for fast startup - `NOTES` / `DOT_NOTES_DIR` — notes vault used by the standalone `notes` CLI/MCP server and OpenCode note commands - `DOT_USAGE_DIR` — usage event root for `dot usage` (default `$XDG_STATE_HOME/tool-usage`). `DOT_USAGE_DISABLE` disables live dot recording - `~/.config/dotfiles-private/dot-git.yml` — private git repo config for clone/bootstrap, doctor checks, `dot git-diff`, `dot git-log`, `dot git-workflows`, and `dot git-notifications --bar-json`; `activity`, `workflows`, and `notifications` each require explicit `enabled` plus 5-field cron `schedule` keys, and `notifications.bar.ignore_bot_activity` controls status-bar bot noise -- `gh` authenticated with a classic token carrying `notifications` or `repo` scope — required for `dot git-notifications` and its Waybar module +- `gh` authenticated with a classic token carrying `notifications` or `repo` scope — required for `dot git-notifications` and its status-bar module - `lazygit` — launched via suspend/resume on Enter in diff view - `opencode` — CLI launched via suspend/resume for interactive sessions from the diff view - `omarchy` — various subcommands for desktop management @@ -332,7 +330,7 @@ dot init --help # init help prints without side effects dot help # help prints ``` -`dot init` clones the managed Omarchy repos into `~/.config/{bootstrap,waybar,uwsm}`. If a stock Omarchy config directory already exists there and is not a git repo, init moves it aside with a `.dot-init-backup-*` suffix before cloning; do not delete those backups automatically. Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`, conf-only) laid down with `--no-folding`, with the runtime `~/.config/hypr/host` symlink selecting the host overrides. Ghostty config is also stowed from `ghostty/.config/ghostty/`; `dot stow` backs up the retired `timmo001/omarchy-ghostty` clone before linking the stowed config. +`dot init` clones the managed Omarchy repos into `~/.config/{bootstrap,uwsm}`. If a stock Omarchy config directory already exists there and is not a git repo, init moves it aside with a `.dot-init-backup-*` suffix before cloning; do not delete those backups automatically. Hyprland config is a stowed dotfiles package (`hypr/.config/hypr/`) laid down with `--no-folding`, with the runtime `~/.config/hypr/host` symlink selecting the host overrides. Ghostty config is also stowed from `ghostty/.config/ghostty/`; `dot stow` backs up the retired `timmo001/omarchy-ghostty` clone before linking the stowed config. ## Logging Style diff --git a/dot/src/cli/spec.ts b/dot/src/cli/spec.ts index ab1b9ca1..f4bc28d1 100644 --- a/dot/src/cli/spec.ts +++ b/dot/src/cli/spec.ts @@ -296,8 +296,8 @@ export const cliCommands: readonly CliCommandSpec[] = [ "Stow integrity Dry-run restow to detect drift", "OpenCode location Canonical paths, legacy remnants", "Git config Managed include is active", - "Workflow runs Repo list, status bar config, legacy watcher cleanup", - "Git notifications API scope and status bar notification module wiring", + "Workflow runs Repo list and legacy watcher cleanup", + "Git notifications API scope and notification access", "Doctor startup Startup notification timer", "Daily volume reset Laptop-only optional timer", "Omarchy repos Diff repos + worktree branch correctness", diff --git a/dot/src/commands/Init.ts b/dot/src/commands/Init.ts index 13c77c49..2d1a8386 100644 --- a/dot/src/commands/Init.ts +++ b/dot/src/commands/Init.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Duration, Effect, Schema } from "effect"; import { existsSync, lstatSync, @@ -10,6 +10,7 @@ import { basename, join } from "path"; import { Config } from "../services/Config.js"; import { CommandExecutor } from "../services/CommandExecutor.js"; import { OutputLog } from "../services/OutputLog.js"; +import { Launcher } from "../services/Launcher.js"; import { agentsSync } from "./AgentsSync.js"; import { install } from "./Install.js"; import { setupPrivateRepo } from "./SetupPrivateRepo.js"; @@ -44,9 +45,11 @@ import type { ConfigService } from "../services/Config.js"; const GIT_INCLUDE_PATH = "~/.config/git/config.dotfiles"; const DOCTOR_STARTUP_TIMER_UNIT = "dot-doctor-startup.timer"; +const RESUME_MONITOR_SERVICE_UNIT = "dot-on-resume-monitor.service"; const DEFAULT_INIT_OMARCHY_HOST = "desktop"; const INIT_OMARCHY_HOSTS = ["desktop", "laptop"] as const; const ETC_SHELLS = "/etc/shells"; +const OMARCHY_HOST_PERSIST_TIMEOUT_SECONDS = 10; /** Upper bound (seconds) for each init phase. */ const INIT_STEP_TIMEOUT_SECONDS = { @@ -413,10 +416,53 @@ function resolveInitOptions( }); } +function persistOmarchyHostEnv( + host: string, +): Effect.Effect { + return Effect.gen(function* () { + const executor = yield* CommandExecutor; + const log = yield* OutputLog; + const file = "/etc/environment"; + const line = `OMARCHY_HOST=${host}`; + // Idempotent: no-op when already correct, otherwise replace any existing + // OMARCHY_HOST line or append one. pam_env reads /etc/environment at login, + // so the value reaches the graphical session and every terminal. + const script = [ + `grep -qx '${line}' ${file} && exit 0`, + `if grep -q '^OMARCHY_HOST=' ${file}; then`, + ` sed -i 's|^OMARCHY_HOST=.*|${line}|' ${file}`, + `else`, + ` printf '%s\\n' '${line}' >> ${file}`, + `fi`, + ].join("\n"); + + const persistCommand = + process.getuid?.() === 0 + ? (["bash", ["-c", script]] as const) + : (yield* executor.exitCode("which", ["pkexec"])) === 0 + ? (["pkexec", ["bash", "-c", script]] as const) + : (["sudo", ["-n", "bash", "-c", script]] as const); + + const exitCode = yield* executor + .exitCode(persistCommand[0], persistCommand[1]) + .pipe( + Effect.timeout(Duration.seconds(OMARCHY_HOST_PERSIST_TIMEOUT_SECONDS)), + Effect.catch(() => Effect.succeed(1)), + ); + if (exitCode === 0) { + yield* log.info(`Persisted ${line} to ${file}`); + } else { + yield* log.warn( + `Could not persist OMARCHY_HOST to ${file} (exit ${exitCode}); set it manually`, + ); + } + }); +} + function ensureInitHyprHostLink( config: ConfigService, options: InitOptions, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const log = yield* OutputLog; if (!config.omarchy.enabled) return; @@ -424,16 +470,36 @@ function ensureInitHyprHostLink( const host = initOmarchyHost(options); yield* log.section("Omarchy Host Links"); - // Select the host now so host-suffixed stow packages and the Hypr host - // link resolve correctly during the stow phase. + // Validate the requested host against the stowed package source so a typo + // fails fast, even on a fresh machine before hypr is stowed. + const sourceHostDir = join( + config.publicDotfiles, + "hypr", + ".config", + "hypr", + "hosts", + host, + ); + if (!existsSync(sourceHostDir)) { + return yield* fail( + `Unknown Hypr host '${host}': missing ${displayPath(sourceHostDir)}. Pass --host with a configured host.`, + ); + } + + // Select the host now so host-suffixed stow packages and the Hypr host link + // resolve correctly during the stow phase. setEnv(ENV.OMARCHY_HOST, host); - const hostDir = join(hyprRepoPath(config), "hosts", host); - if (!existsSync(hostDir)) { - // Hypr config is now a stowed dotfiles package; the hosts directory and - // host link are created during the install/stow phase. + // Persist the host for future login sessions so terminals, status scripts, + // and dot doctor see OMARCHY_HOST without a transient init env. + yield* persistOmarchyHostEnv(host); + + // The live host directory only exists once hypr is stowed; when it is not + // there yet, the stow phase creates the host link after stowing. + const liveHostDir = join(hyprRepoPath(config), "hosts", host); + if (!existsSync(liveHostDir)) { yield* log.info( - `Hypr host config ${displayPath(hostDir)} not present yet; stow will create the host link`, + `Hypr host '${host}' selected; host link will be created during stow`, ); return; } @@ -544,24 +610,18 @@ function runUserSystemctl( }); } -function enableDoctorStartupTimer(): Effect.Effect< - void, - InitError, - CommandExecutor | OutputLog -> { +function enableUserUnit( + unit: string, + sectionTitle: string, +): Effect.Effect { return Effect.gen(function* () { const executor = yield* CommandExecutor; const log = yield* OutputLog; - const unitPath = join( - CONFIG_DIR, - "systemd", - "user", - DOCTOR_STARTUP_TIMER_UNIT, - ); + const unitPath = join(CONFIG_DIR, "systemd", "user", unit); - yield* log.section("Enable Doctor Startup Timer"); + yield* log.section(sectionTitle); if ((yield* executor.exitCode("which", ["systemctl"])) !== 0) { - yield* log.warn("Skipping doctor startup timer (systemctl not found)"); + yield* log.warn(`Skipping ${unit} (systemctl not found)`); return; } @@ -570,8 +630,8 @@ function enableDoctorStartupTimer(): Effect.Effect< } yield* runUserSystemctl(["daemon-reload"]); - yield* runUserSystemctl(["enable", "--now", DOCTOR_STARTUP_TIMER_UNIT]); - yield* log.info(`Enabled ${DOCTOR_STARTUP_TIMER_UNIT}`); + yield* runUserSystemctl(["enable", "--now", unit]); + yield* log.info(`Enabled ${unit}`); }); } @@ -733,7 +793,13 @@ function ensureLoginShellZsh(): Effect.Effect< } /** Run the one-time first-use setup workflow for a fresh machine. */ -export function init(rawArgs: readonly string[]) { +export function init( + rawArgs: readonly string[], +): Effect.Effect< + void, + unknown, + Config | CommandExecutor | OutputLog | Launcher +> { return Effect.gen(function* () { const config = yield* Config; const log = yield* OutputLog; @@ -827,7 +893,12 @@ export function init(rawArgs: readonly string[]) { yield* requiredInitStep( "Enable Doctor Startup Timer", INIT_STEP_TIMEOUT_SECONDS.doctorTimer, - enableDoctorStartupTimer(), + enableUserUnit(DOCTOR_STARTUP_TIMER_UNIT, "Enable Doctor Startup Timer"), + ); + yield* requiredInitStep( + "Enable Resume Monitor", + INIT_STEP_TIMEOUT_SECONDS.doctorTimer, + enableUserUnit(RESUME_MONITOR_SERVICE_UNIT, "Enable Resume Monitor"), ); yield* requiredInitStep( "Sync Agents", diff --git a/dot/src/commands/Install.ts b/dot/src/commands/Install.ts index 254a976f..53dbab12 100644 --- a/dot/src/commands/Install.ts +++ b/dot/src/commands/Install.ts @@ -240,6 +240,8 @@ const stowRepo = ( if (isHypr) { yield* ensureHyprConfigLink(repoDir, log); } else { + // Unstow first (clean slate). Hyprland is excluded because removing + // hyprland.lua even briefly makes its live reload enter emergency mode. const unstowExit = yield* launcher.stream(`stow -D ${folder}`, { cwd: repoDir, }); @@ -269,6 +271,11 @@ const stowRepo = ( removeExternalSymlinks(externalLinks); } } + // Keep ~/.config/hypr a real directory so the runtime host symlink and + // Hyprland runtime state live in the live tree, not the stow source. + if (folder === "hypr") { + flags.push("--no-folding"); + } // Install mode uses --adopt for public scope if (scope === "public") { @@ -289,5 +296,13 @@ const stowRepo = ( exitCode: exit, }); } + + // Apply any added or changed config and clear any prior emergency state. + // Ignore failure: Hyprland may not be running (fresh install, headless). + if (isHypr) { + yield* launcher + .stream("hyprctl reload", { cwd: repoDir }) + .pipe(Effect.catch(() => Effect.void)); + } } }); diff --git a/dot/src/commands/Stow.ts b/dot/src/commands/Stow.ts index af4017ce..cc9de014 100644 --- a/dot/src/commands/Stow.ts +++ b/dot/src/commands/Stow.ts @@ -15,6 +15,7 @@ import { ensureHyprHostLink, } from "../lib/omarchyHost.js"; import { ensureNvimThemeLink } from "../lib/omarchyNvim.js"; +import { applyOmarchyShellConfig } from "../lib/omarchyShellConfig.js"; import { backupUnmanagedStowTargets, backupLegacyGhosttyRepo, @@ -39,6 +40,10 @@ const AGENTS_PRIVATE_IGNORES = [ * * Matches legacy behaviour: enumerates stow package directories, logs each one, * and applies per-folder stow with appropriate flags. + * + * @returns `true` when the generated Omarchy `shell.json` changed during this + * run, so the caller can reload the running shell. Always `false` for + * private-only runs or when the shell config step is skipped. */ export const stow = (opts?: { readonly publicOnly?: boolean; @@ -52,6 +57,8 @@ export const stow = (opts?: { const runPublic = !opts?.privateOnly; const runPrivate = !opts?.publicOnly; + let shellConfigChanged = false; + if (runPublic) { yield* log.section("Stow Public Dotfiles"); const legacyGhosttyMove = yield* Effect.sync(() => @@ -77,6 +84,9 @@ export const stow = (opts?: { yield* log.section("Omarchy Neovim Theme"); yield* ensureNvimThemeLink(log); + + yield* log.section("Omarchy Shell Config"); + shellConfigChanged = yield* applyOmarchyShellConfig; } if (runPrivate) { @@ -98,6 +108,8 @@ export const stow = (opts?: { ); } } + + return shellConfigChanged; }); /** Stow all folders in a single repo */ @@ -135,10 +147,11 @@ const stowRepo = ( const isHypr = folder === "hypr"; if (isHypr) { - // Never unstow hypr: Hyprland autoreload regenerates a default stub the - // instant hyprland.conf goes missing, and that stub real file then - // blocks the restow. Repair the link atomically instead and let the - // idempotent stow below fill in any missing files with no gap. + // Never unstow hypr: Hyprland watches its live config and auto-reloads + // on change. Removing the symlinks (even briefly) drops Hyprland into + // emergency mode, and on a .conf config it regenerates a stub real file + // that then blocks the restow. Repair the link atomically instead and + // let the idempotent stow below fill in any missing files with no gap. yield* ensureHyprConfigLink(repoDir, log); } else { // Unstow first, then restow (equivalent to --restow per folder) diff --git a/dot/src/commands/Update.ts b/dot/src/commands/Update.ts index 48f97689..c0e16148 100644 --- a/dot/src/commands/Update.ts +++ b/dot/src/commands/Update.ts @@ -334,6 +334,51 @@ const runResumeRefresh = Effect.gen(function* () { yield* log.info("On-resume helper started"); }); +/** + * Reload the running Omarchy shell after its generated `shell.json` changed. + * + * Rescans plugins (to register any new bar widgets) then restarts the shell — + * the only reliable way to pick up new or changed plugin QML and layout + * changes, since a live config reload does not reload cached plugin QML. Runs + * `omarchy` via the dispatcher and forces `QT_QPA_PLATFORM=wayland` on the + * restart so the relaunched shell attaches its layer-shell bar even when + * `dot update` is triggered from an environment that defaults to `xcb` (an SSH + * session, a systemd unit, or an agent shell); launching the shell under XCB + * silently drops the bar. `omarchy restart shell` refuses while the session is + * locked, which is treated as a non-fatal skip. No-op when Omarchy is disabled. + */ +const reloadOmarchyShell = Effect.gen(function* () { + const config = yield* Config; + const log = yield* OutputLog; + const executor = yield* CommandExecutor; + + if (!config.omarchy.enabled) return; + + yield* log.section("Reload Shell"); + + yield* executor.exitCode("omarchy", ["plugin", "rescan"]); + + const exitCode = yield* executor.exitCode("omarchy", ["restart", "shell"], { + env: { QT_QPA_PLATFORM: "wayland" }, + }); + + if (exitCode !== 0) { + yield* log.warn( + `Shell reload skipped or failed (exit ${exitCode}; session may be locked)`, + ); + return; + } + + yield* log.info("Reloaded Omarchy shell (shell.json changed)"); +}); + +/** Reload the Omarchy shell only when stow rewrote its generated config. */ +export function reloadOmarchyShellIfChanged( + shellConfigChanged: boolean, +): Effect.Effect { + return shellConfigChanged ? reloadOmarchyShell : Effect.void; +} + /** Exit code from `dot update --check` when in-scope updates are available. */ export const UPDATE_CHECK_AVAILABLE_EXIT = 10; @@ -604,6 +649,7 @@ export const update = (opts?: UpdateOptions) => ); } + let shellConfigChanged = false; if (doStow) { yield* requiredUpdateStep( "Stow", @@ -622,11 +668,13 @@ export const update = (opts?: UpdateOptions) => } yield* mcpSync; - yield* runStow(); + shellConfigChanged = yield* runStow(); }), ); } + yield* reloadOmarchyShellIfChanged(shellConfigChanged); + if (doTui) { yield* requiredUpdateStep( "Rebuild", diff --git a/dot/src/doctor/checks/omarchy.ts b/dot/src/doctor/checks/omarchy.ts index 8fa098c2..f127334a 100644 --- a/dot/src/doctor/checks/omarchy.ts +++ b/dot/src/doctor/checks/omarchy.ts @@ -18,8 +18,6 @@ function omarchyRepoSlug(repoName: string): string | null { switch (repoName) { case "bootstrap": return "timmo001/bootstrap"; - case "waybar": - return "timmo001/omarchy-waybar"; case "uwsm": return "timmo001/omarchy-uwsm"; default: diff --git a/dot/src/doctor/checks/systemd.ts b/dot/src/doctor/checks/systemd.ts index 5f59baa0..590d9933 100644 --- a/dot/src/doctor/checks/systemd.ts +++ b/dot/src/doctor/checks/systemd.ts @@ -1,6 +1,6 @@ import { Effect } from "effect"; import { accessSync, constants, existsSync, lstatSync, readFileSync } from "fs"; -import { join, dirname, resolve } from "path"; +import { join, resolve } from "path"; import { CommandExecutor, type CommandExecutorService, @@ -9,7 +9,6 @@ import { Config } from "../../services/Config.js"; import type { ConfigService } from "../../services/Config.js"; import { GitHub } from "../../git/services/GitHub.js"; import { CONFIG_DIR, HOME_DIR, displayPath } from "../../lib/paths.js"; -import { ENV, envString } from "../../lib/env.js"; import { resolvedOmarchyHost } from "../../lib/omarchyHost.js"; import type { CheckResult } from "../types.js"; @@ -20,6 +19,23 @@ const DOCTOR_STARTUP_TIMER_UNIT = "dot-doctor-startup.timer"; const DAILY_VOLUME_ZERO_TIMER_UNIT = "daily-volume-zero.timer"; const LOCAL_BIN_DIR = join(HOME_DIR, ".local", "bin"); const UWSM_ENV_FILE = join(CONFIG_DIR, "uwsm", "env"); +const RESUME_MONITOR_SERVICE_UNIT = "dot-on-resume-monitor.service"; +const DOCTOR_STARTUP_NOTIFY_SCRIPT = join( + HOME_DIR, + ".local", + "bin", + "dot-doctor-notify", +); +const RESUME_MONITOR_SCRIPT = join( + HOME_DIR, + ".local", + "bin", + "on-resume-monitor", +); + +function userSystemdUnitPath(unit: string): string { + return join(CONFIG_DIR, "systemd", "user", unit); +} function pathExistsOrSymlink(path: string): boolean { try { @@ -66,6 +82,131 @@ function addObsoletePathCheck( } } +function addExecutablePresenceCheck( + results: CheckResult[], + path: string, + okMessage: string, + warnMessage: string, + detail?: string, +): void { + results.push( + executableExists(path) + ? { severity: "ok", message: okMessage } + : { + severity: "warn", + message: warnMessage, + ...(detail && { detail }), + }, + ); +} + +function addFilePresenceCheck( + results: CheckResult[], + path: string, + okMessage: string, + warnMessage: string, + detail: string, +): void { + results.push( + existsSync(path) + ? { severity: "ok", message: okMessage } + : { severity: "warn", message: warnMessage, detail }, + ); +} + +const checkRequiredUserUnit = ( + results: CheckResult[], + executor: CommandExecutorService, + unit: string, + label: string, + enableDetail: string, +) => + Effect.gen(function* () { + const hasSystemctl = + (yield* executor.exitCode("which", ["systemctl"])) === 0; + if (!hasSystemctl) { + results.push({ + severity: "warn", + message: `Skipping ${label.toLowerCase()} checks (systemctl not found)`, + }); + return; + } + + const enabled = yield* executor.exitCode("systemctl", [ + "--user", + "is-enabled", + unit, + ]); + if (enabled === 0) { + results.push({ severity: "ok", message: `${label} enabled: ${unit}` }); + } else { + results.push({ + severity: "warn", + message: `${label} is disabled: ${unit}`, + detail: enableDetail, + }); + } + + const active = yield* executor.exitCode("systemctl", [ + "--user", + "is-active", + unit, + ]); + if (active === 0) { + results.push({ severity: "ok", message: `${label} active: ${unit}` }); + } else { + results.push({ + severity: "warn", + message: `${label} is not active: ${unit}`, + detail: enableDetail, + }); + } + }); + +interface RequiredUserUnitSetup { + readonly scriptPath: string; + readonly scriptOkMessage: string; + readonly scriptWarnMessage: string; + readonly scriptDetail?: string; + readonly unitPath: string; + readonly unitOkMessage: string; + readonly unitWarnMessage: string; + readonly unitDetail: string; + readonly unit: string; + readonly unitLabel: string; +} + +const checkRequiredUserUnitSetup = (setup: RequiredUserUnitSetup) => + Effect.gen(function* () { + const executor = yield* CommandExecutor; + const results: CheckResult[] = []; + const enableDetail = `Enable with: systemctl --user enable --now ${setup.unit}`; + + addExecutablePresenceCheck( + results, + setup.scriptPath, + setup.scriptOkMessage, + setup.scriptWarnMessage, + setup.scriptDetail, + ); + addFilePresenceCheck( + results, + setup.unitPath, + setup.unitOkMessage, + setup.unitWarnMessage, + setup.unitDetail, + ); + yield* checkRequiredUserUnit( + results, + executor, + setup.unit, + setup.unitLabel, + enableDetail, + ); + + return results; + }); + const checkObsoleteUserUnit = ( results: CheckResult[], executor: CommandExecutorService, @@ -110,129 +251,6 @@ const checkObsoleteUserUnit = ( } }); -// --------------------------------------------------------------------------- -// Waybar config walk helpers (matches legacy _waybar_config_walk pattern) -// --------------------------------------------------------------------------- - -/** Walk a Waybar config and its includes, returning true if any file contains the needle */ -function waybarConfigWalkContains(configPath: string, needle: string): boolean { - if (!existsSync(configPath)) return false; - try { - const content = readFileSync(configPath, "utf-8"); - if (content.includes(needle)) return true; - // Check includes - for (const includePath of parseWaybarIncludes(configPath, content)) { - if (waybarConfigWalkContains(includePath, needle)) return true; - } - } catch { - /* ignore */ - } - return false; -} - -/** Parse "include" array entries from a Waybar JSONC config file */ -function parseWaybarIncludes( - configPath: string, - content: string, -): readonly string[] { - const match = content.match(/"include"\s*:\s*\[([^\]]*)\]/); - if (!match) return []; - const configDir = dirname(configPath); - return match[1] - .split(",") - .map((e) => e.trim().replace(/^"|"$/g, "")) - .filter(Boolean) - .map((e) => { - const expanded = e.replace(/^~/, HOME_DIR); - return expanded.startsWith("/") ? expanded : join(configDir, expanded); - }); -} - -function activeWaybarConfigPath(config: ConfigService): string { - const omarchyHost = resolvedOmarchyHost(config) ?? ""; - const waybarConfigDir = join(CONFIG_DIR, "waybar"); - const hostConfig = omarchyHost - ? join(waybarConfigDir, `config.${omarchyHost}.jsonc`) - : ""; - return hostConfig && existsSync(hostConfig) - ? hostConfig - : join(waybarConfigDir, "config.jsonc"); -} - -function addWaybarScriptCheck( - results: CheckResult[], - scriptName: string, - label: string, - missingDetail: string, -): void { - const waybarScript = join(CONFIG_DIR, "waybar", "scripts", scriptName); - results.push( - executableExists(waybarScript) - ? { - severity: "ok", - message: `${label} Waybar script is executable: ${displayPath(waybarScript)}`, - } - : { - severity: "warn", - message: `${label} Waybar script is missing or not executable: ${displayPath(waybarScript)}`, - detail: missingDetail, - }, - ); -} - -function addWaybarHiddenCssCheck( - results: CheckResult[], - selector: string, - label: string, - missingDetail: string, -): void { - const waybarStyle = join(CONFIG_DIR, "waybar", "style.css"); - if (!existsSync(waybarStyle)) { - results.push({ - severity: "warn", - message: `Waybar style file is missing: ${displayPath(waybarStyle)}`, - }); - return; - } - - try { - const styleContent = readFileSync(waybarStyle, "utf-8"); - results.push( - styleContent.includes(selector) - ? { - severity: "ok", - message: `${label} Waybar hidden-empty CSS found: ${displayPath(waybarStyle)}`, - } - : { - severity: "warn", - message: `${label} Waybar hidden-empty CSS is missing: ${displayPath(waybarStyle)}`, - detail: missingDetail, - }, - ); - } catch { - /* ignore */ - } -} - -function addWaybarConfigContainsCheck( - results: CheckResult[], - waybarConfig: string, - needle: string, - okMessage: string, - warnMessage: string, - detail?: string, -): void { - if (waybarConfigWalkContains(waybarConfig, needle)) { - results.push({ severity: "ok", message: okMessage }); - } else { - results.push({ - severity: "warn", - message: warnMessage, - ...(detail && { detail }), - }); - } -} - /** Check workflow runs integration and absence of the legacy notification watcher. */ export const checkWorkflowRuns = Effect.gen(function* () { const executor = yield* CommandExecutor; @@ -348,55 +366,10 @@ export const checkWorkflowRuns = Effect.gen(function* () { }); } - addWaybarScriptCheck( - results, - "git-workflows-waybar.sh", - "Workflow runs", - "Stow or update the Waybar repo to install the workflow runs module script", - ); - addWaybarHiddenCssCheck( - results, - "#custom-git-workflows.hidden", - "Workflow runs", - "Update the Waybar style so the workflow icon hides when there are no recent runs needing attention", - ); - - const waybarConfig = activeWaybarConfigPath(config); - - if (existsSync(waybarConfig)) { - results.push({ - severity: "ok", - message: `Workflow runs active Waybar config: ${displayPath(waybarConfig)}`, - }); - - // Walk config and includes to check for module, click actions, and ordering - const configContains = (needle: string): boolean => - waybarConfigWalkContains(waybarConfig, needle); - - if (configContains("git-workflow-watch")) { - results.push({ - severity: "error", - message: `Active Waybar config still references obsolete git-workflow-watch: ${displayPath(waybarConfig)}`, - detail: - "Update/re-stow the Waybar config on this machine, or remove the legacy git-workflow-watch module/action references from the active Waybar config", - }); - } else { - results.push({ - severity: "ok", - message: "Active Waybar config has no legacy workflow-watch actions", - }); - } - } else { - results.push({ - severity: "warn", - message: `Active Waybar config is missing: ${displayPath(waybarConfig)}`, - }); - } - return results; }); -/** Check GitHub notifications API access and Waybar integration. */ +/** Check GitHub notifications API access. */ export const checkGitNotifications = Effect.gen(function* () { const github = yield* GitHub; const config = yield* Config; @@ -430,141 +403,35 @@ export const checkGitNotifications = Effect.gen(function* () { } } - addWaybarScriptCheck( - results, - "git-notifications-waybar.sh", - "Git notifications", - "Stow or update the Waybar repo to install the Git notifications module script", - ); - addWaybarHiddenCssCheck( - results, - "#custom-git-notifications.hidden", - "Git notifications", - "Update the Waybar style so the notification icon hides when the inbox is clear", - ); - - const waybarConfig = activeWaybarConfigPath(config); - - if (existsSync(waybarConfig)) { - results.push({ - severity: "ok", - message: `Git notifications active Waybar config: ${displayPath(waybarConfig)}`, - }); - - addWaybarConfigContainsCheck( - results, - waybarConfig, - '"custom/git-notifications"', - "Git notifications Waybar module is present in the active config", - `Git notifications Waybar module is missing from ${displayPath(waybarConfig)}`, - "Add custom/git-notifications before custom/git-diff in the active Waybar config", - ); - addWaybarConfigContainsCheck( - results, - waybarConfig, - '"on-click": "~/.config/waybar/scripts/git-notifications-waybar.sh open"', - "Git notifications Waybar left click opens the filtered TUI", - `Git notifications Waybar left-click action is missing in ${displayPath(waybarConfig)}`, - ); - addWaybarConfigContainsCheck( - results, - waybarConfig, - '"on-click-right": "~/.config/waybar/scripts/git-notifications-waybar.sh refresh"', - "Git notifications Waybar right click refreshes the cache", - `Git notifications Waybar right-click refresh action is missing in ${displayPath(waybarConfig)}`, - ); - } else { - results.push({ - severity: "warn", - message: `Active Waybar config is missing: ${displayPath(waybarConfig)}`, - }); - } - return results; }); /** Check doctor startup notification timer */ -export const checkDoctorStartup = Effect.gen(function* () { - const executor = yield* CommandExecutor; - const results: CheckResult[] = []; - - const notifyScript = join(HOME_DIR, ".local", "bin", "dot-doctor-notify"); - const unitPath = join( - CONFIG_DIR, - "systemd", - "user", - DOCTOR_STARTUP_TIMER_UNIT, - ); - - if (existsSync(notifyScript)) { - results.push({ - severity: "ok", - message: `Doctor startup notify script found: ${displayPath(notifyScript)}`, - }); - } else { - results.push({ - severity: "warn", - message: `Doctor startup notify script missing or not executable: ${displayPath(notifyScript)}`, - }); - } - - if (existsSync(unitPath)) { - results.push({ - severity: "ok", - message: `Doctor startup timer unit file found: ${displayPath(unitPath)}`, - }); - } else { - results.push({ - severity: "warn", - message: `Doctor startup timer unit file missing: ${displayPath(unitPath)}`, - detail: "Run dot stow (or dot install) to link systemd user units", - }); - } - - const hasSystemctl = (yield* executor.exitCode("which", ["systemctl"])) === 0; - if (hasSystemctl) { - const enabled = yield* executor.exitCode("systemctl", [ - "--user", - "is-enabled", - DOCTOR_STARTUP_TIMER_UNIT, - ]); - if (enabled === 0) { - results.push({ - severity: "ok", - message: `Doctor startup timer enabled: ${DOCTOR_STARTUP_TIMER_UNIT}`, - }); - } else { - results.push({ - severity: "warn", - message: `Doctor startup timer is disabled: ${DOCTOR_STARTUP_TIMER_UNIT}`, - detail: `Enable with: systemctl --user enable --now ${DOCTOR_STARTUP_TIMER_UNIT}`, - }); - } - - const active = yield* executor.exitCode("systemctl", [ - "--user", - "is-active", - DOCTOR_STARTUP_TIMER_UNIT, - ]); - if (active === 0) { - results.push({ - severity: "ok", - message: `Doctor startup timer active: ${DOCTOR_STARTUP_TIMER_UNIT}`, - }); - } else { - results.push({ - severity: "warn", - message: `Doctor startup timer is not active: ${DOCTOR_STARTUP_TIMER_UNIT}`, - }); - } - } else { - results.push({ - severity: "warn", - message: "Skipping doctor startup timer checks (systemctl not found)", - }); - } +export const checkDoctorStartup = checkRequiredUserUnitSetup({ + scriptPath: DOCTOR_STARTUP_NOTIFY_SCRIPT, + scriptOkMessage: `Doctor startup notify script found: ${displayPath(DOCTOR_STARTUP_NOTIFY_SCRIPT)}`, + scriptWarnMessage: `Doctor startup notify script missing or not executable: ${displayPath(DOCTOR_STARTUP_NOTIFY_SCRIPT)}`, + unitPath: userSystemdUnitPath(DOCTOR_STARTUP_TIMER_UNIT), + unitOkMessage: `Doctor startup timer unit file found: ${displayPath(userSystemdUnitPath(DOCTOR_STARTUP_TIMER_UNIT))}`, + unitWarnMessage: `Doctor startup timer unit file missing: ${displayPath(userSystemdUnitPath(DOCTOR_STARTUP_TIMER_UNIT))}`, + unitDetail: "Run dot stow (or dot install) to link systemd user units", + unit: DOCTOR_STARTUP_TIMER_UNIT, + unitLabel: "Doctor startup timer", +}); - return results; +/** Check resume recovery monitor service used after hypridle is removed. */ +export const checkResumeMonitor = checkRequiredUserUnitSetup({ + scriptPath: RESUME_MONITOR_SCRIPT, + scriptOkMessage: `Resume monitor script is executable: ${displayPath(RESUME_MONITOR_SCRIPT)}`, + scriptWarnMessage: `Resume monitor script is missing or not executable: ${displayPath(RESUME_MONITOR_SCRIPT)}`, + scriptDetail: + "Run dot stow (or dot install) to link the resume monitor script", + unitPath: userSystemdUnitPath(RESUME_MONITOR_SERVICE_UNIT), + unitOkMessage: `Resume monitor service unit file found: ${displayPath(userSystemdUnitPath(RESUME_MONITOR_SERVICE_UNIT))}`, + unitWarnMessage: `Resume monitor service unit file missing: ${displayPath(userSystemdUnitPath(RESUME_MONITOR_SERVICE_UNIT))}`, + unitDetail: "Run dot stow (or dot install) to link systemd user units", + unit: RESUME_MONITOR_SERVICE_UNIT, + unitLabel: "Resume monitor service", }); /** Check daily volume reset timer (laptop-only, informational) */ @@ -575,9 +442,8 @@ export const checkDailyVolumeReset = Effect.gen(function* () { const host = resolvedOmarchyHost(config) ?? "unset"; const script = join(HOME_DIR, ".local", "bin", "daily-volume-zero"); - const systemdDir = join(CONFIG_DIR, "systemd", "user"); - const serviceUnit = join(systemdDir, "daily-volume-zero.service"); - const timerUnit = join(systemdDir, DAILY_VOLUME_ZERO_TIMER_UNIT); + const serviceUnit = userSystemdUnitPath("daily-volume-zero.service"); + const timerUnit = userSystemdUnitPath(DAILY_VOLUME_ZERO_TIMER_UNIT); if (existsSync(script)) { results.push({ diff --git a/dot/src/doctor/runner.ts b/dot/src/doctor/runner.ts index ce91f665..380a9263 100644 --- a/dot/src/doctor/runner.ts +++ b/dot/src/doctor/runner.ts @@ -18,6 +18,7 @@ import { checkGitNotifications, checkWorkflowRuns, checkDoctorStartup, + checkResumeMonitor, checkDailyVolumeReset, checkLocalBinPath, } from "./checks/systemd.js"; @@ -75,6 +76,7 @@ const sections: readonly SectionDef[] = [ { name: "Git notification checks", check: checkGitNotifications }, { name: "Doctor startup notification", check: checkDoctorStartup }, { name: "uwsm session PATH", check: checkLocalBinPath }, + { name: "Resume recovery monitor", check: checkResumeMonitor }, { name: "Daily volume reset", check: checkDailyVolumeReset }, { name: "Omarchy repository checks", check: checkOmarchy }, { name: "Legacy Hypr repo check", check: checkLegacyHyprRepo }, diff --git a/dot/src/git/commands/Diff.ts b/dot/src/git/commands/Diff.ts index 78885363..8821cb5b 100644 --- a/dot/src/git/commands/Diff.ts +++ b/dot/src/git/commands/Diff.ts @@ -58,7 +58,9 @@ export const diffBarJson = (opts?: DiffScanOptions) => }, )).filter((repo): repo is DiffRepo => repo !== null); - const text = changed.length > 0 ? `\uF418 ${changed.length}` : ""; + // Always emit the icon and count so the widget shows "0" when everything + // is up to date, rather than collapsing to an empty (hidden) cell. + const text = `\uF418 ${changed.length}`; const tooltip = changed.length > 0 ? `Repositories with changes pending: ${changed.map((r) => r.name).join("; ")}` diff --git a/dot/src/git/commands/Notifications.ts b/dot/src/git/commands/Notifications.ts index 7830fb63..3e5e631b 100644 --- a/dot/src/git/commands/Notifications.ts +++ b/dot/src/git/commands/Notifications.ts @@ -137,7 +137,8 @@ function notificationBarText( summary: ReturnType, ): string { if (state.message) return "\uf071 ?"; - if (summary.unreadCount === 0) return ""; + // Always emit the count (including "0") so the bar widget has an icon to + // reveal dimmed on hover; the "hidden" class still collapses it when clear. return `\uf0f3 ${summary.unreadCount}`; } diff --git a/dot/src/git/services/GitDiffWaybarCache.ts b/dot/src/git/services/GitDiffWaybarCache.ts deleted file mode 100644 index 999f8b4a..00000000 --- a/dot/src/git/services/GitDiffWaybarCache.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Context, Effect, Layer, Schema } from "effect"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { CACHE_DIR } from "../../lib/paths.js"; - -/** Shape of the JSON written by the Waybar `git-diff` module */ -interface GitDiffWaybarCacheData { - /** Primary display text (e.g. repo count) */ - readonly text: string; - /** Tooltip with repository names (e.g. "Repositories with changes pending: dotfiles; notes") */ - readonly tooltip: string; - /** CSS class indicating change state (e.g. "dots-changed", "dots-ok", "dots-unknown") */ - readonly class: string; -} - -/** Service interface for reading the Waybar git diff cache */ -interface GitDiffWaybarCacheService { - /** Load and parse the Waybar cache JSON, returning null if unavailable */ - readonly load: () => Effect.Effect; - /** Extract changed repository names from the tooltip string */ - readonly parseChangedNames: ( - data: GitDiffWaybarCacheData, - ) => readonly string[]; -} - -class GitDiffWaybarCacheError extends Schema.TaggedErrorClass()( - "GitDiffWaybarCacheError", - { - message: Schema.String, - }, -) {} - -/** Effect service for {@link GitDiffWaybarCacheService} */ -export class GitDiffWaybarCache extends Context.Service< - GitDiffWaybarCache, - GitDiffWaybarCacheService ->()("GitDiffWaybarCache") { - static readonly layer = Layer.succeed(GitDiffWaybarCache, { - load: () => - Effect.tryPromise({ - try: async () => { - const raw = await readFile(getCachePath(), "utf-8"); - const data = JSON.parse(raw) as GitDiffWaybarCacheData; - if (!data.tooltip || !data.class) return null; - return data; - }, - catch: (error) => - new GitDiffWaybarCacheError({ message: String(error) }), - }).pipe(Effect.catch(() => Effect.succeed(null))), - - parseChangedNames: (data: GitDiffWaybarCacheData): readonly string[] => { - // Tooltip format: "Repositories with changes pending: dotfiles; notes" - // or "Repositories with changes pending: dotfiles" - const match = data.tooltip.match(/:\s*(.+)$/); - if (!match) return []; - return match[1] - .split(/[;,]/) - .map((s) => s.trim()) - .filter(Boolean); - }, - }); -} - -function getCachePath(): string { - return join(CACHE_DIR, "waybar", "git-diff-waybar.json"); -} diff --git a/dot/src/git/services/RepoWatcher.ts b/dot/src/git/services/RepoWatcher.ts index b9f92d69..8a83b1d2 100644 --- a/dot/src/git/services/RepoWatcher.ts +++ b/dot/src/git/services/RepoWatcher.ts @@ -11,7 +11,6 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import type { Repo, RepoState } from "../../types.js"; import { DotDiff } from "./DotDiff.js"; -import { GitDiffWaybarCache } from "./GitDiffWaybarCache.js"; const log = (msg: string) => console.error(`[dot:Watcher] ${msg}`); @@ -35,7 +34,6 @@ export class RepoWatcher extends Context.Service< Effect.gen(function* () { log("Initialising RepoWatcher..."); const dotDiff = yield* DotDiff; - const waybarCache = yield* GitDiffWaybarCache; const pubsub = yield* PubSub.unbounded(); let currentState: RepoState = { @@ -67,45 +65,8 @@ export class RepoWatcher extends Context.Service< }), ); - // Fast startup: try Waybar cache first, then full poll - const initialLoad = Effect.gen(function* () { - log("Trying Waybar cache for fast start..."); - const cache = yield* waybarCache.load(); - - if (cache && cache.class !== "dots-unknown") { - const changedNames = waybarCache.parseChangedNames(cache); - log( - `Waybar cache hit: class=${cache.class}, changedNames=[${changedNames.join(", ")}]`, - ); - const all = yield* dotDiff - .listAll() - .pipe(Effect.catch(() => Effect.succeed([] as readonly Repo[]))); - - if (all.length > 0) { - const changedNameSet = new Set(changedNames); - const changed = all.filter((r) => { - const baseName = r.name.includes(":") - ? r.name.split(":").pop()! - : r.name; - return changedNameSet.has(baseName) || changedNameSet.has(r.name); - }); - const now = yield* Clock.currentTimeMillis; - const state = buildRepoState(all, changed, new Date(now)); - currentState = state; - yield* PubSub.publish(pubsub, state); - log( - `Fast start: ${changed.length} changed, ${state.unchanged.length} unchanged`, - ); - return; - } - } - - log("Waybar cache miss — falling back to full poll"); - yield* poll; - }).pipe(Effect.withSpan("RepoWatcher.initialLoad")); - - // Run initial load - yield* initialLoad; + // Fast first paint: run a full poll before the background fiber starts + yield* poll; log("Initial load complete"); // Start background poll fiber (10s interval, matching lazygit) diff --git a/dot/src/index.ts b/dot/src/index.ts index a239bb1e..7ff16b0f 100644 --- a/dot/src/index.ts +++ b/dot/src/index.ts @@ -580,7 +580,7 @@ if (mode.type === "native") { stow({ publicOnly: args.includes("--public"), privateOnly: args.includes("--private"), - }), + }).pipe(Effect.asVoid), doctor: (args) => doctor({ openOpencode: args.includes("--open-opencode"), @@ -665,8 +665,6 @@ if (mode.type === "native") { const { Renderer } = await import("./services/Renderer.js"); const { Toast } = await import("./services/Toast.js"); - const { GitDiffWaybarCache } = - await import("./git/services/GitDiffWaybarCache.js"); const { RepoWatcher } = await import("./git/services/RepoWatcher.js"); const { createCommandRunner } = await import("./services/CommandRunner.js"); const { loadTheme } = await import("./theme.js"); @@ -859,7 +857,6 @@ if (mode.type === "native") { Layer.provideMerge(DashboardLayer), Layer.provideMerge(GitNotifications.layer), Layer.provideMerge(GitHub.layer), - Layer.provideMerge(GitDiffWaybarCache.layer), Layer.provideMerge(Toast.layer(theme)), Layer.provideMerge(Renderer.layer(theme, nativeLibPath)), Layer.provideMerge(OutputLog.tuiLayer), diff --git a/dot/src/lib/env.ts b/dot/src/lib/env.ts index 00b9a80d..093b894c 100644 --- a/dot/src/lib/env.ts +++ b/dot/src/lib/env.ts @@ -45,6 +45,7 @@ export const ENV = { NO_COLOR: "NO_COLOR", NOTES: "NOTES", OMARCHY_HOST: "OMARCHY_HOST", + OMARCHY_PATH: "OMARCHY_PATH", OMARCHY_REPO_BASE_DIR: "OMARCHY_REPO_BASE_DIR", OPENCODE: "OPENCODE", OPENCODE_APP_INFO: "OPENCODE_APP_INFO", diff --git a/dot/src/lib/omarchyShellConfig.ts b/dot/src/lib/omarchyShellConfig.ts new file mode 100644 index 00000000..5a1489c5 --- /dev/null +++ b/dot/src/lib/omarchyShellConfig.ts @@ -0,0 +1,487 @@ +import { Effect } from "effect"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { Config } from "../services/Config.js"; +import { OutputLog } from "../services/OutputLog.js"; +import { CONFIG_DIR, HOME_DIR, displayPath } from "./paths.js"; +import { ENV, envString } from "./env.js"; +import { currentOmarchyHost } from "./omarchyHost.js"; + +/** A single bar layout entry: a plugin id plus inline per-instance settings. */ +interface BarEntry { + readonly id: string; + readonly [key: string]: unknown; +} + +/** The bar layout columns of an Omarchy `shell.json`. */ +interface ShellLayout { + left: BarEntry[]; + center: BarEntry[]; + right: BarEntry[]; +} + +/** The `bar` block of an Omarchy `shell.json` (unknown fields preserved). */ +interface ShellBar { + position?: string; + layout: ShellLayout; + [key: string]: unknown; +} + +/** + * The parts of Omarchy's `shell.json` this generator reads and mutates. All + * other fields (`version`, `centerAnchor`, clock formats, weather, future + * additions) are preserved verbatim via the index signatures. + */ +interface ShellConfig { + idle?: { screensaver: number; lock: number }; + bar: ShellBar; + [key: string]: unknown; +} + +/** + * Class-name to colour map ported from the legacy Waybar `style.css`. Used by + * the `timmo.command` / `timmo.stream-command` widgets to colour their output. + */ +const COLOR = { + purple: "#ac77e5", + teal: "#2bb3b1", + red: "#e06c75", + amber: "#e5c07b", + green: "#98c379", + blue: "#61afef", + grey: "#9b9b9b", + rust: "#a55555", + orange: "#e7ad63", + tan: "#c6a47a", + vocCritical: "#bf6a4e", + co2Critical: "#d56f69", + cream: "#fef6ea", +} as const; + +const HA = "http://homeassistant.local:8123"; + +/** Widget id of Omarchy's default workspaces bar entry (left column). */ +const WORKSPACES_ID = "omarchy.workspaces"; + +/** Widget id of Omarchy's default clock bar entry (default centre anchor). */ +const CLOCK_ID = "omarchy.clock"; + +/** Widget id of Omarchy's default weather bar entry (center column). */ +const WEATHER_ID = "omarchy.weather"; + +/** Widget id of Omarchy's default system-update bar entry (center anchor). */ +const SYSTEM_UPDATE_ID = "omarchy.system-update"; + +/** Widget id of Omarchy's default tray bar entry (right-column anchor). */ +const TRAY_ID = "omarchy.tray"; + +/** Build a polling `timmo.command` bar entry. */ +function command(settings: Omit): BarEntry { + return { id: "timmo.command", ...settings }; +} + +/** Build a streaming `timmo.stream-command` bar entry. */ +function stream(settings: Omit): BarEntry { + return { id: "timmo.stream-command", ...settings }; +} + +/** Resolve the host-specific temperature module settings. */ +function temperatureEntry(host: string): BarEntry { + const desktop = host === "desktop"; + const entity = desktop + ? "sensor.meter_d828_temperature" + : "sensor.meter_plus_378b_temperature"; + const name = desktop ? "Meter D828 Temperature" : "Meter Plus Temperature"; + const page = desktop ? "office" : "living-room"; + return command({ + run: `ha-bar-module temperature --entity ${entity} --name '${name}' --icon 󰔏 --unit °C`, + interval: 15000, + onClick: `omarchy-launch-webapp '${HA}/lovelace/${page}?more-info-entity-id=${entity}'`, + classColors: { temperature: COLOR.cream }, + hideClasses: ["hidden"], + }); +} + +/** + * Laptop-only dining-room temperature module. A second Home Assistant + * temperature sensor shown alongside the main one, matching the living-room + * temperature module (plain reading, no gating). + */ +function diningTemperatureEntry(): BarEntry { + const entity = "sensor.meter_plus_433c_temperature"; + return command({ + run: `ha-bar-module temperature --entity ${entity} --name 'Dining Room Temperature' --icon 󰩰 --unit °C`, + interval: 15000, + onClick: `omarchy-launch-webapp '${HA}/lovelace/home?more-info-entity-id=${entity}'`, + classColors: { temperature: COLOR.cream }, + hideClasses: ["hidden"], + }); +} + +/** Resolve the host-specific CO2 module settings. */ +function co2Entry(host: string): BarEntry { + const desktop = host === "desktop"; + const entity = desktop + ? "sensor.meter_d828_carbon_dioxide" + : "sensor.apollo_air_1_806d64_co2"; + const name = desktop ? "Meter D828 CO2" : "Apollo Air 1 CO2"; + return command({ + run: `ha-bar-module co2-alert --entity ${entity} --name '${name}' --icon 󰟤 --unit ppm`, + interval: 15000, + onClick: `omarchy-launch-webapp '${HA}/lovelace/environment?more-info-entity-id=${entity}'`, + classColors: { warning: COLOR.orange, critical: COLOR.co2Critical }, + hideClasses: ["hidden"], + }); +} + +/** Resolve the host-specific doorbell module settings. */ +function doorbellEntry(host: string): BarEntry { + const base = + "doorbell-popup --open-only --camera-entity camera.front_door_snapshot"; + const triggerCommand = + host === "desktop" + ? `${base} --no-auto-close --monitor DP-1` + : host === "laptop" + ? `${base} --no-auto-close --monitor eDP-1 --width 380 --height 450` + : base; + return stream({ + run: + "ha-bar-module doorbell --entity input_boolean.doorbell --icon 󰂚 " + + "--stream-key doorbell.input_boolean.doorbell --trigger-state on " + + `--trigger-command '${triggerCommand}' --trigger-on transition ` + + "--trigger-initial false --trigger-cooldown 2 " + + "--trigger-key doorbell.popup.input_boolean.doorbell", + onClick: `omarchy-launch-webapp '${HA}/lovelace/home?more-info-entity-id=camera.front_door_snapshot'`, + classColors: { active: COLOR.rust }, + hideClasses: ["hidden"], + }); +} + +/** + * Personal workspaces module that replaces Omarchy's default `omarchy.workspaces` + * widget. The `timmo.workspaces` plugin drops persistent workspaces (only the + * workspaces that currently exist are shown) and renders the focused workspace + * as its number at full opacity, with the rest dimmed — the old Waybar + * behaviour. Opacity is set explicitly here so it is tunable in one place. + */ +function workspacesEntry(): BarEntry { + return { id: "timmo.workspaces", activeOpacity: 1, inactiveOpacity: 0.5 }; +} + +/** Personal calendar module appended to the default left column. */ +function calendarEntry(): BarEntry { + return command({ + run: "ha-bar-module current-next-event --entity input_text.current_next_event_in_an_hour --icon 󰃭", + interval: 30000, + onClick: + "launch-work-browser --tab 'https://calendar.google.com/calendar/u/0/r?pli=1'", + hideClasses: ["hidden"], + }); +} + +/** Personal status widgets inserted into the center column (host-independent). */ +function customCenterEntries(): BarEntry[] { + return [ + stream({ + run: "ha-watch-singleton --module time-check --entity input_boolean.time_check --icon 󱑎 --text-on 'Check the time' --tooltip-on 'Time Check (input_boolean.time_check): On' --tooltip-off 'Time Check (input_boolean.time_check): Off' --class-on active --class-off inactive --hide-off", + onClick: "timmo-run-command go-automate ha ib t time_check", + onClickRight: "timmo-run-command go-automate ha ib t time_check", + classColors: { active: COLOR.purple }, + hideClasses: ["hidden"], + }), + stream({ + run: "ha-watch-singleton --module in-a-call --entity input_boolean.in_a_call --icon --tooltip-on 'In a Call (input_boolean.in_a_call): On' --tooltip-off 'In a Call (input_boolean.in_a_call): Off' --class-on active --class-off inactive --hide-off", + onClick: "timmo-run-command go-automate ha ib t in_a_call", + onClickRight: "timmo-run-command go-automate ha ib t in_a_call", + classColors: { active: COLOR.teal }, + hideClasses: ["hidden"], + }), + command({ + run: "ha-bar-module nas-activity --icon 󰒋", + interval: 5000, + onClick: `omarchy-launch-webapp '${HA}/lovelace/network?more-info-entity-id=sensor.nas_activity'`, + classColors: { active: COLOR.teal }, + hideClasses: ["hidden"], + }), + command({ + run: "dot git-notifications --bar-json", + interval: 60000, + refreshTarget: "timmo.git-notifications", + loadingText: "\uf0f3 ..", + loadingClass: "notifications-unknown", + onClick: + "uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot git-notifications --bar-filter", + onClickRight: "omarchy-shell -q timmo.git-notifications refresh", + classColors: { + "notifications-unknown": COLOR.grey, + "notifications-attention": COLOR.red, + "notifications-unread": COLOR.amber, + }, + hideClasses: ["hidden"], + }), + command({ + run: "dot git-diff --bar-json", + interval: 60000, + refreshTarget: "timmo.git-diff", + loadingText: "\uf418 ..", + loadingClass: "dots-unknown", + onClick: + "uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot tui git-diff", + onClickRight: + "uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot tui git-diff --tab other", + classColors: { + "dots-ok": COLOR.grey, + "dots-unknown": COLOR.grey, + "dots-attention": COLOR.amber, + "dots-pull-only": COLOR.green, + "dots-extra-only": COLOR.blue, + }, + // The "0" (all repos clean) state hides like the other status widgets + // and is revealed dimmed on center-cluster hover; non-zero counts always + // show. The bar-json still emits " 0" so there is an icon to reveal. + hideClasses: ["dots-ok"], + }), + command({ + run: "twitch-notifications --status-bar-json --max-chars 60", + interval: 5000, + onClick: "twitch-menu", + onClickRight: "twitch-notifications-restart", + classColors: { + live: COLOR.purple, + active: COLOR.grey, + inactive: COLOR.rust, + }, + // Running with no live channels ("active") hides like the other status + // widgets and reveals dimmed on center hover. "live" stays visible, and + // "inactive" (the daemon is stopped) stays visible too so its absence is + // noticeable. The module emits its bell icon either way. + hideClasses: ["active"], + }), + ]; +} + +/** Home Assistant sensors inserted before the default right-column cluster. */ +function customRightEntries(host: string): BarEntry[] { + return [ + stream({ + run: "ha-watch-singleton --module heating --entity sensor.thermostat_status --icon 󰈸 --tooltip-on 'Thermostat Status (sensor.thermostat_status)' --class-on heating --class-off hidden --hide-off", + onClick: `omarchy-launch-webapp '${HA}/lovelace/home?more-info-entity-id=sensor.thermostat_status'`, + classColors: { heating: COLOR.orange }, + hideClasses: ["hidden"], + }), + // voc-alert is permanently hidden on desktop, so it is omitted there. + ...(host === "desktop" + ? [] + : [ + command({ + run: "ha-bar-module voc-alert --quality-entity sensor.apollo_air_1_806d64_voc_quality --value-entity sensor.apollo_air_1_806d64_sen55_voc --name 'Apollo Air 1 VOC' --icon 󰵃", + interval: 15000, + onClick: `omarchy-launch-webapp '${HA}/lovelace/environment?more-info-entity-id=sensor.apollo_air_1_806d64_sen55_voc'`, + classColors: { warning: COLOR.tan, critical: COLOR.vocCritical }, + hideClasses: ["hidden"], + }), + ]), + co2Entry(host), + stream({ + run: "ha-watch-singleton --module rain --entity binary_sensor.weather_station_rain_state_piezo --icon 󰖖 --tooltip-on 'Weather Station Rain State Piezo (binary_sensor.weather_station_rain_state_piezo): Raining' --tooltip-off 'Weather Station Rain State Piezo (binary_sensor.weather_station_rain_state_piezo): Not raining' --class-on raining --class-off hidden --hide-off", + onClick: `omarchy-launch-webapp '${HA}/home/areas-048a0fd33b134e3689eda6212a41b99d?more-info-entity-id=binary_sensor.weather_station_rain_state_piezo'`, + classColors: { raining: COLOR.blue }, + hideClasses: ["hidden"], + }), + temperatureEntry(host), + // Laptop shows a second (dining room) temperature alongside the main one. + ...(host === "laptop" ? [diningTemperatureEntry()] : []), + ]; +} + +/** Path to Omarchy's shipped default `shell.json` under `$OMARCHY_PATH`. */ +function omarchyDefaultShellConfigPath(): string { + const base = + envString(ENV.OMARCHY_PATH) ?? join(HOME_DIR, ".local", "share", "omarchy"); + return join(base, "config", "omarchy", "shell.json"); +} + +/** Narrow an unknown value to a `BarEntry[]` (array of `{ id: string, ... }`). */ +function isBarEntryArray(value: unknown): value is BarEntry[] { + return ( + Array.isArray(value) && + value.every( + (entry) => + typeof entry === "object" && + entry !== null && + typeof (entry as { id?: unknown }).id === "string", + ) + ); +} + +/** Narrow parsed JSON to the {@link ShellConfig} shape this generator mutates. */ +function isShellConfig(value: unknown): value is ShellConfig { + if (typeof value !== "object" || value === null) return false; + const bar = (value as { bar?: unknown }).bar; + if (typeof bar !== "object" || bar === null) return false; + const layout = (bar as { layout?: unknown }).layout; + if (typeof layout !== "object" || layout === null) return false; + const { left, center, right } = layout as { + left?: unknown; + center?: unknown; + right?: unknown; + }; + return ( + isBarEntryArray(left) && isBarEntryArray(center) && isBarEntryArray(right) + ); +} + +/** Insert `additions` before the first entry matching `anchorId`, else append. */ +function insertBefore( + entries: BarEntry[], + anchorId: string, + additions: readonly BarEntry[], +): void { + const index = entries.findIndex((entry) => entry.id === anchorId); + if (index === -1) entries.push(...additions); + else entries.splice(index, 0, ...additions); +} + +/** + * Merge personal widgets and host overrides into Omarchy's default shell + * config, mutating `base` in place. Default widgets are kept; personal modules + * are inserted around them ("add, not remove"). The clock stays as the center + * anchor (so the stock bar's config gear, which only renders next to a centered + * clock, still appears); the weather is relocated to the dead start of the + * right column. + * + * @param base - Parsed Omarchy default `shell.json`. + * @param host - The `OMARCHY_HOST` value (e.g. `desktop`, `laptop`). + */ +export function mergeOmarchyShellConfig( + base: ShellConfig, + host: string, +): ShellConfig { + base.idle = { screensaver: 1800, lock: 3600 }; + base.bar.position = host === "laptop" ? "bottom" : "top"; + + const { left, center, right } = base.bar.layout; + + // Left: swap Omarchy's persistent workspaces widget for the personal + // timmo.workspaces widget (no persistent workspaces, focused at full + // opacity), then append the personal calendar module. + const workspacesIndex = left.findIndex((entry) => entry.id === WORKSPACES_ID); + if (workspacesIndex !== -1) left[workspacesIndex] = workspacesEntry(); + left.push(calendarEntry()); + + // Center: keep the clock in place as the center anchor. The stock bar only + // renders the config gear next to a centered clock, so the clock has to stay + // centered for that button to exist. Pull only the weather out (relocated to + // the right column below), insert personal status widgets before the default + // system-update group, and put the doorbell trigger at the very end. Center + // widgets get `revealOnHover` so a class-hidden module fades in dimmed when + // the center cluster is hovered, mirroring the idle indicators (the only bar + // section that exposes a hover-reveal signal). All custom widgets share a + // standard 8px margin (the widget default), so no per-instance margin here. + const reveal = (entry: BarEntry): BarEntry => ({ + ...entry, + revealOnHover: true, + }); + const weatherIndex = center.findIndex((entry) => entry.id === WEATHER_ID); + const weatherEntry = + weatherIndex === -1 ? undefined : center.splice(weatherIndex, 1)[0]; + insertBefore(center, SYSTEM_UPDATE_ID, customCenterEntries().map(reveal)); + center.push(reveal(doorbellEntry(host))); + + // Right: the weather pinned to the dead start of the right column (just + // right of center, before the Home Assistant sensors), then the HA sensors + // before the default tray cluster. The clock stays centered. + insertBefore(right, TRAY_ID, customRightEntries(host)); + if (weatherEntry) right.unshift(weatherEntry); + + // The clock anchors the center so the bar config gear renders next to it. + base.bar.centerAnchor = CLOCK_ID; + + return base; +} + +/** + * Generate and apply the per-host Quickshell `shell.json` by extending + * Omarchy's shipped default with the personal modules. Reads the default from + * `$OMARCHY_PATH/config/omarchy/shell.json`, inserts the custom widgets, and + * writes the result. Idempotent: only writes when the rendered content + * differs. Skips silently when Omarchy is disabled, the host is unknown, + * Omarchy is not installed, or no default shell config exists (pre-Omarchy 4). + * + * @returns `true` when `shell.json` was rewritten (content changed), `false` + * when it was already up to date or the step was skipped. Callers use this to + * decide whether the running shell needs reloading. + */ +export const applyOmarchyShellConfig: Effect.Effect< + boolean, + never, + Config | OutputLog +> = Effect.gen(function* () { + const config = yield* Config; + const log = yield* OutputLog; + + if (!config.omarchy.enabled) return false; + + const host = currentOmarchyHost(); + if (!host) { + yield* log.info("Skipping Omarchy shell config (OMARCHY_HOST is unset)"); + return false; + } + + const omarchyDir = join(CONFIG_DIR, "omarchy"); + if (!existsSync(omarchyDir)) { + yield* log.info( + `Skipping Omarchy shell config (${displayPath(omarchyDir)} not found)`, + ); + return false; + } + + const defaultPath = omarchyDefaultShellConfigPath(); + if (!existsSync(defaultPath)) { + yield* log.info( + `Skipping Omarchy shell config (no default at ${displayPath(defaultPath)}; pre-Omarchy 4?)`, + ); + return false; + } + + const parsed = yield* Effect.sync((): unknown => { + try { + return JSON.parse(readFileSync(defaultPath, "utf-8")); + } catch { + return undefined; + } + }); + if (parsed === undefined) { + yield* log.warn( + `Skipping Omarchy shell config (could not read ${displayPath(defaultPath)})`, + ); + return false; + } + + if (!isShellConfig(parsed)) { + yield* log.warn( + `Skipping Omarchy shell config (unexpected default shape in ${displayPath(defaultPath)})`, + ); + return false; + } + + const merged = mergeOmarchyShellConfig(parsed, host); + const target = join(omarchyDir, "shell.json"); + const rendered = `${JSON.stringify(merged, null, 2)}\n`; + + const existing = existsSync(target) + ? yield* Effect.sync(() => readFileSync(target, "utf-8")) + : null; + if (existing === rendered) { + yield* log.info( + `Omarchy shell config up to date: ${displayPath(target)} (host: ${host})`, + ); + return false; + } + + yield* Effect.sync(() => writeFileSync(target, rendered, { mode: 0o600 })); + yield* log.info( + `Wrote Omarchy shell config: ${displayPath(target)} (host: ${host})`, + ); + return true; +}); diff --git a/dot/src/lib/omarchySync.ts b/dot/src/lib/omarchySync.ts index e6d05b01..f7831c5d 100644 --- a/dot/src/lib/omarchySync.ts +++ b/dot/src/lib/omarchySync.ts @@ -38,7 +38,6 @@ function fail(message: string): Effect.Effect { const REPO_SLUGS: Readonly> = { bootstrap: "timmo001/bootstrap", - waybar: "timmo001/omarchy-waybar", uwsm: "timmo001/omarchy-uwsm", }; diff --git a/dot/src/menu.ts b/dot/src/menu.ts index c65ff87b..83a6b644 100644 --- a/dot/src/menu.ts +++ b/dot/src/menu.ts @@ -430,7 +430,7 @@ const dotItems: readonly MenuItem[] = [ success: "Services restarted", }), undefined, - ["resume", "suspend", "sleep", "wake", "waybar", "restart", "system"], + ["resume", "suspend", "sleep", "wake", "shell", "restart", "system"], "System", ), item( diff --git a/dot/src/services/CommandExecutor.ts b/dot/src/services/CommandExecutor.ts index 72e3fc46..65f5ed80 100644 --- a/dot/src/services/CommandExecutor.ts +++ b/dot/src/services/CommandExecutor.ts @@ -130,7 +130,10 @@ export interface CommandExecutorService { readonly exitCode: ( cmd: string, args: readonly string[], - opts?: { readonly cwd?: string }, + opts?: { + readonly cwd?: string; + readonly env?: Readonly>; + }, ) => Effect.Effect; /** Run a command with inherited stdio (stdin/stdout/stderr pass through) */ @@ -299,6 +302,7 @@ export class CommandExecutor extends Context.Service< stderr: "ignore", cwd: opts?.cwd, detached: true, + ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}), }); killOnAbort(proc, signal); return proc.exited; diff --git a/dot/src/services/Config.ts b/dot/src/services/Config.ts index c3d25839..805ceafc 100644 --- a/dot/src/services/Config.ts +++ b/dot/src/services/Config.ts @@ -26,7 +26,7 @@ import { ENV, envString } from "../lib/env.js"; export interface OmarchyRepoConfig { /** Base directory for omarchy repos (default: ~/.config) */ readonly repoBase: string; - /** Repos to include in diff (e.g. ["waybar", "bootstrap"]) */ + /** Repos to include in diff (e.g. ["bootstrap", "ghostty"]) */ readonly diffRepos: readonly string[]; /** Repos with multiple worktree branches */ readonly worktreeRepos: readonly string[]; @@ -114,11 +114,10 @@ export class Config extends Context.Service()("Config") { // Omarchy config const omarchyRepoBase = envString(ENV.OMARCHY_REPO_BASE_DIR) ?? CONFIG_DIR; - const omarchyDiffRepos = ["waybar", "bootstrap", "uwsm"]; + const omarchyDiffRepos = ["bootstrap", "uwsm"]; const omarchyWorktreeRepos: readonly string[] = []; const omarchyWorktreeBranches = ["desktop", "laptop"]; const omarchyExpectedBranches = { - waybar: "main", bootstrap: "distro/omarchy", uwsm: "main", } satisfies Readonly>; diff --git a/dot/src/tui/Toast.ts b/dot/src/tui/Toast.ts index 7885f313..e7acc8f2 100644 --- a/dot/src/tui/Toast.ts +++ b/dot/src/tui/Toast.ts @@ -84,7 +84,7 @@ export class Toast { * If `id` matches the current toast, the message and variant are replaced * in-place. Otherwise the previous toast is dismissed and a new one shown. * - * @param id - Stable grouping identifier (e.g. "memory", "restart.waybar") + * @param id - Stable grouping identifier (e.g. "memory", "restart.shell") * @param message - Display text * @param variant - Controls border colour and auto-dismiss timing */ diff --git a/dot/src/types.ts b/dot/src/types.ts index b442e41b..f6021704 100644 --- a/dot/src/types.ts +++ b/dot/src/types.ts @@ -15,7 +15,7 @@ export interface Repo { * `dot update --check` to core/system repos. * * - `dotfiles`: public or private dotfiles repositories - * - `omarchy`: Omarchy system repos (bootstrap, waybar, uwsm) + * - `omarchy`: Omarchy system repos (bootstrap, uwsm) * - `notes`: the notes vault repository * - `private`: schedule-gated activity repos from `dot-git.yml` */ diff --git a/dot/tests/commands/Update.test.ts b/dot/tests/commands/Update.test.ts new file mode 100644 index 00000000..36dcd325 --- /dev/null +++ b/dot/tests/commands/Update.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { Effect, Layer, Stream } from "effect"; +import { reloadOmarchyShellIfChanged } from "../../src/commands/Update.js"; +import { CommandExecutor } from "../../src/services/CommandExecutor.js"; +import { Config, type ConfigService } from "../../src/services/Config.js"; +import { emptyDotGitConfig } from "../../src/services/GitConfig.js"; +import { OutputLog } from "../../src/services/OutputLog.js"; +import { emptyMcpConfig } from "../../src/mcp/sync/loadSpec.js"; + +function config(enabled: boolean): ConfigService { + return { + publicDotfiles: "/tmp/dotfiles", + privateDotfiles: null, + canUsePrivate: false, + privateReason: "test", + notesDir: "/tmp/notes", + omarchy: { + repoBase: "/tmp", + diffRepos: [], + worktreeRepos: [], + worktreeBranches: [], + expectedBranches: {}, + enabled, + }, + gitConfig: emptyDotGitConfig("/tmp/dot-git.yml"), + mcpConfig: emptyMcpConfig("/tmp/mcp.yml"), + cacheDir: "/tmp/cache", + stateDir: "/tmp/state", + logDir: "/tmp/state/logs", + }; +} + +describe("reloadOmarchyShellIfChanged", () => { + test("rescans plugins before restarting under Wayland", async () => { + const calls: Array<{ + command: string; + args: readonly string[]; + options?: { readonly env?: Readonly> }; + }> = []; + const messages: string[] = []; + const layers = Layer.mergeAll( + Layer.succeed(Config, config(true)), + Layer.succeed(CommandExecutor, { + run: () => Effect.die("run should not be called"), + stream: () => Stream.die("stream should not be called"), + inherit: () => Effect.die("inherit should not be called"), + exitCode: (command, args, options) => + Effect.sync(() => { + calls.push({ command, args, options }); + return 0; + }), + }), + Layer.succeed(OutputLog, { + info: (message) => Effect.sync(() => void messages.push(message)), + warn: () => Effect.void, + error: () => Effect.void, + section: () => Effect.void, + stream: Stream.empty, + flush: Effect.succeed(""), + withSpinner: (_label, effect) => effect, + updateSpinner: () => Effect.void, + }), + ); + + await Effect.runPromise( + reloadOmarchyShellIfChanged(true).pipe(Effect.provide(layers)), + ); + + expect(calls).toEqual([ + { command: "omarchy", args: ["plugin", "rescan"], options: undefined }, + { + command: "omarchy", + args: ["restart", "shell"], + options: { env: { QT_QPA_PLATFORM: "wayland" } }, + }, + ]); + expect(messages).toContain("Reloaded Omarchy shell (shell.json changed)"); + }); + + test("does nothing when the config did not change or Omarchy is disabled", async () => { + for (const [changed, enabled] of [ + [false, true], + [true, false], + ] as const) { + const layers = Layer.mergeAll( + Layer.succeed(Config, config(enabled)), + Layer.succeed(CommandExecutor, { + run: () => Effect.die("run should not be called"), + stream: () => Stream.die("stream should not be called"), + inherit: () => Effect.die("inherit should not be called"), + exitCode: () => Effect.die("exitCode should not be called"), + }), + Layer.succeed(OutputLog, { + info: () => Effect.void, + warn: () => Effect.void, + error: () => Effect.void, + section: () => Effect.void, + stream: Stream.empty, + flush: Effect.succeed(""), + withSpinner: (_label, effect) => effect, + updateSpinner: () => Effect.void, + }), + ); + + await Effect.runPromise( + reloadOmarchyShellIfChanged(changed).pipe(Effect.provide(layers)), + ); + } + }); +}); diff --git a/dot/tests/lib/omarchyShellConfig.test.ts b/dot/tests/lib/omarchyShellConfig.test.ts new file mode 100644 index 00000000..54b8d362 --- /dev/null +++ b/dot/tests/lib/omarchyShellConfig.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { mergeOmarchyShellConfig } from "../../src/lib/omarchyShellConfig.js"; + +function baseConfig() { + return { + version: 1, + custom: { preserved: true }, + bar: { + position: "top", + customBarField: "preserved", + layout: { + left: [ + { id: "custom.left" }, + { id: "omarchy.workspaces", persistent: true }, + ], + center: [ + { id: "omarchy.clock", format: "HH:mm" }, + { id: "omarchy.weather", location: "home" }, + { id: "omarchy.system-update" }, + { id: "custom.center" }, + ], + right: [{ id: "custom.right" }, { id: "omarchy.tray" }], + }, + }, + }; +} + +function commandRuns( + entries: ReadonlyArray<{ readonly id: string; readonly run?: unknown }>, +): string[] { + return entries.flatMap((entry) => + typeof entry.run === "string" ? [entry.run] : [], + ); +} + +describe("mergeOmarchyShellConfig", () => { + test("preserves defaults while placing personal widgets around their anchors", () => { + const base = baseConfig(); + const merged = mergeOmarchyShellConfig(base, "desktop"); + + expect(merged).toBe(base); + expect(merged.custom).toEqual({ preserved: true }); + expect(merged.idle).toEqual({ screensaver: 1800, lock: 3600 }); + expect(merged.bar.customBarField).toBe("preserved"); + expect(merged.bar.centerAnchor).toBe("omarchy.clock"); + expect(merged.bar.position).toBe("top"); + + expect(merged.bar.layout.left[1]).toEqual({ + id: "timmo.workspaces", + activeOpacity: 1, + inactiveOpacity: 0.5, + }); + expect(merged.bar.layout.left.at(-1)?.id).toBe("timmo.command"); + + const centerIds = merged.bar.layout.center.map(({ id }) => id); + expect(centerIds).not.toContain("omarchy.weather"); + expect(centerIds.indexOf("timmo.command")).toBeLessThan( + centerIds.indexOf("omarchy.system-update"), + ); + expect(merged.bar.layout.center.at(-1)).toMatchObject({ + id: "timmo.stream-command", + revealOnHover: true, + }); + expect( + merged.bar.layout.center + .filter(({ id }) => id.startsWith("timmo.")) + .every((entry) => entry.revealOnHover === true), + ).toBe(true); + + expect(merged.bar.layout.right[0]).toEqual({ + id: "omarchy.weather", + location: "home", + }); + expect(merged.bar.layout.right.at(-1)?.id).toBe("omarchy.tray"); + }); + + test("selects desktop-specific sensors and doorbell placement", () => { + const merged = mergeOmarchyShellConfig(baseConfig(), "desktop"); + const runs = commandRuns([ + ...merged.bar.layout.center, + ...merged.bar.layout.right, + ]).join("\n"); + + expect(runs).toContain("sensor.meter_d828_temperature"); + expect(runs).toContain("sensor.meter_d828_carbon_dioxide"); + expect(runs).not.toContain("voc-alert"); + expect(runs).not.toContain("sensor.meter_plus_433c_temperature"); + expect(runs).toContain("--monitor DP-1"); + }); + + test("selects laptop-specific layout, sensors, and doorbell placement", () => { + const merged = mergeOmarchyShellConfig(baseConfig(), "laptop"); + const runs = commandRuns([ + ...merged.bar.layout.center, + ...merged.bar.layout.right, + ]).join("\n"); + + expect(merged.bar.position).toBe("bottom"); + expect(runs).toContain("sensor.meter_plus_378b_temperature"); + expect(runs).toContain("sensor.apollo_air_1_806d64_co2"); + expect(runs).toContain("voc-alert"); + expect(runs).toContain("sensor.meter_plus_433c_temperature"); + expect(runs).toContain("--monitor eDP-1 --width 380 --height 450"); + }); +}); diff --git a/dot/tests/lib/stowFolders.test.ts b/dot/tests/lib/stowFolders.test.ts index fca17d55..b1c381aa 100644 --- a/dot/tests/lib/stowFolders.test.ts +++ b/dot/tests/lib/stowFolders.test.ts @@ -3,9 +3,9 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import type { ConfigService } from "../../src/services/Config.js"; +import { ENV } from "../../src/lib/env.js"; import { emptyDotGitConfig } from "../../src/services/GitConfig.js"; import { emptyMcpConfig } from "../../src/mcp/sync/loadSpec.js"; -import { ENV } from "../../src/lib/env.js"; import { listStowFolders, requiresNoFolding, @@ -20,11 +20,11 @@ function tempRoot(): string { return root; } -function fakeConfig(repoBase: string): ConfigService { +function fakeConfig(repoBase: string, host = "desktop"): ConfigService { const omarchyRepoBase = join(repoBase, ".omarchy"); - const desktopHost = join(omarchyRepoBase, "hypr", "hosts", "desktop"); - mkdirSync(desktopHost, { recursive: true }); - symlinkSync(desktopHost, join(omarchyRepoBase, "hypr", "host"), "dir"); + const hostDir = join(omarchyRepoBase, "hypr", "hosts", host); + mkdirSync(hostDir, { recursive: true }); + symlinkSync(hostDir, join(omarchyRepoBase, "hypr", "host"), "dir"); return { publicDotfiles: repoBase, @@ -84,6 +84,45 @@ describe("listStowFolders", () => { "scripts--desktop", ]); }); + + test("selects the requested host-specific package", () => { + process.env[ENV.OMARCHY_HOST] = "laptop"; + const root = tempRoot(); + mkdirSync(join(root, "scripts")); + mkdirSync(join(root, "scripts--desktop")); + mkdirSync(join(root, "scripts--laptop")); + + expect(listStowFolders(root, fakeConfig(root)).sort()).toEqual([ + "scripts", + "scripts--laptop", + ]); + }); + + test("falls back to the persisted Hypr host link", () => { + delete process.env[ENV.OMARCHY_HOST]; + const root = tempRoot(); + mkdirSync(join(root, "scripts")); + mkdirSync(join(root, "scripts--desktop")); + mkdirSync(join(root, "scripts--laptop")); + + expect(listStowFolders(root, fakeConfig(root, "laptop")).sort()).toEqual([ + "scripts", + "scripts--laptop", + ]); + }); + + test("prefers the environment host over the persisted Hypr host link", () => { + process.env[ENV.OMARCHY_HOST] = "desktop"; + const root = tempRoot(); + mkdirSync(join(root, "scripts")); + mkdirSync(join(root, "scripts--desktop")); + mkdirSync(join(root, "scripts--laptop")); + + expect(listStowFolders(root, fakeConfig(root, "laptop")).sort()).toEqual([ + "scripts", + "scripts--desktop", + ]); + }); }); describe("requiresNoFolding", () => { diff --git a/ghostty/.config/ghostty/config b/ghostty/.config/ghostty/config index 301bc7fd..bdc53fef 100644 --- a/ghostty/.config/ghostty/config +++ b/ghostty/.config/ghostty/config @@ -1,5 +1,5 @@ # Dynamic theme colors -config-file = ?"~/.config/omarchy/current/theme/ghostty.conf" +config-file = ?"~/.local/state/omarchy/current/theme/ghostty.conf" # Theme font-family = "JetBrainsMono Nerd Font" diff --git a/hypr/.config/hypr/.gitignore b/hypr/.config/hypr/.gitignore new file mode 100644 index 00000000..680eea08 --- /dev/null +++ b/hypr/.config/hypr/.gitignore @@ -0,0 +1,4 @@ +.claude/ +shaders/ +.state/ +host diff --git a/hypr/.config/hypr/.luarc.json b/hypr/.config/hypr/.luarc.json new file mode 100644 index 00000000..ff0e3599 --- /dev/null +++ b/hypr/.config/hypr/.luarc.json @@ -0,0 +1,11 @@ +{ + "workspace": { + "library": [ + "/usr/share/hypr/stubs" + ], + "checkThirdParty": false + }, + "diagnostics": { + "globals": ["hl"] + } +} diff --git a/hypr/.config/hypr/AGENTS.md b/hypr/.config/hypr/AGENTS.md new file mode 100644 index 00000000..d0685e6a --- /dev/null +++ b/hypr/.config/hypr/AGENTS.md @@ -0,0 +1,16 @@ +# HYPR AGENTS + +Instructions for coding agents working in the Hyprland config package. + +## Host Override Layout + +- This config is stowed from the dotfiles repo as the `hypr` package, with host-specific overrides. +- Shared entry files live at the package root. +- Host overrides live under `hosts/desktop/` and `hosts/laptop/`. +- `dot stow` creates `~/.config/hypr/host` as a symlink to `hosts/$OMARCHY_HOST`. +- This package is stowed non-destructively: `dot stow` and `dot install` skip the usual unstow-then-restow for `hypr` (its symlinks, notably `hyprland.lua`, never vanish mid-stow) and reload Hyprland afterwards, so Hyprland's live-config autoreload never trips into emergency mode. Preserve this if you edit the stow loop in `dot/src/commands/{Stow,Install}.ts`. + +## Documentation Sync + +- If this host override arrangement changes, update this package's `README.md` and `AGENTS.md` plus the related documentation and skill guidance in `~/.config/dotfiles` together. +- Keep host-specific instructions accurate for both laptop and desktop overrides. diff --git a/hypr/.config/hypr/README.md b/hypr/.config/hypr/README.md new file mode 100644 index 00000000..afd2f8db --- /dev/null +++ b/hypr/.config/hypr/README.md @@ -0,0 +1,12 @@ +# Omarchy Hyprland Config + +My Hyprland Config for [omarchy](https://omarchy.org), stowed from my [dotfiles](https://github.com/timmo001/dotfiles/tree/distro/arch-omarchy) as the `hypr` package. + +This config uses Lua entry files with host-specific overrides. + +- Shared entry files live at the package root (`hypr/.config/hypr/`). +- Host overrides live under `hosts/desktop/` and `hosts/laptop/`. +- `dot stow` lays down the package with `--no-folding` and creates `~/.config/hypr/host` as a symlink to `hosts/$OMARCHY_HOST`. +- This package is stowed non-destructively: `dot stow` and `dot install` skip the usual unstow-then-restow for `hypr` so its symlinks (notably `hyprland.lua`) never disappear mid-stow, then reload Hyprland afterwards. This keeps Hyprland's live-config autoreload from catching a missing config and dropping into emergency mode. + +If this host override arrangement changes, update this `README.md`, this package's `AGENTS.md`, and the related documentation and skill guidance in `~/.config/dotfiles` together. diff --git a/hypr/.config/hypr/autostart.conf b/hypr/.config/hypr/autostart.conf deleted file mode 100644 index 1aadda5f..00000000 --- a/hypr/.config/hypr/autostart.conf +++ /dev/null @@ -1,10 +0,0 @@ -# Shared autostart processes. -exec-once = $systemBridge -exec-once = $browserPersonal -exec-once = uwsm app -- kdeconnect-indicator -exec-once = uwsm-app -s b -- twitch-notifications - -# Host-specific additions selected by dot via ~/.config/hypr/host. -# hyprlang noerror true -source = ~/.config/hypr/host/autostart.conf -# hyprlang noerror false diff --git a/hypr/.config/hypr/autostart.lua b/hypr/.config/hypr/autostart.lua new file mode 100644 index 00000000..b5be6cc7 --- /dev/null +++ b/hypr/.config/hypr/autostart.lua @@ -0,0 +1,6 @@ +o.exec_on_start("timmo-run-command system-bridge backend") +o.exec_on_start([[uwsm app -- chromium --new-window --ozone-platform=wayland --profile-directory="Default" --force-device-scale-factor=0.8]]) +o.exec_on_start("uwsm app -- kdeconnect-indicator") +o.exec_on_start("uwsm-app -s b -- twitch-notifications") + +require("hypr.host.autostart") diff --git a/hypr/.config/hypr/bindings.conf b/hypr/.config/hypr/bindings.conf deleted file mode 100644 index 05b9ada0..00000000 --- a/hypr/.config/hypr/bindings.conf +++ /dev/null @@ -1,116 +0,0 @@ -# Screen recording -bindd = SHIFT ALT, PRINT, Screenrecording, exec, omarchy screenrecord - -# Application bindings -bindd = SUPER ALT, RETURN, Tmux, exec, uwsm-app -- xdg-terminal-exec --dir="$(omarchy-cmd-terminal-cwd)" tmux new -$terminal = uwsm app -- ghostty-host-config -$fileManager = uwsm app -- thunar -$browserPersonal = uwsm app -- chromium --new-window --ozone-platform=wayland --profile-directory="Default" --force-device-scale-factor=0.8 -$browserWork = launch-work-browser -$discord = launch-work-discord -$slack = launch-work-slack -$systemBridge = timmo-run-command system-bridge backend - -# Resume recovery -bindd = SUPER SHIFT, W, Resume recovery, exec, on-resume -# Hyprland runs all binds for the same chord in order; unbind clears tiling-v2 SUPER+TAB (next workspace). -unbind = SUPER, TAB -# Unbind Ctrl+Alt+Tab / Ctrl+Alt+Shift+Tab so Ghostty can use them for tmux window switching -# (were: focus next/previous monitor) -unbind = CTRL ALT, TAB -unbind = CTRL ALT SHIFT, TAB -bindd = SUPER, TAB, Workspace relayout, exec, ~/.local/bin/workspace-relayout -# unbind clears tiling-v2 SUPER ALT+TAB (next window in group). -unbind = SUPER ALT, TAB -bindd = SUPER ALT, TAB, Workspace relayout edit, exec, ~/.local/bin/workspace-relayout --edit -bindd = SUPER ALT, W, Workspace menu, exec, workspace-menu -bindd = SUPER ALT, D, Dot dashboard, exec, uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot dashboard -bindd = SUPER CTRL, P, Power Profile, exec, power-profile-menu - -# Terminal -bindd = SUPER, return, Terminal, exec, $terminal --working-directory $(omarchy-cmd-terminal-cwd) -bindd = SUPER SHIFT, return, Floating Terminal, exec, uwsm app -- xdg-terminal-exec --app-id=org.omarchy.terminal -bindd = SUPER, Q, Terminal, exec, $terminal -bindd = SUPER SHIFT, Q, Floating Terminal, exec, uwsm app -- xdg-terminal-exec --app-id=org.omarchy.terminal - -# File manager -bindd = SUPER, E, File manager, exec, $fileManager - -# Browser -bindd = SUPER, B, Browser, exec, $browserPersonal -bindd = SUPER SHIFT, B, Browser (private), exec, $browserPersonal --private - -# Browser Work -bindd = SUPER ALT, B, Browser Work, exec, $browserWork - -# Slack -bindd = SUPER SHIFT, S, Slack, exec, $slack - -# Music -# bindd = SUPER, M, Music, exec, WINIT_UNIX_BACKEND=wayland GDK_BACKEND=wayland WEBKIT_DISABLE_DMABUF_RENDERER=1 ~/repos/music-assistant/desktop-companion/src-tauri/target/release/music-assistant-companion -bindd = SUPER, M, Music, exec, omarchy-launch-webapp "https://music.youtube.com" - -# Obsidian -bindd = SUPER, N, Obsidian, exec, omarchy-launch-or-focus ^obsidian$ "uwsm-app -- obsidian" - -# 1Password -bindd = SUPER SHIFT, SLASH, Passwords, exec, uwsm app -- 1password - -# Docker -bindd = SUPER SHIFT, D, Docker, exec, omarchy-launch-tui timmo-run-command lazydocker - -# YouTube -bindd = SUPER, Y, YouTube, exec, omarchy-launch-webapp "https://www.youtube.com/feed/subscriptions" - -# Twitter -bindd = SUPER, X, X, exec, omarchy-launch-webapp "https://twitter.com/" -bindd = SUPER SHIFT, X, X Post, exec, omarchy-launch-webapp "https://x.com/compose/post" - -# Twitch -bindd = SUPER ALT, T, Twitch, exec, omarchy-launch-webapp "https://twitch.tv/directory/following/live" - -# T3 Chat -bindd = SUPER SHIFT, T, T3 Chat, exec, omarchy-launch-webapp "https://t3.chat" - -# GitHub -bindd = SUPER ALT, G, GitHub, exec, omarchy-launch-webapp "https://github.com" - -# Discord -bindd = SUPER, D, Discord, exec, $discord - -# Home Assistant -bindd = SUPER, H, Home Assistant, exec, omarchy-launch-webapp "http://homeassistant.local:8123" -bindd = SUPER ALT, H, Home Assistant DEV, exec, omarchy-launch-webapp "http://localhost:8124" - -# Home Assistant Assist (Borderless Chrome window) -bindd = SUPER, A, Home Assistant Assist, exec, omarchy-launch-webapp "http://homeassistant.local:8123/?conversation=1" - -# In a call -bind = SUPER SHIFT, C, exec, timmo-run-command go-automate ha ib t in_a_call - -# Mic On -bind = SUPER SHIFT, M, exec, pactl set-source-mute @DEFAULT_SOURCE@ toggle - -# Twitch notifications menu -bind = CTRL ALT, T, exec, ~/.local/bin/twitch-menu -bind = CTRL ALT SHIFT, T, exec, ~/.local/bin/twitch-menu channels - -# Git diff -bind = CTRL ALT, R, exec, uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot tui git-diff -bind = CTRL ALT SHIFT, R, exec, uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot tui git-diff --tab other - -# Times -# Overrides Omarchy's default SUPER+CTRL+ALT+T single local time notification. -unbind = SUPER CTRL ALT, T -bindd = SUPER CTRL ALT, T, Show times, exec, omarchy notification send "" "Times" "$(~/.local/bin/times --notify)" -u low - -# Precise window resizing (fractional, like 1% volume with ALT) -bindd = SUPER ALT, code:20, Shrink window width (precise), resizeactive, -2 0 # ALT + - -bindd = SUPER ALT, code:21, Expand window width (precise), resizeactive, 2 0 # ALT + = -bindd = SUPER ALT SHIFT, code:20, Shrink window height (precise), resizeactive, 0 -2 -bindd = SUPER ALT SHIFT, code:21, Expand window height (precise), resizeactive, 0 2 - -# Host-specific additions selected by dot via ~/.config/hypr/host. -# hyprlang noerror true -source = ~/.config/hypr/host/bindings.conf -# hyprlang noerror false diff --git a/hypr/.config/hypr/bindings.lua b/hypr/.config/hypr/bindings.lua new file mode 100644 index 00000000..0cd49211 --- /dev/null +++ b/hypr/.config/hypr/bindings.lua @@ -0,0 +1,78 @@ +-- Screen recording +o.bind("SHIFT + ALT + PRINT", "Screenrecording", "omarchy screenrecord") + +local terminal = "uwsm app -- ghostty-host-config" +local file_manager = "uwsm app -- thunar" +local browser_personal = [[uwsm app -- chromium --new-window --ozone-platform=wayland --profile-directory="Default" --force-device-scale-factor=0.8]] +local browser_work = "launch-work-browser" +local discord = "launch-work-discord" +local slack = "launch-work-slack" + +-- Resume recovery +o.bind("SUPER + SHIFT + W", "Resume recovery", "on-resume") + +-- Hyprland runs all binds for the same chord in order; unbind clears default bindings first. +hl.unbind("SUPER + TAB") +hl.unbind("CTRL + ALT + TAB") +hl.unbind("CTRL + ALT + SHIFT + TAB") +-- unbind clears tiling-v2 SUPER + ALT + TAB (next window in group). +hl.unbind("SUPER + ALT + TAB") +o.bind("SUPER + TAB", "Workspace relayout", "~/.local/bin/workspace-relayout") +o.bind("SUPER + ALT + TAB", "Workspace relayout edit", "~/.local/bin/workspace-relayout --edit") +o.bind("SUPER + ALT + W", "Workspace menu", "workspace-menu") +o.bind("SUPER + ALT + D", "Dot dashboard", "uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot dashboard") +o.bind("SUPER + CTRL + P", "Power Profile", "power-profile-menu") + +-- Terminal +o.bind("SUPER + RETURN", "Terminal", terminal .. " --working-directory $(omarchy-cmd-terminal-cwd)") +o.bind("SUPER + SHIFT + RETURN", "Floating Terminal", "uwsm app -- xdg-terminal-exec --app-id=org.omarchy.terminal") +o.bind("SUPER + Q", "Terminal", terminal) +o.bind("SUPER + SHIFT + Q", "Floating Terminal", "uwsm app -- xdg-terminal-exec --app-id=org.omarchy.terminal") +o.bind("SUPER + ALT + RETURN", "Tmux", [[uwsm-app -- xdg-terminal-exec --dir="$(omarchy-cmd-terminal-cwd)" tmux new]]) + +-- File manager +o.bind("SUPER + E", "File manager", file_manager) + +-- Browser +o.bind("SUPER + B", "Browser", browser_personal) +o.bind("SUPER + SHIFT + B", "Browser (private)", browser_personal .. " --private") +o.bind("SUPER + ALT + B", "Browser Work", browser_work) + +-- Chat and apps +o.bind("SUPER + SHIFT + S", "Slack", slack) +o.bind("SUPER + M", "Music", [[omarchy-launch-webapp "https://music.youtube.com"]]) +o.bind("SUPER + N", "Obsidian", [[omarchy-launch-or-focus ^obsidian$ "uwsm-app -- obsidian"]]) +o.bind("SUPER + SHIFT + SLASH", "Passwords", "uwsm app -- 1password") +o.bind("SUPER + SHIFT + D", "Docker", "omarchy-launch-tui timmo-run-command lazydocker") +o.bind("SUPER + Y", "YouTube", [[omarchy-launch-webapp "https://www.youtube.com/feed/subscriptions"]]) +o.bind("SUPER + X", "X", [[omarchy-launch-webapp "https://twitter.com/"]]) +o.bind("SUPER + SHIFT + X", "X Post", [[omarchy-launch-webapp "https://x.com/compose/post"]]) +o.bind("SUPER + ALT + T", "Twitch", [[omarchy-launch-webapp "https://twitch.tv/directory/following/live"]]) +o.bind("SUPER + SHIFT + T", "T3 Chat", [[omarchy-launch-webapp "https://t3.chat"]]) +o.bind("SUPER + ALT + G", "GitHub", [[omarchy-launch-webapp "https://github.com"]]) +o.bind("SUPER + D", "Discord", discord) + +-- Home Assistant +o.bind("SUPER + H", "Home Assistant", [[omarchy-launch-webapp "http://homeassistant.local:8123"]]) +o.bind("SUPER + ALT + H", "Home Assistant DEV", [[omarchy-launch-webapp "http://localhost:8124"]]) +o.bind("SUPER + A", "Home Assistant Assist", [[omarchy-launch-webapp "http://homeassistant.local:8123/?conversation=1"]]) + +-- Local automations +o.bind("SUPER + SHIFT + C", nil, "timmo-run-command go-automate ha ib t in_a_call") +o.bind("SUPER + SHIFT + M", nil, "pactl set-source-mute @DEFAULT_SOURCE@ toggle") +o.bind("CTRL + ALT + T", nil, "~/.local/bin/twitch-menu") +o.bind("CTRL + ALT + SHIFT + T", nil, "~/.local/bin/twitch-menu channels") +o.bind("CTRL + ALT + R", nil, "uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot tui git-diff") +o.bind("CTRL + ALT + SHIFT + R", nil, "uwsm app -- xdg-terminal-exec --app-id=TUI.float -e dot tui git-diff --tab other") + +-- Overrides Omarchy's default SUPER+CTRL+ALT+T single local time notification. +hl.unbind("SUPER + CTRL + ALT + T") +o.bind("SUPER + CTRL + ALT + T", "Show times", [[omarchy notification send "" "Times" "$(~/.local/bin/times --notify)" -u low]]) + +-- Precise window resizing (fractional, like 1% volume with ALT) +o.bind("SUPER + ALT + code:20", "Shrink window width (precise)", hl.dsp.window.resize({ x = -2, y = 0, relative = true })) +o.bind("SUPER + ALT + code:21", "Expand window width (precise)", hl.dsp.window.resize({ x = 2, y = 0, relative = true })) +o.bind("SUPER + ALT + SHIFT + code:20", "Shrink window height (precise)", hl.dsp.window.resize({ x = 0, y = -2, relative = true })) +o.bind("SUPER + ALT + SHIFT + code:21", "Expand window height (precise)", hl.dsp.window.resize({ x = 0, y = 2, relative = true })) + +require("hypr.host.bindings") diff --git a/hypr/.config/hypr/envs.conf b/hypr/.config/hypr/envs.conf deleted file mode 100644 index 970f831c..00000000 --- a/hypr/.config/hypr/envs.conf +++ /dev/null @@ -1,19 +0,0 @@ -# Extra env variables -# Note: You must relaunch Hyprland after changing envs (use Super+Esc, then Relaunch) -# env = MY_GLOBAL_ENV,setting - -# Wayland -env = ELECTRON_ENABLE_WAYLAND,1 - -# GPU and VA-API env is host-specific: Nvidia on desktop, Intel iHD on laptop. -# Set per host below via ~/.config/hypr/host/envs.conf. - -# Cursor -env = HYPRCURSOR_THEME,catppuccin-mocha-dark-cursors -env = XCURSOR_SIZE,20 -env = HYPRCURSOR_SIZE,20 - -# Host marker selected by dot via ~/.config/hypr/host. -# hyprlang noerror true -source = ~/.config/hypr/host/envs.conf -# hyprlang noerror false diff --git a/hypr/.config/hypr/envs.lua b/hypr/.config/hypr/envs.lua new file mode 100644 index 00000000..76dbd4d0 --- /dev/null +++ b/hypr/.config/hypr/envs.lua @@ -0,0 +1,12 @@ +-- Wayland +hl.env("ELECTRON_ENABLE_WAYLAND", "1") + +-- GPU and VA-API env is host-specific: Nvidia on desktop, Intel iHD on laptop. +-- Set per host in hosts//envs.lua, loaded via require("hypr.host.envs") below. + +-- Cursor +hl.env("HYPRCURSOR_THEME", "catppuccin-mocha-dark-cursors") +hl.env("XCURSOR_SIZE", "20") +hl.env("HYPRCURSOR_SIZE", "20") + +require("hypr.host.envs") diff --git a/hypr/.config/hypr/hosts/desktop/autostart.conf b/hypr/.config/hypr/hosts/desktop/autostart.conf deleted file mode 100644 index 6ea34cb3..00000000 --- a/hypr/.config/hypr/hosts/desktop/autostart.conf +++ /dev/null @@ -1,6 +0,0 @@ -# Desktop autostart additions. -exec-once = opencode-server -exec-once = solaar --window=hide - -# Workspace layouts -exec-once = workspace-setup --sleep=5 diff --git a/hypr/.config/hypr/hosts/desktop/autostart.lua b/hypr/.config/hypr/hosts/desktop/autostart.lua new file mode 100644 index 00000000..765fc2c5 --- /dev/null +++ b/hypr/.config/hypr/hosts/desktop/autostart.lua @@ -0,0 +1,5 @@ +o.exec_on_start("opencode-server") +o.exec_on_start("solaar --window=hide") + +-- Workspace layouts +o.exec_on_start("workspace-setup --sleep=5") diff --git a/hypr/.config/hypr/hosts/desktop/bindings.conf b/hypr/.config/hypr/hosts/desktop/bindings.conf deleted file mode 100644 index 276f88a4..00000000 --- a/hypr/.config/hypr/hosts/desktop/bindings.conf +++ /dev/null @@ -1,5 +0,0 @@ -# Home Assistant Development Terminal -bindd = SUPER SHIFT, H, Home Assistant DEV Terminal, exec, omarchy-launch-tui hadev - -# Doorbell camera popup (permanent floating) -bindd = SUPER ALT, C, Doorbell Camera Popup, exec, mkdir -p "${XDG_STATE_HOME:-$HOME/.local/state}"; nohup setsid ~/.config/dotfiles/scripts/.local/bin/doorbell-popup --open-only --no-auto-close --monitor DP-1 >"${XDG_STATE_HOME:-$HOME/.local/state}/doorbell-camera-popup.log" 2>&1 < /dev/null & diff --git a/hypr/.config/hypr/hosts/desktop/bindings.lua b/hypr/.config/hypr/hosts/desktop/bindings.lua new file mode 100644 index 00000000..0dff318f --- /dev/null +++ b/hypr/.config/hypr/hosts/desktop/bindings.lua @@ -0,0 +1,4 @@ +o.bind("SUPER + SHIFT + H", "Home Assistant DEV Terminal", "omarchy-launch-tui hadev") + +-- Local automations +o.bind("SUPER + ALT + C", "Doorbell Camera Popup", [[mkdir -p "${XDG_STATE_HOME:-$HOME/.local/state}"; nohup setsid ~/.config/dotfiles/scripts/.local/bin/doorbell-popup --open-only --no-auto-close --monitor DP-1 >"${XDG_STATE_HOME:-$HOME/.local/state}/doorbell-camera-popup.log" 2>&1 < /dev/null &]]) diff --git a/hypr/.config/hypr/hosts/desktop/envs.conf b/hypr/.config/hypr/hosts/desktop/envs.conf deleted file mode 100644 index 1d32773b..00000000 --- a/hypr/.config/hypr/hosts/desktop/envs.conf +++ /dev/null @@ -1,8 +0,0 @@ -# Desktop host marker. -env = OMARCHY_HOST,desktop - -# Nvidia + Wayland (desktop has an Nvidia GPU). -env = __GLX_VENDOR_LIBRARY_NAME,nvidia -env = __NV_PRIME_RENDER_OFFLOAD,1 -env = LIBVA_DRIVER_NAME,nvidia -env = NVD_BACKEND,direct diff --git a/hypr/.config/hypr/hosts/desktop/envs.lua b/hypr/.config/hypr/hosts/desktop/envs.lua new file mode 100644 index 00000000..8b010e3f --- /dev/null +++ b/hypr/.config/hypr/hosts/desktop/envs.lua @@ -0,0 +1,7 @@ +hl.env("OMARCHY_HOST", "desktop") + +-- Nvidia + Wayland (desktop has an Nvidia GPU). +hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") +hl.env("__NV_PRIME_RENDER_OFFLOAD", "1") +hl.env("LIBVA_DRIVER_NAME", "nvidia") +hl.env("NVD_BACKEND", "direct") diff --git a/hypr/.config/hypr/hosts/desktop/hypridle.conf b/hypr/.config/hypr/hosts/desktop/hypridle.conf deleted file mode 100644 index c124dd18..00000000 --- a/hypr/.config/hypr/hosts/desktop/hypridle.conf +++ /dev/null @@ -1,17 +0,0 @@ -general { - lock_cmd = omarchy-system-lock # lock screen and 1password - before_sleep_cmd = OMARCHY_LOCK_ONLY=true omarchy-system-lock # lock before suspend without scheduling display off. - after_sleep_cmd = sleep 1 && omarchy-system-wake && on-resume # delay for PAM readiness, then turn on display. - inhibit_sleep = 3 # wait until screen is locked -} - -listener { - timeout = 1800 # 30min - on-timeout = pidof hyprlock || omarchy-launch-screensaver -} - -listener { - timeout = 3600 # 60min - on-timeout = omarchy-system-lock - on-resume = omarchy-system-wake -} diff --git a/hypr/.config/hypr/hosts/desktop/hyprlock.conf b/hypr/.config/hypr/hosts/desktop/hyprlock.conf deleted file mode 100644 index 9550b656..00000000 --- a/hypr/.config/hypr/hosts/desktop/hyprlock.conf +++ /dev/null @@ -1,43 +0,0 @@ -source = ~/.config/omarchy/current/theme/hyprlock.conf - -general { - ignore_empty_input = true -} - -background { - monitor = - color = $color - path = ~/.config/omarchy/current/background - blur_passes = 3 -} - -animations { - enabled = false -} - -input-field { - monitor = - size = 650, 100 - position = 0, 0 - halign = center - valign = center - - inner_color = $inner_color - outer_color = $outer_color - outline_thickness = 4 - - font_family = JetBrainsMono Nerd Font - font_color = $font_color - - placeholder_text = Enter Password - check_color = $check_color - fail_text = $FAIL ($ATTEMPTS) - - rounding = 0 - shadow_passes = 0 - fade_on_empty = false -} - -auth { - fingerprint:enabled = false -} diff --git a/hypr/.config/hypr/hosts/desktop/input.conf b/hypr/.config/hypr/hosts/desktop/input.conf deleted file mode 100644 index d4b996ba..00000000 --- a/hypr/.config/hypr/hosts/desktop/input.conf +++ /dev/null @@ -1 +0,0 @@ -# Desktop currently uses the shared input defaults. diff --git a/hypr/.config/hypr/hosts/desktop/input.lua b/hypr/.config/hypr/hosts/desktop/input.lua new file mode 100644 index 00000000..6f72da9f --- /dev/null +++ b/hypr/.config/hypr/hosts/desktop/input.lua @@ -0,0 +1,5 @@ +hl.config({ + input = { + sensitivity = 0, + }, +}) diff --git a/hypr/.config/hypr/hosts/desktop/looknfeel.conf b/hypr/.config/hypr/hosts/desktop/looknfeel.conf deleted file mode 100644 index 5cde5f19..00000000 --- a/hypr/.config/hypr/hosts/desktop/looknfeel.conf +++ /dev/null @@ -1,38 +0,0 @@ -# Desktop look overrides. -general { - gaps_in = 2 - col.active_border = rgba(ccccffff) -} - -# https://wiki.hyprland.org/Configuring/Variables/#decoration -decoration { - rounding = 4 - - shadow { - enabled = true - range = 2 - render_power = 3 - color = rgba(1a1a1aee) - } - - # https://wiki.hyprland.org/Configuring/Variables/#blur - blur { - enabled = true - size = 3 - passes = 1 - - vibrancy = 0.1696 - } -} - -# Steam: Omarchy floats all steam windows by default. Override only the main -# client window so login/dialog windows can stay floating while the main Steam -# window tiles to avoid the XWayland menu bug. -windowrule = tile on, match:class ^(steam)$, match:title ^Steam$ - -# Workspace rules -windowrule = workspace 1, match:class ^(chromium)$ -windowrule = workspace 1 silent, match:class ^(chrome-discord\.com__app) -windowrule = workspace 1 silent, match:class ^(chrome-app\.slack\.com__client) - -windowrule = workspace 3, match:class ^(work-browser)$ diff --git a/hypr/.config/hypr/hosts/desktop/looknfeel.lua b/hypr/.config/hypr/hosts/desktop/looknfeel.lua new file mode 100644 index 00000000..f152bd75 --- /dev/null +++ b/hypr/.config/hypr/hosts/desktop/looknfeel.lua @@ -0,0 +1,33 @@ +hl.config({ + general = { + gaps_in = 2, + col = { + active_border = "rgba(ccccffff)", + }, + }, + + decoration = { + rounding = 4, + shadow = { + enabled = true, + range = 2, + render_power = 3, + color = "rgba(1a1a1aee)", + }, + blur = { + enabled = true, + size = 3, + passes = 1, + vibrancy = 0.1696, + }, + }, +}) + +-- Steam: keep the main client tiled while login/dialog windows can stay floating. +hl.window_rule({ name = "tile-steam-main", match = { class = "^(steam)$", title = "^Steam$" }, float = false }) + +-- Workspace rules. +hl.window_rule({ name = "workspace-chromium", match = { class = "^(chromium)$" }, workspace = "1" }) +hl.window_rule({ name = "workspace-discord", match = { class = "^(chrome-discord\\.com__app)" }, workspace = "1 silent" }) +hl.window_rule({ name = "workspace-slack", match = { class = "^(chrome-app\\.slack\\.com__client)" }, workspace = "1 silent" }) +hl.window_rule({ name = "workspace-work-browser", match = { class = "^(work-browser)$" }, workspace = "3" }) diff --git a/hypr/.config/hypr/hosts/desktop/monitors.conf b/hypr/.config/hypr/hosts/desktop/monitors.conf deleted file mode 100644 index 70ce6f0b..00000000 --- a/hypr/.config/hypr/hosts/desktop/monitors.conf +++ /dev/null @@ -1,19 +0,0 @@ -# See https://wiki.hyprland.org/Configuring/Monitors/ -# List current monitors and resolutions possible: hyprctl monitors -# Format: monitor = [port], resolution, position, scale -# You must relaunch Hyprland after changing any envs (use Super+Esc, then Relaunch) - -env = GDK_SCALE,1.6 -env = QT_SCALE_FACTOR,1.6 - -monitor=DP-1, 3840x2160@143.86Hz, -1350x0, 1.6, transform, 1 -monitor=HDMI-A-2, 3840x2160@240.00Hz, 0x504, 1.6 -monitor=Virtual-1, 3840x2160@60Hz, 0x0, 1.6 -monitor=, preferred, auto, 1.6 - -workspace = 1, name:Main Left, monitor:DP-1, default:true -workspace = 2, name:Main Right A, monitor:HDMI-A-2, default:true -workspace = 3, name:Main Right B, monitor:HDMI-A-2, default:false -workspace = 4, name:Main Right C, monitor:HDMI-A-2, default:false -workspace = 5, name:Main Right D, monitor:HDMI-A-2, default:false -workspace = 6, name:Main Right E, monitor:HDMI-A-2, default:false diff --git a/hypr/.config/hypr/hosts/desktop/monitors.lua b/hypr/.config/hypr/hosts/desktop/monitors.lua new file mode 100644 index 00000000..037b925b --- /dev/null +++ b/hypr/.config/hypr/hosts/desktop/monitors.lua @@ -0,0 +1,20 @@ +-- May want to use 1.666667 (both) +local machine = require("hypr.lib.machine") + +local is_virtual_machine = machine.is_virtual_machine() +local omarchy_gdk_scale = is_virtual_machine and 1 or 1.6 +local omarchy_monitor_scale = is_virtual_machine and 1.0 or 1.6 + +hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) +hl.env("QT_SCALE_FACTOR", tostring(omarchy_gdk_scale)) + +hl.monitor({ output = "DP-1", mode = "3840x2160@143.86Hz", position = "-1350x0", scale = omarchy_monitor_scale, transform = 1 }) +hl.monitor({ output = "HDMI-A-2", mode = "3840x2160@240.00Hz", position = "0x504", scale = omarchy_monitor_scale }) +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale }) + +hl.workspace_rule({ workspace = "1", default_name = "Main Left", monitor = "DP-1", default = true }) +hl.workspace_rule({ workspace = "2", default_name = "Main Right A", monitor = "HDMI-A-2", default = true }) +hl.workspace_rule({ workspace = "3", default_name = "Main Right B", monitor = "HDMI-A-2" }) +hl.workspace_rule({ workspace = "4", default_name = "Main Right C", monitor = "HDMI-A-2" }) +hl.workspace_rule({ workspace = "5", default_name = "Main Right D", monitor = "HDMI-A-2" }) +hl.workspace_rule({ workspace = "6", default_name = "Main Right E", monitor = "HDMI-A-2" }) diff --git a/hypr/.config/hypr/hosts/laptop/autostart.conf b/hypr/.config/hypr/hosts/laptop/autostart.conf deleted file mode 100644 index f8beecb1..00000000 --- a/hypr/.config/hypr/hosts/laptop/autostart.conf +++ /dev/null @@ -1,3 +0,0 @@ -# Laptop autostart additions. -exec-once = uwsm app -- hyprsunset --config ~/.config/hypr/host/hyprsunset.conf -exec-once = uwsm app -- power-profile-daemon diff --git a/hypr/.config/hypr/hosts/laptop/autostart.lua b/hypr/.config/hypr/hosts/laptop/autostart.lua new file mode 100644 index 00000000..19fa3550 --- /dev/null +++ b/hypr/.config/hypr/hosts/laptop/autostart.lua @@ -0,0 +1,2 @@ +o.exec_on_start("uwsm app -- hyprsunset --config ~/.config/hypr/host/hyprsunset.conf") +o.exec_on_start("uwsm app -- power-profile-daemon") diff --git a/hypr/.config/hypr/hosts/laptop/bindings.conf b/hypr/.config/hypr/hosts/laptop/bindings.conf deleted file mode 100644 index c8f1254d..00000000 --- a/hypr/.config/hypr/hosts/laptop/bindings.conf +++ /dev/null @@ -1,23 +0,0 @@ -# Bluetooth -bindd = SUPER ALT CTRL, B, Bluetooth, exec, DEVICE="$(bluetoothctl devices | grep -i 'MOMENTUM 4' | awk '{print $2}')" && bluetoothctl disconnect "$DEVICE" && sleep 1 && bluetoothctl connect "$DEVICE" - -# Home Assistant Dev -bindd = SUPER SHIFT, H, Home Assistant DEV, exec, omarchy-launch-tui timmo-run-command hadev - -# Doorbell camera popup (permanent floating) -bindd = SUPER ALT, C, Doorbell Camera Popup, exec, mkdir -p "${XDG_STATE_HOME:-$HOME/.local/state}"; nohup setsid ~/.config/dotfiles/scripts/.local/bin/doorbell-popup --open-only --no-auto-close --camera-entity camera.front_door_snapshot --monitor eDP-1 --width 380 --height 450 >"${XDG_STATE_HOME:-$HOME/.local/state}/doorbell-camera-popup.log" 2>&1 < /dev/null & - -# Gamma-only dimming toggle -bindd = SUPER CTRL, D, Toggle dimming, exec, ~/.config/hypr/bin/hyprsunset-toggle-dim -bindd = SUPER CTRL, minus, Dim down, exec, ~/.config/hypr/bin/hyprsunset-dim-step down -bindd = SUPER CTRL, equal, Dim up, exec, ~/.config/hypr/bin/hyprsunset-dim-step up - -# Dim control with Ctrl + Fn brightness keys -bindeld = CTRL, XF86MonBrightnessUp, Dim up, exec, ~/.config/hypr/bin/hyprsunset-dim-step up -bindeld = CTRL, XF86MonBrightnessDown, Dim down, exec, ~/.config/hypr/bin/hyprsunset-dim-step down - -# Normal brightness disables dim -unbind = , XF86MonBrightnessUp -unbind = , XF86MonBrightnessDown -bindeld = , XF86MonBrightnessUp, Brightness up, exec, ~/.config/hypr/bin/hyprsunset-clear-dim && omarchy-brightness-display +5% -bindeld = , XF86MonBrightnessDown, Brightness down, exec, ~/.config/hypr/bin/hyprsunset-clear-dim && omarchy-brightness-display 5%- diff --git a/hypr/.config/hypr/hosts/laptop/bindings.lua b/hypr/.config/hypr/hosts/laptop/bindings.lua new file mode 100644 index 00000000..fad68d19 --- /dev/null +++ b/hypr/.config/hypr/hosts/laptop/bindings.lua @@ -0,0 +1,19 @@ +-- Bluetooth +o.bind("SUPER + ALT + CTRL + B", "Bluetooth", [[DEVICE="$(bluetoothctl devices | grep -i 'MOMENTUM 4' | awk '{print $2}')" && bluetoothctl disconnect "$DEVICE" && sleep 1 && bluetoothctl connect "$DEVICE"]]) +o.bind("SUPER + SHIFT + H", "Home Assistant DEV", "omarchy-launch-tui timmo-run-command hadev") + +-- Local automations +o.bind("SUPER + ALT + C", "Doorbell Camera Popup", [[mkdir -p "${XDG_STATE_HOME:-$HOME/.local/state}"; nohup setsid ~/.config/dotfiles/scripts/.local/bin/doorbell-popup --open-only --no-auto-close --camera-entity camera.front_door_snapshot --monitor eDP-1 --width 380 --height 450 >"${XDG_STATE_HOME:-$HOME/.local/state}/doorbell-camera-popup.log" 2>&1 < /dev/null &]]) + +-- Gamma-only dimming toggle +o.bind("SUPER + CTRL + D", "Toggle dimming", "~/.config/hypr/bin/hyprsunset-toggle-dim") +o.bind("SUPER + CTRL + code:20", "Dim down", "~/.config/hypr/bin/hyprsunset-dim-step down") +o.bind("SUPER + CTRL + code:21", "Dim up", "~/.config/hypr/bin/hyprsunset-dim-step up") +o.bind("CTRL + XF86MonBrightnessUp", "Dim up", "~/.config/hypr/bin/hyprsunset-dim-step up", { locked = true, repeating = true }) +o.bind("CTRL + XF86MonBrightnessDown", "Dim down", "~/.config/hypr/bin/hyprsunset-dim-step down", { locked = true, repeating = true }) + +-- Normal brightness disables dim +hl.unbind("XF86MonBrightnessUp") +hl.unbind("XF86MonBrightnessDown") +o.bind("XF86MonBrightnessUp", "Brightness up", "~/.config/hypr/bin/hyprsunset-clear-dim && omarchy-brightness-display +5%", { locked = true, repeating = true }) +o.bind("XF86MonBrightnessDown", "Brightness down", "~/.config/hypr/bin/hyprsunset-clear-dim && omarchy-brightness-display 5%-", { locked = true, repeating = true }) diff --git a/hypr/.config/hypr/hosts/laptop/envs.conf b/hypr/.config/hypr/hosts/laptop/envs.conf deleted file mode 100644 index bebfaf64..00000000 --- a/hypr/.config/hypr/hosts/laptop/envs.conf +++ /dev/null @@ -1,5 +0,0 @@ -# Laptop host marker. -env = OMARCHY_HOST,laptop - -# Intel-only GPU: use the iHD VA-API driver (Mesa EGL is the default). -env = LIBVA_DRIVER_NAME,iHD diff --git a/hypr/.config/hypr/hosts/laptop/envs.lua b/hypr/.config/hypr/hosts/laptop/envs.lua new file mode 100644 index 00000000..bd54c58b --- /dev/null +++ b/hypr/.config/hypr/hosts/laptop/envs.lua @@ -0,0 +1,4 @@ +hl.env("OMARCHY_HOST", "laptop") + +-- Intel-only GPU: use the iHD VA-API driver (Mesa EGL is the default). +hl.env("LIBVA_DRIVER_NAME", "iHD") diff --git a/hypr/.config/hypr/hosts/laptop/hypridle.conf b/hypr/.config/hypr/hosts/laptop/hypridle.conf deleted file mode 100644 index 8f378e24..00000000 --- a/hypr/.config/hypr/hosts/laptop/hypridle.conf +++ /dev/null @@ -1,19 +0,0 @@ -general { - lock_cmd = omarchy-system-lock # lock screen and 1password - before_sleep_cmd = OMARCHY_LOCK_ONLY=true omarchy-system-lock # lock before suspend without scheduling display off. - after_sleep_cmd = sleep 1 && omarchy-system-wake && kbd-backlight-rearm && on-resume # delay for PAM readiness, turn on display, re-arm keyboard backlight. - inhibit_sleep = 3 # wait until screen is locked -} - -# Start screensaver after 2.5 minutes -listener { - timeout = 150 - on-timeout = pidof hyprlock || omarchy-launch-screensaver -} - -# Lock system after 5 minutes (screensaver resets idle timer, so have to just do half + 2s margin) -listener { - timeout = 152 - on-timeout = omarchy-system-lock - on-resume = omarchy-system-wake && kbd-backlight-rearm -} diff --git a/hypr/.config/hypr/hosts/laptop/hyprlock.conf b/hypr/.config/hypr/hosts/laptop/hyprlock.conf deleted file mode 100644 index 8bf34063..00000000 --- a/hypr/.config/hypr/hosts/laptop/hyprlock.conf +++ /dev/null @@ -1,44 +0,0 @@ -source = ~/.config/omarchy/current/theme/hyprlock.conf - -general { - ignore_empty_input = true -} - -background { - monitor = - color = $color - path = ~/.config/omarchy/current/background - blur_passes = 3 -} - -animations { - enabled = false -} - -input-field { - monitor = - size = 650, 100 - position = 0, 0 - halign = center - valign = center - - inner_color = $inner_color - outer_color = $outer_color - outline_thickness = 4 - - font_family = CaskaydiaCove Nerd Font - font_color = $font_color - - placeholder_text = Touch Security Key or Type Password - check_color = $check_color - check_text = Touch your security key - fail_text = $FAIL ($ATTEMPTS) - - rounding = 0 - shadow_passes = 0 - fade_on_empty = false -} - -auth { - fingerprint:enabled = false -} diff --git a/hypr/.config/hypr/hosts/laptop/input.conf b/hypr/.config/hypr/hosts/laptop/input.conf deleted file mode 100644 index 9dbbbad7..00000000 --- a/hypr/.config/hypr/hosts/laptop/input.conf +++ /dev/null @@ -1,23 +0,0 @@ -# Laptop touchpad and pointer overrides. -input { - sensitivity = 0.35 - - touchpad { - # Disable touchpad when typing - disable_while_typing = true - - # Use natural (inverse) scrolling - natural_scroll = true - - # Use two-finger clicks for right-click instead of lower-right corner - # clickfinger_behavior = true - - # Control the speed of your scrolling - scroll_factor = 0.4 - } -} - -# Scroll speed adjustments -windowrule = match:class Alacritty, scroll_touchpad 1.50 -windowrule = match:class Ghostty, scroll_touchpad 1.50 -windowrule = match:class ^(Chromium|chromium|google-chrome|google-chrome-stable|google-chrome-unstable)$, scroll_touchpad 0.25 diff --git a/hypr/.config/hypr/hosts/laptop/input.lua b/hypr/.config/hypr/hosts/laptop/input.lua new file mode 100644 index 00000000..b7a8a414 --- /dev/null +++ b/hypr/.config/hypr/hosts/laptop/input.lua @@ -0,0 +1,13 @@ +hl.config({ + input = { + sensitivity = 0.35, + touchpad = { + scroll_factor = 0.4, + }, + }, +}) + +-- Scroll speed adjustments +hl.window_rule({ name = "scroll-alacritty", match = { class = "Alacritty" }, scroll_touchpad = 1.50 }) +hl.window_rule({ name = "scroll-ghostty", match = { class = "Ghostty" }, scroll_touchpad = 1.50 }) +hl.window_rule({ name = "scroll-chromium", match = { class = "^(Chromium|chromium|google-chrome|google-chrome-stable|google-chrome-unstable)$" }, scroll_touchpad = 0.25 }) diff --git a/hypr/.config/hypr/hosts/laptop/looknfeel.conf b/hypr/.config/hypr/hosts/laptop/looknfeel.conf deleted file mode 100644 index 9fae41a7..00000000 --- a/hypr/.config/hypr/hosts/laptop/looknfeel.conf +++ /dev/null @@ -1,8 +0,0 @@ -# Laptop look overrides. -general { - gaps_in = 0 - col.active_border = rgba(ccccccaa) -} - -# Smaller floating terminal for SUPER+SHIFT+Q launcher -windowrule = size 760 500, match:class ^org\.omarchy\.terminal$ diff --git a/hypr/.config/hypr/hosts/laptop/looknfeel.lua b/hypr/.config/hypr/hosts/laptop/looknfeel.lua new file mode 100644 index 00000000..39738640 --- /dev/null +++ b/hypr/.config/hypr/hosts/laptop/looknfeel.lua @@ -0,0 +1,11 @@ +hl.config({ + general = { + gaps_in = 0, + col = { + active_border = "rgba(ccccccaa)", + }, + }, +}) + +-- Smaller floating terminal for SUPER+SHIFT+Q launcher +hl.window_rule({ name = "floating-terminal-size", match = { class = "^org\\.omarchy\\.terminal$" }, size = "760 500" }) diff --git a/hypr/.config/hypr/hosts/laptop/monitors.conf b/hypr/.config/hypr/hosts/laptop/monitors.conf deleted file mode 100644 index bce59c20..00000000 --- a/hypr/.config/hypr/hosts/laptop/monitors.conf +++ /dev/null @@ -1,24 +0,0 @@ -# See https://wiki.hyprland.org/Configuring/Monitors/ -# List current monitors and resolutions possible: hyprctl monitors -# Format: monitor = [port], resolution, position, scale -# You must relaunch Hyprland after changing any envs (use Super+Esc, then Relaunch) - -# Optimized for retina-class 2x displays, like 13" 2.8K, 27" 5K, 32" 6K. -# env = GDK_SCALE,2 -# monitor=,preferred,auto,auto - -# Good compromise for 27" or 32" 4K monitors (but fractional!) -# env = GDK_SCALE,1.75 -# monitor=,preferred,auto,1.666667 - -# Straight 1x setup for low-resolution displays like 1080p or 1440p -# env = GDK_SCALE,1 -# monitor=,preferred,auto,1 - -# Example for Framework 13 w/ 6K XDR Apple display -# monitor = DP-5, 6016x3384@60, auto, 2 -# monitor = eDP-1, 2880x1920@120, auto, 2 - -env = GDK_SCALE,2 -env = QT_SCALE_FACTOR,2.0 -monitor=,preferred,auto,2.0 diff --git a/hypr/.config/hypr/hosts/laptop/monitors.lua b/hypr/.config/hypr/hosts/laptop/monitors.lua new file mode 100644 index 00000000..0958150b --- /dev/null +++ b/hypr/.config/hypr/hosts/laptop/monitors.lua @@ -0,0 +1,10 @@ +local machine = require("hypr.lib.machine") + +local is_virtual_machine = machine.is_virtual_machine() +local omarchy_gdk_scale = is_virtual_machine and 1 or 2 +local omarchy_monitor_scale = is_virtual_machine and 1.0 or 2.0 + +hl.env("GDK_SCALE", tostring(omarchy_gdk_scale)) +hl.env("QT_SCALE_FACTOR", tostring(omarchy_gdk_scale)) + +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale }) diff --git a/hypr/.config/hypr/hypridle.conf b/hypr/.config/hypr/hypridle.conf deleted file mode 100644 index 31a8d71a..00000000 --- a/hypr/.config/hypr/hypridle.conf +++ /dev/null @@ -1,2 +0,0 @@ -# Host-specific override selected by dot via ~/.config/hypr/host. -source = ~/.config/hypr/host/hypridle.conf diff --git a/hypr/.config/hypr/hyprland.conf b/hypr/.config/hypr/hyprland.conf deleted file mode 100644 index 52f1fb9a..00000000 --- a/hypr/.config/hypr/hyprland.conf +++ /dev/null @@ -1,28 +0,0 @@ -# Learn how to configure Hyprland: https://wiki.hypr.land/Configuring/ - -# Use defaults Omarchy defaults (but don't edit these directly!) -source = ~/.local/share/omarchy/default/hypr/autostart.conf -source = ~/.local/share/omarchy/default/hypr/bindings/media.conf -source = ~/.local/share/omarchy/default/hypr/bindings/clipboard.conf -source = ~/.local/share/omarchy/default/hypr/bindings/tiling-v2.conf -source = ~/.local/share/omarchy/default/hypr/bindings/utilities.conf -source = ~/.local/share/omarchy/default/hypr/envs.conf -source = ~/.local/share/omarchy/default/hypr/looknfeel.conf -source = ~/.local/share/omarchy/default/hypr/input.conf -source = ~/.local/share/omarchy/default/hypr/windows.conf -source = ~/.config/omarchy/current/theme/hyprland.conf - -# Change your own setup in these files (and overwrite any settings from defaults!) -# envs first so env vars (Nvidia, cursor theme) are set before monitors/autostart apps launch -source = ~/.config/hypr/envs.conf -source = ~/.config/hypr/monitors.conf -source = ~/.config/hypr/input.conf -source = ~/.config/hypr/bindings.conf -source = ~/.config/hypr/looknfeel.conf -source = ~/.config/hypr/autostart.conf - -# Toggle config flags dynamically -source = ~/.local/state/omarchy/toggles/hypr/*.conf - -# Add any other personal Hyprland configuration below -# windowrule = workspace 5, match:class qemu diff --git a/hypr/.config/hypr/hyprland.lua b/hypr/.config/hypr/hyprland.lua new file mode 100644 index 00000000..1c7610e7 --- /dev/null +++ b/hypr/.config/hypr/hyprland.lua @@ -0,0 +1,24 @@ +-- Omarchy 4.0 Lua entrypoint. + +package.path = os.getenv("HOME") + .. "/.local/state/?.lua;" + .. os.getenv("HOME") + .. "/.config/?.lua;" + .. (os.getenv("OMARCHY_PATH") or (os.getenv("HOME") .. "/.local/share/omarchy")) + .. "/?.lua;" + .. package.path + +-- Omarchy defaults and current theme overrides. +require("default.hypr.omarchy") + +-- Local user overrides. +-- envs first so env vars (Nvidia, cursor theme) are set before monitors/autostart apps launch +require("hypr.envs") +require("hypr.monitors") +require("hypr.looknfeel") +require("hypr.input") +require("hypr.bindings") +require("hypr.autostart") + +-- Dynamic Omarchy toggles. +require("default.hypr.toggles") diff --git a/hypr/.config/hypr/hyprlock.conf b/hypr/.config/hypr/hyprlock.conf deleted file mode 100644 index dd2436c7..00000000 --- a/hypr/.config/hypr/hyprlock.conf +++ /dev/null @@ -1,2 +0,0 @@ -# Host-specific override selected by dot via ~/.config/hypr/host. -source = ~/.config/hypr/host/hyprlock.conf diff --git a/hypr/.config/hypr/input.conf b/hypr/.config/hypr/input.conf deleted file mode 100644 index 94185ee1..00000000 --- a/hypr/.config/hypr/input.conf +++ /dev/null @@ -1,21 +0,0 @@ -# Control your input devices -# See https://wiki.hypr.land/Configuring/Variables/#input -input { - kb_layout = gb - kb_options = compose:caps # ,grp:alt_space_toggle - - # Change speed of keyboard repeat - repeat_rate = 40 - repeat_delay = 600 - - # Increase sensitity for mouse/trackpad (default: 0) - sensitivity = 0 - - # Set the acceleration profile (adaptive, flat, custom, unset for libinput defaults) - accel_profile = flat -} - -# Host-specific input additions selected by dot via ~/.config/hypr/host. -# hyprlang noerror true -source = ~/.config/hypr/host/input.conf -# hyprlang noerror false diff --git a/hypr/.config/hypr/input.lua b/hypr/.config/hypr/input.lua new file mode 100644 index 00000000..6f405422 --- /dev/null +++ b/hypr/.config/hypr/input.lua @@ -0,0 +1,18 @@ +hl.config({ + input = { + kb_layout = "gb", + kb_options = "compose:caps", + repeat_rate = 40, + repeat_delay = 600, + sensitivity = 0, + numlock_by_default = true, + accel_profile = "flat", + touchpad = { + disable_while_typing = true, + natural_scroll = true, + clickfinger_behavior = true, + }, + }, +}) + +require("hypr.host.input") diff --git a/hypr/.config/hypr/lib/machine.lua b/hypr/.config/hypr/lib/machine.lua new file mode 100644 index 00000000..571fe8c8 --- /dev/null +++ b/hypr/.config/hypr/lib/machine.lua @@ -0,0 +1,34 @@ +local M = {} + +local function read_file(path) + local file = io.open(path, "r") + if not file then return "" end + + local value = file:read("*a") or "" + file:close() + + return value:lower() +end + +local function contains_virtual_machine_marker(value) + return value:find("qemu", 1, true) + or value:find("kvm", 1, true) + or value:find("virtualbox", 1, true) + or value:find("vmware", 1, true) + or value:find("parallels", 1, true) + or value:find("hyper%-v") + or value:find("bochs", 1, true) +end + +function M.is_virtual_machine() + local dmi = table.concat({ + read_file("/sys/class/dmi/id/sys_vendor"), + read_file("/sys/class/dmi/id/product_name"), + read_file("/sys/class/dmi/id/board_vendor"), + read_file("/sys/class/dmi/id/board_name"), + }, "\n") + + return contains_virtual_machine_marker(dmi) ~= nil +end + +return M diff --git a/hypr/.config/hypr/looknfeel.conf b/hypr/.config/hypr/looknfeel.conf deleted file mode 100644 index f8786d78..00000000 --- a/hypr/.config/hypr/looknfeel.conf +++ /dev/null @@ -1,34 +0,0 @@ -# https://wiki.hypr.land/Configuring/Basics/Variables/#general -# Only our overrides; everything else inherited from default/hypr/looknfeel.conf -general { - gaps_out = 0 - border_size = 1 - resize_on_border = true -} - -# Smart gaps -workspace = w[tv1], gapsout:0, gapsin:0 -workspace = f[1], gapsout:0, gapsin:0 -windowrule = border_size 0, rounding 0, match:float 0, match:workspace w[tv1] -windowrule = border_size 0, rounding 0, match:float 0, match:workspace f[1] - -# Chrome opacity (patch for ~/.local/share/omarchy/default/hypr/apps/chromium.conf) -windowrule = tag +chromium-based-browser, match:class (google-)?[cC]hrom(e|ium)(-stable|-unstable)?|[bB]rave-browser|Microsoft-edge|Vivaldi-stable -windowrule = opacity 1 1, match:tag chromium-based-browser - -# Video websites -windowrule = opacity 1 1, match:initial_title .*twitch\.tv.* -windowrule = opacity 1 1, match:initial_title .*youtube\.com.* -windowrule = opacity 1 1, match:initial_title .*corridordigital\.com.* -windowrule = opacity 1 1, match:initial_title .*floatplane\.com.* -windowrule = opacity 1 1, match:initial_title .*vivaplus\.tv.* -windowrule = opacity 1 1, match:title .*Home Assistant.* -windowrule = opacity 1 1, match:class ^virt-manager$ - -# Shared workspace rules -windowrule = workspace 4, match:class ^(BambuStudio|OrcaSlicer)$ - -# Host-specific look and window rules selected by dot via ~/.config/hypr/host. -# hyprlang noerror true -source = ~/.config/hypr/host/looknfeel.conf -# hyprlang noerror false diff --git a/hypr/.config/hypr/looknfeel.lua b/hypr/.config/hypr/looknfeel.lua new file mode 100644 index 00000000..018b6983 --- /dev/null +++ b/hypr/.config/hypr/looknfeel.lua @@ -0,0 +1,36 @@ +hl.config({ + general = { + gaps_out = 0, + border_size = 1, + col = { + inactive_border = "rgba(595959aa)", + }, + resize_on_border = true, + allow_tearing = false, + layout = "dwindle", + }, +}) + +-- Smart gaps +hl.workspace_rule({ workspace = "w[tv1]", gaps_out = 0, gaps_in = 0 }) +hl.workspace_rule({ workspace = "f[1]", gaps_out = 0, gaps_in = 0 }) +hl.window_rule({ name = "smart-gaps-wtv1", match = { float = false, workspace = "w[tv1]" }, border_size = 0, rounding = 0 }) +hl.window_rule({ name = "smart-gaps-f1", match = { float = false, workspace = "f[1]" }, border_size = 0, rounding = 0 }) + +-- Chrome opacity override. +hl.window_rule({ name = "tag-chromium-based-browser", match = { class = "(google-)?[cC]hrom(e|ium)(-stable|-unstable)?|[bB]rave-browser|Microsoft-edge|Vivaldi-stable" }, tag = "+chromium-based-browser" }) +hl.window_rule({ name = "opaque-chromium-based-browser", match = { tag = "chromium-based-browser" }, opacity = "1 1" }) + +-- Video websites and Home Assistant should be opaque. +hl.window_rule({ name = "opaque-twitch", match = { initial_title = ".*twitch\\.tv.*" }, opacity = "1 1" }) +hl.window_rule({ name = "opaque-youtube", match = { initial_title = ".*youtube\\.com.*" }, opacity = "1 1" }) +hl.window_rule({ name = "opaque-corridor", match = { initial_title = ".*corridordigital\\.com.*" }, opacity = "1 1" }) +hl.window_rule({ name = "opaque-floatplane", match = { initial_title = ".*floatplane\\.com.*" }, opacity = "1 1" }) +hl.window_rule({ name = "opaque-vivaplus", match = { initial_title = ".*vivaplus\\.tv.*" }, opacity = "1 1" }) +hl.window_rule({ name = "opaque-home-assistant", match = { title = ".*Home Assistant.*" }, opacity = "1 1" }) +hl.window_rule({ name = "opaque-virt-manager", match = { class = "^virt-manager$" }, opacity = "1 1" }) + +-- Shared workspace rules. +hl.window_rule({ name = "workspace-slicers", match = { class = "^(BambuStudio|OrcaSlicer)$" }, workspace = "4" }) + +require("hypr.host.looknfeel") diff --git a/hypr/.config/hypr/monitors.conf b/hypr/.config/hypr/monitors.conf deleted file mode 100644 index a1860e69..00000000 --- a/hypr/.config/hypr/monitors.conf +++ /dev/null @@ -1,4 +0,0 @@ -# Host-specific override selected by dot via ~/.config/hypr/host. -# hyprlang noerror true -source = ~/.config/hypr/host/monitors.conf -# hyprlang noerror false diff --git a/hypr/.config/hypr/monitors.lua b/hypr/.config/hypr/monitors.lua new file mode 100644 index 00000000..92eee8a4 --- /dev/null +++ b/hypr/.config/hypr/monitors.lua @@ -0,0 +1 @@ +require("hypr.host.monitors") diff --git a/omarchy/.config/omarchy/plugins/timmo.command/Widget.qml b/omarchy/.config/omarchy/plugins/timmo.command/Widget.qml new file mode 100644 index 00000000..862b9731 --- /dev/null +++ b/omarchy/.config/omarchy/plugins/timmo.command/Widget.qml @@ -0,0 +1,189 @@ +// timmo.command — generic polling command bar widget. +// +// Runs `exec` on an interval and renders the resulting status-bar JSON +// (text/tooltip/class) via a WidgetButton, mapping the parsed class to a +// colour and hiding on configured classes. The Waybar custom/* equivalent +// for the Omarchy 4 Quickshell bar. +// +// Per-instance settings (inline on the shell.json bar layout entry): +// run Shell command to run (its stdout is parsed) +// interval Poll interval in milliseconds (default 60000) +// returnType "json" (text/tooltip/class) or "text" (default "json") +// tooltip Whether to show the JSON tooltip (default true) +// onClick Command run on left click +// onClickRight Command run on right click +// onMiddleClick Command run on middle click +// classColors Map of class name -> colour string +// hideClasses Array of class names that hide the widget +// refreshTarget Optional IPC target id exposing a refresh() method +// loadingText Placeholder shown while (re)loading (e.g. "\uf418 ..") +// loadingClass Class used to colour loadingText (e.g. "dots-unknown") +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +BarWidget { + id: root + + readonly property string exec: setting("run", "") + readonly property int intervalMs: setting("interval", 60000) + readonly property string returnType: setting("returnType", "json") + readonly property bool tooltipEnabled: setting("tooltip", true) + readonly property string onClickCmd: setting("onClick", "") + readonly property string onClickRightCmd: setting("onClickRight", "") + readonly property string onMiddleClickCmd: setting("onMiddleClick", "") + readonly property var classColors: setting("classColors", ({})) + readonly property var hideClasses: setting("hideClasses", []) + readonly property string refreshTarget: setting("refreshTarget", "") + readonly property string loadingText: setting("loadingText", "") + readonly property string loadingClass: setting("loadingClass", "") + readonly property bool revealOnHover: setting("revealOnHover", false) + // Horizontal cell margin, standard 8px across all custom widgets (center, + // right HA, left). The built-in right-side stock widgets keep their own + // margins. Per-instance overridable via the `horizontalMargin` setting. + readonly property real cellMargin: setting("horizontalMargin", 8) + + property string outText: "" + property string outTooltip: "" + property string outClass: "" + property bool loading: false + + readonly property bool hiddenByClass: { + var cls = root.outClass + for (var i = 0; i < root.hideClasses.length; i++) { + if (root.hideClasses[i] === cls) return true + } + return false + } + + // When this is a center widget (revealOnHover, set by the generator) and the + // center cluster is hovered, reveal an otherwise class-hidden module dimmed, + // mirroring the idle indicators. Needs real text (the icon) to show. + readonly property bool hoverRevealed: root.revealOnHover + && root.hiddenByClass + && root.outText !== "" + && !!root.bar + && root.bar.centerSectionRevealHeld === true + + // Whether this widget has anything to draw: the loading placeholder, a + // non-empty value that is not hidden by class, or a class-hidden value + // revealed by hovering the center cluster. Mirrors the WidgetButton `text` + // binding below. Drives `visible`/`implicitWidth` so a hidden module + // collapses to zero width and the bar reflows, instead of reserving the + // button's minimum width as an empty gap. + readonly property bool shown: (root.loading && root.loadingText !== "") + || (!root.hiddenByClass && root.outText !== "") + || root.hoverRevealed + + // Background poll (timer): only show the loading placeholder on a cold + // start when there is no value yet, so warm polls update smoothly. + function poll() { + if (root.exec === "") return + if (root.outText === "" && root.loadingText !== "") root.loading = true + if (!proc.running) proc.running = true + } + + // Explicit refresh (IPC / resume): always show the loading placeholder so + // the reload is visible, like the legacy grey "loading" state. + function refresh() { + if (root.exec === "") return + if (root.loadingText !== "") root.loading = true + if (!proc.running) proc.running = true + } + + function applyOutput(raw) { + var trimmed = (raw || "").trim() + if (trimmed === "") { + root.outText = "" + root.outTooltip = "" + root.outClass = "" + return + } + if (root.returnType === "json") { + try { + var obj = JSON.parse(trimmed) + root.outText = obj.text !== undefined && obj.text !== null ? String(obj.text) : "" + root.outTooltip = obj.tooltip !== undefined && obj.tooltip !== null ? String(obj.tooltip) : "" + if (obj["class"] !== undefined && obj["class"] !== null) root.outClass = String(obj["class"]) + else if (obj.alt !== undefined && obj.alt !== null) root.outClass = String(obj.alt) + else root.outClass = "" + return + } catch (e) { + // Not JSON — fall through to plain text. + } + } + root.outText = trimmed + root.outTooltip = "" + root.outClass = "" + } + + function colorForClass(cls) { + if (cls && root.classColors && root.classColors[cls]) return root.classColors[cls] + return root.bar ? root.bar.barForeground : Color.foreground + } + + visible: root.shown + implicitWidth: root.shown ? button.implicitWidth : 0 + implicitHeight: button.implicitHeight + + Process { + id: proc + command: ["bash", "-lc", root.exec] + stdout: StdioCollector { + id: outCollector + waitForEnd: true + } + onExited: function (exitCode) { + root.applyOutput(outCollector.text) + root.loading = false + } + } + + Timer { + interval: Math.max(1000, root.intervalMs) + running: root.exec !== "" + repeat: true + triggeredOnStart: true + onTriggered: root.poll() + } + + Loader { + active: root.refreshTarget !== "" + sourceComponent: Component { + IpcHandler { + target: root.refreshTarget + function refresh(): void { + root.refresh() + } + } + } + } + + WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + // Match the stock right-side indicators (audio/network/tray), which render + // at caption size. The clock/weather sit at body, but their Weather-Icons + // and digit glyphs are visually lighter than the Material Design / Font + // Awesome icons these modules use, so caption keeps the icons in step. + fontSize: Style.font.caption + horizontalMargin: root.cellMargin + text: root.loading && root.loadingText !== "" ? root.loadingText : ((root.hiddenByClass && !root.hoverRevealed) ? "" : root.outText) + dimmed: root.hoverRevealed + tooltipText: root.tooltipEnabled ? root.outTooltip : "" + foreground: root.loading && root.loadingText !== "" ? root.colorForClass(root.loadingClass) : root.colorForClass(root.outClass) + onPressed: function (b) { + if (!root.bar) return + if (b === Qt.RightButton) { + if (root.onClickRightCmd !== "") root.bar.run(root.onClickRightCmd) + } else if (b === Qt.MiddleButton) { + if (root.onMiddleClickCmd !== "") root.bar.run(root.onMiddleClickCmd) + } else { + if (root.onClickCmd !== "") root.bar.run(root.onClickCmd) + } + } + } +} diff --git a/omarchy/.config/omarchy/plugins/timmo.command/manifest.json b/omarchy/.config/omarchy/plugins/timmo.command/manifest.json new file mode 100644 index 00000000..ccce89ac --- /dev/null +++ b/omarchy/.config/omarchy/plugins/timmo.command/manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "timmo.command", + "name": "Command", + "version": "1.0.0", + "author": "Aidan Timson", + "description": "Runs a shell command on an interval and renders its status-bar JSON (text/tooltip/class) output", + "kinds": ["bar-widget"], + "entryPoints": { "barWidget": "Widget.qml" }, + "barWidget": { + "displayName": "Command", + "description": "Polling command module (the Waybar custom/* equivalent)", + "category": "Custom", + "allowMultiple": true + } +} diff --git a/omarchy/.config/omarchy/plugins/timmo.stream-command/Widget.qml b/omarchy/.config/omarchy/plugins/timmo.stream-command/Widget.qml new file mode 100644 index 00000000..1f21f0ba --- /dev/null +++ b/omarchy/.config/omarchy/plugins/timmo.stream-command/Widget.qml @@ -0,0 +1,145 @@ +// timmo.stream-command — streaming command bar widget. +// +// Runs a long-running `exec` that emits newline-delimited status-bar JSON +// (text/tooltip/class) and renders the most recent line. Auto-restarts the +// process after it exits. Used for Home Assistant watchers (ha-watch-singleton +// / ha-bar-module doorbell) that the Omarchy 4 bar cannot drive as one-shot +// polling command modules. +// +// Per-instance settings (inline on the shell.json bar layout entry): +// run Long-running command emitting JSON lines on stdout +// tooltip Whether to show the JSON tooltip (default true) +// onClick Command run on left click +// onClickRight Command run on right click +// onMiddleClick Command run on middle click +// classColors Map of class name -> colour string +// hideClasses Array of class names that hide the widget +// restartInterval Delay before restarting after exit, ms (default 5000) +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +BarWidget { + id: root + + readonly property string exec: setting("run", "") + readonly property bool tooltipEnabled: setting("tooltip", true) + readonly property string onClickCmd: setting("onClick", "") + readonly property string onClickRightCmd: setting("onClickRight", "") + readonly property string onMiddleClickCmd: setting("onMiddleClick", "") + readonly property var classColors: setting("classColors", ({})) + readonly property var hideClasses: setting("hideClasses", []) + readonly property int restartDelayMs: setting("restartInterval", 5000) + readonly property bool revealOnHover: setting("revealOnHover", false) + // Horizontal cell margin, standard 8px across all custom widgets (center, + // right HA, left). The built-in right-side stock widgets keep their own + // margins. Per-instance overridable via the `horizontalMargin` setting. + readonly property real cellMargin: setting("horizontalMargin", 8) + + property string outText: "" + property string outTooltip: "" + property string outClass: "" + + readonly property bool hiddenByClass: { + var cls = root.outClass + for (var i = 0; i < root.hideClasses.length; i++) { + if (root.hideClasses[i] === cls) return true + } + return false + } + + // When this is a center widget (revealOnHover, set by the generator) and the + // center cluster is hovered, reveal an otherwise class-hidden module dimmed, + // mirroring the idle indicators. Needs real text (the icon) to show. + readonly property bool hoverRevealed: root.revealOnHover + && root.hiddenByClass + && root.outText !== "" + && !!root.bar + && root.bar.centerSectionRevealHeld === true + + // Whether this widget has anything to draw: a non-empty value that is not + // hidden by class, or a class-hidden value revealed by hovering the center + // cluster. Mirrors the WidgetButton `text` binding below. Drives + // `visible`/`implicitWidth` so a hidden module collapses to zero width and + // the bar reflows, instead of reserving the button's minimum width as an + // empty gap. + readonly property bool shown: (!root.hiddenByClass && root.outText !== "") + || root.hoverRevealed + + function applyOutput(raw) { + var trimmed = (raw || "").trim() + if (trimmed === "") return + try { + var obj = JSON.parse(trimmed) + root.outText = obj.text !== undefined && obj.text !== null ? String(obj.text) : "" + root.outTooltip = obj.tooltip !== undefined && obj.tooltip !== null ? String(obj.tooltip) : "" + if (obj["class"] !== undefined && obj["class"] !== null) root.outClass = String(obj["class"]) + else if (obj.alt !== undefined && obj.alt !== null) root.outClass = String(obj.alt) + else root.outClass = "" + } catch (e) { + root.outText = trimmed + root.outTooltip = "" + root.outClass = "" + } + } + + function colorForClass(cls) { + if (cls && root.classColors && root.classColors[cls]) return root.classColors[cls] + return root.bar ? root.bar.barForeground : Color.foreground + } + + visible: root.shown + implicitWidth: root.shown ? button.implicitWidth : 0 + implicitHeight: button.implicitHeight + + Process { + id: proc + running: root.exec !== "" + command: ["bash", "-lc", root.exec] + stdout: SplitParser { + onRead: function (line) { + root.applyOutput(line) + } + } + onExited: function (exitCode) { + if (root.exec !== "") restartTimer.start() + } + } + + Timer { + id: restartTimer + interval: Math.max(1000, root.restartDelayMs) + repeat: false + onTriggered: { + if (root.exec !== "" && !proc.running) proc.running = true + } + } + + WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + // Match the stock right-side indicators (audio/network/tray), which render + // at caption size. The clock/weather sit at body, but their Weather-Icons + // and digit glyphs are visually lighter than the Material Design / Font + // Awesome icons these modules use, so caption keeps the icons in step. + fontSize: Style.font.caption + horizontalMargin: root.cellMargin + text: (root.hiddenByClass && !root.hoverRevealed) ? "" : root.outText + dimmed: root.hoverRevealed + tooltipText: root.tooltipEnabled ? root.outTooltip : "" + foreground: root.colorForClass(root.outClass) + onPressed: function (b) { + if (!root.bar) return + if (b === Qt.RightButton) { + if (root.onClickRightCmd !== "") root.bar.run(root.onClickRightCmd) + } else if (b === Qt.MiddleButton) { + if (root.onMiddleClickCmd !== "") root.bar.run(root.onMiddleClickCmd) + } else { + if (root.onClickCmd !== "") root.bar.run(root.onClickCmd) + } + } + } +} diff --git a/omarchy/.config/omarchy/plugins/timmo.stream-command/manifest.json b/omarchy/.config/omarchy/plugins/timmo.stream-command/manifest.json new file mode 100644 index 00000000..aba43324 --- /dev/null +++ b/omarchy/.config/omarchy/plugins/timmo.stream-command/manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "timmo.stream-command", + "name": "Stream command", + "version": "1.0.0", + "author": "Aidan Timson", + "description": "Runs a long-running command that streams status-bar JSON lines and renders the latest line", + "kinds": ["bar-widget"], + "entryPoints": { "barWidget": "Widget.qml" }, + "barWidget": { + "displayName": "Stream command", + "description": "Streaming command module for long-running watchers (e.g. ha-watch-singleton)", + "category": "Custom", + "allowMultiple": true + } +} diff --git a/omarchy/.config/omarchy/plugins/timmo.workspaces/Widget.qml b/omarchy/.config/omarchy/plugins/timmo.workspaces/Widget.qml new file mode 100644 index 00000000..9a611b44 --- /dev/null +++ b/omarchy/.config/omarchy/plugins/timmo.workspaces/Widget.qml @@ -0,0 +1,101 @@ +// timmo.workspaces — dynamic Hyprland workspace indicators. +// +// A fork of the stock omarchy.workspaces widget with three differences: +// * No persistent workspaces. The stock widget always seeds 1-5; this one +// shows only workspaces that currently exist (the occupied ones plus the +// focused one), matching the old Waybar behaviour. +// * Every workspace renders as its number. The stock widget swaps the +// focused workspace for a glyph icon; here the focused workspace keeps +// its number. +// * The focused workspace sits at full opacity and the rest are dimmed, +// so the active workspace reads as "number + full opacity". +// +// Per-instance settings (inline on the shell.json bar layout entry): +// activeOpacity Opacity of the focused workspace (default 1.0) +// inactiveOpacity Opacity of the other existing workspaces (default 0.5) +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland +import qs.Commons +import qs.Ui + +BarWidget { + id: root + moduleName: "timmo.workspaces" + + readonly property real activeOpacity: setting("activeOpacity", 1.0) + readonly property real inactiveOpacity: setting("inactiveOpacity", 0.5) + + function workspaceById(id) { + var values = Hyprland.workspaces.values + for (var i = 0; i < values.length; i++) { + if (values[i].id === id) return values[i] + } + + return null + } + + // Only workspaces that currently exist: every workspace Hyprland reports + // (occupied ones, and the focused one which always exists) within the 1-10 + // range. No persistent seeding, so empty workspaces collapse away. + function workspaceIds() { + var ids = [] + var values = Hyprland.workspaces.values + + for (var i = 0; i < values.length; i++) { + var id = values[i].id + if (id > 0 && id <= 10 && ids.indexOf(id) === -1) ids.push(id) + } + + // Defensive: the focused workspace always exists in Hyprland, but make + // sure it is present even if the model has not caught up yet. + var focused = Hyprland.focusedWorkspace + if (focused !== null && focused.id > 0 && focused.id <= 10 && ids.indexOf(focused.id) === -1) + ids.push(focused.id) + + ids.sort(function(left, right) { return left - right }) + return ids + } + + function focusWorkspace(id) { + if (!root.bar) return + root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })")) + } + + // Gap before the first workspace, to separate it from the menu icon on its + // left. Tunable via the `leadingGap` setting (in Style space units). + readonly property real leadingGap: root.vertical ? 0 : Style.spaceReal(setting("leadingGap", 6)) + readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5) + + implicitWidth: grid.implicitWidth + leadingGap + trailingGap + implicitHeight: grid.implicitHeight + + GridLayout { + id: grid + anchors.fill: parent + anchors.leftMargin: root.leadingGap + anchors.rightMargin: root.trailingGap + columns: root.vertical ? 1 : Math.max(1, root.workspaceIds().length) + columnSpacing: root.vertical ? 0 : Style.space(1) + rowSpacing: root.vertical ? Style.space(2) : 0 + + Repeater { + model: root.workspaceIds() + + WidgetButton { + required property int modelData + + readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData + + bar: root.bar + text: modelData === 10 ? "0" : String(modelData) + opacity: focused ? root.activeOpacity : root.inactiveOpacity + horizontalMargin: 6 + verticalPadding: 6 + fixedWidth: root.vertical ? root.barSize : Style.space(20) + fixedHeight: root.barSize + onPressed: function() { root.focusWorkspace(modelData) } + } + } + } +} diff --git a/omarchy/.config/omarchy/plugins/timmo.workspaces/manifest.json b/omarchy/.config/omarchy/plugins/timmo.workspaces/manifest.json new file mode 100644 index 00000000..0c76f742 --- /dev/null +++ b/omarchy/.config/omarchy/plugins/timmo.workspaces/manifest.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "timmo.workspaces", + "name": "Workspaces", + "version": "1.0.0", + "author": "Aidan Timson", + "description": "Workspace number indicators without persistent workspaces; the focused workspace is shown at full opacity and the rest are dimmed", + "kinds": ["bar-widget"], + "entryPoints": { "barWidget": "Widget.qml" }, + "barWidget": { + "displayName": "Workspaces (Timmo)", + "description": "Dynamic workspace numbers: only existing workspaces, focused at full opacity, others dimmed", + "category": "Compositor", + "allowMultiple": false + } +} diff --git a/opencode.json b/opencode.json index 3bee63f2..ee5a982e 100644 --- a/opencode.json +++ b/opencode.json @@ -5,12 +5,10 @@ "notes_note_write": "ask", "notes_note_delete": "ask", "external_directory": { + "~/.agents/skills/**": "allow", "~/.config/opencode/**": "allow", "~/.config/hypr/**": "allow", - "~/.config/hypr-desktop/**": "allow", - "~/.config/hypr-laptop/**": "allow", - "~/.config/waybar/**": "allow", - "~/.agents/skills/**": "allow", + "~/.local/share/omarchy/**": "allow", "/tmp/**": "allow" } }, @@ -29,7 +27,7 @@ }, "go-automate": { "repository": "timmo001/go-automate", - "description": "Use for the go-automate CLI and HA bridge invoked by Waybar, Hypr binds, and dotfiles scripts" + "description": "Use for the go-automate CLI and HA bridge invoked by the Omarchy shell, Hypr binds, and dotfiles scripts" }, "notes": { "repository": "timmo001/notes", @@ -38,7 +36,7 @@ "omarchy": { "repository": "basecamp/omarchy", "branch": "master", - "description": "Use for Omarchy desktop, Hyprland, and Waybar customisation details" + "description": "Use for Omarchy desktop, Hyprland, and Quickshell shell customisation details" }, "opencode": { "repository": "anomalyco/opencode", @@ -52,6 +50,10 @@ "repository": "anomalyco/opentui", "description": "Use for OpenTUI core, renderables, and keyboard handling when working in dot/" }, + "quickshell": { + "repository": "quickshell-mirror/quickshell", + "description": "Use for upstream Quickshell QML types, WlrLayershell/layer-shell, IpcHandler, reloadable config, and the qs CLI when customising the Omarchy shell bar widgets" + }, "system-bridge": { "repository": "timmo001/system-bridge", "description": "Use for the system-bridge backend autostarted via Hypr and its API/WebSocket behaviour" diff --git a/renovate.json b/renovate.json index aa2e6be3..2543ceb9 100644 --- a/renovate.json +++ b/renovate.json @@ -8,11 +8,27 @@ ], "dependencyDashboard": true, "labels": ["dependencies"], + "customManagers": [ + { + "description": "Keep QUICKSHELL_VERSION in sync with the Arch quickshell package (what the Omarchy shell runs on)", + "customType": "regex", + "managerFilePatterns": ["/^\\.github/workflows/quickshell-lint\\.ya?ml$/"], + "matchStrings": ["QUICKSHELL_VERSION: \"(?[^\"]+)\""], + "depNameTemplate": "arch/quickshell", + "datasourceTemplate": "repology", + "versioningTemplate": "loose" + } + ], "packageRules": [ { "description": "Group GitHub Actions.", "groupName": "github actions", "matchManagers": ["github-actions"] + }, + { + "description": "Group the Quickshell lint version updates.", + "groupName": "quickshell lint version", + "matchDepNames": ["arch/quickshell"] } ] } diff --git a/scripts/.local/bin/ha-bar-module b/scripts/.local/bin/ha-bar-module new file mode 100755 index 00000000..a1ecdcef --- /dev/null +++ b/scripts/.local/bin/ha-bar-module @@ -0,0 +1,765 @@ +#!/usr/bin/env bash + +set -euo pipefail + +MODE="" +ENTITY_ID="" +ENTITY_NAME="" +ICON="" +UNIT="" +ICON_PREFIX="" +UNIT_SUFFIX="" +QUALITY_ENTITY="" +VALUE_ENTITY="" +SWITCH_ENTITY="" +INACTIVE_SCRIPT_ENTITY="" +SIMULATE_STATE="" +FORCE_TRUE=0 +FAKE_STATE="" +STREAM_KEY="" + +TRIGGER_STATE="" +TRIGGER_COMMAND="" +TRIGGER_ON="transition" +TRIGGER_INITIAL=0 +TRIGGER_COOLDOWN=0 +TRIGGER_KEY="" + +BAR_PARENT_PID="" +BAR_PARENT_STARTTIME="" +LAST_OUTPUT="" + +usage() { + cat <<'EOF' +Usage: ha-bar-module [options] + +Emits single-line status-bar JSON (text/class/tooltip) for Home Assistant +entities. Polling modes print one line and exit; the doorbell mode streams +continuous lines for a long-running bar widget. + +Modes: + temperature + co2-alert + voc-alert + nas-activity + current-next-event + doorbell + +Common options: + --entity Primary entity id + --name