Compare commits
5 commits
515101b39a
...
0f1cf85114
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f1cf85114 | ||
|
|
5d23a586fa | ||
|
|
041d927b00 | ||
|
|
f3a5839cf1 | ||
|
|
da6af6abf9 |
9 changed files with 547 additions and 148 deletions
53
.forgejo/workflows/release.yml
Normal file
53
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && cargo build --release --locked
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
|
||||
strip "${PKG_DIR}/bos-settings-x86_64"
|
||||
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bos-settings-x86_64.sha256"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/bos-settings/latest"
|
||||
|
||||
- name: regenerate index.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf /tmp/bread-ecosystem-ci
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
|
||||
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bos-settings \
|
||||
--title "bos-settings v${VERSION}" --generate-notes 2>/dev/null || true
|
||||
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bos-settings \
|
||||
"${PKG_DIR}/bos-settings-x86_64" \
|
||||
"${PKG_DIR}/bos-settings-x86_64.sha256" \
|
||||
--clobber
|
||||
86
Cargo.lock
generated
86
Cargo.lock
generated
|
|
@ -22,9 +22,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bos-settings"
|
||||
|
|
@ -32,6 +32,7 @@ version = "0.6.3"
|
|||
dependencies = [
|
||||
"async-channel",
|
||||
"bread-theme",
|
||||
"bread-utils",
|
||||
"glib",
|
||||
"gtk4",
|
||||
"serde",
|
||||
|
|
@ -51,6 +52,17 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.22.0"
|
||||
|
|
@ -101,9 +113,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
|
|
@ -165,24 +177,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
|
|
@ -191,15 +203,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -208,15 +220,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
|
|
@ -526,9 +538,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
|
|
@ -592,7 +604,7 @@ 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",
|
||||
"toml_edit 0.25.13+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -714,9 +726,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -732,7 +744,7 @@ dependencies = [
|
|||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
|
|
@ -776,9 +788,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
|
|
@ -786,7 +798,7 @@ dependencies = [
|
|||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.3",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -823,14 +835,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.3",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -839,7 +851,7 @@ 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",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -850,9 +862,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
|||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
|
|
@ -964,15 +976,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
|
|
|||
|
|
@ -17,3 +17,5 @@ toml = "0.8"
|
|||
# drops the rest of the user's config file.
|
||||
toml_edit = "0.22"
|
||||
async-channel = "2"
|
||||
# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["toml"] }
|
||||
|
|
|
|||
|
|
@ -21,43 +21,30 @@ use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
|
|||
/// breadcrumbs' saved network passwords, ...). Back up the unparseable file
|
||||
/// once before falling back, so a bad edit is always recoverable.
|
||||
pub fn load_doc(path: &Path) -> DocumentMut {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return DocumentMut::default();
|
||||
};
|
||||
match text.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
||||
eprintln!(
|
||||
"bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults",
|
||||
path.display(),
|
||||
backup.display()
|
||||
);
|
||||
let _ = std::fs::write(&backup, &text);
|
||||
DocumentMut::default()
|
||||
}
|
||||
}
|
||||
bread_utils::tomlcfg::load_doc("bos-settings", path)
|
||||
}
|
||||
|
||||
/// Write the document back to disk, creating parent dirs as needed.
|
||||
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, doc.to_string())?;
|
||||
bread_utils::tomlcfg::save_doc(path, doc)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `contents` to `path` atomically, backing up whatever was there
|
||||
/// before overwriting it.
|
||||
///
|
||||
/// Every config-writing view in this app (TOML via `save_doc` above, and the
|
||||
/// plain-JSON views — keybinds, autostart, appearance/settings.json,
|
||||
/// monitors.json, breadbar's CSS) goes through this instead of a bare
|
||||
/// `std::fs::write` — see `bread_utils::atomic::write_atomic_backed_up`'s
|
||||
/// doc comment for why (crash/power-loss safety via temp-then-rename, plus
|
||||
/// a `.bak` of whatever was there before).
|
||||
pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
bread_utils::atomic::write_atomic_backed_up(path, contents)
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
// Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config.
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
let p = PathBuf::from(xdg);
|
||||
if p.is_absolute() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
PathBuf::from(home).join(".config")
|
||||
bread_utils::xdg::config_home()
|
||||
}
|
||||
|
||||
// --- typed readers (walk a dotted path, return None if absent/wrong type) ---
|
||||
|
|
@ -210,4 +197,30 @@ password = \"secret\" # keep me
|
|||
set_str_list(&mut doc, &["modules", "disable"], &items);
|
||||
assert_eq!(get_str_list(&doc, &["modules", "disable"]), items);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() {
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-atomic-write-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
let backup = dir.join("config.toml.bak");
|
||||
|
||||
atomic_write(&path, "first").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
|
||||
assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet");
|
||||
|
||||
atomic_write(&path, "second").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "first");
|
||||
|
||||
let leftover_tmp: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.contains(".tmp."))
|
||||
.collect();
|
||||
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,10 +81,7 @@ fn load() -> Appearance {
|
|||
|
||||
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())
|
||||
crate::config::atomic_write(&path, &serde_json::to_string_pretty(a).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// "rgba(RRGGBBAA)" (Hyprland's format) <-> gdk::RGBA, so the color fields
|
||||
|
|
|
|||
|
|
@ -56,11 +56,8 @@ fn load() -> Vec<Entry_> {
|
|||
|
||||
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())
|
||||
crate::config::atomic_write(&path, &serde_json::to_string_pretty(&file).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<Entry_>>>) {
|
||||
|
|
|
|||
|
|
@ -57,10 +57,7 @@ pub fn build() -> GBox {
|
|||
save_btn.connect_clicked(move |_| {
|
||||
let (start, end) = buf.bounds();
|
||||
let text = buf.text(&start, &end, false);
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match std::fs::write(&path, text.as_str()) {
|
||||
match crate::config::atomic_write(&path, text.as_str()) {
|
||||
Ok(()) => {
|
||||
// breadbar has no systemd unit (it's launched directly by
|
||||
// hyprland.lua's exec-once) — SIGHUP is its own documented
|
||||
|
|
|
|||
|
|
@ -64,19 +64,18 @@ fn load() -> Vec<MonitorRule> {
|
|||
|
||||
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())
|
||||
crate::config::atomic_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 {
|
||||
// Was a bare Command::new("hyprctl").output() with no timeout.
|
||||
let Some(value) =
|
||||
bread_utils::proc::run_json("hyprctl", &["monitors", "-j"], std::time::Duration::from_secs(3))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let Ok(monitors) = serde_json::from_str::<Vec<serde_json::Value>>(&text) else {
|
||||
let Ok(monitors) = serde_json::from_value::<Vec<serde_json::Value>>(value) else {
|
||||
return Vec::new();
|
||||
};
|
||||
monitors
|
||||
|
|
|
|||
|
|
@ -1,20 +1,41 @@
|
|||
//! hypr/binds.json — Hyprland keybind editor, read by
|
||||
//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua). The
|
||||
//! schema has four kinds of bind lists (`globals`, `common`, and one per
|
||||
//! keyboard `layouts` entry) and each bind's shape varies by `action`
|
||||
//! (`exec` needs `command`, `move_dir` needs `direction`, workspace-focus
|
||||
//! needs `workspace`, mouse binds need `options.mouse`, ...). Rather than
|
||||
//! modelling every action's field set as its own row layout — which would
|
||||
//! mean a combinatorial explosion of widgets and silently dropping any
|
||||
//! action shape this editor doesn't already know about — `action`/`key`/
|
||||
//! `mods` get real fields (the ones every bind has) and everything else
|
||||
//! round-trips through `#[serde(flatten)]` into a small inline-JSON column,
|
||||
//! same trade-off the other Hyprland JSON editors (appearance.rs,
|
||||
//! hyprland.rs, autostart.rs) already make: no comments to preserve, so this
|
||||
//! is a whole-file round trip, not the `toml_edit`/`Doc` path-based pattern.
|
||||
//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua).
|
||||
//!
|
||||
//! This file has TWO real on-disk shapes, and which one applies depends on
|
||||
//! the machine:
|
||||
//!
|
||||
//! - **Flat** (`default_mods` + a single `bindings` array) — what BOS itself
|
||||
//! ships (`iso/airootfs/etc/skel/.config/hypr/binds.json`, read by the
|
||||
//! BOS-shipped `scripts/input/binds.lua`). No layouts. Each bind carries
|
||||
//! `label`/`category`/`demo_cmd` fields breadhelp depends on for its
|
||||
//! cheatsheet and guided tour.
|
||||
//! - **MultiLayout** (`globals`/`common`/one `layouts` entry per keyboard
|
||||
//! layout) — a personal, per-machine schema some dev setups use instead,
|
||||
//! read by a different, personal `binds.lua`.
|
||||
//!
|
||||
//! This editor was originally built only against the MultiLayout shape.
|
||||
//! Loading a real BOS (Flat) file into that model, then saving, silently
|
||||
//! dropped the `bindings` key entirely — still valid JSON, so the Lua
|
||||
//! `pcall` failsafes on the reading side never caught it. `SchemaKind`
|
||||
//! detects which shape is actually on disk (from the top-level key set) and
|
||||
//! `save()` always emits that SAME shape back — see `SchemaKind::detect`
|
||||
//! and `save_to`.
|
||||
//!
|
||||
//! Each bind's shape also varies by `action` (`exec` needs `command`,
|
||||
//! `move_dir` needs `direction`, workspace-focus needs `workspace`, mouse
|
||||
//! binds need `options.mouse`, ...). Rather than modelling every action's
|
||||
//! field set as its own row layout — which would mean a combinatorial
|
||||
//! explosion of widgets and silently dropping any action shape this editor
|
||||
//! doesn't already know about — `action`/`key`/`mods` get real fields (the
|
||||
//! ones every bind has) and everything else round-trips through
|
||||
//! `#[serde(flatten)]` into a small inline-JSON column, same trade-off the
|
||||
//! other Hyprland JSON editors (appearance.rs, hyprland.rs, autostart.rs)
|
||||
//! already make: no comments to preserve, so this is a whole-file round
|
||||
//! trip, not the `toml_edit`/`Doc` path-based pattern.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
|
|
@ -27,17 +48,65 @@ use serde_json::{Map, Value};
|
|||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
/// Which on-disk shape `binds.json` was loaded as. Detected once at load
|
||||
/// time from the top-level key set present in the JSON, then pinned for the
|
||||
/// lifetime of the editor session so `save()` always writes back the same
|
||||
/// shape it read, regardless of what the in-memory model happens to have
|
||||
/// populated.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum SchemaKind {
|
||||
/// `{ "default_mods": [...], "bindings": [...] }` — BOS's real shipped
|
||||
/// shape. No layout-switching UI applies; there's nothing to switch.
|
||||
Flat,
|
||||
/// `{ "active_layout", "default_mods", "globals", "common", "layouts" }`
|
||||
/// — the personal, multi-keyboard-layout schema this editor was
|
||||
/// originally built against.
|
||||
MultiLayout,
|
||||
/// Neither key set matched — an empty file, a totally different shape,
|
||||
/// or unparsable JSON. Loading still renders (empty), but `save()`
|
||||
/// refuses outright rather than guessing a shape and risking silently
|
||||
/// destroying whatever the real file's actual schema was.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SchemaKind {
|
||||
fn detect(top_level: &Map<String, Value>) -> Self {
|
||||
if top_level.contains_key("bindings") {
|
||||
SchemaKind::Flat
|
||||
} else if top_level.contains_key("globals")
|
||||
|| top_level.contains_key("common")
|
||||
|| top_level.contains_key("layouts")
|
||||
{
|
||||
SchemaKind::MultiLayout
|
||||
} else {
|
||||
SchemaKind::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct Bind {
|
||||
action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
mods: Vec<String>,
|
||||
/// `None` (key omitted) means "fall back to `default_mods`"; `Some(_)`
|
||||
/// — including `Some(vec![])` — means "use exactly this, even if that's
|
||||
/// no modifiers at all." Real BOS binds rely on that distinction (e.g.
|
||||
/// media keys pin `"mods": []` on purpose so they never inherit
|
||||
/// `default_mods`), so this can't collapse both cases to "omit the
|
||||
/// key" the way a bare `Vec<String>` with `skip_serializing_if` would —
|
||||
/// that would silently turn an explicit "no mods" into "use the
|
||||
/// default" the next time this editor saves the file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mods: Option<Vec<String>>,
|
||||
/// Everything else a bind can carry — `command`, `direction`,
|
||||
/// `workspace`, `x`, `y`, `layout`, `options`, and any action shape not
|
||||
/// yet invented. Edited as compact inline JSON (see `extra_field`).
|
||||
/// `workspace`, `x`, `y`, `layout`, `options`, `label`, `category`,
|
||||
/// `demo_cmd`, and any action shape not yet invented. Edited as compact
|
||||
/// inline JSON (see `extra_field`). This flatten is what keeps
|
||||
/// breadhelp's `label`/`category`/`demo_cmd` fields — which this
|
||||
/// editor's UI has no dedicated widgets for — alive across a full
|
||||
/// load/save round trip instead of being silently dropped.
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
|
@ -45,6 +114,7 @@ struct Bind {
|
|||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct BindsFile {
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
active_layout: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
default_mods: Vec<String>,
|
||||
|
|
@ -57,22 +127,83 @@ struct BindsFile {
|
|||
/// the rest of this file's JSON-config siblings.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
layouts: BTreeMap<String, Vec<Bind>>,
|
||||
/// Flat-schema bind list — BOS's real shipped shape. Only ever populated
|
||||
/// when `SchemaKind::Flat` was detected at load time; stays empty (and
|
||||
/// so omitted, see `to_json`) for a MultiLayout file.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
bindings: Vec<Bind>,
|
||||
}
|
||||
|
||||
/// The editor's full in-memory state: which shape was loaded, plus the data
|
||||
/// itself. Kept together so a stray code path can't accidentally serialize
|
||||
/// `file` without knowing which shape it's supposed to come back out as.
|
||||
struct Model {
|
||||
kind: SchemaKind,
|
||||
file: BindsFile,
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/binds.json")
|
||||
}
|
||||
|
||||
fn load() -> BindsFile {
|
||||
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
|
||||
fn load_from(path: &Path) -> (BindsFile, SchemaKind) {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
// No file yet (fresh install/environment) — nothing on disk to
|
||||
// misdetect or destroy. BOS itself ships the flat schema, so a new
|
||||
// file defaults to Flat rather than the personal MultiLayout schema
|
||||
// this editor originally assumed.
|
||||
return (BindsFile::default(), SchemaKind::Flat);
|
||||
};
|
||||
let kind = match serde_json::from_str::<Value>(&text) {
|
||||
Ok(Value::Object(top_level)) => SchemaKind::detect(&top_level),
|
||||
// Unparsable JSON, or valid JSON that isn't even an object — treat
|
||||
// as Unknown so save() refuses rather than silently overwriting
|
||||
// whatever this file actually was with an empty default.
|
||||
_ => SchemaKind::Unknown,
|
||||
};
|
||||
let file: BindsFile = serde_json::from_str(&text).unwrap_or_default();
|
||||
(file, kind)
|
||||
}
|
||||
|
||||
fn save(f: &BindsFile) -> std::io::Result<()> {
|
||||
let path = config_path();
|
||||
fn load() -> (BindsFile, SchemaKind) {
|
||||
load_from(&config_path())
|
||||
}
|
||||
|
||||
/// Serialize `f` in exactly the shape `kind` implies:
|
||||
/// - `Flat` -> `{ "default_mods": [...], "bindings": [...] }`, nothing else
|
||||
/// — no `active_layout`/`globals`/`common`/`layouts` keys, even if the
|
||||
/// struct happens to carry empty values for them.
|
||||
/// - `MultiLayout` -> today's existing shape (whatever fields are
|
||||
/// non-empty), via `BindsFile`'s own `Serialize` impl.
|
||||
fn to_json(f: &BindsFile, kind: SchemaKind) -> Value {
|
||||
match kind {
|
||||
SchemaKind::Flat => serde_json::json!({
|
||||
"default_mods": f.default_mods,
|
||||
"bindings": f.bindings,
|
||||
}),
|
||||
SchemaKind::MultiLayout => serde_json::to_value(f).unwrap_or(Value::Null),
|
||||
SchemaKind::Unknown => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_to(path: &Path, f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
|
||||
if kind == SchemaKind::Unknown {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
|
||||
\"globals\"/\"common\"/\"layouts\") — refusing to save so nothing gets silently \
|
||||
overwritten. Fix or remove the file, then reopen this panel.",
|
||||
));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(f).unwrap_or_default())
|
||||
let text = serde_json::to_string_pretty(&to_json(f, kind)).unwrap_or_default();
|
||||
crate::config::atomic_write(path, &text)
|
||||
}
|
||||
|
||||
fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
|
||||
save_to(&config_path(), f, kind)
|
||||
}
|
||||
|
||||
fn mods_to_text(mods: &[String]) -> String {
|
||||
|
|
@ -84,22 +215,26 @@ fn text_to_mods(s: &str) -> Vec<String> {
|
|||
}
|
||||
|
||||
/// A `Vec<Bind>` accessor that always finds the right list regardless of
|
||||
/// whether it's `globals`, `common`, or a named entry in `layouts` — lets one
|
||||
/// row-builder work for every section instead of three near-duplicates.
|
||||
type SectionAccessor = Rc<dyn Fn(&mut BindsFile) -> &mut Vec<Bind>>;
|
||||
/// whether it's `globals`, `common`, a named entry in `layouts`, or the flat
|
||||
/// `bindings` list — lets one row-builder work for every section instead of
|
||||
/// near-duplicates per schema.
|
||||
type SectionAccessor = Rc<dyn Fn(&mut Model) -> &mut Vec<Bind>>;
|
||||
|
||||
fn globals_accessor() -> SectionAccessor {
|
||||
Rc::new(|f| &mut f.globals)
|
||||
Rc::new(|m| &mut m.file.globals)
|
||||
}
|
||||
fn common_accessor() -> SectionAccessor {
|
||||
Rc::new(|f| &mut f.common)
|
||||
Rc::new(|m| &mut m.file.common)
|
||||
}
|
||||
fn layout_accessor(name: String) -> SectionAccessor {
|
||||
Rc::new(move |f| f.layouts.entry(name.clone()).or_default())
|
||||
Rc::new(move |m| m.file.layouts.entry(name.clone()).or_default())
|
||||
}
|
||||
fn bindings_accessor() -> SectionAccessor {
|
||||
Rc::new(|m| &mut m.file.bindings)
|
||||
}
|
||||
|
||||
fn bind_row(
|
||||
model: &Rc<RefCell<BindsFile>>,
|
||||
model: &Rc<RefCell<Model>>,
|
||||
accessor: &SectionAccessor,
|
||||
idx: usize,
|
||||
rerender: &Rc<dyn Fn()>,
|
||||
|
|
@ -116,7 +251,7 @@ fn bind_row(
|
|||
let mut m = model.borrow_mut();
|
||||
let bind = &accessor(&mut m)[idx];
|
||||
(
|
||||
mods_to_text(&bind.mods),
|
||||
mods_to_text(bind.mods.as_deref().unwrap_or(&[])),
|
||||
bind.key.clone().unwrap_or_default(),
|
||||
bind.action.clone(),
|
||||
if bind.extra.is_empty() { String::new() } else { serde_json::to_string(&bind.extra).unwrap_or_default() },
|
||||
|
|
@ -152,7 +287,11 @@ fn bind_row(
|
|||
mods.connect_changed(move |e| {
|
||||
let mut m = model.borrow_mut();
|
||||
if let Some(b) = accessor(&mut m).get_mut(idx) {
|
||||
b.mods = text_to_mods(&e.text());
|
||||
// Explicitly setting this field (even to an empty string,
|
||||
// which `text_to_mods` turns into `vec![]`) always records
|
||||
// `Some(_)` — "use exactly these mods" — never falls back
|
||||
// to inferring "key omitted" from an empty result.
|
||||
b.mods = Some(text_to_mods(&e.text()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -219,10 +358,10 @@ fn bind_row(
|
|||
}
|
||||
|
||||
/// A section = a title, an "Add bind" button, and the section's bind rows —
|
||||
/// shared by Globals, Common, and every named layout.
|
||||
/// shared by Globals, Common, every named layout, and the flat Bindings list.
|
||||
fn section(
|
||||
title: Option<&str>,
|
||||
model: &Rc<RefCell<BindsFile>>,
|
||||
model: &Rc<RefCell<Model>>,
|
||||
accessor: SectionAccessor,
|
||||
rerender: &Rc<dyn Fn()>,
|
||||
) -> GBox {
|
||||
|
|
@ -256,21 +395,51 @@ fn section(
|
|||
wrapper
|
||||
}
|
||||
|
||||
fn rerender(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
||||
fn rerender(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label) {
|
||||
while let Some(child) = content.first_child() {
|
||||
content.remove(&child);
|
||||
}
|
||||
populate(content, model, status);
|
||||
}
|
||||
|
||||
fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
||||
let rerender: Rc<dyn Fn()> = {
|
||||
let content = content.clone();
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
Rc::new(move || rerender(&content, &model, &status))
|
||||
};
|
||||
fn populate_unknown(content: &GBox, status: &Label) {
|
||||
content.append(&w::hint(
|
||||
"binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
|
||||
\"globals\"/\"common\"/\"layouts\"). Nothing below is editable, and Save is disabled, \
|
||||
so the file on disk isn't at risk of being silently overwritten with the wrong shape. \
|
||||
Fix or remove the file by hand, then reopen this panel.",
|
||||
));
|
||||
status.set_text("binds.json schema not recognized — editing disabled");
|
||||
}
|
||||
|
||||
fn populate_flat(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label, rerender: &Rc<dyn Fn()>) {
|
||||
content.append(&w::hint(
|
||||
"Mods/Key/Action are the fields every bind needs. The last column holds action-specific \
|
||||
extras as inline JSON — e.g. {\"command\": \"kitty\"}, {\"direction\": \"left\"}, \
|
||||
{\"workspace\": \"e+1\"}, {\"label\": \"...\", \"category\": \"...\"} — leave it blank \
|
||||
for actions with none (close, exit, fullscreen, ...). This machine's binds.json uses \
|
||||
BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on \
|
||||
next login/reload.",
|
||||
));
|
||||
|
||||
let default_mods = Entry::new();
|
||||
default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
|
||||
default_mods.set_hexpand(true);
|
||||
default_mods.set_width_chars(20);
|
||||
{
|
||||
let model = model.clone();
|
||||
default_mods.connect_changed(move |e| {
|
||||
model.borrow_mut().file.default_mods = text_to_mods(&e.text());
|
||||
});
|
||||
}
|
||||
content.append(&w::row("Default mods", &default_mods));
|
||||
|
||||
content.append(§ion(Some("Bindings"), model, bindings_accessor(), rerender));
|
||||
|
||||
let _ = status;
|
||||
}
|
||||
|
||||
fn populate_multi_layout(content: &GBox, model: &Rc<RefCell<Model>>, rerender: &Rc<dyn Fn()>) {
|
||||
content.append(&w::hint(
|
||||
"Mods/Key/Action are the fields every bind needs. The last column \
|
||||
holds action-specific extras as inline JSON — e.g. \
|
||||
|
|
@ -280,7 +449,7 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
|||
Applies on next login/reload.",
|
||||
));
|
||||
|
||||
let layout_names: Vec<String> = model.borrow().layouts.keys().cloned().collect();
|
||||
let layout_names: Vec<String> = model.borrow().file.layouts.keys().cloned().collect();
|
||||
|
||||
let top_row = GBox::new(Orientation::Horizontal, 12);
|
||||
top_row.append(&{
|
||||
|
|
@ -288,13 +457,13 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
|||
Some(StringList::new(&layout_names.iter().map(String::as_str).collect::<Vec<_>>())),
|
||||
Expression::NONE,
|
||||
);
|
||||
let cur = model.borrow().active_layout.clone();
|
||||
let cur = model.borrow().file.active_layout.clone();
|
||||
dd.set_selected(layout_names.iter().position(|n| *n == cur).unwrap_or(0) as u32);
|
||||
let model = model.clone();
|
||||
let layout_names = layout_names.clone();
|
||||
dd.connect_selected_notify(move |dd| {
|
||||
if let Some(name) = layout_names.get(dd.selected() as usize) {
|
||||
model.borrow_mut().active_layout = name.clone();
|
||||
model.borrow_mut().file.active_layout = name.clone();
|
||||
}
|
||||
});
|
||||
w::row("Active layout", &dd)
|
||||
|
|
@ -302,19 +471,19 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
|||
content.append(&top_row);
|
||||
|
||||
let default_mods = Entry::new();
|
||||
default_mods.set_text(&mods_to_text(&model.borrow().default_mods));
|
||||
default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
|
||||
default_mods.set_hexpand(true);
|
||||
default_mods.set_width_chars(20);
|
||||
{
|
||||
let model = model.clone();
|
||||
default_mods.connect_changed(move |e| {
|
||||
model.borrow_mut().default_mods = text_to_mods(&e.text());
|
||||
model.borrow_mut().file.default_mods = text_to_mods(&e.text());
|
||||
});
|
||||
}
|
||||
content.append(&w::row("Default mods", &default_mods));
|
||||
|
||||
content.append(§ion(Some("Media & function keys (globals)"), model, globals_accessor(), &rerender));
|
||||
content.append(§ion(Some("Common (every layout)"), model, common_accessor(), &rerender));
|
||||
content.append(§ion(Some("Media & function keys (globals)"), model, globals_accessor(), rerender));
|
||||
content.append(§ion(Some("Common (every layout)"), model, common_accessor(), rerender));
|
||||
|
||||
for name in &layout_names {
|
||||
let header = GBox::new(Orientation::Horizontal, 8);
|
||||
|
|
@ -345,9 +514,9 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
|||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
||||
if result == Ok(1) {
|
||||
let mut m = model.borrow_mut();
|
||||
m.layouts.remove(&name);
|
||||
if m.active_layout == name {
|
||||
m.active_layout = m.layouts.keys().next().cloned().unwrap_or_default();
|
||||
m.file.layouts.remove(&name);
|
||||
if m.file.active_layout == name {
|
||||
m.file.active_layout = m.file.layouts.keys().next().cloned().unwrap_or_default();
|
||||
}
|
||||
drop(m);
|
||||
rerender();
|
||||
|
|
@ -360,7 +529,7 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
|||
|
||||
// No section title here — the "Layout: X" header above (with its
|
||||
// own Remove button) already covers it.
|
||||
content.append(§ion(None, model, layout_accessor(name.clone()), &rerender));
|
||||
content.append(§ion(None, model, layout_accessor(name.clone()), rerender));
|
||||
}
|
||||
|
||||
let add_layout_row = GBox::new(Orientation::Horizontal, 8);
|
||||
|
|
@ -377,7 +546,7 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
|||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
model.borrow_mut().layouts.entry(name).or_default();
|
||||
model.borrow_mut().file.layouts.entry(name).or_default();
|
||||
entry.set_text("");
|
||||
rerender();
|
||||
});
|
||||
|
|
@ -387,9 +556,25 @@ fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
|
|||
content.append(&add_layout_row);
|
||||
}
|
||||
|
||||
fn populate(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label) {
|
||||
let rerender: Rc<dyn Fn()> = {
|
||||
let content = content.clone();
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
Rc::new(move || rerender(&content, &model, &status))
|
||||
};
|
||||
|
||||
match model.borrow().kind {
|
||||
SchemaKind::Unknown => populate_unknown(content, status),
|
||||
SchemaKind::Flat => populate_flat(content, model, status, &rerender),
|
||||
SchemaKind::MultiLayout => populate_multi_layout(content, model, &rerender),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Keybinds");
|
||||
let model = Rc::new(RefCell::new(load()));
|
||||
let (file, kind) = load();
|
||||
let model = Rc::new(RefCell::new(Model { kind, file }));
|
||||
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
|
|
@ -403,16 +588,19 @@ pub fn build() -> GBox {
|
|||
{
|
||||
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
|
||||
});
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let m = model.borrow();
|
||||
match save(&m.file, m.kind) {
|
||||
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}")),
|
||||
}
|
||||
Err(e) => status.set_text(&format!("Error: {e}")),
|
||||
});
|
||||
}
|
||||
btn_row.append(&save_btn);
|
||||
|
|
@ -421,3 +609,144 @@ pub fn build() -> GBox {
|
|||
|
||||
outer
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A representative slice of BOS's real shipped `binds.json`
|
||||
/// (`iso/airootfs/etc/skel/.config/hypr/binds.json`, flat schema) —
|
||||
/// chosen to exercise the extra-field variety breadhelp reads (`label`,
|
||||
/// `category`, `demo_cmd`), an explicit `mods: []` override, a nested
|
||||
/// `options` object, and both integer and string `workspace` values.
|
||||
/// This is the fixture that would have caught the original bug: the
|
||||
/// editor mis-detecting this shape as MultiLayout and silently dropping
|
||||
/// the whole `bindings` array on save.
|
||||
const REAL_BOS_FLAT_FIXTURE: &str = r#"{
|
||||
"default_mods": ["SUPER"],
|
||||
"bindings": [
|
||||
{ "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" },
|
||||
{ "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" },
|
||||
{ "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" },
|
||||
{ "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" },
|
||||
{ "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" },
|
||||
{ "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" },
|
||||
{ "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" },
|
||||
{ "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn parse(text: &str) -> (BindsFile, SchemaKind) {
|
||||
let kind = match serde_json::from_str::<Value>(text) {
|
||||
Ok(Value::Object(top)) => SchemaKind::detect(&top),
|
||||
_ => SchemaKind::Unknown,
|
||||
};
|
||||
let file: BindsFile = serde_json::from_str(text).unwrap_or_default();
|
||||
(file, kind)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_flat_schema_from_real_bos_binds_json() {
|
||||
let (_, kind) = parse(REAL_BOS_FLAT_FIXTURE);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_real_bos_flat_binds_json_through_load_and_save() {
|
||||
let (file, kind) = parse(REAL_BOS_FLAT_FIXTURE);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
|
||||
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
|
||||
let saved = to_json(&file, kind);
|
||||
|
||||
// Flat save must emit EXACTLY {default_mods, bindings} — no
|
||||
// active_layout/globals/common/layouts keys leaking in.
|
||||
let saved_obj = saved.as_object().expect("flat save must be a JSON object");
|
||||
assert_eq!(
|
||||
saved_obj.keys().cloned().collect::<std::collections::BTreeSet<_>>(),
|
||||
["default_mods", "bindings"].into_iter().map(String::from).collect(),
|
||||
"Flat schema must round-trip as exactly {{default_mods, bindings}}"
|
||||
);
|
||||
|
||||
// The `bindings` array — and every per-bind extra field (label,
|
||||
// category, demo_cmd, mods, options, integer vs string workspace,
|
||||
// ...) — must survive the round trip semantically untouched.
|
||||
assert_eq!(saved["bindings"], original["bindings"]);
|
||||
assert_eq!(saved["default_mods"], original["default_mods"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_via_files_preserves_bindings_key_and_extras() {
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("binds.json");
|
||||
std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap();
|
||||
|
||||
let (file, kind) = load_from(&path);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
save_to(&path, &file, kind).unwrap();
|
||||
|
||||
let saved_text = std::fs::read_to_string(&path).unwrap();
|
||||
let saved: Value = serde_json::from_str(&saved_text).unwrap();
|
||||
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
|
||||
|
||||
assert!(saved.get("bindings").is_some(), "bindings key must survive a load -> save round trip");
|
||||
assert_eq!(saved["bindings"], original["bindings"]);
|
||||
assert_eq!(saved["default_mods"], original["default_mods"]);
|
||||
|
||||
// Backup safety net: a second save must leave `.bak` holding the
|
||||
// prior contents.
|
||||
save_to(&path, &file, kind).unwrap();
|
||||
let backup_path = dir.join("binds.json.bak");
|
||||
assert!(backup_path.exists(), "save must back up the previous file");
|
||||
let backup: Value = serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap();
|
||||
assert_eq!(backup["bindings"], original["bindings"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_round_trips_multi_layout_schema() {
|
||||
let text = r#"{
|
||||
"active_layout": "qwerty",
|
||||
"default_mods": ["SUPER"],
|
||||
"globals": [{ "action": "exec", "command": "kitty", "key": "RETURN" }],
|
||||
"common": [],
|
||||
"layouts": { "qwerty": [{ "action": "close", "key": "BACKSPACE" }] }
|
||||
}"#;
|
||||
let (file, kind) = parse(text);
|
||||
assert_eq!(kind, SchemaKind::MultiLayout);
|
||||
|
||||
let saved = to_json(&file, kind);
|
||||
assert!(saved.get("bindings").is_none(), "MultiLayout save must not emit a flat `bindings` key");
|
||||
assert_eq!(saved["active_layout"], "qwerty");
|
||||
assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close");
|
||||
assert_eq!(saved["globals"][0]["command"], "kitty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_is_detected_and_refuses_to_save() {
|
||||
let text = r#"{ "some_other_shape": true }"#;
|
||||
let (file, kind) = parse(text);
|
||||
assert_eq!(kind, SchemaKind::Unknown);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-unknown-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("binds.json");
|
||||
|
||||
let result = save_to(&path, &file, kind);
|
||||
assert!(result.is_err(), "save() must refuse when schema kind is Unknown");
|
||||
assert!(!path.exists(), "refusing to save must not create/touch the target file");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_defaults_to_flat_not_multi_layout() {
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-missing-test-{}", std::process::id()));
|
||||
// Don't create the file at all.
|
||||
let path = dir.join("binds.json");
|
||||
let (_, kind) = load_from(&path);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue