diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..0fee1b9 --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -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/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..4ae3545 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45cd856 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2507d69 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e837178 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c0f179 --- /dev/null +++ b/README.md @@ -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. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 6747f88..3079663 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -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" } diff --git a/src/main.rs b/src/main.rs index a5e73fd..1c28518 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 ` 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 { + 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 +} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 5b2ef8e..7a04229 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -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"), diff --git a/src/ui/views/appearance.rs b/src/ui/views/appearance.rs new file mode 100644 index 0000000..6f9092f --- /dev/null +++ b/src/ui/views/appearance.rs @@ -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 { + 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: >k4::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>, 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>, 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>, 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>, 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>, 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 = 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 +} diff --git a/src/ui/views/autostart.rs b/src/ui/views/autostart.rs new file mode 100644 index 0000000..ce58ae3 --- /dev/null +++ b/src/ui/views/autostart.rs @@ -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, +} + +fn default_extra() -> Vec { + 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 { + std::fs::read_to_string(config_path()) + .ok() + .and_then(|s| serde_json::from_str::(&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>>) { + 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 = 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 +} diff --git a/src/ui/views/hyprland.rs b/src/ui/views/hyprland.rs index 72ef215..c37e19c 100644 --- a/src/ui/views/hyprland.rs +++ b/src/ui/views/hyprland.rs @@ -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, +} + +fn config_path() -> std::path::PathBuf { + crate::config::config_dir().join("hypr/monitors.json") +} + +fn load() -> Vec { + std::fs::read_to_string(config_path()) + .ok() + .and_then(|s| serde_json::from_str::(&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>>) { + 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 diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs index 8a6dafb..ce086cc 100644 --- a/src/ui/views/mod.rs +++ b/src/ui/views/mod.rs @@ -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; diff --git a/src/ui/window.rs b/src/ui/window.rs index 393ee4f..fd53fcd 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -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 ` (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) { 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();