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

539 lines
22 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.
//! 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;
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 => {
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 => {
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]));
}
}
_ => 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));
}
}