Merge branch 'ui-views' into rust-port
This commit is contained in:
+351
-9
@@ -1,25 +1,367 @@
|
|||||||
//! GitHub repository browser.
|
//! GitHub repository browser: owner groups of repo cards with ZIP download and Visit. Port of
|
||||||
//! STUB — interface fixed; implementation pending.
|
//! `github.js`.
|
||||||
|
|
||||||
use crate::state::Shared;
|
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 nexus_core::models::GitHubRepo;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
enum Msg {
|
||||||
|
Repos(Vec<GitHubRepo>),
|
||||||
|
RefreshFailed(String),
|
||||||
|
Downloaded(String, String),
|
||||||
|
DownloadFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GithubView {
|
pub struct GithubView {
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
title: String,
|
||||||
|
login: String,
|
||||||
|
repos: Vec<GitHubRepo>,
|
||||||
|
filter: String,
|
||||||
|
status: String,
|
||||||
|
status_error: bool,
|
||||||
|
inbox: Arc<Mutex<Vec<Msg>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_time(s: &str) -> Option<DateTime<Utc>> {
|
||||||
|
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<Utc>) -> 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 {
|
impl GithubView {
|
||||||
pub fn new(session: &str, _title: &str) -> GithubView {
|
pub fn new(session: &str, title: &str) -> GithubView {
|
||||||
GithubView { session: session.into() }
|
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<GitHubRepo>) {}
|
pub fn on_repos(&mut self, login: String, repos: Vec<GitHubRepo>) {
|
||||||
|
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) {
|
fn update_status(&mut self) {
|
||||||
ui.label("GitHub");
|
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<Utc>) -> Option<Action> {
|
||||||
|
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), "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+521
-9
@@ -1,23 +1,535 @@
|
|||||||
//! IRC client view: room tabs, message log, composer and user/room lists.
|
//! IRC client view: room tabs, message log, composer and room/user lists. Port of `irc.js`;
|
||||||
//! STUB — interface fixed; implementation pending.
|
//! lines are `ircformat::format_event` spans drawn in the terminal theme's colours.
|
||||||
|
|
||||||
use crate::state::Shared;
|
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 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<Span>,
|
||||||
|
/// Laid-out line for the wrap width and theme it was built with.
|
||||||
|
galley: Option<(f32, &'static str, Arc<Galley>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Target {
|
||||||
|
name: String,
|
||||||
|
lines: VecDeque<Line>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
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 struct IrcView {
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
targets: Vec<Target>,
|
||||||
|
active: String,
|
||||||
|
unread: HashSet<String>,
|
||||||
|
users: HashMap<String, Vec<String>>,
|
||||||
|
joined: Vec<RoomInfo>,
|
||||||
|
room_list: Vec<RoomInfo>,
|
||||||
|
listing: bool,
|
||||||
|
room_filter: String,
|
||||||
|
join_input: String,
|
||||||
|
input: String,
|
||||||
|
refreshed: bool,
|
||||||
|
focus_composer: bool,
|
||||||
|
font_size: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IrcView {
|
impl IrcView {
|
||||||
pub fn new(session: &str, _profile: &Profile) -> IrcView {
|
pub fn new(session: &str, profile: &Profile) -> IrcView {
|
||||||
IrcView { session: session.into() }
|
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).
|
fn append(&mut self, target: &str, e: &irc::Event) {
|
||||||
pub fn ui(&mut self, ui: &mut egui::Ui, _s: &mut Shared) {
|
let spans = ircformat::format_event(e, &time(e.timestamp));
|
||||||
ui.label("IRC");
|
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<String> = 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 <nick> <message>".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::<Vec<_>>(), [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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+505
-8
@@ -1,22 +1,519 @@
|
|||||||
//! RAW TCP viewer: text / hex / split views of the capture and a send bar.
|
//! RAW TCP viewer: text / hex / split views of the capture, pause, clear, save, and a send bar.
|
||||||
//! STUB — interface fixed; implementation pending.
|
//! 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 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 struct RawView {
|
||||||
pub session: String,
|
pub session: String,
|
||||||
|
show_timestamps: bool,
|
||||||
|
show_direction: bool,
|
||||||
|
encoding: String,
|
||||||
|
presets: Vec<PayloadPreset>,
|
||||||
|
mode: Mode,
|
||||||
|
paused: bool,
|
||||||
|
entries: VecDeque<Entry>,
|
||||||
|
next_seq: u64,
|
||||||
|
rows: VecDeque<Row>,
|
||||||
|
/// Widest row in characters (for horizontal scrolling).
|
||||||
|
widest: usize,
|
||||||
|
tx_bytes: u64,
|
||||||
|
rx_bytes: u64,
|
||||||
|
format: Format,
|
||||||
|
input: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RawView {
|
impl RawView {
|
||||||
pub fn new(session: &str, _profile: &Profile) -> RawView {
|
pub fn new(session: &str, profile: &Profile) -> RawView {
|
||||||
RawView { session: session.into() }
|
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).
|
/// 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) {
|
fn push_rows(&mut self, rows: Vec<Row>) {
|
||||||
ui.label("RAW viewer");
|
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<Row> = 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<Row> {
|
||||||
|
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<String> {
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+384
-7
@@ -1,22 +1,399 @@
|
|||||||
//! VNC viewer: the remote framebuffer as a texture, with mouse and keyboard input.
|
//! VNC viewer: the remote framebuffer as a texture, with mouse, keyboard and clipboard input.
|
||||||
//! STUB — interface fixed; implementation pending.
|
//! 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::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 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 struct VncView {
|
||||||
pub session: String,
|
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 {
|
impl VncView {
|
||||||
pub fn new(session: &str, _profile: &Profile) -> VncView {
|
pub fn new(session: &str, profile: &Profile) -> VncView {
|
||||||
VncView { session: session.into() }
|
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)`).
|
/// 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) {
|
fn key(&self, sym: u32, down: bool) {
|
||||||
ui.label("VNC");
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user