use std::
{
mem,
iter,
time::Instant,
collections::
{
BTreeMap,
BTreeSet,
HashMap,
VecDeque,
},
};
use ratatui::
{
layout::Rect,
style::Style,
text::{ Line, Span },
};
use unicode_width::UnicodeWidthChar;
use image::{ DynamicImage, Rgba };
use ratatui_image::
{
FontSize,
FilterType,
picker::Picker,
protocol::{ StatefulProtocol, StatefulProtocolType },
};
use crate::
{
config,
misc,
role::Role,
options::{ self, LoginState },
network::
{
codes::
{
MessageColors,
OnlineUser,
Device,
},
client::
{
self,
VoiceUser,
image::{ Animation, ImageFrame },
},
},
};
#[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::
{
consts,
input::InputBuffer,
login::{ Login, Reconnect, Stage },
palette::Palette,
settings::Settings,
tofu::Prompt,
theme::Theme,
};
pub enum Entry {
Line(Line<'static>),
Message
{
username: String,
id: usize,
message_id: u64,
text: String,
colors: MessageColors,
},
History
{
username: String,
message_id: u64,
text: String,
colors: MessageColors,
},
Private
{
sent: bool, username: String,
id: usize,
text: String,
colors: MessageColors,
},
Transfer(Transfer),
Image
{
username: String,
filename: String,
message_id: u64,
username_color: Option<u8>, hash: Option<[u8; 32]>, picture: Picture,
},
}
impl Entry
{
pub fn message_id(&self) -> Option<u64> {
match self
{
Entry::Message { message_id, .. } | Entry::History { message_id, .. } | Entry::Image { message_id, .. } =>
Some(*message_id),
_ => None,
}
}
}
pub enum Picture
{
Absent, Deferred, Waiting, Gone, Ready(Box<Fitted>),
}
pub struct Transfer {
pub uid: u64, pub upload: bool, pub image: bool, pub filename: String,
pub done: u64,
pub total: u64,
pub outcome: Option<bool>, }
pub struct Fitted {
pub frames: Animation, pub current: usize, pub next: Instant, pub rows: u16, pub fitted: u16, pub protocol: Option<StatefulProtocol>, pub unloaded: Option<Unloaded>, }
pub struct Unloaded
{
pub kind: StatefulProtocolType,
pub background: Option<Rgba<u8>>,
}
#[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 offline: BTreeMap<String, Option<u8>>, pub offline_listed: bool, pub devices: HashMap<String, Device>, 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 typing_users: BTreeMap<String, Instant>,
typing: bool, typing_sent: Option<Instant>,
pub list_requested: bool,
#[cfg(feature = "client_screen")]
pub screens_requested: bool,
pub image_requests: Vec<[u8; 32]>,
pub image_loads: Vec<[u8; 32]>,
image_fetching: Vec<[u8; 32]>,
history_anchor: Option<usize>, history_cursor: Option<u64>, history_pending: bool, pub history_request: Option<u64>,
pub leaving: bool, pub logging_out: bool, pub disconnect_reason: Option<String>, pub reconnect: Reconnect, pub drop_stream: bool, pub should_quit: bool,
pub exit_code: i32,
pub quit_message: Option<String>, pub dirty: bool,
overlays: Vec<Rect>,
picture_rows: Vec<(u16, String)>, pub avatar_marks: Vec<(u16, usize)>,
generation: u64,
wrapped: Option<(u16, u64, Vec<Line<'static>>, Vec<Placement>, Vec<u16>)>,
}
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(),
offline: BTreeMap::new(),
offline_listed: false,
devices: HashMap::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(),
overlays: Vec::new(),
picture_rows: Vec::new(),
avatar_marks: Vec::new(),
picker: Picker::halfblocks(), pane: Rect::ZERO,
pane_offset: 0,
selection: None,
notice: None,
typing_users: BTreeMap::new(),
typing: false,
typing_sent: None,
list_requested: false,
#[cfg(feature = "client_screen")]
screens_requested: false,
image_requests: Vec::new(),
image_loads: Vec::new(),
image_fetching: Vec::new(),
history_anchor: None,
history_cursor: None,
history_pending: false,
history_request: None,
leaving: false,
logging_out: false,
disconnect_reason: None,
reconnect: Reconnect::default(),
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, message_id: u64, text: String, colors: MessageColors)
{
self.push_entry(Entry::Message { username, id, message_id, text, colors });
}
pub fn park_entry(&mut self, channel: String, entry: Entry)
{
let lobby = channel.is_empty();
let pane = self.panes.entry(channel).or_default();
pane.push_back(entry);
while pane.len() > consts::HISTORY_LIMIT
{
pane.pop_front();
if lobby
{
self.history_anchor = self.history_anchor.and_then(|anchor| anchor.checked_sub(1));
if self.history_anchor.is_none() { self.history_cursor = None; }
}
}
}
pub fn push_private(&mut self, sent: bool, username: String, id: usize, text: String, colors: MessageColors)
{
self.push_entry(Entry::Private { sent, username, id, text, colors });
}
pub fn start_history(&mut self, entries: Vec<Entry>, start: u64, more: bool)
{
self.history_anchor = Some(self.messages.len());
self.history_cursor = more.then_some(start);
for entry in entries { self.push_entry(entry); }
}
pub fn prepend_history(&mut self, entries: Vec<Entry>, start: u64, more: bool)
{
self.history_pending = false;
if !self.channel.is_empty() { return; }
let Some(anchor) = self.history_anchor else { return };
let room = consts::HISTORY_LIMIT.saturating_sub(self.messages.len());
let skip = entries.len().saturating_sub(room);
self.history_cursor = (more && skip == 0).then_some(start);
let before = self.wrapped_rows();
let tail = self.messages.split_off(anchor);
self.messages.extend(entries.into_iter().skip(skip));
self.messages.extend(tail);
self.generation += 1;
self.dirty = true;
let grown = self.wrapped_rows().saturating_sub(before);
if let Some(scroll) = self.scroll.as_mut() { *scroll = scroll.saturating_add(grown); }
if let Some(selection) = self.selection.as_mut()
{
selection.anchor.0 = selection.anchor.0.saturating_add(grown);
selection.cursor.0 = selection.cursor.0.saturating_add(grown);
}
}
pub fn delete_message(&mut self, message_id: u64)
{
let lobby = self.channel.is_empty();
if let Some(index) = self.messages.iter().position(|entry| entry.message_id() == Some(message_id))
{
self.remove_entry(index, lobby);
}
else if let Some(pane) = self.panes.get_mut("")
&& let Some(index) = pane.iter().position(|entry| entry.message_id() == Some(message_id))
{
pane.remove(index);
self.shift_anchor(index);
}
}
fn remove_entry(&mut self, index: usize, lobby: bool)
{
self.rewrap(self.pane.width);
let first = self.wrapped.as_ref().and_then(|wrapped| wrapped.4.get(index).copied()).unwrap_or(0);
let before = self.wrapped_len();
self.messages.remove(index);
if lobby { self.shift_anchor(index); }
self.generation += 1;
self.dirty = true;
let removed = before.saturating_sub(self.wrapped_rows());
let shift = |row: u16| match row
{
row if row >= first + removed => row - removed,
row if row > first => first,
row => row,
};
if let Some(scroll) = self.scroll.as_mut() { *scroll = shift(*scroll); }
if let Some(selection) = self.selection.as_mut()
{
selection.anchor.0 = shift(selection.anchor.0);
selection.cursor.0 = shift(selection.cursor.0);
}
}
fn shift_anchor(&mut self, index: usize) {
if let Some(anchor) = self.history_anchor.as_mut() && index < *anchor { *anchor -= 1; }
}
fn wrapped_rows(&mut self) -> u16
{
self.rewrap(self.pane.width);
self.wrapped_len()
}
pub fn take_image_requests(&mut self) -> Vec<[u8; 32]>
{
if self.image_requests.is_empty() { return Vec::new(); }
let drawn = self.pane.height > 0;
let visible = match drawn
{
true => self.on_screen(),
false => Vec::new(),
};
let mut send = Vec::new();
let mut queued = Vec::new();
for hash in mem::take(&mut self.image_requests)
{
let waiting: Vec<usize> = self.messages.iter().enumerate()
.filter(|(_, entry)| matches!(entry, Entry::Image { hash: Some(h), picture: Picture::Waiting, .. } if *h == hash))
.map(|(entry, _)| entry)
.collect();
if drawn && !waiting.is_empty() && !waiting.iter().any(|entry| visible.contains(entry))
{
for entry in waiting
{
if let Some(Entry::Image { picture, .. }) = self.messages.get_mut(entry) { *picture = Picture::Deferred; }
}
continue;
}
match self.image_fetching.len() < consts::MAX_IMAGE_FETCHES
{
true =>
{
self.image_fetching.push(hash);
send.push(hash);
},
false => queued.push(hash),
}
}
self.image_requests = queued;
send
}
pub fn fetched(&mut self, hash: &[u8; 32])
{
if let Some(index) = self.image_fetching.iter().position(|h| h == hash) { self.image_fetching.swap_remove(index); }
}
fn on_screen(&mut self) -> Vec<usize>
{
let (offset, height) = (self.pane_offset, self.pane.height);
self.placements(self.pane.width).into_iter()
.filter(|placement| in_reach(placement, offset, height))
.map(|placement| placement.entry)
.collect()
}
pub fn push_image(&mut self, username: String, filename: String, message_id: u64, image: Animation,
username_color: Option<u8>)
{
let picture = self.fit(image);
self.push_entry(Entry::Image { username, filename, message_id, username_color, hash: None, picture });
}
pub fn push_transfer(&mut self, uid: u64, filename: String, total: u64, upload: bool, image: bool)
{
self.push_entry(Entry::Transfer(Transfer { uid, upload, image, filename, done: 0, total, outcome: None }));
}
pub fn update_transfer(&mut self, uid: u64, done: u64)
{
let Some(transfer) = self.transfer(uid) else { return };
let before = percent(transfer.done, transfer.total);
transfer.done = done;
if percent(done, transfer.total) == before { return; }
self.generation += 1;
self.dirty = true;
}
pub fn finish_transfer(&mut self, uid: u64, ok: bool)
{
let Some(transfer) = self.transfer(uid) else { return };
transfer.done = transfer.total;
transfer.outcome = Some(ok);
self.generation += 1;
self.dirty = true;
}
fn transfer(&mut self, uid: u64) -> Option<&mut Transfer>
{
self.messages.iter_mut().rev().find_map(|entry| match entry
{
Entry::Transfer(transfer) if transfer.uid == uid => Some(transfer),
_ => None,
})
}
pub fn push_caption(&mut self, username: String, filename: String, message_id: u64, hash: [u8; 32],
picture: Picture, username_color: Option<u8>)
{
self.push_entry(Entry::Image { username, filename, message_id, 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
{
Picture::Ready(self.fit_rows(image, consts::IMAGE_ROWS))
}
fn fit_rows(&self, image: Animation, rows: u16) -> Box<Fitted>
{
let font = self.picker.font_size();
let limit = 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();
Box::new(Fitted
{
frames,
current: 0,
next,
rows: 1,
fitted: 0,
protocol: None,
unloaded: None,
})
}
pub fn deliver_avatar(&mut self, hash: [u8; 32], image: Option<Animation>)
{
let Some(image) = image else { return };
self.settings.picture = Some(self.fit_rows(image, consts::AVATAR_ROWS));
self.settings.picture_of = Some(hash);
self.dirty = true;
}
pub fn wants_avatar(&self, hash: &[u8; 32]) -> bool
{
self.settings.open && self.settings.avatar.as_ref() == Some(hash)
&& self.settings.picture_of.as_ref() != Some(hash)
}
pub fn load_avatar(&mut self, width: u16)
{
let font = self.picker.font_size();
let Some(ready) = self.settings.picture.as_mut() else { return };
if ready.fitted == width && ready.protocol.is_some() { return; }
let image = fit_image(&ready.frames[ready.current].image, width, consts::AVATAR_ROWS, font);
ready.protocol = match ready.protocol.take()
{
Some(protocol) => Some(StatefulProtocol::new(image, font,
protocol.background_color(), protocol.protocol_type_owned())),
None => Some(self.picker.new_resize_protocol(image)),
};
ready.fitted = width;
}
fn advance_avatar(&mut self)
{
if !self.settings.open { return; }
let font = self.picker.font_size();
let now = Instant::now();
let Some(ready) = self.settings.picture.as_mut() else { return };
if ready.frames.len() < 2 || ready.protocol.is_none() || now < ready.next { return; }
if now.duration_since(ready.next) > consts::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, consts::AVATAR_ROWS, 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 advance_animations(&mut self)
{
self.advance_avatar();
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) > consts::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, consts::IMAGE_ROWS, 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 load_visible(&mut self, width: u16, offset: u16, height: u16)
{
let font = self.picker.font_size();
if offset < height.saturating_mul(consts::PRELOAD_SCREENS + 1) && self.channel.is_empty() && !self.history_pending
&& let Some(cursor) = self.history_cursor
{
self.history_pending = true;
self.history_request = Some(cursor);
}
for placement in self.placements(width)
{
let visible = in_reach(&placement, offset, height);
let Some(Entry::Image { hash, picture, .. }) = self.messages.get_mut(placement.entry)
else { continue };
match picture
{
Picture::Deferred if visible =>
{
if let Some(hash) = *hash { self.image_loads.push(hash); }
*picture = Picture::Waiting;
},
Picture::Ready(ready) => match visible
{
true => if ready.fitted != width || ready.protocol.is_none()
{
let image = fit_image(&ready.frames[ready.current].image, width, consts::IMAGE_ROWS, font);
ready.protocol = Some(match ready.unloaded.take()
{
Some(Unloaded { kind, background }) =>
StatefulProtocol::new(image, font, background, kind),
None => self.picker.new_resize_protocol(image),
});
ready.fitted = width;
},
false => if let Some(protocol) = ready.protocol.take()
{
ready.unloaded = Some(Unloaded
{
background: protocol.background_color(),
kind: protocol.protocol_type_owned(),
});
},
},
_ => {},
}
}
}
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() > consts::HISTORY_LIMIT
{
self.messages.pop_front();
if self.channel.is_empty()
{
self.history_anchor = self.history_anchor.and_then(|anchor| anchor.checked_sub(1));
if self.history_anchor.is_none() { self.history_cursor = None; }
}
}
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;
if self.channel.is_empty()
{
self.history_anchor = None;
self.history_cursor = None;
}
self.generation += 1;
self.dirty = true;
}
pub fn switch_channel(&mut self, channel: String)
{
if channel == self.channel { return; }
let mut parked = mem::take(&mut self.messages);
for entry in parked.iter_mut()
{
if let Entry::Image { picture: picture @ Picture::Waiting, .. } = entry { *picture = Picture::Deferred; }
}
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.typing_users.clear();
self.wrapped = None;
self.selection = None;
self.scroll = None;
self.unread = 0;
self.generation += 1;
self.dirty = true;
}
pub fn sort_online(&mut self)
{
let me = self.username.clone();
self.online.sort_by_key(|user| (user.username != me, user.id));
}
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 answer_step(&mut self, stage: Stage)
{
let Some(answer) = self.reconnect.answer(stage) else { return };
if let Some(login) = self.login.as_mut() { login.input.insert_str(&answer); }
self.reconnect.submit = true;
}
pub fn disconnected(&mut self, reason: impl Into<String>)
{
let attempt = self.login.as_ref().map_or(0, Login::attempt);
if self.logging_out { self.reconnect.forget(); }
let retrying = self.reconnect.arm();
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.offline.clear();
self.offline_listed = false;
self.devices.clear();
self.channels.clear();
self.voice.clear();
self.voice_roster.clear();
self.voice_activity.clear();
self.voice_enabled = false;
self.typing_users.clear();
self.typing = false;
self.typing_sent = None;
self.list_requested = false;
#[cfg(feature = "client_screen")]
{ self.screens_requested = false; }
self.image_requests.clear();
self.image_loads.clear();
self.image_fetching.clear();
self.history_anchor = None;
self.history_cursor = None;
self.history_pending = false;
self.history_request = None;
self.logging_out = false; self.disconnect_reason = None;
reset_session();
if retrying && let Some(login) = self.login.as_mut() { login.busy = true; }
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 link_at(&mut self, column: u16, row: u16) -> Option<String>
{
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, column) = self.pane_cell(column, row);
let line = self.wrapped_lines(pane.width).get(row as usize)?;
url(&word_at(line, column as usize)?)
}
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() < consts::NOTICE_DURATION)
.map(|(text, _)| text.as_str())
}
pub fn overlays_drawn(&mut self, overlays: &[Rect]) -> Vec<Rect>
{
let previous = std::mem::take(&mut self.overlays);
self.overlays.extend_from_slice(overlays);
previous
}
pub fn picture_row_sent(&self, y: u16, symbol: &str) -> bool
{
self.picture_rows.iter().any(|(row, sent)| *row == y && sent == symbol)
}
pub fn picture_rows_drawn(&mut self, rows: Vec<(u16, String)>) -> Vec<u16>
{
let changed = rows.iter()
.filter(|row| !self.picture_rows.contains(row))
.map(|(y, _)| *y).collect();
self.picture_rows = rows;
changed
}
pub fn expire_notice(&mut self)
{
if self.notice.is_some() && self.notice().is_none()
{
self.notice = None;
self.dirty = true;
}
}
pub fn typed(&mut self)
{
let text = self.input.text();
let text = text.trim_start();
if text.is_empty() || text.starts_with('/')
{
self.typing = false;
self.typing_sent = None;
return;
}
self.typing = config::read_config::<bool>("typing_indicator");
}
pub fn take_typing(&mut self) -> bool
{
if !mem::take(&mut self.typing) { return false; }
if self.typing_sent.is_some_and(|sent| sent.elapsed() < crate::consts::TYPING_INTERVAL) { return false; }
self.typing_sent = Some(Instant::now());
true
}
pub fn set_typing(&mut self, username: String)
{
if !config::read_config::<bool>("typing_indicator") { return; }
if self.typing_users.insert(username, Instant::now()).is_none() { self.dirty = true; }
}
pub fn stopped_typing(&mut self, username: &str)
{
if self.typing_users.remove(username).is_some() { self.dirty = true; }
}
pub fn expire_typing(&mut self)
{
let before = self.typing_users.len();
self.typing_users.retain(|_, seen| seen.elapsed() < crate::consts::TYPING_TIMEOUT);
if self.typing_users.len() != before { self.dirty = true; }
}
pub fn typing_line(&self) -> Option<String>
{
let names: Vec<&str> = self.typing_users.keys().map(String::as_str).collect();
match names.as_slice()
{
[] => None,
[one] => Some(format!("{one} is typing…")),
[one, two] => Some(format!("{one} and {two} are typing…")),
_ => Some(format!("{} people are typing…", names.len())),
}
}
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();
let mut starts: Vec<u16> = Vec::with_capacity(self.messages.len());
for entry in 0..self.messages.len()
{
let row = lines.len() as u16;
starts.push(row);
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) =>
{
let frame = &ready.frames[ready.current].image;
let (_, height) = fit_size(frame.width(), frame.height(), width, consts::IMAGE_ROWS, font);
ready.rows = (height.div_ceil(font.height as u32) as u16).clamp(1, consts::IMAGE_ROWS);
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, starts));
}
fn wrapped_len(&self) -> u16
{
self.wrapped.as_ref().map(|(_, _, lines, ..)| lines.len() as u16).unwrap_or(0)
}
}
pub fn percent(done: u64, total: u64) -> u64
{
match total
{
0 => 100,
total => (done.min(total) * 100) / total,
}
}
fn in_reach(placement: &Placement, offset: u16, height: u16) -> bool
{
let margin = height.saturating_mul(consts::PRELOAD_SCREENS);
placement.caption < offset.saturating_add(height).saturating_add(margin)
&& placement.row + placement.height > offset.saturating_sub(margin)
}
fn fit_size(width: u32, height: u32, pane: u16, rows: u16, font: FontSize) -> (u32, u32)
{
let available_width = pane.max(1) as u32 * font.width as u32;
let available_height = rows as u32 * font.height as u32;
if width <= available_width && height <= available_height { return (width, height); }
let ratio = f64::min(available_width as f64 / width as f64, available_height as f64 / height as f64);
(((width as f64 * ratio).round() as u32).max(1), ((height as f64 * ratio).round() as u32).max(1))
}
pub fn picture_cells(image: &DynamicImage, pane: u16, rows: u16, font: FontSize) -> (u16, u16)
{
let (width, height) = fit_size(image.width(), image.height(), pane, rows, font);
((width.div_ceil(font.width as u32) as u16).max(1).min(pane),
(height.div_ceil(font.height as u32) as u16).clamp(1, rows))
}
fn fit_image(image: &DynamicImage, width: u16, rows: u16, font: FontSize) -> DynamicImage
{
let (fit_width, fit_height) = fit_size(image.width(), image.height(), width, rows, font);
match (fit_width, fit_height) == (image.width(), image.height())
{
true => image.clone(),
false => image.resize_exact(fit_width, fit_height, FilterType::Triangle),
}
}
fn word_at(line: &Line<'static>, column: usize) -> Option<String> {
let mut word = String::new();
let mut start = 0usize;
let mut cell = 0usize;
for c in line.spans.iter().flat_map(|span| span.content.chars())
{
let w = c.width().unwrap_or(0).max(1);
match c.is_whitespace()
{
true =>
{
if (start..cell).contains(&column) { return Some(word); }
word.clear();
start = cell + w;
},
false => word.push(c),
}
cell += w;
}
(start..cell).contains(&column).then_some(word)
}
fn url(word: &str) -> Option<String>
{
let mut word = word.trim_start_matches(['(', '[', '<']);
while word.ends_with([')', ']', '>', '.', ',', '!', '?', ';', ':'])
{
if word.ends_with(')') && word.matches('(').count() >= word.matches(')').count() { break; }
word = &word[..word.len() - 1];
}
(misc::is_web_url(word) && word.len() <= consts::MAX_URL).then(|| word.to_owned())
}
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);
}
}