feat(window): restore window position on X11

X11 sessions now persist and restore the window position (window_x/y) in
addition to size. Gated to X11: Wayland's xdg-shell gives clients no way
to self-position, so we center there (and iced never emits Moved on
Wayland, so window_x/y stay None). No drift across save/restore — iced's
Moved event and Position::Specific both use the window's outer position.

Also confirmed peerspeak already runs on X11 out of the box (winit
compiles both backends and auto-selects via WAYLAND_DISPLAY/DISPLAY) and
documented X11/Wayland support in FEATURES.md.

- config: window_x/window_y: Option<i32> (serde-default None).
- app: is_wayland() + pure initial_window_position() helper; a Moved
  handler records position; the close path persists it.
- tests: +3 initial_window_position (X11 restore / Wayland centers /
  partial-or-missing centers), +2 config (round-trip incl. negative
  coords; backward-compat load without the new fields). 130 -> 135 lib.

Verified live on X11/XWayland: saved an off-center position, the window
reopened there (not centered). clippy clean incl. --all-targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:51:22 -04:00
co-authored by Claude Opus 4.8
parent 45cea799ec
commit c888c30c08
3 changed files with 133 additions and 8 deletions
+42 -1
View File
@@ -158,11 +158,19 @@ pub struct AppConfig {
#[serde(default)]
pub pixelpass_path: Option<String>,
/// Last window size (px), restored as the initial size on next launch.
/// Saved on close. (Window *position* can't be restored on Wayland.)
/// Saved on close.
#[serde(default = "default_window_width")]
pub window_width: f32,
#[serde(default = "default_window_height")]
pub window_height: f32,
/// Last window position (px). Saved on close, restored on next launch — but
/// only on **X11**: Wayland's xdg-shell gives clients no way to place their
/// own window, so we center there and let the compositor decide. `None` =
/// never saved a position yet (e.g. always run under Wayland) → center.
#[serde(default)]
pub window_x: Option<i32>,
#[serde(default)]
pub window_y: Option<i32>,
}
impl Default for AppConfig {
@@ -193,6 +201,8 @@ impl Default for AppConfig {
pixelpass_path: None,
window_width: default_window_width(),
window_height: default_window_height(),
window_x: None,
window_y: None,
}
}
}
@@ -290,6 +300,37 @@ mod tests {
assert_eq!(back.window_height, 720.0);
}
#[test]
fn test_window_position_fields() {
// Position is unset by default (only ever saved on X11).
let def = AppConfig::default();
assert_eq!(def.window_x, None);
assert_eq!(def.window_y, None);
// A saved position (incl. negative coords) round-trips.
let cfg = AppConfig {
window_x: Some(200),
window_y: Some(-50),
..AppConfig::default()
};
let json = serde_json::to_string(&cfg).unwrap();
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.window_x, Some(200));
assert_eq!(back.window_y, Some(-50));
}
#[test]
fn old_config_without_position_loads() {
// A config written before window_x/window_y existed still deserializes
// (serde default → None), so upgrades don't wipe a user's settings.
// (The three device/gate fields have no serde default, so any valid
// config must carry them — mirrors test_backward_compat_default_fill.)
let json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"window_width":1024.0,"window_height":768.0}"#;
let cfg: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.window_x, None);
assert_eq!(cfg.window_y, None);
assert_eq!(cfg.window_width, 1024.0);
}
#[test]
fn test_input_output_volume_fields() {
// Default impl is unity gain.