use std::
{
mem,
iter,
time::{ Duration, Instant },
collections::
{
BTreeMap,
BTreeSet,
HashMap,
VecDeque,
},
};
use ratatui::
{
layout::Rect,
style::Style,
text::{ Line, Span },
};
use unicode_width::UnicodeWidthChar;
use image::DynamicImage;
use ratatui_image::
{
FontSize,
FilterType,
picker::Picker,
protocol::StatefulProtocol,
};
use crate::
{
role::Role,
options::{ self, LoginState },
network::
{
codes::{ MessageColors, OnlineUser },
client::{ self, Animation, ImageFrame, VoiceUser },
},
};
#[cfg(feature = "client_voice")]
use crate::network::voice::client::options as voice_options;
#[cfg(feature = "client_screen")]
use crate::network::screen::client::options as screen_options;
use super::
{
input::InputBuffer,
login::Login,
palette::Palette,
settings::Settings,
tofu::Prompt,
theme::Theme,
};
pub const HISTORY_LIMIT: usize = 5000; pub const ANIMATION_CATCHUP: Duration = Duration::from_secs(1); pub const IMAGE_ROWS: u16 = 20; pub const NOTICE_DURATION: Duration = Duration::from_secs(2);
pub enum Entry {
Line(Line<'static>),
Message
{
username: String,
id: usize,
text: String,
colors: MessageColors,
},
History
{
username: String,
text: String,
colors: MessageColors,
},
Prefixed
{
prefix: Vec<Span<'static>>,
text: String,
},
Image
{
username: String,
filename: String,
username_color: Option<u8>, hash: Option<[u8; 32]>, picture: Picture,
},
}
pub enum Picture
{
Absent, Waiting, Gone, Ready(Box<Fitted>),
}
pub struct Fitted {
pub frames: Animation, pub current: usize, pub next: Instant, pub rows: u16, pub fitted: u16, pub protocol: Option<StatefulProtocol>, }
#[derive(Clone, Copy)]
pub struct Placement {
pub entry: usize, pub caption: u16, pub row: u16, pub height: u16, }
#[derive(Clone, Copy)]
pub struct Selection
{
pub anchor: (u16, u16), pub cursor: (u16, u16),
pub dragged: bool, }
pub struct App
{
pub messages: VecDeque<Entry>, pub channel: String, pub panes: HashMap<String, VecDeque<Entry>>, pub scroll: Option<u16>, pub unread: usize,
pub username: String, pub role: Role, pub online: Vec<OnlineUser>,
pub channels: BTreeSet<String>, pub voice: Vec<VoiceUser>, pub voice_roster: BTreeMap<usize, String>, pub voice_activity: Vec<VoiceUser>, pub voice_enabled: bool,
pub address: String, pub server_name: String,
pub input: InputBuffer,
pub palette: Palette,
pub settings: Settings, pub login: Option<Login>, pub tofu: Option<Prompt>, pub theme: Theme,
pub picker: Picker,
pub pane: Rect,
pub pane_offset: u16,
pub selection: Option<Selection>,
pub notice: Option<(String, Instant)>,
pub list_requested: bool,
#[cfg(feature = "client_screen")]
pub screens_requested: bool,
pub refresh_online: bool,
pub image_requests: Vec<[u8; 32]>,
pub leaving: bool, pub logging_out: bool, pub drop_stream: bool, pub should_quit: bool,
pub exit_code: i32,
pub quit_message: Option<String>, pub dirty: bool,
generation: u64,
wrapped: Option<(u16, u64, Vec<Line<'static>>, Vec<Placement>)>,
}
impl Selection
{
pub fn ordered(&self) -> ((u16, u16), (u16, u16)) {
if self.cursor < self.anchor { (self.cursor, self.anchor) } else { (self.anchor, self.cursor) }
}
}
impl Default for App
{
fn default() -> Self { Self::new() }
}
impl App
{
pub fn new() -> Self
{
Self
{
messages: VecDeque::new(),
channel: String::new(),
panes: HashMap::new(),
scroll: None,
unread: 0,
username: String::new(),
role: Role::default(),
online: Vec::new(),
channels: BTreeSet::new(),
voice: Vec::new(),
voice_roster: BTreeMap::new(),
voice_activity: Vec::new(),
voice_enabled: false,
address: String::new(),
server_name: String::new(),
input: InputBuffer::new(),
palette: Palette::new(),
settings: Settings::new(),
login: Some(Login::new()),
tofu: None,
theme: Theme::load(),
picker: Picker::halfblocks(), pane: Rect::ZERO,
pane_offset: 0,
selection: None,
notice: None,
list_requested: false,
#[cfg(feature = "client_screen")]
screens_requested: false,
refresh_online: false,
image_requests: Vec::new(),
leaving: false,
logging_out: false,
drop_stream: false,
should_quit: false,
exit_code: 0,
quit_message: None,
dirty: true,
generation: 0,
wrapped: None,
}
}
pub fn rebuild_voice(&mut self)
{
let mut users: Vec<VoiceUser> = Vec::with_capacity(self.voice_roster.len() + 1);
if self.voice_enabled
{
users.push(match self.voice_activity.iter().find(|user| user.is_local)
{
Some(local) => VoiceUser { username: self.username.clone(), ..*local },
None => VoiceUser
{
id: 0,
username: self.username.clone(),
is_speaking: false,
latency: None,
is_local: true,
},
});
}
for (id, username) in self.voice_roster.iter()
{
let heard = self.voice_activity.iter().find(|user| !user.is_local && user.id == *id);
users.push(VoiceUser
{
id: *id,
username: username.clone(),
is_speaking: heard.is_some_and(|user| user.is_speaking),
latency: heard.and_then(|user| user.latency),
is_local: false,
});
}
self.voice = users;
self.dirty = true;
}
pub fn push(&mut self, line: Line<'static>)
{
self.push_entry(Entry::Line(line));
}
pub fn push_message(&mut self, username: String, id: usize, text: String, colors: MessageColors)
{
self.push_entry(Entry::Message { username, id, text, colors });
}
pub fn push_prefixed(&mut self, prefix: Vec<Span<'static>>, text: String)
{
self.push_entry(Entry::Prefixed { prefix, text });
}
pub fn push_history(&mut self, username: String, text: String, colors: MessageColors)
{
self.push_entry(Entry::History { username, text, colors });
}
pub fn push_image(&mut self, username: String, filename: String, image: Animation,
username_color: Option<u8>)
{
let picture = self.fit(image);
self.push_entry(Entry::Image { username, filename, username_color, hash: None, picture });
}
pub fn push_caption(&mut self, username: String, filename: String, hash: [u8; 32], pending: bool,
username_color: Option<u8>)
{
let picture = match pending
{
true => Picture::Waiting,
false => Picture::Absent,
};
self.push_entry(Entry::Image { username, filename, username_color, hash: Some(hash), picture });
}
pub fn request_image(&mut self, entry: usize) -> Option<[u8; 32]>
{
let Some(Entry::Image { hash, picture, .. }) = self.messages.get_mut(entry) else { return None };
if !matches!(picture, Picture::Absent | Picture::Gone) { return None; }
*picture = Picture::Waiting;
self.generation += 1;
self.dirty = true;
*hash
}
pub fn deliver_image(&mut self, hash: [u8; 32], image: Option<Animation>)
{
let (picture, asked) = match image
{
Some(image) => (self.fit(image), false),
None => (Picture::Gone, true),
};
let waiting = self.messages.iter().position(|entry| match entry
{
Entry::Image { hash: Some(h), picture: slot, .. } if *h == hash => match asked
{
true => matches!(slot, Picture::Waiting),
false => matches!(slot, Picture::Absent | Picture::Waiting),
},
_ => false,
});
let Some(entry) = waiting else { return };
if let Some(Entry::Image { picture: slot, .. }) = self.messages.get_mut(entry) { *slot = picture; }
self.generation += 1;
self.dirty = true;
}
fn fit(&self, image: Animation) -> Picture
{
let font = self.picker.font_size();
let limit = IMAGE_ROWS as u32 * font.height as u32;
let frames = image.into_iter().map(|ImageFrame { image, delay }|
{
let image = match image.height() > limit
{
true => image.resize(image.width(), limit, FilterType::Triangle),
false => image,
};
ImageFrame { image, delay }
}).collect::<Animation>();
let next = Instant::now() + frames.first().map(|frame| frame.delay).unwrap_or_default();
Picture::Ready(Box::new(Fitted { frames, current: 0, next, rows: 1, fitted: 0, protocol: None }))
}
pub fn advance_animations(&mut self)
{
let pane = self.pane;
if pane.width == 0 || pane.height == 0 { return; }
let now = Instant::now();
let offset = self.pane_offset;
let font = self.picker.font_size();
let visible = self.placements(pane.width).into_iter()
.filter(|placement| placement.height > 0
&& placement.row < offset + pane.height
&& placement.row + placement.height > offset)
.map(|placement| placement.entry)
.collect::<Vec<usize>>();
for entry in visible
{
let Some(Entry::Image { picture: Picture::Ready(ready), .. }) = self.messages.get_mut(entry)
else { continue };
if ready.frames.len() < 2 || ready.protocol.is_none() || now < ready.next { continue; }
if now.duration_since(ready.next) > ANIMATION_CATCHUP { ready.next = now; }
while now >= ready.next
{
ready.current = (ready.current + 1) % ready.frames.len();
ready.next += ready.frames[ready.current].delay;
}
let image = fit_image(&ready.frames[ready.current].image, ready.fitted, font);
ready.protocol = ready.protocol.take().map(|protocol|
{
let background = protocol.background_color();
StatefulProtocol::new(image, font, background, protocol.protocol_type_owned())
});
self.dirty = true;
}
}
pub fn init_picker(&mut self)
{
if let Ok(picker) = Picker::from_query_stdio() { self.picker = picker; }
}
fn push_entry(&mut self, entry: Entry)
{
self.messages.push_back(entry);
while self.messages.len() > HISTORY_LIMIT { self.messages.pop_front(); }
self.generation += 1;
self.dirty = true;
if self.scroll.is_some() { self.unread += 1; }
}
pub fn push_text(&mut self, text: impl Into<String>)
{
self.push(Line::from(Span::raw(text.into())));
}
pub fn push_styled(&mut self, text: impl Into<String>, style: Style)
{
self.push(Line::from(Span::styled(text.into(), style)));
}
pub fn clear_messages(&mut self)
{
self.messages.clear();
self.wrapped = None;
self.selection = None;
self.scroll = None;
self.unread = 0;
self.generation += 1;
self.dirty = true;
}
pub fn switch_channel(&mut self, channel: String)
{
if channel == self.channel { return; }
let parked = mem::take(&mut self.messages);
if !parked.is_empty() { self.panes.insert(mem::take(&mut self.channel), parked); }
self.messages = self.panes.remove(&channel).unwrap_or_default();
self.channel = channel;
self.wrapped = None;
self.selection = None;
self.scroll = None;
self.unread = 0;
self.generation += 1;
self.dirty = true;
}
pub fn prune_panes(&mut self)
{
self.panes.retain(|channel, _| channel.is_empty() || self.channels.contains(channel));
}
pub fn reload_theme(&mut self)
{
self.theme.reload();
self.generation += 1;
self.wrapped = None;
self.dirty = true;
}
pub fn disconnected(&mut self, reason: impl Into<String>)
{
let attempt = self.login.as_ref().map_or(0, Login::attempt);
self.login = Some(Login::again(&self.address, attempt, reason.into()));
self.drop_stream = true;
self.clear_messages();
self.panes.clear();
self.channel.clear();
self.input = InputBuffer::new();
self.palette.dismiss();
self.settings.close();
self.tofu = None;
self.username.clear();
self.role = Role::default(); self.server_name.clear();
self.online.clear();
self.channels.clear();
self.voice.clear();
self.voice_roster.clear();
self.voice_activity.clear();
self.voice_enabled = false;
self.list_requested = false;
#[cfg(feature = "client_screen")]
{ self.screens_requested = false; }
self.refresh_online = false;
self.image_requests.clear();
self.logging_out = false;
reset_session();
self.dirty = true;
}
pub fn scroll_up(&mut self, amount: u16, viewport: u16)
{
let total = self.wrapped_len();
let max_offset = total.saturating_sub(viewport);
let current = self.scroll.unwrap_or(max_offset);
self.scroll = Some(current.saturating_sub(amount));
self.dirty = true;
}
pub fn scroll_down(&mut self, amount: u16, viewport: u16)
{
let total = self.wrapped_len();
let max_offset = total.saturating_sub(viewport);
if let Some(current) = self.scroll
{
let next = current.saturating_add(amount);
if next >= max_offset { self.stick_to_bottom(); } else { self.scroll = Some(next); }
}
self.dirty = true;
}
pub fn stick_to_bottom(&mut self)
{
self.scroll = None;
self.unread = 0;
self.dirty = true;
}
pub fn wrapped_lines(&mut self, width: u16) -> &[Line<'static>]
{
self.rewrap(width);
&self.wrapped.as_ref().unwrap().2
}
pub fn image_at(&mut self, column: u16, row: u16) -> Option<usize>
{
let pane = self.pane;
if column < pane.x || column >= pane.x + pane.width { return None; }
if row < pane.y || row >= pane.y + pane.height { return None; }
let row = self.pane_offset + (row - pane.y);
self.placements(pane.width).into_iter()
.find(|placement| row >= placement.caption && row < placement.row)
.map(|placement| placement.entry)
}
pub fn selection_start(&mut self, column: u16, row: u16) -> bool
{
let pane = self.pane;
if column < pane.x || column >= pane.x + pane.width { return false; }
if row < pane.y || row >= pane.y + pane.height { return false; }
let cell = self.pane_cell(column, row);
self.selection = Some(Selection { anchor: cell, cursor: cell, dragged: false });
self.dirty = true;
true
}
pub fn selection_extend(&mut self, column: u16, row: u16)
{
let pane = self.pane;
if self.selection.is_none() || pane.height == 0 { return; }
let cell = match row
{
_ if row < pane.y =>
{
self.scroll_up(1, pane.height);
(self.pane_offset.saturating_sub(1), self.pane_cell(column, row).1)
},
_ if row >= pane.y + pane.height =>
{
self.scroll_down(1, pane.height);
(self.pane_offset + pane.height, self.pane_cell(column, row).1)
},
_ => self.pane_cell(column, row),
};
if let Some(selection) = self.selection.as_mut()
{
selection.cursor = cell;
selection.dragged = true;
}
self.dirty = true;
}
pub fn notify(&mut self, text: impl Into<String>)
{
self.notice = Some((text.into(), Instant::now()));
self.dirty = true;
}
pub fn notice(&self) -> Option<&str>
{
self.notice.as_ref()
.filter(|(_, shown)| shown.elapsed() < NOTICE_DURATION)
.map(|(text, _)| text.as_str())
}
pub fn expire_notice(&mut self)
{
if self.notice.is_some() && self.notice().is_none()
{
self.notice = None;
self.dirty = true;
}
}
pub fn clear_selection(&mut self)
{
if self.selection.take().is_some() { self.dirty = true; }
}
pub fn selection_columns(&self, row: u16) -> Option<(u16, u16)>
{
let selection = self.selection?;
if !selection.dragged { return None; }
let (start, end) = selection.ordered();
if row < start.0 || row > end.0 { return None; }
let last = self.pane.width.saturating_sub(1);
let first = if row == start.0 { start.1 } else { 0 };
let final_column = if row == end.0 { end.1 } else { last };
(first <= final_column).then_some((first, final_column))
}
pub fn selection_text(&mut self) -> Option<String>
{
let selection = self.selection?;
if !selection.dragged { return None; }
let width = self.pane.width;
let (start, end) = selection.ordered();
self.rewrap(width);
let lines = &self.wrapped.as_ref().unwrap().2;
let last = width.saturating_sub(1);
let mut out: Vec<String> = Vec::new();
for row in start.0..=end.0
{
let Some(line) = lines.get(row as usize) else { break };
let first = if row == start.0 { start.1 } else { 0 };
let final_column = if row == end.0 { end.1 } else { last };
if first > final_column { continue; }
out.push(slice_cells(line, first as usize, final_column as usize).trim_end().to_owned());
}
let text = out.join("\n");
(!text.trim().is_empty()).then_some(text)
}
fn pane_cell(&self, column: u16, row: u16) -> (u16, u16)
{
let pane = self.pane;
let column = column.clamp(pane.x, pane.x + pane.width.saturating_sub(1)) - pane.x;
let row = row.clamp(pane.y, pane.y + pane.height.saturating_sub(1)) - pane.y;
(self.pane_offset + row, column)
}
pub fn placements(&mut self, width: u16) -> Vec<Placement>
{
self.rewrap(width);
self.wrapped.as_ref().unwrap().3.clone()
}
fn rewrap(&mut self, width: u16)
{
let stale = match &self.wrapped
{
Some((w, g, _, _)) => *w != width || *g != self.generation,
None => true,
};
if !stale { return; }
let font = self.picker.font_size();
let mut lines: Vec<Line<'static>> = Vec::new();
let mut placements: Vec<Placement> = Vec::new();
for entry in 0..self.messages.len()
{
let row = lines.len() as u16;
lines.extend(self.theme.render(&self.messages[entry], width));
if let Entry::Image { picture, .. } = &mut self.messages[entry]
{
let caption = row;
let row = lines.len() as u16;
let height = match picture
{
Picture::Ready(ready) =>
{
if ready.fitted != width || ready.protocol.is_none()
{
let image = fit_image(&ready.frames[ready.current].image, width, font);
ready.rows = (image.height().div_ceil(font.height as u32) as u16).clamp(1, IMAGE_ROWS);
ready.protocol = Some(self.picker.new_resize_protocol(image));
ready.fitted = width;
}
ready.rows
},
_ => 0,
};
placements.push(Placement { entry, caption, row, height });
lines.extend(iter::repeat_n(Line::default(), height as usize));
}
}
self.wrapped = Some((width, self.generation, lines, placements));
}
fn wrapped_len(&self) -> u16
{
self.wrapped.as_ref().map(|(_, _, lines, _)| lines.len() as u16).unwrap_or(0)
}
}
fn fit_image(image: &DynamicImage, width: u16, font: FontSize) -> DynamicImage
{
let available_width = width.max(1) as u32 * font.width as u32;
let available_height = IMAGE_ROWS as u32 * font.height as u32;
match image.width() > available_width || image.height() > available_height
{
true => image.resize(available_width, available_height, FilterType::Triangle),
false => image.clone(),
}
}
fn slice_cells(line: &Line<'static>, from: usize, to: usize) -> String
{
let mut out = String::new();
let mut column = 0usize;
for span in &line.spans
{
for c in span.content.chars()
{
let w = c.width().unwrap_or(0).max(1);
if column + w > from && column <= to { out.push(c); }
column += w;
if column > to { return out; }
}
}
out
}
pub fn wrap_line(line: &Line<'static>, width: u16) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
let mut out: Vec<Line<'static>> = Vec::new();
let mut current: Vec<Span<'static>> = Vec::new();
let mut column = 0usize;
for span in &line.spans
{
let style = span.style;
for word in split_words(span.content.as_ref())
{
let word_width = text_width(word);
if column + word_width > width && column > 0
{
out.push(Line::from(mem::take(&mut current)));
column = 0;
if word.chars().all(char::is_whitespace) { continue; } }
if word_width > width {
let mut chunk = String::new();
for c in word.chars()
{
let w = c.width().unwrap_or(0);
if column + w > width && column > 0
{
current.push(Span::styled(mem::take(&mut chunk), style));
out.push(Line::from(mem::take(&mut current)));
column = 0;
}
chunk.push(c);
column += w;
}
if !chunk.is_empty() { current.push(Span::styled(chunk, style)); }
} else
{
current.push(Span::styled(word.to_owned(), style));
column += word_width;
}
}
}
out.push(Line::from(current));
out
}
fn split_words(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0usize;
let mut space: Option<bool> = None;
for (i, c) in text.char_indices()
{
let is_space = c.is_whitespace();
match space
{
Some(prev) if prev != is_space =>
{
out.push(&text[start..i]);
start = i;
},
_ => {}
}
space = Some(is_space);
}
if start < text.len() { out.push(&text[start..]); }
out
}
fn text_width(text: &str) -> usize
{
text.chars().map(|c| c.width().unwrap_or(0)).sum()
}
fn reset_session()
{
options::set_seq(0);
options::set_server_seq(0);
options::set_login_state(LoginState::None);
options::set_sending_messages(false);
options::set_asking_password(false);
options::set_channel(String::new());
options::set_server_username("");
client::ACTIVE_UPLOADS.lock().unwrap().clear();
#[cfg(feature = "client_voice")]
voice_options::set_use_voice(false);
#[cfg(feature = "client_screen")]
{
screen_options::set_use_screen(false);
screen_options::set_attach_screen(false);
screen_options::set_monitor(None);
}
}