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
+81 -7
View File
@@ -322,11 +322,12 @@ fn theme(_state: &AppState) -> Theme {
}
pub fn run_gui() -> iced::Result {
// Restore the last window size (saved on close). Position can't be restored on
// Wayland xdg-shell gives clients no way to set their own position — so we
// only persist size and leave placement to the compositor.
// Restore the last window size (saved on close). Position is restored too,
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
// position, so we center there and leave placement to the compositor.
let saved = AppConfig::load();
let init_size = iced::Size::new(saved.window_width, saved.window_height);
let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland());
iced::application(AppState::default, update, view)
.title("PeerSpeak P2P Voice Chat")
.theme(theme)
@@ -335,7 +336,7 @@ pub fn run_gui() -> iced::Result {
// Restored from config (defaults 900×760: taller so the bottom chat
// dock doesn't squeeze the controls column). Layout is responsive.
size: init_size,
position: iced::window::Position::Centered,
position: init_position,
// App/taskbar icon (mainly used on X11/XWayland; native Wayland takes
// the icon from the .desktop file matched by app_id instead).
icon: window_icon(),
@@ -360,6 +361,31 @@ fn window_icon() -> Option<iced::window::Icon> {
iced::window::icon::from_rgba(RGBA.to_vec(), 128, 128).ok()
}
/// True when running under a Wayland compositor (winit will use its Wayland
/// backend). Mirrors winit's own selection: it prefers Wayland when
/// `WAYLAND_DISPLAY` is set, otherwise falls back to X11 via `DISPLAY`.
fn is_wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
}
/// Decide the initial window position from the saved coordinates.
///
/// Position restore only works on **X11** — Wayland's xdg-shell gives clients no
/// way to place their own window, so we center there and let the compositor
/// decide. Returns `Centered` when we're on Wayland or have no saved position.
fn initial_window_position(
saved_x: Option<i32>,
saved_y: Option<i32>,
is_wayland: bool,
) -> iced::window::Position {
match (saved_x, saved_y) {
(Some(x), Some(y)) if !is_wayland => {
iced::window::Position::Specific(iced::Point::new(x as f32, y as f32))
}
_ => iced::window::Position::Centered,
}
}
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
@@ -777,10 +803,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.config.chat_drawer_width =
clamp_chat_drawer_width(state.config.chat_drawer_width, size.width);
}
AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => {
// Remember the position in-memory; written to disk once on close.
// Negative coords are valid (a monitor left of/above the primary), so
// we don't clamp. On Wayland iced doesn't report position, so this
// arm simply never fires there and window_x/y stay None.
state.config.window_x = Some(position.x as i32);
state.config.window_y = Some(position.y as i32);
}
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
// We took over the close path (exit_on_close_request:false) so we can
// persist the final window size before quitting. The latest size is
// already mirrored into config by the Resized handler above.
// persist the final window size + position before quitting. Both are
// already mirrored into config by the Resized/Moved handlers above.
state.config.save();
return iced::exit();
}
@@ -2513,7 +2547,47 @@ impl Program<AppMessage> for Icon {
#[cfg(test)]
mod tests {
use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX};
use super::{
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
GateMeter, METER_MAX,
};
#[test]
fn x11_restores_saved_window_position() {
// On X11 (is_wayland = false) a saved position becomes Specific(x, y).
match initial_window_position(Some(120), Some(-40), false) {
iced::window::Position::Specific(p) => {
assert_eq!(p.x, 120.0);
assert_eq!(p.y, -40.0); // negative coords (left/above primary) preserved
}
other => panic!("expected Specific, got {other:?}"),
}
}
#[test]
fn wayland_always_centers_even_with_saved_position() {
assert!(matches!(
initial_window_position(Some(120), Some(40), true),
iced::window::Position::Centered
));
}
#[test]
fn missing_or_partial_saved_position_centers() {
assert!(matches!(
initial_window_position(None, None, false),
iced::window::Position::Centered
));
// A half-saved position (one axis missing) is not enough to restore.
assert!(matches!(
initial_window_position(Some(10), None, false),
iced::window::Position::Centered
));
assert!(matches!(
initial_window_position(None, Some(10), false),
iced::window::Position::Centered
));
}
#[test]
fn format_duration_renders_mss_and_hmmss() {