Set up workspace
This commit is contained in:
parent
babdd10fba
commit
288a5f97dd
45 changed files with 68 additions and 56 deletions
57
cove/Cargo.toml
Normal file
57
cove/Cargo.toml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
[package]
|
||||
name = "cove"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.70"
|
||||
async-trait = "0.1.68"
|
||||
clap = { version = "4.2.1", features = ["derive", "deprecated"] }
|
||||
cookie = "0.17.0"
|
||||
crossterm = "0.26.1"
|
||||
directories = "5.0.0"
|
||||
edit = "0.1.4"
|
||||
linkify = "0.9.0"
|
||||
log = { version = "0.4.17", features = ["std"] }
|
||||
once_cell = "1.17.1"
|
||||
open = "4.0.1"
|
||||
parking_lot = "0.12.1"
|
||||
rusqlite = { version = "0.29.0", features = ["bundled", "time"] }
|
||||
serde = { version = "1.0.159", features = ["derive"] }
|
||||
serde_json = "1.0.95"
|
||||
thiserror = "1.0.40"
|
||||
tokio = { version = "1.27.0", features = ["full"] }
|
||||
toml = "0.7.3"
|
||||
unicode-segmentation = "1.10.1"
|
||||
unicode-width = "0.1.10"
|
||||
|
||||
[dependencies.time]
|
||||
version = "0.3.20"
|
||||
features = ["macros", "formatting", "parsing", "serde"]
|
||||
|
||||
[dependencies.tokio-tungstenite]
|
||||
version = "0.18.0"
|
||||
features = ["rustls-tls-native-roots"]
|
||||
|
||||
[dependencies.euphoxide]
|
||||
git = "https://github.com/Garmelon/euphoxide.git"
|
||||
rev = "0f217a6279181b0731216760219e8ff0fa01e449"
|
||||
features = ["bot"]
|
||||
|
||||
# [patch."https://github.com/Garmelon/euphoxide.git"]
|
||||
# euphoxide = { path = "../euphoxide/" }
|
||||
|
||||
[dependencies.toss]
|
||||
git = "https://github.com/Garmelon/toss.git"
|
||||
rev = "f414db40d526295c74cbcae6c3d194088da8f1d9"
|
||||
|
||||
# [patch."https://github.com/Garmelon/toss.git"]
|
||||
# toss = { path = "../toss/" }
|
||||
|
||||
[dependencies.vault]
|
||||
git = "https://github.com/Garmelon/vault.git"
|
||||
rev = "b4cf23b7279770226725c895e482c8eda88c43a7"
|
||||
features = ["tokio"]
|
||||
|
||||
# [patch."https://github.com/Garmelon/vault.git"]
|
||||
# vault = { path = "../vault/" }
|
||||
61
cove/src/config.rs
Normal file
61
cove/src/config.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::macros::ok_or_return;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RoomsSortOrder {
|
||||
#[default]
|
||||
Alphabet,
|
||||
Importance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct EuphRoom {
|
||||
// TODO Mark favourite rooms via printable ascii characters
|
||||
#[serde(default)]
|
||||
pub autojoin: bool,
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub force_username: bool,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Euph {
|
||||
pub rooms: HashMap<String, EuphRoom>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
pub data_dir: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
#[serde(default)]
|
||||
pub offline: bool,
|
||||
#[serde(default)]
|
||||
pub rooms_sort_order: RoomsSortOrder,
|
||||
// TODO Invoke external notification command?
|
||||
pub euph: Euph,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let content = ok_or_return!(fs::read_to_string(path), Self::default());
|
||||
match toml::from_str(&content) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading config file: {err}");
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn euph_room(&self, name: &str) -> EuphRoom {
|
||||
self.euph.rooms.get(name).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
7
cove/src/euph.rs
Normal file
7
cove/src/euph.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod room;
|
||||
mod small_message;
|
||||
mod util;
|
||||
|
||||
pub use room::*;
|
||||
pub use small_message::*;
|
||||
pub use util::*;
|
||||
322
cove/src/euph/room.rs
Normal file
322
cove/src/euph/room.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// TODO Stop if room does not exist (e.g. 404)
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use euphoxide::api::packet::ParsedPacket;
|
||||
use euphoxide::api::{
|
||||
Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply, Time,
|
||||
UserId,
|
||||
};
|
||||
use euphoxide::bot::instance::{Event, Instance, InstanceConfig, Snapshot};
|
||||
use euphoxide::conn::{self, ConnTx};
|
||||
use log::{debug, error, info, warn};
|
||||
use tokio::select;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::macros::{logging_unwrap, ok_or_return};
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
const LOG_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum State {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected(ConnTx, conn::State),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn conn_tx(&self) -> Option<&ConnTx> {
|
||||
if let Self::Connected(conn_tx, _) = self {
|
||||
Some(conn_tx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("not connected to room")]
|
||||
NotConnected,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Room {
|
||||
vault: EuphRoomVault,
|
||||
ephemeral: bool,
|
||||
|
||||
instance: Instance,
|
||||
state: State,
|
||||
|
||||
/// `None` before any `snapshot-event`, then either `Some(None)` or
|
||||
/// `Some(Some(id))`. Reset whenever connection is lost.
|
||||
last_msg_id: Option<Option<MessageId>>,
|
||||
|
||||
/// `Some` while `Self::regularly_request_logs` is running. Set to `None` to
|
||||
/// drop the sender and stop the task.
|
||||
log_request_canary: Option<oneshot::Sender<Infallible>>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn new<F>(vault: EuphRoomVault, instance_config: InstanceConfig, on_event: F) -> Self
|
||||
where
|
||||
F: Fn(Event) + std::marker::Send + Sync + 'static,
|
||||
{
|
||||
// &rl2dev's message history is broken and requesting old messages past
|
||||
// a certain point results in errors. Cove should not keep retrying log
|
||||
// requests when hitting that limit, so &rl2dev is always opened in
|
||||
// ephemeral mode.
|
||||
let ephemeral = vault.vault().vault().ephemeral() || vault.room() == "rl2dev";
|
||||
|
||||
Self {
|
||||
vault,
|
||||
ephemeral,
|
||||
instance: instance_config.build(on_event),
|
||||
state: State::Disconnected,
|
||||
last_msg_id: None,
|
||||
log_request_canary: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopped(&self) -> bool {
|
||||
self.instance.stopped()
|
||||
}
|
||||
|
||||
pub fn instance(&self) -> &Instance {
|
||||
&self.instance
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &State {
|
||||
&self.state
|
||||
}
|
||||
|
||||
fn conn_tx(&self) -> Result<&ConnTx, Error> {
|
||||
self.state.conn_tx().ok_or(Error::NotConnected)
|
||||
}
|
||||
|
||||
pub async fn handle_event(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Connecting(_) => {
|
||||
self.state = State::Connecting;
|
||||
|
||||
// Juuust to make sure
|
||||
self.last_msg_id = None;
|
||||
self.log_request_canary = None;
|
||||
}
|
||||
Event::Connected(_, Snapshot { conn_tx, state }) => {
|
||||
if !self.ephemeral {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.log_request_canary = Some(tx);
|
||||
let vault_clone = self.vault.clone();
|
||||
let conn_tx_clone = conn_tx.clone();
|
||||
debug!("{}: spawning log request task", self.instance.config().room);
|
||||
tokio::task::spawn(async move {
|
||||
select! {
|
||||
_ = rx => {},
|
||||
_ = Self::regularly_request_logs(vault_clone, conn_tx_clone) => {},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.state = State::Connected(conn_tx, state);
|
||||
|
||||
let cookies = &*self.instance.config().server.cookies;
|
||||
let cookies = cookies.lock().unwrap().clone();
|
||||
logging_unwrap!(self.vault.vault().set_cookies(cookies).await);
|
||||
}
|
||||
Event::Packet(_, packet, Snapshot { conn_tx, state }) => {
|
||||
self.state = State::Connected(conn_tx, state);
|
||||
self.on_packet(packet).await;
|
||||
}
|
||||
Event::Disconnected(_) => {
|
||||
self.state = State::Disconnected;
|
||||
self.last_msg_id = None;
|
||||
self.log_request_canary = None;
|
||||
}
|
||||
Event::Stopped(_) => {
|
||||
// TODO Remove room somewhere if this happens? If it doesn't already happen during stabilization
|
||||
self.state = State::Stopped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn regularly_request_logs(vault: EuphRoomVault, conn_tx: ConnTx) {
|
||||
// TODO Make log downloading smarter
|
||||
|
||||
// Possible log-related mechanics. Some of these could also run in some
|
||||
// sort of "repair logs" mode that can be started via some key binding.
|
||||
// For now, this is just a list of ideas.
|
||||
//
|
||||
// Download room history until there are no more gaps between now and
|
||||
// the first known message.
|
||||
//
|
||||
// Download room history until reaching the beginning of the room's
|
||||
// history.
|
||||
//
|
||||
// Check if the last known message still exists on the server. If it
|
||||
// doesn't, do a binary search to find the server's last message and
|
||||
// delete all older messages.
|
||||
//
|
||||
// Untruncate messages in the history, as well as new messages.
|
||||
//
|
||||
// Try to retrieve messages that are not in the room log by retrieving
|
||||
// them by id.
|
||||
//
|
||||
// Redownload messages that are already known to find any edits and
|
||||
// deletions that happened while the client was offline.
|
||||
//
|
||||
// Delete messages marked as deleted as well as all their children.
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(LOG_INTERVAL).await;
|
||||
Self::request_logs(&vault, &conn_tx).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_logs(vault: &EuphRoomVault, conn_tx: &ConnTx) {
|
||||
let before = match logging_unwrap!(vault.last_span().await) {
|
||||
Some((None, _)) => return, // Already at top of room history
|
||||
Some((Some(before), _)) => Some(before),
|
||||
None => None,
|
||||
};
|
||||
|
||||
debug!("{}: requesting logs", vault.room());
|
||||
|
||||
// &rl2dev's message history is broken and requesting old messages past
|
||||
// a certain point results in errors. By reducing the amount of messages
|
||||
// in each log request, we can get closer to this point. Since &rl2dev
|
||||
// is fairly low in activity, this should be fine.
|
||||
let n = if vault.room() == "rl2dev" { 50 } else { 1000 };
|
||||
|
||||
let _ = conn_tx.send(Log { n, before }).await;
|
||||
// The code handling incoming events and replies also handles
|
||||
// `LogReply`s, so we don't need to do anything special here.
|
||||
}
|
||||
|
||||
fn own_user_id(&self) -> Option<UserId> {
|
||||
if let State::Connected(_, state) = &self.state {
|
||||
Some(match state {
|
||||
conn::State::Joining(joining) => joining.hello.as_ref()?.session.id.clone(),
|
||||
conn::State::Joined(joined) => joined.session.id.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_packet(&mut self, packet: ParsedPacket) {
|
||||
let room_name = &self.instance.config().room;
|
||||
let data = ok_or_return!(&packet.content);
|
||||
match data {
|
||||
Data::BounceEvent(_) => {}
|
||||
Data::DisconnectEvent(_) => {}
|
||||
Data::HelloEvent(_) => {}
|
||||
Data::JoinEvent(d) => {
|
||||
debug!("{room_name}: {:?} joined", d.0.name);
|
||||
}
|
||||
Data::LoginEvent(_) => {}
|
||||
Data::LogoutEvent(_) => {}
|
||||
Data::NetworkEvent(d) => {
|
||||
warn!("{room_name}: network event ({})", d.r#type);
|
||||
}
|
||||
Data::NickEvent(d) => {
|
||||
debug!("{room_name}: {:?} renamed to {:?}", d.from, d.to);
|
||||
}
|
||||
Data::EditMessageEvent(_) => {
|
||||
info!("{room_name}: a message was edited");
|
||||
}
|
||||
Data::PartEvent(d) => {
|
||||
debug!("{room_name}: {:?} left", d.0.name);
|
||||
}
|
||||
Data::PingEvent(_) => {}
|
||||
Data::PmInitiateEvent(d) => {
|
||||
// TODO Show info popup and automatically join PM room
|
||||
info!(
|
||||
"{room_name}: {:?} initiated a pm from &{}",
|
||||
d.from_nick, d.from_room
|
||||
);
|
||||
}
|
||||
Data::SendEvent(SendEvent(msg)) | Data::SendReply(SendReply(msg)) => {
|
||||
let own_user_id = self.own_user_id();
|
||||
if let Some(last_msg_id) = &mut self.last_msg_id {
|
||||
logging_unwrap!(
|
||||
self.vault
|
||||
.add_msg(Box::new(msg.clone()), *last_msg_id, own_user_id)
|
||||
.await
|
||||
);
|
||||
*last_msg_id = Some(msg.id);
|
||||
}
|
||||
}
|
||||
Data::SnapshotEvent(d) => {
|
||||
info!("{room_name}: successfully joined");
|
||||
logging_unwrap!(self.vault.join(Time::now()).await);
|
||||
self.last_msg_id = Some(d.log.last().map(|m| m.id));
|
||||
logging_unwrap!(
|
||||
self.vault
|
||||
.add_msgs(d.log.clone(), None, self.own_user_id())
|
||||
.await
|
||||
);
|
||||
}
|
||||
Data::LogReply(d) => {
|
||||
logging_unwrap!(
|
||||
self.vault
|
||||
.add_msgs(d.log.clone(), d.before, self.own_user_id())
|
||||
.await
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth(&self, password: String) -> Result<(), Error> {
|
||||
self.conn_tx()?.send_only(Auth {
|
||||
r#type: AuthOption::Passcode,
|
||||
passcode: Some(password),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log(&self) -> Result<(), Error> {
|
||||
let conn_tx_clone = self.conn_tx()?.clone();
|
||||
let vault_clone = self.vault.clone();
|
||||
tokio::task::spawn(async move { Self::request_logs(&vault_clone, &conn_tx_clone).await });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn nick(&self, name: String) -> Result<(), Error> {
|
||||
self.conn_tx()?.send_only(Nick { name });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send(
|
||||
&self,
|
||||
parent: Option<MessageId>,
|
||||
content: String,
|
||||
) -> Result<oneshot::Receiver<MessageId>, Error> {
|
||||
let reply = self.conn_tx()?.send(Send { content, parent });
|
||||
let (tx, rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(reply) = reply.await {
|
||||
let _ = tx.send(reply.0.id);
|
||||
}
|
||||
});
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
pub fn login(&self, email: String, password: String) -> Result<(), Error> {
|
||||
self.conn_tx()?.send_only(Login {
|
||||
namespace: "email".to_string(),
|
||||
id: email,
|
||||
password,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn logout(&self) -> Result<(), Error> {
|
||||
self.conn_tx()?.send_only(Logout {});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
292
cove/src/euph/small_message.rs
Normal file
292
cove/src/euph/small_message.rs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
use std::mem;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::{MessageId, Snowflake, Time};
|
||||
use time::OffsetDateTime;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::store::Msg;
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
use super::util;
|
||||
|
||||
fn nick_char(ch: char) -> bool {
|
||||
// Closely following the heim mention regex:
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14-L15
|
||||
// `>` has been experimentally confirmed to delimit mentions as well.
|
||||
match ch {
|
||||
',' | '.' | '!' | '?' | ';' | '&' | '<' | '>' | '\'' | '"' => false,
|
||||
_ => !ch.is_whitespace(),
|
||||
}
|
||||
}
|
||||
|
||||
fn room_char(ch: char) -> bool {
|
||||
// Basically just \w, see also
|
||||
// https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/ui/MessageText.js#L66
|
||||
ch.is_ascii_alphanumeric() || ch == '_'
|
||||
}
|
||||
|
||||
enum Span {
|
||||
Nothing,
|
||||
Mention,
|
||||
Room,
|
||||
Emoji,
|
||||
}
|
||||
|
||||
struct Highlighter<'a> {
|
||||
content: &'a str,
|
||||
base_style: Style,
|
||||
exact: bool,
|
||||
|
||||
span: Span,
|
||||
span_start: usize,
|
||||
room_or_mention_possible: bool,
|
||||
|
||||
result: Styled,
|
||||
}
|
||||
|
||||
impl<'a> Highlighter<'a> {
|
||||
/// Does *not* guarantee `self.span_start == idx` after running!
|
||||
fn close_mention(&mut self, idx: usize) {
|
||||
let span_length = idx.saturating_sub(self.span_start);
|
||||
if span_length <= 1 {
|
||||
// We can repurpose the current span
|
||||
self.span = Span::Nothing;
|
||||
return;
|
||||
}
|
||||
|
||||
let text = &self.content[self.span_start..idx]; // Includes @
|
||||
self.result = mem::take(&mut self.result).and_then(if self.exact {
|
||||
util::style_nick_exact(text, self.base_style)
|
||||
} else {
|
||||
util::style_nick(text, self.base_style)
|
||||
});
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
/// Does *not* guarantee `self.span_start == idx` after running!
|
||||
fn close_room(&mut self, idx: usize) {
|
||||
let span_length = idx.saturating_sub(self.span_start);
|
||||
if span_length <= 1 {
|
||||
// We can repurpose the current span
|
||||
self.span = Span::Nothing;
|
||||
return;
|
||||
}
|
||||
|
||||
self.result = mem::take(&mut self.result).then(
|
||||
&self.content[self.span_start..idx],
|
||||
self.base_style.blue().bold(),
|
||||
);
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
// Warning: `idx` is the index of the closing colon.
|
||||
fn close_emoji(&mut self, idx: usize) {
|
||||
let name = &self.content[self.span_start + 1..idx];
|
||||
if let Some(replace) = util::EMOJI.get(name) {
|
||||
match replace {
|
||||
Some(replace) if !self.exact => {
|
||||
self.result = mem::take(&mut self.result).then(replace, self.base_style);
|
||||
}
|
||||
_ => {
|
||||
let text = &self.content[self.span_start..=idx];
|
||||
let style = self.base_style.magenta();
|
||||
self.result = mem::take(&mut self.result).then(text, style);
|
||||
}
|
||||
}
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx + 1;
|
||||
} else {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Emoji;
|
||||
}
|
||||
}
|
||||
|
||||
/// Guarantees `self.span_start == idx` after running.
|
||||
fn close_plain(&mut self, idx: usize) {
|
||||
if self.span_start == idx {
|
||||
// Span has length 0
|
||||
return;
|
||||
}
|
||||
|
||||
self.result =
|
||||
mem::take(&mut self.result).then(&self.content[self.span_start..idx], self.base_style);
|
||||
|
||||
self.span = Span::Nothing;
|
||||
self.span_start = idx;
|
||||
}
|
||||
|
||||
fn close_span_before_current_char(&mut self, idx: usize, char: char) {
|
||||
match self.span {
|
||||
Span::Mention if !nick_char(char) => self.close_mention(idx),
|
||||
Span::Room if !room_char(char) => self.close_room(idx),
|
||||
Span::Emoji if char == '&' || char == '@' => {
|
||||
self.span = Span::Nothing;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_span_with_current_char(&mut self, idx: usize, char: char) {
|
||||
match self.span {
|
||||
Span::Nothing if char == '@' && self.room_or_mention_possible => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Mention;
|
||||
}
|
||||
Span::Nothing if char == '&' && self.room_or_mention_possible => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Room;
|
||||
}
|
||||
Span::Nothing if char == ':' => {
|
||||
self.close_plain(idx);
|
||||
self.span = Span::Emoji;
|
||||
}
|
||||
Span::Emoji if char == ':' => self.close_emoji(idx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_final_span(&mut self) {
|
||||
let idx = self.content.len();
|
||||
if self.span_start >= idx {
|
||||
return; // Span has no contents
|
||||
}
|
||||
|
||||
match self.span {
|
||||
Span::Mention => self.close_mention(idx),
|
||||
Span::Room => self.close_room(idx),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.close_plain(idx);
|
||||
}
|
||||
|
||||
fn step(&mut self, idx: usize, char: char) {
|
||||
if self.span_start < idx {
|
||||
self.close_span_before_current_char(idx, char);
|
||||
}
|
||||
|
||||
self.update_span_with_current_char(idx, char);
|
||||
|
||||
// More permissive than the heim web client
|
||||
self.room_or_mention_possible = !char.is_alphanumeric();
|
||||
}
|
||||
|
||||
fn highlight(content: &'a str, base_style: Style, exact: bool) -> Styled {
|
||||
let mut this = Self {
|
||||
content: if exact { content } else { content.trim() },
|
||||
base_style,
|
||||
exact,
|
||||
span: Span::Nothing,
|
||||
span_start: 0,
|
||||
room_or_mention_possible: true,
|
||||
result: Styled::default(),
|
||||
};
|
||||
|
||||
for (idx, char) in (if exact { content } else { content.trim() }).char_indices() {
|
||||
this.step(idx, char);
|
||||
}
|
||||
|
||||
this.close_final_span();
|
||||
|
||||
this.result
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_content(content: &str, base_style: Style, exact: bool) -> Styled {
|
||||
Highlighter::highlight(content, base_style, exact)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmallMessage {
|
||||
pub id: MessageId,
|
||||
pub parent: Option<MessageId>,
|
||||
pub time: Time,
|
||||
pub nick: String,
|
||||
pub content: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
fn as_me(content: &str) -> Option<&str> {
|
||||
content.strip_prefix("/me")
|
||||
}
|
||||
|
||||
fn style_me() -> Style {
|
||||
Style::new().grey().italic()
|
||||
}
|
||||
|
||||
fn styled_nick(nick: &str) -> Styled {
|
||||
Styled::new_plain("[")
|
||||
.and_then(util::style_nick(nick, Style::new()))
|
||||
.then_plain("]")
|
||||
}
|
||||
|
||||
fn styled_nick_me(nick: &str) -> Styled {
|
||||
let style = style_me();
|
||||
Styled::new("*", style).and_then(util::style_nick(nick, style))
|
||||
}
|
||||
|
||||
fn styled_content(content: &str) -> Styled {
|
||||
highlight_content(content.trim(), Style::new(), false)
|
||||
}
|
||||
|
||||
fn styled_content_me(content: &str) -> Styled {
|
||||
let style = style_me();
|
||||
highlight_content(content.trim(), style, false).then("*", style)
|
||||
}
|
||||
|
||||
fn styled_editor_content(content: &str) -> Styled {
|
||||
let style = if as_me(content).is_some() {
|
||||
style_me()
|
||||
} else {
|
||||
Style::new()
|
||||
};
|
||||
highlight_content(content, style, true)
|
||||
}
|
||||
|
||||
impl Msg for SmallMessage {
|
||||
type Id = MessageId;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn parent(&self) -> Option<Self::Id> {
|
||||
self.parent
|
||||
}
|
||||
|
||||
fn seen(&self) -> bool {
|
||||
self.seen
|
||||
}
|
||||
|
||||
fn last_possible_id() -> Self::Id {
|
||||
MessageId(Snowflake::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMsg for SmallMessage {
|
||||
fn time(&self) -> OffsetDateTime {
|
||||
self.time.0
|
||||
}
|
||||
|
||||
fn styled(&self) -> (Styled, Styled) {
|
||||
Self::pseudo(&self.nick, &self.content)
|
||||
}
|
||||
|
||||
fn edit(nick: &str, content: &str) -> (Styled, Styled) {
|
||||
(styled_nick(nick), styled_editor_content(content))
|
||||
}
|
||||
|
||||
fn pseudo(nick: &str, content: &str) -> (Styled, Styled) {
|
||||
if let Some(content) = as_me(content) {
|
||||
(styled_nick_me(nick), styled_content_me(content))
|
||||
} else {
|
||||
(styled_nick(nick), styled_content(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
56
cove/src/euph/util.rs
Normal file
56
cove/src/euph/util.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use crossterm::style::{Color, Stylize};
|
||||
use euphoxide::Emoji;
|
||||
use once_cell::sync::Lazy;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
pub static EMOJI: Lazy<Emoji> = Lazy::new(Emoji::load);
|
||||
|
||||
/// Convert HSL to RGB following [this approach from wikipedia][1].
|
||||
///
|
||||
/// `h` must be in the range `[0, 360]`, `s` and `l` in the range `[0, 1]`.
|
||||
///
|
||||
/// [1]: https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
|
||||
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||
assert!((0.0..=360.0).contains(&h), "h must be in range [0, 360]");
|
||||
assert!((0.0..=1.0).contains(&s), "s must be in range [0, 1]");
|
||||
assert!((0.0..=1.0).contains(&l), "l must be in range [0, 1]");
|
||||
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
|
||||
let h_prime = h / 60.0;
|
||||
let x = c * (1.0 - (h_prime.rem_euclid(2.0) - 1.0).abs());
|
||||
|
||||
let (r1, g1, b1) = match () {
|
||||
_ if h_prime < 1.0 => (c, x, 0.0),
|
||||
_ if h_prime < 2.0 => (x, c, 0.0),
|
||||
_ if h_prime < 3.0 => (0.0, c, x),
|
||||
_ if h_prime < 4.0 => (0.0, x, c),
|
||||
_ if h_prime < 5.0 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
|
||||
let m = l - c / 2.0;
|
||||
let (r, g, b) = (r1 + m, g1 + m, b1 + m);
|
||||
|
||||
// The rgb values in the range [0,1] are each split into 256 segments of the
|
||||
// same length, which are then assigned to the 256 possible values of an u8.
|
||||
((r * 256.0) as u8, (g * 256.0) as u8, (b * 256.0) as u8)
|
||||
}
|
||||
|
||||
pub fn nick_color(nick: &str) -> (u8, u8, u8) {
|
||||
let hue = euphoxide::nick::hue(&EMOJI, nick) as f32;
|
||||
hsl_to_rgb(hue, 1.0, 0.72)
|
||||
}
|
||||
|
||||
pub fn nick_style(nick: &str, base: Style) -> Style {
|
||||
let (r, g, b) = nick_color(nick);
|
||||
base.bold().with(Color::Rgb { r, g, b })
|
||||
}
|
||||
|
||||
pub fn style_nick(nick: &str, base: Style) -> Styled {
|
||||
Styled::new(EMOJI.replace(nick), nick_style(nick, base))
|
||||
}
|
||||
|
||||
pub fn style_nick_exact(nick: &str, base: Style) -> Styled {
|
||||
Styled::new(nick, nick_style(nick, base))
|
||||
}
|
||||
145
cove/src/export.rs
Normal file
145
cove/src/export.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! Export logs from the vault to plain text files.
|
||||
|
||||
mod json;
|
||||
mod text;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
|
||||
use crate::vault::{EuphRoomVault, EuphVault};
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
pub enum Format {
|
||||
/// Human-readable tree-structured messages.
|
||||
Text,
|
||||
/// Array of message objects in the same format as the euphoria API uses.
|
||||
Json,
|
||||
/// Message objects in the same format as the euphoria API uses, one per line.
|
||||
JsonStream,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::Json => "json",
|
||||
Self::JsonStream => "json stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "txt",
|
||||
Self::Json | Self::JsonStream => "json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
pub struct Args {
|
||||
rooms: Vec<String>,
|
||||
|
||||
/// Export all rooms.
|
||||
#[arg(long, short)]
|
||||
all: bool,
|
||||
|
||||
/// Format of the output file.
|
||||
#[arg(long, short, value_enum, default_value_t = Format::Text)]
|
||||
format: Format,
|
||||
|
||||
/// Location of the output file
|
||||
///
|
||||
/// May include the following placeholders:
|
||||
/// `%r` - room name
|
||||
/// `%e` - format extension
|
||||
/// A literal `%` can be written as `%%`.
|
||||
///
|
||||
/// If the value ends with a `/`, it is assumed to point to a directory and
|
||||
/// `%r.%e` will be appended.
|
||||
///
|
||||
/// If the value is a literal `-`, the export will be written to stdout. To
|
||||
/// write to a file named `-`, you can use `./-`.
|
||||
///
|
||||
/// Must be a valid utf-8 encoded string.
|
||||
#[arg(long, short, default_value_t = Into::into("%r.%e"))]
|
||||
#[arg(verbatim_doc_comment)]
|
||||
out: String,
|
||||
}
|
||||
|
||||
async fn export_room<W: Write>(
|
||||
vault: &EuphRoomVault,
|
||||
out: &mut W,
|
||||
format: Format,
|
||||
) -> anyhow::Result<()> {
|
||||
match format {
|
||||
Format::Text => text::export(vault, out).await?,
|
||||
Format::Json => json::export(vault, out).await?,
|
||||
Format::JsonStream => json::export_stream(vault, out).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn export(vault: &EuphVault, mut args: Args) -> anyhow::Result<()> {
|
||||
if args.out.ends_with('/') {
|
||||
args.out.push_str("%r.%e");
|
||||
}
|
||||
|
||||
let rooms = if args.all {
|
||||
let mut rooms = vault.rooms().await?;
|
||||
rooms.sort_unstable();
|
||||
rooms
|
||||
} else {
|
||||
let mut rooms = args.rooms.clone();
|
||||
rooms.dedup();
|
||||
rooms
|
||||
};
|
||||
|
||||
if rooms.is_empty() {
|
||||
eprintln!("No rooms to export");
|
||||
}
|
||||
|
||||
for room in rooms {
|
||||
if args.out == "-" {
|
||||
eprintln!("Exporting &{room} as {} to stdout", args.format.name());
|
||||
let vault = vault.room(room);
|
||||
let mut stdout = BufWriter::new(io::stdout());
|
||||
export_room(&vault, &mut stdout, args.format).await?;
|
||||
stdout.flush()?;
|
||||
} else {
|
||||
let out = format_out(&args.out, &room, args.format);
|
||||
eprintln!("Exporting &{room} as {} to {out}", args.format.name());
|
||||
let vault = vault.room(room);
|
||||
let mut file = BufWriter::new(File::create(out)?);
|
||||
export_room(&vault, &mut file, args.format).await?;
|
||||
file.flush()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_out(out: &str, room: &str, format: Format) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
let mut special = false;
|
||||
for char in out.chars() {
|
||||
if special {
|
||||
match char {
|
||||
'r' => result.push_str(room),
|
||||
'e' => result.push_str(format.extension()),
|
||||
'%' => result.push('%'),
|
||||
_ => {
|
||||
result.push('%');
|
||||
result.push(char);
|
||||
}
|
||||
}
|
||||
special = false;
|
||||
} else if char == '%' {
|
||||
special = true;
|
||||
} else {
|
||||
result.push(char);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
63
cove/src/export/json.rs
Normal file
63
cove/src/export/json.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::io::Write;
|
||||
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
const CHUNK_SIZE: usize = 10000;
|
||||
|
||||
pub async fn export<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
write!(file, "[")?;
|
||||
|
||||
let mut total = 0;
|
||||
let mut last_msg_id = None;
|
||||
loop {
|
||||
let messages = vault.chunk_after(last_msg_id, CHUNK_SIZE).await?;
|
||||
last_msg_id = Some(match messages.last() {
|
||||
Some(last_msg) => last_msg.id,
|
||||
None => break, // No more messages, export finished
|
||||
});
|
||||
|
||||
for message in messages {
|
||||
if total == 0 {
|
||||
writeln!(file)?;
|
||||
} else {
|
||||
writeln!(file, ",")?;
|
||||
}
|
||||
serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D
|
||||
total += 1;
|
||||
}
|
||||
|
||||
if total % 100000 == 0 {
|
||||
eprintln!(" {total} messages");
|
||||
}
|
||||
}
|
||||
|
||||
write!(file, "\n]")?;
|
||||
|
||||
eprintln!(" {total} messages in total");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn export_stream<W: Write>(vault: &EuphRoomVault, file: &mut W) -> anyhow::Result<()> {
|
||||
let mut total = 0;
|
||||
let mut last_msg_id = None;
|
||||
loop {
|
||||
let messages = vault.chunk_after(last_msg_id, CHUNK_SIZE).await?;
|
||||
last_msg_id = Some(match messages.last() {
|
||||
Some(last_msg) => last_msg.id,
|
||||
None => break, // No more messages, export finished
|
||||
});
|
||||
|
||||
for message in messages {
|
||||
serde_json::to_writer(&mut *file, &message)?; // Fancy reborrow! :D
|
||||
writeln!(file)?;
|
||||
total += 1;
|
||||
}
|
||||
|
||||
if total % 100000 == 0 {
|
||||
eprintln!(" {total} messages");
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(" {total} messages in total");
|
||||
Ok(())
|
||||
}
|
||||
87
cove/src/export/text.rs
Normal file
87
cove/src/export/text.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use std::io::Write;
|
||||
|
||||
use euphoxide::api::MessageId;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::euph::SmallMessage;
|
||||
use crate::store::Tree;
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
const TIME_FORMAT: &[FormatItem<'_>] =
|
||||
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
|
||||
const TIME_EMPTY: &str = " ";
|
||||
|
||||
pub async fn export<W: Write>(vault: &EuphRoomVault, out: &mut W) -> anyhow::Result<()> {
|
||||
let mut exported_trees = 0;
|
||||
let mut exported_msgs = 0;
|
||||
let mut root_id = vault.first_root_id().await?;
|
||||
while let Some(some_root_id) = root_id {
|
||||
let tree = vault.tree(some_root_id).await?;
|
||||
write_tree(out, &tree, some_root_id, 0)?;
|
||||
root_id = vault.next_root_id(some_root_id).await?;
|
||||
|
||||
exported_trees += 1;
|
||||
exported_msgs += tree.len();
|
||||
|
||||
if exported_trees % 10000 == 0 {
|
||||
eprintln!(" {exported_trees} trees, {exported_msgs} messages")
|
||||
}
|
||||
}
|
||||
eprintln!(" {exported_trees} trees, {exported_msgs} messages in total");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tree<W: Write>(
|
||||
out: &mut W,
|
||||
tree: &Tree<SmallMessage>,
|
||||
id: MessageId,
|
||||
indent: usize,
|
||||
) -> anyhow::Result<()> {
|
||||
let indent_string = "| ".repeat(indent);
|
||||
|
||||
if let Some(msg) = tree.msg(&id) {
|
||||
write_msg(out, &indent_string, msg)?;
|
||||
} else {
|
||||
write_placeholder(out, &indent_string)?;
|
||||
}
|
||||
|
||||
if let Some(children) = tree.children(&id) {
|
||||
for child in children {
|
||||
write_tree(out, tree, *child, indent + 1)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_msg<W: Write>(
|
||||
file: &mut W,
|
||||
indent_string: &str,
|
||||
msg: &SmallMessage,
|
||||
) -> anyhow::Result<()> {
|
||||
let nick = &msg.nick;
|
||||
let nick_empty = " ".repeat(nick.width());
|
||||
|
||||
for (i, line) in msg.content.lines().enumerate() {
|
||||
if i == 0 {
|
||||
let time = msg
|
||||
.time
|
||||
.0
|
||||
.format(TIME_FORMAT)
|
||||
.expect("time can be formatted");
|
||||
writeln!(file, "{time} {indent_string}[{nick}] {line}")?;
|
||||
} else {
|
||||
writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_placeholder<W: Write>(file: &mut W, indent_string: &str) -> anyhow::Result<()> {
|
||||
writeln!(file, "{TIME_EMPTY} {indent_string}[...]")?;
|
||||
Ok(())
|
||||
}
|
||||
245
cove/src/logger.rs
Normal file
245
cove/src/logger.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::vec;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crossterm::style::Stylize;
|
||||
use log::{Level, LevelFilter, Log};
|
||||
use parking_lot::Mutex;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::{Style, Styled};
|
||||
|
||||
use crate::store::{Msg, MsgStore, Path, Tree};
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogMsg {
|
||||
id: usize,
|
||||
time: OffsetDateTime,
|
||||
level: Level,
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl Msg for LogMsg {
|
||||
type Id = usize;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn parent(&self) -> Option<Self::Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn seen(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn last_possible_id() -> Self::Id {
|
||||
Self::Id::MAX
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatMsg for LogMsg {
|
||||
fn time(&self) -> OffsetDateTime {
|
||||
self.time
|
||||
}
|
||||
|
||||
fn styled(&self) -> (Styled, Styled) {
|
||||
let nick_style = match self.level {
|
||||
Level::Error => Style::new().bold().red(),
|
||||
Level::Warn => Style::new().bold().yellow(),
|
||||
Level::Info => Style::new().bold().green(),
|
||||
Level::Debug => Style::new().bold().blue(),
|
||||
Level::Trace => Style::new().bold().magenta(),
|
||||
};
|
||||
let nick = Styled::new(format!("{}", self.level), nick_style);
|
||||
let content = Styled::new_plain(&self.content);
|
||||
(nick, content)
|
||||
}
|
||||
|
||||
fn edit(_nick: &str, _content: &str) -> (Styled, Styled) {
|
||||
panic!("log is not editable")
|
||||
}
|
||||
|
||||
fn pseudo(_nick: &str, _content: &str) -> (Styled, Styled) {
|
||||
panic!("log is not editable")
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints all error messages when dropped.
|
||||
pub struct LoggerGuard {
|
||||
messages: Arc<Mutex<Vec<LogMsg>>>,
|
||||
}
|
||||
|
||||
impl Drop for LoggerGuard {
|
||||
fn drop(&mut self) {
|
||||
let guard = self.messages.lock();
|
||||
let mut error_encountered = false;
|
||||
for msg in &*guard {
|
||||
if msg.level == Level::Error {
|
||||
if !error_encountered {
|
||||
eprintln!();
|
||||
eprintln!("The following errors occurred while cove was running:");
|
||||
}
|
||||
error_encountered = true;
|
||||
eprintln!("{}", msg.content);
|
||||
}
|
||||
}
|
||||
if error_encountered {
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Logger {
|
||||
event_tx: mpsc::UnboundedSender<()>,
|
||||
messages: Arc<Mutex<Vec<LogMsg>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MsgStore<LogMsg> for Logger {
|
||||
type Error = Infallible;
|
||||
|
||||
async fn path(&self, id: &usize) -> Result<Path<usize>, Self::Error> {
|
||||
Ok(Path::new(vec![*id]))
|
||||
}
|
||||
|
||||
async fn msg(&self, id: &usize) -> Result<Option<LogMsg>, Self::Error> {
|
||||
Ok(self.messages.lock().get(*id).cloned())
|
||||
}
|
||||
|
||||
async fn tree(&self, root_id: &usize) -> Result<Tree<LogMsg>, Self::Error> {
|
||||
let msgs = self
|
||||
.messages
|
||||
.lock()
|
||||
.get(*root_id)
|
||||
.map(|msg| vec![msg.clone()])
|
||||
.unwrap_or_default();
|
||||
Ok(Tree::new(*root_id, msgs))
|
||||
}
|
||||
|
||||
async fn first_root_id(&self) -> Result<Option<usize>, Self::Error> {
|
||||
let empty = self.messages.lock().is_empty();
|
||||
Ok(Some(0).filter(|_| !empty))
|
||||
}
|
||||
|
||||
async fn last_root_id(&self) -> Result<Option<usize>, Self::Error> {
|
||||
Ok(self.messages.lock().len().checked_sub(1))
|
||||
}
|
||||
|
||||
async fn prev_root_id(&self, root_id: &usize) -> Result<Option<usize>, Self::Error> {
|
||||
Ok(root_id.checked_sub(1))
|
||||
}
|
||||
|
||||
async fn next_root_id(&self, root_id: &usize) -> Result<Option<usize>, Self::Error> {
|
||||
let len = self.messages.lock().len();
|
||||
Ok(root_id.checked_add(1).filter(|t| *t < len))
|
||||
}
|
||||
|
||||
async fn oldest_msg_id(&self) -> Result<Option<usize>, Self::Error> {
|
||||
self.first_root_id().await
|
||||
}
|
||||
|
||||
async fn newest_msg_id(&self) -> Result<Option<usize>, Self::Error> {
|
||||
self.last_root_id().await
|
||||
}
|
||||
|
||||
async fn older_msg_id(&self, id: &usize) -> Result<Option<usize>, Self::Error> {
|
||||
self.prev_root_id(id).await
|
||||
}
|
||||
|
||||
async fn newer_msg_id(&self, id: &usize) -> Result<Option<usize>, Self::Error> {
|
||||
self.next_root_id(id).await
|
||||
}
|
||||
|
||||
async fn oldest_unseen_msg_id(&self) -> Result<Option<usize>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn newest_unseen_msg_id(&self) -> Result<Option<usize>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn older_unseen_msg_id(&self, _id: &usize) -> Result<Option<usize>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn newer_unseen_msg_id(&self, _id: &usize) -> Result<Option<usize>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn unseen_msgs_count(&self) -> Result<usize, Self::Error> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn set_seen(&self, _id: &usize, _seen: bool) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_older_seen(&self, _id: &usize, _seen: bool) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Log for Logger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
if metadata.level() <= Level::Info {
|
||||
return true;
|
||||
}
|
||||
|
||||
let target = metadata.target();
|
||||
if target.starts_with("cove")
|
||||
|| target.starts_with("euphoxide::bot")
|
||||
|| target.starts_with("euphoxide::live")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut guard = self.messages.lock();
|
||||
let msg = LogMsg {
|
||||
id: guard.len(),
|
||||
time: OffsetDateTime::now_utc(),
|
||||
level: record.level(),
|
||||
content: format!("<{}> {}", record.target(), record.args()),
|
||||
};
|
||||
guard.push(msg);
|
||||
|
||||
let _ = self.event_tx.send(());
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
pub fn init(verbose: bool) -> (Self, LoggerGuard, mpsc::UnboundedReceiver<()>) {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let logger = Self {
|
||||
event_tx,
|
||||
messages: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let guard = LoggerGuard {
|
||||
messages: logger.messages.clone(),
|
||||
};
|
||||
|
||||
log::set_max_level(if verbose {
|
||||
LevelFilter::Debug
|
||||
} else {
|
||||
LevelFilter::Info
|
||||
});
|
||||
|
||||
log::set_boxed_logger(Box::new(logger.clone())).expect("logger already set");
|
||||
|
||||
(logger, guard, event_rx)
|
||||
}
|
||||
}
|
||||
45
cove/src/macros.rs
Normal file
45
cove/src/macros.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
macro_rules! some_or_return {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
Some(result) => result,
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
($e:expr, $ret:expr) => {
|
||||
match $e {
|
||||
Some(result) => result,
|
||||
None => return $ret,
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use some_or_return;
|
||||
|
||||
macro_rules! ok_or_return {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
Ok(result) => result,
|
||||
Err(_) => return,
|
||||
}
|
||||
};
|
||||
($e:expr, $ret:expr) => {
|
||||
match $e {
|
||||
Ok(result) => result,
|
||||
Err(_) => return $ret,
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use ok_or_return;
|
||||
|
||||
// TODO Get rid of this macro as much as possible
|
||||
macro_rules! logging_unwrap {
|
||||
($e:expr) => {
|
||||
match $e {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
log::error!("{err}");
|
||||
panic!("{err}");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use logging_unwrap;
|
||||
192
cove/src/main.rs
Normal file
192
cove/src/main.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#![forbid(unsafe_code)]
|
||||
// Rustc lint groups
|
||||
#![warn(future_incompatible)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
#![warn(unused)]
|
||||
// Rustc lints
|
||||
#![warn(noop_method_call)]
|
||||
#![warn(single_use_lifetimes)]
|
||||
// Clippy lints
|
||||
#![warn(clippy::use_self)]
|
||||
|
||||
// TODO Enable warn(unreachable_pub)?
|
||||
// TODO Remove unnecessary Debug impls and compare compile times
|
||||
// TODO Time zones other than UTC
|
||||
// TODO Fix password room auth
|
||||
|
||||
mod config;
|
||||
mod euph;
|
||||
mod export;
|
||||
mod logger;
|
||||
mod macros;
|
||||
mod store;
|
||||
mod ui;
|
||||
mod util;
|
||||
mod vault;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use cookie::CookieJar;
|
||||
use directories::{BaseDirs, ProjectDirs};
|
||||
use log::info;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::Terminal;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::logger::Logger;
|
||||
use crate::ui::Ui;
|
||||
use crate::vault::Vault;
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
enum Command {
|
||||
/// Run the client interactively (default).
|
||||
Run,
|
||||
/// Export room logs as plain text files.
|
||||
Export(export::Args),
|
||||
/// Compact and clean up vault.
|
||||
Gc,
|
||||
/// Clear euphoria session cookies.
|
||||
ClearCookies,
|
||||
}
|
||||
|
||||
impl Default for Command {
|
||||
fn default() -> Self {
|
||||
Self::Run
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
#[command(version)]
|
||||
struct Args {
|
||||
/// Show more detailed log messages.
|
||||
#[arg(long, short)]
|
||||
verbose: bool,
|
||||
|
||||
/// Path to the config file.
|
||||
///
|
||||
/// Relative paths are interpreted relative to the current directory.
|
||||
#[arg(long, short)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Path to a directory for cove to store its data in.
|
||||
///
|
||||
/// Relative paths are interpreted relative to the current directory.
|
||||
#[arg(long, short)]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
/// If set, cove won't store data permanently.
|
||||
#[arg(long, short)]
|
||||
ephemeral: bool,
|
||||
|
||||
/// If set, cove will ignore the autojoin config option.
|
||||
#[arg(long, short)]
|
||||
offline: bool,
|
||||
|
||||
/// Measure the width of characters as displayed by the terminal emulator
|
||||
/// instead of guessing the width.
|
||||
#[arg(long, short)]
|
||||
measure_widths: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
fn set_data_dir(config: &mut Config, args_data_dir: Option<PathBuf>) {
|
||||
if let Some(data_dir) = args_data_dir {
|
||||
// The data dir specified via args_data_dir is relative to the current
|
||||
// directory and needs no resolving.
|
||||
config.data_dir = Some(data_dir);
|
||||
} else if let Some(data_dir) = &config.data_dir {
|
||||
// Resolve the data dir specified in the config file relative to the
|
||||
// user's home directory, if possible.
|
||||
if let Some(base_dirs) = BaseDirs::new() {
|
||||
config.data_dir = Some(base_dirs.home_dir().join(data_dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_ephemeral(config: &mut Config, args_ephemeral: bool) {
|
||||
if args_ephemeral {
|
||||
config.ephemeral = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_offline(config: &mut Config, args_offline: bool) {
|
||||
if args_offline {
|
||||
config.offline = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
let (logger, logger_guard, logger_rx) = Logger::init(args.verbose);
|
||||
let dirs = ProjectDirs::from("de", "plugh", "cove").expect("unable to determine directories");
|
||||
|
||||
let config_path = args
|
||||
.config
|
||||
.unwrap_or_else(|| dirs.config_dir().join("config.toml"));
|
||||
eprintln!("Config file: {}", config_path.to_string_lossy());
|
||||
let mut config = Config::load(&config_path);
|
||||
set_data_dir(&mut config, args.data_dir);
|
||||
set_ephemeral(&mut config, args.ephemeral);
|
||||
set_offline(&mut config, args.offline);
|
||||
let config = Box::leak(Box::new(config));
|
||||
|
||||
let vault = if config.ephemeral {
|
||||
vault::launch_in_memory()?
|
||||
} else {
|
||||
let data_dir = config
|
||||
.data_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| dirs.data_dir().to_path_buf());
|
||||
eprintln!("Data dir: {}", data_dir.to_string_lossy());
|
||||
vault::launch(&data_dir.join("vault.db"))?
|
||||
};
|
||||
|
||||
match args.command.unwrap_or_default() {
|
||||
Command::Run => run(logger, logger_rx, config, &vault, args.measure_widths).await?,
|
||||
Command::Export(args) => export::export(&vault.euph(), args).await?,
|
||||
Command::Gc => {
|
||||
eprintln!("Cleaning up and compacting vault");
|
||||
eprintln!("This may take a while...");
|
||||
vault.gc().await?;
|
||||
}
|
||||
Command::ClearCookies => {
|
||||
eprintln!("Clearing cookies");
|
||||
vault.euph().set_cookies(CookieJar::new()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
vault.close().await;
|
||||
|
||||
// Print all logged errors. This should always happen, even if cove panics,
|
||||
// because the errors may be key in diagnosing what happened. Because of
|
||||
// this, it is not implemented via a normal function call.
|
||||
drop(logger_guard);
|
||||
|
||||
eprintln!("Goodbye!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(
|
||||
logger: Logger,
|
||||
logger_rx: mpsc::UnboundedReceiver<()>,
|
||||
config: &'static Config,
|
||||
vault: &Vault,
|
||||
measure_widths: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
info!(
|
||||
"Welcome to {} {}",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let mut terminal = Terminal::new()?;
|
||||
terminal.set_measuring(measure_widths);
|
||||
Ui::run(config, &mut terminal, vault.clone(), logger, logger_rx).await?;
|
||||
drop(terminal); // So other things can print again
|
||||
|
||||
Ok(())
|
||||
}
|
||||
158
cove/src/store.rs
Normal file
158
cove/src/store.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
use std::vec;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub trait Msg {
|
||||
type Id: Clone + Debug + Hash + Eq + Ord;
|
||||
fn id(&self) -> Self::Id;
|
||||
fn parent(&self) -> Option<Self::Id>;
|
||||
fn seen(&self) -> bool;
|
||||
|
||||
fn last_possible_id() -> Self::Id;
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Path<I>(Vec<I>);
|
||||
|
||||
impl<I> Path<I> {
|
||||
pub fn new(segments: Vec<I>) -> Self {
|
||||
assert!(!segments.is_empty(), "segments must not be empty");
|
||||
Self(segments)
|
||||
}
|
||||
|
||||
pub fn parent_segments(&self) -> impl Iterator<Item = &I> {
|
||||
self.0.iter().take(self.0.len() - 1)
|
||||
}
|
||||
|
||||
pub fn push(&mut self, segment: I) {
|
||||
self.0.push(segment)
|
||||
}
|
||||
|
||||
pub fn first(&self) -> &I {
|
||||
self.0.first().expect("path is empty")
|
||||
}
|
||||
|
||||
pub fn into_first(self) -> I {
|
||||
self.0.into_iter().next().expect("path is empty")
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> IntoIterator for Path<I> {
|
||||
type Item = I;
|
||||
type IntoIter = vec::IntoIter<I>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tree<M: Msg> {
|
||||
root: M::Id,
|
||||
msgs: HashMap<M::Id, M>,
|
||||
children: HashMap<M::Id, Vec<M::Id>>,
|
||||
}
|
||||
|
||||
impl<M: Msg> Tree<M> {
|
||||
pub fn new(root: M::Id, msgs: Vec<M>) -> Self {
|
||||
let msgs: HashMap<M::Id, M> = msgs.into_iter().map(|m| (m.id(), m)).collect();
|
||||
|
||||
let mut children: HashMap<M::Id, Vec<M::Id>> = HashMap::new();
|
||||
for msg in msgs.values() {
|
||||
children.entry(msg.id()).or_default();
|
||||
if let Some(parent) = msg.parent() {
|
||||
children.entry(parent).or_default().push(msg.id());
|
||||
}
|
||||
}
|
||||
|
||||
for list in children.values_mut() {
|
||||
list.sort_unstable();
|
||||
}
|
||||
|
||||
Self {
|
||||
root,
|
||||
msgs,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.msgs.len()
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &M::Id {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn msg(&self, id: &M::Id) -> Option<&M> {
|
||||
self.msgs.get(id)
|
||||
}
|
||||
|
||||
pub fn parent(&self, id: &M::Id) -> Option<M::Id> {
|
||||
self.msg(id).and_then(|m| m.parent())
|
||||
}
|
||||
|
||||
pub fn children(&self, id: &M::Id) -> Option<&[M::Id]> {
|
||||
self.children.get(id).map(|c| c as &[M::Id])
|
||||
}
|
||||
|
||||
pub fn subtree_size(&self, id: &M::Id) -> usize {
|
||||
let children = self.children(id).unwrap_or_default();
|
||||
let mut result = children.len();
|
||||
for child in children {
|
||||
result += self.subtree_size(child);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn siblings(&self, id: &M::Id) -> Option<&[M::Id]> {
|
||||
if let Some(parent) = self.parent(id) {
|
||||
self.children(&parent)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_sibling(&self, id: &M::Id) -> Option<M::Id> {
|
||||
let siblings = self.siblings(id)?;
|
||||
siblings
|
||||
.iter()
|
||||
.zip(siblings.iter().skip(1))
|
||||
.find(|(_, s)| *s == id)
|
||||
.map(|(s, _)| s.clone())
|
||||
}
|
||||
|
||||
pub fn next_sibling(&self, id: &M::Id) -> Option<M::Id> {
|
||||
let siblings = self.siblings(id)?;
|
||||
siblings
|
||||
.iter()
|
||||
.zip(siblings.iter().skip(1))
|
||||
.find(|(s, _)| *s == id)
|
||||
.map(|(_, s)| s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait MsgStore<M: Msg> {
|
||||
type Error;
|
||||
async fn path(&self, id: &M::Id) -> Result<Path<M::Id>, Self::Error>;
|
||||
async fn msg(&self, id: &M::Id) -> Result<Option<M>, Self::Error>;
|
||||
async fn tree(&self, root_id: &M::Id) -> Result<Tree<M>, Self::Error>;
|
||||
async fn first_root_id(&self) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn last_root_id(&self) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn prev_root_id(&self, root_id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn next_root_id(&self, root_id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn oldest_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn newest_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn older_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn newer_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn oldest_unseen_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn newest_unseen_msg_id(&self) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn older_unseen_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn newer_unseen_msg_id(&self, id: &M::Id) -> Result<Option<M::Id>, Self::Error>;
|
||||
async fn unseen_msgs_count(&self) -> Result<usize, Self::Error>;
|
||||
async fn set_seen(&self, id: &M::Id, seen: bool) -> Result<(), Self::Error>;
|
||||
async fn set_older_seen(&self, id: &M::Id, seen: bool) -> Result<(), Self::Error>;
|
||||
}
|
||||
335
cove/src/ui.rs
Normal file
335
cove/src/ui.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
mod chat;
|
||||
mod euph;
|
||||
mod input;
|
||||
mod rooms;
|
||||
mod util;
|
||||
mod widgets;
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
use tokio::task;
|
||||
use toss::widgets::BoxedAsync;
|
||||
use toss::{Terminal, WidgetExt};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::logger::{LogMsg, Logger};
|
||||
use crate::macros::{logging_unwrap, ok_or_return, some_or_return};
|
||||
use crate::util::InfallibleExt;
|
||||
use crate::vault::Vault;
|
||||
|
||||
pub use self::chat::ChatMsg;
|
||||
use self::chat::ChatState;
|
||||
use self::input::{key, InputEvent, KeyBindingsList};
|
||||
use self::rooms::Rooms;
|
||||
use self::widgets::ListState;
|
||||
|
||||
/// Time to spend batch processing events before redrawing the screen.
|
||||
const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 fps
|
||||
|
||||
/// Error for anything that can go wrong while rendering.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UiError {
|
||||
#[error("{0}")]
|
||||
Vault(#[from] vault::tokio::Error<rusqlite::Error>),
|
||||
#[error("{0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
impl From<Infallible> for UiError {
|
||||
fn from(value: Infallible) -> Self {
|
||||
Err(value).infallible()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum UiEvent {
|
||||
GraphemeWidthsChanged,
|
||||
LogChanged,
|
||||
Term(crossterm::event::Event),
|
||||
Euph(euphoxide::bot::instance::Event),
|
||||
}
|
||||
|
||||
enum EventHandleResult {
|
||||
Redraw,
|
||||
Continue,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Main,
|
||||
Log,
|
||||
}
|
||||
|
||||
pub struct Ui {
|
||||
event_tx: UnboundedSender<UiEvent>,
|
||||
|
||||
mode: Mode,
|
||||
|
||||
rooms: Rooms,
|
||||
log_chat: ChatState<LogMsg, Logger>,
|
||||
key_bindings_list: Option<ListState<Infallible>>,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
const POLL_DURATION: Duration = Duration::from_millis(100);
|
||||
|
||||
pub async fn run(
|
||||
config: &'static Config,
|
||||
terminal: &mut Terminal,
|
||||
vault: Vault,
|
||||
logger: Logger,
|
||||
logger_rx: UnboundedReceiver<()>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let crossterm_lock = Arc::new(FairMutex::new(()));
|
||||
|
||||
// Prepare and start crossterm event polling task
|
||||
let weak_crossterm_lock = Arc::downgrade(&crossterm_lock);
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let crossterm_event_task = task::spawn_blocking(|| {
|
||||
Self::poll_crossterm_events(event_tx_clone, weak_crossterm_lock)
|
||||
});
|
||||
|
||||
// Run main UI.
|
||||
//
|
||||
// If the run_main method exits at any point or if this `run` method is
|
||||
// not awaited any more, the crossterm_lock Arc should be deallocated,
|
||||
// meaning the crossterm_event_task will also stop after at most
|
||||
// `Self::POLL_DURATION`.
|
||||
//
|
||||
// On the other hand, if the crossterm_event_task stops for any reason,
|
||||
// the rest of the UI is also shut down and the client stops.
|
||||
let mut ui = Self {
|
||||
event_tx: event_tx.clone(),
|
||||
mode: Mode::Main,
|
||||
rooms: Rooms::new(config, vault, event_tx.clone()).await,
|
||||
log_chat: ChatState::new(logger),
|
||||
key_bindings_list: None,
|
||||
};
|
||||
tokio::select! {
|
||||
e = ui.run_main(terminal, event_rx, crossterm_lock) => e?,
|
||||
_ = Self::update_on_log_event(logger_rx, &event_tx) => (),
|
||||
e = crossterm_event_task => e??,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_crossterm_events(
|
||||
tx: UnboundedSender<UiEvent>,
|
||||
lock: Weak<FairMutex<()>>,
|
||||
) -> crossterm::Result<()> {
|
||||
loop {
|
||||
let lock = some_or_return!(lock.upgrade(), Ok(()));
|
||||
let _guard = lock.lock();
|
||||
if crossterm::event::poll(Self::POLL_DURATION)? {
|
||||
let event = crossterm::event::read()?;
|
||||
ok_or_return!(tx.send(UiEvent::Term(event)), Ok(()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_on_log_event(
|
||||
mut logger_rx: UnboundedReceiver<()>,
|
||||
event_tx: &UnboundedSender<UiEvent>,
|
||||
) {
|
||||
loop {
|
||||
some_or_return!(logger_rx.recv().await);
|
||||
ok_or_return!(event_tx.send(UiEvent::LogChanged));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_main(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
mut event_rx: UnboundedReceiver<UiEvent>,
|
||||
crossterm_lock: Arc<FairMutex<()>>,
|
||||
) -> Result<(), UiError> {
|
||||
let mut redraw = true;
|
||||
|
||||
loop {
|
||||
// Redraw if necessary
|
||||
if redraw {
|
||||
redraw = false;
|
||||
terminal.present_async_widget(self.widget().await).await?;
|
||||
|
||||
if terminal.measuring_required() {
|
||||
let _guard = crossterm_lock.lock();
|
||||
terminal.measure_widths()?;
|
||||
ok_or_return!(self.event_tx.send(UiEvent::GraphemeWidthsChanged), Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle events (in batches)
|
||||
let mut event = match event_rx.recv().await {
|
||||
Some(event) => event,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let end_time = Instant::now() + EVENT_PROCESSING_TIME;
|
||||
loop {
|
||||
match self.handle_event(terminal, &crossterm_lock, event).await {
|
||||
EventHandleResult::Redraw => redraw = true,
|
||||
EventHandleResult::Continue => {}
|
||||
EventHandleResult::Stop => return Ok(()),
|
||||
}
|
||||
if Instant::now() >= end_time {
|
||||
break;
|
||||
}
|
||||
event = match event_rx.try_recv() {
|
||||
Ok(event) => event,
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => return Ok(()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn widget(&mut self) -> BoxedAsync<'_, UiError> {
|
||||
let key_bindings_list = if self.key_bindings_list.is_some() {
|
||||
let mut bindings = KeyBindingsList::new();
|
||||
self.list_key_bindings(&mut bindings).await;
|
||||
Some(bindings)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let widget = match self.mode {
|
||||
Mode::Main => self.rooms.widget().await,
|
||||
Mode::Log => self.log_chat.widget(String::new(), true),
|
||||
};
|
||||
|
||||
if let Some(key_bindings_list) = key_bindings_list {
|
||||
// We checked whether this was Some earlier.
|
||||
let list_state = self.key_bindings_list.as_mut().unwrap();
|
||||
|
||||
key_bindings_list
|
||||
.widget(list_state)
|
||||
.desync()
|
||||
.above(widget)
|
||||
.boxed_async()
|
||||
} else {
|
||||
widget
|
||||
}
|
||||
}
|
||||
|
||||
fn show_key_bindings(&mut self) {
|
||||
if self.key_bindings_list.is_none() {
|
||||
self.key_bindings_list = Some(ListState::new())
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("ctrl+c", "quit cove");
|
||||
bindings.binding("F1, ?", "show this menu");
|
||||
bindings.binding("F12", "toggle log");
|
||||
bindings.empty();
|
||||
match self.mode {
|
||||
Mode::Main => self.rooms.list_key_bindings(bindings).await,
|
||||
Mode::Log => self.log_chat.list_key_bindings(bindings, false).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: UiEvent,
|
||||
) -> EventHandleResult {
|
||||
match event {
|
||||
UiEvent::GraphemeWidthsChanged => EventHandleResult::Redraw,
|
||||
UiEvent::LogChanged if self.mode == Mode::Log => EventHandleResult::Redraw,
|
||||
UiEvent::LogChanged => EventHandleResult::Continue,
|
||||
UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw,
|
||||
UiEvent::Term(event) => {
|
||||
self.handle_term_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
}
|
||||
UiEvent::Euph(event) => {
|
||||
if self.rooms.handle_euph_event(event).await {
|
||||
EventHandleResult::Redraw
|
||||
} else {
|
||||
EventHandleResult::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_term_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: crossterm::event::Event,
|
||||
) -> EventHandleResult {
|
||||
let event = some_or_return!(InputEvent::from_event(event), EventHandleResult::Continue);
|
||||
|
||||
if let key!(Ctrl + 'c') = event {
|
||||
// Exit unconditionally on ctrl+c. Previously, shift+q would also
|
||||
// unconditionally exit, but that interfered with typing text in
|
||||
// inline editors.
|
||||
return EventHandleResult::Stop;
|
||||
}
|
||||
|
||||
// Key bindings list overrides any other bindings if visible
|
||||
if let Some(key_bindings_list) = &mut self.key_bindings_list {
|
||||
match event {
|
||||
key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_list = None,
|
||||
key!('k') | key!(Up) => key_bindings_list.scroll_up(1),
|
||||
key!('j') | key!(Down) => key_bindings_list.scroll_down(1),
|
||||
_ => return EventHandleResult::Continue,
|
||||
}
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
|
||||
match event {
|
||||
key!(F 1) => {
|
||||
self.key_bindings_list = Some(ListState::new());
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
key!(F 12) => {
|
||||
self.mode = match self.mode {
|
||||
Mode::Main => Mode::Log,
|
||||
Mode::Log => Mode::Main,
|
||||
};
|
||||
return EventHandleResult::Redraw;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut handled = match self.mode {
|
||||
Mode::Main => {
|
||||
self.rooms
|
||||
.handle_input_event(terminal, crossterm_lock, &event)
|
||||
.await
|
||||
}
|
||||
Mode::Log => {
|
||||
let reaction = self
|
||||
.log_chat
|
||||
.handle_input_event(terminal, crossterm_lock, &event, false)
|
||||
.await;
|
||||
let reaction = logging_unwrap!(reaction);
|
||||
reaction.handled()
|
||||
}
|
||||
};
|
||||
|
||||
// Pressing '?' should only open the key bindings list if it doesn't
|
||||
// interfere with any part of the main UI, such as entering text in a
|
||||
// text editor.
|
||||
if !handled {
|
||||
if let key!('?') = event {
|
||||
self.show_key_bindings();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if handled {
|
||||
EventHandleResult::Redraw
|
||||
} else {
|
||||
EventHandleResult::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
157
cove/src/ui/chat.rs
Normal file
157
cove/src/ui/chat.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
mod blocks;
|
||||
mod cursor;
|
||||
mod renderer;
|
||||
mod tree;
|
||||
mod widgets;
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use time::OffsetDateTime;
|
||||
use toss::widgets::{BoxedAsync, EditorState};
|
||||
use toss::{Styled, Terminal, WidgetExt};
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
|
||||
use self::cursor::Cursor;
|
||||
use self::tree::TreeViewState;
|
||||
|
||||
use super::input::{InputEvent, KeyBindingsList};
|
||||
use super::UiError;
|
||||
|
||||
pub trait ChatMsg {
|
||||
fn time(&self) -> OffsetDateTime;
|
||||
fn styled(&self) -> (Styled, Styled);
|
||||
fn edit(nick: &str, content: &str) -> (Styled, Styled);
|
||||
fn pseudo(nick: &str, content: &str) -> (Styled, Styled);
|
||||
}
|
||||
|
||||
pub enum Mode {
|
||||
Tree,
|
||||
}
|
||||
|
||||
pub struct ChatState<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
|
||||
cursor: Cursor<M::Id>,
|
||||
editor: EditorState,
|
||||
|
||||
mode: Mode,
|
||||
tree: TreeViewState<M, S>,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
|
||||
pub fn new(store: S) -> Self {
|
||||
Self {
|
||||
cursor: Cursor::Bottom,
|
||||
editor: EditorState::new(),
|
||||
|
||||
mode: Mode::Tree,
|
||||
tree: TreeViewState::new(store.clone()),
|
||||
|
||||
store,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
||||
pub fn store(&self) -> &S {
|
||||
&self.store
|
||||
}
|
||||
|
||||
pub fn widget(&mut self, nick: String, focused: bool) -> BoxedAsync<'_, UiError>
|
||||
where
|
||||
M: ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: Send + Sync,
|
||||
S::Error: Send,
|
||||
UiError: From<S::Error>,
|
||||
{
|
||||
match self.mode {
|
||||
Mode::Tree => self
|
||||
.tree
|
||||
.widget(&mut self.cursor, &mut self.editor, nick, focused)
|
||||
.boxed_async(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
|
||||
match self.mode {
|
||||
Mode::Tree => self
|
||||
.tree
|
||||
.list_key_bindings(bindings, &self.cursor, can_compose),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
can_compose: bool,
|
||||
) -> Result<Reaction<M>, S::Error>
|
||||
where
|
||||
M: ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
match self.mode {
|
||||
Mode::Tree => {
|
||||
self.tree
|
||||
.handle_input_event(
|
||||
terminal,
|
||||
crossterm_lock,
|
||||
event,
|
||||
&mut self.cursor,
|
||||
&mut self.editor,
|
||||
can_compose,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> Option<&M::Id> {
|
||||
match &self.cursor {
|
||||
Cursor::Msg(id) => Some(id),
|
||||
Cursor::Bottom | Cursor::Editor { .. } | Cursor::Pseudo { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`Reaction::Composed`] message was sent successfully.
|
||||
pub fn send_successful(&mut self, id: M::Id) {
|
||||
if let Cursor::Pseudo { .. } = &self.cursor {
|
||||
self.tree.send_successful(&id);
|
||||
self.cursor = Cursor::Msg(id);
|
||||
self.editor.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`Reaction::Composed`] message failed to be sent.
|
||||
pub fn send_failed(&mut self) {
|
||||
if let Cursor::Pseudo { coming_from, .. } = &self.cursor {
|
||||
self.cursor = match coming_from {
|
||||
Some(id) => Cursor::Msg(id.clone()),
|
||||
None => Cursor::Bottom,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Reaction<M: Msg> {
|
||||
NotHandled,
|
||||
Handled,
|
||||
Composed {
|
||||
parent: Option<M::Id>,
|
||||
content: String,
|
||||
},
|
||||
ComposeError(io::Error),
|
||||
}
|
||||
|
||||
impl<M: Msg> Reaction<M> {
|
||||
pub fn handled(&self) -> bool {
|
||||
!matches!(self, Self::NotHandled)
|
||||
}
|
||||
}
|
||||
223
cove/src/ui/chat/blocks.rs
Normal file
223
cove/src/ui/chat/blocks.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Common rendering logic.
|
||||
|
||||
use std::collections::{vec_deque, VecDeque};
|
||||
|
||||
use toss::widgets::Predrawn;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Range<T> {
|
||||
pub top: T,
|
||||
pub bottom: T,
|
||||
}
|
||||
|
||||
impl<T> Range<T> {
|
||||
pub fn new(top: T, bottom: T) -> Self {
|
||||
Self { top, bottom }
|
||||
}
|
||||
}
|
||||
|
||||
impl Range<i32> {
|
||||
pub fn shifted(self, delta: i32) -> Self {
|
||||
Self::new(self.top + delta, self.bottom + delta)
|
||||
}
|
||||
|
||||
pub fn with_top(self, top: i32) -> Self {
|
||||
self.shifted(top - self.top)
|
||||
}
|
||||
|
||||
pub fn with_bottom(self, bottom: i32) -> Self {
|
||||
self.shifted(bottom - self.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Block<Id> {
|
||||
id: Id,
|
||||
widget: Predrawn,
|
||||
focus: Range<i32>,
|
||||
can_be_cursor: bool,
|
||||
}
|
||||
|
||||
impl<Id> Block<Id> {
|
||||
pub fn new(id: Id, widget: Predrawn, can_be_cursor: bool) -> Self {
|
||||
let height: i32 = widget.size().height.into();
|
||||
Self {
|
||||
id,
|
||||
widget,
|
||||
focus: Range::new(0, height),
|
||||
can_be_cursor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &Id {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn into_widget(self) -> Predrawn {
|
||||
self.widget
|
||||
}
|
||||
|
||||
fn height(&self) -> i32 {
|
||||
self.widget.size().height.into()
|
||||
}
|
||||
|
||||
pub fn set_focus(&mut self, focus: Range<i32>) {
|
||||
assert!(0 <= focus.top);
|
||||
assert!(focus.top <= focus.bottom);
|
||||
assert!(focus.bottom <= self.height());
|
||||
self.focus = focus;
|
||||
}
|
||||
|
||||
pub fn focus(&self, range: Range<i32>) -> Range<i32> {
|
||||
Range::new(range.top + self.focus.top, range.top + self.focus.bottom)
|
||||
}
|
||||
|
||||
pub fn can_be_cursor(&self) -> bool {
|
||||
self.can_be_cursor
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Blocks<Id> {
|
||||
blocks: VecDeque<Block<Id>>,
|
||||
range: Range<i32>,
|
||||
end: Range<bool>,
|
||||
}
|
||||
|
||||
impl<Id> Blocks<Id> {
|
||||
pub fn new(at: i32) -> Self {
|
||||
Self {
|
||||
blocks: VecDeque::new(),
|
||||
range: Range::new(at, at),
|
||||
end: Range::new(false, false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Range<i32> {
|
||||
self.range
|
||||
}
|
||||
|
||||
pub fn end(&self) -> Range<bool> {
|
||||
self.end
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_, Id> {
|
||||
Iter {
|
||||
iter: self.blocks.iter(),
|
||||
range: self.range,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_iter(self) -> IntoIter<Id> {
|
||||
IntoIter {
|
||||
iter: self.blocks.into_iter(),
|
||||
range: self.range,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Remove index from search result
|
||||
pub fn find_block(&self, id: &Id) -> Option<(Range<i32>, &Block<Id>)>
|
||||
where
|
||||
Id: Eq,
|
||||
{
|
||||
self.iter().find(|(_, block)| block.id == *id)
|
||||
}
|
||||
|
||||
pub fn push_top(&mut self, block: Block<Id>) {
|
||||
assert!(!self.end.top);
|
||||
self.range.top -= block.height();
|
||||
self.blocks.push_front(block);
|
||||
}
|
||||
|
||||
pub fn push_bottom(&mut self, block: Block<Id>) {
|
||||
assert!(!self.end.bottom);
|
||||
self.range.bottom += block.height();
|
||||
self.blocks.push_back(block);
|
||||
}
|
||||
|
||||
pub fn append_top(&mut self, other: Self) {
|
||||
assert!(!self.end.top);
|
||||
assert!(!other.end.bottom);
|
||||
for block in other.blocks.into_iter().rev() {
|
||||
self.push_top(block);
|
||||
}
|
||||
self.end.top = other.end.top;
|
||||
}
|
||||
|
||||
pub fn append_bottom(&mut self, other: Self) {
|
||||
assert!(!self.end.bottom);
|
||||
assert!(!other.end.top);
|
||||
for block in other.blocks {
|
||||
self.push_bottom(block);
|
||||
}
|
||||
self.end.bottom = other.end.bottom;
|
||||
}
|
||||
|
||||
pub fn end_top(&mut self) {
|
||||
self.end.top = true;
|
||||
}
|
||||
|
||||
pub fn end_bottom(&mut self) {
|
||||
self.end.bottom = true;
|
||||
}
|
||||
|
||||
pub fn shift(&mut self, delta: i32) {
|
||||
self.range = self.range.shifted(delta);
|
||||
}
|
||||
|
||||
pub fn set_top(&mut self, top: i32) {
|
||||
self.shift(top - self.range.top);
|
||||
}
|
||||
|
||||
pub fn set_bottom(&mut self, bottom: i32) {
|
||||
self.shift(bottom - self.range.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Iter<'a, Id> {
|
||||
iter: vec_deque::Iter<'a, Block<Id>>,
|
||||
range: Range<i32>,
|
||||
}
|
||||
|
||||
impl<'a, Id> Iterator for Iter<'a, Id> {
|
||||
type Item = (Range<i32>, &'a Block<Id>);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let block = self.iter.next()?;
|
||||
let range = Range::new(self.range.top, self.range.top + block.height());
|
||||
self.range.top = range.bottom;
|
||||
Some((range, block))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Id> DoubleEndedIterator for Iter<'_, Id> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
let block = self.iter.next_back()?;
|
||||
let range = Range::new(self.range.bottom - block.height(), self.range.bottom);
|
||||
self.range.bottom = range.top;
|
||||
Some((range, block))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IntoIter<Id> {
|
||||
iter: vec_deque::IntoIter<Block<Id>>,
|
||||
range: Range<i32>,
|
||||
}
|
||||
|
||||
impl<Id> Iterator for IntoIter<Id> {
|
||||
type Item = (Range<i32>, Block<Id>);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let block = self.iter.next()?;
|
||||
let range = Range::new(self.range.top, self.range.top + block.height());
|
||||
self.range.top = range.bottom;
|
||||
Some((range, block))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Id> DoubleEndedIterator for IntoIter<Id> {
|
||||
fn next_back(&mut self) -> Option<Self::Item> {
|
||||
let block = self.iter.next_back()?;
|
||||
let range = Range::new(self.range.bottom - block.height(), self.range.bottom);
|
||||
self.range.bottom = range.top;
|
||||
Some((range, block))
|
||||
}
|
||||
}
|
||||
528
cove/src/ui/chat/cursor.rs
Normal file
528
cove/src/ui/chat/cursor.rs
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
//! Common cursor movement logic.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::hash::Hash;
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Cursor<Id> {
|
||||
Bottom,
|
||||
Msg(Id),
|
||||
Editor {
|
||||
coming_from: Option<Id>,
|
||||
parent: Option<Id>,
|
||||
},
|
||||
Pseudo {
|
||||
coming_from: Option<Id>,
|
||||
parent: Option<Id>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<Id: Clone + Eq + Hash> Cursor<Id> {
|
||||
fn find_parent<M>(tree: &Tree<M>, id: &mut Id) -> bool
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
{
|
||||
if let Some(parent) = tree.parent(id) {
|
||||
*id = parent;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the previous sibling, or don't move if this is not possible.
|
||||
///
|
||||
/// Always stays at the same level of indentation.
|
||||
async fn find_prev_sibling<M, S>(
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
let moved = if let Some(prev_sibling) = tree.prev_sibling(id) {
|
||||
*id = prev_sibling;
|
||||
true
|
||||
} else if tree.parent(id).is_none() {
|
||||
// We're at the root of our tree, so we need to move to the root of
|
||||
// the previous tree.
|
||||
if let Some(prev_root_id) = store.prev_root_id(tree.root()).await? {
|
||||
*tree = store.tree(&prev_root_id).await?;
|
||||
*id = prev_root_id;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Move to the next sibling, or don't move if this is not possible.
|
||||
///
|
||||
/// Always stays at the same level of indentation.
|
||||
async fn find_next_sibling<M, S>(
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
let moved = if let Some(next_sibling) = tree.next_sibling(id) {
|
||||
*id = next_sibling;
|
||||
true
|
||||
} else if tree.parent(id).is_none() {
|
||||
// We're at the root of our tree, so we need to move to the root of
|
||||
// the next tree.
|
||||
if let Some(next_root_id) = store.next_root_id(tree.root()).await? {
|
||||
*tree = store.tree(&next_root_id).await?;
|
||||
*id = next_root_id;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn find_first_child_in_tree<M>(folded: &HashSet<Id>, tree: &Tree<M>, id: &mut Id) -> bool
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
{
|
||||
if folded.contains(id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(child) = tree.children(id).and_then(|c| c.first()) {
|
||||
*id = child.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn find_last_child_in_tree<M>(folded: &HashSet<Id>, tree: &Tree<M>, id: &mut Id) -> bool
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
{
|
||||
if folded.contains(id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(child) = tree.children(id).and_then(|c| c.last()) {
|
||||
*id = child.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the message above, or don't move if this is not possible.
|
||||
async fn find_above_msg_in_tree<M, S>(
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
// Move to previous sibling, then to its last child
|
||||
// If not possible, move to parent
|
||||
let moved = if Self::find_prev_sibling(store, tree, id).await? {
|
||||
while Self::find_last_child_in_tree(folded, tree, id) {}
|
||||
true
|
||||
} else {
|
||||
Self::find_parent(tree, id)
|
||||
};
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Move to the next message, or don't move if this is not possible.
|
||||
async fn find_below_msg_in_tree<M, S>(
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut Id,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
if Self::find_first_child_in_tree(folded, tree, id) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if Self::find_next_sibling(store, tree, id).await? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Temporary id to avoid modifying the original one if no parent-sibling
|
||||
// can be found.
|
||||
let mut tmp_id = id.clone();
|
||||
while Self::find_parent(tree, &mut tmp_id) {
|
||||
if Self::find_next_sibling(store, tree, &mut tmp_id).await? {
|
||||
*id = tmp_id;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn move_to_top<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
if let Some(first_root_id) = store.first_root_id().await? {
|
||||
*self = Self::Msg(first_root_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn move_to_bottom(&mut self) {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
|
||||
pub async fn move_to_older_msg<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Msg(id) => {
|
||||
if let Some(prev_id) = store.older_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
}
|
||||
}
|
||||
Self::Bottom | Self::Pseudo { .. } => {
|
||||
if let Some(id) = store.newest_msg_id().await? {
|
||||
*self = Self::Msg(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_to_newer_msg<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Msg(id) => {
|
||||
if let Some(prev_id) = store.newer_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
} else {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
}
|
||||
Self::Pseudo { .. } => {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_to_older_unseen_msg<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Msg(id) => {
|
||||
if let Some(prev_id) = store.older_unseen_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
}
|
||||
}
|
||||
Self::Bottom | Self::Pseudo { .. } => {
|
||||
if let Some(id) = store.newest_unseen_msg_id().await? {
|
||||
*self = Self::Msg(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_to_newer_unseen_msg<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Msg(id) => {
|
||||
if let Some(prev_id) = store.newer_unseen_msg_id(id).await? {
|
||||
*id = prev_id;
|
||||
} else {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
}
|
||||
Self::Pseudo { .. } => {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_to_parent<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Editor { parent, .. } | Self::Pseudo { parent, .. } => {
|
||||
if let Some(parent_id) = parent {
|
||||
*self = Self::Msg(parent_id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
Self::Msg(id) => {
|
||||
let path = store.path(id).await?;
|
||||
if let Some(parent_id) = path.parent_segments().last() {
|
||||
*id = parent_id.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_to_root<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let path = store.path(parent).await?;
|
||||
*self = Self::Msg(path.first().clone());
|
||||
}
|
||||
Self::Msg(id) => {
|
||||
let path = store.path(id).await?;
|
||||
*id = path.first().clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_to_prev_sibling<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Bottom | Self::Pseudo { parent: None, .. } => {
|
||||
if let Some(last_root_id) = store.last_root_id().await? {
|
||||
*self = Self::Msg(last_root_id);
|
||||
}
|
||||
}
|
||||
Self::Msg(msg) => {
|
||||
let path = store.path(msg).await?;
|
||||
let mut tree = store.tree(path.first()).await?;
|
||||
Self::find_prev_sibling(store, &mut tree, msg).await?;
|
||||
}
|
||||
Self::Editor { .. } => {}
|
||||
Self::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let path = store.path(parent).await?;
|
||||
let tree = store.tree(path.first()).await?;
|
||||
if let Some(children) = tree.children(parent) {
|
||||
if let Some(last_child) = children.last() {
|
||||
*self = Self::Msg(last_child.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_to_next_sibling<M, S>(&mut self, store: &S) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Msg(msg) => {
|
||||
let path = store.path(msg).await?;
|
||||
let mut tree = store.tree(path.first()).await?;
|
||||
if !Self::find_next_sibling(store, &mut tree, msg).await?
|
||||
&& tree.parent(msg).is_none()
|
||||
{
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
}
|
||||
Self::Pseudo { parent: None, .. } => {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_up_in_tree<M, S>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Bottom | Self::Pseudo { parent: None, .. } => {
|
||||
if let Some(last_root_id) = store.last_root_id().await? {
|
||||
let tree = store.tree(&last_root_id).await?;
|
||||
let mut id = last_root_id;
|
||||
while Self::find_last_child_in_tree(folded, &tree, &mut id) {}
|
||||
*self = Self::Msg(id);
|
||||
}
|
||||
}
|
||||
Self::Msg(msg) => {
|
||||
let path = store.path(msg).await?;
|
||||
let mut tree = store.tree(path.first()).await?;
|
||||
Self::find_above_msg_in_tree(store, folded, &mut tree, msg).await?;
|
||||
}
|
||||
Self::Editor { .. } => {}
|
||||
Self::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let tree = store.tree(parent).await?;
|
||||
let mut id = parent.clone();
|
||||
while Self::find_last_child_in_tree(folded, &tree, &mut id) {}
|
||||
*self = Self::Msg(id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_down_in_tree<M, S>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
folded: &HashSet<Id>,
|
||||
) -> Result<(), S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
match self {
|
||||
Self::Msg(msg) => {
|
||||
let path = store.path(msg).await?;
|
||||
let mut tree = store.tree(path.first()).await?;
|
||||
if !Self::find_below_msg_in_tree(store, folded, &mut tree, msg).await? {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
}
|
||||
Self::Pseudo { parent: None, .. } => {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
Self::Pseudo {
|
||||
parent: Some(parent),
|
||||
..
|
||||
} => {
|
||||
let mut tree = store.tree(parent).await?;
|
||||
let mut id = parent.clone();
|
||||
while Self::find_last_child_in_tree(folded, &tree, &mut id) {}
|
||||
// Now we're at the previous message
|
||||
if Self::find_below_msg_in_tree(store, folded, &mut tree, &mut id).await? {
|
||||
*self = Self::Msg(id);
|
||||
} else {
|
||||
*self = Self::Bottom;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The outer `Option` shows whether a parent exists or not. The inner
|
||||
/// `Option` shows if that parent has an id.
|
||||
pub async fn parent_for_normal_tree_reply<M, S>(
|
||||
&self,
|
||||
store: &S,
|
||||
) -> Result<Option<Option<M::Id>>, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
Ok(match self {
|
||||
Self::Bottom => Some(None),
|
||||
Self::Msg(id) => {
|
||||
let path = store.path(id).await?;
|
||||
let tree = store.tree(path.first()).await?;
|
||||
|
||||
Some(Some(if tree.next_sibling(id).is_some() {
|
||||
// A reply to a message that has further siblings should be
|
||||
// a direct reply. An indirect reply might end up a lot
|
||||
// further down in the current conversation.
|
||||
id.clone()
|
||||
} else if let Some(parent) = tree.parent(id) {
|
||||
// A reply to a message without younger siblings should be
|
||||
// an indirect reply so as not to create unnecessarily deep
|
||||
// threads. In the case that our message has children, this
|
||||
// might get a bit confusing. I'm not sure yet how well this
|
||||
// "smart" reply actually works in practice.
|
||||
parent
|
||||
} else {
|
||||
// When replying to a top-level message, it makes sense to
|
||||
// avoid creating unnecessary new threads.
|
||||
id.clone()
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The outer `Option` shows whether a parent exists or not. The inner
|
||||
/// `Option` shows if that parent has an id.
|
||||
pub async fn parent_for_alternate_tree_reply<M, S>(
|
||||
&self,
|
||||
store: &S,
|
||||
) -> Result<Option<Option<M::Id>>, S::Error>
|
||||
where
|
||||
M: Msg<Id = Id>,
|
||||
S: MsgStore<M>,
|
||||
{
|
||||
Ok(match self {
|
||||
Self::Bottom => Some(None),
|
||||
Self::Msg(id) => {
|
||||
let path = store.path(id).await?;
|
||||
let tree = store.tree(path.first()).await?;
|
||||
|
||||
Some(Some(if tree.next_sibling(id).is_none() {
|
||||
// The opposite of replying normally
|
||||
id.clone()
|
||||
} else if let Some(parent) = tree.parent(id) {
|
||||
// The opposite of replying normally
|
||||
parent
|
||||
} else {
|
||||
// The same as replying normally, still to avoid creating
|
||||
// unnecessary new threads
|
||||
id.clone()
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
348
cove/src/ui/chat/renderer.rs
Normal file
348
cove/src/ui/chat/renderer.rs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
use std::cmp::Ordering;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use toss::Size;
|
||||
|
||||
use super::blocks::{Blocks, Range};
|
||||
|
||||
#[async_trait]
|
||||
pub trait Renderer<Id> {
|
||||
type Error;
|
||||
|
||||
fn size(&self) -> Size;
|
||||
fn scrolloff(&self) -> i32;
|
||||
|
||||
fn blocks(&self) -> &Blocks<Id>;
|
||||
fn blocks_mut(&mut self) -> &mut Blocks<Id>;
|
||||
fn into_blocks(self) -> Blocks<Id>;
|
||||
|
||||
async fn expand_top(&mut self) -> Result<(), Self::Error>;
|
||||
async fn expand_bottom(&mut self) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
||||
/// A range of all the lines that are visible given the renderer's size.
|
||||
pub fn visible_area<Id, R>(r: &R) -> Range<i32>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let height: i32 = r.size().height.into();
|
||||
Range::new(0, height)
|
||||
}
|
||||
|
||||
/// The renderer's visible area, reduced by its scrolloff at the top and bottom.
|
||||
fn scroll_area<Id, R>(r: &R) -> Range<i32>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let range = visible_area(r);
|
||||
let scrolloff = r.scrolloff();
|
||||
let top = range.top + scrolloff;
|
||||
let bottom = top.max(range.bottom - scrolloff);
|
||||
Range::new(top, bottom)
|
||||
}
|
||||
|
||||
/// Compute a delta that makes the object partially or fully overlap the area
|
||||
/// when added to the object. This delta should be as close to zero as possible.
|
||||
///
|
||||
/// If the object has a height of zero, it must be within the area or exactly on
|
||||
/// its border to be considered overlapping.
|
||||
///
|
||||
/// If the object has a nonzero height, at least one line of the object must be
|
||||
/// within the area for the object to be considered overlapping.
|
||||
fn overlap_delta(area: Range<i32>, object: Range<i32>) -> i32 {
|
||||
assert!(object.top <= object.bottom, "object range not well-formed");
|
||||
assert!(area.top <= area.bottom, "area range not well-formed");
|
||||
|
||||
if object.top == object.bottom || area.top == area.bottom {
|
||||
// Delta that moves the object.bottom to area.top. If this is positive,
|
||||
// we need to move the object because it is too high.
|
||||
let move_to_top = area.top - object.bottom;
|
||||
|
||||
// Delta that moves the object.top to area.bottom. If this is negative,
|
||||
// we need to move the object because it is too low.
|
||||
let move_to_bottom = area.bottom - object.top;
|
||||
|
||||
// move_to_top <= move_to_bottom because...
|
||||
//
|
||||
// Case 1: object.top == object.bottom
|
||||
// Premise follows from rom area.top <= area.bottom
|
||||
//
|
||||
// Case 2: area.top == area.bottom
|
||||
// Premise follows from object.top <= object.bottom
|
||||
0.clamp(move_to_top, move_to_bottom)
|
||||
} else {
|
||||
// Delta that moves object.bottom one line below area.top. If this is
|
||||
// positive, we need to move the object because it is too high.
|
||||
let move_to_top = (area.top + 1) - object.bottom;
|
||||
|
||||
// Delta that moves object.top one line above area.bottom. If this is
|
||||
// negative, we need to move the object because it is too low.
|
||||
let move_to_bottom = (area.bottom - 1) - object.top;
|
||||
|
||||
// move_to_top <= move_to_bottom because...
|
||||
//
|
||||
// We know that area.top < area.bottom and object.top < object.bottom,
|
||||
// otherwise we'd be in the previous `if` branch.
|
||||
//
|
||||
// We get the largest value for move_to_top if area.top is largest and
|
||||
// object.bottom is smallest. We get the smallest value for
|
||||
// move_to_bottom if area.bottom is smallest and object.top is largest.
|
||||
//
|
||||
// This means that the worst case scenario is when area.top and
|
||||
// area.bottom as well as object.top and object.bottom are closest
|
||||
// together. In other words:
|
||||
//
|
||||
// area.top + 1 == area.bottom
|
||||
// object.top + 1 == object.bottom
|
||||
//
|
||||
// Inserting that into our formulas for move_to_top and move_to_bottom,
|
||||
// we get:
|
||||
//
|
||||
// move_to_top = (area.top + 1) - (object.top + 1) = area.top + object.top
|
||||
// move_to_bottom = (area.top + 1 - 1) - object.top = area.top + object.top
|
||||
0.clamp(move_to_top, move_to_bottom)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn overlaps(area: Range<i32>, object: Range<i32>) -> bool {
|
||||
overlap_delta(area, object) == 0
|
||||
}
|
||||
|
||||
/// Move the object such that it overlaps the area.
|
||||
fn overlap(area: Range<i32>, object: Range<i32>) -> Range<i32> {
|
||||
object.shifted(overlap_delta(area, object))
|
||||
}
|
||||
|
||||
/// Compute a delta that makes the object fully overlap the area when added to
|
||||
/// the object. This delta should be as close to zero as possible.
|
||||
///
|
||||
/// If the object is higher than the area, it should be moved such that
|
||||
/// object.top == area.top.
|
||||
fn full_overlap_delta(area: Range<i32>, object: Range<i32>) -> i32 {
|
||||
assert!(object.top <= object.bottom, "object range not well-formed");
|
||||
assert!(area.top <= area.bottom, "area range not well-formed");
|
||||
|
||||
// Delta that moves object.top to area.top. If this is positive, we need to
|
||||
// move the object because it is too high.
|
||||
let move_to_top = area.top - object.top;
|
||||
|
||||
// Delta that moves object.bottom to area.bottom. If this is negative, we
|
||||
// need to move the object because it is too low.
|
||||
let move_to_bottom = area.bottom - object.bottom;
|
||||
|
||||
// If the object is higher than the area, move_to_top becomes larger than
|
||||
// move_to_bottom. In that case, this function should return move_to_top.
|
||||
0.min(move_to_bottom).max(move_to_top)
|
||||
}
|
||||
|
||||
async fn expand_upwards_until<Id, R>(r: &mut R, top: i32) -> Result<(), R::Error>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
loop {
|
||||
let blocks = r.blocks();
|
||||
if blocks.end().top || blocks.range().top <= top {
|
||||
break;
|
||||
}
|
||||
|
||||
r.expand_top().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expand_downwards_until<Id, R>(r: &mut R, bottom: i32) -> Result<(), R::Error>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
loop {
|
||||
let blocks = r.blocks();
|
||||
if blocks.end().bottom || blocks.range().bottom >= bottom {
|
||||
break;
|
||||
}
|
||||
|
||||
r.expand_bottom().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn expand_to_fill_visible_area<Id, R>(r: &mut R) -> Result<(), R::Error>
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = visible_area(r);
|
||||
expand_upwards_until(r, area.top).await?;
|
||||
expand_downwards_until(r, area.bottom).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expand blocks such that the screen is full for any offset where the
|
||||
/// specified block is visible.
|
||||
pub async fn expand_to_fill_screen_around_block<Id, R>(r: &mut R, id: &Id) -> Result<(), R::Error>
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let screen = visible_area(r);
|
||||
let (block, _) = r.blocks().find_block(id).expect("no block with that id");
|
||||
|
||||
let top = overlap(block, screen.with_bottom(block.top)).top;
|
||||
let bottom = overlap(block, screen.with_top(block.bottom)).bottom;
|
||||
|
||||
expand_upwards_until(r, top).await?;
|
||||
expand_downwards_until(r, bottom).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scroll_to_set_block_top<Id, R>(r: &mut R, id: &Id, top: i32) -> bool
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
if let Some((range, _)) = r.blocks().find_block(id) {
|
||||
let delta = top - range.top;
|
||||
r.blocks_mut().shift(delta);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_so_block_is_centered<Id, R>(r: &mut R, id: &Id)
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = visible_area(r);
|
||||
let (range, block) = r.blocks().find_block(id).expect("no block with that id");
|
||||
let focus = block.focus(range);
|
||||
let focus_height = focus.bottom - focus.top;
|
||||
let top = (area.top + area.bottom - focus_height) / 2;
|
||||
r.blocks_mut().shift(top - range.top);
|
||||
}
|
||||
|
||||
pub fn scroll_blocks_fully_above_screen<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = visible_area(r);
|
||||
let blocks = r.blocks_mut();
|
||||
let delta = area.top - blocks.range().bottom;
|
||||
blocks.shift(delta);
|
||||
}
|
||||
|
||||
pub fn scroll_blocks_fully_below_screen<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = visible_area(r);
|
||||
let blocks = r.blocks_mut();
|
||||
let delta = area.bottom - blocks.range().top;
|
||||
blocks.shift(delta);
|
||||
}
|
||||
|
||||
pub fn scroll_so_block_focus_overlaps_scroll_area<Id, R>(r: &mut R, id: &Id) -> bool
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
if let Some((range, block)) = r.blocks().find_block(id) {
|
||||
let area = scroll_area(r);
|
||||
let delta = overlap_delta(area, block.focus(range));
|
||||
r.blocks_mut().shift(delta);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_so_block_focus_fully_overlaps_scroll_area<Id, R>(r: &mut R, id: &Id) -> bool
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
if let Some((range, block)) = r.blocks().find_block(id) {
|
||||
let area = scroll_area(r);
|
||||
let delta = full_overlap_delta(area, block.focus(range));
|
||||
r.blocks_mut().shift(delta);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clamp_scroll_biased_upwards<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = visible_area(r);
|
||||
let blocks = r.blocks().range();
|
||||
|
||||
// Delta that moves blocks.top to the top of the screen. If this is
|
||||
// negative, we need to move the blocks because they're too low.
|
||||
let move_to_top = blocks.top - area.top;
|
||||
|
||||
// Delta that moves blocks.bottom to the bottom of the screen. If this is
|
||||
// positive, we need to move the blocks because they're too high.
|
||||
let move_to_bottom = blocks.bottom - area.bottom;
|
||||
|
||||
// If the screen is higher, the blocks should rather be moved to the top
|
||||
// than the bottom because of the upwards bias.
|
||||
let delta = 0.max(move_to_bottom).min(move_to_top);
|
||||
r.blocks_mut().shift(delta);
|
||||
}
|
||||
|
||||
pub fn clamp_scroll_biased_downwards<Id, R>(r: &mut R)
|
||||
where
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = visible_area(r);
|
||||
let blocks = r.blocks().range();
|
||||
|
||||
// Delta that moves blocks.top to the top of the screen. If this is
|
||||
// negative, we need to move the blocks because they're too low.
|
||||
let move_to_top = area.top - blocks.top;
|
||||
|
||||
// Delta that moves blocks.bottom to the bottom of the screen. If this is
|
||||
// positive, we need to move the blocks because they're too high.
|
||||
let move_to_bottom = area.bottom - blocks.bottom;
|
||||
|
||||
// If the screen is higher, the blocks should rather be moved to the bottom
|
||||
// than the top because of the downwards bias.
|
||||
let delta = 0.min(move_to_top).max(move_to_bottom);
|
||||
r.blocks_mut().shift(delta);
|
||||
}
|
||||
|
||||
pub fn find_cursor_starting_at<'a, Id, R>(r: &'a R, id: &Id) -> Option<&'a Id>
|
||||
where
|
||||
Id: Eq,
|
||||
R: Renderer<Id>,
|
||||
{
|
||||
let area = scroll_area(r);
|
||||
let (range, block) = r.blocks().find_block(id)?;
|
||||
let delta = overlap_delta(area, block.focus(range));
|
||||
match delta.cmp(&0) {
|
||||
Ordering::Equal => Some(block.id()),
|
||||
|
||||
// Blocks must be scrolled downwards to become visible, meaning the
|
||||
// cursor must be above the visible area.
|
||||
Ordering::Greater => r
|
||||
.blocks()
|
||||
.iter()
|
||||
.filter(|(_, block)| block.can_be_cursor())
|
||||
.find(|(range, block)| overlaps(area, block.focus(*range)))
|
||||
.map(|(_, block)| block.id()),
|
||||
|
||||
// Blocks must be scrolled upwards to become visible, meaning the cursor
|
||||
// must be below the visible area.
|
||||
Ordering::Less => r
|
||||
.blocks()
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|(_, block)| block.can_be_cursor())
|
||||
.find(|(range, block)| overlaps(area, block.focus(*range)))
|
||||
.map(|(_, block)| block.id()),
|
||||
}
|
||||
}
|
||||
497
cove/src/ui/chat/tree.rs
Normal file
497
cove/src/ui/chat/tree.rs
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
//! Rendering messages as full trees.
|
||||
|
||||
// TODO Focusing on sub-trees
|
||||
|
||||
mod renderer;
|
||||
mod scroll;
|
||||
mod widgets;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::FairMutex;
|
||||
use toss::widgets::EditorState;
|
||||
use toss::{AsyncWidget, Frame, Pos, Size, Terminal, WidgetExt, WidthDb};
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::{util, ChatMsg, UiError};
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
use self::renderer::{TreeContext, TreeRenderer};
|
||||
|
||||
use super::cursor::Cursor;
|
||||
use super::Reaction;
|
||||
|
||||
pub struct TreeViewState<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
|
||||
last_size: Size,
|
||||
last_nick: String,
|
||||
last_cursor: Cursor<M::Id>,
|
||||
last_cursor_top: i32,
|
||||
last_visible_msgs: Vec<M::Id>,
|
||||
|
||||
folded: HashSet<M::Id>,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
||||
pub fn new(store: S) -> Self {
|
||||
Self {
|
||||
store,
|
||||
last_size: Size::ZERO,
|
||||
last_nick: String::new(),
|
||||
last_cursor: Cursor::Bottom,
|
||||
last_cursor_top: 0,
|
||||
last_visible_msgs: vec![],
|
||||
folded: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("j/k, ↓/↑", "move cursor up/down");
|
||||
bindings.binding("J/K, ctrl+↓/↑", "move cursor to prev/next sibling");
|
||||
bindings.binding("p/P", "move cursor to parent/root");
|
||||
bindings.binding("h/l, ←/→", "move cursor chronologically");
|
||||
bindings.binding("H/L, ctrl+←/→", "move cursor to prev/next unseen message");
|
||||
bindings.binding("g, home", "move cursor to top");
|
||||
bindings.binding("G, end", "move cursor to bottom");
|
||||
bindings.binding("ctrl+y/e", "scroll up/down a line");
|
||||
bindings.binding("ctrl+u/d", "scroll up/down half a screen");
|
||||
bindings.binding("ctrl+b/f, page up/down", "scroll up/down one screen");
|
||||
bindings.binding("z", "center cursor on screen");
|
||||
// TODO Bindings inspired by vim's ()/[]/{} bindings?
|
||||
}
|
||||
|
||||
async fn handle_movement_input_event(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
event: &InputEvent,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
let chat_height: i32 = (frame.size().height - 3).into();
|
||||
let widthdb = frame.widthdb();
|
||||
|
||||
match event {
|
||||
key!('k') | key!(Up) => cursor.move_up_in_tree(&self.store, &self.folded).await?,
|
||||
key!('j') | key!(Down) => cursor.move_down_in_tree(&self.store, &self.folded).await?,
|
||||
key!('K') | key!(Ctrl + Up) => cursor.move_to_prev_sibling(&self.store).await?,
|
||||
key!('J') | key!(Ctrl + Down) => cursor.move_to_next_sibling(&self.store).await?,
|
||||
key!('p') => cursor.move_to_parent(&self.store).await?,
|
||||
key!('P') => cursor.move_to_root(&self.store).await?,
|
||||
key!('h') | key!(Left) => cursor.move_to_older_msg(&self.store).await?,
|
||||
key!('l') | key!(Right) => cursor.move_to_newer_msg(&self.store).await?,
|
||||
key!('H') | key!(Ctrl + Left) => cursor.move_to_older_unseen_msg(&self.store).await?,
|
||||
key!('L') | key!(Ctrl + Right) => cursor.move_to_newer_unseen_msg(&self.store).await?,
|
||||
key!('g') | key!(Home) => cursor.move_to_top(&self.store).await?,
|
||||
key!('G') | key!(End) => cursor.move_to_bottom(),
|
||||
key!(Ctrl + 'y') => self.scroll_by(cursor, editor, widthdb, 1).await?,
|
||||
key!(Ctrl + 'e') => self.scroll_by(cursor, editor, widthdb, -1).await?,
|
||||
key!(Ctrl + 'u') => {
|
||||
let delta = chat_height / 2;
|
||||
self.scroll_by(cursor, editor, widthdb, delta).await?;
|
||||
}
|
||||
key!(Ctrl + 'd') => {
|
||||
let delta = -(chat_height / 2);
|
||||
self.scroll_by(cursor, editor, widthdb, delta).await?;
|
||||
}
|
||||
key!(Ctrl + 'b') | key!(PageUp) => {
|
||||
let delta = chat_height.saturating_sub(1);
|
||||
self.scroll_by(cursor, editor, widthdb, delta).await?;
|
||||
}
|
||||
key!(Ctrl + 'f') | key!(PageDown) => {
|
||||
let delta = -chat_height.saturating_sub(1);
|
||||
self.scroll_by(cursor, editor, widthdb, delta).await?;
|
||||
}
|
||||
key!('z') => self.center_cursor(cursor, editor, widthdb).await?,
|
||||
_ => return Ok(false),
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("space", "fold current message's subtree");
|
||||
bindings.binding("s", "toggle current message's seen status");
|
||||
bindings.binding("S", "mark all visible messages as seen");
|
||||
bindings.binding("ctrl+s", "mark all older messages as seen");
|
||||
}
|
||||
|
||||
async fn handle_action_input_event(
|
||||
&mut self,
|
||||
event: &InputEvent,
|
||||
id: Option<&M::Id>,
|
||||
) -> Result<bool, S::Error> {
|
||||
match event {
|
||||
key!(' ') => {
|
||||
if let Some(id) = id {
|
||||
if !self.folded.remove(id) {
|
||||
self.folded.insert(id.clone());
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
key!('s') => {
|
||||
if let Some(id) = id {
|
||||
if let Some(msg) = self.store.tree(id).await?.msg(id) {
|
||||
self.store.set_seen(id, !msg.seen()).await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
key!('S') => {
|
||||
for id in &self.last_visible_msgs {
|
||||
self.store.set_seen(id, true).await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
key!(Ctrl + 's') => {
|
||||
if let Some(id) = id {
|
||||
self.store.set_older_seen(id, true).await?;
|
||||
} else {
|
||||
self.store
|
||||
.set_older_seen(&M::last_possible_id(), true)
|
||||
.await?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("r", "reply to message (inline if possible, else directly)");
|
||||
bindings.binding("R", "reply to message (opposite of R)");
|
||||
bindings.binding("t", "start a new thread");
|
||||
}
|
||||
|
||||
async fn handle_edit_initiating_input_event(
|
||||
&mut self,
|
||||
event: &InputEvent,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
id: Option<M::Id>,
|
||||
) -> Result<bool, S::Error> {
|
||||
match event {
|
||||
key!('r') => {
|
||||
if let Some(parent) = cursor.parent_for_normal_tree_reply(&self.store).await? {
|
||||
*cursor = Cursor::Editor {
|
||||
coming_from: id,
|
||||
parent,
|
||||
};
|
||||
}
|
||||
}
|
||||
key!('R') => {
|
||||
if let Some(parent) = cursor.parent_for_alternate_tree_reply(&self.store).await? {
|
||||
*cursor = Cursor::Editor {
|
||||
coming_from: id,
|
||||
parent,
|
||||
};
|
||||
}
|
||||
}
|
||||
key!('t') | key!('T') => {
|
||||
*cursor = Cursor::Editor {
|
||||
coming_from: id,
|
||||
parent: None,
|
||||
};
|
||||
}
|
||||
_ => return Ok(false),
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
|
||||
self.list_movement_key_bindings(bindings);
|
||||
bindings.empty();
|
||||
self.list_action_key_bindings(bindings);
|
||||
if can_compose {
|
||||
bindings.empty();
|
||||
self.list_edit_initiating_key_bindings(bindings);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_normal_input_event(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
event: &InputEvent,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
can_compose: bool,
|
||||
id: Option<M::Id>,
|
||||
) -> Result<bool, S::Error>
|
||||
where
|
||||
M: ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
#[allow(clippy::if_same_then_else)]
|
||||
Ok(
|
||||
if self
|
||||
.handle_movement_input_event(frame, event, cursor, editor)
|
||||
.await?
|
||||
{
|
||||
true
|
||||
} else if self.handle_action_input_event(event, id.as_ref()).await? {
|
||||
true
|
||||
} else if can_compose {
|
||||
self.handle_edit_initiating_input_event(event, cursor, id)
|
||||
.await?
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close editor");
|
||||
bindings.binding("enter", "send message");
|
||||
util::list_editor_key_bindings_allowing_external_editing(bindings, |_| true);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_editor_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
coming_from: Option<M::Id>,
|
||||
parent: Option<M::Id>,
|
||||
) -> Reaction<M> {
|
||||
// TODO Tab-completion
|
||||
|
||||
match event {
|
||||
key!(Esc) => {
|
||||
*cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom);
|
||||
return Reaction::Handled;
|
||||
}
|
||||
|
||||
key!(Enter) => {
|
||||
let content = editor.text().to_string();
|
||||
if !content.trim().is_empty() {
|
||||
*cursor = Cursor::Pseudo {
|
||||
coming_from,
|
||||
parent: parent.clone(),
|
||||
};
|
||||
return Reaction::Composed { parent, content };
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
let handled = util::handle_editor_input_event_allowing_external_editing(
|
||||
editor,
|
||||
terminal,
|
||||
crossterm_lock,
|
||||
event,
|
||||
|_| true,
|
||||
);
|
||||
match handled {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Reaction::NotHandled,
|
||||
Err(e) => return Reaction::ComposeError(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reaction::Handled
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(
|
||||
&self,
|
||||
bindings: &mut KeyBindingsList,
|
||||
cursor: &Cursor<M::Id>,
|
||||
can_compose: bool,
|
||||
) {
|
||||
bindings.heading("Chat");
|
||||
match cursor {
|
||||
Cursor::Bottom | Cursor::Msg(_) => {
|
||||
self.list_normal_key_bindings(bindings, can_compose);
|
||||
}
|
||||
Cursor::Editor { .. } => self.list_editor_key_bindings(bindings),
|
||||
Cursor::Pseudo { .. } => {
|
||||
self.list_normal_key_bindings(bindings, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
can_compose: bool,
|
||||
) -> Result<Reaction<M>, S::Error>
|
||||
where
|
||||
M: ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
Ok(match cursor {
|
||||
Cursor::Bottom => {
|
||||
if self
|
||||
.handle_normal_input_event(
|
||||
terminal.frame(),
|
||||
event,
|
||||
cursor,
|
||||
editor,
|
||||
can_compose,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Reaction::Handled
|
||||
} else {
|
||||
Reaction::NotHandled
|
||||
}
|
||||
}
|
||||
Cursor::Msg(id) => {
|
||||
let id = id.clone();
|
||||
if self
|
||||
.handle_normal_input_event(
|
||||
terminal.frame(),
|
||||
event,
|
||||
cursor,
|
||||
editor,
|
||||
can_compose,
|
||||
Some(id),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Reaction::Handled
|
||||
} else {
|
||||
Reaction::NotHandled
|
||||
}
|
||||
}
|
||||
Cursor::Editor {
|
||||
coming_from,
|
||||
parent,
|
||||
} => {
|
||||
let coming_from = coming_from.clone();
|
||||
let parent = parent.clone();
|
||||
self.handle_editor_input_event(
|
||||
terminal,
|
||||
crossterm_lock,
|
||||
event,
|
||||
cursor,
|
||||
editor,
|
||||
coming_from,
|
||||
parent,
|
||||
)
|
||||
}
|
||||
Cursor::Pseudo { .. } => {
|
||||
if self
|
||||
.handle_movement_input_event(terminal.frame(), event, cursor, editor)
|
||||
.await?
|
||||
{
|
||||
Reaction::Handled
|
||||
} else {
|
||||
Reaction::NotHandled
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send_successful(&mut self, id: &M::Id) {
|
||||
if let Cursor::Pseudo { .. } = self.last_cursor {
|
||||
self.last_cursor = Cursor::Msg(id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget<'a>(
|
||||
&'a mut self,
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
editor: &'a mut EditorState,
|
||||
nick: String,
|
||||
focused: bool,
|
||||
) -> TreeView<'a, M, S> {
|
||||
TreeView {
|
||||
state: self,
|
||||
cursor,
|
||||
editor,
|
||||
nick,
|
||||
focused,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TreeView<'a, M: Msg, S: MsgStore<M>> {
|
||||
state: &'a mut TreeViewState<M, S>,
|
||||
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
editor: &'a mut EditorState,
|
||||
|
||||
nick: String,
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<M, S> AsyncWidget<UiError> for TreeView<'_, M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: Send,
|
||||
UiError: From<S::Error>,
|
||||
{
|
||||
async fn size(
|
||||
&self,
|
||||
_widthdb: &mut WidthDb,
|
||||
_max_width: Option<u16>,
|
||||
_max_height: Option<u16>,
|
||||
) -> Result<Size, UiError> {
|
||||
Ok(Size::ZERO)
|
||||
}
|
||||
|
||||
async fn draw(self, frame: &mut Frame) -> Result<(), UiError> {
|
||||
let size = frame.size();
|
||||
|
||||
let context = TreeContext {
|
||||
size,
|
||||
nick: self.nick.clone(),
|
||||
focused: self.focused,
|
||||
last_cursor: self.state.last_cursor.clone(),
|
||||
last_cursor_top: self.state.last_cursor_top,
|
||||
};
|
||||
|
||||
let mut renderer = TreeRenderer::new(
|
||||
context,
|
||||
&self.state.store,
|
||||
self.cursor,
|
||||
self.editor,
|
||||
frame.widthdb(),
|
||||
);
|
||||
|
||||
renderer.prepare_blocks_for_drawing().await?;
|
||||
|
||||
self.state.last_size = size;
|
||||
self.state.last_nick = self.nick;
|
||||
renderer.update_render_info(
|
||||
&mut self.state.last_cursor,
|
||||
&mut self.state.last_cursor_top,
|
||||
&mut self.state.last_visible_msgs,
|
||||
);
|
||||
|
||||
for (range, block) in renderer.into_visible_blocks() {
|
||||
let widget = block.into_widget();
|
||||
frame.push(Pos::new(0, range.top), widget.size());
|
||||
widget.desync().draw(frame).await.infallible();
|
||||
frame.pop();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
458
cove/src/ui/chat/tree/renderer.rs
Normal file
458
cove/src/ui/chat/tree/renderer.rs
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
//! A [`Renderer`] for message trees.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use toss::widgets::{EditorState, Empty, Predrawn, Resize};
|
||||
use toss::{Size, Widget, WidthDb};
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
use crate::ui::chat::blocks::{Block, Blocks, Range};
|
||||
use crate::ui::chat::cursor::Cursor;
|
||||
use crate::ui::chat::renderer::{self, overlaps, Renderer};
|
||||
use crate::ui::ChatMsg;
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
use super::widgets;
|
||||
|
||||
/// When rendering messages as full trees, special ids and zero-height messages
|
||||
/// are used for robust scrolling behaviour.
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum TreeBlockId<Id> {
|
||||
/// There is a zero-height block at the very bottom of the chat that has
|
||||
/// this id. It is used for positioning [`Cursor::Bottom`].
|
||||
Bottom,
|
||||
/// Normal messages have this id. It is used for positioning
|
||||
/// [`Cursor::Msg`].
|
||||
Msg(Id),
|
||||
/// After all children of a message, a zero-height block with this id is
|
||||
/// rendered. It is used for positioning [`Cursor::Editor`] and
|
||||
/// [`Cursor::Pseudo`].
|
||||
After(Id),
|
||||
}
|
||||
|
||||
impl<Id: Clone> TreeBlockId<Id> {
|
||||
pub fn from_cursor(cursor: &Cursor<Id>) -> Self {
|
||||
match cursor {
|
||||
Cursor::Bottom
|
||||
| Cursor::Editor { parent: None, .. }
|
||||
| Cursor::Pseudo { parent: None, .. } => Self::Bottom,
|
||||
|
||||
Cursor::Msg(id) => Self::Msg(id.clone()),
|
||||
|
||||
Cursor::Editor {
|
||||
parent: Some(id), ..
|
||||
}
|
||||
| Cursor::Pseudo {
|
||||
parent: Some(id), ..
|
||||
} => Self::After(id.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn any_id(&self) -> Option<&Id> {
|
||||
match self {
|
||||
Self::Bottom => None,
|
||||
Self::Msg(id) | Self::After(id) => Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn msg_id(&self) -> Option<&Id> {
|
||||
match self {
|
||||
Self::Bottom | Self::After(_) => None,
|
||||
Self::Msg(id) => Some(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type TreeBlock<Id> = Block<TreeBlockId<Id>>;
|
||||
type TreeBlocks<Id> = Blocks<TreeBlockId<Id>>;
|
||||
|
||||
pub struct TreeContext<Id> {
|
||||
pub size: Size,
|
||||
pub nick: String,
|
||||
pub focused: bool,
|
||||
pub last_cursor: Cursor<Id>,
|
||||
pub last_cursor_top: i32,
|
||||
}
|
||||
|
||||
pub struct TreeRenderer<'a, M: Msg, S: MsgStore<M>> {
|
||||
context: TreeContext<M::Id>,
|
||||
|
||||
store: &'a S,
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
editor: &'a mut EditorState,
|
||||
widthdb: &'a mut WidthDb,
|
||||
|
||||
/// Root id of the topmost tree in the blocks. When set to `None`, only the
|
||||
/// bottom of the chat history has been rendered.
|
||||
top_root_id: Option<M::Id>,
|
||||
/// Root id of the bottommost tree in the blocks. When set to `None`, only
|
||||
/// the bottom of the chat history has been rendered.
|
||||
bottom_root_id: Option<M::Id>,
|
||||
|
||||
blocks: TreeBlocks<M::Id>,
|
||||
}
|
||||
|
||||
impl<'a, M, S> TreeRenderer<'a, M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
/// You must call [`Self::prepare_blocks_for_drawing`] immediately after
|
||||
/// calling this function.
|
||||
pub fn new(
|
||||
context: TreeContext<M::Id>,
|
||||
store: &'a S,
|
||||
cursor: &'a mut Cursor<M::Id>,
|
||||
editor: &'a mut EditorState,
|
||||
widthdb: &'a mut WidthDb,
|
||||
) -> Self {
|
||||
Self {
|
||||
context,
|
||||
store,
|
||||
cursor,
|
||||
editor,
|
||||
widthdb,
|
||||
top_root_id: None,
|
||||
bottom_root_id: None,
|
||||
blocks: Blocks::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn predraw<W>(widget: W, size: Size, widthdb: &mut WidthDb) -> Predrawn
|
||||
where
|
||||
W: Widget<Infallible>,
|
||||
{
|
||||
Predrawn::new(Resize::new(widget).with_max_width(size.width), widthdb).infallible()
|
||||
}
|
||||
|
||||
fn zero_height_block(&mut self, parent: Option<&M::Id>) -> TreeBlock<M::Id> {
|
||||
let id = match parent {
|
||||
Some(parent) => TreeBlockId::After(parent.clone()),
|
||||
None => TreeBlockId::Bottom,
|
||||
};
|
||||
|
||||
let widget = Self::predraw(Empty::new(), self.context.size, self.widthdb);
|
||||
Block::new(id, widget, false)
|
||||
}
|
||||
|
||||
fn editor_block(&mut self, indent: usize, parent: Option<&M::Id>) -> TreeBlock<M::Id> {
|
||||
let id = match parent {
|
||||
Some(parent) => TreeBlockId::After(parent.clone()),
|
||||
None => TreeBlockId::Bottom,
|
||||
};
|
||||
|
||||
// TODO Unhighlighted version when focusing on nick list
|
||||
let widget = widgets::editor::<M>(indent, &self.context.nick, self.editor);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
let mut block = Block::new(id, widget, false);
|
||||
|
||||
// Since the editor was rendered when the `Predrawn` was created, the
|
||||
// last cursor pos is accurate now.
|
||||
let cursor_line = self.editor.last_cursor_pos().y;
|
||||
block.set_focus(Range::new(cursor_line, cursor_line + 1));
|
||||
|
||||
block
|
||||
}
|
||||
|
||||
fn pseudo_block(&mut self, indent: usize, parent: Option<&M::Id>) -> TreeBlock<M::Id> {
|
||||
let id = match parent {
|
||||
Some(parent) => TreeBlockId::After(parent.clone()),
|
||||
None => TreeBlockId::Bottom,
|
||||
};
|
||||
|
||||
// TODO Unhighlighted version when focusing on nick list
|
||||
let widget = widgets::pseudo::<M>(indent, &self.context.nick, self.editor);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
Block::new(id, widget, false)
|
||||
}
|
||||
|
||||
fn message_block(&mut self, indent: usize, msg: &M) -> TreeBlock<M::Id> {
|
||||
let msg_id = msg.id();
|
||||
|
||||
let highlighted = match self.cursor {
|
||||
Cursor::Msg(id) => *id == msg_id,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// TODO Amount of folded messages
|
||||
let widget = widgets::msg(self.context.focused && highlighted, indent, msg, None);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
Block::new(TreeBlockId::Msg(msg_id), widget, true)
|
||||
}
|
||||
|
||||
fn message_placeholder_block(&mut self, indent: usize, msg_id: &M::Id) -> TreeBlock<M::Id> {
|
||||
let highlighted = match self.cursor {
|
||||
Cursor::Msg(id) => id == msg_id,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// TODO Amount of folded messages
|
||||
let widget = widgets::msg_placeholder(self.context.focused && highlighted, indent, None);
|
||||
let widget = Self::predraw(widget, self.context.size, self.widthdb);
|
||||
Block::new(TreeBlockId::Msg(msg_id.clone()), widget, true)
|
||||
}
|
||||
|
||||
fn layout_bottom(&mut self) -> TreeBlocks<M::Id> {
|
||||
let mut blocks = Blocks::new(0);
|
||||
|
||||
match self.cursor {
|
||||
Cursor::Editor { parent: None, .. } => blocks.push_bottom(self.editor_block(0, None)),
|
||||
Cursor::Pseudo { parent: None, .. } => blocks.push_bottom(self.pseudo_block(0, None)),
|
||||
_ => blocks.push_bottom(self.zero_height_block(None)),
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn layout_subtree(
|
||||
&mut self,
|
||||
tree: &Tree<M>,
|
||||
indent: usize,
|
||||
msg_id: &M::Id,
|
||||
blocks: &mut TreeBlocks<M::Id>,
|
||||
) {
|
||||
// Message itself
|
||||
let block = if let Some(msg) = tree.msg(msg_id) {
|
||||
self.message_block(indent, msg)
|
||||
} else {
|
||||
self.message_placeholder_block(indent, msg_id)
|
||||
};
|
||||
blocks.push_bottom(block);
|
||||
|
||||
// Children, recursively
|
||||
if let Some(children) = tree.children(msg_id) {
|
||||
for child in children {
|
||||
self.layout_subtree(tree, indent + 1, child, blocks);
|
||||
}
|
||||
}
|
||||
|
||||
// After message (zero-height block, editor, or placeholder)
|
||||
let block = match self.cursor {
|
||||
Cursor::Editor {
|
||||
parent: Some(id), ..
|
||||
} if id == msg_id => self.editor_block(indent + 1, Some(msg_id)),
|
||||
|
||||
Cursor::Pseudo {
|
||||
parent: Some(id), ..
|
||||
} if id == msg_id => self.pseudo_block(indent + 1, Some(msg_id)),
|
||||
|
||||
_ => self.zero_height_block(Some(msg_id)),
|
||||
};
|
||||
blocks.push_bottom(block);
|
||||
}
|
||||
|
||||
fn layout_tree(&mut self, tree: Tree<M>) -> TreeBlocks<M::Id> {
|
||||
let mut blocks = Blocks::new(0);
|
||||
self.layout_subtree(&tree, 0, tree.root(), &mut blocks);
|
||||
blocks
|
||||
}
|
||||
|
||||
async fn root_id(&self, id: &TreeBlockId<M::Id>) -> Result<Option<M::Id>, S::Error> {
|
||||
let Some(id) = id.any_id() else { return Ok(None); };
|
||||
let path = self.store.path(id).await?;
|
||||
Ok(Some(path.into_first()))
|
||||
}
|
||||
|
||||
async fn prepare_initial_tree(&mut self, root_id: &Option<M::Id>) -> Result<(), S::Error> {
|
||||
self.top_root_id = root_id.clone();
|
||||
self.bottom_root_id = root_id.clone();
|
||||
|
||||
let blocks = if let Some(root_id) = root_id {
|
||||
let tree = self.store.tree(root_id).await?;
|
||||
self.layout_tree(tree)
|
||||
} else {
|
||||
self.layout_bottom()
|
||||
};
|
||||
self.blocks.append_bottom(blocks);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_cursor_visible(&mut self) {
|
||||
let cursor_id = TreeBlockId::from_cursor(self.cursor);
|
||||
if *self.cursor == self.context.last_cursor {
|
||||
// Cursor did not move, so we just need to ensure it overlaps the
|
||||
// scroll area
|
||||
renderer::scroll_so_block_focus_overlaps_scroll_area(self, &cursor_id);
|
||||
} else {
|
||||
// Cursor moved, so it should fully overlap the scroll area
|
||||
renderer::scroll_so_block_focus_fully_overlaps_scroll_area(self, &cursor_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn root_id_is_above_root_id(first: Option<M::Id>, second: Option<M::Id>) -> bool {
|
||||
match (first, second) {
|
||||
(Some(_), None) => true,
|
||||
(Some(a), Some(b)) => a < b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_blocks_for_drawing(&mut self) -> Result<(), S::Error> {
|
||||
let cursor_id = TreeBlockId::from_cursor(self.cursor);
|
||||
let cursor_root_id = self.root_id(&cursor_id).await?;
|
||||
|
||||
// Render cursor and blocks around it until screen is filled as long as
|
||||
// the cursor is visible, regardless of how the screen is scrolled.
|
||||
self.prepare_initial_tree(&cursor_root_id).await?;
|
||||
renderer::expand_to_fill_screen_around_block(self, &cursor_id).await?;
|
||||
|
||||
// Scroll based on last cursor position
|
||||
let last_cursor_id = TreeBlockId::from_cursor(&self.context.last_cursor);
|
||||
if !renderer::scroll_to_set_block_top(self, &last_cursor_id, self.context.last_cursor_top) {
|
||||
// Since the last cursor is not within scrolling distance of our
|
||||
// current cursor, we need to estimate whether the last cursor was
|
||||
// above or below the current cursor.
|
||||
let last_cursor_root_id = self.root_id(&last_cursor_id).await?;
|
||||
if Self::root_id_is_above_root_id(last_cursor_root_id, cursor_root_id) {
|
||||
renderer::scroll_blocks_fully_below_screen(self);
|
||||
} else {
|
||||
renderer::scroll_blocks_fully_above_screen(self);
|
||||
}
|
||||
}
|
||||
|
||||
// Fulfill scroll constraints
|
||||
self.make_cursor_visible();
|
||||
renderer::clamp_scroll_biased_downwards(self);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_cursor_so_it_is_visible(&mut self) {
|
||||
let cursor_id = TreeBlockId::from_cursor(self.cursor);
|
||||
if matches!(cursor_id, TreeBlockId::Bottom | TreeBlockId::Msg(_)) {
|
||||
match renderer::find_cursor_starting_at(self, &cursor_id) {
|
||||
Some(TreeBlockId::Bottom) => *self.cursor = Cursor::Bottom,
|
||||
Some(TreeBlockId::Msg(id)) => *self.cursor = Cursor::Msg(id.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn scroll_by(&mut self, delta: i32) -> Result<(), S::Error> {
|
||||
self.blocks.shift(delta);
|
||||
renderer::expand_to_fill_visible_area(self).await?;
|
||||
renderer::clamp_scroll_biased_downwards(self);
|
||||
|
||||
self.move_cursor_so_it_is_visible();
|
||||
|
||||
self.make_cursor_visible();
|
||||
renderer::clamp_scroll_biased_downwards(self);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn center_cursor(&mut self) {
|
||||
let cursor_id = TreeBlockId::from_cursor(self.cursor);
|
||||
renderer::scroll_so_block_is_centered(self, &cursor_id);
|
||||
|
||||
self.make_cursor_visible();
|
||||
renderer::clamp_scroll_biased_downwards(self);
|
||||
}
|
||||
|
||||
pub fn update_render_info(
|
||||
&self,
|
||||
last_cursor: &mut Cursor<M::Id>,
|
||||
last_cursor_top: &mut i32,
|
||||
last_visible_msgs: &mut Vec<M::Id>,
|
||||
) {
|
||||
*last_cursor = self.cursor.clone();
|
||||
|
||||
let cursor_id = TreeBlockId::from_cursor(self.cursor);
|
||||
let (range, _) = self.blocks.find_block(&cursor_id).unwrap();
|
||||
*last_cursor_top = range.top;
|
||||
|
||||
let area = renderer::visible_area(self);
|
||||
*last_visible_msgs = self
|
||||
.blocks
|
||||
.iter()
|
||||
.filter(|(range, _)| overlaps(area, *range))
|
||||
.filter_map(|(_, block)| block.id().msg_id())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn into_visible_blocks(
|
||||
self,
|
||||
) -> impl Iterator<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> {
|
||||
let area = renderer::visible_area(&self);
|
||||
self.blocks
|
||||
.into_iter()
|
||||
.filter(move |(range, block)| overlaps(area, block.focus(*range)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<M, S> Renderer<TreeBlockId<M::Id>> for TreeRenderer<'_, M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
type Error = S::Error;
|
||||
|
||||
fn size(&self) -> Size {
|
||||
self.context.size
|
||||
}
|
||||
|
||||
fn scrolloff(&self) -> i32 {
|
||||
2 // TODO Make configurable
|
||||
}
|
||||
|
||||
fn blocks(&self) -> &TreeBlocks<M::Id> {
|
||||
&self.blocks
|
||||
}
|
||||
|
||||
fn blocks_mut(&mut self) -> &mut TreeBlocks<M::Id> {
|
||||
&mut self.blocks
|
||||
}
|
||||
|
||||
fn into_blocks(self) -> TreeBlocks<M::Id> {
|
||||
self.blocks
|
||||
}
|
||||
|
||||
async fn expand_top(&mut self) -> Result<(), Self::Error> {
|
||||
let prev_root_id = if let Some(top_root_id) = &self.top_root_id {
|
||||
self.store.prev_root_id(top_root_id).await?
|
||||
} else {
|
||||
self.store.last_root_id().await?
|
||||
};
|
||||
|
||||
if let Some(prev_root_id) = prev_root_id {
|
||||
let tree = self.store.tree(&prev_root_id).await?;
|
||||
let blocks = self.layout_tree(tree);
|
||||
self.blocks.append_top(blocks);
|
||||
self.top_root_id = Some(prev_root_id);
|
||||
} else {
|
||||
self.blocks.end_top();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expand_bottom(&mut self) -> Result<(), Self::Error> {
|
||||
let Some(bottom_root_id) = &self.bottom_root_id else {
|
||||
self.blocks.end_bottom();
|
||||
return Ok(())
|
||||
};
|
||||
|
||||
let next_root_id = self.store.next_root_id(bottom_root_id).await?;
|
||||
if let Some(next_root_id) = next_root_id {
|
||||
let tree = self.store.tree(&next_root_id).await?;
|
||||
let blocks = self.layout_tree(tree);
|
||||
self.blocks.append_bottom(blocks);
|
||||
self.bottom_root_id = Some(next_root_id);
|
||||
} else {
|
||||
let blocks = self.layout_bottom();
|
||||
self.blocks.append_bottom(blocks);
|
||||
self.blocks.end_bottom();
|
||||
self.bottom_root_id = None;
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
68
cove/src/ui/chat/tree/scroll.rs
Normal file
68
cove/src/ui/chat/tree/scroll.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use toss::widgets::EditorState;
|
||||
use toss::WidthDb;
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
use crate::ui::chat::cursor::Cursor;
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
use super::renderer::{TreeContext, TreeRenderer};
|
||||
use super::TreeViewState;
|
||||
|
||||
impl<M, S> TreeViewState<M, S>
|
||||
where
|
||||
M: Msg + ChatMsg + Send + Sync,
|
||||
M::Id: Send + Sync,
|
||||
S: MsgStore<M> + Send + Sync,
|
||||
S::Error: Send,
|
||||
{
|
||||
fn last_context(&self) -> TreeContext<M::Id> {
|
||||
TreeContext {
|
||||
size: self.last_size,
|
||||
nick: self.last_nick.clone(),
|
||||
focused: true,
|
||||
last_cursor: self.last_cursor.clone(),
|
||||
last_cursor_top: self.last_cursor_top,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn scroll_by(
|
||||
&mut self,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
widthdb: &mut WidthDb,
|
||||
delta: i32,
|
||||
) -> Result<(), S::Error> {
|
||||
let context = self.last_context();
|
||||
let mut renderer = TreeRenderer::new(context, &self.store, cursor, editor, widthdb);
|
||||
renderer.prepare_blocks_for_drawing().await?;
|
||||
|
||||
renderer.scroll_by(delta).await?;
|
||||
|
||||
renderer.update_render_info(
|
||||
&mut self.last_cursor,
|
||||
&mut self.last_cursor_top,
|
||||
&mut self.last_visible_msgs,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn center_cursor(
|
||||
&mut self,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
editor: &mut EditorState,
|
||||
widthdb: &mut WidthDb,
|
||||
) -> Result<(), S::Error> {
|
||||
let context = self.last_context();
|
||||
let mut renderer = TreeRenderer::new(context, &self.store, cursor, editor, widthdb);
|
||||
renderer.prepare_blocks_for_drawing().await?;
|
||||
|
||||
renderer.center_cursor();
|
||||
|
||||
renderer.update_render_info(
|
||||
&mut self.last_cursor,
|
||||
&mut self.last_cursor_top,
|
||||
&mut self.last_visible_msgs,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
181
cove/src/ui/chat/tree/widgets.rs
Normal file
181
cove/src/ui/chat/tree/widgets.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use toss::widgets::{Boxed, EditorState, Join2, Join4, Join5, Text};
|
||||
use toss::{Style, Styled, WidgetExt};
|
||||
|
||||
use crate::store::Msg;
|
||||
use crate::ui::chat::widgets::{Indent, Seen, Time};
|
||||
use crate::ui::ChatMsg;
|
||||
|
||||
pub const PLACEHOLDER: &str = "[...]";
|
||||
|
||||
pub fn style_placeholder() -> Style {
|
||||
Style::new().dark_grey()
|
||||
}
|
||||
|
||||
fn style_time(highlighted: bool) -> Style {
|
||||
if highlighted {
|
||||
Style::new().black().on_white()
|
||||
} else {
|
||||
Style::new().grey()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_indent(highlighted: bool) -> Style {
|
||||
if highlighted {
|
||||
Style::new().black().on_white()
|
||||
} else {
|
||||
Style::new().dark_grey()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_info() -> Style {
|
||||
Style::new().italic().dark_grey()
|
||||
}
|
||||
|
||||
fn style_editor_highlight() -> Style {
|
||||
Style::new().black().on_cyan()
|
||||
}
|
||||
|
||||
fn style_pseudo_highlight() -> Style {
|
||||
Style::new().black().on_yellow()
|
||||
}
|
||||
|
||||
pub fn msg<M: Msg + ChatMsg>(
|
||||
highlighted: bool,
|
||||
indent: usize,
|
||||
msg: &M,
|
||||
folded_info: Option<usize>,
|
||||
) -> Boxed<'static, Infallible> {
|
||||
let (nick, mut content) = msg.styled();
|
||||
|
||||
if let Some(amount) = folded_info {
|
||||
content = content
|
||||
.then_plain("\n")
|
||||
.then(format!("[{amount} more]"), style_info());
|
||||
}
|
||||
|
||||
Join5::horizontal(
|
||||
Seen::new(msg.seen()).segment().with_fixed(true),
|
||||
Time::new(Some(msg.time()), style_time(highlighted))
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.with_stretch(true)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Indent::new(indent, style_indent(highlighted))
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Join2::vertical(
|
||||
Text::new(nick)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Indent::new(1, style_indent(false)).segment(),
|
||||
)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
// TODO Minimum content width
|
||||
// TODO Minimizing and maximizing messages
|
||||
Text::new(content).segment(),
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
pub fn msg_placeholder(
|
||||
highlighted: bool,
|
||||
indent: usize,
|
||||
folded_info: Option<usize>,
|
||||
) -> Boxed<'static, Infallible> {
|
||||
let mut content = Styled::new(PLACEHOLDER, style_placeholder());
|
||||
|
||||
if let Some(amount) = folded_info {
|
||||
content = content
|
||||
.then_plain("\n")
|
||||
.then(format!("[{amount} more]"), style_info());
|
||||
}
|
||||
|
||||
Join4::horizontal(
|
||||
Seen::new(true).segment().with_fixed(true),
|
||||
Time::new(None, style_time(highlighted))
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.with_stretch(true)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Indent::new(indent, style_indent(highlighted))
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Text::new(content).segment(),
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
pub fn editor<'a, M: ChatMsg>(
|
||||
indent: usize,
|
||||
nick: &str,
|
||||
editor: &'a mut EditorState,
|
||||
) -> Boxed<'a, Infallible> {
|
||||
let (nick, content) = M::edit(nick, editor.text());
|
||||
let editor = editor.widget().with_highlight(|_| content);
|
||||
|
||||
Join5::horizontal(
|
||||
Seen::new(true).segment().with_fixed(true),
|
||||
Time::new(None, style_editor_highlight())
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.with_stretch(true)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Indent::new(indent, style_editor_highlight())
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Join2::vertical(
|
||||
Text::new(nick)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Indent::new(1, style_indent(false)).segment(),
|
||||
)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
editor.segment(),
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
pub fn pseudo<'a, M: ChatMsg>(
|
||||
indent: usize,
|
||||
nick: &str,
|
||||
editor: &'a mut EditorState,
|
||||
) -> Boxed<'a, Infallible> {
|
||||
let (nick, content) = M::edit(nick, editor.text());
|
||||
|
||||
Join5::horizontal(
|
||||
Seen::new(true).segment().with_fixed(true),
|
||||
Time::new(None, style_pseudo_highlight())
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.with_stretch(true)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Indent::new(indent, style_pseudo_highlight())
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Join2::vertical(
|
||||
Text::new(nick)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Indent::new(1, style_indent(false)).segment(),
|
||||
)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Text::new(content).segment(),
|
||||
)
|
||||
.boxed()
|
||||
}
|
||||
117
cove/src/ui/chat/widgets.rs
Normal file
117
cove/src/ui/chat/widgets.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use time::OffsetDateTime;
|
||||
use toss::widgets::{Boxed, Empty, Text};
|
||||
use toss::{Frame, Pos, Size, Style, Widget, WidgetExt, WidthDb};
|
||||
|
||||
use crate::util::InfallibleExt;
|
||||
|
||||
pub const INDENT_STR: &str = "│ ";
|
||||
pub const INDENT_WIDTH: usize = 2;
|
||||
|
||||
pub struct Indent {
|
||||
level: usize,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl Indent {
|
||||
pub fn new(level: usize, style: Style) -> Self {
|
||||
Self { level, style }
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Widget<E> for Indent {
|
||||
fn size(
|
||||
&self,
|
||||
_widthdb: &mut WidthDb,
|
||||
_max_width: Option<u16>,
|
||||
_max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
let width = (INDENT_WIDTH * self.level).try_into().unwrap_or(u16::MAX);
|
||||
Ok(Size::new(width, 0))
|
||||
}
|
||||
|
||||
fn draw(self, frame: &mut Frame) -> Result<(), E> {
|
||||
let size = frame.size();
|
||||
let indent_string = INDENT_STR.repeat(self.level);
|
||||
|
||||
for y in 0..size.height {
|
||||
frame.write(Pos::new(0, y.into()), (&indent_string, self.style))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const TIME_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day] [hour]:[minute]");
|
||||
const TIME_WIDTH: u16 = 16;
|
||||
|
||||
pub struct Time(Boxed<'static, Infallible>);
|
||||
|
||||
impl Time {
|
||||
pub fn new(time: Option<OffsetDateTime>, style: Style) -> Self {
|
||||
let widget = if let Some(time) = time {
|
||||
let text = time.format(TIME_FORMAT).expect("could not format time");
|
||||
Text::new((text, style))
|
||||
.background()
|
||||
.with_style(style)
|
||||
.boxed()
|
||||
} else {
|
||||
Empty::new()
|
||||
.with_width(TIME_WIDTH)
|
||||
.background()
|
||||
.with_style(style)
|
||||
.boxed()
|
||||
};
|
||||
Self(widget)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Widget<E> for Time {
|
||||
fn size(
|
||||
&self,
|
||||
widthdb: &mut WidthDb,
|
||||
max_width: Option<u16>,
|
||||
max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
Ok(self.0.size(widthdb, max_width, max_height).infallible())
|
||||
}
|
||||
|
||||
fn draw(self, frame: &mut Frame) -> Result<(), E> {
|
||||
self.0.draw(frame).infallible();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Seen(Boxed<'static, Infallible>);
|
||||
|
||||
impl Seen {
|
||||
pub fn new(seen: bool) -> Self {
|
||||
let widget = if seen {
|
||||
Empty::new().with_width(1).boxed()
|
||||
} else {
|
||||
let style = Style::new().black().on_green();
|
||||
Text::new("*").background().with_style(style).boxed()
|
||||
};
|
||||
Self(widget)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Widget<E> for Seen {
|
||||
fn size(
|
||||
&self,
|
||||
widthdb: &mut WidthDb,
|
||||
max_width: Option<u16>,
|
||||
max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
Ok(self.0.size(widthdb, max_width, max_height).infallible())
|
||||
}
|
||||
|
||||
fn draw(self, frame: &mut Frame) -> Result<(), E> {
|
||||
self.0.draw(frame).infallible();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
8
cove/src/ui/euph.rs
Normal file
8
cove/src/ui/euph.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
mod account;
|
||||
mod auth;
|
||||
mod inspect;
|
||||
mod links;
|
||||
mod nick;
|
||||
mod nick_list;
|
||||
mod popup;
|
||||
pub mod room;
|
||||
214
cove/src/ui/euph/account.rs
Normal file
214
cove/src/ui/euph/account.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::PersonalAccountView;
|
||||
use euphoxide::conn;
|
||||
use toss::widgets::{EditorState, Empty, Join3, Join4, Text};
|
||||
use toss::{Style, Terminal, Widget, WidgetExt};
|
||||
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Email,
|
||||
Password,
|
||||
}
|
||||
|
||||
pub struct LoggedOut {
|
||||
focus: Focus,
|
||||
email: EditorState,
|
||||
password: EditorState,
|
||||
}
|
||||
|
||||
impl LoggedOut {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
focus: Focus::Email,
|
||||
email: EditorState::new(),
|
||||
password: EditorState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
let bold = Style::new().bold();
|
||||
Join4::vertical(
|
||||
Text::new(("Not logged in", bold.yellow())).segment(),
|
||||
Empty::new().with_height(1).segment(),
|
||||
Join3::horizontal(
|
||||
Text::new(("Email address:", bold))
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Empty::new().with_width(1).segment().with_fixed(true),
|
||||
self.email
|
||||
.widget()
|
||||
.with_focus(self.focus == Focus::Email)
|
||||
.segment(),
|
||||
)
|
||||
.segment(),
|
||||
Join3::horizontal(
|
||||
Text::new(("Password:", bold)).segment().with_fixed(true),
|
||||
Empty::new().with_width(5 + 1).segment().with_fixed(true),
|
||||
self.password
|
||||
.widget()
|
||||
.with_focus(self.focus == Focus::Password)
|
||||
.with_hidden_default_placeholder()
|
||||
.segment(),
|
||||
)
|
||||
.segment(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LoggedIn(PersonalAccountView);
|
||||
|
||||
impl LoggedIn {
|
||||
fn widget(&self) -> impl Widget<UiError> {
|
||||
let bold = Style::new().bold();
|
||||
Join3::vertical(
|
||||
Text::new(("Logged in", bold.green())).segment(),
|
||||
Empty::new().with_height(1).segment(),
|
||||
Join3::horizontal(
|
||||
Text::new(("Email address:", bold))
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Empty::new().with_width(1).segment().with_fixed(true),
|
||||
Text::new((&self.0.email,)).segment(),
|
||||
)
|
||||
.segment(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AccountUiState {
|
||||
LoggedOut(LoggedOut),
|
||||
LoggedIn(LoggedIn),
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
ResetState,
|
||||
}
|
||||
|
||||
impl AccountUiState {
|
||||
pub fn new() -> Self {
|
||||
Self::LoggedOut(LoggedOut::new())
|
||||
}
|
||||
|
||||
/// Returns `false` if the account UI should not be displayed any longer.
|
||||
pub fn stabilize(&mut self, state: Option<&euph::State>) -> bool {
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(state))) = state {
|
||||
match (&self, &state.account) {
|
||||
(Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())),
|
||||
(Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
let inner = match self {
|
||||
Self::LoggedOut(logged_out) => logged_out.widget().first2(),
|
||||
Self::LoggedIn(logged_in) => logged_in.widget().second2(),
|
||||
}
|
||||
.resize()
|
||||
.with_min_width(40);
|
||||
|
||||
Popup::new(inner, "Account")
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close account ui");
|
||||
|
||||
match self {
|
||||
Self::LoggedOut(logged_out) => {
|
||||
match logged_out.focus {
|
||||
Focus::Email => bindings.binding("enter", "focus on password"),
|
||||
Focus::Password => bindings.binding("enter", "log in"),
|
||||
}
|
||||
bindings.binding("tab", "switch focus");
|
||||
util::list_editor_key_bindings(bindings, |c| c != '\n');
|
||||
}
|
||||
Self::LoggedIn(_) => bindings.binding("L", "log out"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
room: &Option<Room>,
|
||||
) -> EventResult {
|
||||
if let key!(Esc) = event {
|
||||
return EventResult::ResetState;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::LoggedOut(logged_out) => {
|
||||
if let key!(Tab) = event {
|
||||
logged_out.focus = match logged_out.focus {
|
||||
Focus::Email => Focus::Password,
|
||||
Focus::Password => Focus::Email,
|
||||
};
|
||||
return EventResult::Handled;
|
||||
}
|
||||
|
||||
match logged_out.focus {
|
||||
Focus::Email => {
|
||||
if let key!(Enter) = event {
|
||||
logged_out.focus = Focus::Password;
|
||||
return EventResult::Handled;
|
||||
}
|
||||
|
||||
if util::handle_editor_input_event(
|
||||
&mut logged_out.email,
|
||||
terminal,
|
||||
event,
|
||||
|c| c != '\n',
|
||||
) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
Focus::Password => {
|
||||
if let key!(Enter) = event {
|
||||
if let Some(room) = room {
|
||||
let _ = room.login(
|
||||
logged_out.email.text().to_string(),
|
||||
logged_out.password.text().to_string(),
|
||||
);
|
||||
}
|
||||
return EventResult::Handled;
|
||||
}
|
||||
|
||||
if util::handle_editor_input_event(
|
||||
&mut logged_out.password,
|
||||
terminal,
|
||||
event,
|
||||
|c| c != '\n',
|
||||
) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::LoggedIn(_) => {
|
||||
if let key!('L') = event {
|
||||
if let Some(room) = room {
|
||||
let _ = room.logout();
|
||||
}
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
cove/src/ui/euph/auth.rs
Normal file
54
cove/src/ui/euph/auth.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use toss::widgets::EditorState;
|
||||
use toss::{Terminal, Widget};
|
||||
|
||||
use crate::euph::Room;
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
|
||||
pub fn new() -> EditorState {
|
||||
EditorState::new()
|
||||
}
|
||||
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
|
||||
Popup::new(
|
||||
editor.widget().with_hidden_default_placeholder(),
|
||||
"Enter password",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "authenticate");
|
||||
util::list_editor_key_bindings(bindings, |_| true);
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
ResetState,
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
room: &Option<Room>,
|
||||
editor: &mut EditorState,
|
||||
) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => EventResult::ResetState,
|
||||
key!(Enter) => {
|
||||
if let Some(room) = &room {
|
||||
let _ = room.auth(editor.text().to_string());
|
||||
}
|
||||
EventResult::ResetState
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(editor, terminal, event, |_| true) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
cove/src/ui/euph/inspect.rs
Normal file
139
cove/src/ui/euph/inspect.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::{Message, NickEvent, SessionView};
|
||||
use euphoxide::conn::SessionInfo;
|
||||
use toss::widgets::Text;
|
||||
use toss::{Style, Styled, Widget};
|
||||
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::UiError;
|
||||
|
||||
macro_rules! line {
|
||||
( $text:ident, $name:expr, $val:expr ) => {
|
||||
$text = $text
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(format!(" {}\n", $val));
|
||||
};
|
||||
( $text:ident, $name:expr, $val:expr, debug ) => {
|
||||
$text = $text
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(format!(" {:?}\n", $val));
|
||||
};
|
||||
( $text:ident, $name:expr, $val:expr, optional ) => {
|
||||
if let Some(val) = $val {
|
||||
$text = $text
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(format!(" {val}\n"));
|
||||
} else {
|
||||
$text = $text
|
||||
.then($name, Style::new().cyan())
|
||||
.then_plain(" ")
|
||||
.then("none", Style::new().italic().grey())
|
||||
.then_plain("\n");
|
||||
}
|
||||
};
|
||||
( $text:ident, $name:expr, $val:expr, yes or no ) => {
|
||||
$text = $text.then($name, Style::new().cyan()).then_plain(if $val {
|
||||
" yes\n"
|
||||
} else {
|
||||
" no\n"
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
fn session_view_lines(mut text: Styled, session: &SessionView) -> Styled {
|
||||
line!(text, "id", session.id);
|
||||
line!(text, "name", session.name);
|
||||
line!(text, "name (raw)", session.name, debug);
|
||||
line!(text, "server_id", session.server_id);
|
||||
line!(text, "server_era", session.server_era);
|
||||
line!(text, "session_id", session.session_id.0);
|
||||
line!(text, "is_staff", session.is_staff, yes or no);
|
||||
line!(text, "is_manager", session.is_manager, yes or no);
|
||||
line!(
|
||||
text,
|
||||
"client_address",
|
||||
session.client_address.as_ref(),
|
||||
optional
|
||||
);
|
||||
line!(
|
||||
text,
|
||||
"real_client_address",
|
||||
session.real_client_address.as_ref(),
|
||||
optional
|
||||
);
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
fn nick_event_lines(mut text: Styled, event: &NickEvent) -> Styled {
|
||||
line!(text, "id", event.id);
|
||||
line!(text, "name", event.to);
|
||||
line!(text, "name (raw)", event.to, debug);
|
||||
line!(text, "session_id", event.session_id.0);
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
fn message_lines(mut text: Styled, msg: &Message) -> Styled {
|
||||
line!(text, "id", msg.id.0);
|
||||
line!(text, "parent", msg.parent.map(|p| p.0), optional);
|
||||
line!(text, "previous_edit_id", msg.previous_edit_id, optional);
|
||||
line!(text, "time", msg.time.0);
|
||||
line!(text, "encryption_key_id", &msg.encryption_key_id, optional);
|
||||
line!(text, "edited", msg.edited.map(|t| t.0), optional);
|
||||
line!(text, "deleted", msg.deleted.map(|t| t.0), optional);
|
||||
line!(text, "truncated", msg.truncated, yes or no);
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
pub fn session_widget(session: &SessionInfo) -> impl Widget<UiError> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let text = match session {
|
||||
SessionInfo::Full(session) => {
|
||||
let text = Styled::new("Full session", heading_style).then_plain("\n");
|
||||
session_view_lines(text, session)
|
||||
}
|
||||
SessionInfo::Partial(event) => {
|
||||
let text = Styled::new("Partial session", heading_style).then_plain("\n");
|
||||
nick_event_lines(text, event)
|
||||
}
|
||||
};
|
||||
|
||||
Popup::new(Text::new(text), "Inspect session")
|
||||
}
|
||||
|
||||
pub fn message_widget(msg: &Message) -> impl Widget<UiError> {
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
let mut text = Styled::new("Message", heading_style).then_plain("\n");
|
||||
|
||||
text = message_lines(text, msg);
|
||||
|
||||
text = text
|
||||
.then_plain("\n")
|
||||
.then("Sender", heading_style)
|
||||
.then_plain("\n");
|
||||
|
||||
text = session_view_lines(text, &msg.sender);
|
||||
|
||||
Popup::new(Text::new(text), "Inspect message")
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close");
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Close,
|
||||
}
|
||||
|
||||
pub fn handle_input_event(event: &InputEvent) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => EventResult::Close,
|
||||
_ => EventResult::NotHandled,
|
||||
}
|
||||
}
|
||||
140
cove/src/ui/euph/links.rs
Normal file
140
cove/src/ui/euph/links.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use std::io;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use linkify::{LinkFinder, LinkKind};
|
||||
use toss::widgets::Text;
|
||||
use toss::{Style, Styled, Widget};
|
||||
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::{ListBuilder, ListState, Popup};
|
||||
use crate::ui::UiError;
|
||||
|
||||
pub struct LinksState {
|
||||
links: Vec<String>,
|
||||
list: ListState<usize>,
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
Close,
|
||||
ErrorOpeningLink { link: String, error: io::Error },
|
||||
}
|
||||
|
||||
const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
|
||||
|
||||
impl LinksState {
|
||||
pub fn new(content: &str) -> Self {
|
||||
let links = LinkFinder::new()
|
||||
.url_must_have_scheme(false)
|
||||
.kinds(&[LinkKind::Url])
|
||||
.links(content)
|
||||
.map(|l| l.as_str().to_string())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
links,
|
||||
list: ListState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(&mut self) -> impl Widget<UiError> + '_ {
|
||||
let style_selected = Style::new().black().on_white();
|
||||
|
||||
let mut list_builder = ListBuilder::new();
|
||||
|
||||
if self.links.is_empty() {
|
||||
list_builder.add_unsel(Text::new(("No links found", Style::new().grey().italic())))
|
||||
}
|
||||
|
||||
for (id, link) in self.links.iter().enumerate() {
|
||||
let link = link.clone();
|
||||
if let Some(&number_key) = NUMBER_KEYS.get(id) {
|
||||
list_builder.add_sel(id, move |selected| {
|
||||
let text = if selected {
|
||||
Styled::new(format!("[{number_key}]"), style_selected.bold())
|
||||
.then(" ", style_selected)
|
||||
.then(link, style_selected)
|
||||
} else {
|
||||
Styled::new(format!("[{number_key}]"), Style::new().dark_grey().bold())
|
||||
.then_plain(" ")
|
||||
.then_plain(link)
|
||||
};
|
||||
Text::new(text)
|
||||
});
|
||||
} else {
|
||||
list_builder.add_sel(id, move |selected| {
|
||||
let text = if selected {
|
||||
Styled::new(format!(" {link}"), style_selected)
|
||||
} else {
|
||||
Styled::new_plain(format!(" {link}"))
|
||||
};
|
||||
Text::new(text)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Popup::new(list_builder.build(&mut self.list), "Links")
|
||||
}
|
||||
|
||||
fn open_link_by_id(&self, id: usize) -> EventResult {
|
||||
if let Some(link) = self.links.get(id) {
|
||||
// The `http://` or `https://` schema is necessary for open::that to
|
||||
// successfully open the link in the browser.
|
||||
let link = if link.starts_with("http://") || link.starts_with("https://") {
|
||||
link.clone()
|
||||
} else {
|
||||
format!("https://{link}")
|
||||
};
|
||||
|
||||
if let Err(error) = open::that(&link) {
|
||||
return EventResult::ErrorOpeningLink { link, error };
|
||||
}
|
||||
}
|
||||
EventResult::Handled
|
||||
}
|
||||
|
||||
fn open_link(&self) -> EventResult {
|
||||
if let Some(id) = self.list.selected() {
|
||||
self.open_link_by_id(*id)
|
||||
} else {
|
||||
EventResult::Handled
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "close links popup");
|
||||
bindings.binding("j/k, ↓/↑", "move cursor up/down");
|
||||
bindings.binding("g, home", "move cursor to top");
|
||||
bindings.binding("G, end", "move cursor to bottom");
|
||||
bindings.binding("ctrl+y/e", "scroll up/down");
|
||||
bindings.empty();
|
||||
bindings.binding("enter", "open selected link");
|
||||
bindings.binding("1,2,...", "open link by position");
|
||||
}
|
||||
|
||||
pub fn handle_input_event(&mut self, event: &InputEvent) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => return EventResult::Close,
|
||||
key!('k') | key!(Up) => self.list.move_cursor_up(),
|
||||
key!('j') | key!(Down) => self.list.move_cursor_down(),
|
||||
key!('g') | key!(Home) => self.list.move_cursor_to_top(),
|
||||
key!('G') | key!(End) => self.list.move_cursor_to_bottom(),
|
||||
key!(Ctrl + 'y') => self.list.scroll_up(1),
|
||||
key!(Ctrl + 'e') => self.list.scroll_down(1),
|
||||
key!(Enter) => return self.open_link(),
|
||||
key!('1') => return self.open_link_by_id(0),
|
||||
key!('2') => return self.open_link_by_id(1),
|
||||
key!('3') => return self.open_link_by_id(2),
|
||||
key!('4') => return self.open_link_by_id(3),
|
||||
key!('5') => return self.open_link_by_id(4),
|
||||
key!('6') => return self.open_link_by_id(5),
|
||||
key!('7') => return self.open_link_by_id(6),
|
||||
key!('8') => return self.open_link_by_id(7),
|
||||
key!('9') => return self.open_link_by_id(8),
|
||||
key!('0') => return self.open_link_by_id(9),
|
||||
_ => return EventResult::NotHandled,
|
||||
}
|
||||
EventResult::Handled
|
||||
}
|
||||
}
|
||||
60
cove/src/ui/euph/nick.rs
Normal file
60
cove/src/ui/euph/nick.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use euphoxide::conn::Joined;
|
||||
use toss::widgets::EditorState;
|
||||
use toss::{Style, Terminal, Widget};
|
||||
|
||||
use crate::euph::{self, Room};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::{util, UiError};
|
||||
|
||||
pub fn new(joined: Joined) -> EditorState {
|
||||
EditorState::with_initial_text(joined.session.name)
|
||||
}
|
||||
|
||||
pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
|
||||
let inner = editor
|
||||
.widget()
|
||||
.with_highlight(|s| euph::style_nick_exact(s, Style::new()));
|
||||
|
||||
Popup::new(inner, "Choose nick")
|
||||
}
|
||||
|
||||
fn nick_char(c: char) -> bool {
|
||||
c != '\n'
|
||||
}
|
||||
|
||||
pub fn list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "set nick");
|
||||
util::list_editor_key_bindings(bindings, nick_char);
|
||||
}
|
||||
|
||||
pub enum EventResult {
|
||||
NotHandled,
|
||||
Handled,
|
||||
ResetState,
|
||||
}
|
||||
|
||||
pub fn handle_input_event(
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
room: &Option<Room>,
|
||||
editor: &mut EditorState,
|
||||
) -> EventResult {
|
||||
match event {
|
||||
key!(Esc) => EventResult::ResetState,
|
||||
key!(Enter) => {
|
||||
if let Some(room) = &room {
|
||||
let _ = room.nick(editor.text().to_string());
|
||||
}
|
||||
EventResult::ResetState
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(editor, terminal, event, nick_char) {
|
||||
EventResult::Handled
|
||||
} else {
|
||||
EventResult::NotHandled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
cove/src/ui/euph/nick_list.rs
Normal file
174
cove/src/ui/euph/nick_list.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
use std::iter;
|
||||
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use euphoxide::api::{NickEvent, SessionId, SessionType, SessionView, UserId};
|
||||
use euphoxide::conn::{Joined, SessionInfo};
|
||||
use toss::widgets::{Background, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
|
||||
use crate::euph;
|
||||
use crate::ui::widgets::{ListBuilder, ListState};
|
||||
use crate::ui::UiError;
|
||||
|
||||
pub fn widget<'a>(
|
||||
list: &'a mut ListState<SessionId>,
|
||||
joined: &Joined,
|
||||
focused: bool,
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
let mut list_builder = ListBuilder::new();
|
||||
render_rows(&mut list_builder, joined, focused);
|
||||
list_builder.build(list)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct HalfSession {
|
||||
name: String,
|
||||
id: UserId,
|
||||
session_id: SessionId,
|
||||
is_staff: bool,
|
||||
is_manager: bool,
|
||||
}
|
||||
|
||||
impl HalfSession {
|
||||
fn from_session_view(sess: &SessionView) -> Self {
|
||||
Self {
|
||||
name: sess.name.clone(),
|
||||
id: sess.id.clone(),
|
||||
session_id: sess.session_id.clone(),
|
||||
is_staff: sess.is_staff,
|
||||
is_manager: sess.is_manager,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_nick_event(nick: &NickEvent) -> Self {
|
||||
Self {
|
||||
name: nick.to.clone(),
|
||||
id: nick.id.clone(),
|
||||
session_id: nick.session_id.clone(),
|
||||
is_staff: false,
|
||||
is_manager: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_session_info(info: &SessionInfo) -> Self {
|
||||
match info {
|
||||
SessionInfo::Full(sess) => Self::from_session_view(sess),
|
||||
SessionInfo::Partial(nick) => Self::from_nick_event(nick),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_rows(
|
||||
list_builder: &mut ListBuilder<'_, SessionId, Background<Text>>,
|
||||
joined: &Joined,
|
||||
focused: bool,
|
||||
) {
|
||||
let mut people = vec![];
|
||||
let mut bots = vec![];
|
||||
let mut lurkers = vec![];
|
||||
let mut nurkers = vec![];
|
||||
|
||||
let sessions = joined
|
||||
.listing
|
||||
.values()
|
||||
.map(HalfSession::from_session_info)
|
||||
.chain(iter::once(HalfSession::from_session_view(&joined.session)));
|
||||
for sess in sessions {
|
||||
match sess.id.session_type() {
|
||||
Some(SessionType::Bot) if sess.name.is_empty() => nurkers.push(sess),
|
||||
Some(SessionType::Bot) => bots.push(sess),
|
||||
_ if sess.name.is_empty() => lurkers.push(sess),
|
||||
_ => people.push(sess),
|
||||
}
|
||||
}
|
||||
|
||||
people.sort_unstable();
|
||||
bots.sort_unstable();
|
||||
lurkers.sort_unstable();
|
||||
nurkers.sort_unstable();
|
||||
|
||||
render_section(list_builder, "People", &people, &joined.session, focused);
|
||||
render_section(list_builder, "Bots", &bots, &joined.session, focused);
|
||||
render_section(list_builder, "Lurkers", &lurkers, &joined.session, focused);
|
||||
render_section(list_builder, "Nurkers", &nurkers, &joined.session, focused);
|
||||
}
|
||||
|
||||
fn render_section(
|
||||
list_builder: &mut ListBuilder<'_, SessionId, Background<Text>>,
|
||||
name: &str,
|
||||
sessions: &[HalfSession],
|
||||
own_session: &SessionView,
|
||||
focused: bool,
|
||||
) {
|
||||
if sessions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let heading_style = Style::new().bold();
|
||||
|
||||
if !list_builder.is_empty() {
|
||||
list_builder.add_unsel(Text::new("").background());
|
||||
}
|
||||
|
||||
let row = Styled::new_plain(" ")
|
||||
.then(name, heading_style)
|
||||
.then_plain(format!(" ({})", sessions.len()));
|
||||
list_builder.add_unsel(Text::new(row).background());
|
||||
|
||||
for session in sessions {
|
||||
render_row(list_builder, session, own_session, focused);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_row(
|
||||
list_builder: &mut ListBuilder<'_, SessionId, Background<Text>>,
|
||||
session: &HalfSession,
|
||||
own_session: &SessionView,
|
||||
focused: bool,
|
||||
) {
|
||||
let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() {
|
||||
let name = "lurk".to_string();
|
||||
let style = Style::new().grey();
|
||||
let style_inv = Style::new().black().on_grey();
|
||||
(name, style, style_inv, style_inv)
|
||||
} else {
|
||||
let name = &session.name as &str;
|
||||
let (r, g, b) = euph::nick_color(name);
|
||||
let name = euph::EMOJI.replace(name).to_string();
|
||||
let color = Color::Rgb { r, g, b };
|
||||
let style = Style::new().bold().with(color);
|
||||
let style_inv = Style::new().bold().black().on(color);
|
||||
let perms_style_inv = Style::new().black().on(color);
|
||||
(name, style, style_inv, perms_style_inv)
|
||||
};
|
||||
|
||||
let perms = if session.is_staff {
|
||||
"!"
|
||||
} else if session.is_manager {
|
||||
"*"
|
||||
} else if session.id.session_type() == Some(SessionType::Account) {
|
||||
"~"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let owner = if session.session_id == own_session.session_id {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
|
||||
list_builder.add_sel(session.session_id.clone(), move |selected| {
|
||||
if focused && selected {
|
||||
let text = Styled::new_plain(owner)
|
||||
.then(name, style_inv)
|
||||
.then(perms, perms_style_inv);
|
||||
Text::new(text).background().with_style(style_inv)
|
||||
} else {
|
||||
let text = Styled::new_plain(owner)
|
||||
.then(&name, style)
|
||||
.then_plain(perms);
|
||||
Text::new(text).background()
|
||||
}
|
||||
});
|
||||
}
|
||||
32
cove/src/ui/euph/popup.rs
Normal file
32
cove/src/ui/euph/popup.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use crossterm::style::Stylize;
|
||||
use toss::widgets::Text;
|
||||
use toss::{Style, Styled, Widget};
|
||||
|
||||
use crate::ui::widgets::Popup;
|
||||
use crate::ui::UiError;
|
||||
|
||||
pub enum RoomPopup {
|
||||
Error { description: String, reason: String },
|
||||
}
|
||||
|
||||
impl RoomPopup {
|
||||
fn server_error_widget(description: &str, reason: &str) -> impl Widget<UiError> {
|
||||
let border_style = Style::new().red().bold();
|
||||
let text = Styled::new_plain(description)
|
||||
.then_plain("\n\n")
|
||||
.then("Reason:", Style::new().bold())
|
||||
.then_plain(" ")
|
||||
.then_plain(reason);
|
||||
|
||||
Popup::new(Text::new(text), ("Error", border_style)).with_border_style(border_style)
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> impl Widget<UiError> {
|
||||
match self {
|
||||
Self::Error {
|
||||
description,
|
||||
reason,
|
||||
} => Self::server_error_widget(description, reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
787
cove/src/ui/euph/room.rs
Normal file
787
cove/src/ui/euph/room.rs
Normal file
|
|
@ -0,0 +1,787 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId};
|
||||
use euphoxide::bot::instance::{Event, ServerConfig};
|
||||
use euphoxide::conn::{self, Joined, Joining, SessionInfo};
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use toss::widgets::{BoxedAsync, EditorState, Join2, Layer, Text};
|
||||
use toss::{Style, Styled, Terminal, Widget, WidgetExt};
|
||||
|
||||
use crate::config;
|
||||
use crate::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::ui::chat::{ChatState, Reaction};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::ListState;
|
||||
use crate::ui::{util, UiError, UiEvent};
|
||||
use crate::vault::EuphRoomVault;
|
||||
|
||||
use super::account::{self, AccountUiState};
|
||||
use super::links::{self, LinksState};
|
||||
use super::popup::RoomPopup;
|
||||
use super::{auth, inspect, nick, nick_list};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Chat,
|
||||
NickList,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum State {
|
||||
Normal,
|
||||
Auth(EditorState),
|
||||
Nick(EditorState),
|
||||
Account(AccountUiState),
|
||||
Links(LinksState),
|
||||
InspectMessage(Message),
|
||||
InspectSession(SessionInfo),
|
||||
}
|
||||
|
||||
type EuphChatState = ChatState<euph::SmallMessage, EuphRoomVault>;
|
||||
|
||||
pub struct EuphRoom {
|
||||
server_config: ServerConfig,
|
||||
config: config::EuphRoom,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
||||
room: Option<euph::Room>,
|
||||
|
||||
focus: Focus,
|
||||
state: State,
|
||||
popups: VecDeque<RoomPopup>,
|
||||
|
||||
chat: EuphChatState,
|
||||
last_msg_sent: Option<oneshot::Receiver<MessageId>>,
|
||||
|
||||
nick_list: ListState<SessionId>,
|
||||
}
|
||||
|
||||
impl EuphRoom {
|
||||
pub fn new(
|
||||
server_config: ServerConfig,
|
||||
config: config::EuphRoom,
|
||||
vault: EuphRoomVault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
server_config,
|
||||
config,
|
||||
ui_event_tx,
|
||||
room: None,
|
||||
focus: Focus::Chat,
|
||||
state: State::Normal,
|
||||
popups: VecDeque::new(),
|
||||
chat: ChatState::new(vault),
|
||||
last_msg_sent: None,
|
||||
nick_list: ListState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn vault(&self) -> &EuphRoomVault {
|
||||
self.chat.store()
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
self.vault().room()
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, next_instance_id: &mut usize) {
|
||||
if self.room.is_none() {
|
||||
let room = self.vault().room();
|
||||
let instance_config = self
|
||||
.server_config
|
||||
.clone()
|
||||
.room(self.vault().room().to_string())
|
||||
.name(format!("{room}-{}", next_instance_id))
|
||||
.human(true)
|
||||
.username(self.config.username.clone())
|
||||
.force_username(self.config.force_username)
|
||||
.password(self.config.password.clone());
|
||||
*next_instance_id = next_instance_id.wrapping_add(1);
|
||||
|
||||
let tx = self.ui_event_tx.clone();
|
||||
self.room = Some(euph::Room::new(
|
||||
self.vault().clone(),
|
||||
instance_config,
|
||||
move |e| {
|
||||
let _ = tx.send(UiEvent::Euph(e));
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self) {
|
||||
self.room = None;
|
||||
}
|
||||
|
||||
pub fn room_state(&self) -> Option<&euph::State> {
|
||||
if let Some(room) = &self.room {
|
||||
Some(room.state())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// TODO fn room_state_joined(&self) -> Option<&Joined> {}
|
||||
|
||||
pub fn stopped(&self) -> bool {
|
||||
self.room.as_ref().map(|r| r.stopped()).unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn retain(&mut self) {
|
||||
if let Some(room) = &self.room {
|
||||
if room.stopped() {
|
||||
self.room = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unseen_msgs_count(&self) -> usize {
|
||||
logging_unwrap!(self.vault().unseen_msgs_count().await)
|
||||
}
|
||||
|
||||
async fn stabilize_pseudo_msg(&mut self) {
|
||||
if let Some(id_rx) = &mut self.last_msg_sent {
|
||||
match id_rx.try_recv() {
|
||||
Ok(id) => {
|
||||
self.chat.send_successful(id);
|
||||
self.last_msg_sent = None;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {} // Wait a bit longer
|
||||
Err(TryRecvError::Closed) => {
|
||||
self.chat.send_failed();
|
||||
self.last_msg_sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stabilize_focus(&mut self) {
|
||||
match self.room_state() {
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_))) => {}
|
||||
_ => self.focus = Focus::Chat, // There is no nick list to focus on
|
||||
}
|
||||
}
|
||||
|
||||
fn stabilize_state(&mut self) {
|
||||
let room_state = self.room.as_ref().map(|r| r.state());
|
||||
match (&mut self.state, room_state) {
|
||||
(
|
||||
State::Auth(_),
|
||||
Some(euph::State::Connected(
|
||||
_,
|
||||
conn::State::Joining(Joining {
|
||||
bounce: Some(_), ..
|
||||
}),
|
||||
)),
|
||||
) => {} // Nothing to see here
|
||||
(State::Auth(_), _) => self.state = State::Normal,
|
||||
|
||||
(State::Nick(_), Some(euph::State::Connected(_, conn::State::Joined(_)))) => {}
|
||||
(State::Nick(_), _) => self.state = State::Normal,
|
||||
|
||||
(State::Account(account), state) => {
|
||||
if !account.stabilize(state) {
|
||||
self.state = State::Normal
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stabilize(&mut self) {
|
||||
self.stabilize_pseudo_msg().await;
|
||||
self.stabilize_focus();
|
||||
self.stabilize_state();
|
||||
}
|
||||
|
||||
pub async fn widget(&mut self) -> BoxedAsync<'_, UiError> {
|
||||
self.stabilize().await;
|
||||
|
||||
let room_state = self.room.as_ref().map(|room| room.state());
|
||||
let status_widget = self.status_widget(room_state).await;
|
||||
let chat = if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = room_state
|
||||
{
|
||||
Self::widget_with_nick_list(
|
||||
&mut self.chat,
|
||||
status_widget,
|
||||
&mut self.nick_list,
|
||||
joined,
|
||||
self.focus,
|
||||
)
|
||||
} else {
|
||||
Self::widget_without_nick_list(&mut self.chat, status_widget)
|
||||
};
|
||||
|
||||
let mut layers = vec![chat];
|
||||
|
||||
match &mut self.state {
|
||||
State::Normal => {}
|
||||
State::Auth(editor) => layers.push(auth::widget(editor).desync().boxed_async()),
|
||||
State::Nick(editor) => layers.push(nick::widget(editor).desync().boxed_async()),
|
||||
State::Account(account) => layers.push(account.widget().desync().boxed_async()),
|
||||
State::Links(links) => layers.push(links.widget().desync().boxed_async()),
|
||||
State::InspectMessage(message) => {
|
||||
layers.push(inspect::message_widget(message).desync().boxed_async())
|
||||
}
|
||||
State::InspectSession(session) => {
|
||||
layers.push(inspect::session_widget(session).desync().boxed_async())
|
||||
}
|
||||
}
|
||||
|
||||
for popup in &self.popups {
|
||||
layers.push(popup.widget().desync().boxed_async());
|
||||
}
|
||||
|
||||
Layer::new(layers).boxed_async()
|
||||
}
|
||||
|
||||
fn widget_without_nick_list(
|
||||
chat: &mut EuphChatState,
|
||||
status_widget: impl Widget<UiError> + Send + Sync + 'static,
|
||||
) -> BoxedAsync<'_, UiError> {
|
||||
let chat_widget = chat.widget(String::new(), true);
|
||||
|
||||
Join2::vertical(
|
||||
status_widget.desync().segment().with_fixed(true),
|
||||
chat_widget.segment(),
|
||||
)
|
||||
.boxed_async()
|
||||
}
|
||||
|
||||
fn widget_with_nick_list<'a>(
|
||||
chat: &'a mut EuphChatState,
|
||||
status_widget: impl Widget<UiError> + Send + Sync + 'static,
|
||||
nick_list: &'a mut ListState<SessionId>,
|
||||
joined: &Joined,
|
||||
focus: Focus,
|
||||
) -> BoxedAsync<'a, UiError> {
|
||||
let nick_list_widget = nick_list::widget(nick_list, joined, focus == Focus::NickList)
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.border()
|
||||
.desync();
|
||||
|
||||
let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat);
|
||||
|
||||
Join2::horizontal(
|
||||
Join2::vertical(
|
||||
status_widget.desync().segment().with_fixed(true),
|
||||
chat_widget.segment(),
|
||||
)
|
||||
.segment(),
|
||||
nick_list_widget.segment().with_fixed(true),
|
||||
)
|
||||
.boxed_async()
|
||||
}
|
||||
|
||||
async fn status_widget(&self, state: Option<&euph::State>) -> impl Widget<UiError> {
|
||||
let room_style = Style::new().bold().blue();
|
||||
let mut info = Styled::new(format!("&{}", self.name()), room_style);
|
||||
|
||||
info = match state {
|
||||
None | Some(euph::State::Stopped) => info.then_plain(", archive"),
|
||||
Some(euph::State::Disconnected) => info.then_plain(", waiting..."),
|
||||
Some(euph::State::Connecting) => info.then_plain(", connecting..."),
|
||||
Some(euph::State::Connected(_, conn::State::Joining(j))) if j.bounce.is_some() => {
|
||||
info.then_plain(", auth required")
|
||||
}
|
||||
Some(euph::State::Connected(_, conn::State::Joining(_))) => {
|
||||
info.then_plain(", joining...")
|
||||
}
|
||||
Some(euph::State::Connected(_, conn::State::Joined(j))) => {
|
||||
let nick = &j.session.name;
|
||||
if nick.is_empty() {
|
||||
info.then_plain(", present without nick")
|
||||
} else {
|
||||
info.then_plain(", present as ")
|
||||
.and_then(euph::style_nick(nick, Style::new()))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let unseen = self.unseen_msgs_count().await;
|
||||
if unseen > 0 {
|
||||
info = info
|
||||
.then_plain(" (")
|
||||
.then(format!("{unseen}"), Style::new().bold().green())
|
||||
.then_plain(")");
|
||||
}
|
||||
|
||||
Text::new(info).padding().with_horizontal(1).border()
|
||||
}
|
||||
|
||||
async fn list_chat_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
let can_compose = matches!(
|
||||
self.room_state(),
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_)))
|
||||
);
|
||||
self.chat.list_key_bindings(bindings, can_compose).await;
|
||||
}
|
||||
|
||||
async fn handle_chat_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
let can_compose = matches!(
|
||||
self.room_state(),
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_)))
|
||||
);
|
||||
|
||||
let reaction = self
|
||||
.chat
|
||||
.handle_input_event(terminal, crossterm_lock, event, can_compose)
|
||||
.await;
|
||||
let reaction = logging_unwrap!(reaction);
|
||||
|
||||
match reaction {
|
||||
Reaction::NotHandled => {}
|
||||
Reaction::Handled => return true,
|
||||
Reaction::Composed { parent, content } => {
|
||||
if let Some(room) = &self.room {
|
||||
match room.send(parent, content) {
|
||||
Ok(id_rx) => self.last_msg_sent = Some(id_rx),
|
||||
Err(_) => self.chat.send_failed(),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reaction::ComposeError(e) => {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: "Failed to use external editor".to_string(),
|
||||
reason: format!("{e}"),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn list_room_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
match self.room_state() {
|
||||
// Authenticating
|
||||
Some(euph::State::Connected(
|
||||
_,
|
||||
conn::State::Joining(Joining {
|
||||
bounce: Some(_), ..
|
||||
}),
|
||||
)) => {
|
||||
bindings.binding("a", "authenticate");
|
||||
}
|
||||
|
||||
// Connected
|
||||
Some(euph::State::Connected(_, conn::State::Joined(_))) => {
|
||||
bindings.binding("n", "change nick");
|
||||
bindings.binding("m", "download more messages");
|
||||
bindings.binding("A", "show account ui");
|
||||
}
|
||||
|
||||
// Otherwise
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Inspecting messages
|
||||
bindings.binding("i", "inspect message");
|
||||
bindings.binding("I", "show message links");
|
||||
bindings.binding("ctrl+p", "open room's plugh.de/present page");
|
||||
}
|
||||
|
||||
async fn handle_room_input_event(&mut self, event: &InputEvent) -> bool {
|
||||
match self.room_state() {
|
||||
// Authenticating
|
||||
Some(euph::State::Connected(
|
||||
_,
|
||||
conn::State::Joining(Joining {
|
||||
bounce: Some(_), ..
|
||||
}),
|
||||
)) => {
|
||||
if let key!('a') = event {
|
||||
self.state = State::Auth(auth::new());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Joined
|
||||
Some(euph::State::Connected(_, conn::State::Joined(joined))) => match event {
|
||||
key!('n') | key!('N') => {
|
||||
self.state = State::Nick(nick::new(joined.clone()));
|
||||
return true;
|
||||
}
|
||||
key!('m') => {
|
||||
if let Some(room) = &self.room {
|
||||
let _ = room.log();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('A') => {
|
||||
self.state = State::Account(AccountUiState::new());
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
// Otherwise
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Always applicable
|
||||
match event {
|
||||
key!('i') => {
|
||||
if let Some(id) = self.chat.cursor() {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) {
|
||||
self.state = State::InspectMessage(msg);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('I') => {
|
||||
if let Some(id) = self.chat.cursor() {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().msg(*id).await) {
|
||||
self.state = State::Links(LinksState::new(&msg.content));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!(Ctrl + 'p') => {
|
||||
let link = format!("https://plugh.de/present/{}/", self.name());
|
||||
if let Err(error) = open::that(&link) {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: format!("Failed to open link: {link}"),
|
||||
reason: format!("{error}"),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
async fn list_chat_focus_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
self.list_room_key_bindings(bindings);
|
||||
bindings.empty();
|
||||
self.list_chat_key_bindings(bindings).await;
|
||||
}
|
||||
|
||||
async fn handle_chat_focus_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
// We need to handle chat input first, otherwise the other
|
||||
// key bindings will shadow characters in the editor.
|
||||
if self
|
||||
.handle_chat_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.handle_room_input_event(event).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn list_nick_list_focus_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
util::list_list_key_bindings(bindings);
|
||||
|
||||
bindings.binding("i", "inspect session");
|
||||
}
|
||||
|
||||
fn handle_nick_list_focus_input_event(&mut self, event: &InputEvent) -> bool {
|
||||
if util::handle_list_input_event(&mut self.nick_list, event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let key!('i') = event {
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state()
|
||||
{
|
||||
if let Some(id) = self.nick_list.selected() {
|
||||
if *id == joined.session.session_id {
|
||||
self.state =
|
||||
State::InspectSession(SessionInfo::Full(joined.session.clone()));
|
||||
} else if let Some(session) = joined.listing.get(id) {
|
||||
self.state = State::InspectSession(session.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
// Handled in rooms list, not here
|
||||
bindings.binding("esc", "leave room");
|
||||
|
||||
match self.focus {
|
||||
Focus::Chat => {
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() {
|
||||
bindings.binding("tab", "focus on nick list");
|
||||
}
|
||||
|
||||
self.list_chat_focus_key_bindings(bindings).await;
|
||||
}
|
||||
Focus::NickList => {
|
||||
bindings.binding("tab, esc", "focus on chat");
|
||||
bindings.empty();
|
||||
bindings.heading("Nick list");
|
||||
self.list_nick_list_focus_key_bindings(bindings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_normal_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
match self.focus {
|
||||
Focus::Chat => {
|
||||
// Needs to be handled first or the tab key may be shadowed
|
||||
// during editing.
|
||||
if self
|
||||
.handle_chat_focus_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() {
|
||||
if let key!(Tab) = event {
|
||||
self.focus = Focus::NickList;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Focus::NickList => {
|
||||
if let key!(Tab) | key!(Esc) = event {
|
||||
self.focus = Focus::Chat;
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.handle_nick_list_focus_input_event(event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
bindings.heading("Room");
|
||||
|
||||
if !self.popups.is_empty() {
|
||||
bindings.binding("esc", "close popup");
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.state {
|
||||
State::Normal => self.list_normal_key_bindings(bindings).await,
|
||||
State::Auth(_) => auth::list_key_bindings(bindings),
|
||||
State::Nick(_) => nick::list_key_bindings(bindings),
|
||||
State::Account(account) => account.list_key_bindings(bindings),
|
||||
State::Links(links) => links.list_key_bindings(bindings),
|
||||
State::InspectMessage(_) | State::InspectSession(_) => {
|
||||
inspect::list_key_bindings(bindings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
if !self.popups.is_empty() {
|
||||
if matches!(event, key!(Esc)) {
|
||||
self.popups.pop_back();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO Use a common EventResult
|
||||
|
||||
match &mut self.state {
|
||||
State::Normal => {
|
||||
self.handle_normal_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
}
|
||||
State::Auth(editor) => {
|
||||
match auth::handle_input_event(terminal, event, &self.room, editor) {
|
||||
auth::EventResult::NotHandled => false,
|
||||
auth::EventResult::Handled => true,
|
||||
auth::EventResult::ResetState => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Nick(editor) => {
|
||||
match nick::handle_input_event(terminal, event, &self.room, editor) {
|
||||
nick::EventResult::NotHandled => false,
|
||||
nick::EventResult::Handled => true,
|
||||
nick::EventResult::ResetState => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Account(account) => {
|
||||
match account.handle_input_event(terminal, event, &self.room) {
|
||||
account::EventResult::NotHandled => false,
|
||||
account::EventResult::Handled => true,
|
||||
account::EventResult::ResetState => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Links(links) => match links.handle_input_event(event) {
|
||||
links::EventResult::NotHandled => false,
|
||||
links::EventResult::Handled => true,
|
||||
links::EventResult::Close => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
links::EventResult::ErrorOpeningLink { link, error } => {
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description: format!("Failed to open link: {link}"),
|
||||
reason: format!("{error}"),
|
||||
});
|
||||
true
|
||||
}
|
||||
},
|
||||
State::InspectMessage(_) | State::InspectSession(_) => {
|
||||
match inspect::handle_input_event(event) {
|
||||
inspect::EventResult::NotHandled => false,
|
||||
inspect::EventResult::Close => {
|
||||
self.state = State::Normal;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_event(&mut self, event: Event) -> bool {
|
||||
let room = match &self.room {
|
||||
None => return false,
|
||||
Some(room) => room,
|
||||
};
|
||||
|
||||
if event.config().name != room.instance().config().name {
|
||||
// If we allowed names other than the current one, old instances
|
||||
// that haven't yet shut down properly could mess up our state.
|
||||
return false;
|
||||
}
|
||||
|
||||
// We handle the packet internally first because the room event handling
|
||||
// will consume it while we only need a reference.
|
||||
let handled = if let Event::Packet(_, packet, _) = &event {
|
||||
match &packet.content {
|
||||
Ok(data) => self.handle_euph_data(data),
|
||||
Err(reason) => self.handle_euph_error(packet.r#type, reason),
|
||||
}
|
||||
} else {
|
||||
// The room state changes, which always means a redraw.
|
||||
true
|
||||
};
|
||||
|
||||
self.room
|
||||
.as_mut()
|
||||
// See check at the beginning of the function.
|
||||
.expect("no room even though we checked earlier")
|
||||
.handle_event(event)
|
||||
.await;
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
fn handle_euph_data(&mut self, data: &Data) -> bool {
|
||||
// These packets don't result in any noticeable change in the UI.
|
||||
#[allow(clippy::match_like_matches_macro)]
|
||||
let handled = match data {
|
||||
Data::PingEvent(_) | Data::PingReply(_) => {
|
||||
// Pings are displayed nowhere in the room UI.
|
||||
false
|
||||
}
|
||||
Data::DisconnectEvent(_) => {
|
||||
// Followed by the server closing the connection, meaning that
|
||||
// we'll get an `EuphRoomEvent::Disconnected` soon after this.
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
|
||||
// Because the euphoria API is very carefully designed with emphasis on
|
||||
// consistency, some failures are not normal errors but instead
|
||||
// error-free replies that encode their own error.
|
||||
let error = match data {
|
||||
Data::AuthReply(reply) if !reply.success => {
|
||||
Some(("authenticate", reply.reason.clone()))
|
||||
}
|
||||
Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason.clone())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((action, reason)) = error {
|
||||
let description = format!("Failed to {action}.");
|
||||
let reason = reason.unwrap_or_else(|| "no idea, the server wouldn't say".to_string());
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
handled
|
||||
}
|
||||
|
||||
fn handle_euph_error(&mut self, r#type: PacketType, reason: &str) -> bool {
|
||||
let action = match r#type {
|
||||
PacketType::AuthReply => "authenticate",
|
||||
PacketType::NickReply => "set nick",
|
||||
PacketType::PmInitiateReply => "initiate pm",
|
||||
PacketType::SendReply => "send message",
|
||||
PacketType::ChangeEmailReply => "change account email",
|
||||
PacketType::ChangeNameReply => "change account name",
|
||||
PacketType::ChangePasswordReply => "change account password",
|
||||
PacketType::LoginReply => "log in",
|
||||
PacketType::LogoutReply => "log out",
|
||||
PacketType::RegisterAccountReply => "register account",
|
||||
PacketType::ResendVerificationEmailReply => "resend verification email",
|
||||
PacketType::ResetPasswordReply => "reset account password",
|
||||
PacketType::BanReply => "ban",
|
||||
PacketType::EditMessageReply => "edit message",
|
||||
PacketType::GrantAccessReply => "grant room access",
|
||||
PacketType::GrantManagerReply => "grant manager permissions",
|
||||
PacketType::RevokeAccessReply => "revoke room access",
|
||||
PacketType::RevokeManagerReply => "revoke manager permissions",
|
||||
PacketType::UnbanReply => "unban",
|
||||
_ => return false,
|
||||
};
|
||||
let description = format!("Failed to {action}.");
|
||||
self.popups.push_front(RoomPopup::Error {
|
||||
description,
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
175
cove/src/ui/input.rs
Normal file
175
cove/src/ui/input.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||
use crossterm::style::Stylize;
|
||||
use toss::widgets::{Empty, Join2, Text};
|
||||
use toss::{Style, Styled, Widget, WidgetExt};
|
||||
|
||||
use super::widgets::{ListBuilder, ListState};
|
||||
use super::UiError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
Key(KeyEvent),
|
||||
Paste(String),
|
||||
}
|
||||
|
||||
impl InputEvent {
|
||||
pub fn from_event(event: Event) -> Option<Self> {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key) => Some(Self::Key(key.into())),
|
||||
crossterm::event::Event::Paste(text) => Some(Self::Paste(text)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key event data type that is a bit easier to pattern match on than
|
||||
/// [`crossterm::event::KeyEvent`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct KeyEvent {
|
||||
pub code: KeyCode,
|
||||
pub shift: bool,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
}
|
||||
|
||||
impl From<crossterm::event::KeyEvent> for KeyEvent {
|
||||
fn from(event: crossterm::event::KeyEvent) -> Self {
|
||||
Self {
|
||||
code: event.code,
|
||||
shift: event.modifiers.contains(KeyModifiers::SHIFT),
|
||||
ctrl: event.modifiers.contains(KeyModifiers::CONTROL),
|
||||
alt: event.modifiers.contains(KeyModifiers::ALT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
#[allow(unused_macro_rules)]
|
||||
macro_rules! key {
|
||||
// key!(Paste text)
|
||||
( Paste $text:ident ) => { crate::ui::input::InputEvent::Paste($text) };
|
||||
|
||||
// key!('a')
|
||||
( $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) };
|
||||
( Ctrl + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) };
|
||||
( Alt + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) };
|
||||
|
||||
// key!(Char c)
|
||||
( Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) };
|
||||
( Ctrl + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) };
|
||||
( Alt + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) };
|
||||
|
||||
// key!(F n)
|
||||
( F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: false, }) };
|
||||
( Shift + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: true, ctrl: false, alt: false, }) };
|
||||
( Ctrl + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: true, alt: false, }) };
|
||||
( Alt + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: true, }) };
|
||||
|
||||
// key!(other)
|
||||
( $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: false, }) };
|
||||
( Shift + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: true, ctrl: false, alt: false, }) };
|
||||
( Ctrl + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: true, alt: false, }) };
|
||||
( Alt + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: true, }) };
|
||||
}
|
||||
pub(crate) use key;
|
||||
|
||||
enum Row {
|
||||
Empty,
|
||||
Heading(String),
|
||||
Binding(String, String),
|
||||
BindingContd(String),
|
||||
}
|
||||
|
||||
pub struct KeyBindingsList(Vec<Row>);
|
||||
|
||||
impl KeyBindingsList {
|
||||
/// Width of the left column of key bindings.
|
||||
const BINDING_WIDTH: u16 = 24;
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self(vec![])
|
||||
}
|
||||
|
||||
fn binding_style() -> Style {
|
||||
Style::new().cyan()
|
||||
}
|
||||
|
||||
fn row_widget(row: Row) -> impl Widget<UiError> {
|
||||
match row {
|
||||
Row::Empty => Text::new("").first3(),
|
||||
|
||||
Row::Heading(name) => Text::new((name, Style::new().bold())).first3(),
|
||||
|
||||
Row::Binding(binding, description) => Join2::horizontal(
|
||||
Text::new((binding, Self::binding_style()))
|
||||
.padding()
|
||||
.with_right(1)
|
||||
.resize()
|
||||
.with_min_width(Self::BINDING_WIDTH)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Text::new(description).segment(),
|
||||
)
|
||||
.second3(),
|
||||
|
||||
Row::BindingContd(description) => Join2::horizontal(
|
||||
Empty::new()
|
||||
.with_width(Self::BINDING_WIDTH)
|
||||
.segment()
|
||||
.with_fixed(true),
|
||||
Text::new(description).segment(),
|
||||
)
|
||||
.third3(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(self, list_state: &mut ListState<Infallible>) -> impl Widget<UiError> + '_ {
|
||||
let binding_style = Self::binding_style();
|
||||
|
||||
let hint_text = Styled::new("jk/↓↑", binding_style)
|
||||
.then_plain(" to scroll, ")
|
||||
.then("esc", binding_style)
|
||||
.then_plain(" to close");
|
||||
|
||||
let hint = Text::new(hint_text)
|
||||
.padding()
|
||||
.with_horizontal(1)
|
||||
.float()
|
||||
.with_horizontal(0.5)
|
||||
.with_vertical(0.0);
|
||||
|
||||
let mut list_builder = ListBuilder::new();
|
||||
for row in self.0 {
|
||||
list_builder.add_unsel(Self::row_widget(row));
|
||||
}
|
||||
|
||||
list_builder
|
||||
.build(list_state)
|
||||
.padding()
|
||||
.with_horizontal(1)
|
||||
.border()
|
||||
.below(hint)
|
||||
.background()
|
||||
.float()
|
||||
.with_center()
|
||||
}
|
||||
|
||||
pub fn empty(&mut self) {
|
||||
self.0.push(Row::Empty);
|
||||
}
|
||||
|
||||
pub fn heading(&mut self, name: &str) {
|
||||
self.0.push(Row::Heading(name.to_string()));
|
||||
}
|
||||
|
||||
pub fn binding(&mut self, binding: &str, description: &str) {
|
||||
self.0
|
||||
.push(Row::Binding(binding.to_string(), description.to_string()));
|
||||
}
|
||||
|
||||
pub fn binding_ctd(&mut self, description: &str) {
|
||||
self.0.push(Row::BindingContd(description.to_string()));
|
||||
}
|
||||
}
|
||||
621
cove/src/ui/rooms.rs
Normal file
621
cove/src/ui/rooms.rs
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossterm::style::Stylize;
|
||||
use euphoxide::api::SessionType;
|
||||
use euphoxide::bot::instance::{Event, ServerConfig};
|
||||
use euphoxide::conn::{self, Joined};
|
||||
use parking_lot::FairMutex;
|
||||
use tokio::sync::mpsc;
|
||||
use toss::widgets::{BoxedAsync, EditorState, Empty, Join2, Text};
|
||||
use toss::{Style, Styled, Terminal, Widget, WidgetExt};
|
||||
|
||||
use crate::config::{Config, RoomsSortOrder};
|
||||
use crate::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::vault::Vault;
|
||||
|
||||
use super::euph::room::EuphRoom;
|
||||
use super::input::{key, InputEvent, KeyBindingsList};
|
||||
use super::widgets::{ListBuilder, ListState, Popup};
|
||||
use super::{util, UiError, UiEvent};
|
||||
|
||||
enum State {
|
||||
ShowList,
|
||||
ShowRoom(String),
|
||||
Connect(EditorState),
|
||||
Delete(String, EditorState),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Order {
|
||||
Alphabet,
|
||||
Importance,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
fn from_rooms_sort_order(order: RoomsSortOrder) -> Self {
|
||||
match order {
|
||||
RoomsSortOrder::Alphabet => Self::Alphabet,
|
||||
RoomsSortOrder::Importance => Self::Importance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Rooms {
|
||||
config: &'static Config,
|
||||
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
|
||||
state: State,
|
||||
|
||||
list: ListState<String>,
|
||||
order: Order,
|
||||
|
||||
euph_server_config: ServerConfig,
|
||||
euph_next_instance_id: usize,
|
||||
euph_rooms: HashMap<String, EuphRoom>,
|
||||
}
|
||||
|
||||
impl Rooms {
|
||||
pub async fn new(
|
||||
config: &'static Config,
|
||||
vault: Vault,
|
||||
ui_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||
) -> Self {
|
||||
let cookies = logging_unwrap!(vault.euph().cookies().await);
|
||||
let euph_server_config = ServerConfig::default().cookies(Arc::new(Mutex::new(cookies)));
|
||||
|
||||
let mut result = Self {
|
||||
config,
|
||||
vault,
|
||||
ui_event_tx,
|
||||
state: State::ShowList,
|
||||
list: ListState::new(),
|
||||
order: Order::from_rooms_sort_order(config.rooms_sort_order),
|
||||
euph_server_config,
|
||||
euph_next_instance_id: 0,
|
||||
euph_rooms: HashMap::new(),
|
||||
};
|
||||
|
||||
if !config.offline {
|
||||
for (name, config) in &config.euph.rooms {
|
||||
if config.autojoin {
|
||||
result.connect_to_room(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom {
|
||||
self.euph_rooms.entry(name.clone()).or_insert_with(|| {
|
||||
EuphRoom::new(
|
||||
self.euph_server_config.clone(),
|
||||
self.config.euph_room(&name),
|
||||
self.vault.euph().room(name),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn connect_to_room(&mut self, name: String) {
|
||||
let room = self.euph_rooms.entry(name.clone()).or_insert_with(|| {
|
||||
EuphRoom::new(
|
||||
self.euph_server_config.clone(),
|
||||
self.config.euph_room(&name),
|
||||
self.vault.euph().room(name),
|
||||
self.ui_event_tx.clone(),
|
||||
)
|
||||
});
|
||||
room.connect(&mut self.euph_next_instance_id);
|
||||
}
|
||||
|
||||
fn connect_to_all_rooms(&mut self) {
|
||||
for room in self.euph_rooms.values_mut() {
|
||||
room.connect(&mut self.euph_next_instance_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_from_room(&mut self, name: &str) {
|
||||
if let Some(room) = self.euph_rooms.get_mut(name) {
|
||||
room.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_from_all_rooms(&mut self) {
|
||||
for room in self.euph_rooms.values_mut() {
|
||||
room.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove rooms that are not running any more and can't be found in the db
|
||||
/// or config. Insert rooms that are in the db or config but not yet in in
|
||||
/// the hash map.
|
||||
///
|
||||
/// These kinds of rooms are either
|
||||
/// - failed connection attempts, or
|
||||
/// - rooms that were deleted from the db.
|
||||
async fn stabilize_rooms(&mut self) {
|
||||
// Collect all rooms from the db and config file
|
||||
let rooms = logging_unwrap!(self.vault.euph().rooms().await);
|
||||
let mut rooms_set = rooms
|
||||
.into_iter()
|
||||
.chain(self.config.euph.rooms.keys().cloned())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// Prevent room that is currently being shown from being removed. This
|
||||
// could otherwise happen after connecting to a room that doesn't exist.
|
||||
if let State::ShowRoom(name) = &self.state {
|
||||
rooms_set.insert(name.clone());
|
||||
}
|
||||
|
||||
// Now `rooms_set` contains all rooms that must exist. Other rooms may
|
||||
// also exist, for example rooms that are connecting for the first time.
|
||||
|
||||
self.euph_rooms
|
||||
.retain(|n, r| !r.stopped() || rooms_set.contains(n));
|
||||
|
||||
for room in rooms_set {
|
||||
self.get_or_insert_room(room).retain();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn widget(&mut self) -> BoxedAsync<'_, UiError> {
|
||||
match &self.state {
|
||||
State::ShowRoom(_) => {}
|
||||
_ => self.stabilize_rooms().await,
|
||||
}
|
||||
|
||||
match &mut self.state {
|
||||
State::ShowList => Self::rooms_widget(&mut self.list, &self.euph_rooms, self.order)
|
||||
.await
|
||||
.desync()
|
||||
.boxed_async(),
|
||||
|
||||
State::ShowRoom(name) => {
|
||||
self.euph_rooms
|
||||
.get_mut(name)
|
||||
.expect("room exists after stabilization")
|
||||
.widget()
|
||||
.await
|
||||
}
|
||||
|
||||
State::Connect(editor) => {
|
||||
Self::rooms_widget(&mut self.list, &self.euph_rooms, self.order)
|
||||
.await
|
||||
.below(Self::new_room_widget(editor))
|
||||
.desync()
|
||||
.boxed_async()
|
||||
}
|
||||
|
||||
State::Delete(name, editor) => {
|
||||
Self::rooms_widget(&mut self.list, &self.euph_rooms, self.order)
|
||||
.await
|
||||
.below(Self::delete_room_widget(name, editor))
|
||||
.desync()
|
||||
.boxed_async()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_room_widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
|
||||
let room_style = Style::new().bold().blue();
|
||||
|
||||
let inner = Join2::horizontal(
|
||||
Text::new(("&", room_style)).segment().with_fixed(true),
|
||||
editor
|
||||
.widget()
|
||||
.with_highlight(|s| Styled::new(s, room_style))
|
||||
.segment(),
|
||||
);
|
||||
|
||||
Popup::new(inner, "Connect to")
|
||||
}
|
||||
|
||||
fn delete_room_widget<'a>(
|
||||
name: &str,
|
||||
editor: &'a mut EditorState,
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
let warn_style = Style::new().bold().red();
|
||||
let room_style = Style::new().bold().blue();
|
||||
let text = Styled::new_plain("Are you sure you want to delete ")
|
||||
.then("&", room_style)
|
||||
.then(name, room_style)
|
||||
.then_plain("?\n\n")
|
||||
.then_plain("This will delete the entire room history from your vault. ")
|
||||
.then_plain("To shrink your vault afterwards, run ")
|
||||
.then("cove gc", Style::new().italic().grey())
|
||||
.then_plain(".\n\n")
|
||||
.then_plain("To confirm the deletion, ")
|
||||
.then_plain("enter the full name of the room and press enter:");
|
||||
|
||||
let inner = Join2::vertical(
|
||||
// The Join prevents the text from filling up the entire available
|
||||
// space if the editor is wider than the text.
|
||||
Join2::horizontal(
|
||||
Text::new(text)
|
||||
.resize()
|
||||
.with_max_width(54)
|
||||
.segment()
|
||||
.with_growing(false),
|
||||
Empty::new().segment(),
|
||||
)
|
||||
.segment(),
|
||||
Join2::horizontal(
|
||||
Text::new(("&", room_style)).segment().with_fixed(true),
|
||||
editor
|
||||
.widget()
|
||||
.with_highlight(|s| Styled::new(s, room_style))
|
||||
.segment(),
|
||||
)
|
||||
.segment(),
|
||||
);
|
||||
|
||||
Popup::new(inner, "Delete room").with_border_style(warn_style)
|
||||
}
|
||||
|
||||
fn format_pbln(joined: &Joined) -> String {
|
||||
let mut p = 0_usize;
|
||||
let mut b = 0_usize;
|
||||
let mut l = 0_usize;
|
||||
let mut n = 0_usize;
|
||||
|
||||
let sessions = joined
|
||||
.listing
|
||||
.values()
|
||||
.map(|s| (s.id(), s.name()))
|
||||
.chain(iter::once((
|
||||
&joined.session.id,
|
||||
&joined.session.name as &str,
|
||||
)));
|
||||
for (user_id, name) in sessions {
|
||||
match user_id.session_type() {
|
||||
Some(SessionType::Bot) if name.is_empty() => n += 1,
|
||||
Some(SessionType::Bot) => b += 1,
|
||||
_ if name.is_empty() => l += 1,
|
||||
_ => p += 1,
|
||||
}
|
||||
}
|
||||
|
||||
// There must always be either one p, b, l or n since we're including
|
||||
// ourselves.
|
||||
let mut result = vec![];
|
||||
if p > 0 {
|
||||
result.push(format!("{p}p"));
|
||||
}
|
||||
if b > 0 {
|
||||
result.push(format!("{b}b"));
|
||||
}
|
||||
if l > 0 {
|
||||
result.push(format!("{l}l"));
|
||||
}
|
||||
if n > 0 {
|
||||
result.push(format!("{n}n"));
|
||||
}
|
||||
result.join(" ")
|
||||
}
|
||||
|
||||
fn format_room_state(state: Option<&euph::State>) -> Option<String> {
|
||||
match state {
|
||||
None | Some(euph::State::Stopped) => None,
|
||||
Some(euph::State::Disconnected) => Some("waiting".to_string()),
|
||||
Some(euph::State::Connecting) => Some("connecting".to_string()),
|
||||
Some(euph::State::Connected(_, connected)) => match connected {
|
||||
conn::State::Joining(joining) if joining.bounce.is_some() => {
|
||||
Some("auth required".to_string())
|
||||
}
|
||||
conn::State::Joining(_) => Some("joining".to_string()),
|
||||
conn::State::Joined(joined) => Some(Self::format_pbln(joined)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn format_unseen_msgs(unseen: usize) -> Option<String> {
|
||||
if unseen == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{unseen}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_room_info(state: Option<&euph::State>, unseen: usize) -> Styled {
|
||||
let unseen_style = Style::new().bold().green();
|
||||
|
||||
let state = Self::format_room_state(state);
|
||||
let unseen = Self::format_unseen_msgs(unseen);
|
||||
|
||||
match (state, unseen) {
|
||||
(None, None) => Styled::default(),
|
||||
(None, Some(u)) => Styled::new_plain(" (")
|
||||
.then(u, unseen_style)
|
||||
.then_plain(")"),
|
||||
(Some(s), None) => Styled::new_plain(" (").then_plain(s).then_plain(")"),
|
||||
(Some(s), Some(u)) => Styled::new_plain(" (")
|
||||
.then_plain(s)
|
||||
.then_plain(", ")
|
||||
.then(u, unseen_style)
|
||||
.then_plain(")"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_rooms(rooms: &mut [(&String, Option<&euph::State>, usize)], order: Order) {
|
||||
match order {
|
||||
Order::Alphabet => rooms.sort_unstable_by_key(|(name, _, _)| *name),
|
||||
Order::Importance => rooms.sort_unstable_by_key(|(name, state, unseen)| {
|
||||
(state.is_none(), *unseen == 0, *name)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_rows(
|
||||
list_builder: &mut ListBuilder<'_, String, Text>,
|
||||
euph_rooms: &HashMap<String, EuphRoom>,
|
||||
order: Order,
|
||||
) {
|
||||
if euph_rooms.is_empty() {
|
||||
list_builder.add_unsel(Text::new((
|
||||
"Press F1 for key bindings",
|
||||
Style::new().grey().italic(),
|
||||
)))
|
||||
}
|
||||
|
||||
let mut rooms = vec![];
|
||||
for (name, room) in euph_rooms {
|
||||
let state = room.room_state();
|
||||
let unseen = room.unseen_msgs_count().await;
|
||||
rooms.push((name, state, unseen));
|
||||
}
|
||||
Self::sort_rooms(&mut rooms, order);
|
||||
for (name, state, unseen) in rooms {
|
||||
let name = name.clone();
|
||||
let info = Self::format_room_info(state, unseen);
|
||||
list_builder.add_sel(name.clone(), move |selected| {
|
||||
let style = if selected {
|
||||
Style::new().bold().black().on_white()
|
||||
} else {
|
||||
Style::new().bold().blue()
|
||||
};
|
||||
|
||||
let text = Styled::new(format!("&{name}"), style).and_then(info);
|
||||
|
||||
Text::new(text)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn rooms_widget<'a>(
|
||||
list: &'a mut ListState<String>,
|
||||
euph_rooms: &HashMap<String, EuphRoom>,
|
||||
order: Order,
|
||||
) -> impl Widget<UiError> + 'a {
|
||||
let heading_style = Style::new().bold();
|
||||
let heading_text =
|
||||
Styled::new("Rooms", heading_style).then_plain(format!(" ({})", euph_rooms.len()));
|
||||
|
||||
let mut list_builder = ListBuilder::new();
|
||||
Self::render_rows(&mut list_builder, euph_rooms, order).await;
|
||||
|
||||
Join2::vertical(
|
||||
Text::new(heading_text).segment().with_fixed(true),
|
||||
list_builder.build(list).segment(),
|
||||
)
|
||||
}
|
||||
|
||||
fn room_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn list_showlist_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.heading("Rooms");
|
||||
util::list_list_key_bindings(bindings);
|
||||
bindings.empty();
|
||||
bindings.binding("enter", "enter selected room");
|
||||
bindings.binding("c", "connect to selected room");
|
||||
bindings.binding("C", "connect to all rooms");
|
||||
bindings.binding("d", "disconnect from selected room");
|
||||
bindings.binding("D", "disconnect from all rooms");
|
||||
bindings.binding("a", "connect to all autojoin room");
|
||||
bindings.binding("A", "disconnect from all non-autojoin rooms");
|
||||
bindings.binding("n", "connect to new room");
|
||||
bindings.binding("X", "delete room");
|
||||
bindings.empty();
|
||||
bindings.binding("s", "change sort order");
|
||||
}
|
||||
|
||||
fn handle_showlist_input_event(&mut self, event: &InputEvent) -> bool {
|
||||
if util::handle_list_input_event(&mut self.list, event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
match event {
|
||||
key!(Enter) => {
|
||||
if let Some(name) = self.list.selected() {
|
||||
self.state = State::ShowRoom(name.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('c') => {
|
||||
if let Some(name) = self.list.selected() {
|
||||
self.connect_to_room(name.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('C') => {
|
||||
self.connect_to_all_rooms();
|
||||
return true;
|
||||
}
|
||||
key!('d') => {
|
||||
if let Some(name) = self.list.selected() {
|
||||
self.disconnect_from_room(&name.clone());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('D') => {
|
||||
self.disconnect_from_all_rooms();
|
||||
return true;
|
||||
}
|
||||
key!('a') => {
|
||||
for (name, options) in &self.config.euph.rooms {
|
||||
if options.autojoin {
|
||||
self.connect_to_room(name.clone());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('A') => {
|
||||
for (name, room) in &mut self.euph_rooms {
|
||||
let autojoin = self
|
||||
.config
|
||||
.euph
|
||||
.rooms
|
||||
.get(name)
|
||||
.map(|r| r.autojoin)
|
||||
.unwrap_or(false);
|
||||
if !autojoin {
|
||||
room.disconnect();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('n') => {
|
||||
self.state = State::Connect(EditorState::new());
|
||||
return true;
|
||||
}
|
||||
key!('X') => {
|
||||
if let Some(name) = self.list.selected() {
|
||||
self.state = State::Delete(name.clone(), EditorState::new());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('s') => {
|
||||
self.order = match self.order {
|
||||
Order::Alphabet => Order::Importance,
|
||||
Order::Importance => Order::Alphabet,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) {
|
||||
match &self.state {
|
||||
State::ShowList => Self::list_showlist_key_bindings(bindings),
|
||||
State::ShowRoom(name) => {
|
||||
// Key bindings for leaving the room are a part of the room's
|
||||
// list_key_bindings function since they may be shadowed by the
|
||||
// nick selector or message editor.
|
||||
if let Some(room) = self.euph_rooms.get(name) {
|
||||
room.list_key_bindings(bindings).await;
|
||||
} else {
|
||||
// There should always be a room here already but I don't
|
||||
// really want to panic in case it is not. If I show a
|
||||
// message like this, it'll hopefully be reported if
|
||||
// somebody ever encounters it.
|
||||
bindings.binding_ctd("oops, this text should never be visible")
|
||||
}
|
||||
}
|
||||
State::Connect(_) => {
|
||||
bindings.heading("Rooms");
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "connect to room");
|
||||
util::list_editor_key_bindings(bindings, Self::room_char);
|
||||
}
|
||||
State::Delete(_, _) => {
|
||||
bindings.heading("Rooms");
|
||||
bindings.binding("esc", "abort");
|
||||
bindings.binding("enter", "delete room");
|
||||
util::list_editor_key_bindings(bindings, Self::room_char);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_input_event(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
) -> bool {
|
||||
self.stabilize_rooms().await;
|
||||
|
||||
match &mut self.state {
|
||||
State::ShowList => {
|
||||
if self.handle_showlist_input_event(event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
State::ShowRoom(name) => {
|
||||
if let Some(room) = self.euph_rooms.get_mut(name) {
|
||||
if room
|
||||
.handle_input_event(terminal, crossterm_lock, event)
|
||||
.await
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if let key!(Esc) = event {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
State::Connect(ed) => match event {
|
||||
key!(Esc) => {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
key!(Enter) => {
|
||||
let name = ed.text().to_string();
|
||||
if !name.is_empty() {
|
||||
self.connect_to_room(name.clone());
|
||||
self.state = State::ShowRoom(name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(ed, terminal, event, Self::room_char) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
State::Delete(name, editor) => match event {
|
||||
key!(Esc) => {
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
key!(Enter) if editor.text() == *name => {
|
||||
self.euph_rooms.remove(name);
|
||||
logging_unwrap!(self.vault.euph().room(name.clone()).delete().await);
|
||||
self.state = State::ShowList;
|
||||
return true;
|
||||
}
|
||||
_ => {
|
||||
if util::handle_editor_input_event(editor, terminal, event, Self::room_char) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn handle_euph_event(&mut self, event: Event) -> bool {
|
||||
let room_name = event.config().room.clone();
|
||||
let Some(room) = self.euph_rooms.get_mut(&room_name) else { return false; };
|
||||
|
||||
let handled = room.handle_event(event).await;
|
||||
|
||||
let room_visible = match &self.state {
|
||||
State::ShowRoom(name) => *name == room_name,
|
||||
_ => true,
|
||||
};
|
||||
handled && room_visible
|
||||
}
|
||||
}
|
||||
191
cove/src/ui/util.rs
Normal file
191
cove/src/ui/util.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use toss::widgets::EditorState;
|
||||
use toss::Terminal;
|
||||
|
||||
use super::input::{key, InputEvent, KeyBindingsList};
|
||||
use super::widgets::ListState;
|
||||
|
||||
pub fn prompt(
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
initial_text: &str,
|
||||
) -> io::Result<String> {
|
||||
let content = {
|
||||
let _guard = crossterm_lock.lock();
|
||||
terminal.suspend().expect("could not suspend");
|
||||
let content = edit::edit(initial_text);
|
||||
terminal.unsuspend().expect("could not unsuspend");
|
||||
content
|
||||
};
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
//////////
|
||||
// List //
|
||||
//////////
|
||||
|
||||
pub fn list_list_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("j/k, ↓/↑", "move cursor up/down");
|
||||
bindings.binding("g, home", "move cursor to top");
|
||||
bindings.binding("G, end", "move cursor to bottom");
|
||||
bindings.binding("ctrl+y/e", "scroll up/down");
|
||||
}
|
||||
|
||||
pub fn handle_list_input_event<Id: Clone>(list: &mut ListState<Id>, event: &InputEvent) -> bool {
|
||||
match event {
|
||||
key!('k') | key!(Up) => list.move_cursor_up(),
|
||||
key!('j') | key!(Down) => list.move_cursor_down(),
|
||||
key!('g') | key!(Home) => list.move_cursor_to_top(),
|
||||
key!('G') | key!(End) => list.move_cursor_to_bottom(),
|
||||
key!(Ctrl + 'y') => list.scroll_up(1),
|
||||
key!(Ctrl + 'e') => list.scroll_down(1),
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
////////////
|
||||
// Editor //
|
||||
////////////
|
||||
|
||||
fn list_editor_editing_key_bindings(
|
||||
bindings: &mut KeyBindingsList,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) {
|
||||
if char_filter('\n') {
|
||||
bindings.binding("enter+<any modifier>", "insert newline");
|
||||
}
|
||||
|
||||
bindings.binding("ctrl+h, backspace", "delete before cursor");
|
||||
bindings.binding("ctrl+d, delete", "delete after cursor");
|
||||
bindings.binding("ctrl+l", "clear editor contents");
|
||||
}
|
||||
|
||||
fn list_editor_cursor_movement_key_bindings(bindings: &mut KeyBindingsList) {
|
||||
bindings.binding("ctrl+b, ←", "move cursor left");
|
||||
bindings.binding("ctrl+f, →", "move cursor right");
|
||||
bindings.binding("alt+b, ctrl+←", "move cursor left a word");
|
||||
bindings.binding("alt+f, ctrl+→", "move cursor right a word");
|
||||
bindings.binding("ctrl+a, home", "move cursor to start of line");
|
||||
bindings.binding("ctrl+e, end", "move cursor to end of line");
|
||||
bindings.binding("↑/↓", "move cursor up/down");
|
||||
}
|
||||
|
||||
pub fn list_editor_key_bindings(
|
||||
bindings: &mut KeyBindingsList,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) {
|
||||
list_editor_editing_key_bindings(bindings, char_filter);
|
||||
bindings.empty();
|
||||
list_editor_cursor_movement_key_bindings(bindings);
|
||||
}
|
||||
|
||||
pub fn handle_editor_input_event(
|
||||
editor: &mut EditorState,
|
||||
terminal: &mut Terminal,
|
||||
event: &InputEvent,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) -> bool {
|
||||
match event {
|
||||
// Enter with *any* modifier pressed - if ctrl and shift don't
|
||||
// work, maybe alt does
|
||||
key!(Enter) => return false,
|
||||
InputEvent::Key(crate::ui::input::KeyEvent {
|
||||
code: crossterm::event::KeyCode::Enter,
|
||||
..
|
||||
}) if char_filter('\n') => editor.insert_char(terminal.widthdb(), '\n'),
|
||||
|
||||
// Editing
|
||||
key!(Char ch) if char_filter(*ch) => editor.insert_char(terminal.widthdb(), *ch),
|
||||
key!(Paste str) => {
|
||||
// It seems that when pasting, '\n' are converted into '\r' for some
|
||||
// reason. I don't really know why, or at what point this happens.
|
||||
// Vim converts any '\r' pasted via the terminal into '\n', so I
|
||||
// decided to mirror that behaviour.
|
||||
let str = str.replace('\r', "\n");
|
||||
if str.chars().all(char_filter) {
|
||||
editor.insert_str(terminal.widthdb(), &str);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.widthdb()),
|
||||
key!(Ctrl + 'd') | key!(Delete) => editor.delete(),
|
||||
key!(Ctrl + 'l') => editor.clear(),
|
||||
// TODO Key bindings to delete words
|
||||
|
||||
// Cursor movement
|
||||
key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.widthdb()),
|
||||
key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.widthdb()),
|
||||
key!(Alt + 'b') | key!(Ctrl + Left) => editor.move_cursor_left_a_word(terminal.widthdb()),
|
||||
key!(Alt + 'f') | key!(Ctrl + Right) => editor.move_cursor_right_a_word(terminal.widthdb()),
|
||||
key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.widthdb()),
|
||||
key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.widthdb()),
|
||||
key!(Up) => editor.move_cursor_up(terminal.widthdb()),
|
||||
key!(Down) => editor.move_cursor_down(terminal.widthdb()),
|
||||
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn edit_externally(
|
||||
editor: &mut EditorState,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
) -> io::Result<()> {
|
||||
let text = prompt(terminal, crossterm_lock, editor.text())?;
|
||||
|
||||
if text.trim().is_empty() {
|
||||
// The user likely wanted to abort the edit and has deleted the
|
||||
// entire text (bar whitespace left over by some editors).
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(text) = text.strip_suffix('\n') {
|
||||
// Some editors like vim add a trailing newline that would look out of
|
||||
// place in cove's editors. To intentionally add a trailing newline,
|
||||
// simply add two in-editor.
|
||||
editor.set_text(terminal.widthdb(), text.to_string());
|
||||
} else {
|
||||
editor.set_text(terminal.widthdb(), text);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_editor_key_bindings_allowing_external_editing(
|
||||
bindings: &mut KeyBindingsList,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) {
|
||||
list_editor_editing_key_bindings(bindings, char_filter);
|
||||
bindings.binding("ctrl+x", "edit in external editor");
|
||||
bindings.empty();
|
||||
list_editor_cursor_movement_key_bindings(bindings);
|
||||
}
|
||||
|
||||
pub fn handle_editor_input_event_allowing_external_editing(
|
||||
editor: &mut EditorState,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: &InputEvent,
|
||||
char_filter: impl Fn(char) -> bool,
|
||||
) -> io::Result<bool> {
|
||||
if let key!(Ctrl + 'x') = event {
|
||||
edit_externally(editor, terminal, crossterm_lock)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(handle_editor_input_event(
|
||||
editor,
|
||||
terminal,
|
||||
event,
|
||||
char_filter,
|
||||
))
|
||||
}
|
||||
}
|
||||
5
cove/src/ui/widgets.rs
Normal file
5
cove/src/ui/widgets.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod list;
|
||||
mod popup;
|
||||
|
||||
pub use self::list::*;
|
||||
pub use self::popup::*;
|
||||
339
cove/src/ui/widgets/list.rs
Normal file
339
cove/src/ui/widgets/list.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
use std::vec;
|
||||
|
||||
use toss::{Frame, Pos, Size, Widget, WidthDb};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cursor<Id> {
|
||||
/// Id of the element the cursor is pointing to.
|
||||
///
|
||||
/// If the rows change (e.g. reorder) but there is still a row with this id,
|
||||
/// the cursor is moved to this row.
|
||||
id: Id,
|
||||
|
||||
/// Index of the row the cursor is pointing to.
|
||||
///
|
||||
/// If the rows change and there is no longer a row with the cursor's id,
|
||||
/// the cursor is moved up or down to the next selectable row. This way, it
|
||||
/// stays close to its previous position.
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
impl<Id> Cursor<Id> {
|
||||
pub fn new(id: Id, idx: usize) -> Self {
|
||||
Self { id, idx }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ListState<Id> {
|
||||
/// Amount of lines that the list is scrolled, i.e. offset from the top.
|
||||
offset: usize,
|
||||
|
||||
/// A cursor within the list.
|
||||
///
|
||||
/// Set to `None` if the list contains no selectable rows.
|
||||
cursor: Option<Cursor<Id>>,
|
||||
|
||||
/// Height of the list when it was last rendered.
|
||||
last_height: u16,
|
||||
|
||||
/// Rows when the list was last rendered.
|
||||
last_rows: Vec<Option<Id>>,
|
||||
}
|
||||
|
||||
impl<Id> ListState<Id> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
offset: 0,
|
||||
cursor: None,
|
||||
last_height: 0,
|
||||
last_rows: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<&Id> {
|
||||
self.cursor.as_ref().map(|cursor| &cursor.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Id: Clone> ListState<Id> {
|
||||
fn first_selectable(&self) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i)))
|
||||
}
|
||||
|
||||
fn last_selectable(&self) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i)))
|
||||
}
|
||||
|
||||
fn selectable_at_or_before_index(&self, i: usize) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(i + 1)
|
||||
.rev()
|
||||
.find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i)))
|
||||
}
|
||||
|
||||
fn selectable_at_or_after_index(&self, i: usize) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(i)
|
||||
.find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i)))
|
||||
}
|
||||
|
||||
fn selectable_before_index(&self, i: usize) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(i)
|
||||
.rev()
|
||||
.find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i)))
|
||||
}
|
||||
|
||||
fn selectable_after_index(&self, i: usize) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(i + 1)
|
||||
.find_map(|(i, row)| row.as_ref().map(|id| Cursor::new(id.clone(), i)))
|
||||
}
|
||||
|
||||
fn move_cursor_to_make_it_visible(&mut self) {
|
||||
if let Some(cursor) = &self.cursor {
|
||||
let first_visible_line_idx = self.offset;
|
||||
let last_visible_line_idx = self
|
||||
.offset
|
||||
.saturating_add(self.last_height.into())
|
||||
.saturating_sub(1);
|
||||
|
||||
let new_cursor = if cursor.idx < first_visible_line_idx {
|
||||
self.selectable_at_or_after_index(first_visible_line_idx)
|
||||
} else if cursor.idx > last_visible_line_idx {
|
||||
self.selectable_at_or_before_index(last_visible_line_idx)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(new_cursor) = new_cursor {
|
||||
self.cursor = Some(new_cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_so_cursor_is_visible(&mut self) {
|
||||
if self.last_height == 0 {
|
||||
// Cursor can't be visible because nothing is visible
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(cursor) = &self.cursor {
|
||||
// As long as height > 0, min <= max is true
|
||||
let min = (cursor.idx + 1).saturating_sub(self.last_height.into());
|
||||
let max = cursor.idx; // Rows have a height of 1
|
||||
self.offset = self.offset.clamp(min, max);
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_scrolling(&mut self) {
|
||||
let min = 0;
|
||||
let max = self.last_rows.len().saturating_sub(self.last_height.into());
|
||||
self.offset = self.offset.clamp(min, max);
|
||||
}
|
||||
|
||||
fn scroll_to(&mut self, new_offset: usize) {
|
||||
self.offset = new_offset;
|
||||
self.clamp_scrolling();
|
||||
self.move_cursor_to_make_it_visible();
|
||||
}
|
||||
|
||||
fn move_cursor_to(&mut self, new_cursor: Cursor<Id>) {
|
||||
self.cursor = Some(new_cursor);
|
||||
self.scroll_so_cursor_is_visible();
|
||||
self.clamp_scrolling();
|
||||
}
|
||||
|
||||
/// Scroll the list up by an amount of lines.
|
||||
pub fn scroll_up(&mut self, lines: usize) {
|
||||
self.scroll_to(self.offset.saturating_sub(lines));
|
||||
}
|
||||
|
||||
/// Scroll the list down by an amount of lines.
|
||||
pub fn scroll_down(&mut self, lines: usize) {
|
||||
self.scroll_to(self.offset.saturating_add(lines));
|
||||
}
|
||||
|
||||
/// Scroll so that the cursor is in the center of the widget, or at least as
|
||||
/// close as possible.
|
||||
pub fn center_cursor(&mut self) {
|
||||
if let Some(cursor) = &self.cursor {
|
||||
let height: usize = self.last_height.into();
|
||||
self.scroll_to(cursor.idx.saturating_sub(height / 2));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor up to the next selectable row.
|
||||
pub fn move_cursor_up(&mut self) {
|
||||
if let Some(cursor) = &self.cursor {
|
||||
if let Some(new_cursor) = self.selectable_before_index(cursor.idx) {
|
||||
self.move_cursor_to(new_cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor down to the next selectable row.
|
||||
pub fn move_cursor_down(&mut self) {
|
||||
if let Some(cursor) = &self.cursor {
|
||||
if let Some(new_cursor) = self.selectable_after_index(cursor.idx) {
|
||||
self.move_cursor_to(new_cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor to the first selectable row.
|
||||
pub fn move_cursor_to_top(&mut self) {
|
||||
if let Some(new_cursor) = self.first_selectable() {
|
||||
self.move_cursor_to(new_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor to the last selectable row.
|
||||
pub fn move_cursor_to_bottom(&mut self) {
|
||||
if let Some(new_cursor) = self.last_selectable() {
|
||||
self.move_cursor_to(new_cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Id: Clone + Eq> ListState<Id> {
|
||||
fn selectable_of_id(&self, id: &Id) -> Option<Cursor<Id>> {
|
||||
self.last_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, row)| match row {
|
||||
Some(rid) if rid == id => Some(Cursor::new(rid.clone(), i)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn fix_cursor(&mut self) {
|
||||
let new_cursor = if let Some(cursor) = &self.cursor {
|
||||
self.selectable_of_id(&cursor.id)
|
||||
.or_else(|| self.selectable_at_or_before_index(cursor.idx))
|
||||
.or_else(|| self.selectable_at_or_after_index(cursor.idx))
|
||||
} else {
|
||||
self.first_selectable()
|
||||
};
|
||||
|
||||
if let Some(new_cursor) = new_cursor {
|
||||
self.move_cursor_to(new_cursor);
|
||||
} else {
|
||||
self.cursor = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnrenderedRow<'a, Id, W> {
|
||||
id: Option<Id>,
|
||||
widget: Box<dyn FnOnce(bool) -> W + 'a>,
|
||||
}
|
||||
|
||||
pub struct ListBuilder<'a, Id, W> {
|
||||
rows: Vec<UnrenderedRow<'a, Id, W>>,
|
||||
}
|
||||
|
||||
impl<'a, Id, W> ListBuilder<'a, Id, W> {
|
||||
pub fn new() -> Self {
|
||||
Self { rows: vec![] }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rows.is_empty()
|
||||
}
|
||||
|
||||
pub fn add_unsel(&mut self, widget: W)
|
||||
where
|
||||
W: 'a,
|
||||
{
|
||||
self.rows.push(UnrenderedRow {
|
||||
id: None,
|
||||
widget: Box::new(|_| widget),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_sel(&mut self, id: Id, widget: impl FnOnce(bool) -> W + 'a) {
|
||||
self.rows.push(UnrenderedRow {
|
||||
id: Some(id),
|
||||
widget: Box::new(widget),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn build(self, state: &mut ListState<Id>) -> List<'_, Id, W>
|
||||
where
|
||||
Id: Clone + Eq,
|
||||
{
|
||||
state.last_rows = self.rows.iter().map(|row| row.id.clone()).collect();
|
||||
state.fix_cursor();
|
||||
|
||||
let selected = state.selected();
|
||||
let rows = self
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| (row.widget)(row.id.as_ref() == selected))
|
||||
.collect();
|
||||
List { state, rows }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct List<'a, Id, W> {
|
||||
state: &'a mut ListState<Id>,
|
||||
rows: Vec<W>,
|
||||
}
|
||||
|
||||
impl<Id, E, W> Widget<E> for List<'_, Id, W>
|
||||
where
|
||||
Id: Clone + Eq,
|
||||
W: Widget<E>,
|
||||
{
|
||||
fn size(
|
||||
&self,
|
||||
widthdb: &mut WidthDb,
|
||||
max_width: Option<u16>,
|
||||
_max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
let mut width = 0;
|
||||
for row in &self.rows {
|
||||
let size = row.size(widthdb, max_width, Some(1))?;
|
||||
width = width.max(size.width);
|
||||
}
|
||||
let height = self.rows.len().try_into().unwrap_or(u16::MAX);
|
||||
Ok(Size::new(width, height))
|
||||
}
|
||||
|
||||
fn draw(self, frame: &mut Frame) -> Result<(), E> {
|
||||
let size = frame.size();
|
||||
|
||||
self.state.last_height = size.height;
|
||||
|
||||
for (y, row) in self
|
||||
.rows
|
||||
.into_iter()
|
||||
.skip(self.state.offset)
|
||||
.take(size.height.into())
|
||||
.enumerate()
|
||||
{
|
||||
frame.push(Pos::new(0, y as i32), Size::new(size.width, 1));
|
||||
row.draw(frame)?;
|
||||
frame.pop();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
52
cove/src/ui/widgets/popup.rs
Normal file
52
cove/src/ui/widgets/popup.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use toss::widgets::{Background, Border, Desync, Float, Layer2, Padding, Text};
|
||||
use toss::{Frame, Size, Style, Styled, Widget, WidgetExt, WidthDb};
|
||||
|
||||
type Body<I> = Background<Border<Padding<I>>>;
|
||||
type Title = Float<Padding<Background<Padding<Text>>>>;
|
||||
|
||||
pub struct Popup<I>(Float<Layer2<Body<I>, Desync<Title>>>);
|
||||
|
||||
impl<I> Popup<I> {
|
||||
pub fn new<S: Into<Styled>>(inner: I, title: S) -> Self {
|
||||
let title = Text::new(title)
|
||||
.padding()
|
||||
.with_horizontal(1)
|
||||
// The background displaces the border without affecting the style
|
||||
.background()
|
||||
.with_style(Style::new())
|
||||
.padding()
|
||||
.with_horizontal(2)
|
||||
.float()
|
||||
.with_top()
|
||||
.with_left()
|
||||
.desync();
|
||||
|
||||
let body = inner.padding().with_horizontal(1).border().background();
|
||||
|
||||
Self(title.above(body).float().with_center())
|
||||
}
|
||||
|
||||
pub fn with_border_style(mut self, style: Style) -> Self {
|
||||
let border = &mut self.0.inner.first.inner;
|
||||
border.style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<E, I> Widget<E> for Popup<I>
|
||||
where
|
||||
I: Widget<E>,
|
||||
{
|
||||
fn size(
|
||||
&self,
|
||||
widthdb: &mut WidthDb,
|
||||
max_width: Option<u16>,
|
||||
max_height: Option<u16>,
|
||||
) -> Result<Size, E> {
|
||||
self.0.size(widthdb, max_width, max_height)
|
||||
}
|
||||
|
||||
fn draw(self, frame: &mut Frame) -> Result<(), E> {
|
||||
self.0.draw(frame)
|
||||
}
|
||||
}
|
||||
15
cove/src/util.rs
Normal file
15
cove/src/util.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use std::convert::Infallible;
|
||||
|
||||
pub trait InfallibleExt {
|
||||
type Inner;
|
||||
|
||||
fn infallible(self) -> Self::Inner;
|
||||
}
|
||||
|
||||
impl<T> InfallibleExt for Result<T, Infallible> {
|
||||
type Inner = T;
|
||||
|
||||
fn infallible(self) -> T {
|
||||
self.expect("infallible")
|
||||
}
|
||||
}
|
||||
83
cove/src/vault.rs
Normal file
83
cove/src/vault.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
mod euph;
|
||||
mod migrate;
|
||||
mod prepare;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use vault::tokio::TokioVault;
|
||||
use vault::Action;
|
||||
|
||||
pub use self::euph::{EuphRoomVault, EuphVault};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Vault {
|
||||
tokio_vault: TokioVault,
|
||||
ephemeral: bool,
|
||||
}
|
||||
|
||||
struct GcAction;
|
||||
|
||||
impl Action for GcAction {
|
||||
type Output = ();
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
fn run(self, conn: &mut Connection) -> Result<Self::Output, Self::Error> {
|
||||
conn.execute_batch("ANALYZE; VACUUM;")
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
pub fn ephemeral(&self) -> bool {
|
||||
self.ephemeral
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
self.tokio_vault.stop().await;
|
||||
}
|
||||
|
||||
pub async fn gc(&self) -> Result<(), vault::tokio::Error<rusqlite::Error>> {
|
||||
self.tokio_vault.execute(GcAction).await
|
||||
}
|
||||
|
||||
pub fn euph(&self) -> EuphVault {
|
||||
EuphVault::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_from_connection(conn: Connection, ephemeral: bool) -> rusqlite::Result<Vault> {
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
conn.pragma_update(None, "trusted_schema", false)?;
|
||||
|
||||
eprintln!("Opening vault");
|
||||
|
||||
let tokio_vault = TokioVault::launch_and_prepare(conn, &migrate::MIGRATIONS, prepare::prepare)?;
|
||||
Ok(Vault {
|
||||
tokio_vault,
|
||||
ephemeral,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn launch(path: &Path) -> rusqlite::Result<Vault> {
|
||||
// If this fails, rusqlite will complain about not being able to open the db
|
||||
// file, which saves me from adding a separate vault error type.
|
||||
let _ = fs::create_dir_all(path.parent().expect("path to file"));
|
||||
|
||||
let conn = Connection::open(path)?;
|
||||
|
||||
// Setting locking mode before journal mode so no shared memory files
|
||||
// (*-shm) need to be created by sqlite. Apparently, setting the journal
|
||||
// mode is also enough to immediately acquire the exclusive lock even if the
|
||||
// database was already using WAL.
|
||||
// https://sqlite.org/pragma.html#pragma_locking_mode
|
||||
conn.pragma_update(None, "locking_mode", "exclusive")?;
|
||||
conn.pragma_update(None, "journal_mode", "wal")?;
|
||||
|
||||
launch_from_connection(conn, false)
|
||||
}
|
||||
|
||||
pub fn launch_in_memory() -> rusqlite::Result<Vault> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
launch_from_connection(conn, true)
|
||||
}
|
||||
1127
cove/src/vault/euph.rs
Normal file
1127
cove/src/vault/euph.rs
Normal file
File diff suppressed because it is too large
Load diff
80
cove/src/vault/migrate.rs
Normal file
80
cove/src/vault/migrate.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use rusqlite::Transaction;
|
||||
use vault::Migration;
|
||||
|
||||
pub const MIGRATIONS: [Migration; 2] = [m1, m2];
|
||||
|
||||
fn m1(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
|
||||
tx.execute_batch(
|
||||
"
|
||||
CREATE TABLE euph_rooms (
|
||||
room TEXT NOT NULL PRIMARY KEY,
|
||||
first_joined INT NOT NULL,
|
||||
last_joined INT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_msgs (
|
||||
-- Message
|
||||
room TEXT NOT NULL,
|
||||
id INT NOT NULL,
|
||||
parent INT,
|
||||
previous_edit_id INT,
|
||||
time INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
encryption_key_id TEXT,
|
||||
edited INT,
|
||||
deleted INT,
|
||||
truncated INT NOT NULL,
|
||||
|
||||
-- SessionView
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
server_id TEXT NOT NULL,
|
||||
server_era TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
is_staff INT NOT NULL,
|
||||
is_manager INT NOT NULL,
|
||||
client_address TEXT,
|
||||
real_client_address TEXT,
|
||||
|
||||
PRIMARY KEY (room, id),
|
||||
FOREIGN KEY (room) REFERENCES euph_rooms (room)
|
||||
ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_spans (
|
||||
room TEXT NOT NULL,
|
||||
start INT,
|
||||
end INT,
|
||||
|
||||
UNIQUE (room, start, end),
|
||||
FOREIGN KEY (room) REFERENCES euph_rooms (room)
|
||||
ON DELETE CASCADE,
|
||||
CHECK (start IS NULL OR end IS NOT NULL)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE euph_cookies (
|
||||
cookie TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX euph_idx_msgs_room_id_parent
|
||||
ON euph_msgs (room, id, parent);
|
||||
|
||||
CREATE INDEX euph_idx_msgs_room_parent_id
|
||||
ON euph_msgs (room, parent, id);
|
||||
",
|
||||
)
|
||||
}
|
||||
|
||||
fn m2(tx: &mut Transaction<'_>, nr: usize, total: usize) -> rusqlite::Result<()> {
|
||||
eprintln!("Migrating vault from {} to {} (out of {total})", nr, nr + 1);
|
||||
tx.execute_batch(
|
||||
"
|
||||
ALTER TABLE euph_msgs
|
||||
ADD COLUMN seen INTEGER NOT NULL DEFAULT TRUE;
|
||||
|
||||
CREATE INDEX euph_idx_msgs_room_id_seen
|
||||
ON euph_msgs (room, id, seen);
|
||||
",
|
||||
)
|
||||
}
|
||||
122
cove/src/vault/prepare.rs
Normal file
122
cove/src/vault/prepare.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use rusqlite::Connection;
|
||||
|
||||
pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> {
|
||||
// Cache ids of tree roots.
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TEMPORARY TABLE euph_trees (
|
||||
room TEXT NOT NULL,
|
||||
id INT NOT NULL,
|
||||
|
||||
PRIMARY KEY (room, id)
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO euph_trees (room, id)
|
||||
SELECT room, id
|
||||
FROM euph_msgs
|
||||
WHERE parent IS NULL
|
||||
UNION
|
||||
SELECT room, parent
|
||||
FROM euph_msgs
|
||||
WHERE parent IS NOT NULL
|
||||
AND NOT EXISTS(
|
||||
SELECT *
|
||||
FROM euph_msgs AS parents
|
||||
WHERE parents.room = euph_msgs.room
|
||||
AND parents.id = euph_msgs.parent
|
||||
);
|
||||
|
||||
CREATE TEMPORARY TRIGGER et_delete_room
|
||||
AFTER DELETE ON main.euph_rooms
|
||||
BEGIN
|
||||
DELETE FROM euph_trees
|
||||
WHERE room = old.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER et_insert_msg_without_parent
|
||||
AFTER INSERT ON main.euph_msgs
|
||||
WHEN new.parent IS NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO euph_trees (room, id)
|
||||
VALUES (new.room, new.id);
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER et_insert_msg_with_parent
|
||||
AFTER INSERT ON main.euph_msgs
|
||||
WHEN new.parent IS NOT NULL
|
||||
BEGIN
|
||||
DELETE FROM euph_trees
|
||||
WHERE room = new.room
|
||||
AND id = new.id;
|
||||
|
||||
INSERT OR IGNORE INTO euph_trees (room, id)
|
||||
SELECT *
|
||||
FROM (VALUES (new.room, new.parent))
|
||||
WHERE NOT EXISTS(
|
||||
SELECT *
|
||||
FROM euph_msgs
|
||||
WHERE room = new.room
|
||||
AND id = new.parent
|
||||
AND parent IS NOT NULL
|
||||
);
|
||||
END;
|
||||
",
|
||||
)?;
|
||||
|
||||
// Cache amount of unseen messages per room.
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TEMPORARY TABLE euph_unseen_counts (
|
||||
room TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (room)
|
||||
) STRICT;
|
||||
|
||||
-- There must be an entry for every existing room.
|
||||
INSERT INTO euph_unseen_counts (room, amount)
|
||||
SELECT room, 0
|
||||
FROM euph_rooms;
|
||||
|
||||
INSERT OR REPLACE INTO euph_unseen_counts (room, amount)
|
||||
SELECT room, COUNT(*)
|
||||
FROM euph_msgs
|
||||
WHERE NOT seen
|
||||
GROUP BY room;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_insert_room
|
||||
AFTER INSERT ON main.euph_rooms
|
||||
BEGIN
|
||||
INSERT INTO euph_unseen_counts (room, amount)
|
||||
VALUES (new.room, 0);
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_delete_room
|
||||
AFTER DELETE ON main.euph_rooms
|
||||
BEGIN
|
||||
DELETE FROM euph_unseen_counts
|
||||
WHERE room = old.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_insert_msg
|
||||
AFTER INSERT ON main.euph_msgs
|
||||
WHEN NOT new.seen
|
||||
BEGIN
|
||||
UPDATE euph_unseen_counts
|
||||
SET amount = amount + 1
|
||||
WHERE room = new.room;
|
||||
END;
|
||||
|
||||
CREATE TEMPORARY TRIGGER euc_update_msg
|
||||
AFTER UPDATE OF seen ON main.euph_msgs
|
||||
WHEN old.seen != new.seen
|
||||
BEGIN
|
||||
UPDATE euph_unseen_counts
|
||||
SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END
|
||||
WHERE room = new.room;
|
||||
END;
|
||||
",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue