Files
nexus/rust/app/src/irc_view.rs
T
Andy Dixon 9805681c2c license: release Nexus Terminal under the GNU AGPL v3
- LICENSE: the GNU Affero General Public License, version 3 (verbatim
  from gnu.org).
- SPDX-License-Identifier: AGPL-3.0-only plus a copyright line on every
  first-party source file (Rust, shell, Python, PHP); vendored crates keep
  their own licenses.
- Cargo manifests carry license/authors/repository; the macOS bundle
  copyright and the About page state the license and link the source.
- Release zips (and the macOS app bundle) include LICENSE.
- README: License section.
2026-10-13 09:46:31 +01:00

559 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Andy Dixon
//! 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 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;
/// Open conversations; past this the oldest idle query is dropped, so a flood of messages from
/// unique nicks can't grow the view without bound.
const MAX_TARGETS: usize = 200;
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 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 {
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
}
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 => {
if self.targets.len() >= MAX_TARGETS {
// ponytail: evicts the oldest-opened idle target, not least-recently-used.
let (active, joined) = (&self.active, &self.joined);
if let Some(i) = self.targets.iter().position(|t| t.name != SERVER && t.name != *active && !joined.iter().any(|r| r.name == t.name)) {
let gone = self.targets.remove(i);
self.unread.remove(&gone.name);
self.users.remove(&gone.name);
}
}
self.targets.push(Target { name: name.into(), lines: VecDeque::new() });
self.targets.len() - 1
}
};
&mut self.targets[i]
}
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 => {
// Show the first room once auto-join lands, rather than leaving the server log up.
let first = self.joined.is_empty() && self.active == SERVER;
self.joined = event.rooms;
if first && let Some(r) = self.joined.first().map(|r| r.name.clone()) {
self.set_active(&r);
}
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]));
}
}
_ => 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));
}
}