use std::
{
mem,
collections::
{
BTreeMap,
BTreeSet,
HashMap,
VecDeque,
},
};
use ratatui::
{
style::Style,
text::{ Line, Span },
};
use unicode_width::UnicodeWidthChar;
use crate::
{
role::Role,
options::{ self, LoginState },
network::
{
codes::{ MessageColors, OnlineUser },
client::{ self, 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 enum Entry {
Line(Line<'static>),
Message
{
username: String,
id: usize,
text: String,
colors: MessageColors,
},
History
{
username: String,
text: String,
colors: MessageColors,
},
}
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 list_requested: bool,
#[cfg(feature = "client_screen")]
pub screens_requested: bool,
pub refresh_online: bool,
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>>)>,
}
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(),
list_requested: false,
#[cfg(feature = "client_screen")]
screens_requested: false,
refresh_online: false,
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_history(&mut self, username: String, text: String, colors: MessageColors)
{
self.push_entry(Entry::History { username, text, colors });
}
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.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.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.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>]
{
let stale = match &self.wrapped
{
Some((w, g, _)) => *w != width || *g != self.generation,
None => true,
};
if stale
{
let theme = &self.theme;
let lines = self.messages.iter().flat_map(|entry| wrap_line(&theme.render(entry), width)).collect();
self.wrapped = Some((width, self.generation, lines));
}
&self.wrapped.as_ref().unwrap().2
}
fn wrapped_len(&self) -> u16
{
self.wrapped.as_ref().map(|(_, _, l)| l.len() as u16).unwrap_or(0)
}
}
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);
}
}