diff --git a/src/cli.rs b/src/cli.rs index ed095d5..60c7fef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -94,6 +94,11 @@ pub struct ConnectArgs { #[arg(long)] pub undecorated: bool, + /// Do not show the floating toolbar (or its top-edge hover hotspot) in + /// fullscreen. Useful when the guest has panels at the screen edges. + #[arg(long)] + pub no_fullscreen_bar: bool, + /// Use QEMU-provided DMABUF damage rectangles instead of full-surface /// refreshes. This can be faster, but some guest/driver combinations may /// flicker. diff --git a/src/main.rs b/src/main.rs index fd06864..8732b7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,7 @@ async fn run_connect_command(args: ConnectArgs) -> Result<()> { args.hotkeys.as_deref(), args.fullscreen, args.undecorated, + args.no_fullscreen_bar, args.dmabuf_partial_updates, ) } diff --git a/src/viewer/chrome.rs b/src/viewer/chrome.rs index 1a590f9..a9aa6a6 100644 --- a/src/viewer/chrome.rs +++ b/src/viewer/chrome.rs @@ -341,8 +341,11 @@ pub(super) fn sync_fullscreen_chrome( update_fullscreen_button(button, is_fullscreen); } + // GTK4 forbids set_titlebar() on a realized window (warning + segfault). + // Keep the titlebar set once at construction and only toggle its visibility; + // GTK hides titlebars in fullscreen anyway. if is_fullscreen { - window.set_titlebar(None::<>k::Widget>); + header_bar.set_visible(false); fullscreen_hotspot.set_visible(true); reveal_fullscreen_bar(fullscreen_revealer, fullscreen_state); schedule_hide_fullscreen_bar(window, fullscreen_revealer, fullscreen_state); @@ -351,10 +354,6 @@ pub(super) fn sync_fullscreen_chrome( fullscreen_revealer.set_reveal_child(false); fullscreen_revealer.set_visible(false); fullscreen_hotspot.set_visible(false); - if decorated_window { - window.set_titlebar(Some(header_bar)); - } else { - window.set_titlebar(None::<>k::Widget>); - } + header_bar.set_visible(decorated_window); } } diff --git a/src/viewer/framebuffer.rs b/src/viewer/framebuffer.rs index 6bd33ca..2d63cd5 100644 --- a/src/viewer/framebuffer.rs +++ b/src/viewer/framebuffer.rs @@ -703,6 +703,7 @@ fn premultiply(channel: u8, alpha: u8) -> u8 { pub(super) struct FrameStreamHandler { event_tx: EventSender, framebuffer: Option, + disable_notice_pending: bool, } impl FrameStreamHandler { @@ -710,6 +711,14 @@ impl FrameStreamHandler { Self { event_tx, framebuffer: None, + disable_notice_pending: false, + } + } + + fn clear_disable_notice(&mut self) { + if self.disable_notice_pending { + self.disable_notice_pending = false; + self.send_status(""); } } @@ -794,16 +803,19 @@ impl FrameStreamHandler { #[cfg(unix)] pub(super) fn emit_dmabuf_scanout(&mut self, scanout: DmabufFrame) { self.framebuffer = None; + self.clear_disable_notice(); let _ = self.event_tx.send(ViewerEvent::Dmabuf(scanout)); } #[cfg(unix)] pub(super) fn update_dmabuf(&mut self, update: UpdateDMABUF) { + self.clear_disable_notice(); let _ = self.event_tx.send(ViewerEvent::DmabufUpdate(update)); } pub(super) fn disable(&mut self) { self.framebuffer = None; + self.disable_notice_pending = true; self.send_status("The guest display was disabled."); } diff --git a/src/viewer/listener/remote.rs b/src/viewer/listener/remote.rs index 5a52505..332f419 100644 --- a/src/viewer/listener/remote.rs +++ b/src/viewer/listener/remote.rs @@ -71,6 +71,13 @@ impl RemoteConsole { .context("failed to query the mouse mode") } + pub(super) async fn set_ui_info(&self, width: u32, height: u32) -> Result<()> { + self.proxy + .set_ui_info(0, 0, 0, 0, width, height) + .await + .with_context(|| format!("failed to request a guest resize to {width}x{height}")) + } + pub(super) async fn check_alive(&self) -> Result<()> { self.proxy .label() @@ -91,9 +98,9 @@ impl RemoteConsole { .release(keycode) .await .with_context(|| format!("failed to send key release for qnum {keycode}")), - InputEvent::ClipboardViewerFocused(_) | InputEvent::ClipboardHostChanged(_, _) => { - Ok(()) - } + InputEvent::ClipboardViewerFocused(_) + | InputEvent::ClipboardHostChanged(_, _) + | InputEvent::UiInfo { .. } => Ok(()), InputEvent::MousePress(button) => self .mouse .press(button) diff --git a/src/viewer/listener/session.rs b/src/viewer/listener/session.rs index 9ad45cd..bd55e4c 100644 --- a/src/viewer/listener/session.rs +++ b/src/viewer/listener/session.rs @@ -137,6 +137,15 @@ pub(super) async fn listener_session( continue; } + if let InputEvent::UiInfo { width, height } = &input { + if let Err(error) = console.set_ui_info(*width, *height).await { + let _ = event_tx.send(ViewerEvent::Status(format!( + "Guest resize request failed: {error:#}" + ))); + } + continue; + } + let needs_mouse_mode = mouse::input_needs_mouse_mode(&input); if let Err(error) = console.handle_input(input).await { let recovered = if needs_mouse_mode { diff --git a/src/viewer/mod.rs b/src/viewer/mod.rs index 104779a..7c23ff5 100644 --- a/src/viewer/mod.rs +++ b/src/viewer/mod.rs @@ -125,6 +125,7 @@ pub fn connect( hotkeys_spec: Option<&str>, start_fullscreen: bool, undecorated: bool, + no_fullscreen_bar: bool, dmabuf_partial_updates: bool, ) -> Result<()> { let hotkeys = hotkeys::ViewerHotkeys::parse(hotkeys_spec) @@ -156,6 +157,7 @@ pub fn connect( hotkeys, start_fullscreen, undecorated, + no_fullscreen_bar, dmabuf_partial_updates, ); @@ -176,6 +178,7 @@ fn run_window( hotkeys: hotkeys::ViewerHotkeys, start_fullscreen: bool, undecorated: bool, + no_fullscreen_bar: bool, dmabuf_partial_updates: bool, ) -> Result<()> { gtk::init().context("failed to initialize GTK4")?; @@ -290,9 +293,13 @@ fn run_window( .build(); fullscreen_revealer.set_child(Some(&floating_controls.container)); fullscreen_revealer.set_visible(false); - overlay.add_overlay(&fullscreen_revealer); - overlay.set_measure_overlay(&fullscreen_revealer, false); - overlay.set_clip_overlay(&fullscreen_revealer, false); + // --no-fullscreen-bar: leave the floating bar and its hotspot unparented so + // fullscreen has no screen-edge chrome at all. + if !no_fullscreen_bar { + overlay.add_overlay(&fullscreen_revealer); + overlay.set_measure_overlay(&fullscreen_revealer, false); + overlay.set_clip_overlay(&fullscreen_revealer, false); + } let fullscreen_hotspot = gtk::Box::builder() .halign(gtk::Align::Center) @@ -302,9 +309,11 @@ fn run_window( .build(); fullscreen_hotspot.set_opacity(0.0); fullscreen_hotspot.set_visible(false); - overlay.add_overlay(&fullscreen_hotspot); - overlay.set_measure_overlay(&fullscreen_hotspot, false); - overlay.set_clip_overlay(&fullscreen_hotspot, false); + if !no_fullscreen_bar { + overlay.add_overlay(&fullscreen_hotspot); + overlay.set_measure_overlay(&fullscreen_hotspot, false); + overlay.set_clip_overlay(&fullscreen_hotspot, false); + } let fullscreen_state = Rc::new(RefCell::new(chrome::FullscreenChromeState::default())); let titlebar_widget = header_bar.clone().upcast::(); @@ -341,6 +350,91 @@ fn run_window( } }); + // Auto-resize: forward viewer size changes to the guest via SetUIInfo, + // the same mechanism the SPICE vdagent uses. Debounced so an interactive + // resize sends one final size instead of a flood. + { + let input_tx = input_tx.clone(); + let picture_for_send = picture.clone(); + let last_sent: Rc> = Rc::new(RefCell::new((0, 0))); + let pending: Rc>> = Rc::new(RefCell::new(None)); + let send_ui_info: Rc = Rc::new(move || { + let scale = picture_for_send.scale_factor(); + let width = picture_for_send.width() * scale; + let height = picture_for_send.height() * scale; + if width > 0 && height > 0 && *last_sent.borrow() != (width, height) { + *last_sent.borrow_mut() = (width, height); + let _ = input_tx.send(InputEvent::UiInfo { + width: width as u32, + height: height as u32, + }); + } + }); + let schedule: Rc = Rc::new({ + let pending = pending.clone(); + move || { + if let Some(source) = pending.borrow_mut().take() { + source.remove(); + } + let send_ui_info = send_ui_info.clone(); + let pending_inner = pending.clone(); + let source = glib::timeout_add_local_once( + std::time::Duration::from_millis(350), + move || { + *pending_inner.borrow_mut() = None; + send_ui_info(); + }, + ); + *pending.borrow_mut() = Some(source); + } + }); + window.connect_default_width_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + window.connect_default_height_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + window.connect_fullscreened_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + window.connect_maximized_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + // First map: the window may already be fullscreen (--fullscreen) before + // the picture gets an allocation, so no notify above will fire — request + // once the widgets are actually laid out. + window.connect_map({ + let schedule = schedule.clone(); + move |_| schedule() + }); + // The compositor can resize the surface without any of the window + // properties above changing — e.g. the monitor's resolution changes + // while the viewer is fullscreen. The surface `layout` signal is the + // ground truth for actual size changes, so hook it on every realize + // (each realize creates a fresh surface). + window.connect_realize({ + let schedule = schedule.clone(); + move |window| { + if let Some(surface) = window.surface() { + surface.connect_layout({ + let schedule = schedule.clone(); + move |_, _, _| schedule() + }); + } + } + }); + // Moving to a monitor with a different scale changes the physical + // pixel count without a logical resize. + window.connect_scale_factor_notify({ + let schedule = schedule.clone(); + move |_| schedule() + }); + } + let hotspot_motion = gtk::EventControllerMotion::new(); hotspot_motion.connect_enter({ let window = window.clone(); @@ -842,8 +936,12 @@ fn run_window( } if let Some(message) = latest_status { - status_label.set_label(&message); - status_label.set_visible(true); + if message.is_empty() { + status_label.set_visible(false); + } else { + status_label.set_label(&message); + status_label.set_visible(true); + } } glib::ControlFlow::Continue @@ -999,6 +1097,7 @@ enum InputEvent { MouseAbs { x: u32, y: u32 }, MouseRel { dx: i32, dy: i32 }, MouseWheel(MouseButton), + UiInfo { width: u32, height: u32 }, } #[cfg(test)]