Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
26 changed files with 370 additions and 1543 deletions
|
|
@ -1,24 +0,0 @@
|
|||
name: check
|
||||
|
||||
# Fast-fail lint/test on short-lived work branches, before it ever reaches
|
||||
# main and triggers a dev-track release build.
|
||||
on:
|
||||
push:
|
||||
branches: ['feature/**', 'fix/**']
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: clippy
|
||||
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
- name: test
|
||||
run: cd src && bash ci/build.sh cargo test --workspace --locked
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
name: dev release
|
||||
|
||||
# Publishes a dev-track build on every push to `main` (the trunk
|
||||
# branch — there is no separate `dev` branch). See bread-ecosystem's
|
||||
# docs/release-channels.md for the release-track policy this is part of.
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch main --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||
|
||||
- name: compute dev version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
# Base the dev version off the latest published stable tag,
|
||||
# not Cargo.toml — Cargo.toml can go stale relative to the last
|
||||
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
|
||||
# breadpaper), which would make a dev build sort as OLDER than
|
||||
# what's already installed and bakery would correctly refuse it.
|
||||
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||
if [ -n "${LATEST_TAG}" ]; then
|
||||
CUR="${LATEST_TAG}"
|
||||
else
|
||||
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||
fi
|
||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/breadmon/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/breadmon" "${PKG_DIR}/breadmon-x86_64"
|
||||
strip "${PKG_DIR}/breadmon-x86_64"
|
||||
sha256sum "${PKG_DIR}/breadmon-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/breadmon-x86_64.sha256"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadmon/latest"
|
||||
|
||||
# No GitHub Release upload — dev, like the other non-stable track,
|
||||
# is only distributed via dl.breadway.dev/dev/.
|
||||
- name: regenerate dev index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||
# workflows run close together on the same self-hosted runner.
|
||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
name: beta (rc) release
|
||||
|
||||
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
|
||||
# pushed to `main` — there is no separate `beta` branch; "freezing" is
|
||||
# just pausing pushes to main while an RC gets tested. See
|
||||
# bread-ecosystem's docs/release-channels.md for the release-track policy.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/beta/breadmon/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/breadmon" "${PKG_DIR}/breadmon-x86_64"
|
||||
strip "${PKG_DIR}/breadmon-x86_64"
|
||||
sha256sum "${PKG_DIR}/breadmon-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/breadmon-x86_64.sha256"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadmon/latest"
|
||||
|
||||
# No GitHub Release upload — beta, like dev, is only distributed via
|
||||
# dl.breadway.dev/beta/.
|
||||
- name: regenerate beta index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||
# workflows run close together on the same self-hosted runner.
|
||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f src/ci/build.sh ]; then
|
||||
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash ci/build.sh cargo build --release --locked || {
|
||||
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/breadmon/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/breadmon" "${PKG_DIR}/breadmon-x86_64"
|
||||
strip "${PKG_DIR}/breadmon-x86_64"
|
||||
sha256sum "${PKG_DIR}/breadmon-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/breadmon-x86_64.sha256"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest"
|
||||
|
||||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/breadmon/${VERSION}"
|
||||
gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadmon \
|
||||
--title "breadmon v${VERSION}" --generate-notes 2>/dev/null || true
|
||||
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadmon \
|
||||
"${PKG_DIR}/breadmon-x86_64" \
|
||||
"${PKG_DIR}/breadmon-x86_64.sha256" \
|
||||
--clobber
|
||||
54
.github/workflows/release.yml
vendored
Normal file
54
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
DL_DIR: /srv/breadway-dl
|
||||
ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: build
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="${DL_DIR}/breadmon/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "target/release/breadmon" "${PKG_DIR}/breadmon-x86_64"
|
||||
strip "${PKG_DIR}/breadmon-x86_64"
|
||||
sha256sum "${PKG_DIR}/breadmon-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/breadmon-x86_64.sha256"
|
||||
cp bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "${DL_DIR}/breadmon/latest"
|
||||
|
||||
- name: ensure bread-ecosystem
|
||||
run: |
|
||||
rm -rf "${ECOSYSTEM_DIR}"
|
||||
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
|
||||
|
||||
- name: regenerate index.json
|
||||
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="${DL_DIR}/breadmon/${VERSION}"
|
||||
gh release create "${GITHUB_REF_NAME}" \
|
||||
--title "breadmon v${VERSION}" --generate-notes 2>/dev/null || true
|
||||
gh release upload "${GITHUB_REF_NAME}" \
|
||||
"${PKG_DIR}/breadmon-x86_64" \
|
||||
"${PKG_DIR}/breadmon-x86_64.sha256" \
|
||||
--clobber
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -29,12 +29,3 @@ logs/
|
|||
# Runtime files
|
||||
*.sock
|
||||
*.pid
|
||||
|
||||
# Local hygiene notes (not for commit)
|
||||
CLAUDE.md
|
||||
|
||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||
graphify-out/
|
||||
|
||||
# .freebuff local tool state (not for commit)
|
||||
.freebuff/
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -1,33 +0,0 @@
|
|||
# AGENTS.md — Repo hygiene
|
||||
|
||||
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
|
||||
— read and follow it for any git, branch, or release work here (the
|
||||
single-trunk `main` model, `feature/x`/`fix/x` branch naming, RC-tag-driven
|
||||
beta releases, etc). Don't improvise a different workflow. The short version:
|
||||
there is one long-lived branch, `main` — no `dev` or `beta` branch exists.
|
||||
`main` auto-publishes a dev-track build on every push. "Beta" and "stable"
|
||||
are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a
|
||||
beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable
|
||||
release. "Freezing" for stabilization means pausing pushes to `main`, not
|
||||
moving a branch.
|
||||
|
||||
## Remotes
|
||||
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||
- `github` — GitHub mirror. Push both when publishing.
|
||||
|
||||
## CI
|
||||
- `check.yml` — clippy + test, triggers on push to `feature/**`/`fix/**`.
|
||||
- `dev-release.yml` — triggers on push to `main`.
|
||||
- `rc-release.yml` — triggers on `vX.Y.Z-rc.N` tag push.
|
||||
- `release.yml` — triggers on any other `v*` tag push.
|
||||
|
||||
All four run on a self-hosted runner (`hestia`) inside a pinned Arch
|
||||
container — not the host's native environment. The Containerfile/build
|
||||
script are shared across bread-ecosystem products and live in
|
||||
`bread-ecosystem/ci/`; this repo's `ci/build.sh` clones that repo at the
|
||||
sha in `ci/bread-ecosystem.rev` (deliberately pinned, not `main`) and
|
||||
delegates to it. Nothing runs automatically on plain commits or PRs
|
||||
beyond what's listed.
|
||||
|
||||
## Don't
|
||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
# Contributing
|
||||
|
||||
`breadmon` — Terminal UI monitor manager for Hyprland.
|
||||
|
||||
Part of the bread ecosystem; this repo follows the same branch/release
|
||||
workflow as every other ecosystem product.
|
||||
|
||||
## Branches
|
||||
|
||||
There is one long-lived branch: **`main`**. All day-to-day work lands here.
|
||||
Every push to `main` automatically builds and publishes a **dev-track**
|
||||
build (see Tracks below) — a real install you can test before cutting
|
||||
anything more formal.
|
||||
|
||||
New work — features and bug fixes alike — goes on a short-lived branch:
|
||||
|
||||
```
|
||||
feature/<short-name>
|
||||
fix/<issue-number-or-short-name>
|
||||
```
|
||||
|
||||
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
||||
branches get deleted on merge — they never accumulate the kind of drift a
|
||||
second long-lived branch does.
|
||||
|
||||
## The release cycle
|
||||
|
||||
There's no separate `beta` or release branch — "stable" and "beta" are both
|
||||
just **tags** on `main`, not branches that need to be kept in sync:
|
||||
|
||||
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
||||
auto-publishes a dev build — install it with `bakery track set dev` and
|
||||
`bakery update --all`, then fix anything broken with another push.
|
||||
2. When you want to stabilize before a real release, tag a release
|
||||
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
||||
both remotes). That tag alone triggers a beta-track build —
|
||||
"freezing" is just pausing pushes to `main` while you test it, not a
|
||||
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
||||
3. Once an RC has gone without issues, tag the real release:
|
||||
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
||||
signed stable release build.
|
||||
|
||||
## Tracks, from a user's perspective
|
||||
|
||||
```
|
||||
bakery track show # what you're currently on (defaults to stable)
|
||||
bakery track set dev # or beta, or stable
|
||||
bakery update --all # pull the latest build on your current track
|
||||
```
|
||||
|
||||
| Track | What it is | Published from |
|
||||
|--------|-----------|-----------------|
|
||||
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
||||
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
||||
| `dev` | Bleeding edge | `main`, on every push |
|
||||
|
||||
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
||||
latest published stable tag, so they always sort as newer than what you
|
||||
have installed — no manual version bumping needed. Beta versions are just
|
||||
the RC tag itself (already valid semver, already sorts below the real
|
||||
release it's a candidate for).
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
cargo test --release
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
- `dev-release.yml` — triggered on push to `main`.
|
||||
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
|
||||
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
|
||||
stable release.
|
||||
|
||||
All CI runs on a self-hosted runner; nothing runs automatically on plain
|
||||
commits or PRs beyond the track builds above. See
|
||||
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
|
||||
for the full policy, including how a new product gets wired onto these tracks.
|
||||
|
||||
## Questions
|
||||
|
||||
Open an issue on this repo's Forgejo tracker.
|
||||
109
Cargo.lock
generated
109
Cargo.lock
generated
|
|
@ -10,44 +10,21 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bread-shared"
|
||||
version = "0.7.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.7.2"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||
dependencies = [
|
||||
"bread-shared",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "breadmon"
|
||||
version = "0.1.3"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-utils",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"futures",
|
||||
|
|
@ -60,9 +37,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
|
|
@ -210,9 +187,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
|||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
|
@ -225,9 +202,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
|
|
@ -235,15 +212,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
|
|
@ -252,15 +229,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -269,21 +246,21 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
|
@ -391,9 +368,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
|||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.18"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
|
@ -430,15 +407,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
|
|
@ -561,9 +538,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
|
|
@ -674,9 +651,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
|||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
|
|
@ -718,9 +695,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -749,9 +726,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.0"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
|
|
@ -765,9 +742,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -1043,6 +1020,6 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "breadmon"
|
||||
version = "0.1.3"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "TUI monitor manager for Hyprland"
|
||||
license = "MIT"
|
||||
|
|
@ -19,7 +19,6 @@ toml = "0.8"
|
|||
anyhow = "1"
|
||||
dirs = "5"
|
||||
futures = "0.3"
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
|
|
|||
45
EVENTS.md
45
EVENTS.md
|
|
@ -1,45 +0,0 @@
|
|||
# breadmon — bread event integration
|
||||
|
||||
breadmon is a standalone TUI monitor manager: it works exactly the same
|
||||
with or without `breadd` running. When breadd *is* present, a successful
|
||||
live apply publishes an event into the shared bread automation fabric.
|
||||
See the parent `bread` repo's `Documentation.md` — specifically its
|
||||
"Namespaces" and "Integrating a bread\* app" sections — for the general
|
||||
convention this follows.
|
||||
|
||||
App id: **`mon`**. Transport: `bread-utils`'s `bread_client` module
|
||||
(feature `bread-client`) — the TUI links it directly and emits from the
|
||||
same process that ran `hyprctl eval`. Each `emit` is its own short-lived
|
||||
connection (`BreadClient::emit` never blocks or errors the caller).
|
||||
|
||||
This event is about breadmon's own live apply (`hyprctl eval
|
||||
'hl.monitor({...})'` on [BOS](https://git.breadway.dev/breadway/bos)-patched
|
||||
Hyprland). After that apply succeeds, breadmon also writes
|
||||
`~/.config/hypr/monitors.json` (the store shared with the `bos-settings`
|
||||
Display panel). The event is **not** fired by Display itself — that GUI
|
||||
only edits the JSON. Vanilla/upstream Hyprland has no `eval` request
|
||||
and no `hl.monitor()`, so apply fails there and this event is not
|
||||
published.
|
||||
|
||||
## Events published (`bread.mon.*`)
|
||||
|
||||
| Event | Data | When |
|
||||
|-------|------|------|
|
||||
| `bread.mon.applied` | `{ "profile": <string or null> }` | After `hyprctl eval 'hl.monitor({...})'` succeeds. `profile` is the named snapshot that was just applied (the last loaded or saved profile this session, if the layout was not edited after that), or `null` for an ad-hoc layout. Not emitted when apply fails. |
|
||||
|
||||
## Commands honored (`bread.command.mon.*`)
|
||||
|
||||
None. breadmon is an interactive TUI, not a long-running daemon — a
|
||||
command subscription would only be live while the TUI is open, which is
|
||||
a poor control surface. Apply, load, and save stay keyboard-driven.
|
||||
If/when breadmon grows a headless apply path, the corresponding
|
||||
`bread.command.mon.apply` verb should be added at the same time, not
|
||||
stubbed out ahead of it.
|
||||
|
||||
## Fail-safe behavior
|
||||
|
||||
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||
(`BreadClient::emit` never blocks or errors the caller) — breadmon's
|
||||
actual apply / profile / TUI functionality is entirely unaffected.
|
||||
- There is no command subscription, so a breadd restart has nothing to
|
||||
reconnect.
|
||||
47
README.md
47
README.md
|
|
@ -1,24 +1,13 @@
|
|||
# breadmon
|
||||
|
||||
A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply a live layout on [BOS](https://git.breadway.dev/breadway/bos)-patched Hyprland.
|
||||
|
||||
The Display panel in `bos-settings` (GUI) and breadmon (TUI) share `~/.config/hypr/monitors.json` — the layout Hyprland reads at login/reload. Applying in breadmon writes that file so the settings app and the next session stay in sync. Named profiles remain optional snapshots under `~/.config/breadmon/profiles/`.
|
||||
A terminal UI monitor manager for Hyprland. Lets you position, configure, and mirror displays interactively, then apply changes live via `hyprctl`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[BOS (Bread OS)](https://git.breadway.dev/breadway/bos)'s patched Hyprland build** for live apply. The `a` key runs `hyprctl eval 'hl.monitor({...})'` — a BOS-specific Lua extension. Vanilla/upstream Hyprland has no `eval` request and no `hl.monitor()`, so apply will fail there with an explicit error instead of the raw hyprctl response. Viewing, arranging, and saving profiles work on any Hyprland; only live apply needs BOS.
|
||||
- The `hyprctl` binary must be on `PATH`
|
||||
- Hyprland compositor (the `hyprctl` binary must be on `PATH`)
|
||||
- Rust toolchain (to build from source)
|
||||
|
||||
## Install
|
||||
|
||||
Via [bakery](https://git.breadway.dev/Breadway/bread-ecosystem), the bread ecosystem package manager:
|
||||
|
||||
```
|
||||
bakery install breadmon
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
## Build
|
||||
|
||||
```
|
||||
cargo build --release
|
||||
|
|
@ -26,6 +15,12 @@ cargo build --release
|
|||
|
||||
The binary is written to `target/release/breadmon`.
|
||||
|
||||
If you use the bread ecosystem, `bakery` can install it instead:
|
||||
|
||||
```
|
||||
bread modules install /path/to/breadmon
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
|
|
@ -80,7 +75,7 @@ Finds the best common mode between two monitors and sets one to mirror the other
|
|||
|
||||
### Profiles
|
||||
|
||||
Named snapshots of the current monitor configuration, stored as TOML files. Loading a profile updates the TUI; applying it (`a`) also writes `monitors.json`.
|
||||
Named snapshots of the current monitor configuration, stored as TOML files.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
|
|
@ -95,8 +90,8 @@ Profiles are saved to `~/.config/breadmon/profiles/`.
|
|||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `a` | Apply current configuration via `hyprctl eval 'hl.monitor({...})'` (BOS-patched Hyprland only) and write `~/.config/hypr/monitors.json` |
|
||||
| `s` | Write `~/.config/hypr/monitors.json` without a live apply |
|
||||
| `a` | Apply current configuration via `hyprctl` |
|
||||
| `s` | Save current configuration as a profile |
|
||||
| `r` | Refresh monitor list from Hyprland |
|
||||
| `Ctrl+Z` | Undo last change (up to 20 steps) |
|
||||
| `q` / `Ctrl+C` | Quit (prompts once if there are unsaved changes) |
|
||||
|
|
@ -105,20 +100,4 @@ breadmon also listens on Hyprland's event socket and reloads the monitor list au
|
|||
|
||||
## Config
|
||||
|
||||
**Shared store:** `~/.config/hypr/monitors.json` — the same file the bos-settings Display panel edits and Hyprland applies on login/reload. breadmon is the TUI; Display is the GUI. Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"monitors": [
|
||||
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto", "mirror": "<optional string>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Empty `output` is the wildcard default (any connector). breadmon loads this file on start (overlaid onto the live `hyprctl` list) and writes it — pretty JSON — after a successful apply, and when you press `s`.
|
||||
|
||||
**Named snapshots:** plain TOML under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab. Applying a profile writes `monitors.json` so Hyprland and Display stay in sync.
|
||||
|
||||
## bread event integration
|
||||
|
||||
breadmon works the same with or without `breadd`. After a successful live apply (`hyprctl eval 'hl.monitor({...})'` on BOS-patched Hyprland — not the `bos-settings` Display panel), it publishes `bread.mon.applied`. If breadd is down, the emit is a silent no-op; apply itself is unchanged. See [EVENTS.md](EVENTS.md) for the bus contract. `bread` is not a bakery dependency.
|
||||
Profiles are plain TOML files under `~/.config/breadmon/profiles/`. Each file records the monitor name, mode, position, scale, transform, VRR, DPMS, and mirror source. They are created and managed through the Profiles tab; there is no hand-written config file.
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
620c5a1317a6b57276eabca961facdb78bf510db
|
||||
20
ci/build.sh
20
ci/build.sh
|
|
@ -1,20 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
|
||||
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
|
||||
# files now affect every product's release pipeline, so bumping the pin
|
||||
# is a deliberate act instead of silent drift.
|
||||
#
|
||||
# Usage: ci/build.sh cargo build --release --locked
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
|
||||
|
||||
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
|
||||
if [ ! -d "$CACHE_DIR" ]; then
|
||||
rm -rf /tmp/bread-ecosystem-ci-*
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
|
||||
git -C "$CACHE_DIR" checkout --quiet "$REV"
|
||||
fi
|
||||
|
||||
bash "${CACHE_DIR}/ci/build.sh" breadmon "$ROOT" "$@"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
//! `bread.mon.*` event integration — optional, non-blocking. See
|
||||
//! `EVENTS.md` at the repo root for the full contract. breadmon works
|
||||
//! identically with or without breadd running; every call here is
|
||||
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
|
||||
//! process) so a missing or restarting breadd never affects apply itself.
|
||||
|
||||
use bread_utils::bread_client::BreadClient;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// This app's id in bread's sibling-app namespace registry
|
||||
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.mon.*`.
|
||||
pub const APP_ID: &str = "mon";
|
||||
|
||||
/// JSON payload for `bread.mon.applied`. `profile` is the named snapshot
|
||||
/// that was just applied, or `null` for an ad-hoc layout.
|
||||
pub fn applied_data(profile: Option<&str>) -> Value {
|
||||
json!({ "profile": profile })
|
||||
}
|
||||
|
||||
/// Publishes `bread.mon.applied` after a successful hyprctl apply.
|
||||
/// Fire-and-forget and non-fatal by design — breadd being absent or not
|
||||
/// installed must never affect breadmon's own apply path.
|
||||
pub fn emit_applied(profile: Option<&str>) {
|
||||
BreadClient::connect(APP_ID).emit("bread.mon.applied", applied_data(profile));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn applied_data_serializes_name_or_null() {
|
||||
assert_eq!(applied_data(Some("dock")), json!({ "profile": "dock" }));
|
||||
assert_eq!(applied_data(None), json!({ "profile": null }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_applied_is_silent_when_breadd_is_down() {
|
||||
// No daemon in the unit-test environment; must not panic or block.
|
||||
emit_applied(Some("dock"));
|
||||
emit_applied(None);
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ pub fn snap_position(
|
|||
}
|
||||
|
||||
/// Move the selected monitor by (dx, dy) pixels, then snap.
|
||||
pub fn move_selected(state: &LayoutState, monitors: &mut [Monitor], dx: i32, dy: i32) {
|
||||
pub fn move_selected(state: &LayoutState, monitors: &mut Vec<Monitor>, dx: i32, dy: i32) {
|
||||
let idx = state.selected;
|
||||
if idx >= monitors.len() {
|
||||
return;
|
||||
|
|
@ -116,7 +116,7 @@ pub fn move_selected(state: &LayoutState, monitors: &mut [Monitor], dx: i32, dy:
|
|||
}
|
||||
|
||||
/// Place monitors in a left-to-right row with no gaps.
|
||||
pub fn auto_arrange(monitors: &mut [Monitor]) {
|
||||
pub fn auto_arrange(monitors: &mut Vec<Monitor>) {
|
||||
let mut cursor = 0i32;
|
||||
for m in monitors.iter_mut() {
|
||||
m.x = cursor;
|
||||
|
|
@ -196,11 +196,7 @@ mod tests {
|
|||
Monitor {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
active_mode: Mode {
|
||||
width: w,
|
||||
height: h,
|
||||
refresh: 60.0,
|
||||
},
|
||||
active_mode: Mode { width: w, height: h, refresh: 60.0 },
|
||||
x,
|
||||
y,
|
||||
scale: 1.0,
|
||||
|
|
|
|||
61
src/main.rs
61
src/main.rs
|
|
@ -1,9 +1,7 @@
|
|||
mod bread_events;
|
||||
mod layout;
|
||||
mod mirror;
|
||||
mod monitor;
|
||||
mod profile;
|
||||
mod store;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
|
@ -11,7 +9,8 @@ use std::io;
|
|||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{
|
||||
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind, MouseEventKind,
|
||||
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyEventKind,
|
||||
MouseEventKind,
|
||||
},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
|
|
@ -25,6 +24,12 @@ use tokio::{
|
|||
|
||||
use ui::{AppState, StatusLevel};
|
||||
|
||||
fn hyprland_socket2_path() -> Option<String> {
|
||||
let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".into());
|
||||
Some(format!("{}/hypr/{}/.socket2.sock", runtime, instance))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AppEvent {
|
||||
Key(crossterm::event::KeyEvent),
|
||||
|
|
@ -36,15 +41,10 @@ enum AppEvent {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let mut monitors = monitor::load_monitors().await.unwrap_or_else(|e| {
|
||||
let monitors = monitor::load_monitors().await.unwrap_or_else(|e| {
|
||||
eprintln!("Warning: could not load monitors: {}", e);
|
||||
vec![]
|
||||
});
|
||||
match store::load() {
|
||||
Ok(Some(file)) => store::apply_to_monitors(&file, &mut monitors),
|
||||
Ok(None) => {}
|
||||
Err(e) => eprintln!("Warning: could not load monitors.json: {}", e),
|
||||
}
|
||||
|
||||
// Terminal setup
|
||||
enable_raw_mode()?;
|
||||
|
|
@ -57,11 +57,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
|
|
@ -108,7 +104,7 @@ async fn run(
|
|||
// Hyprland socket hotplug listener
|
||||
let tx_hotplug = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(sig) = bread_utils::hypr::socket_path(bread_utils::hypr::Socket::Events) {
|
||||
if let Some(sig) = hyprland_socket2_path() {
|
||||
if let Ok(mut stream) = tokio::net::UnixStream::connect(&sig).await {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let reader = tokio::io::BufReader::new(&mut stream);
|
||||
|
|
@ -145,7 +141,6 @@ async fn run(
|
|||
if let Ok(monitors) = monitor::load_monitors().await {
|
||||
state.monitors = monitors;
|
||||
state.layout.clamp_selected(state.monitors.len());
|
||||
state.active_profile = None;
|
||||
state.set_status("Monitor configuration changed.", StatusLevel::Info);
|
||||
}
|
||||
}
|
||||
|
|
@ -171,26 +166,22 @@ async fn run(
|
|||
crossterm::event::KeyCode::Char('s') => {
|
||||
ui::layout_view::trigger_save(&mut state);
|
||||
}
|
||||
crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await {
|
||||
crossterm::event::KeyCode::Char('r') => {
|
||||
match monitor::load_monitors().await {
|
||||
Ok(monitors) => {
|
||||
if state.dirty {
|
||||
// Don't clobber unsaved edits (or silently drop the
|
||||
// unsaved-changes quit guard) on a refresh.
|
||||
state.set_status(
|
||||
"Refresh skipped: unsaved changes present.",
|
||||
StatusLevel::Info,
|
||||
);
|
||||
} else {
|
||||
state.monitors = monitors;
|
||||
state.layout.clamp_selected(state.monitors.len());
|
||||
state.active_profile = None;
|
||||
state.dirty = false;
|
||||
state.set_status("Monitors refreshed.", StatusLevel::Success);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
|
||||
state.set_status(
|
||||
format!("Refresh failed: {}", e),
|
||||
StatusLevel::Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
if !ui::handle_key(key, &mut state) {
|
||||
break;
|
||||
|
|
@ -204,20 +195,8 @@ async fn run(
|
|||
state.pending_apply = false;
|
||||
match monitor::apply_monitors(&state.monitors).await {
|
||||
Ok(()) => {
|
||||
bread_events::emit_applied(state.active_profile.as_deref());
|
||||
match store::save_from_monitors(&state.monitors) {
|
||||
Ok(()) => {
|
||||
state.dirty = false;
|
||||
state.set_status("Applied.", StatusLevel::Success);
|
||||
}
|
||||
Err(e) => {
|
||||
state.set_status(
|
||||
format!("Applied, but monitors.json write failed: {}", e),
|
||||
StatusLevel::Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
state.set_status(format!("Apply failed: {}", e), StatusLevel::Error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ pub struct MirrorResult {
|
|||
}
|
||||
|
||||
fn gcd(a: u32, b: u32) -> u32 {
|
||||
if b == 0 {
|
||||
a
|
||||
} else {
|
||||
gcd(b, a % b)
|
||||
}
|
||||
if b == 0 { a } else { gcd(b, a % b) }
|
||||
}
|
||||
|
||||
fn reduced_ar(w: u32, h: u32) -> (u32, u32) {
|
||||
|
|
@ -39,10 +35,7 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
|||
// Group source modes by reduced AR
|
||||
let mut src_by_ar: HashMap<(u32, u32), Vec<&Mode>> = HashMap::new();
|
||||
for m in src_modes {
|
||||
src_by_ar
|
||||
.entry(reduced_ar(m.width, m.height))
|
||||
.or_default()
|
||||
.push(m);
|
||||
src_by_ar.entry(reduced_ar(m.width, m.height)).or_default().push(m);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -60,15 +53,8 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
|||
|
||||
if src_by_ar.contains_key(&tgt_ar) {
|
||||
// Check if we already have this exact pair
|
||||
if !candidates
|
||||
.iter()
|
||||
.any(|c| c.src_ar == tgt_ar && c.tgt_ar == tgt_ar && c.is_exact)
|
||||
{
|
||||
candidates.push(Candidate {
|
||||
src_ar: tgt_ar,
|
||||
tgt_ar,
|
||||
is_exact: true,
|
||||
});
|
||||
if !candidates.iter().any(|c| c.src_ar == tgt_ar && c.tgt_ar == tgt_ar && c.is_exact) {
|
||||
candidates.push(Candidate { src_ar: tgt_ar, tgt_ar, is_exact: true });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -76,16 +62,10 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
|||
// Approximate: check all source ARs within 5%
|
||||
for &s_ar in src_by_ar.keys() {
|
||||
let s_ratio = ratio_f64(s_ar);
|
||||
if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05
|
||||
&& !candidates
|
||||
.iter()
|
||||
.any(|c| c.src_ar == s_ar && c.tgt_ar == tgt_ar)
|
||||
{
|
||||
candidates.push(Candidate {
|
||||
src_ar: s_ar,
|
||||
tgt_ar,
|
||||
is_exact: false,
|
||||
});
|
||||
if (s_ratio - tgt_ratio).abs() / s_ratio < 0.05 {
|
||||
if !candidates.iter().any(|c| c.src_ar == s_ar && c.tgt_ar == tgt_ar) {
|
||||
candidates.push(Candidate { src_ar: s_ar, tgt_ar, is_exact: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,10 +148,7 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
|||
.collect();
|
||||
|
||||
let chosen_refresh = if !exact_common.is_empty() {
|
||||
exact_common
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max)
|
||||
exact_common.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||
} else {
|
||||
// Near-match within 1 Hz
|
||||
let near: Vec<f64> = src_refreshes
|
||||
|
|
@ -188,10 +165,7 @@ pub fn find_mirror_modes(source: &Monitor, target: &Monitor) -> Option<MirrorRes
|
|||
near.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||
} else {
|
||||
// Fallback: max source refresh
|
||||
src_refreshes
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f64::NEG_INFINITY, f64::max)
|
||||
src_refreshes.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -244,7 +218,7 @@ pub fn refresh_match_label(result: &MirrorResult) -> &'static str {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::monitor::Transform;
|
||||
use crate::monitor::{Transform};
|
||||
|
||||
fn make_monitor_with_modes(name: &str, modes: Vec<Mode>) -> Monitor {
|
||||
let active = modes[0].clone();
|
||||
|
|
@ -267,11 +241,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn m(w: u32, h: u32, r: f64) -> Mode {
|
||||
Mode {
|
||||
width: w,
|
||||
height: h,
|
||||
refresh: r,
|
||||
}
|
||||
Mode { width: w, height: h, refresh: r }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -145,15 +145,11 @@ impl Monitor {
|
|||
.collect();
|
||||
// Sort descending by pixels then refresh for consistent ordering
|
||||
modes.sort_by(|a, b| {
|
||||
b.pixels().cmp(&a.pixels()).then(
|
||||
b.refresh
|
||||
.partial_cmp(&a.refresh)
|
||||
.unwrap_or(std::cmp::Ordering::Equal),
|
||||
)
|
||||
});
|
||||
modes.dedup_by(|a, b| {
|
||||
a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01
|
||||
b.pixels()
|
||||
.cmp(&a.pixels())
|
||||
.then(b.refresh.partial_cmp(&a.refresh).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
modes.dedup_by(|a, b| a.width == b.width && a.height == b.height && (a.refresh - b.refresh).abs() < 0.01);
|
||||
|
||||
let active_mode = Mode {
|
||||
width: raw.width,
|
||||
|
|
@ -218,10 +214,8 @@ impl Monitor {
|
|||
if self.physical_width_mm == 0 || self.physical_height_mm == 0 {
|
||||
return None;
|
||||
}
|
||||
let diag_px =
|
||||
((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt();
|
||||
let diag_mm =
|
||||
((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt();
|
||||
let diag_px = ((self.active_mode.width.pow(2) + self.active_mode.height.pow(2)) as f64).sqrt();
|
||||
let diag_mm = ((self.physical_width_mm.pow(2) + self.physical_height_mm.pow(2)) as f64).sqrt();
|
||||
Some(diag_px / (diag_mm / 25.4))
|
||||
}
|
||||
|
||||
|
|
@ -274,10 +268,8 @@ pub async fn load_monitors() -> Result<Vec<Monitor>> {
|
|||
|
||||
// Hyprland reports mirrorOf as a numeric ID string when using `monitors all`.
|
||||
// Resolve to monitor name so format_hypr_line emits the correct `mirror,<name>`.
|
||||
let id_to_name: std::collections::HashMap<String, String> = raw
|
||||
.iter()
|
||||
.map(|r| (r.id.to_string(), r.name.clone()))
|
||||
.collect();
|
||||
let id_to_name: std::collections::HashMap<String, String> =
|
||||
raw.iter().map(|r| (r.id.to_string(), r.name.clone())).collect();
|
||||
|
||||
Ok(raw
|
||||
.into_iter()
|
||||
|
|
@ -292,7 +284,6 @@ pub async fn load_monitors() -> Result<Vec<Monitor>> {
|
|||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn format_hypr_line(m: &Monitor) -> String {
|
||||
if let Some(src) = &m.mirror_of {
|
||||
format!(
|
||||
|
|
@ -376,15 +367,6 @@ pub async fn apply_monitors(monitors: &[Monitor]) -> Result<()> {
|
|||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||
if stdout != "ok" {
|
||||
if is_missing_eval_extension(&stdout) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"breadmon: `hyprctl eval` (and the `hl.monitor()` Lua extension it uses \
|
||||
to apply monitor changes) is not available on this compositor. \
|
||||
This feature requires BOS (Bread OS)'s patched Hyprland build — it \
|
||||
does not exist on vanilla/upstream Hyprland. See the breadmon README \
|
||||
for details. (raw hyprctl response: {stdout:?})"
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("hyprctl: {}", stdout));
|
||||
}
|
||||
}
|
||||
|
|
@ -400,37 +382,6 @@ pub async fn apply_monitors(monitors: &[Monitor]) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Vanilla/upstream Hyprland doesn't recognize the `eval` request at all
|
||||
/// (BOS's Hyprland fork adds it, along with the `hl.monitor()` Lua global
|
||||
/// used above), and responds with an "unknown request" style message rather
|
||||
/// than an error about `hl`. Detect that case so we can point the user at
|
||||
/// the actual cause instead of surfacing the raw hyprctl text.
|
||||
fn is_missing_eval_extension(hyprctl_stdout: &str) -> bool {
|
||||
let s = hyprctl_stdout.to_lowercase();
|
||||
s.contains("unknown request") || s.contains("unknown command")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod eval_extension_tests {
|
||||
use super::is_missing_eval_extension;
|
||||
|
||||
#[test]
|
||||
fn detects_unknown_request() {
|
||||
assert!(is_missing_eval_extension("unknown request"));
|
||||
assert!(is_missing_eval_extension("Unknown Request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_flag_lua_errors() {
|
||||
// A real Lua/apply-time error from a BOS build should still surface
|
||||
// as a normal hyprctl error, not the "requires BOS" message.
|
||||
assert!(!is_missing_eval_extension(
|
||||
"error: attempt to call a nil value"
|
||||
));
|
||||
assert!(!is_missing_eval_extension("ok"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -453,11 +404,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn mode_compact_roundtrip() {
|
||||
let m = Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh: 60.0,
|
||||
};
|
||||
let m = Mode { width: 1920, height: 1080, refresh: 60.0 };
|
||||
let s = m.compact();
|
||||
let m2 = Mode::parse(&format!("{}Hz", s)).unwrap();
|
||||
assert_eq!(m.width, m2.width);
|
||||
|
|
@ -469,11 +416,7 @@ mod tests {
|
|||
let m = Monitor {
|
||||
name: "eDP-1".into(),
|
||||
description: String::new(),
|
||||
active_mode: Mode {
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
refresh: 60.0,
|
||||
},
|
||||
active_mode: Mode { width: 1920, height: 1200, refresh: 60.0 },
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
|
|
@ -497,11 +440,7 @@ mod tests {
|
|||
let m = Monitor {
|
||||
name: "HDMI-A-1".into(),
|
||||
description: String::new(),
|
||||
active_mode: Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh: 60.0,
|
||||
},
|
||||
active_mode: Mode { width: 1920, height: 1080, refresh: 60.0 },
|
||||
x: 1920,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
|
|
|
|||
|
|
@ -30,14 +30,9 @@ pub struct Profile {
|
|||
}
|
||||
|
||||
pub fn profiles_dir() -> PathBuf {
|
||||
// Was `dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))`
|
||||
// — same literal-tilde-fallback bug found (and fixed) in breadclip-core
|
||||
// and breadpad-shared during tonight's ecosystem-utils pass: PathBuf/
|
||||
// std::fs never expand `~`, so on a box where `dirs` can't resolve a
|
||||
// home directory this would silently resolve to a directory literally
|
||||
// named `~` under the current working directory instead of the user's
|
||||
// actual home.
|
||||
bread_utils::xdg::config_dir("breadmon/profiles")
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||
.join("breadmon/profiles")
|
||||
}
|
||||
|
||||
pub fn save(profile: &Profile) -> Result<()> {
|
||||
|
|
@ -77,7 +72,8 @@ pub fn list() -> Result<Vec<String>> {
|
|||
|
||||
pub fn delete(name: &str) -> Result<()> {
|
||||
let path = profiles_dir().join(format!("{}.toml", name));
|
||||
std::fs::remove_file(&path).with_context(|| format!("failed to delete profile '{}'", name))
|
||||
std::fs::remove_file(&path)
|
||||
.with_context(|| format!("failed to delete profile '{}'", name))
|
||||
}
|
||||
|
||||
pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile {
|
||||
|
|
@ -107,7 +103,7 @@ pub fn from_monitors(name: &str, monitors: &[Monitor]) -> Profile {
|
|||
|
||||
/// Apply a profile's settings onto a list of live monitors (matched by name).
|
||||
/// Monitors not in the profile are left unchanged.
|
||||
pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) {
|
||||
pub fn apply_to_monitors(profile: &Profile, monitors: &mut Vec<Monitor>) {
|
||||
for pm in &profile.monitors {
|
||||
if let Some(m) = monitors.iter_mut().find(|m| m.name == pm.name) {
|
||||
if let Some(mode) = Mode::parse(&format!("{}Hz", pm.mode)) {
|
||||
|
|
@ -129,39 +125,15 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) {
|
|||
}
|
||||
|
||||
fn chrono_now() -> String {
|
||||
// In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling
|
||||
// out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch
|
||||
// algorithm. Falls back to the Unix epoch instant if the clock is broken.
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let (h, m, s) = secs_of_day(secs % 86_400);
|
||||
let (y, mo, d) = civil_from_days((secs / 86_400) as i64);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
||||
}
|
||||
|
||||
/// Convert days since 1970-01-01 to a (year, month, day) civil date.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
/// Seconds within the day -> (hours, minutes, seconds).
|
||||
fn secs_of_day(secs: u64) -> (u32, u32, u32) {
|
||||
(
|
||||
((secs / 3600) % 24) as u32,
|
||||
((secs / 60) % 60) as u32,
|
||||
(secs % 60) as u32,
|
||||
)
|
||||
// Simple ISO 8601 timestamp without pulling in chrono
|
||||
// Uses date command; falls back to a placeholder if unavailable
|
||||
std::process::Command::new("date")
|
||||
.arg("+%Y-%m-%dT%H:%M:%SZ")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_owned())
|
||||
.unwrap_or_else(|| "unknown".to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -173,11 +145,7 @@ mod tests {
|
|||
Monitor {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
active_mode: Mode {
|
||||
width: w,
|
||||
height: h,
|
||||
refresh: 60.0,
|
||||
},
|
||||
active_mode: Mode { width: w, height: h, refresh: 60.0 },
|
||||
x,
|
||||
y,
|
||||
scale: 1.0,
|
||||
|
|
@ -208,19 +176,4 @@ mod tests {
|
|||
assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00");
|
||||
assert_eq!(deserialized.monitors[1].x, 1920);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chrono_now_helpers() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
// 2024-01-01 is epoch day 19723.
|
||||
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
|
||||
assert_eq!(secs_of_day(0), (0, 0, 0));
|
||||
assert_eq!(secs_of_day(86_399), (23, 59, 59));
|
||||
// Spot-check the formatted output shape.
|
||||
let s = chrono_now();
|
||||
assert_eq!(s.len(), 20);
|
||||
|
||||
assert!(s.ends_with('Z'));
|
||||
assert!(s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
369
src/store.rs
369
src/store.rs
|
|
@ -1,369 +0,0 @@
|
|||
//! Shared Hyprland layout store: `~/.config/hypr/monitors.json`.
|
||||
//!
|
||||
//! Same schema as bos-settings `MonitorRule` and
|
||||
//! `iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua`.
|
||||
//! Empty `output` is the wildcard default (matches any connector).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::monitor::{Mode, Monitor};
|
||||
|
||||
fn default_mode() -> String {
|
||||
"preferred".to_string()
|
||||
}
|
||||
fn default_position() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
fn default_scale() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
/// One `hl.monitor()` rule. Field names and defaults must stay in sync with
|
||||
/// bos-settings `MonitorRule` and the ISO `monitors.lua` loader.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MonitorRule {
|
||||
pub output: String,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(default = "default_position")]
|
||||
pub position: String,
|
||||
#[serde(default = "default_scale")]
|
||||
pub scale: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mirror: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MonitorRule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output: String::new(),
|
||||
mode: default_mode(),
|
||||
position: default_position(),
|
||||
scale: default_scale(),
|
||||
mirror: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MonitorsFile {
|
||||
#[serde(default)]
|
||||
pub monitors: Vec<MonitorRule>,
|
||||
}
|
||||
|
||||
impl Default for MonitorsFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
monitors: vec![MonitorRule::default()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `~/.config/hypr/monitors.json` — same path Hyprland and bos-settings use.
|
||||
pub fn config_path() -> PathBuf {
|
||||
bread_utils::xdg::config_dir("hypr").join("monitors.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Option<MonitorsFile>> {
|
||||
load_from(&config_path())
|
||||
}
|
||||
|
||||
pub fn load_from(path: &Path) -> Result<Option<MonitorsFile>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let file: MonitorsFile = serde_json::from_str(&content)
|
||||
.with_context(|| format!("failed to parse {}", path.display()))?;
|
||||
// Empty file ≡ missing: Lua falls back to the wildcard default rather
|
||||
// than applying zero rules (which can black-screen the session).
|
||||
if file.monitors.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
pub fn save(file: &MonitorsFile) -> Result<()> {
|
||||
save_to(&config_path(), file)
|
||||
}
|
||||
|
||||
pub fn save_to(path: &Path, file: &MonitorsFile) -> Result<()> {
|
||||
let json = serde_json::to_string_pretty(file).context("failed to serialize monitors.json")?;
|
||||
bread_utils::atomic::write_atomic_backed_up(path, &json)
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn save_from_monitors(monitors: &[Monitor]) -> Result<()> {
|
||||
save(&from_monitors(monitors))
|
||||
}
|
||||
|
||||
/// Persist the TUI layout as named `hl.monitor()` rules. Mirror slaves are
|
||||
/// omitted (mirror is recorded on the source, matching `hl.monitor()`). If
|
||||
/// nothing is writable, emit the wildcard default so the file is never empty.
|
||||
pub fn from_monitors(monitors: &[Monitor]) -> MonitorsFile {
|
||||
let mut source_to_slave: HashMap<&str, &str> = HashMap::new();
|
||||
for m in monitors {
|
||||
if let Some(src) = &m.mirror_of {
|
||||
source_to_slave.insert(src.as_str(), m.name.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let mut rules = Vec::new();
|
||||
for m in monitors {
|
||||
if m.disabled || m.mirror_of.is_some() {
|
||||
continue;
|
||||
}
|
||||
let refresh = (m.active_mode.refresh + 0.5) as u32;
|
||||
rules.push(MonitorRule {
|
||||
output: m.name.clone(),
|
||||
mode: format!(
|
||||
"{}x{}@{}",
|
||||
m.active_mode.width, m.active_mode.height, refresh
|
||||
),
|
||||
position: format!("{}x{}", m.x, m.y),
|
||||
scale: format!("{:.2}", m.scale),
|
||||
mirror: source_to_slave
|
||||
.get(m.name.as_str())
|
||||
.map(|s| (*s).to_owned()),
|
||||
});
|
||||
}
|
||||
|
||||
if rules.is_empty() {
|
||||
MonitorsFile::default()
|
||||
} else {
|
||||
MonitorsFile { monitors: rules }
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay persisted rules onto live `hyprctl` monitors (matched by name;
|
||||
/// empty `output` is the wildcard fallback). `preferred` / `auto` leave the
|
||||
/// live value. A file with at least one named output is treated as a full
|
||||
/// layout and replaces live mirrors; a wildcard-only file does not.
|
||||
pub fn apply_to_monitors(file: &MonitorsFile, monitors: &mut [Monitor]) {
|
||||
let has_specific = file.monitors.iter().any(|r| !r.output.is_empty());
|
||||
if has_specific {
|
||||
for m in monitors.iter_mut() {
|
||||
m.mirror_of = None;
|
||||
}
|
||||
for rule in &file.monitors {
|
||||
let Some(slave_name) = rule.mirror.as_deref().filter(|s| !s.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
if rule.output.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(slave) = monitors.iter_mut().find(|m| m.name == slave_name) {
|
||||
slave.mirror_of = Some(rule.output.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for m in monitors.iter_mut() {
|
||||
if let Some(rule) = find_rule(&file.monitors, &m.name) {
|
||||
apply_rule_fields(m, rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_rule<'a>(rules: &'a [MonitorRule], name: &str) -> Option<&'a MonitorRule> {
|
||||
rules
|
||||
.iter()
|
||||
.find(|r| r.output == name)
|
||||
.or_else(|| rules.iter().find(|r| r.output.is_empty()))
|
||||
}
|
||||
|
||||
fn apply_rule_fields(m: &mut Monitor, rule: &MonitorRule) {
|
||||
if rule.mode != "preferred" {
|
||||
if let Some(mode) =
|
||||
Mode::parse(&format!("{}Hz", rule.mode)).or_else(|| Mode::parse(&rule.mode))
|
||||
{
|
||||
m.active_mode = mode;
|
||||
}
|
||||
}
|
||||
if rule.position != "auto" {
|
||||
if let Some((x, y)) = parse_position(&rule.position) {
|
||||
m.x = x;
|
||||
m.y = y;
|
||||
}
|
||||
}
|
||||
if rule.scale != "auto" {
|
||||
if let Ok(scale) = rule.scale.parse::<f64>() {
|
||||
if scale > 0.0 {
|
||||
m.scale = scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_position(s: &str) -> Option<(i32, i32)> {
|
||||
let (x, y) = s.split_once('x')?;
|
||||
Some((x.parse().ok()?, y.parse().ok()?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::monitor::Transform;
|
||||
|
||||
fn make_monitor(name: &str, w: u32, h: u32, x: i32, y: i32) -> Monitor {
|
||||
Monitor {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
active_mode: Mode {
|
||||
width: w,
|
||||
height: h,
|
||||
refresh: 60.0,
|
||||
},
|
||||
x,
|
||||
y,
|
||||
scale: 1.0,
|
||||
transform: Transform::Normal,
|
||||
vrr: false,
|
||||
dpms: true,
|
||||
disabled: false,
|
||||
mirror_of: None,
|
||||
available_modes: vec![],
|
||||
physical_width_mm: 0,
|
||||
physical_height_mm: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_default_parses() {
|
||||
let json = r#"{
|
||||
"monitors": [
|
||||
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto" }
|
||||
]
|
||||
}"#;
|
||||
let file: MonitorsFile = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(file.monitors.len(), 1);
|
||||
assert_eq!(file.monitors[0], MonitorRule::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretty_roundtrip_omits_absent_mirror() {
|
||||
let file = MonitorsFile::default();
|
||||
let json = serde_json::to_string_pretty(&file).unwrap();
|
||||
assert!(json.contains("\"output\": \"\""));
|
||||
assert!(json.contains("\"mode\": \"preferred\""));
|
||||
assert!(!json.contains("mirror"));
|
||||
let back: MonitorsFile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(file, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_monitors_writes_named_rules_and_source_mirror() {
|
||||
let mut hdmi = make_monitor("HDMI-A-1", 1920, 1080, 1920, 0);
|
||||
hdmi.mirror_of = Some("eDP-1".into());
|
||||
let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0), hdmi]);
|
||||
assert_eq!(file.monitors.len(), 1);
|
||||
let rule = &file.monitors[0];
|
||||
assert_eq!(rule.output, "eDP-1");
|
||||
assert_eq!(rule.mode, "1920x1200@60");
|
||||
assert_eq!(rule.position, "0x0");
|
||||
assert_eq!(rule.scale, "1.00");
|
||||
assert_eq!(rule.mirror.as_deref(), Some("HDMI-A-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_monitors_empty_or_all_slaves_emits_wildcard() {
|
||||
let mut only_slave = make_monitor("HDMI-A-1", 1920, 1080, 0, 0);
|
||||
only_slave.mirror_of = Some("missing".into());
|
||||
assert_eq!(from_monitors(&[]), MonitorsFile::default());
|
||||
assert_eq!(from_monitors(&[only_slave]), MonitorsFile::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_overlay_leaves_live_geometry_and_mirrors() {
|
||||
let file = MonitorsFile::default();
|
||||
let mut monitors = vec![make_monitor("eDP-1", 1920, 1200, 10, 20)];
|
||||
monitors[0].scale = 1.5;
|
||||
monitors[0].mirror_of = Some("HDMI-A-1".into());
|
||||
apply_to_monitors(&file, &mut monitors);
|
||||
assert_eq!(monitors[0].x, 10);
|
||||
assert_eq!(monitors[0].y, 20);
|
||||
assert!((monitors[0].scale - 1.5).abs() < f64::EPSILON);
|
||||
assert_eq!(monitors[0].mirror_of.as_deref(), Some("HDMI-A-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specific_overlay_applies_fields_and_replaces_mirrors() {
|
||||
let file = MonitorsFile {
|
||||
monitors: vec![
|
||||
MonitorRule {
|
||||
output: "eDP-1".into(),
|
||||
mode: "1920x1200@60".into(),
|
||||
position: "0x0".into(),
|
||||
scale: "1.25".into(),
|
||||
mirror: Some("HDMI-A-1".into()),
|
||||
},
|
||||
MonitorRule {
|
||||
output: "DP-1".into(),
|
||||
mode: "2560x1440@144".into(),
|
||||
position: "-2560x0".into(),
|
||||
scale: "1".into(),
|
||||
mirror: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut monitors = vec![
|
||||
make_monitor("eDP-1", 1600, 900, 100, 100),
|
||||
make_monitor("HDMI-A-1", 1920, 1080, 200, 0),
|
||||
make_monitor("DP-1", 1920, 1080, 300, 0),
|
||||
];
|
||||
monitors[1].mirror_of = Some("DP-1".into());
|
||||
apply_to_monitors(&file, &mut monitors);
|
||||
|
||||
assert_eq!(monitors[0].active_mode.width, 1920);
|
||||
assert_eq!(monitors[0].active_mode.height, 1200);
|
||||
assert!((monitors[0].active_mode.refresh - 60.0).abs() < 0.01);
|
||||
assert_eq!(monitors[0].x, 0);
|
||||
assert_eq!(monitors[0].y, 0);
|
||||
assert!((monitors[0].scale - 1.25).abs() < f64::EPSILON);
|
||||
|
||||
assert_eq!(monitors[1].mirror_of.as_deref(), Some("eDP-1"));
|
||||
|
||||
assert_eq!(monitors[2].active_mode.width, 2560);
|
||||
assert_eq!(monitors[2].x, -2560);
|
||||
assert!(monitors[2].mirror_of.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_or_empty_is_none() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadmon-store-test-{}-{}",
|
||||
std::process::id(),
|
||||
"empty"
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let missing = dir.join("nope.json");
|
||||
assert!(load_from(&missing).unwrap().is_none());
|
||||
|
||||
let empty = dir.join("empty.json");
|
||||
std::fs::write(&empty, "{ \"monitors\": [] }\n").unwrap();
|
||||
assert!(load_from(&empty).unwrap().is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_to_roundtrips() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadmon-store-test-{}-{}",
|
||||
std::process::id(),
|
||||
"save"
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("monitors.json");
|
||||
let file = from_monitors(&[make_monitor("eDP-1", 1920, 1200, 0, 0)]);
|
||||
save_to(&path, &file).unwrap();
|
||||
let loaded = load_from(&path).unwrap().unwrap();
|
||||
assert_eq!(loaded, file);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -125,38 +125,32 @@ impl ConfigState {
|
|||
}
|
||||
|
||||
fn prev_field(&mut self) {
|
||||
self.focused = self
|
||||
.focused
|
||||
.checked_sub(1)
|
||||
.unwrap_or(ConfigField::ALL.len() - 1);
|
||||
self.focused = self.focused.checked_sub(1).unwrap_or(ConfigField::ALL.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
||||
let cfg = &mut state.config;
|
||||
|
||||
match event.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.next_field();
|
||||
cfg.scale_editing = false;
|
||||
cfg.next_field();
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.prev_field();
|
||||
cfg.scale_editing = false;
|
||||
cfg.prev_field();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.next_field();
|
||||
cfg.scale_editing = false;
|
||||
cfg.next_field();
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
state.config.prev_field();
|
||||
cfg.scale_editing = false;
|
||||
cfg.prev_field();
|
||||
}
|
||||
// Navigate between monitors
|
||||
KeyCode::Char('[') => {
|
||||
state.clear_burst();
|
||||
let count = state.monitors.len();
|
||||
if count > 0 {
|
||||
let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
|
|
@ -165,7 +159,6 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
}
|
||||
}
|
||||
KeyCode::Char(']') => {
|
||||
state.clear_burst();
|
||||
let count = state.monitors.len();
|
||||
if count > 0 {
|
||||
let new_idx = (state.config.monitor_idx + 1) % count;
|
||||
|
|
@ -180,20 +173,19 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
crate::ui::layout_view::trigger_save(state);
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
state.clear_burst();
|
||||
state.config.scale_editing = false;
|
||||
// Re-sync from live monitor to discard pending edits
|
||||
let idx = state.config.monitor_idx;
|
||||
state.config.sync_from_monitor(idx, &state.monitors);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.clear_burst();
|
||||
if state.config.current_field() == ConfigField::Scale {
|
||||
commit_scale(state);
|
||||
}
|
||||
apply_current(state);
|
||||
}
|
||||
_ => {
|
||||
state.push_undo();
|
||||
handle_field_key(event, state);
|
||||
}
|
||||
}
|
||||
|
|
@ -219,10 +211,12 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
}
|
||||
}
|
||||
MouseEventKind::ScrollUp => {
|
||||
state.push_undo();
|
||||
let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
|
||||
handle_field_key(fake_right, state);
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
state.push_undo();
|
||||
let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
|
||||
handle_field_key(fake_left, state);
|
||||
}
|
||||
|
|
@ -236,8 +230,6 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
|||
return;
|
||||
}
|
||||
let idx = state.config.monitor_idx.min(monitors_len - 1);
|
||||
// Coalesce consecutive value cycles (and scroll) into one undo step.
|
||||
state.micro_edit();
|
||||
|
||||
match state.config.current_field() {
|
||||
ConfigField::Resolution => match event.code {
|
||||
|
|
@ -247,17 +239,17 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
|||
let m = &state.monitors[idx];
|
||||
state.config.update_refreshes(m);
|
||||
sync_mode_to_monitor(state, idx);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right
|
||||
if state.config.res_idx + 1 < state.config.resolutions.len() =>
|
||||
{
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
if state.config.res_idx + 1 < state.config.resolutions.len() {
|
||||
state.config.res_idx += 1;
|
||||
let m = &state.monitors[idx];
|
||||
state.config.update_refreshes(m);
|
||||
sync_mode_to_monitor(state, idx);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
|
@ -266,15 +258,15 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
|||
if state.config.refresh_idx > 0 {
|
||||
state.config.refresh_idx -= 1;
|
||||
sync_mode_to_monitor(state, idx);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right
|
||||
if state.config.refresh_idx + 1 < state.config.refreshes.len() =>
|
||||
{
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
if state.config.refresh_idx + 1 < state.config.refreshes.len() {
|
||||
state.config.refresh_idx += 1;
|
||||
sync_mode_to_monitor(state, idx);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
|
@ -284,14 +276,14 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
|||
state.monitors[idx].scale = (s * 100.0).round() / 100.0;
|
||||
state.monitors[idx].scale = state.monitors[idx].scale.max(0.1);
|
||||
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Char('.') => {
|
||||
let s = state.monitors[idx].scale + 0.1;
|
||||
state.monitors[idx].scale = (s * 100.0).round() / 100.0;
|
||||
state.monitors[idx].scale = state.monitors[idx].scale.min(10.0);
|
||||
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
|
||||
state.config.scale_editing = true;
|
||||
|
|
@ -311,35 +303,27 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
|||
.checked_sub(1)
|
||||
.unwrap_or(all.len() - 1);
|
||||
state.monitors[idx].transform = all[state.config.transform_idx];
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
let all = Transform::all();
|
||||
state.config.transform_idx = (state.config.transform_idx + 1) % all.len();
|
||||
state.monitors[idx].transform = all[state.config.transform_idx];
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ConfigField::Vrr => match event.code {
|
||||
KeyCode::Char('h')
|
||||
| KeyCode::Left
|
||||
| KeyCode::Char('l')
|
||||
| KeyCode::Right
|
||||
| KeyCode::Char(' ') => {
|
||||
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
|
||||
state.monitors[idx].vrr = !state.monitors[idx].vrr;
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ConfigField::Dpms => match event.code {
|
||||
KeyCode::Char('h')
|
||||
| KeyCode::Left
|
||||
| KeyCode::Char('l')
|
||||
| KeyCode::Right
|
||||
| KeyCode::Char(' ') => {
|
||||
KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => {
|
||||
state.monitors[idx].dpms = !state.monitors[idx].dpms;
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
|
@ -348,15 +332,15 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
|
|||
if state.config.mirror_idx > 0 {
|
||||
state.config.mirror_idx -= 1;
|
||||
sync_mirror_to_monitor(state, idx);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right
|
||||
if state.config.mirror_idx + 1 < state.config.mirror_options.len() =>
|
||||
{
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
if state.config.mirror_idx + 1 < state.config.mirror_options.len() {
|
||||
state.config.mirror_idx += 1;
|
||||
sync_mirror_to_monitor(state, idx);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
|
@ -374,11 +358,7 @@ fn sync_mode_to_monitor(state: &mut AppState, idx: usize) {
|
|||
}
|
||||
|
||||
fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) {
|
||||
let chosen = state
|
||||
.config
|
||||
.mirror_options
|
||||
.get(state.config.mirror_idx)
|
||||
.cloned();
|
||||
let chosen = state.config.mirror_options.get(state.config.mirror_idx).cloned();
|
||||
state.monitors[idx].mirror_of = match chosen.as_deref() {
|
||||
Some("(none)") | None => None,
|
||||
Some(s) => Some(s.to_owned()),
|
||||
|
|
@ -386,25 +366,19 @@ fn sync_mirror_to_monitor(state: &mut AppState, idx: usize) {
|
|||
}
|
||||
|
||||
fn commit_scale(state: &mut AppState) {
|
||||
let idx = state
|
||||
.config
|
||||
.monitor_idx
|
||||
.min(state.monitors.len().saturating_sub(1));
|
||||
let idx = state.config.monitor_idx.min(state.monitors.len().saturating_sub(1));
|
||||
if let Ok(v) = state.config.scale_str.parse::<f64>() {
|
||||
state.monitors[idx].scale = v.clamp(0.1, 10.0);
|
||||
state.config.scale_str = format!("{:.2}", state.monitors[idx].scale);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
state.config.scale_editing = false;
|
||||
}
|
||||
|
||||
fn apply_current(state: &mut AppState) {
|
||||
state.clear_burst();
|
||||
state.pending_apply = true;
|
||||
state.set_status("Applying...", StatusLevel::Info);
|
||||
// Don't clear `dirty` here: it must survive until the apply actually
|
||||
// succeeds (main.rs clears it on a successful apply + save). Otherwise a
|
||||
// failed `hyprctl` apply would silently drop the unsaved-changes guard.
|
||||
state.dirty = false;
|
||||
}
|
||||
|
||||
pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
|
|
@ -431,15 +405,12 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
};
|
||||
let header = format!(" {} — {}{}", m.name, m.description, ppi_hint);
|
||||
f.render_widget(
|
||||
Paragraph::new(header).style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Paragraph::new(header).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
let form_area = chunks[1];
|
||||
let row_height = 1u16;
|
||||
let fields = ConfigField::ALL;
|
||||
|
||||
let items: Vec<ListItem> = fields
|
||||
|
|
@ -461,6 +432,7 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
})
|
||||
.collect();
|
||||
|
||||
let _ = row_height; // used implicitly via ListItem heights
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
|
|
@ -491,10 +463,7 @@ fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String {
|
|||
}
|
||||
ConfigField::Scale => {
|
||||
if state.config.scale_editing {
|
||||
format!(
|
||||
"{}| (Enter to commit, ,/. for ±0.1)",
|
||||
state.config.scale_str
|
||||
)
|
||||
format!("{}| (Enter to commit, ,/. for ±0.1)", state.config.scale_str)
|
||||
} else {
|
||||
format!("{} (,/. for ±0.1)", state.config.scale_str)
|
||||
}
|
||||
|
|
@ -504,18 +473,10 @@ fn field_value(field: ConfigField, state: &AppState, m: &Monitor) -> String {
|
|||
.label()
|
||||
.to_owned(),
|
||||
ConfigField::Vrr => {
|
||||
if m.vrr {
|
||||
"ON".to_owned()
|
||||
} else {
|
||||
"OFF".to_owned()
|
||||
}
|
||||
if m.vrr { "ON".to_owned() } else { "OFF".to_owned() }
|
||||
}
|
||||
ConfigField::Dpms => {
|
||||
if m.dpms {
|
||||
"ON".to_owned()
|
||||
} else {
|
||||
"OFF".to_owned()
|
||||
}
|
||||
if m.dpms { "ON".to_owned() } else { "OFF".to_owned() }
|
||||
}
|
||||
ConfigField::MirrorOf => state
|
||||
.config
|
||||
|
|
|
|||
|
|
@ -8,10 +8,7 @@ use ratatui::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
layout::{
|
||||
auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position,
|
||||
world_to_canvas,
|
||||
},
|
||||
layout::{auto_arrange, bounding_box, canvas_scale, canvas_to_world, move_selected, snap_position, world_to_canvas},
|
||||
monitor::Monitor,
|
||||
ui::{AppState, DragState, StatusLevel, Tab},
|
||||
};
|
||||
|
|
@ -23,51 +20,36 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
|
||||
match event.code {
|
||||
KeyCode::Char('h') | KeyCode::Left => {
|
||||
state.micro_edit();
|
||||
state.push_undo();
|
||||
move_selected(&state.layout, &mut state.monitors, -step, 0);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
state.micro_edit();
|
||||
state.push_undo();
|
||||
move_selected(&state.layout, &mut state.monitors, step, 0);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
state.micro_edit();
|
||||
state.push_undo();
|
||||
move_selected(&state.layout, &mut state.monitors, 0, -step);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
state.micro_edit();
|
||||
state.push_undo();
|
||||
move_selected(&state.layout, &mut state.monitors, 0, step);
|
||||
state.mark_dirty();
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Char('n') => {
|
||||
state.clear_burst();
|
||||
state.layout.next(count);
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Char('p') => {
|
||||
state.clear_burst();
|
||||
state.layout.prev(count);
|
||||
}
|
||||
KeyCode::Char('[') => {
|
||||
state.clear_burst();
|
||||
state.layout.zoom = (state.layout.zoom - 0.1).max(0.1);
|
||||
}
|
||||
KeyCode::Char(']') => {
|
||||
state.clear_burst();
|
||||
state.layout.zoom = (state.layout.zoom + 0.1).min(5.0);
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count),
|
||||
KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count),
|
||||
KeyCode::Char('[') => state.layout.zoom = (state.layout.zoom - 0.1).max(0.1),
|
||||
KeyCode::Char(']') => state.layout.zoom = (state.layout.zoom + 0.1).min(5.0),
|
||||
KeyCode::Char('0') => {
|
||||
state.push_undo();
|
||||
auto_arrange(&mut state.monitors);
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.clear_burst();
|
||||
state
|
||||
.config
|
||||
.sync_from_monitor(state.layout.selected, &state.monitors);
|
||||
state.config.sync_from_monitor(state.layout.selected, &state.monitors);
|
||||
state.tab = Tab::Config;
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -84,8 +66,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
if let Some(idx) = monitor_at(col, row, canvas, state) {
|
||||
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
||||
let scale = canvas_scale_for(canvas, state);
|
||||
let (wx, wy) =
|
||||
canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1);
|
||||
let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1);
|
||||
|
||||
// Push undo at drag start, not on every move
|
||||
state.push_undo();
|
||||
|
|
@ -104,22 +85,15 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
let canvas = canvas_area(state.terminal_size);
|
||||
let (min_x, min_y, _, _) = bounding_box(&state.monitors);
|
||||
let scale = canvas_scale_for(canvas, state);
|
||||
let (wx, wy) =
|
||||
canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1);
|
||||
let (wx, wy) = canvas_to_world(col, row, scale, min_x, min_y, canvas.x + 1, canvas.y + 1);
|
||||
|
||||
let idx = drag.monitor_idx;
|
||||
let new_x = drag.origin_x + (wx - drag.click_world_x);
|
||||
let new_y = drag.origin_y + (wy - drag.click_world_y);
|
||||
let (sx, sy) = snap_position(
|
||||
idx,
|
||||
new_x,
|
||||
new_y,
|
||||
&state.monitors,
|
||||
state.layout.snap_threshold,
|
||||
);
|
||||
let (sx, sy) = snap_position(idx, new_x, new_y, &state.monitors, state.layout.snap_threshold);
|
||||
state.monitors[idx].x = sx;
|
||||
state.monitors[idx].y = sy;
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
|
|
@ -188,31 +162,18 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
continue;
|
||||
}
|
||||
|
||||
let rect = Rect {
|
||||
x: cx,
|
||||
y: cy,
|
||||
width: cw,
|
||||
height: ch,
|
||||
};
|
||||
let rect = Rect { x: cx, y: cy, width: cw, height: ch };
|
||||
|
||||
let is_selected = i == selected;
|
||||
let is_dragging = state
|
||||
.drag_state
|
||||
.as_ref()
|
||||
.map(|d| d.monitor_idx == i)
|
||||
.unwrap_or(false);
|
||||
let is_dragging = state.drag_state.as_ref().map(|d| d.monitor_idx == i).unwrap_or(false);
|
||||
let is_overlapping = overlapping[i];
|
||||
|
||||
let border_style = if is_dragging {
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
|
||||
} else if is_overlapping {
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Blue)
|
||||
};
|
||||
|
|
@ -229,10 +190,7 @@ fn render_canvas(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
})
|
||||
.border_style(border_style)
|
||||
.title(Span::styled(&label, border_style))
|
||||
.title_bottom(Span::styled(
|
||||
&mode_str,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
.title_bottom(Span::styled(&mode_str, Style::default().fg(Color::DarkGray)));
|
||||
|
||||
f.render_widget(block, rect);
|
||||
}
|
||||
|
|
@ -245,9 +203,7 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
let idx = state.layout.selected.min(state.monitors.len() - 1);
|
||||
let m = &state.monitors[idx];
|
||||
|
||||
let mirror_info = m
|
||||
.mirror_of
|
||||
.as_ref()
|
||||
let mirror_info = m.mirror_of.as_ref()
|
||||
.map(|src| format!(" mirror:{}", src))
|
||||
.unwrap_or_default();
|
||||
|
||||
|
|
@ -257,24 +213,14 @@ fn render_readout(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
""
|
||||
};
|
||||
|
||||
let drag_hint = if state.drag_state.is_some() {
|
||||
" [dragging]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let drag_hint = if state.drag_state.is_some() { " [dragging]" } else { "" };
|
||||
|
||||
let text = format!(
|
||||
" {} x:{} y:{} {}x{}@{:.0}Hz scale:{:.2}{}{}{}",
|
||||
m.name,
|
||||
m.x,
|
||||
m.y,
|
||||
m.active_mode.width,
|
||||
m.active_mode.height,
|
||||
m.active_mode.refresh,
|
||||
m.name, m.x, m.y,
|
||||
m.active_mode.width, m.active_mode.height, m.active_mode.refresh,
|
||||
m.scale,
|
||||
mirror_info,
|
||||
overlap_warn,
|
||||
drag_hint,
|
||||
mirror_info, overlap_warn, drag_hint,
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(text).style(Style::default().fg(Color::Cyan)),
|
||||
|
|
@ -289,10 +235,8 @@ fn overlapping_monitors(monitors: &[Monitor]) -> Vec<bool> {
|
|||
for j in (i + 1)..monitors.len() {
|
||||
let a = &monitors[i];
|
||||
let b = &monitors[j];
|
||||
if a.x < b.right_edge()
|
||||
&& a.right_edge() > b.x
|
||||
&& a.y < b.bottom_edge()
|
||||
&& a.bottom_edge() > b.y
|
||||
if a.x < b.right_edge() && a.right_edge() > b.x
|
||||
&& a.y < b.bottom_edge() && a.bottom_edge() > b.y
|
||||
{
|
||||
flags[i] = true;
|
||||
flags[j] = true;
|
||||
|
|
@ -315,9 +259,9 @@ pub fn canvas_area(terminal_size: (u16, u16)) -> Rect {
|
|||
}
|
||||
|
||||
fn in_canvas(col: u16, row: u16, canvas: Rect) -> bool {
|
||||
col > canvas.x
|
||||
col >= canvas.x + 1
|
||||
&& col < canvas.x + canvas.width.saturating_sub(1)
|
||||
&& row > canvas.y
|
||||
&& row >= canvas.y + 1
|
||||
&& row < canvas.y + canvas.height.saturating_sub(1)
|
||||
}
|
||||
|
||||
|
|
@ -357,13 +301,31 @@ fn monitor_at(col: u16, row: u16, canvas: Rect, state: &AppState) -> Option<usiz
|
|||
}
|
||||
|
||||
pub fn trigger_save(state: &mut AppState) {
|
||||
match crate::store::save_from_monitors(&state.monitors) {
|
||||
use crate::monitor::format_hypr_line;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let path: PathBuf = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()))
|
||||
.join("hypr/monitors.conf");
|
||||
|
||||
let is_new = !path.exists();
|
||||
let mut lines = vec!["# Generated by breadmon — do not edit by hand".to_owned()];
|
||||
for m in &state.monitors {
|
||||
lines.push(format_hypr_line(m));
|
||||
}
|
||||
let content = lines.join("\n") + "\n";
|
||||
|
||||
match std::fs::write(&path, &content) {
|
||||
Ok(()) => {
|
||||
state.dirty = false;
|
||||
if is_new {
|
||||
state.set_status(
|
||||
format!("Saved to {}", crate::store::config_path().display()),
|
||||
format!("Saved. Add: source = {} to hyprland.conf", path.display()),
|
||||
StatusLevel::Success,
|
||||
);
|
||||
} else {
|
||||
state.set_status(format!("Saved to {}", path.display()), StatusLevel::Success);
|
||||
}
|
||||
state.dirty = false;
|
||||
}
|
||||
Err(e) => state.set_status(format!("Save failed: {}", e), StatusLevel::Error),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,7 @@ impl MirrorState {
|
|||
fn next_field(&mut self) {
|
||||
// Skip Apply/Cancel if no result yet
|
||||
let mut next = (self.focused + 1) % FIELDS.len();
|
||||
if self.result.is_none()
|
||||
&& (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel)
|
||||
{
|
||||
if self.result.is_none() && (FIELDS[next] == MirrorField::Apply || FIELDS[next] == MirrorField::Cancel) {
|
||||
next = 0;
|
||||
}
|
||||
self.focused = next;
|
||||
|
|
@ -56,13 +54,8 @@ impl MirrorState {
|
|||
fn prev_field(&mut self) {
|
||||
let len = FIELDS.len();
|
||||
let mut prev = self.focused.checked_sub(1).unwrap_or(len - 1);
|
||||
if self.result.is_none()
|
||||
&& (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel)
|
||||
{
|
||||
prev = FIELDS
|
||||
.iter()
|
||||
.position(|&f| f == MirrorField::Compute)
|
||||
.unwrap_or(2);
|
||||
if self.result.is_none() && (FIELDS[prev] == MirrorField::Apply || FIELDS[prev] == MirrorField::Cancel) {
|
||||
prev = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2);
|
||||
}
|
||||
self.focused = prev;
|
||||
}
|
||||
|
|
@ -93,14 +86,12 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
}
|
||||
KeyCode::Char('h') | KeyCode::Left => match state.mirror.current_field() {
|
||||
MirrorField::Source => {
|
||||
state.mirror.source_idx =
|
||||
state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.fix_indices(count);
|
||||
state.mirror.result = None;
|
||||
}
|
||||
MirrorField::Target => {
|
||||
state.mirror.target_idx =
|
||||
state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.fix_indices(count);
|
||||
state.mirror.result = None;
|
||||
}
|
||||
|
|
@ -127,10 +118,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
Some(result) => {
|
||||
state.mirror.result = Some(result);
|
||||
// Move focus to Apply
|
||||
state.mirror.focused = FIELDS
|
||||
.iter()
|
||||
.position(|&f| f == MirrorField::Apply)
|
||||
.unwrap_or(3);
|
||||
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
||||
}
|
||||
None => {
|
||||
state.set_status(
|
||||
|
|
@ -149,13 +137,15 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
|
|||
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
|
||||
state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
|
||||
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
state.mirror.result = None;
|
||||
state.mirror.focused = 0;
|
||||
state.set_status(
|
||||
format!(
|
||||
"Mirror set: {} → {} at {}",
|
||||
src_name, state.monitors[tgt_idx].name, result.mirror_mode
|
||||
src_name,
|
||||
state.monitors[tgt_idx].name,
|
||||
result.mirror_mode
|
||||
),
|
||||
StatusLevel::Success,
|
||||
);
|
||||
|
|
@ -192,26 +182,17 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
match row {
|
||||
2 | 3 => {
|
||||
// Source picker area
|
||||
let f_idx = FIELDS
|
||||
.iter()
|
||||
.position(|&f| f == MirrorField::Source)
|
||||
.unwrap_or(0);
|
||||
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Source).unwrap_or(0);
|
||||
state.mirror.focused = f_idx;
|
||||
}
|
||||
4 | 5 => {
|
||||
// Target picker area
|
||||
let f_idx = FIELDS
|
||||
.iter()
|
||||
.position(|&f| f == MirrorField::Target)
|
||||
.unwrap_or(1);
|
||||
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Target).unwrap_or(1);
|
||||
state.mirror.focused = f_idx;
|
||||
}
|
||||
6 => {
|
||||
// Compute button
|
||||
let f_idx = FIELDS
|
||||
.iter()
|
||||
.position(|&f| f == MirrorField::Compute)
|
||||
.unwrap_or(2);
|
||||
let f_idx = FIELDS.iter().position(|&f| f == MirrorField::Compute).unwrap_or(2);
|
||||
state.mirror.focused = f_idx;
|
||||
// Also activate it
|
||||
let src = &state.monitors[state.mirror.source_idx];
|
||||
|
|
@ -219,10 +200,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
match crate::mirror::find_mirror_modes(src, tgt) {
|
||||
Some(result) => {
|
||||
state.mirror.result = Some(result);
|
||||
state.mirror.focused = FIELDS
|
||||
.iter()
|
||||
.position(|&f| f == MirrorField::Apply)
|
||||
.unwrap_or(3);
|
||||
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
||||
}
|
||||
None => {
|
||||
state.set_status(
|
||||
|
|
@ -232,32 +210,25 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
r if r >= 7
|
||||
r if r >= 7 => {
|
||||
// Result panel: Apply is on the line with buttons.
|
||||
// Rough column check: col < 20 = Apply, col >= 20 = Cancel
|
||||
&& state.mirror.result.is_some() =>
|
||||
{
|
||||
if state.mirror.result.is_some() {
|
||||
let col = event.column;
|
||||
if col < 20 {
|
||||
// Activate Apply
|
||||
state.mirror.focused = FIELDS
|
||||
.iter()
|
||||
.position(|&f| f == MirrorField::Apply)
|
||||
.unwrap_or(3);
|
||||
state.mirror.focused = FIELDS.iter().position(|&f| f == MirrorField::Apply).unwrap_or(3);
|
||||
if let Some(result) = state.mirror.result.clone() {
|
||||
state.push_undo();
|
||||
let src_name = state.monitors[state.mirror.source_idx].name.clone();
|
||||
let tgt_idx = state.mirror.target_idx;
|
||||
state.monitors[tgt_idx].active_mode = result.mirror_mode.clone();
|
||||
state.monitors[tgt_idx].mirror_of = Some(src_name.clone());
|
||||
state.mark_dirty();
|
||||
state.dirty = true;
|
||||
state.mirror.result = None;
|
||||
state.mirror.focused = 0;
|
||||
state.set_status(
|
||||
format!(
|
||||
"Mirror set: {} → {} at {}",
|
||||
src_name, state.monitors[tgt_idx].name, result.mirror_mode
|
||||
),
|
||||
format!("Mirror set: {} → {} at {}", src_name, state.monitors[tgt_idx].name, result.mirror_mode),
|
||||
crate::ui::StatusLevel::Success,
|
||||
);
|
||||
}
|
||||
|
|
@ -267,6 +238,7 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
state.mirror.focused = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -274,21 +246,20 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
// Scroll in source/target pickers to cycle monitors
|
||||
match state.mirror.current_field() {
|
||||
MirrorField::Source => {
|
||||
state.mirror.source_idx =
|
||||
state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.source_idx = state.mirror.source_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.fix_indices(count);
|
||||
state.mirror.result = None;
|
||||
}
|
||||
MirrorField::Target => {
|
||||
state.mirror.target_idx =
|
||||
state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.target_idx = state.mirror.target_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.mirror.fix_indices(count);
|
||||
state.mirror.result = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollDown => match state.mirror.current_field() {
|
||||
MouseEventKind::ScrollDown => {
|
||||
match state.mirror.current_field() {
|
||||
MirrorField::Source => {
|
||||
state.mirror.source_idx = (state.mirror.source_idx + 1) % count;
|
||||
state.mirror.fix_indices(count);
|
||||
|
|
@ -300,7 +271,8 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
state.mirror.result = None;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -352,16 +324,12 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) {
|
|||
let focused = state.mirror.current_field();
|
||||
|
||||
let src_style = if focused == MirrorField::Source {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
let tgt_style = if focused == MirrorField::Target {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
|
@ -386,9 +354,7 @@ fn render_pickers(f: &mut Frame, area: Rect, state: &AppState, count: usize) {
|
|||
fn render_compute_btn(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
let focused = state.mirror.current_field() == MirrorField::Compute;
|
||||
let style = if focused {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
|
@ -410,16 +376,12 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes
|
|||
|
||||
let focused = state.mirror.current_field();
|
||||
let apply_style = if focused == MirrorField::Apply {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
let cancel_style = if focused == MirrorField::Cancel {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
|
@ -435,10 +397,7 @@ fn render_result(f: &mut Frame, area: Rect, state: &AppState, result: &MirrorRes
|
|||
Style::default().fg(Color::White),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(
|
||||
" Refresh: {:.2} Hz ({})",
|
||||
result.refresh, refresh_label
|
||||
),
|
||||
format!(" Refresh: {:.2} Hz ({})", result.refresh, refresh_label),
|
||||
Style::default().fg(Color::White),
|
||||
)),
|
||||
Line::raw(""),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ use ratatui::{
|
|||
Frame,
|
||||
};
|
||||
|
||||
use crate::{layout::LayoutState, monitor::Monitor};
|
||||
use crate::{
|
||||
layout::LayoutState,
|
||||
monitor::Monitor,
|
||||
};
|
||||
|
||||
use config_view::ConfigState;
|
||||
use mirror_view::MirrorState;
|
||||
|
|
@ -108,15 +111,8 @@ pub struct AppState {
|
|||
pub terminal_size: (u16, u16),
|
||||
/// Set to true by any handler that wants `main.rs` to run `apply_monitors`.
|
||||
pub pending_apply: bool,
|
||||
/// Named snapshot last loaded or saved this session. Cleared when the
|
||||
/// in-memory layout is edited, so `bread.mon.applied` can report it
|
||||
/// honestly (or `null` for an ad-hoc layout).
|
||||
pub active_profile: Option<String>,
|
||||
/// Snapshots for Ctrl+Z undo (up to 20 deep).
|
||||
pub undo_stack: Vec<Vec<Monitor>>,
|
||||
/// True while a run of small incremental edits (nudges / value cycles)
|
||||
/// is ongoing, so undo coalesces the whole burst into one snapshot.
|
||||
undo_in_burst: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -135,26 +131,12 @@ impl AppState {
|
|||
drag_state: None,
|
||||
terminal_size,
|
||||
pending_apply: false,
|
||||
active_profile: None,
|
||||
undo_stack: Vec::new(),
|
||||
undo_in_burst: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, text: impl Into<String>, level: StatusLevel) {
|
||||
self.status = Some(StatusMsg {
|
||||
text: text.into(),
|
||||
level,
|
||||
born: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Mark the in-memory layout as edited. Also forgets `active_profile`
|
||||
/// — a mutated layout is no longer the named snapshot that was loaded
|
||||
/// or saved.
|
||||
pub fn mark_dirty(&mut self) {
|
||||
self.dirty = true;
|
||||
self.active_profile = None;
|
||||
self.status = Some(StatusMsg { text: text.into(), level, born: Instant::now() });
|
||||
}
|
||||
|
||||
pub fn tick_status(&mut self) {
|
||||
|
|
@ -166,40 +148,14 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn switch_tab(&mut self, tab: Tab) {
|
||||
self.undo_in_burst = false;
|
||||
self.tab = tab;
|
||||
if tab == Tab::Config {
|
||||
self.config
|
||||
.sync_from_monitor(self.layout.selected, &self.monitors);
|
||||
self.config.sync_from_monitor(self.layout.selected, &self.monitors);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a monitor snapshot for undo (max 20 entries) and end any
|
||||
/// in-progress edit burst.
|
||||
/// Save a monitor snapshot for undo (max 20 entries).
|
||||
pub fn push_undo(&mut self) {
|
||||
self.push_snapshot();
|
||||
self.undo_in_burst = false;
|
||||
}
|
||||
|
||||
/// Start (or continue) a run of small incremental edits. Only the first
|
||||
/// edit in the run actually snapshots, so nudging a monitor 20 px (or
|
||||
/// cycling a value repeatedly) collapses to a single undo step rather
|
||||
/// than consuming 20 of the 20-step undo stack.
|
||||
pub fn micro_edit(&mut self) {
|
||||
if !self.undo_in_burst {
|
||||
self.push_snapshot();
|
||||
self.undo_in_burst = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// End a coalesced-edit burst without snapping. Called on navigation
|
||||
/// (tab switches, monitor/field changes, zoom) so bursts don't bleed
|
||||
/// across distinct actions.
|
||||
pub fn clear_burst(&mut self) {
|
||||
self.undo_in_burst = false;
|
||||
}
|
||||
|
||||
fn push_snapshot(&mut self) {
|
||||
self.undo_stack.push(self.monitors.clone());
|
||||
if self.undo_stack.len() > 20 {
|
||||
self.undo_stack.remove(0);
|
||||
|
|
@ -210,8 +166,7 @@ impl AppState {
|
|||
pub fn undo(&mut self) {
|
||||
if let Some(snapshot) = self.undo_stack.pop() {
|
||||
self.monitors = snapshot;
|
||||
self.undo_in_burst = false;
|
||||
self.mark_dirty();
|
||||
self.dirty = true;
|
||||
self.layout.clamp_selected(self.monitors.len());
|
||||
// Re-sync config view to the restored state
|
||||
let idx = self.layout.selected;
|
||||
|
|
@ -246,22 +201,10 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) -> bool {
|
|||
|
||||
// Global tab switching
|
||||
match event.code {
|
||||
KeyCode::Char('1') | KeyCode::F(1) => {
|
||||
state.switch_tab(Tab::Layout);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Char('2') | KeyCode::F(2) => {
|
||||
state.switch_tab(Tab::Config);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Char('3') | KeyCode::F(3) => {
|
||||
state.switch_tab(Tab::Mirror);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Char('4') | KeyCode::F(4) => {
|
||||
state.switch_tab(Tab::Profiles);
|
||||
return true;
|
||||
}
|
||||
KeyCode::Char('1') | KeyCode::F(1) => { state.switch_tab(Tab::Layout); return true; }
|
||||
KeyCode::Char('2') | KeyCode::F(2) => { state.switch_tab(Tab::Config); return true; }
|
||||
KeyCode::Char('3') | KeyCode::F(3) => { state.switch_tab(Tab::Mirror); return true; }
|
||||
KeyCode::Char('4') | KeyCode::F(4) => { state.switch_tab(Tab::Profiles); return true; }
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,10 +126,7 @@ fn handle_list_key(event: KeyEvent, state: &mut AppState) {
|
|||
match profile::delete(&name) {
|
||||
Ok(()) => {
|
||||
state.profiles.refresh();
|
||||
state.set_status(
|
||||
format!("Deleted profile '{}'", name),
|
||||
StatusLevel::Success,
|
||||
);
|
||||
state.set_status(format!("Deleted profile '{}'", name), StatusLevel::Success);
|
||||
}
|
||||
Err(e) => {
|
||||
state.set_status(format!("Delete failed: {}", e), StatusLevel::Error);
|
||||
|
|
@ -198,11 +195,8 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
|
|||
MouseEventKind::ScrollUp => {
|
||||
let count = state.profiles.profiles.len();
|
||||
if count > 0 {
|
||||
state.profiles.selected_idx = state
|
||||
.profiles
|
||||
.selected_idx
|
||||
.checked_sub(1)
|
||||
.unwrap_or(count - 1);
|
||||
state.profiles.selected_idx =
|
||||
state.profiles.selected_idx.checked_sub(1).unwrap_or(count - 1);
|
||||
state.profiles.focused = ProfileField::List;
|
||||
}
|
||||
}
|
||||
|
|
@ -246,7 +240,6 @@ fn do_save(state: &mut AppState) {
|
|||
Ok(()) => {
|
||||
state.profiles.new_name.clear();
|
||||
state.profiles.refresh();
|
||||
state.active_profile = Some(name.clone());
|
||||
state.set_status(format!("Saved profile '{}'", name), StatusLevel::Success);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -261,7 +254,6 @@ fn do_load(state: &mut AppState) {
|
|||
Ok(p) => {
|
||||
profile::apply_to_monitors(&p, &mut state.monitors);
|
||||
state.dirty = true;
|
||||
state.active_profile = Some(name.clone());
|
||||
state.set_status(
|
||||
format!("Loaded profile '{}'. Press [a] to apply.", name),
|
||||
StatusLevel::Success,
|
||||
|
|
@ -314,9 +306,7 @@ fn render_list(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
.add_modifier(Modifier::BOLD)
|
||||
.bg(Color::DarkGray)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
|
@ -370,28 +360,22 @@ fn render_save_row(f: &mut Frame, area: Rect, state: &AppState) {
|
|||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(input_display).style(input_style).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(input_style),
|
||||
),
|
||||
Paragraph::new(input_display)
|
||||
.style(input_style)
|
||||
.block(Block::default().borders(Borders::ALL).border_style(input_style)),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Save button
|
||||
let save_style = if save_focused {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(" [ Save ] ").style(save_style).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(save_style),
|
||||
),
|
||||
Paragraph::new(" [ Save ] ")
|
||||
.style(save_style)
|
||||
.block(Block::default().borders(Borders::ALL).border_style(save_style)),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue