Fix keybinds editor destroying real BOS binds.json + make config writes atomic
keybinds.rs was built only against a personal multi-layout binds.json schema (globals/common/layouts). Loading the real BOS-shipped flat schema (default_mods/bindings, with per-bind label/category/demo_cmd breadhelp depends on) into that model and saving silently dropped the bindings key -- still valid JSON, so the Lua pcall failsafes never caught it. - Add SchemaKind (Flat/MultiLayout/Unknown), detected from the file's top-level keys at load time and pinned for the session so save() always emits the same shape it read. - Flat mode hides the layout-switching UI (there's nothing to switch) and edits a single Bindings list; MultiLayout keeps today's UI unchanged. - Bind's #[serde(flatten)] extra map already preserved label/category/ demo_cmd/options across a round trip; changed `mods` from Vec<String> to Option<Vec<String>> so an explicit "mods": [] (media keys pinning "no modifiers") isn't collapsed into "field omitted -> falls back to default_mods" -- a real behavior-changing loss the old round trip had. - Unknown schema refuses to save (visible status-label error) instead of guessing a shape and overwriting the file. - New round-trip tests in keybinds.rs load a real BOS-shaped binds.json fixture through load -> save and assert the bindings key and every per-bind extra field survive untouched; this is the regression test that would have caught the original bug. Also: config writes across the app (TOML via config::save_doc, and the JSON views -- autostart, appearance/settings.json, hyprland.rs/ monitors.json, breadbar's CSS) now go through a shared config::atomic_write: write to a temp file in the same directory, rename over the target, and back up whatever was there first to <path>.bak. A crash or disk-full mid-write can no longer leave a config truncated or corrupted with no way back.
This commit is contained in:
parent
515101b39a
commit
da6af6abf9
6 changed files with 451 additions and 80 deletions
|
|
@ -41,10 +41,38 @@ pub fn load_doc(path: &Path) -> DocumentMut {
|
|||
|
||||
/// Write the document back to disk, creating parent dirs as needed.
|
||||
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
|
||||
atomic_write(path, &doc.to_string())?;
|
||||
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) should go through this instead of a bare
|
||||
/// `std::fs::write`: writing straight to the target path means a crash,
|
||||
/// power loss, or disk-full error mid-write can leave the file truncated or
|
||||
/// corrupted with no way back. Writing to a temp file in the *same*
|
||||
/// directory first, then `rename`-ing it over the target, avoids that — a
|
||||
/// rename within one filesystem is atomic, so the target either has the old
|
||||
/// complete contents or the new complete contents, never a partial write.
|
||||
/// Backing up the previous file first (best-effort — the write can still
|
||||
/// proceed if the backup fails, e.g. read-only source) means even a
|
||||
/// successful-but-wrong write is always recoverable from `<path>.bak`.
|
||||
pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, doc.to_string())?;
|
||||
if path.exists() {
|
||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
||||
let _ = std::fs::copy(path, &backup);
|
||||
}
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("config");
|
||||
let tmp_path = dir.join(format!(".{file_name}.tmp.{}", std::process::id()));
|
||||
std::fs::write(&tmp_path, contents)?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -210,4 +238,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,11 +64,8 @@ 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)> {
|
||||
|
|
|
|||
|
|
@ -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