Files
nexus/rust/app/src/vnc_view.rs
T

406 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! VNC viewer: the remote framebuffer as a texture, with mouse, keyboard and clipboard input.
//! Toolbar after the web build's `vnc.js` (status, Ctrl+Alt+Del, clipboard), plus refresh and a
//! scale toggle.
use crate::state::Shared;
use crate::theme::{self, Tokens};
use crate::widgets;
use egui::{Event, Key, Modifiers, Pos2, Rect, RichText, Sense, TextureHandle, Ui, vec2};
use egui_phosphor::regular as icon;
use nexus_core::clients::vnc::{self, keysym_for};
use nexus_core::models::Profile;
use std::sync::Arc;
const XK_SHIFT: u32 = 0xffe1;
const XK_CONTROL: u32 = 0xffe3;
const XK_ALT: u32 = 0xffe9;
const XK_SUPER: u32 = 0xffeb;
const XK_DELETE: u32 = 0xffff;
/// Wheel distance (points) per remote scroll notch for pixel-precise wheels.
const NOTCH: f32 = 40.0;
pub struct VncView {
pub session: String,
view_only: bool,
scale_to_fit: bool,
ready: bool,
client: Option<Arc<vnc::Client>>,
texture: Option<TextureHandle>,
fb_size: [usize; 2],
name: String,
/// Modifier keysyms currently held on the remote side.
held: Vec<u32>,
buttons: u8,
last_pos: (u16, u16),
wheel: f32,
had_focus: bool,
}
/// Keysym for a non-printing key (arrows, F-keys, Enter…); printable keys arrive as `Event::Text`.
fn named_keysym(key: Key) -> Option<u32> {
let name = key.name();
if name.chars().count() == 1 || key == Key::Space {
return None;
}
keysym_for(name, None)
}
/// Keysym for a printable key pressed with Ctrl/Alt/Cmd (egui sends no text for those).
fn chord_keysym(key: Key, shift: bool) -> Option<u32> {
if key == Key::Space {
return Some(0x20);
}
let mut chars = key.symbol_or_name().chars();
let c = chars.next()?;
if chars.next().is_some() {
return None;
}
keysym_for("", Some(if shift { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() }))
}
/// Modifier keysyms that should be held for `m`.
fn modifier_syms(m: Modifiers) -> Vec<u32> {
let mut v = vec![];
if m.shift {
v.push(XK_SHIFT);
}
if m.ctrl {
v.push(XK_CONTROL);
}
if m.alt {
v.push(XK_ALT);
}
if m.mac_cmd {
v.push(XK_SUPER);
}
v
}
/// Vertical wheel movement this frame in notches (positive = up).
fn wheel_notches(events: &[Event]) -> f32 {
events
.iter()
.map(|e| match e {
Event::MouseWheel { unit, delta, .. } => match unit {
egui::MouseWheelUnit::Point => delta.y / NOTCH,
egui::MouseWheelUnit::Line => delta.y,
egui::MouseWheelUnit::Page => delta.y * 5.0,
},
_ => 0.0,
})
.sum()
}
/// Pointer button mask: bit 0 left, 1 middle, 2 right.
fn button_mask(left: bool, middle: bool, right: bool) -> u8 {
left as u8 | (middle as u8) << 1 | (right as u8) << 2
}
/// A screen position over the drawn image → framebuffer coordinates (clamped).
fn to_remote(p: Pos2, img: Rect, fb: [usize; 2]) -> (u16, u16) {
let sx = fb[0] as f32 / img.width().max(1.0);
let sy = fb[1] as f32 / img.height().max(1.0);
let x = ((p.x - img.left()) * sx).clamp(0.0, fb[0].saturating_sub(1) as f32);
let y = ((p.y - img.top()) * sy).clamp(0.0, fb[1].saturating_sub(1) as f32);
(x as u16, y as u16)
}
impl VncView {
pub fn new(session: &str, profile: &Profile) -> VncView {
let cfg = profile.vnc.clone().unwrap_or_default();
VncView {
session: session.into(),
view_only: cfg.view_only,
scale_to_fit: cfg.scale_to_fit,
ready: false,
client: None,
texture: None,
fb_size: [0, 0],
name: String::new(),
held: vec![],
buttons: 0,
last_pos: (u16::MAX, u16::MAX),
wheel: 0.0,
had_focus: false,
}
}
/// Called on `Event::VncReady` (the client is connected; fetch it via `s.svc.sessions.vnc(id)`).
pub fn on_ready(&mut self) {
self.ready = true;
self.client = None;
}
fn key(&self, sym: u32, down: bool) {
if let Some(c) = &self.client {
let _ = c.key(sym, down);
}
}
fn tap(&self, syms: &[u32]) {
syms.iter().for_each(|&k| self.key(k, true));
syms.iter().rev().for_each(|&k| self.key(k, false));
}
fn sync_modifiers(&mut self, want: Vec<u32>) {
for k in self.held.clone() {
if !want.contains(&k) {
self.key(k, false);
}
}
for &k in &want {
if !self.held.contains(&k) {
self.key(k, true);
}
}
self.held = want;
}
fn release_all(&mut self) {
self.sync_modifiers(vec![]);
if self.buttons != 0
&& let Some(c) = &self.client
{
let _ = c.pointer(self.last_pos.0, self.last_pos.1, 0);
}
self.buttons = 0;
}
fn paste(&self, s: &mut Shared, text: &str) {
if let Some(c) = &self.client
&& let Err(e) = c.send_clipboard(text)
{
s.error(format!("Clipboard: {e}"));
}
}
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
if self.ready && self.client.is_none() {
self.client = s.svc.sessions.vnc(&self.session).ok();
if let Some(c) = &self.client {
self.name = c.desktop_name();
}
}
self.upload(ui);
self.toolbar(ui, s);
let t = Tokens::get(ui.ctx());
let area = ui.available_rect_before_wrap();
ui.painter().rect_filled(area, 0.0, t.canvas);
let Some(tex) = self.texture.clone().filter(|_| self.ready) else {
ui.allocate_rect(area, Sense::hover());
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area));
c.vertical_centered(|ui| {
ui.add_space(area.height() / 2.0 - 30.0);
ui.add(egui::Spinner::new().size(22.0));
ui.label(RichText::new(if self.ready { "Waiting for the first frame…" } else { "Connecting…" }).color(t.text_dim));
});
return;
};
let fb = self.fb_size;
let size = vec2(fb[0] as f32, fb[1] as f32);
if self.scale_to_fit {
ui.allocate_rect(area, Sense::hover());
let scale = (area.width() / size.x).min(area.height() / size.y);
let img = Rect::from_center_size(area.center(), size * scale);
self.draw(ui, s, &tex, img);
} else {
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(area));
egui::ScrollArea::both().id_salt(("vnc", &self.session)).auto_shrink([false, false]).show(&mut c, |ui| {
let (img, _) = ui.allocate_exact_size(size, Sense::hover());
self.draw(ui, s, &tex, img);
});
ui.allocate_rect(area, Sense::hover());
}
}
/// Uploads the framebuffer when the client marked it dirty.
fn upload(&mut self, ui: &Ui) {
let Some(c) = &self.client else { return };
let fb = c.framebuffer();
let mut fb = fb.lock().unwrap_or_else(|e| e.into_inner());
if !fb.dirty || fb.width == 0 || fb.height == 0 {
return;
}
let size = [fb.width as usize, fb.height as usize];
// Alpha is always 255, so the buffer is already premultiplied.
let img = egui::ColorImage::from_rgba_premultiplied(size, &fb.rgba);
fb.dirty = false;
drop(fb);
self.fb_size = size;
match &mut self.texture {
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
None => self.texture = Some(ui.ctx().load_texture(format!("vnc-{}", self.session), img, egui::TextureOptions::LINEAR)),
}
}
fn toolbar(&mut self, ui: &mut Ui, s: &mut Shared) {
let t = Tokens::get(ui.ctx());
egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (dot, status) = match (self.ready, self.name.is_empty()) {
(false, _) => (t.warning, "Connecting…".to_string()),
(true, true) => (t.success, "Connected".to_string()),
(true, false) => (t.success, format!("Connected — {}", self.name)),
};
ui.label(RichText::new("●").color(dot).size(10.0));
ui.label(RichText::new(status).font(theme::semibold(12.5)));
if self.fb_size[0] > 0 {
ui.label(RichText::new(format!("{}×{}", self.fb_size[0], self.fb_size[1])).color(t.text_faint).size(11.5));
}
if self.view_only {
widgets::chip(ui, "VIEW ONLY", t.warning).on_hover_text("Input is not sent to the remote desktop");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_enabled_ui(self.ready, |ui| {
let (g, tip) = if self.scale_to_fit {
(icon::CORNERS_IN, "Scaled to fit (click for 1:1)")
} else {
(icon::CORNERS_OUT, "1:1 (click to scale to fit)")
};
if widgets::icon_button(ui, g, tip).clicked() {
self.scale_to_fit = !self.scale_to_fit;
}
if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh the screen").clicked()
&& let Some(c) = &self.client
{
let _ = c.request_full_update();
}
if !self.view_only {
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard")
.on_hover_text("Send the local clipboard to the remote desktop")
.clicked()
{
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
Ok(text) if !text.is_empty() => {
self.paste(s, &text);
s.info("Clipboard sent");
}
_ => s.warn("The clipboard has no text"),
}
}
if widgets::button(ui, icon::KEYBOARD, "Ctrl+Alt+Del").clicked() {
self.tap(&[XK_CONTROL, XK_ALT, XK_DELETE]);
}
}
});
});
});
});
}
fn draw(&mut self, ui: &mut Ui, s: &mut Shared, tex: &TextureHandle, img: Rect) {
ui.painter().image(tex.id(), img, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), egui::Color32::WHITE);
let id = egui::Id::new(("vnc-input", &self.session));
let resp = ui.interact(img, id, Sense::click_and_drag());
if resp.clicked() || resp.drag_started() || resp.secondary_clicked() || resp.middle_clicked() {
resp.request_focus();
}
let focused = resp.has_focus();
if focused {
ui.painter().rect_stroke(img, 0.0, egui::Stroke::new(1.0, Tokens::get(ui.ctx()).accent.gamma_multiply(0.6)), egui::StrokeKind::Outside);
ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
} else if self.had_focus {
self.release_all();
}
self.had_focus = focused;
if self.view_only || self.client.is_none() {
return;
}
self.pointer(ui, &resp, img);
if focused {
self.keyboard(ui, s);
}
}
fn pointer(&mut self, ui: &Ui, resp: &egui::Response, img: Rect) {
let Some(c) = self.client.clone() else { return };
let (pos, left, middle, right, scroll) = ui.input(|i| {
let p = &i.pointer;
(p.latest_pos(), p.primary_down(), p.middle_down(), p.secondary_down(), wheel_notches(&i.events))
});
let Some(pos) = pos else { return };
// Only while hovering the image, or while a drag that began on it continues.
if !resp.hovered() && !resp.dragged() && self.buttons == 0 {
return;
}
let buttons = if resp.hovered() || resp.dragged() || self.buttons != 0 { button_mask(left, middle, right) } else { 0 };
let at = to_remote(pos, img, self.fb_size);
if at != self.last_pos || buttons != self.buttons {
let _ = c.pointer(at.0, at.1, buttons);
self.last_pos = at;
self.buttons = buttons;
}
if resp.hovered() && scroll != 0.0 {
self.wheel += scroll;
while self.wheel.abs() >= 1.0 {
let bit = if self.wheel > 0.0 { 1 << 3 } else { 1 << 4 };
let _ = c.pointer(at.0, at.1, buttons | bit);
let _ = c.pointer(at.0, at.1, buttons);
self.wheel -= self.wheel.signum();
}
}
}
fn keyboard(&mut self, ui: &Ui, s: &mut Shared) {
let (events, mods) = ui.input(|i| (i.events.clone(), i.modifiers));
self.sync_modifiers(modifier_syms(mods));
for e in events {
match e {
Event::Key { key, pressed, modifiers, .. } => {
let sym = named_keysym(key)
.or_else(|| (modifiers.ctrl || modifiers.alt || modifiers.mac_cmd).then(|| chord_keysym(key, modifiers.shift)).flatten());
if let Some(sym) = sym {
self.key(sym, pressed);
}
}
Event::Text(text) => {
for ch in text.chars() {
if let Some(sym) = keysym_for("", Some(ch)) {
self.tap(&[sym]);
}
}
}
// egui turns Ctrl+C/X/V into these; forward the keystroke (and the text for paste).
Event::Copy => self.tap(&[u32::from(b'c')]),
Event::Cut => self.tap(&[u32::from(b'x')]),
Event::Paste(text) => {
self.paste(s, &text);
self.tap(&[u32::from(b'v')]);
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_map_to_keysyms() {
assert_eq!(named_keysym(Key::Enter), Some(0xff0d));
assert_eq!(named_keysym(Key::ArrowLeft), Some(0xff51));
assert_eq!(named_keysym(Key::F5), Some(0xffc2));
assert_eq!(named_keysym(Key::A), None);
assert_eq!(named_keysym(Key::Space), None);
assert_eq!(named_keysym(Key::Comma), None);
assert_eq!(chord_keysym(Key::A, false), Some(u32::from(b'a')));
assert_eq!(chord_keysym(Key::A, true), Some(u32::from(b'A')));
assert_eq!(chord_keysym(Key::Num1, false), Some(u32::from(b'1')));
assert_eq!(chord_keysym(Key::Space, false), Some(0x20));
assert_eq!(modifier_syms(Modifiers::CTRL | Modifiers::SHIFT), vec![XK_SHIFT, XK_CONTROL]);
}
#[test]
fn pointer_mapping() {
assert_eq!(button_mask(true, false, true), 0b101);
let img = Rect::from_min_size(Pos2::new(100.0, 50.0), vec2(400.0, 300.0));
assert_eq!(to_remote(Pos2::new(100.0, 50.0), img, [800, 600]), (0, 0));
assert_eq!(to_remote(Pos2::new(300.0, 200.0), img, [800, 600]), (400, 300));
assert_eq!(to_remote(Pos2::new(900.0, 900.0), img, [800, 600]), (799, 599));
}
}