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:
@@ -122,6 +122,16 @@ covers internals). When you ship a feature, add it here.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Config file | ✅ | `~/.config/peerspeak/config.json`. |
|
| Config file | ✅ | `~/.config/peerspeak/config.json`. |
|
||||||
| Backward-compatible loading | ✅ | serde `default`s fill missing fields; unknown fields tolerated. |
|
| Backward-compatible loading | ✅ | serde `default`s fill missing fields; unknown fields tolerated. |
|
||||||
|
| Window size restored | ✅ | `window_width`/`window_height`, saved on close. |
|
||||||
|
| Window position restored | ✅ | `window_x`/`window_y`, saved on close. **X11 only** — see Platform support. |
|
||||||
|
|
||||||
|
## Platform support (Linux desktop)
|
||||||
|
|
||||||
|
| Concern | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Wayland | ✅ | Default on this dev box; winit's Wayland backend. App/taskbar icon comes from the `.desktop` file matched by `application_id = "peerspeak"`. |
|
||||||
|
| X11 (incl. XWayland) | ✅ | winit's X11 backend (both backends compile in by default; winit auto-selects — Wayland if `WAYLAND_DISPLAY` is set, else X11 via `DISPLAY`). Launch verified rendering on X11. The embedded RGBA window icon (`from_rgba`) is honored on X11 even without the `.desktop` installed. |
|
||||||
|
| Window position restore | X11 only | Wayland's xdg-shell gives clients no way to place their own window, so we center there. On X11 the saved `window_x`/`window_y` is restored via `Position::Specific` (outer position; no drift across save/restore — iced's `Moved` and `Position::Specific` both use the outer position). Verified live on X11 (saved an off-center position; the window reopened there, not centered). Gated by `is_wayland()` (`src/app/mod.rs`); decision logic unit-tested (`initial_window_position`). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+81
-7
@@ -322,11 +322,12 @@ fn theme(_state: &AppState) -> Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_gui() -> iced::Result {
|
pub fn run_gui() -> iced::Result {
|
||||||
// Restore the last window size (saved on close). Position can't be restored on
|
// Restore the last window size (saved on close). Position is restored too,
|
||||||
// Wayland — xdg-shell gives clients no way to set their own position — so we
|
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
||||||
// only persist size and leave placement to the compositor.
|
// position, so we center there and leave placement to the compositor.
|
||||||
let saved = AppConfig::load();
|
let saved = AppConfig::load();
|
||||||
let init_size = iced::Size::new(saved.window_width, saved.window_height);
|
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)
|
iced::application(AppState::default, update, view)
|
||||||
.title("PeerSpeak P2P Voice Chat")
|
.title("PeerSpeak P2P Voice Chat")
|
||||||
.theme(theme)
|
.theme(theme)
|
||||||
@@ -335,7 +336,7 @@ pub fn run_gui() -> iced::Result {
|
|||||||
// Restored from config (defaults 900×760: taller so the bottom chat
|
// Restored from config (defaults 900×760: taller so the bottom chat
|
||||||
// dock doesn't squeeze the controls column). Layout is responsive.
|
// dock doesn't squeeze the controls column). Layout is responsive.
|
||||||
size: init_size,
|
size: init_size,
|
||||||
position: iced::window::Position::Centered,
|
position: init_position,
|
||||||
// App/taskbar icon (mainly used on X11/XWayland; native Wayland takes
|
// App/taskbar icon (mainly used on X11/XWayland; native Wayland takes
|
||||||
// the icon from the .desktop file matched by app_id instead).
|
// the icon from the .desktop file matched by app_id instead).
|
||||||
icon: window_icon(),
|
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()
|
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> {
|
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
|
||||||
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
|
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
|
||||||
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
|
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 =
|
state.config.chat_drawer_width =
|
||||||
clamp_chat_drawer_width(state.config.chat_drawer_width, size.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)) => {
|
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
|
||||||
// We took over the close path (exit_on_close_request:false) so we can
|
// 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
|
// persist the final window size + position before quitting. Both are
|
||||||
// already mirrored into config by the Resized handler above.
|
// already mirrored into config by the Resized/Moved handlers above.
|
||||||
state.config.save();
|
state.config.save();
|
||||||
return iced::exit();
|
return iced::exit();
|
||||||
}
|
}
|
||||||
@@ -2513,7 +2547,47 @@ impl Program<AppMessage> for Icon {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn format_duration_renders_mss_and_hmmss() {
|
fn format_duration_renders_mss_and_hmmss() {
|
||||||
|
|||||||
+42
-1
@@ -158,11 +158,19 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pixelpass_path: Option<String>,
|
pub pixelpass_path: Option<String>,
|
||||||
/// Last window size (px), restored as the initial size on next launch.
|
/// 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")]
|
#[serde(default = "default_window_width")]
|
||||||
pub window_width: f32,
|
pub window_width: f32,
|
||||||
#[serde(default = "default_window_height")]
|
#[serde(default = "default_window_height")]
|
||||||
pub window_height: f32,
|
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 {
|
impl Default for AppConfig {
|
||||||
@@ -193,6 +201,8 @@ impl Default for AppConfig {
|
|||||||
pixelpass_path: None,
|
pixelpass_path: None,
|
||||||
window_width: default_window_width(),
|
window_width: default_window_width(),
|
||||||
window_height: default_window_height(),
|
window_height: default_window_height(),
|
||||||
|
window_x: None,
|
||||||
|
window_y: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +300,37 @@ mod tests {
|
|||||||
assert_eq!(back.window_height, 720.0);
|
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]
|
#[test]
|
||||||
fn test_input_output_volume_fields() {
|
fn test_input_output_volume_fields() {
|
||||||
// Default impl is unity gain.
|
// Default impl is unity gain.
|
||||||
|
|||||||
Reference in New Issue
Block a user