Extract bos-settings into its own repo; add Appearance/Startup Apps/Display editors

Split out of the bos repo so a bos-settings release no longer requires a
BOS ISO release (and vice versa) — CI here publishes straight to the
[breadway] pacman repo on tag push, independent of bos's own release cadence.

Also adds real GUI editors for the Hyprland JSON config surfaces bos gained
recently (hypr/settings.json, hypr/monitors.json, hypr/autostart.json):
- Appearance panel: gaps, borders (with a real color picker), blur, shadow,
  tiling layout, and basic input settings.
- Display panel: a monitor-rule list editor alongside the existing live
  connected-monitor readout.
- Startup Apps panel: toggle/edit/add/remove the extra autostart commands.
- --page argv support for deep-linking (breadhelp's guides can now open
  bos-settings straight to a specific page).
- Fixed a stale "View keybinds cheat sheet" button pointing at a file
  deleted when breadhelp replaced bos-keybinds/bos-welcome.
This commit is contained in:
Breadway 2026-07-04 23:31:18 +08:00
parent d0fc16bc84
commit 121e93d273
14 changed files with 1801 additions and 30 deletions

View file

@ -0,0 +1,21 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bos-settings.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -0,0 +1,40 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="bos-settings-${VERSION}/" HEAD \
> packaging/bos-settings-${VERSION}.tar.gz
SHA=$(sha256sum packaging/bos-settings-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

34
.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# Rust build artifacts
/target/
**/*.pdb
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
.direnv/
# OS artifacts
.DS_Store
Thumbs.db
desktop.ini
# Environment / secrets
.env
.env.local
*.env.*
secrets/
*.pem
*.key
*.p12
# Runtime / logs
*.log
logs/
*.pid
*.sock
# Claude Code local agent state
.claude/

978
Cargo.lock generated Normal file
View file

@ -0,0 +1,978 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bos-settings"
version = "0.6.0"
dependencies = [
"async-channel",
"bread-theme",
"glib",
"gtk4",
"serde",
"serde_json",
"toml 0.8.23",
"toml_edit 0.22.27",
]
[[package]]
name = "bread-theme"
version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780"
dependencies = [
"dirs",
"gtk4",
"serde",
"serde_json",
]
[[package]]
name = "cairo-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags",
"cairo-sys-rs",
"glib",
"libc",
]
[[package]]
name = "cairo-sys-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "cfg-expr"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "gdk-pixbuf"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk4"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk4-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk4-sys"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "gio"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"pin-project-lite",
"smallvec",
]
[[package]]
name = "gio-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
]
[[package]]
name = "glib"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100"
dependencies = [
"bitflags",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "glib-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "gobject-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "graphene-rs"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff"
dependencies = [
"glib",
"graphene-sys",
]
[[package]]
name = "graphene-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "gsk4"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff"
dependencies = [
"cairo-rs",
"gdk4",
"glib",
"graphene-rs",
"gsk4-sys",
"libc",
"pango",
]
[[package]]
name = "gsk4-sys"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088"
dependencies = [
"cairo-sys-rs",
"gdk4-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk4"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9"
dependencies = [
"cairo-rs",
"field-offset",
"futures-channel",
"gdk-pixbuf",
"gdk4",
"gio",
"glib",
"graphene-rs",
"gsk4",
"gtk4-macros",
"gtk4-sys",
"libc",
"pango",
]
[[package]]
name = "gtk4-macros"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gtk4-sys"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"gsk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "pango"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c"
dependencies = [
"gio",
"glib",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"libredox",
"thiserror",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml 1.1.2+spec-1.1.0",
"version-compare",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Breadway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# bos-settings
System settings app for [BOS (Bread Operating System)](https://github.com/Breadway/bos) — GTK4, configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively.
Split out of the `bos` repo into its own repo so a bos-settings release doesn't require a BOS ISO release, and vice versa. Still the only pacman-packaged (not bakery-managed) bread app — see `packaging/README.md`.
## Building
```bash
cargo build --release
```
## Packaging / releasing
See `packaging/README.md`. In short: bump `Cargo.toml`'s version, tag `vX.Y.Z`, push the tag to both remotes — `.forgejo/workflows/package.yml` builds and publishes to the `[breadway]` pacman repo automatically.

View file

@ -5,7 +5,7 @@ pkgver=0.1.0
pkgrel=1
pkgdesc="System settings app for Bread OS"
arch=('x86_64')
url="https://github.com/Breadway/bos"
url="https://github.com/Breadway/bos-settings"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
@ -21,18 +21,18 @@ sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked -p bos-settings
cargo build --release --locked
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked -p bos-settings
cargo test --release --locked
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/bos-settings "${pkgdir}/usr/bin/bos-settings"
install -Dm644 packaging/arch/bos-settings.desktop \
install -Dm644 packaging/bos-settings.desktop \
"${pkgdir}/usr/share/applications/bos-settings.desktop"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

@ -2,12 +2,37 @@ mod config;
mod theme;
mod ui;
use gtk4::gio::ApplicationFlags;
use gtk4::prelude::*;
fn main() {
let app = gtk4::Application::builder()
.application_id("com.breadway.bos-settings")
// HANDLES_COMMAND_LINE: without it, GApplication validates argv
// against registered GOptionEntries (none here) and would reject
// `--page <id>` with "unknown option" before `activate` ever runs.
.flags(ApplicationFlags::HANDLES_COMMAND_LINE)
.build();
app.connect_activate(ui::window::build_ui);
app.connect_command_line(|app, cmdline| {
let page = parse_page_arg(&cmdline.arguments());
ui::window::build_ui(app, page);
glib::ExitCode::SUCCESS
});
app.run();
}
/// Best-effort: like any non-command-line-aware launch of an already-running
/// GApplication, this is only seen by the launch that becomes primary — a
/// `--page` while bos-settings is already open just refocuses the existing
/// window on whatever page it was already showing.
fn parse_page_arg(args: &[std::ffi::OsString]) -> Option<String> {
let mut it = args.iter().skip(1);
while let Some(arg) = it.next() {
if arg == "--page" {
return it.next().and_then(|s| s.to_str()).map(str::to_string);
}
}
None
}

View file

@ -37,11 +37,13 @@ pub const SYSTEM_ITEMS: &[SidebarItem] = &[
item("sound", "Sound", "audio-volume-high-symbolic"),
item("power", "Power", "battery-good-symbolic"),
item("datetime", "Date & Time", "preferences-system-time-symbolic"),
item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"),
item_sub("hyprland", "Display", "monitors.json", "video-display-symbolic"),
item_sub("autostart", "Startup Apps", "autostart.json", "system-run-symbolic"),
item("users", "Users", "system-users-symbolic"),
];
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
item_sub("appearance", "Appearance", "settings.json", "applications-graphics-symbolic"),
item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"),
item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"),
item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"),

224
src/ui/views/appearance.rs Normal file
View file

@ -0,0 +1,224 @@
//! hypr/settings.json — Hyprland gaps/borders/blur/shadow/input, read by
//! `scripts/ui/settings.lua` on the Hyprland side (see hyprland.lua). Unlike
//! the TOML-backed views, this has no comments to preserve, so it's modeled
//! as a plain typed struct (round-tripped whole) rather than the
//! `toml_edit`/`Doc` path-based editor the other panels use.
//!
//! `Default` here must stay in sync with `scripts/ui/settings.lua`'s
//! `DEFAULTS` table on the Hyprland side — both independently define "what
//! BOS ships out of the box," the same duplication `binds.json`/
//! `content/keybinds.rs` already accept for the same reason (two different
//! languages/processes reading the same file, neither able to import the
//! other's defaults).
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
Adjustment, Box as GBox, ColorDialog, ColorDialogButton, DropDown, Entry, Expression, Orientation,
SpinButton, StringList, Switch,
};
use serde::{Deserialize, Serialize};
use crate::ui::widgets as w;
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
struct Appearance {
gaps_in: i64,
gaps_out: i64,
border_size: i64,
active_border: String,
inactive_border: String,
layout: String,
resize_on_border: bool,
rounding: i64,
blur_enabled: bool,
blur_size: i64,
blur_passes: i64,
shadow_enabled: bool,
shadow_range: i64,
shadow_render_power: i64,
kb_layout: String,
follow_mouse: i64,
natural_scroll: bool,
}
impl Default for Appearance {
fn default() -> Self {
Self {
gaps_in: 5,
gaps_out: 10,
border_size: 2,
active_border: "rgba(88c0d0ff)".to_string(),
inactive_border: "rgba(4c566aff)".to_string(),
layout: "dwindle".to_string(),
resize_on_border: true,
rounding: 8,
blur_enabled: true,
blur_size: 6,
blur_passes: 2,
shadow_enabled: true,
shadow_range: 12,
shadow_render_power: 3,
kb_layout: "us".to_string(),
follow_mouse: 1,
natural_scroll: true,
}
}
}
fn config_path() -> std::path::PathBuf {
crate::config::config_dir().join("hypr/settings.json")
}
/// A missing or malformed file yields defaults — same failsafe posture as
/// the Lua loader reading this same file on the Hyprland side.
fn load() -> Appearance {
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
fn save(a: &Appearance) -> std::io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(a).unwrap_or_default())
}
/// "rgba(RRGGBBAA)" (Hyprland's format) <-> gdk::RGBA, so the color fields
/// get a real color-picker button instead of a raw hex text field.
fn parse_hypr_color(s: &str) -> Option<gtk4::gdk::RGBA> {
let inner = s.strip_prefix("rgba(")?.strip_suffix(')')?;
if inner.len() != 8 {
return None;
}
let r = u8::from_str_radix(&inner[0..2], 16).ok()?;
let g = u8::from_str_radix(&inner[2..4], 16).ok()?;
let b = u8::from_str_radix(&inner[4..6], 16).ok()?;
let a = u8::from_str_radix(&inner[6..8], 16).ok()?;
Some(gtk4::gdk::RGBA::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0))
}
fn to_hypr_color(c: &gtk4::gdk::RGBA) -> String {
let clamp = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("rgba({:02x}{:02x}{:02x}{:02x})", clamp(c.red()), clamp(c.green()), clamp(c.blue()), clamp(c.alpha()))
}
fn color_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox {
let cur = parse_hypr_color(get(&model.borrow())).unwrap_or(gtk4::gdk::RGBA::new(0.5, 0.5, 0.5, 1.0));
let btn = ColorDialogButton::new(Some(ColorDialog::builder().with_alpha(true).build()));
btn.set_rgba(&cur);
let model = model.clone();
btn.connect_rgba_notify(move |b| {
set(&mut model.borrow_mut(), to_hypr_color(&b.rgba()));
});
w::row(label, &btn)
}
fn spin_row(label: &str, model: &Rc<RefCell<Appearance>>, min: f64, max: f64, get: fn(&Appearance) -> i64, set: fn(&mut Appearance, i64)) -> GBox {
let cur = get(&model.borrow());
let adj = Adjustment::new(cur as f64, min, max, 1.0, 1.0, 0.0);
let spin = SpinButton::new(Some(&adj), 1.0, 0);
let model = model.clone();
spin.connect_value_changed(move |s| set(&mut model.borrow_mut(), s.value() as i64));
w::row(label, &spin)
}
fn switch_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> bool, set: fn(&mut Appearance, bool)) -> GBox {
let sw = Switch::new();
sw.set_active(get(&model.borrow()));
let model = model.clone();
sw.connect_active_notify(move |s| set(&mut model.borrow_mut(), s.is_active()));
w::row(label, &sw)
}
fn entry_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> String, set: fn(&mut Appearance, String)) -> GBox {
let entry = Entry::new();
entry.set_text(&get(&model.borrow()));
entry.set_hexpand(true);
entry.set_width_chars(16);
let model = model.clone();
entry.connect_changed(move |e| set(&mut model.borrow_mut(), e.text().to_string()));
w::row(label, &entry)
}
fn dropdown_row(label: &str, model: &Rc<RefCell<Appearance>>, options: &[&str], get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox {
let cur = get(&model.borrow()).to_string();
let dd = DropDown::new(Some(StringList::new(options)), Expression::NONE);
dd.set_selected(options.iter().position(|o| *o == cur).unwrap_or(0) as u32);
let owned: Vec<String> = options.iter().map(|s| s.to_string()).collect();
let model = model.clone();
dd.connect_selected_notify(move |dd| {
if let Some(opt) = owned.get(dd.selected() as usize) {
set(&mut model.borrow_mut(), opt.clone());
}
});
w::row(label, &dd)
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Appearance");
content.append(&w::hint(
"Gaps, borders, blur, and input feel for Hyprland — the same \
settings.json the compositor itself reads at login.",
));
let model = Rc::new(RefCell::new(load()));
content.append(&w::section("Layout & borders"));
content.append(&spin_row("Gaps (inner)", &model, 0.0, 50.0, |a| a.gaps_in, |a, v| a.gaps_in = v));
content.append(&spin_row("Gaps (outer)", &model, 0.0, 50.0, |a| a.gaps_out, |a, v| a.gaps_out = v));
content.append(&spin_row("Border width", &model, 0.0, 10.0, |a| a.border_size, |a, v| a.border_size = v));
content.append(&color_row("Active border color", &model, |a| &a.active_border, |a, v| a.active_border = v));
content.append(&color_row("Inactive border color", &model, |a| &a.inactive_border, |a, v| a.inactive_border = v));
content.append(&dropdown_row("Tiling layout", &model, &["dwindle", "master"], |a| &a.layout, |a, v| a.layout = v));
content.append(&switch_row("Resize by dragging borders", &model, |a| a.resize_on_border, |a, v| a.resize_on_border = v));
content.append(&w::section("Effects"));
content.append(&spin_row("Corner rounding", &model, 0.0, 30.0, |a| a.rounding, |a, v| a.rounding = v));
content.append(&switch_row("Blur", &model, |a| a.blur_enabled, |a, v| a.blur_enabled = v));
content.append(&spin_row("Blur size", &model, 0.0, 20.0, |a| a.blur_size, |a, v| a.blur_size = v));
content.append(&spin_row("Blur passes", &model, 1.0, 5.0, |a| a.blur_passes, |a, v| a.blur_passes = v));
content.append(&switch_row("Window shadows", &model, |a| a.shadow_enabled, |a, v| a.shadow_enabled = v));
content.append(&spin_row("Shadow range", &model, 0.0, 40.0, |a| a.shadow_range, |a, v| a.shadow_range = v));
content.append(&spin_row("Shadow render power", &model, 1.0, 4.0, |a| a.shadow_render_power, |a, v| a.shadow_render_power = v));
content.append(&w::section("Input"));
content.append(&entry_row("Keyboard layout", &model, |a| a.kb_layout.clone(), |a, v| a.kb_layout = v));
let follow_mouse_row = spin_row("Focus-follows-mouse mode", &model, 0.0, 3.0, |a| a.follow_mouse, |a, v| a.follow_mouse = v);
content.append(&follow_mouse_row);
content.append(&w::hint("0-3 — see the Hyprland wiki's follow_mouse setting for exact behavior of each value."));
content.append(&switch_row("Natural scrolling (touchpad)", &model, |a| a.natural_scroll, |a, v| a.natural_scroll = v));
content.append(&w::hint("Applies on next login/Hyprland reload — this saves settings.json, it doesn't reload Hyprland live."));
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(16);
let save_btn = gtk4::Button::with_label("Save");
save_btn.add_css_class("suggested-action");
let status = gtk4::Label::new(None);
status.add_css_class("dim-label");
{
let model = model.clone();
let status = status.clone();
save_btn.connect_clicked(move |_| match save(&model.borrow()) {
Ok(()) => {
status.set_text("Saved");
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status.set_text(&format!("Error: {e}")),
});
}
btn_row.append(&save_btn);
btn_row.append(&status);
outer.append(&btn_row);
outer
}

201
src/ui/views/autostart.rs Normal file
View file

@ -0,0 +1,201 @@
//! hypr/autostart.json — the *extra*, user-toggleable autostart apps
//! (breadbar, hypridle, bos-netcheck, breadhelp, plus anything a user adds).
//! The core bootstrap sequence (theme generation, dark-mode gsettings,
//! polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd)
//! stays hardcoded in hyprland.lua on purpose — it's timing/order-sensitive
//! infrastructure, not something this panel exposes.
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch};
use serde::{Deserialize, Serialize};
use crate::ui::widgets as w;
#[derive(Clone, Serialize, Deserialize)]
struct Entry_ {
command: String,
#[serde(default)]
label: String,
#[serde(default = "default_true")]
enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Serialize, Deserialize)]
struct AutostartFile {
#[serde(default)]
extra: Vec<Entry_>,
}
fn default_extra() -> Vec<Entry_> {
vec![
Entry_ { command: "breadbar".to_string(), label: "Bar (breadbar)".to_string(), enabled: true },
Entry_ { command: "hypridle".to_string(), label: "Idle / lock daemon (hypridle)".to_string(), enabled: true },
Entry_ { command: "bos-netcheck".to_string(), label: "Network connectivity check".to_string(), enabled: true },
Entry_ { command: "breadhelp --autostart".to_string(), label: "BOS Help (first-run onboarding)".to_string(), enabled: true },
]
}
fn config_path() -> std::path::PathBuf {
crate::config::config_dir().join("hypr/autostart.json")
}
fn load() -> Vec<Entry_> {
std::fs::read_to_string(config_path())
.ok()
.and_then(|s| serde_json::from_str::<AutostartFile>(&s).ok())
.map(|f| f.extra)
.unwrap_or_else(default_extra)
}
fn save(entries: &[Entry_]) -> std::io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = AutostartFile { extra: entries.to_vec() };
std::fs::write(path, serde_json::to_string_pretty(&file).unwrap_or_default())
}
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<Entry_>>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
for (i, entry) in model.borrow().iter().enumerate() {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 8);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let enabled = Switch::new();
enabled.set_active(entry.enabled);
enabled.set_valign(gtk4::Align::Center);
let label = Entry::new();
label.set_text(&entry.label);
label.set_placeholder_text(Some("Label"));
label.set_width_chars(20);
let command = Entry::new();
command.set_text(&entry.command);
command.set_placeholder_text(Some("command"));
command.set_hexpand(true);
let remove = Button::with_label("Remove");
remove.add_css_class("destructive-action");
{
let model = model.clone();
enabled.connect_active_notify(move |s| {
if let Some(e) = model.borrow_mut().get_mut(i) {
e.enabled = s.is_active();
}
});
}
{
let model = model.clone();
label.connect_changed(move |e| {
if let Some(entry) = model.borrow_mut().get_mut(i) {
entry.label = e.text().to_string();
}
});
}
{
let model = model.clone();
command.connect_changed(move |e| {
if let Some(entry) = model.borrow_mut().get_mut(i) {
entry.command = e.text().to_string();
}
});
}
{
let model = model.clone();
let list = list.clone();
remove.connect_clicked(move |_| {
model.borrow_mut().remove(i);
rebuild(&list, &model);
});
}
hbox.append(&enabled);
hbox.append(&label);
hbox.append(&command);
hbox.append(&remove);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Startup Apps");
content.append(&w::hint(
"What launches after login, beyond the core desktop (bar, theme, \
clipboard, etc. always start regardless). Toggle off, edit, or add \
your own each row is one command run at login.",
));
let model = Rc::new(RefCell::new(load()));
content.append(&w::section("Extra autostart apps"));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
list.add_css_class("boxed-list");
rebuild(&list, &model);
content.append(&list);
let add_btn = Button::with_label("Add app");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(6);
{
let model = model.clone();
let list = list.clone();
add_btn.connect_clicked(move |_| {
model.borrow_mut().push(Entry_ { command: String::new(), label: String::new(), enabled: true });
rebuild(&list, &model);
});
}
content.append(&add_btn);
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(16);
let save_btn = Button::with_label("Save");
save_btn.add_css_class("suggested-action");
let status = Label::new(None);
status.add_css_class("dim-label");
{
let model = model.clone();
let status = status.clone();
save_btn.connect_clicked(move |_| {
// Empty command rows (still-being-typed "Add app" entries) are
// dropped on save rather than written as a broken autostart.json
// entry the Lua loader would otherwise have to reject.
let entries: Vec<Entry_> = model.borrow().iter().filter(|e| !e.command.trim().is_empty()).cloned().collect();
match save(&entries) {
Ok(()) => {
status.set_text("Saved");
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status.set_text(&format!("Error: {e}")),
}
});
}
btn_row.append(&save_btn);
btn_row.append(&status);
outer.append(&btn_row);
outer
}

View file

@ -1,11 +1,78 @@
//! Display: live-connected-monitor readout (from `hyprctl monitors -j`) plus
//! a real editor for `hypr/monitors.json` — the monitor *layout* Hyprland
//! itself reads at login via `scripts/display/monitors.lua`. Like
//! appearance.rs/autostart.rs, this is a plain typed struct round-tripped
//! whole (JSON has no comments to preserve), not the TOML `Doc` pattern.
//!
//! `Default` here (the single wildcard rule) must stay in sync with
//! `scripts/display/monitors.lua`'s own `DEFAULT_MONITORS` fallback.
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button};
use std::process::Command;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation};
use serde::{Deserialize, Serialize};
use crate::ui::widgets as w;
fn get_monitors() -> Vec<(String, String)> {
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
#[derive(Clone, Serialize, Deserialize)]
struct MonitorRule {
output: String,
#[serde(default = "default_mode")]
mode: String,
#[serde(default = "default_position")]
position: String,
#[serde(default = "default_scale")]
scale: String,
}
fn default_mode() -> String {
"preferred".to_string()
}
fn default_position() -> String {
"auto".to_string()
}
fn default_scale() -> String {
"auto".to_string()
}
impl Default for MonitorRule {
fn default() -> Self {
Self { output: String::new(), mode: default_mode(), position: default_position(), scale: default_scale() }
}
}
#[derive(Serialize, Deserialize)]
struct MonitorsFile {
#[serde(default)]
monitors: Vec<MonitorRule>,
}
fn config_path() -> std::path::PathBuf {
crate::config::config_dir().join("hypr/monitors.json")
}
fn load() -> Vec<MonitorRule> {
std::fs::read_to_string(config_path())
.ok()
.and_then(|s| serde_json::from_str::<MonitorsFile>(&s).ok())
.filter(|f| !f.monitors.is_empty())
.map(|f| f.monitors)
.unwrap_or_else(|| vec![MonitorRule::default()])
}
fn save(rules: &[MonitorRule]) -> std::io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = MonitorsFile { monitors: rules.to_vec() };
std::fs::write(path, serde_json::to_string_pretty(&file).unwrap_or_default())
}
fn get_live_monitors() -> Vec<(String, String)> {
let Ok(output) = std::process::Command::new("hyprctl").args(["monitors", "-j"]).output() else {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
@ -34,16 +101,111 @@ fn hypr_path(name: &str) -> std::path::PathBuf {
/// wrapper. Uses kitty, which is what BOS actually ships (not foot).
fn open_in_terminal(path: &std::path::Path) {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
if let Ok(mut child) = std::process::Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
std::thread::spawn(move || { let _ = child.wait(); });
}
}
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<MonitorRule>>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
for (i, rule) in model.borrow().iter().enumerate() {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 8);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let output = Entry::new();
output.set_text(&rule.output);
output.set_placeholder_text(Some("any (blank = all)"));
output.set_width_chars(12);
let mode = Entry::new();
mode.set_text(&rule.mode);
mode.set_placeholder_text(Some("preferred / 1920x1080@60"));
mode.set_width_chars(18);
let position = Entry::new();
position.set_text(&rule.position);
position.set_placeholder_text(Some("auto / 0x0"));
position.set_width_chars(10);
let scale = Entry::new();
scale.set_text(&rule.scale);
scale.set_placeholder_text(Some("auto / 1"));
scale.set_width_chars(8);
scale.set_hexpand(true);
let remove = Button::with_label("Remove");
remove.add_css_class("destructive-action");
{
let model = model.clone();
output.connect_changed(move |e| {
if let Some(r) = model.borrow_mut().get_mut(i) {
r.output = e.text().to_string();
}
});
}
{
let model = model.clone();
mode.connect_changed(move |e| {
if let Some(r) = model.borrow_mut().get_mut(i) {
r.mode = e.text().to_string();
}
});
}
{
let model = model.clone();
position.connect_changed(move |e| {
if let Some(r) = model.borrow_mut().get_mut(i) {
r.position = e.text().to_string();
}
});
}
{
let model = model.clone();
scale.connect_changed(move |e| {
if let Some(r) = model.borrow_mut().get_mut(i) {
r.scale = e.text().to_string();
}
});
}
{
let model = model.clone();
let list = list.clone();
remove.connect_clicked(move |_| {
model.borrow_mut().remove(i);
if model.borrow().is_empty() {
model.borrow_mut().push(MonitorRule::default());
}
rebuild(&list, &model);
});
}
hbox.append(&Label::new(Some("Output")));
hbox.append(&output);
hbox.append(&Label::new(Some("Mode")));
hbox.append(&mode);
hbox.append(&Label::new(Some("Position")));
hbox.append(&position);
hbox.append(&Label::new(Some("Scale")));
hbox.append(&scale);
hbox.append(&remove);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Display");
content.append(&w::section("Connected monitors"));
let monitors = get_monitors();
let monitors = get_live_monitors();
if monitors.is_empty() {
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
} else {
@ -52,16 +214,58 @@ pub fn build() -> GBox {
}
}
content.append(&w::section("Configuration"));
content.append(&w::section("Layout"));
content.append(&w::hint(
"Monitor layout, keyboard/input, and workspace rules are configured \
directly in hyprland.lua there's no live editor for them here yet.",
"One row per monitor rule. Leave Output blank to match any monitor \
(the default works on any hardware). Applies on next login/reload.",
));
// BOS's Hyprland config is Lua-native (hyprland.lua), not the classic
// hyprland.conf/keybinds.conf pair — those names only ever matched a
// stale, unshipped dotfiles/ directory, so this button opened (or
// silently created) the wrong file entirely.
let model = Rc::new(RefCell::new(load()));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
list.add_css_class("boxed-list");
rebuild(&list, &model);
content.append(&list);
let add_btn = Button::with_label("Add monitor rule");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(6);
{
let model = model.clone();
let list = list.clone();
add_btn.connect_clicked(move |_| {
model.borrow_mut().push(MonitorRule::default());
rebuild(&list, &model);
});
}
content.append(&add_btn);
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(16);
let save_btn = Button::with_label("Save");
save_btn.add_css_class("suggested-action");
let status = Label::new(None);
status.add_css_class("dim-label");
{
let model = model.clone();
let status = status.clone();
save_btn.connect_clicked(move |_| match save(&model.borrow()) {
Ok(()) => {
status.set_text("Saved");
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status.set_text(&format!("Error: {e}")),
});
}
btn_row.append(&save_btn);
btn_row.append(&status);
outer.append(&btn_row);
content.append(&w::section("Advanced"));
let open_btn = Button::with_label("Open hyprland.lua in editor");
open_btn.set_halign(gtk4::Align::Start);
{
@ -70,15 +274,14 @@ pub fn build() -> GBox {
}
content.append(&open_btn);
// Keybinds are defined inline in hyprland.lua (no separate file); point
// this at the shipped cheat sheet instead of a keybinds.conf that has
// never existed on BOS.
let keybinds_btn = Button::with_label("View keybinds cheat sheet");
// breadhelp is the real keybind viewer now (SUPER+/) — /usr/share/bos/
// keybinds.txt was deleted when breadhelp replaced bos-keybinds/
// bos-welcome, so this used to open a file that no longer exists.
let keybinds_btn = Button::with_label("View keybinds (breadhelp)");
keybinds_btn.set_halign(gtk4::Align::Start);
{
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
}
keybinds_btn.connect_clicked(move |_| {
let _ = std::process::Command::new("breadhelp").spawn();
});
content.append(&keybinds_btn);
outer

View file

@ -1,5 +1,7 @@
pub mod about;
pub mod appearance;
pub mod aur;
pub mod autostart;
pub mod bread;
pub mod breadbar;
pub mod breadbox;

View file

@ -10,7 +10,9 @@ use super::views;
// initial selection, so the two can't independently drift out of sync.
const DEFAULT_PAGE: &str = "about";
pub fn build_ui(app: &Application) {
/// `requested_page` comes from `--page <id>` (e.g. breadhelp's "Learn more"
/// deep links) and overrides `DEFAULT_PAGE` when it names a real page.
pub fn build_ui(app: &Application, requested_page: Option<String>) {
let window = ApplicationWindow::builder()
.application(app)
.title("BOS Settings")
@ -25,7 +27,8 @@ pub fn build_ui(app: &Application) {
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let (sidebar_box, list) = sidebar::build(DEFAULT_PAGE);
let initial_page = requested_page.as_deref().unwrap_or(DEFAULT_PAGE);
let (sidebar_box, list) = sidebar::build(initial_page);
let stack = Stack::new();
stack.set_hexpand(true);
@ -51,8 +54,10 @@ pub fn build_ui(app: &Application) {
stack.add_named(&views::breadpaper::build(), Some("breadpaper"));
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
stack.add_named(&views::hyprland::build(), Some("hyprland"));
stack.add_named(&views::appearance::build(), Some("appearance"));
stack.add_named(&views::autostart::build(), Some("autostart"));
stack.set_visible_child_name(DEFAULT_PAGE);
stack.set_visible_child_name(initial_page);
{
let stack = stack.clone();