Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
540 changes: 170 additions & 370 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ homepage = "https://github.com/thelicato/qd2"
anyhow = "1.0.102"
async-trait = "0.1.89"
clap = { version = "4.6.0", features = ["derive"] }
gtk4 = { version = "0.10.0", features = ["v4_14"] }
gtk4 = { version = "0.10.0", features = ["v4_16"] }
pixman-sys = "0.1.0"
qemu-display = "0.2.1"
serde_bytes = "0.11.19"
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ QD2 is built for people who want the flexibility of QEMU's D-Bus display stack w
| `--undecorated` | Open the GTK4 viewer without normal window decorations for `connect`. | `qd2 connect --undecorated` |
| `--dpu` | Use QEMU-provided DMABUF damage rectangles instead of the default full-surface DMABUF refresh path. | `qd2 connect --dpu` |
| `--hotkeys ...` | Override viewer shortcuts in a virt-viewer-style format for `connect`. | `qd2 connect --hotkeys "toggle-fullscreen=ctrl+enter,release-cursor=ctrl+alt"` |
| `--no-fullscreen-bar` | Hide the floating fullscreen toolbar and its top-edge hover hotspot for `connect`. Useful when the guest has panels at the screen edges. | `qd2 connect --fullscreen --no-fullscreen-bar` |
| `--name <APP_ID>` | Set the viewer's application identity (Wayland app_id / X11 WM class) so taskbars and window rules can match it, like spicy's `--name`. | `qd2 connect --name my-vm-launcher` |

## 🖥️ Viewer Highlights

Expand All @@ -55,6 +57,10 @@ QD2 is built for people who want the flexibility of QEMU's D-Bus display stack w
- Guest audio playback through the QEMU D-Bus audio interface.
- Floating fullscreen controls inspired by virt-viewer.
- Direct fullscreen launch with `qd2 connect --fullscreen`.
- Guest display resolution follows the viewer window size (including fullscreen).
- Sharp guest cursor on HiDPI displays: the cursor is rendered inside the scene at content scale instead of through the GTK cursor API.
- DMABUF frames are offloaded to a compositor subsurface (`GtkGraphicsOffload`), keeping large guest resolutions smooth in fullscreen.
- Keyboard forwarding starts when the viewer window gains focus (once a display is presented), with host shortcuts restored on focus loss.
- Optional undecorated launch mode for tiling compositor workflows.
- Top-bar actions for taking screenshots and sending guest shortcuts like `Ctrl+Alt+Delete`.
- Configurable hotkeys for fullscreen, grab release, and DMABUF transforms.
Expand All @@ -69,7 +75,7 @@ Linux releases also ship native `.deb` and `.rpm` packages. Those packages insta
Building from source currently requires:

- Rust stable with Cargo
- GTK4 development files
- GTK4 development files (GTK 4.16 or newer)
- pixman development files
- usbredir 0.13+ libraries visible to `pkg-config` (`libusbredirhost.pc` and `libusbredirparser-0.5.pc`)
- `pkg-config` or `pkgconf`
Expand Down
11 changes: 11 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,22 @@ 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.
#[arg(long = "dpu")]
pub dmabuf_partial_updates: bool,

/// Set the viewer's application/window identity (Wayland app_id, X11 WM
/// class), like spicy's --name. Lets taskbars and window rules match the
/// viewer to a specific launcher.
#[arg(long, value_name = "APP_ID")]
pub name: Option<String>,
}

impl ConnectArgs {
Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ async fn main() -> Result<()> {
}

async fn run_connect_command(args: ConnectArgs) -> Result<()> {
// Must happen before GTK initializes a display connection: the Wayland
// app_id / X11 WM class are derived from the program name.
if let Some(name) = args.name.as_deref() {
gtk4::glib::set_prgname(Some(name));
}

let target = if let Some(selector) = args.vm.as_deref() {
qemu::resolve_connect_target(args.address(), Some(selector), args.console).await?
} else {
Expand All @@ -68,6 +74,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,
)
}
Expand Down
11 changes: 5 additions & 6 deletions src/viewer/chrome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<&gtk::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);
Expand All @@ -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::<&gtk::Widget>);
}
header_bar.set_visible(decorated_window);
}
}
16 changes: 11 additions & 5 deletions src/viewer/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ impl GuestCursor {
}))
}

fn to_gdk_cursor(&self) -> gdk::Cursor {
/// The cursor texture plus hotspot, for rendering inside the scene.
pub(super) fn to_texture(&self) -> (gdk::Texture, i32, i32) {
let bytes = glib::Bytes::from_owned(self.rgba.clone());
let texture = gdk::MemoryTexture::new(
self.width,
Expand All @@ -64,8 +65,7 @@ impl GuestCursor {
&bytes,
self.stride(),
);
let fallback = gdk::Cursor::from_name("default", None);
gdk::Cursor::from_texture(&texture, self.hotspot_x, self.hotspot_y, fallback.as_ref())
(texture.upcast(), self.hotspot_x, self.hotspot_y)
}

fn stride(&self) -> usize {
Expand Down Expand Up @@ -106,8 +106,14 @@ impl Default for CursorState {
}

impl CursorState {
pub(super) fn set_shape(&mut self, shape: Option<GuestCursor>) {
self.active_cursor = shape.as_ref().map(GuestCursor::to_gdk_cursor);
/// While a guest shape is defined the scene draws it (see `cursor_scene`),
/// so the widget cursor only needs to keep the host cursor out of the way.
pub(super) fn set_shape(&mut self, has_guest_shape: bool) {
self.active_cursor = if has_guest_shape {
Some(self.hidden_cursor())
} else {
None
};
}

pub(super) fn set_visible(&mut self, visible: bool) {
Expand Down
176 changes: 176 additions & 0 deletions src/viewer/cursor_scene.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! Renders the guest cursor inside the viewer scene instead of relying on
//! `gdk::Cursor`. GTK rasterizes pointer cursors at logical scale (scale=1
//! even on HiDPI outputs), so a cursor set through the GTK cursor API is
//! always blurry on scaled displays. Drawing the cursor texture as part of
//! the scene maps guest pixels 1:1 to physical pixels whenever the display
//! itself does, which is what makes it sharp.

use std::{cell::RefCell, rc::Rc};

use gtk::{gdk, glib, graphene, prelude::*, subclass::prelude::*};
use gtk4 as gtk;

use super::{UiState, mouse::MouseMode};

pub(super) struct SceneState {
texture: Option<gdk::Texture>,
hotspot: (i32, i32),
pointer: Option<(f64, f64)>,
cursor_visible: bool,
picture: Option<gtk::Picture>,
ui_state: Option<Rc<RefCell<UiState>>>,
mouse_mode: Option<Rc<RefCell<MouseMode>>>,
}

impl Default for SceneState {
fn default() -> Self {
Self {
texture: None,
hotspot: (0, 0),
pointer: None,
cursor_visible: true,
picture: None,
ui_state: None,
mouse_mode: None,
}
}
}

mod cursor_scene_imp {
use super::*;

#[derive(Default)]
pub struct CursorScene {
pub(super) state: RefCell<SceneState>,
}

#[glib::object_subclass]
impl ObjectSubclass for CursorScene {
const NAME: &'static str = "Qd2CursorScene";
type Type = super::CursorScene;
type ParentType = gtk::Widget;
}

impl ObjectImpl for CursorScene {}

impl WidgetImpl for CursorScene {
fn snapshot(&self, snapshot: &gtk::Snapshot) {
let state = self.state.borrow();

if !state.cursor_visible {
return;
}
let Some(texture) = &state.texture else {
return;
};
let Some((pointer_x, pointer_y)) = state.pointer else {
return;
};
let (Some(picture), Some(ui_state), Some(mouse_mode)) =
(&state.picture, &state.ui_state, &state.mouse_mode)
else {
return;
};
// In relative mode the local pointer position says nothing about
// where the guest keeps its cursor, so don't pretend otherwise.
if *mouse_mode.borrow() != MouseMode::Absolute {
return;
}
let Some((frame_width, frame_height)) = ui_state.borrow().frame_size else {
return;
};
let Some(bounds) = picture.compute_bounds(self.obj().upcast_ref::<gtk::Widget>())
else {
return;
};

// The same guest-pixel -> logical-pixel factor ContentFit::Contain
// applies to the framebuffer, so the cursor scales with the scene.
let scale = (f64::from(bounds.width()) / f64::from(frame_width))
.min(f64::from(bounds.height()) / f64::from(frame_height));
if !scale.is_finite() || scale <= 0.0 {
return;
}

let x = f64::from(bounds.x()) + pointer_x - f64::from(state.hotspot.0) * scale;
let y = f64::from(bounds.y()) + pointer_y - f64::from(state.hotspot.1) * scale;
snapshot.append_texture(
texture,
&graphene::Rect::new(
x as f32,
y as f32,
(f64::from(texture.width()) * scale) as f32,
(f64::from(texture.height()) * scale) as f32,
),
);
}
}
}

glib::wrapper! {
pub struct CursorScene(ObjectSubclass<cursor_scene_imp::CursorScene>)
@extends gtk::Widget,
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
}

impl CursorScene {
pub(super) fn new(
picture: &gtk::Picture,
ui_state: Rc<RefCell<UiState>>,
mouse_mode: Rc<RefCell<MouseMode>>,
) -> Self {
let scene: Self = glib::Object::builder().build();
{
let mut state = scene.imp().state.borrow_mut();
state.picture = Some(picture.clone());
state.ui_state = Some(ui_state);
state.mouse_mode = Some(mouse_mode);
}
scene.set_can_target(false);
scene
}

pub(super) fn set_shape(&self, shape: Option<(gdk::Texture, i32, i32)>) {
{
let mut state = self.imp().state.borrow_mut();
match shape {
Some((texture, hotspot_x, hotspot_y)) => {
state.texture = Some(texture);
state.hotspot = (hotspot_x, hotspot_y);
}
None => state.texture = None,
}
}
self.queue_draw();
}

pub(super) fn set_cursor_visible(&self, visible: bool) {
self.imp().state.borrow_mut().cursor_visible = visible;
self.queue_draw();
}

pub(super) fn set_pointer(&self, pointer: Option<(f64, f64)>) {
self.imp().state.borrow_mut().pointer = pointer;
self.queue_draw();
}
}

/// Follow the local pointer over the picture. Unlike the input controllers in
/// `mouse.rs` this is not gated on the input grab: the drawn cursor replaces
/// the host cursor, which also follows the pointer unconditionally.
pub(super) fn track_pointer(picture: &gtk::Picture, scene: &CursorScene) {
let motion = gtk::EventControllerMotion::new();
motion.connect_enter({
let scene = scene.clone();
move |_, x, y| scene.set_pointer(Some((x, y)))
});
motion.connect_motion({
let scene = scene.clone();
move |_, x, y| scene.set_pointer(Some((x, y)))
});
motion.connect_leave({
let scene = scene.clone();
move |_| scene.set_pointer(None)
});
picture.add_controller(motion);
}
12 changes: 12 additions & 0 deletions src/viewer/framebuffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -703,13 +703,22 @@ fn premultiply(channel: u8, alpha: u8) -> u8 {
pub(super) struct FrameStreamHandler {
event_tx: EventSender,
framebuffer: Option<Framebuffer>,
disable_notice_pending: bool,
}

impl FrameStreamHandler {
pub(super) fn new(event_tx: EventSender) -> Self {
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("");
}
}

Expand Down Expand Up @@ -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.");
}

Expand Down
19 changes: 19 additions & 0 deletions src/viewer/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,25 @@ impl Hotkey {
}
}

/// Whether the binding is a modifier-only chord (like the default
/// `Ctrl+Alt`). Those must not fire on press: half of every
/// `Ctrl+Alt+<key>` guest shortcut would trigger them. The keyboard
/// controller arms them on press and fires on a clean release instead.
pub(super) fn is_modifier_chord(&self) -> bool {
matches!(self, Self::Modifiers { .. })
}

/// Whether this key is one of the modifiers making up a modifier-only
/// binding (used for release-time chord detection).
pub(super) fn chord_member(&self, keyval: gdk::Key) -> bool {
match self {
Self::Modifiers { modifiers, .. } => {
modifier_mask_for_key(keyval).is_some_and(|mask| modifiers.contains(mask))
}
_ => false,
}
}

pub(super) fn accelerator(&self) -> Option<&str> {
match self {
Self::Disabled => None,
Expand Down
Loading