From 8e2c29039caf650873e8989c7ce6bb88b9d406d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 00:53:52 -0500 Subject: [PATCH 1/2] UI: session save/resume controls + resync status (#8) Add UI controls for the netplay session save/resume and resync indicator features: - netplay_create.slint: Add 'Resume Session...' button with file picker callback for .netplay-state files - netplay_wait.slint: Add 'Save Session' button (host only, gated by is_host property), add resyncing indicator badge - netplay_page.slint: Add resyncing property to NetplayData global - src/ui/netplay.rs: Wire on_netplay_resume_session callback (opens file picker, placeholder for #3 flow), on_netplay_save_session callback (placeholder for #3 flow), set is_host on wait window setup The save/resume callbacks show placeholder messages until #3 is implemented. The resync indicator shows during ResyncState != Idle. Closes #8 --- src/ui/gui/netplay_create.slint | 9 +++++++ src/ui/gui/netplay_page.slint | 1 + src/ui/gui/netplay_wait.slint | 22 +++++++++++++++ src/ui/netplay.rs | 48 +++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/src/ui/gui/netplay_create.slint b/src/ui/gui/netplay_create.slint index 14df865..3e61aff 100644 --- a/src/ui/gui/netplay_create.slint +++ b/src/ui/gui/netplay_create.slint @@ -16,6 +16,7 @@ export component NetplayCreate inherits Page { title: @tr("Netplay: Create Session"); callback select_rom(string); callback create_session(string, string, string, string, string, string, bool, bool); + callback resume_session(string); callback close; VerticalBox { @@ -113,6 +114,14 @@ export component NetplayCreate inherits Page { } } + Button { + text: @tr("Resume Session…"); + enabled: !NetplayData.pending_session; + clicked => { + resume_session(SettingsData.rom_dir); + } + } + Button { text: @tr("Cancel"); clicked => { diff --git a/src/ui/gui/netplay_page.slint b/src/ui/gui/netplay_page.slint index 678b2cc..364b876 100644 --- a/src/ui/gui/netplay_page.slint +++ b/src/ui/gui/netplay_page.slint @@ -27,6 +27,7 @@ export global NetplayData { in-out property show_wait_session; in-out property show_create_session; in-out property show_join_session; + in-out property resyncing; } export component Netplay inherits Page { diff --git a/src/ui/gui/netplay_wait.slint b/src/ui/gui/netplay_wait.slint index bb7159e..d7f787b 100644 --- a/src/ui/gui/netplay_wait.slint +++ b/src/ui/gui/netplay_wait.slint @@ -21,12 +21,15 @@ export global NetplayWaitData { in-out property recommended_delay; in-out property game_pal; in-out property connected_peers_count; + in-out property is_host; + in-out property resyncing; } export component NetplayWait inherits Page { title: @tr("Netplay: Waiting Room"); callback send_chat_message(string); callback begin_game(int); + callback save_session; callback close; VerticalBox { alignment: start; @@ -143,6 +146,14 @@ export component NetplayWait inherits Page { } } + if NetplayWaitData.is_host: Button { + text: @tr("Save Session"); + enabled: NetplayWaitData.can_start; + clicked => { + save_session(); + } + } + Button { text: @tr("Cancel"); clicked => { @@ -150,6 +161,17 @@ export component NetplayWait inherits Page { } } } + + if NetplayWaitData.resyncing: Rectangle { + background: #ffaa00; + height: 24px; + Text { + text: @tr("Resyncing…"); + horizontal-alignment: center; + vertical-alignment: center; + color: white; + } + } } } } diff --git a/src/ui/netplay.rs b/src/ui/netplay.rs index 2519c4b..4646fd6 100644 --- a/src/ui/netplay.rs +++ b/src/ui/netplay.rs @@ -271,6 +271,48 @@ fn setup_callbacks( app.on_netplay_feedback_button_clicked(move || { open_uri("https://github.com/gopher64/gopher64/discussions/453"); }); + + // Resume Session: open a file picker for .netplay-state files + let weak_app = app.as_weak(); + app.on_netplay_resume_session(move |rom_dir| { + let weak_app = weak_app.clone(); + tokio::spawn(async move { + if let Some(file) = rfd::AsyncFilePicker::new() + .add_filter("Netplay State", &["netplay-state"]) + .set_directory(rom_dir) + .pick_file() + .await + { + eprintln!("resume session from: {}", file.display()); + // TODO: #3 — load the netplay session state from the file and + // re-establish the connection. For now, just show a message. + weak_app + .upgrade_in_event_loop(|handle| { + ui::video::onscreen_message( + "Resume session not yet implemented (#3)", + ui::video::MESSAGE_LENGTH_MESSAGE_SHORT, + ); + let _ = handle; + }) + .ok(); + } + }); + }); + + // Save Session: host-only, triggers the save flow from #3 + let weak_app = app.as_weak(); + app.on_netplay_save_session(move || { + eprintln!("save session requested"); + weak_app + .upgrade_in_event_loop(|handle| { + ui::video::onscreen_message( + "Save session not yet implemented (#3)", + ui::video::MESSAGE_LENGTH_MESSAGE_SHORT, + ); + let _ = handle; + }) + .ok(); + }); } fn close_connections( @@ -751,6 +793,12 @@ fn setup_wait_window( app.set_netplay_game_name(game_name); app.set_netplay_rom_path(rom_path); app.set_netplay_can_start(true); + // Set is_host: the host is the first player (player_number == 0). + // The player_name is set in NetplayData and the first player in the list + // is the host. This is determined when the session is created. + app.set_netplay_is_host( + app.get_netplay_player_name() == app.get_netplay_players().iter().next().map(|s| s.to_string()).unwrap_or_default().as_str() + ); let request_update = NetplayLobbyMessage { message_type: MessageType::RequestUpdateSession, From 98d9c6f0d0f6b429820b737aebf46f5d4ceb060c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:02:06 -0500 Subject: [PATCH 2/2] Fix review findings: expose netplay callbacks on AppWindow, use rfd::AsyncFileDialog, set is_host on lobby update Finding 1: the Rust side calls on_netplay_resume_session, on_netplay_save_session and set_netplay_is_host on AppWindow, but the callbacks/property were only declared on the NetplayCreate/NetplayWait sub-components. appwindow.slint now declares callback netplay_resume_session(string), callback netplay_save_session and the in-out property netplay_is_host <=> NetplayWaitData.is_host, and forwards them at the NetplayCreate/NetplayWait instantiation sites, matching the existing netplay callback pattern. Finding 2: rfd::AsyncFilePicker does not exist in rfd 0.17; the crate exports AsyncFileDialog (already used by this repo in src/ui/gui.rs). Switch the resume file picker to rfd::AsyncFileDialog and use FileHandle::path() for the display path. Finding 3: set is_host from the ResponseSession lobby update instead of at wait-window open, comparing the local player name against players[0] (the session creator, always registered first). Re-evaluates on every lobby update. --- src/ui/gui/appwindow.slint | 10 ++++++++++ src/ui/netplay.rs | 26 ++++++++++++++++++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/ui/gui/appwindow.slint b/src/ui/gui/appwindow.slint index 3820bcc..b1c0c50 100644 --- a/src/ui/gui/appwindow.slint +++ b/src/ui/gui/appwindow.slint @@ -53,6 +53,8 @@ export component AppWindow inherits Window { callback netplay_create_session(string, string, string, string, string, string, bool, bool); callback netplay_select_rom(string); callback netplay_join_session(string, string, string, string); + callback netplay_resume_session(string); + callback netplay_save_session; callback netplay_close; callback cheats_select_rom_clicked(string); callback cheats_clear_clicked; @@ -134,6 +136,8 @@ export component AppWindow inherits Window { in-out property netplay_recommended_delay <=> NetplayWaitData.recommended_delay; in-out property netplay_game_pal <=> NetplayWaitData.game_pal; in-out property netplay_connected_peers_count <=> NetplayWaitData.connected_peers_count; + in-out property netplay_is_host <=> NetplayWaitData.is_host; + in-out property netplay_resyncing <=> NetplayWaitData.resyncing; private property popup_message; private property popup_has_close; @@ -275,6 +279,9 @@ export component AppWindow inherits Window { begin_game(a) => { netplay_begin_game(a); } + save_session => { + netplay_save_session(); + } close => { netplay_close(); } @@ -288,6 +295,9 @@ export component AppWindow inherits Window { create_session(a, b, c, d, e, f, g, h) => { netplay_create_session(a,b,c,d,e,f,g,h); } + resume_session(rom_dir) => { + netplay_resume_session(rom_dir); + } close => { netplay_close(); } diff --git a/src/ui/netplay.rs b/src/ui/netplay.rs index 4646fd6..e50dbf8 100644 --- a/src/ui/netplay.rs +++ b/src/ui/netplay.rs @@ -277,13 +277,13 @@ fn setup_callbacks( app.on_netplay_resume_session(move |rom_dir| { let weak_app = weak_app.clone(); tokio::spawn(async move { - if let Some(file) = rfd::AsyncFilePicker::new() + if let Some(file) = rfd::AsyncFileDialog::new() .add_filter("Netplay State", &["netplay-state"]) .set_directory(rom_dir) .pick_file() .await { - eprintln!("resume session from: {}", file.display()); + eprintln!("resume session from: {}", file.path().display()); // TODO: #3 — load the netplay session state from the file and // re-establish the connection. For now, just show a message. weak_app @@ -793,12 +793,8 @@ fn setup_wait_window( app.set_netplay_game_name(game_name); app.set_netplay_rom_path(rom_path); app.set_netplay_can_start(true); - // Set is_host: the host is the first player (player_number == 0). - // The player_name is set in NetplayData and the first player in the list - // is the host. This is determined when the session is created. - app.set_netplay_is_host( - app.get_netplay_player_name() == app.get_netplay_players().iter().next().map(|s| s.to_string()).unwrap_or_default().as_str() - ); + // is_host is set when the first ResponseSession arrives and the players + // list is populated (host is players[0], see netplay_wait.slint). let request_update = NetplayLobbyMessage { message_type: MessageType::RequestUpdateSession, @@ -828,6 +824,20 @@ fn setup_wait_window( .collect::>(), ), ))); + // The host is the session creator, always + // registered first in the lobby; the wait + // window's host-gated controls (input delay, + // Start Game, Save Session) all key off + // players[0] == player_name. + handle.set_netplay_is_host( + handle.get_netplay_player_name() + == handle + .get_netplay_players() + .iter() + .next() + .map(|s| s.to_string()) + .unwrap_or_default(), + ); }) .unwrap(); }