From 3ed4454081dea6be8a6f3ffd90fb9a4f5a7e295f Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 11 Oct 2026 17:35:15 +0100 Subject: [PATCH] rust: RAW, IRC, GitHub and VNC views --- rust/app/src/github_view.rs | 360 +++++++++++++++++++++++- rust/app/src/irc_view.rs | 530 +++++++++++++++++++++++++++++++++++- rust/app/src/raw_view.rs | 513 +++++++++++++++++++++++++++++++++- rust/app/src/vnc_view.rs | 391 +++++++++++++++++++++++++- 4 files changed, 1761 insertions(+), 33 deletions(-) diff --git a/rust/app/src/github_view.rs b/rust/app/src/github_view.rs index 0b9a472..583598d 100644 --- a/rust/app/src/github_view.rs +++ b/rust/app/src/github_view.rs @@ -1,25 +1,367 @@ -//! GitHub repository browser. -//! STUB — interface fixed; implementation pending. +//! GitHub repository browser: owner groups of repo cards with ZIP download and Visit. Port of +//! `github.js`. use crate::state::Shared; +use crate::theme::{self, Tokens}; +use crate::widgets; +use chrono::{DateTime, Utc}; +use egui::{Color32, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; use nexus_core::models::GitHubRepo; +use std::sync::{Arc, Mutex}; + +enum Msg { + Repos(Vec), + RefreshFailed(String), + Downloaded(String, String), + DownloadFailed(String), +} pub struct GithubView { pub session: String, + title: String, + login: String, + repos: Vec, + filter: String, + status: String, + status_error: bool, + inbox: Arc>>, +} + +fn parse_time(s: &str) -> Option> { + DateTime::parse_from_rfc3339(s).ok().map(|d| d.with_timezone(&Utc)) +} + +/// Latest commit, else push, else update time, in unix ms (0 when unknown). +fn commit_ts(r: &GitHubRepo) -> i64 { + [&r.latest_commit_at, &r.pushed_at, &r.updated_at].into_iter().find(|s| !s.is_empty()).and_then(|s| parse_time(s)).map_or(0, |d| d.timestamp_millis()) +} + +/// Filters, groups by owner (repos newest first), and orders groups by their newest repo with +/// the user's own login first on ties, then by name. +fn grouped<'a>(repos: &'a [GitHubRepo], login: &str, filter: &str) -> Vec<(String, Vec<&'a GitHubRepo>)> { + let f = filter.to_lowercase(); + let mut groups: Vec<(String, Vec<&GitHubRepo>)> = Vec::new(); + for r in repos.iter().filter(|r| { + f.is_empty() || r.full_name.to_lowercase().contains(&f) || r.description.to_lowercase().contains(&f) || r.language.to_lowercase().contains(&f) + }) { + let owner = if r.owner.is_empty() { "unknown" } else { &r.owner }; + match groups.iter_mut().find(|(o, _)| o == owner) { + Some((_, v)) => v.push(r), + None => groups.push((owner.to_string(), vec![r])), + } + } + for (_, v) in &mut groups { + v.sort_by_key(|r| std::cmp::Reverse(commit_ts(r))); + } + let newest = |v: &[&GitHubRepo]| v.first().map_or(0, |r| commit_ts(r)); + groups.sort_by(|(a, av), (b, bv)| newest(bv).cmp(&newest(av)).then_with(|| (b == login).cmp(&(a == login))).then_with(|| a.cmp(b))); + groups +} + +/// "just now", "3 minutes ago", "1 day ago"… (the web build's relativeTime). +fn relative_time(iso: &str, now: DateTime) -> String { + let Some(d) = parse_time(iso) else { return String::new() }; + let secs = (now - d).num_seconds(); + if secs < 60 { + return "just now".into(); + } + let plural = |n: i64, unit: &str| if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") }; + let mins = secs / 60; + let hrs = mins / 60; + let days = hrs / 24; + let months = days / 30; + if mins < 60 { + plural(mins, "minute") + } else if hrs < 24 { + plural(hrs, "hour") + } else if days < 30 { + plural(days, "day") + } else if months < 12 { + plural(months, "month") + } else { + plural(months / 12, "year") + } +} + +fn local_time(iso: &str) -> String { + parse_time(iso).map(|d| d.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| iso.to_string()) +} + +/// A subset of GitHub's linguist colours. +fn lang_colour(lang: &str) -> Color32 { + let hex = match lang { + "JavaScript" => 0xf1e05a, + "TypeScript" => 0x3178c6, + "Python" => 0x3572a5, + "Go" => 0x00add8, + "Rust" => 0xdea584, + "Java" => 0xb07219, + "C" => 0x555555, + "C++" => 0xf34b7d, + "C#" => 0x178600, + "Ruby" => 0x701516, + "PHP" => 0x4f5d95, + "Swift" => 0xf05138, + "Kotlin" => 0xa97bff, + "Shell" => 0x89e051, + "HTML" => 0xe34c26, + "CSS" => 0x563d7c, + "Dart" => 0x00b4ab, + "Lua" => 0x000080, + "Scala" => 0xc22d40, + "Elixir" => 0x6e4a7e, + "Haskell" => 0x5e5086, + "Zig" => 0xec915c, + "Vue" => 0x41b883, + "SCSS" => 0xc6538c, + _ => 0x8b949e, + }; + Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8) +} + +enum Action { + Zip(GitHubRepo), + Visit(String), + Copy(String), } impl GithubView { - pub fn new(session: &str, _title: &str) -> GithubView { - GithubView { session: session.into() } + pub fn new(session: &str, title: &str) -> GithubView { + GithubView { + session: session.into(), + title: if title.is_empty() { "GitHub".into() } else { title.into() }, + login: String::new(), + repos: vec![], + filter: String::new(), + status: "Connecting…".into(), + status_error: false, + inbox: Default::default(), + } } - pub fn on_status(&mut self, _status: String) {} + pub fn on_status(&mut self, status: String) { + self.status = status; + self.status_error = false; + } - pub fn on_repos(&mut self, _login: String, _repos: Vec) {} + pub fn on_repos(&mut self, login: String, repos: Vec) { + self.login = login; + self.repos = repos; + self.update_status(); + } - pub fn on_error(&mut self, _error: String) {} + pub fn on_error(&mut self, error: String) { + self.status = format!("{} {error}", icon::WARNING); + self.status_error = true; + } - pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) { - ui.label("GitHub"); + fn update_status(&mut self) { + let n = self.repos.len(); + let now = chrono::Local::now().format("%H:%M:%S"); + self.on_status(format!("{} · {n} repositor{} · updated {now}", self.login, if n == 1 { "y" } else { "ies" })); + } + + fn refresh(&mut self, s: &Shared) { + self.on_status("Refreshing…".into()); + let (svc, sid, inbox, ctx) = (s.svc.clone(), self.session.clone(), self.inbox.clone(), s.ctx.clone()); + std::thread::spawn(move || { + let msg = match svc.sessions.github(&sid).and_then(|c| c.fetch_repos()) { + Ok(r) => Msg::Repos(r), + Err(e) => Msg::RefreshFailed(e.to_string()), + }; + inbox.lock().unwrap().push(msg); + ctx.request_repaint(); + }); + } + + fn download(&mut self, s: &Shared, repo: &GitHubRepo) { + let reference = repo.default_branch.clone(); + let name = if reference.is_empty() { format!("{}.zip", repo.name) } else { format!("{}-{reference}.zip", repo.name) }; + let Some(path) = rfd::FileDialog::new().set_file_name(name).add_filter("ZIP archive", &["zip"]).save_file() else { return }; + self.on_status(format!("Downloading {}...", repo.full_name)); + let (svc, sid, inbox, ctx, full) = (s.svc.clone(), self.session.clone(), self.inbox.clone(), s.ctx.clone(), repo.full_name.clone()); + std::thread::spawn(move || { + let msg = match svc.sessions.github(&sid).and_then(|c| c.download_repo_archive(&full, &reference, &path)) { + Ok(()) => Msg::Downloaded(full, path.display().to_string()), + Err(e) => Msg::DownloadFailed(e.to_string()), + }; + inbox.lock().unwrap().push(msg); + ctx.request_repaint(); + }); + } + + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + let msgs = std::mem::take(&mut *self.inbox.lock().unwrap()); + for m in msgs { + match m { + Msg::Repos(r) => { + let login = self.login.clone(); + self.on_repos(login, r); + } + Msg::RefreshFailed(e) => self.on_error(e), + Msg::Downloaded(name, path) => { + self.update_status(); + s.success(format!("Downloaded {name} to {path}")); + } + Msg::DownloadFailed(e) => { + self.on_error(format!("Download failed: {e}")); + s.error(format!("Download failed: {e}")); + } + } + } + + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(12, 8)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(icon::GITHUB_LOGO).size(18.0)); + ui.label(RichText::new(&self.title).font(theme::semibold(14.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh now").clicked() { + self.refresh(s); + } + ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text(format!("{} Filter repositories…", icon::MAGNIFYING_GLASS)).desired_width(240.0)); + }); + }); + let c = if self.status_error { t.danger } else { t.text_dim }; + ui.label(RichText::new(&self.status).color(c).size(11.5)); + }); + + let mut action = None; + egui::ScrollArea::vertical().id_salt(("gh", &self.session)).auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(8.0); + let groups = grouped(&self.repos, &self.login, &self.filter); + if groups.is_empty() { + let msg = if self.filter.is_empty() { "No repositories found." } else { "No matching repositories." }; + widgets::empty(ui, icon::GITHUB_LOGO, msg, ""); + return; + } + let now = Utc::now(); + egui::Frame::new().inner_margin(egui::Margin::symmetric(14, 0)).show(ui, |ui| { + for (owner, repos) in groups { + ui.horizontal(|ui| { + ui.label(RichText::new(&owner).font(theme::semibold(13.0))); + widgets::chip(ui, &repos.len().to_string(), t.text_dim); + }); + ui.add_space(2.0); + for r in repos { + if let Some(a) = card(ui, r, now) { + action = Some(a); + } + ui.add_space(6.0); + } + ui.add_space(8.0); + } + }); + }); + match action { + Some(Action::Zip(r)) => self.download(s, &r), + Some(Action::Visit(url)) if !url.is_empty() => ui.ctx().open_url(egui::OpenUrl::new_tab(url)), + Some(Action::Copy(url)) => { + ui.ctx().copy_text(url); + s.info("URL copied"); + } + _ => {} + } + } +} + +fn card(ui: &mut Ui, r: &GitHubRepo, now: DateTime) -> Option { + let t = Tokens::get(ui.ctx()); + let mut action = None; + let resp = egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.card_border)).corner_radius(6).inner_margin(egui::Margin::symmetric(12, 9)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(&r.name).font(theme::semibold(13.5)).color(t.accent)); + if r.private { + widgets::chip(ui, "Private", t.warning); + } + if r.fork { + widgets::chip(ui, "Fork", t.text_dim); + } + if r.archived { + widgets::chip(ui, "Archived", t.danger); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).on_hover_text("Open repository in browser").clicked() { + action = Some(Action::Visit(r.html_url.clone())); + } + if ui.button(format!("{} ZIP", icon::FILE_ZIP)).on_hover_text("Download repository as zip").clicked() { + action = Some(Action::Zip(r.clone())); + } + }); + }); + if !r.latest_committer.is_empty() && !r.latest_commit_at.is_empty() { + ui.label(RichText::new(format!("Last commit: {} by {}", local_time(&r.latest_commit_at), r.latest_committer)).color(t.text_faint).size(11.5)); + } + if !r.description.is_empty() { + ui.label(RichText::new(&r.description).color(t.text_dim)); + } + ui.horizontal(|ui| { + if !r.language.is_empty() { + let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(dot.center(), 5.0, lang_colour(&r.language)); + ui.label(RichText::new(&r.language).color(t.text_dim).size(11.5)); + ui.add_space(8.0); + } + if r.stars > 0 { + ui.label(RichText::new(format!("{} {}", icon::STAR, widgets::group(r.stars as u64))).color(t.text_dim).size(11.5)); + ui.add_space(8.0); + } + if !r.pushed_at.is_empty() { + ui.label(RichText::new(relative_time(&r.pushed_at, now)).color(t.text_faint).size(11.5)).on_hover_text(local_time(&r.pushed_at)); + } + }); + }); + let resp = resp.response.interact(Sense::click()); + if resp.double_clicked() { + action = Some(Action::Visit(r.html_url.clone())); + } + resp.context_menu(|ui| { + if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).clicked() { + action = Some(Action::Visit(r.html_url.clone())); + } + if ui.button(format!("{} Download ZIP…", icon::FILE_ZIP)).clicked() { + action = Some(Action::Zip(r.clone())); + } + if ui.button(format!("{} Copy URL", icon::COPY)).clicked() { + action = Some(Action::Copy(r.html_url.clone())); + } + }); + action +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo(owner: &str, name: &str, at: &str) -> GitHubRepo { + GitHubRepo { owner: owner.into(), name: name.into(), full_name: format!("{owner}/{name}"), pushed_at: at.into(), ..Default::default() } + } + + #[test] + fn groups_by_newest_with_login_first_on_ties() { + let repos = vec![ + repo("org", "a", "2024-01-01T00:00:00Z"), + repo("me", "old", "2023-01-01T00:00:00Z"), + repo("me", "new", "2024-01-01T00:00:00Z"), + repo("zed", "z", "2025-01-01T00:00:00Z"), + ]; + let g = grouped(&repos, "me", ""); + let names: Vec<(&str, Vec<&str>)> = g.iter().map(|(o, v)| (o.as_str(), v.iter().map(|r| r.name.as_str()).collect())).collect(); + assert_eq!(names, [("zed", vec!["z"]), ("me", vec!["new", "old"]), ("org", vec!["a"])]); + assert_eq!(grouped(&repos, "me", "OLD").len(), 1); + } + + #[test] + fn relative_times() { + let now = parse_time("2025-06-10T12:00:00Z").unwrap(); + assert_eq!(relative_time("2025-06-10T11:59:30Z", now), "just now"); + assert_eq!(relative_time("2025-06-10T11:59:00Z", now), "1 minute ago"); + assert_eq!(relative_time("2025-06-10T09:00:00Z", now), "3 hours ago"); + assert_eq!(relative_time("2025-06-01T12:00:00Z", now), "9 days ago"); + assert_eq!(relative_time("2023-06-01T12:00:00Z", now), "2 years ago"); + assert_eq!(relative_time("bad", now), ""); } } diff --git a/rust/app/src/irc_view.rs b/rust/app/src/irc_view.rs index 76dc455..a4c974d 100644 --- a/rust/app/src/irc_view.rs +++ b/rust/app/src/irc_view.rs @@ -1,23 +1,535 @@ -//! IRC client view: room tabs, message log, composer and user/room lists. -//! STUB — interface fixed; implementation pending. +//! IRC client view: room tabs, message log, composer and room/user lists. Port of `irc.js`; +//! lines are `ircformat::format_event` spans drawn in the terminal theme's colours. use crate::state::Shared; -use nexus_core::clients::irc; +use crate::terminal_themes::TermTheme; +use crate::theme::Tokens; +use crate::widgets; +use egui::text::LayoutJob; +use egui::{Color32, FontId, Galley, Rect, RichText, Sense, Stroke, TextFormat, Ui, pos2, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::clients::irc::{self, RoomInfo}; +use nexus_core::ircformat::{self, Span}; use nexus_core::models::Profile; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +const SERVER: &str = "server"; +const MAX_LINES: usize = 2000; +const SIDEBAR_W: f32 = 230.0; + +struct Line { + spans: Vec, + /// Laid-out line for the wrap width and theme it was built with. + galley: Option<(f32, &'static str, Arc)>, +} + +struct Target { + name: String, + lines: VecDeque, +} + +/// Where an event goes (irc.js `handleEvent`). +#[derive(Debug, PartialEq)] +enum Route { + Rooms, + Users(String), + RoomList, + Line(String), +} + +fn route(e: &irc::Event) -> Route { + match e.kind.as_str() { + "rooms" => Route::Rooms, + "users" => Route::Users(e.target.clone()), + "room-list" => Route::RoomList, + _ => Route::Line(if e.target.is_empty() { SERVER.into() } else { e.target.clone() }), + } +} + +fn is_room(t: &str) -> bool { + t.starts_with(['#', '&', '+', '!']) +} + +fn display_nick(u: &str) -> &str { + u.trim_start_matches(['~', '&', '@', '%', '+']) +} + +/// Tab completion of the last word: `@pre` → `@nick `, `pre` → `nick ` (first match, case-insensitive). +fn complete(input: &str, users: &[String]) -> Option { + let start = input.rfind(char::is_whitespace).map_or(0, |i| i + 1); + let word = &input[start..]; + let (at, prefix) = match word.strip_prefix('@') { + Some(p) => ("@", p), + None => ("", word), + }; + if prefix.contains('@') || (at.is_empty() && prefix.is_empty()) { + return None; + } + let lower = prefix.to_lowercase(); + let nick = users.iter().map(|u| display_nick(u)).find(|n| n.to_lowercase().starts_with(&lower))?; + Some(format!("{}{at}{nick} ", &input[..start])) +} + +fn time(ms: i64) -> String { + use chrono::{Local, TimeZone}; + let d = if ms > 0 { Local.timestamp_millis_opt(ms).single() } else { None }.unwrap_or_else(Local::now); + d.format("%H:%M:%S").to_string() +} + +/// xterm-256 palette: the theme's 16 colours, the 6×6×6 cube, then the grey ramp. +fn xterm(i: u8, theme: &TermTheme) -> Color32 { + let i = i as usize; + if i < 16 { + let [r, g, b, _] = theme.ansi[i]; + return Color32::from_rgb(r, g, b); + } + if i < 232 { + let i = i - 16; + let v = |x: usize| if x == 0 { 0 } else { (55 + x * 40) as u8 }; + return Color32::from_rgb(v(i / 36), v((i / 6) % 6), v(i % 6)); + } + let g = (8 + (i - 232) * 10) as u8; + Color32::from_rgb(g, g, g) +} + +fn rgb4(c: [u8; 4]) -> Color32 { + Color32::from_rgb(c[0], c[1], c[2]) +} + +fn layout(spans: &[Span], theme: &TermTheme, font: &FontId, wrap: f32) -> LayoutJob { + let mut job = LayoutJob { wrap: egui::text::TextWrapping { max_width: wrap, ..Default::default() }, ..Default::default() }; + let (dfg, dbg) = (rgb4(theme.foreground), Color32::TRANSPARENT); + for s in spans { + let st = s.style; + let mut fg = st.fg_rgb.map(|[r, g, b]| Color32::from_rgb(r, g, b)).or(st.fg.map(|i| xterm(i, theme))).unwrap_or(dfg); + let mut bg = st.bg_rgb.map(|[r, g, b]| Color32::from_rgb(r, g, b)).or(st.bg.map(|i| xterm(i, theme))).unwrap_or(dbg); + if st.reverse { + (fg, bg) = (if bg == Color32::TRANSPARENT { rgb4(theme.background) } else { bg }, fg); + } + if st.dim { + fg = fg.gamma_multiply(0.65); + } + let line = Stroke::new(1.0, fg); + job.append( + &s.text, + 0.0, + TextFormat { + font_id: font.clone(), + color: fg, + background: bg, + italics: st.italic, + underline: if st.underline { line } else { Stroke::NONE }, + strikethrough: if st.strike { line } else { Stroke::NONE }, + ..Default::default() + }, + ); + } + job +} pub struct IrcView { pub session: String, + targets: Vec, + active: String, + unread: HashSet, + users: HashMap>, + joined: Vec, + room_list: Vec, + listing: bool, + room_filter: String, + join_input: String, + input: String, + refreshed: bool, + focus_composer: bool, + font_size: f32, } impl IrcView { - pub fn new(session: &str, _profile: &Profile) -> IrcView { - IrcView { session: session.into() } + pub fn new(session: &str, profile: &Profile) -> IrcView { + let mut v = IrcView { + session: session.into(), + targets: vec![], + active: SERVER.into(), + unread: HashSet::new(), + users: HashMap::new(), + joined: vec![], + room_list: vec![], + listing: false, + room_filter: String::new(), + join_input: String::new(), + input: String::new(), + refreshed: false, + focus_composer: true, + font_size: if profile.terminal_font_size > 0 { profile.terminal_font_size as f32 } else { 13.0 }, + }; + v.append(SERVER, &irc::Event { kind: "system".into(), text: "Connecting...".into(), ..Default::default() }); + v } - pub fn on_event(&mut self, _event: irc::Event) {} + fn target(&mut self, name: &str) -> &mut Target { + let name = if name.is_empty() { SERVER } else { name }; + let i = match self.targets.iter().position(|t| t.name == name) { + Some(i) => i, + None => { + self.targets.push(Target { name: name.into(), lines: VecDeque::new() }); + self.targets.len() - 1 + } + }; + &mut self.targets[i] + } - /// True when an event for a room other than the visible one arrived (for the tab marker). - pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) { - ui.label("IRC"); + fn append(&mut self, target: &str, e: &irc::Event) { + let spans = ircformat::format_event(e, &time(e.timestamp)); + let t = self.target(target); + t.lines.push_back(Line { spans, galley: None }); + while t.lines.len() > MAX_LINES { + t.lines.pop_front(); + } + } + + pub fn on_event(&mut self, event: irc::Event) { + match route(&event) { + Route::Rooms => { + self.joined = event.rooms; + for r in self.joined.clone() { + self.target(&r.name); + } + } + Route::Users(target) => { + if !target.is_empty() { + self.users.insert(target, event.users); + } + } + Route::RoomList => { + self.room_list = event.rooms; + self.listing = false; + } + Route::Line(target) => { + self.append(&target, &event); + if target != self.active { + self.unread.insert(target); + } + } + } + } + + fn set_active(&mut self, target: &str) { + self.target(target); + self.active = target.into(); + self.unread.remove(target); + self.focus_composer = true; + } + + fn send(&mut self, s: &mut Shared) { + let text = std::mem::take(&mut self.input); + if text.trim().is_empty() { + return; + } + let r = s.svc.sessions.irc(&self.session).and_then(|c| c.handle_input(&self.active, &text)); + if let Err(e) = r { + s.error(e.to_string()); + let active = self.active.clone(); + self.append(&active, &irc::Event { kind: "error".into(), text: e.to_string(), ..Default::default() }); + } + } + + fn join(&mut self, s: &mut Shared, room: &str) { + if let Err(e) = s.svc.sessions.irc(&self.session).and_then(|c| c.join(room)) { + s.error(format!("Join failed: {e}")); + } + } + + fn insert_mention(&mut self, nick: &str) { + if !self.input.is_empty() && !self.input.ends_with(char::is_whitespace) { + self.input.push(' '); + } + self.input.push_str(&format!("@{nick} ")); + self.focus_composer = true; + } + + pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { + if !self.refreshed { + self.refreshed = true; + if let Ok(c) = s.svc.sessions.irc(&self.session) { + self.on_event(irc::Event { kind: "rooms".into(), rooms: c.rooms(), ..Default::default() }); + } + } + let t = Tokens::get(ui.ctx()); + let full = ui.available_rect_before_wrap(); + let side_w = SIDEBAR_W.min(full.width() * 0.4); + let main = Rect::from_min_max(full.min, pos2(full.right() - side_w, full.bottom())); + let side = Rect::from_min_max(pos2(main.right(), full.top()), full.max); + ui.allocate_rect(full, Sense::hover()); + + let tabs_h = 32.0; + let comp_h = 40.0; + let tabs = Rect::from_min_max(main.min, pos2(main.right(), main.top() + tabs_h)); + let log = Rect::from_min_max(pos2(main.left(), tabs.bottom()), pos2(main.right(), main.bottom() - comp_h)); + let comp = Rect::from_min_max(pos2(main.left(), log.bottom()), main.max); + + ui.painter().rect_filled(tabs, 0.0, t.tab_strip); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(tabs.shrink2(vec2(6.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + self.tabs(&mut c, s); + + let theme = crate::terminal_themes::get(&s.prefs.terminal_theme); + ui.painter().rect_filled(log, 0.0, rgb4(theme.background)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(log.shrink2(vec2(10.0, 6.0)))); + self.log(&mut c, theme); + + ui.painter().rect_filled(comp, 0.0, t.chrome); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(comp.shrink2(vec2(8.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + self.composer(&mut c, s); + + ui.painter().rect_filled(side, 0.0, t.dock); + ui.painter().line_segment([side.left_top(), side.left_bottom()], Stroke::new(1.0, t.separator)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(side.shrink2(vec2(10.0, 8.0)))); + self.sidebar(&mut c, s); + } + + fn tabs(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + egui::ScrollArea::horizontal().id_salt("irc-tabs").show(ui, |ui| { + ui.horizontal(|ui| { + let names: Vec = self.targets.iter().map(|t| t.name.clone()).collect(); + for name in names { + let label = if name == SERVER { format!("{} Server", icon::HARD_DRIVES) } else { name.clone() }; + let unread = self.unread.contains(&name); + let text = if unread { RichText::new(format!("{label} •")).color(t.accent) } else { RichText::new(label) }; + let r = ui.selectable_label(self.active == name, text); + if r.clicked() { + self.set_active(&name); + } + if is_room(&name) { + r.context_menu(|ui| { + if ui.button(format!("{} Leave {name}", icon::SIGN_OUT)).clicked() + && let Err(e) = s.svc.sessions.irc(&self.session).and_then(|c| c.part(&name)) + { + s.error(format!("Part failed: {e}")); + } + }); + } + } + }); + }); + } + + fn log(&mut self, ui: &mut Ui, theme: &'static TermTheme) { + let font = FontId::monospace(self.font_size); + let wrap = ui.available_width().max(100.0); + let Some(target) = self.targets.iter_mut().find(|t| t.name == self.active) else { return }; + // Lay out lines once per width/theme; the galley cache keeps scrolling cheap. + let mut tops = Vec::with_capacity(target.lines.len()); + let mut y = 0.0; + for line in target.lines.iter_mut() { + let stale = line.galley.as_ref().is_none_or(|(w, id, _)| *w != wrap || *id != theme.id); + if stale { + let g = ui.fonts_mut(|f| f.layout_job(layout(&line.spans, theme, &font, wrap))); + line.galley = Some((wrap, theme.id, g)); + } + tops.push(y); + y += line.galley.as_ref().map_or(0.0, |(_, _, g)| g.size().y) + 1.0; + } + let total = y; + egui::ScrollArea::vertical().id_salt(("irc-log", &self.active)).auto_shrink([false, false]).stick_to_bottom(true).show_viewport(ui, |ui, vp| { + let (rect, _) = ui.allocate_exact_size(vec2(wrap, total), Sense::hover()); + let first = tops.partition_point(|&top| top < vp.min.y).saturating_sub(1); + for (i, line) in target.lines.iter().enumerate().skip(first) { + if tops[i] > vp.max.y { + break; + } + if let Some((_, _, g)) = &line.galley { + ui.painter().galley(pos2(rect.left(), rect.top() + tops[i]), g.clone(), rgb4(theme.foreground)); + } + } + }); + } + + fn composer(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + let id = egui::Id::new(("irc-composer", &self.session)); + // Tab completes nicks instead of moving focus. + if ui.memory(|m| m.has_focus(id)) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) && is_room(&self.active) { + let users = self.users.get(&self.active).cloned().unwrap_or_default(); + if let Some(done) = complete(&self.input, &users) { + self.input = done; + self.move_cursor_to_end(ui, id); + } + } + let mut send = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + send |= ui.add(egui::Button::new(RichText::new(format!("{} Send", icon::PAPER_PLANE_RIGHT)).color(t.accent_text)).fill(t.accent)).clicked(); + let hint = if self.active == SERVER { "/command or /msg ".to_string() } else { format!("Message {} or /command", self.active) }; + let r = ui.add(egui::TextEdit::singleline(&mut self.input).id(id).hint_text(hint).desired_width(ui.available_width())); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + send = true; + self.focus_composer = true; + } + }); + if send { + self.send(s); + } + if std::mem::take(&mut self.focus_composer) { + ui.memory_mut(|m| m.request_focus(id)); + self.move_cursor_to_end(ui, id); + } + } + + fn move_cursor_to_end(&self, ui: &Ui, id: egui::Id) { + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), id) { + let end = egui::text::CCursor::new(self.input.chars().count()); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(end))); + st.store(ui.ctx(), id); + } + } + + fn sidebar(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + let total_h = ui.available_height(); + + widgets::section(ui, "Rooms"); + let mut join = false; + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut self.join_input).hint_text("#room").desired_width(ui.available_width() - 52.0)); + join |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + join |= ui.button("Join").clicked(); + }); + if join && !self.join_input.trim().is_empty() { + let room = self.join_input.trim().to_string(); + self.join(s, &room); + self.join_input.clear(); + } + let rows_h = 22.0; + if self.joined.is_empty() { + widgets::hint(ui, "No joined rooms"); + } else { + let joined = self.joined.clone(); + let h = (joined.len() as f32 * (rows_h + 2.0)).min(total_h * 0.22); + ui.allocate_ui(vec2(ui.available_width(), h), |ui| { + let sel = joined.iter().position(|r| r.name == self.active); + let clicked = widgets::rows(ui, "irc-joined", joined.len(), rows_h, sel, |ui, i| { + let r = &joined[i]; + ui.label(&r.name); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(r.users.to_string()).color(t.text_faint).size(11.0)); + }); + let tip = ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic }); + ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(tip); + }); + if let Some(i) = clicked { + self.set_active(&joined[i].name); + } + }); + } + + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::section(ui, "Room List"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button(format!("{} List", icon::LIST_BULLETS)).clicked() { + self.room_list.clear(); + self.listing = true; + if let Err(e) = s.svc.sessions.irc(&self.session).and_then(|c| c.list_rooms()) { + self.listing = false; + s.error(format!("Room list failed: {e}")); + } + } + }); + }); + ui.add(egui::TextEdit::singleline(&mut self.room_filter).hint_text("Filter rooms").desired_width(ui.available_width())); + let filter = self.room_filter.to_lowercase(); + let listed: Vec<&RoomInfo> = + self.room_list.iter().filter(|r| filter.is_empty() || format!("{} {}", r.name, r.topic).to_lowercase().contains(&filter)).collect(); + let list_h = total_h * 0.3; + let mut join_room = None; + if listed.is_empty() { + widgets::hint(ui, if self.listing { "Loading rooms..." } else { "No rooms loaded" }); + } else { + ui.allocate_ui(vec2(ui.available_width(), list_h), |ui| { + let clicked = widgets::rows(ui, "irc-room-list", listed.len(), rows_h, None, |ui, i| { + let r = listed[i]; + ui.label(&r.name); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(r.users.to_string()).color(t.text_faint).size(11.0)); + ui.label(RichText::new(ircformat::plain_text(&r.topic)).color(t.text_faint).size(11.0)); + }); + ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(format!("{}\nClick to join", ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic }))); + }); + join_room = clicked.map(|i| listed[i].name.clone()); + }); + } + if let Some(room) = join_room { + self.join(s, &room); + } + + ui.add_space(6.0); + let users = if is_room(&self.active) { self.users.get(&self.active).cloned() } else { None }; + widgets::section(ui, &format!("Users{}", users.as_ref().map(|u| format!(" ({})", u.len())).unwrap_or_default())); + match users { + _ if !is_room(&self.active) => widgets::hint(ui, "No room selected"), + Some(u) if !u.is_empty() => { + let clicked = widgets::rows(ui, "irc-users", u.len(), rows_h, None, |ui, i| { + let nick = display_nick(&u[i]); + ui.label(RichText::new(nick).color(xterm(ircformat::nick_colour(nick), crate::terminal_themes::get(&s.prefs.terminal_theme)))); + ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(format!("{}\nClick to mention", u[i])); + }); + if let Some(i) = clicked { + self.insert_mention(&display_nick(&u[i]).to_string()); + } + } + _ => widgets::hint(ui, "No users yet"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(kind: &str, target: &str) -> irc::Event { + irc::Event { kind: kind.into(), target: target.into(), text: "hi".into(), ..Default::default() } + } + + #[test] + fn routes_like_irc_js() { + assert_eq!(route(&ev("rooms", "")), Route::Rooms); + assert_eq!(route(&ev("users", "#a")), Route::Users("#a".into())); + assert_eq!(route(&ev("room-list", "")), Route::RoomList); + assert_eq!(route(&ev("message", "#a")), Route::Line("#a".into())); + assert_eq!(route(&ev("quit", "")), Route::Line(SERVER.into())); + } + + #[test] + fn events_fill_targets_and_unread() { + let mut v = IrcView::new("s", &Profile::new(nexus_core::models::Protocol::Irc)); + v.on_event(ev("message", "#rust")); + v.on_event(ev("nick", "")); + assert_eq!(v.targets.iter().map(|t| t.name.as_str()).collect::>(), [SERVER, "#rust"]); + assert!(v.unread.contains("#rust") && !v.unread.contains(SERVER)); + assert_eq!(v.targets[0].lines.len(), 2); // "Connecting..." + nick + v.set_active("#rust"); + assert!(v.unread.is_empty()); + for _ in 0..MAX_LINES + 5 { + v.on_event(ev("message", "#rust")); + } + assert_eq!(v.targets[1].lines.len(), MAX_LINES); + } + + #[test] + fn nick_completion() { + let users = vec!["@Alice".to_string(), "+bob".to_string()]; + assert_eq!(complete("hi @al", &users).as_deref(), Some("hi @Alice ")); + assert_eq!(complete("bo", &users).as_deref(), Some("bob ")); + assert_eq!(complete("hi ", &users), None); + assert_eq!(complete("x @zz", &users), None); + assert_eq!(display_nick("~&@%+nick"), "nick"); + } + + #[test] + fn palette() { + let th = crate::terminal_themes::get("charcoal"); + assert_eq!(xterm(16, th), Color32::from_rgb(0, 0, 0)); + assert_eq!(xterm(231, th), Color32::from_rgb(255, 255, 255)); + assert_eq!(xterm(244, th), Color32::from_rgb(128, 128, 128)); } } diff --git a/rust/app/src/raw_view.rs b/rust/app/src/raw_view.rs index 33e8210..ba1e5c4 100644 --- a/rust/app/src/raw_view.rs +++ b/rust/app/src/raw_view.rs @@ -1,22 +1,519 @@ -//! RAW TCP viewer: text / hex / split views of the capture and a send bar. -//! STUB — interface fixed; implementation pending. +//! RAW TCP viewer: text / hex / split views of the capture, pause, clear, save, and a send bar. +//! Port of the web build's `raw.js`. Sent data comes back through `Event::RawData` ("send"), so +//! the echo of a sent entry needs no local bookkeeping. use crate::state::Shared; -use nexus_core::models::Profile; +use crate::theme::{self, Tokens}; +use crate::widgets; +use egui::text::LayoutJob; +use egui::{Color32, FontId, RichText, Sense, TextFormat, Ui, pos2, vec2}; +use egui_phosphor::regular as icon; +use nexus_core::clients::raw::format_hex_dump; +use nexus_core::clients::raw::to_display_encoding; +use nexus_core::models::{PayloadPreset, Profile}; +use std::collections::VecDeque; + +/// Entries kept in the viewer (the core keeps its own, larger capture history). +const MAX_ENTRIES: usize = 5000; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Text, + Hex, + Split, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Format { + Text, + Hex, + Escaped, +} + +impl Format { + fn label(self) -> &'static str { + match self { + Format::Text => "Text", + Format::Hex => "Hex", + Format::Escaped => "Escaped", + } + } + fn from_id(s: &str) -> Format { + match s { + "hex" => Format::Hex, + "escaped" => Format::Escaped, + _ => Format::Text, + } + } +} + +struct Entry { + seq: u64, + send: bool, + timestamp: i64, + data: Vec, +} + +/// One display row. `header` rows carry the direction/time line of a hex or split entry. +#[derive(Debug, PartialEq)] +struct Row { + seq: u64, + send: bool, + header: bool, + left: String, + /// The hex dump column in split mode. + right: String, +} pub struct RawView { pub session: String, + show_timestamps: bool, + show_direction: bool, + encoding: String, + presets: Vec, + mode: Mode, + paused: bool, + entries: VecDeque, + next_seq: u64, + rows: VecDeque, + /// Widest row in characters (for horizontal scrolling). + widest: usize, + tx_bytes: u64, + rx_bytes: u64, + format: Format, + input: String, } impl RawView { - pub fn new(session: &str, _profile: &Profile) -> RawView { - RawView { session: session.into() } + pub fn new(session: &str, profile: &Profile) -> RawView { + let raw = profile.raw.clone().unwrap_or_default(); + RawView { + session: session.into(), + show_timestamps: raw.show_timestamps, + show_direction: raw.show_direction, + encoding: raw.encoding, + presets: raw.payload_presets, + mode: Mode::Text, + paused: false, + entries: VecDeque::new(), + next_seq: 0, + rows: VecDeque::new(), + widest: 0, + tx_bytes: 0, + rx_bytes: 0, + format: Format::Text, + input: String::new(), + } } /// Data from `Event::RawData` (direction "send"/"recv", unix ms timestamp). - pub fn on_data(&mut self, _data: &[u8], _direction: &str, _timestamp: i64) {} + pub fn on_data(&mut self, data: &[u8], direction: &str, timestamp: i64) { + let send = direction == "send"; + if send { + self.tx_bytes += data.len() as u64; + } else { + self.rx_bytes += data.len() as u64; + } + let entry = Entry { seq: self.next_seq, send, timestamp, data: data.to_vec() }; + self.next_seq += 1; + if !self.paused { + let rows = self.rows_for(&entry); + self.push_rows(rows); + } + self.entries.push_back(entry); + while self.entries.len() > MAX_ENTRIES { + self.entries.pop_front(); + } + // A paused display stays frozen; it is rebuilt on resume. + if !self.paused + && let Some(first) = self.entries.front().map(|e| e.seq) + { + while self.rows.front().is_some_and(|r| r.seq < first) { + self.rows.pop_front(); + } + } + } - pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) { - ui.label("RAW viewer"); + fn push_rows(&mut self, rows: Vec) { + for r in rows { + self.widest = self.widest.max(r.left.chars().count() + r.right.chars().count()); + self.rows.push_back(r); + } + } + + fn rebuild(&mut self) { + self.rows.clear(); + self.widest = 0; + let rows: Vec = self.entries.iter().flat_map(|e| self.rows_for(e)).collect(); + self.push_rows(rows); + } + + fn prefix(&self, e: &Entry) -> String { + let mut p = String::new(); + if self.show_timestamps { + p.push_str(&format!("[{}] ", format_time(e.timestamp))); + } + if self.show_direction { + p.push_str(if e.send { "[TX] " } else { "[RX] " }); + } + p + } + + fn rows_for(&self, e: &Entry) -> Vec { + let row = |header: bool, left: String, right: String| Row { seq: e.seq, send: e.send, header, left, right }; + match self.mode { + Mode::Text => { + let prefix = self.prefix(e); + let pad = " ".repeat(prefix.chars().count()); + text_lines(&to_display_encoding(&e.data, &self.encoding)) + .into_iter() + .enumerate() + .map(|(i, l)| row(false, format!("{}{l}", if i == 0 { &prefix } else { &pad }), String::new())) + .collect() + } + Mode::Hex => { + let mut out = Vec::new(); + let prefix = self.prefix(e); + if !prefix.is_empty() { + out.push(row(true, format!("{}· {} bytes", prefix, e.data.len()), String::new())); + } + out.extend(format_hex_dump(&e.data).lines().map(|l| row(false, l.to_string(), String::new()))); + out + } + Mode::Split => { + let mut head = String::new(); + if self.show_timestamps { + head.push_str(&format_time(e.timestamp)); + head.push(' '); + } + head.push_str(if e.send { "▲ TX" } else { "▼ RX" }); + let mut out = vec![row(true, format!("{head} · {} bytes", e.data.len()), String::new())]; + let text = text_lines(&to_display_encoding(&e.data, &self.encoding)); + let dump = format_hex_dump(&e.data); + let hex: Vec<&str> = dump.lines().collect(); + for i in 0..text.len().max(hex.len()) { + out.push(row(false, text.get(i).cloned().unwrap_or_default(), hex.get(i).map(|s| s.to_string()).unwrap_or_default())); + } + out + } + } + } + + fn set_mode(&mut self, mode: Mode) { + if self.mode != mode { + self.mode = mode; + self.rebuild(); + } + } + + fn send(&mut self, s: &mut Shared) { + if self.input.is_empty() { + return; + } + let m = &s.svc.sessions; + let r = match self.format { + Format::Text => m.raw_write_text(&self.session, &self.input), + Format::Hex => m.raw_write_hex(&self.session, &self.input), + Format::Escaped => m.raw_write_escaped(&self.session, &self.input), + }; + match r { + Ok(()) => self.input.clear(), + Err(e) => s.error(format!("Send failed: {e}")), + } + } + + fn save(&mut self, s: &mut Shared) { + if self.entries.is_empty() { + s.warn("Nothing to save"); + return; + } + let Some(path) = rfd::FileDialog::new().set_file_name("capture.json").add_filter("JSON", &["json"]).save_file() else { return }; + match s.svc.sessions.raw_save_capture(&self.session, &path) { + Ok(()) => s.success("Capture saved"), + Err(e) => s.error(format!("Save failed: {e}")), + } + } + + fn clear(&mut self, s: &mut Shared) { + self.entries.clear(); + self.rows.clear(); + self.widest = 0; + self.tx_bytes = 0; + self.rx_bytes = 0; + let _ = s.svc.sessions.raw_clear_history(&self.session); + } + + pub fn ui(&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| self.toolbar(ui, s)); + }); + let send_h = 40.0; + let h = (ui.available_height() - send_h).max(40.0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::hover()); + ui.painter().rect_filled(rect, 0.0, t.canvas); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0)))); + self.display(&mut child); + egui::Frame::new().fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| self.send_bar(ui, s)); + }); + } + + fn toolbar(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + for (m, label, glyph) in [(Mode::Text, "Text", icon::TEXT_AA), (Mode::Hex, "Hex", icon::HASH), (Mode::Split, "Split", icon::COLUMNS)] { + if ui.selectable_label(self.mode == m, format!("{glyph} {label}")).clicked() { + self.set_mode(m); + } + } + ui.separator(); + let (g, label) = if self.paused { (icon::PLAY, "Resume") } else { (icon::PAUSE, "Pause") }; + if widgets::button(ui, g, label).clicked() { + self.paused = !self.paused; + if !self.paused { + self.rebuild(); + } + } + if widgets::button(ui, icon::TRASH, "Clear").clicked() { + self.clear(s); + } + if widgets::button(ui, icon::FLOPPY_DISK, "Save").clicked() { + self.save(s); + } + if self.paused { + widgets::chip(ui, "PAUSED", t.warning); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let total = self.tx_bytes + self.rx_bytes; + ui.label(RichText::new(widgets::human_size(total)).font(theme::semibold(12.0))); + ui.label(RichText::new(format!("{} RX {}", icon::ARROW_DOWN, widgets::human_size(self.rx_bytes))).color(t.text_dim).size(11.5)); + ui.label(RichText::new(format!("{} TX {}", icon::ARROW_UP, widgets::human_size(self.tx_bytes))).color(t.accent).size(11.5)); + ui.label(RichText::new(format!("{} entries", widgets::group(self.entries.len() as u64))).color(t.text_faint).size(11.5)); + }); + } + + fn display(&mut self, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + if self.rows.is_empty() { + widgets::empty(ui, icon::BROADCAST, if self.paused { "Paused" } else { "No data yet" }, "Received and sent bytes appear here"); + return; + } + let font = theme::mono(12.5); + let (cw, rh) = ui.fonts_mut(|f| (f.glyph_width(&font, '0'), f.row_height(&font))); + let split = self.mode == Mode::Split; + let avail = ui.available_width(); + // Split mode: the text column takes what the hex dump (77 chars) leaves, at least 30 chars. + let hex_w = 79.0 * cw; + let left_w = if split { (avail - hex_w - 12.0).max(30.0 * cw) } else { 0.0 }; + let width = if split { left_w + 12.0 + hex_w } else { self.widest as f32 * cw + 16.0 }; + ui.spacing_mut().item_spacing.y = 0.0; + egui::ScrollArea::both().auto_shrink([false, false]).stick_to_bottom(true).show_rows(ui, rh, self.rows.len(), |ui, range| { + for i in range { + let row = &self.rows[i]; + let (rect, _) = ui.allocate_exact_size(vec2(width.max(avail), rh), Sense::hover()); + let fg = if row.send { t.accent } else { t.byte_print }; + let p = ui.painter(); + if row.header { + p.rect_filled(rect, 0.0, t.text.gamma_multiply(0.04)); + p.text(rect.left_center(), egui::Align2::LEFT_CENTER, &row.left, font.clone(), if row.send { t.accent } else { t.text_dim }); + continue; + } + match self.mode { + Mode::Text => { + p.text(rect.left_center(), egui::Align2::LEFT_CENTER, &row.left, font.clone(), fg); + } + Mode::Hex => { + let g = ui.fonts_mut(|f| f.layout_job(hex_job(&row.left, &font, &t, fg))); + p.galley(pos2(rect.left(), rect.top()), g, fg); + } + Mode::Split => { + let clip = egui::Rect::from_min_size(rect.min, vec2(left_w, rh)).intersect(ui.clip_rect()); + p.with_clip_rect(clip).text(rect.left_center(), egui::Align2::LEFT_CENTER, &row.left, font.clone(), fg); + let x = rect.left() + left_w + 6.0; + p.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], egui::Stroke::new(1.0, t.separator)); + if !row.right.is_empty() { + let g = ui.fonts_mut(|f| f.layout_job(hex_job(&row.right, &font, &t, fg))); + p.galley(pos2(x + 6.0, rect.top()), g, fg); + } + } + } + } + }); + } + + fn send_bar(&mut self, ui: &mut Ui, s: &mut Shared) { + let t = Tokens::get(ui.ctx()); + egui::ComboBox::from_id_salt(("raw-format", &self.session)).width(84.0).selected_text(self.format.label()).show_ui(ui, |ui| { + for f in [Format::Text, Format::Hex, Format::Escaped] { + ui.selectable_value(&mut self.format, f, f.label()); + } + }); + // Right-hand controls are laid out first so the input takes the remaining width. + let mut send = false; + let mut insert: Option<&str> = None; + let mut focus = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + send |= ui.add(egui::Button::new(RichText::new(format!("{} Send", icon::PAPER_PLANE_RIGHT)).color(t.accent_text)).fill(t.accent)).clicked(); + if !self.presets.is_empty() { + egui::ComboBox::from_id_salt(("raw-presets", &self.session)).width(110.0).selected_text("Presets…").show_ui(ui, |ui| { + for p in &self.presets { + if ui.selectable_label(false, &p.name).clicked() { + self.input = p.payload.clone(); + self.format = Format::from_id(&p.format); + focus = true; + } + } + }); + } + for (label, esc) in [("EOT", "\\x04"), ("ETX", "\\x03"), ("LF", "\\n"), ("CR", "\\r"), ("NUL", "\\x00")] { + if ui.small_button(label).on_hover_text(esc).clicked() { + insert = Some(esc); + } + } + let r = ui.add( + egui::TextEdit::singleline(&mut self.input) + .id_salt(("raw-input", &self.session)) + .font(theme::mono(12.5)) + .hint_text("Type data to send…") + .desired_width(ui.available_width()), + ); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + send = true; + focus = true; + } + if let Some(esc) = insert { + self.insert_byte(esc); + focus = true; + } + if focus { + r.request_focus(); + } + }); + if send { + self.send(s); + } + } + + /// Appends a quick byte in the current format (Text switches to Escaped so it isn't sent literally). + fn insert_byte(&mut self, esc: &str) { + match self.format { + Format::Hex => { + if !self.input.is_empty() && !self.input.ends_with(' ') { + self.input.push(' '); + } + self.input.push_str(&esc_to_hex(esc)); + } + Format::Text | Format::Escaped => { + self.format = Format::Escaped; + self.input.push_str(esc); + } + } + } +} + +/// "\\x04" / "\\n" → "04" / "0a". +fn esc_to_hex(esc: &str) -> String { + nexus_core::clients::raw::parse_escaped(esc).map(|b| nexus_core::clients::raw::format_hex(&b)).unwrap_or_default() +} + +/// Splits decoded text into display lines: CRLF/LF break lines, other control characters show as '·'. +fn text_lines(s: &str) -> Vec { + let s = s.strip_suffix('\n').unwrap_or(s); + s.split('\n') + .map(|l| { + l.trim_end_matches('\r') + .chars() + .map(|c| match c { + '\t' => " ".to_string(), + c if c.is_control() => "·".to_string(), + c => c.to_string(), + }) + .collect() + }) + .collect() +} + +/// A hex dump line coloured by column: faint offset, bytes, dim ASCII. +fn hex_job(line: &str, font: &FontId, t: &Tokens, fg: Color32) -> LayoutJob { + let mut job = LayoutJob::default(); + let fmt = |c: Color32| TextFormat { font_id: font.clone(), color: c, ..Default::default() }; + let (offset, rest) = line.split_at(line.len().min(10)); + let (bytes, ascii) = rest.find('|').map(|i| rest.split_at(i)).unwrap_or((rest, "")); + job.append(offset, 0.0, fmt(t.text_faint)); + job.append(bytes, 0.0, fmt(fg)); + job.append(ascii, 0.0, fmt(t.text_dim)); + job +} + +/// HH:MM:SS for today, otherwise "Mon DD HH:MM:SS" (the web build's formatTimestamp). +fn format_time(ms: i64) -> String { + use chrono::{Local, TimeZone}; + let Some(d) = Local.timestamp_millis_opt(ms).single() else { return String::new() }; + if d.date_naive() == Local::now().date_naive() { d.format("%H:%M:%S").to_string() } else { d.format("%b %d %H:%M:%S").to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view() -> RawView { + let mut p = Profile::new(nexus_core::models::Protocol::Raw); + p.raw = Some(nexus_core::models::RawConfig { show_direction: true, ..Default::default() }); + RawView::new("s", &p) + } + + #[test] + fn text_rows_decode_utf8_and_prefix_direction() { + let mut v = view(); + v.on_data("héllo\r\nwörld\n".as_bytes(), "recv", 0); + v.on_data(b"ping\x01", "send", 0); + let rows: Vec<&str> = v.rows.iter().map(|r| r.left.as_str()).collect(); + assert_eq!(rows, ["[RX] héllo", " wörld", "[TX] ping·"]); + assert_eq!((v.rx_bytes, v.tx_bytes), (15, 5)); + } + + #[test] + fn hex_and_split_rows() { + let mut v = view(); + v.on_data(&[0x41; 20], "recv", 0); + v.set_mode(Mode::Hex); + assert_eq!(v.rows.len(), 3); // header + 2 dump lines + assert!(v.rows[1].left.starts_with("00000000 41 41")); + v.set_mode(Mode::Split); + assert!(v.rows[0].header && v.rows[0].left.contains("▼ RX")); + assert_eq!(v.rows[1].left, "A".repeat(20)); + assert!(v.rows[2].right.starts_with("00000010")); + } + + #[test] + fn pause_freezes_and_buffer_caps() { + let mut v = view(); + v.paused = true; + for _ in 0..MAX_ENTRIES + 10 { + v.on_data(b"x", "recv", 0); + } + assert!(v.rows.is_empty()); + assert_eq!(v.entries.len(), MAX_ENTRIES); + v.paused = false; + v.rebuild(); + assert_eq!(v.rows.len(), MAX_ENTRIES); + v.on_data(b"y", "recv", 0); + assert_eq!(v.rows.len(), MAX_ENTRIES); + assert_eq!(v.rows.back().unwrap().left, "[RX] y"); + } + + #[test] + fn quick_bytes() { + assert_eq!(esc_to_hex("\\x04"), "04"); + assert_eq!(esc_to_hex("\\n"), "0a"); + let mut v = view(); + v.insert_byte("\\r"); + assert!(v.format == Format::Escaped && v.input == "\\r"); + v.format = Format::Hex; + v.input = "41".into(); + v.insert_byte("\\x00"); + assert_eq!(v.input, "41 00"); } } diff --git a/rust/app/src/vnc_view.rs b/rust/app/src/vnc_view.rs index 24c7a1d..8776cb5 100644 --- a/rust/app/src/vnc_view.rs +++ b/rust/app/src/vnc_view.rs @@ -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>, + texture: Option, + fb_size: [usize; 2], + name: String, + /// Modifier keysyms currently held on the remote side. + held: Vec, + 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 { + 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 { + 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 { + 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) { + 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)); } }