Set up workspace

This commit is contained in:
Joscha 2023-04-19 23:43:03 +02:00
parent babdd10fba
commit 288a5f97dd
45 changed files with 68 additions and 56 deletions

57
cove/Cargo.toml Normal file
View 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
View 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
View 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
View 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(())
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
})
}
}

View 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
View 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(())
}
}

View 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(())
}
}

View 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(())
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(())
}
}

View 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
View 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
View 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

File diff suppressed because it is too large Load diff

80
cove/src/vault/migrate.rs Normal file
View 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
View 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(())
}