rust: RAW, IRC, GitHub and VNC views

This commit is contained in:
Andy Dixon
2026-10-11 17:35:15 +01:00
parent 43acc57c51
commit 3ed4454081
4 changed files with 1761 additions and 33 deletions
+384 -7
View File
@@ -1,22 +1,399 @@
//! VNC viewer: the remote framebuffer as a texture, with mouse and keyboard input.
//! STUB — interface fixed; implementation pending.
//! 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 {
VncView { session: session.into() }
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) {}
pub fn on_ready(&mut self) {
self.ready = true;
self.client = None;
}
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
ui.label("VNC");
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));
}
}