Move chat to ui module
This commit is contained in:
parent
603876738f
commit
446e3e885a
11 changed files with 7 additions and 12 deletions
107
src/ui/chat.rs
Normal file
107
src/ui/chat.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
mod tree;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use parking_lot::FairMutex;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
|
||||
use self::tree::TreeView;
|
||||
|
||||
pub enum Mode {
|
||||
Tree,
|
||||
// Thread,
|
||||
// Flat,
|
||||
}
|
||||
|
||||
pub struct Cursor<I> {
|
||||
id: I,
|
||||
/// Where on the screen the cursor is visible (`0.0` = first line, `1.0` =
|
||||
/// last line).
|
||||
proportion: f32,
|
||||
}
|
||||
|
||||
impl<I> Cursor<I> {
|
||||
/// Create a new cursor with arbitrary proportion.
|
||||
pub fn new(id: I) -> Self {
|
||||
Self {
|
||||
id,
|
||||
proportion: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat<M: Msg, S: MsgStore<M>> {
|
||||
store: S,
|
||||
cursor: Option<Cursor<M::Id>>,
|
||||
mode: Mode,
|
||||
tree: TreeView<M>,
|
||||
// thread: ThreadView,
|
||||
// flat: FlatView,
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> Chat<M, S> {
|
||||
pub fn new(store: S) -> Self {
|
||||
Self {
|
||||
store,
|
||||
cursor: None,
|
||||
mode: Mode::Tree,
|
||||
tree: TreeView::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(&self) -> &S {
|
||||
&self.store
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg, S: MsgStore<M>> Chat<M, S> {
|
||||
pub async fn handle_navigation(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
size: Size,
|
||||
event: KeyEvent,
|
||||
) {
|
||||
match self.mode {
|
||||
Mode::Tree => {
|
||||
self.tree
|
||||
.handle_navigation(&mut self.store, &mut self.cursor, terminal, size, event)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_messaging(
|
||||
&mut self,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
event: KeyEvent,
|
||||
) -> Option<(Option<M::Id>, String)> {
|
||||
match self.mode {
|
||||
Mode::Tree => {
|
||||
self.tree
|
||||
.handle_messaging(
|
||||
&mut self.store,
|
||||
&mut self.cursor,
|
||||
terminal,
|
||||
crossterm_lock,
|
||||
event,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render(&mut self, frame: &mut Frame, pos: Pos, size: Size) {
|
||||
match self.mode {
|
||||
Mode::Tree => {
|
||||
self.tree
|
||||
.render(&mut self.store, &self.cursor, frame, pos, size)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/ui/chat/tree.rs
Normal file
84
src/ui/chat/tree.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
mod action;
|
||||
mod blocks;
|
||||
mod cursor;
|
||||
mod layout;
|
||||
mod render;
|
||||
mod util;
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use parking_lot::FairMutex;
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
|
||||
use super::Cursor;
|
||||
|
||||
pub struct TreeView<M: Msg> {
|
||||
// pub focus: Option<M::Id>,
|
||||
// pub folded: HashSet<M::Id>,
|
||||
// pub minimized: HashSet<M::Id>,
|
||||
phantom: PhantomData<M::Id>, // TODO Remove
|
||||
}
|
||||
|
||||
impl<M: Msg> TreeView<M> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_navigation<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
s: &mut S,
|
||||
c: &mut Option<Cursor<M::Id>>,
|
||||
t: &mut Terminal,
|
||||
z: Size,
|
||||
event: KeyEvent,
|
||||
) {
|
||||
match event.code {
|
||||
KeyCode::Char('k') => self.move_up(s, c, t.frame(), z).await,
|
||||
KeyCode::Char('j') => self.move_down(s, c, t.frame(), z).await,
|
||||
KeyCode::Char('K') => self.move_up_sibling(s, c, t.frame(), z).await,
|
||||
KeyCode::Char('J') => self.move_down_sibling(s, c, t.frame(), z).await,
|
||||
KeyCode::Char('z') | KeyCode::Char('Z') => self.center_cursor(s, c, t.frame(), z).await,
|
||||
KeyCode::Char('g') => self.move_to_first(s, c, t.frame(), z).await,
|
||||
KeyCode::Char('G') => self.move_to_last(s, c, t.frame(), z).await,
|
||||
KeyCode::Esc => *c = None, // TODO Make 'G' do the same thing?
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_messaging<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
s: &mut S,
|
||||
c: &mut Option<Cursor<M::Id>>,
|
||||
t: &mut Terminal,
|
||||
l: &Arc<FairMutex<()>>,
|
||||
event: KeyEvent,
|
||||
) -> Option<(Option<M::Id>, String)> {
|
||||
match event.code {
|
||||
KeyCode::Char('r') => Self::reply_normal(s, c, t, l).await,
|
||||
KeyCode::Char('R') => Self::reply_alternate(s, c, t, l).await,
|
||||
KeyCode::Char('t') | KeyCode::Char('T') => Self::create_new_thread(t, l).await,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &mut S,
|
||||
cursor: &Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
pos: Pos,
|
||||
size: Size,
|
||||
) {
|
||||
let blocks = self
|
||||
.layout_blocks(store, cursor.as_ref(), frame, size)
|
||||
.await;
|
||||
Self::render_blocks(frame, pos, size, blocks);
|
||||
}
|
||||
}
|
||||
96
src/ui/chat/tree/action.rs
Normal file
96
src/ui/chat/tree/action.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
|
||||
use super::{Cursor, TreeView};
|
||||
|
||||
impl<M: Msg> TreeView<M> {
|
||||
fn prompt_msg(crossterm_lock: &Arc<FairMutex<()>>, terminal: &mut Terminal) -> Option<String> {
|
||||
let content = {
|
||||
let _guard = crossterm_lock.lock();
|
||||
terminal.suspend().expect("could not suspend");
|
||||
let content = edit::edit("").expect("could not edit");
|
||||
terminal.unsuspend().expect("could not unsuspend");
|
||||
content
|
||||
};
|
||||
|
||||
if content.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(content)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reply_normal<S: MsgStore<M>>(
|
||||
store: &S,
|
||||
cursor: &Option<Cursor<M::Id>>,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
) -> Option<(Option<M::Id>, String)> {
|
||||
if let Some(cursor) = cursor {
|
||||
let tree = store.tree(store.path(&cursor.id).await.first()).await;
|
||||
let parent_id = if tree.next_sibling(&cursor.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.
|
||||
cursor.id.clone()
|
||||
} else if let Some(parent) = tree.parent(&cursor.id) {
|
||||
// A reply to a message without further 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.
|
||||
cursor.id.clone()
|
||||
};
|
||||
|
||||
if let Some(content) = Self::prompt_msg(crossterm_lock, terminal) {
|
||||
return Some((Some(parent_id), content));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Does approximately the opposite of [`Self::reply_normal`].
|
||||
pub async fn reply_alternate<S: MsgStore<M>>(
|
||||
store: &S,
|
||||
cursor: &Option<Cursor<M::Id>>,
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
) -> Option<(Option<M::Id>, String)> {
|
||||
if let Some(cursor) = cursor {
|
||||
let tree = store.tree(store.path(&cursor.id).await.first()).await;
|
||||
let parent_id = if tree.next_sibling(&cursor.id).is_none() {
|
||||
// The opposite of replying normally
|
||||
cursor.id.clone()
|
||||
} else if let Some(parent) = tree.parent(&cursor.id) {
|
||||
// The opposite of replying normally
|
||||
parent
|
||||
} else {
|
||||
// The same as replying normally, still to avoid creating
|
||||
// unnecessary new threads
|
||||
cursor.id.clone()
|
||||
};
|
||||
|
||||
if let Some(content) = Self::prompt_msg(crossterm_lock, terminal) {
|
||||
return Some((Some(parent_id), content));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn create_new_thread(
|
||||
terminal: &mut Terminal,
|
||||
crossterm_lock: &Arc<FairMutex<()>>,
|
||||
) -> Option<(Option<M::Id>, String)> {
|
||||
Self::prompt_msg(crossterm_lock, terminal).map(|c| (None, c))
|
||||
}
|
||||
}
|
||||
179
src/ui/chat/tree/blocks.rs
Normal file
179
src/ui/chat/tree/blocks.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//! Intermediate representation of messages as blocks of lines.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use toss::styled::Styled;
|
||||
|
||||
use super::{util, Cursor};
|
||||
|
||||
pub struct Block<I> {
|
||||
pub id: I,
|
||||
pub line: i32,
|
||||
pub height: i32,
|
||||
pub cursor: bool,
|
||||
pub time: Option<DateTime<Utc>>,
|
||||
pub indent: usize,
|
||||
pub body: BlockBody,
|
||||
}
|
||||
|
||||
impl<I> Block<I> {
|
||||
pub fn msg(
|
||||
id: I,
|
||||
indent: usize,
|
||||
time: DateTime<Utc>,
|
||||
nick: Styled,
|
||||
lines: Vec<Styled>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
line: 0,
|
||||
height: lines.len() as i32,
|
||||
indent,
|
||||
time: Some(time),
|
||||
cursor: false,
|
||||
body: BlockBody::Msg(MsgBlock { nick, lines }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn placeholder(id: I, indent: usize) -> Self {
|
||||
Self {
|
||||
id,
|
||||
line: 0,
|
||||
height: 1,
|
||||
indent,
|
||||
time: None,
|
||||
cursor: false,
|
||||
body: BlockBody::Placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn time(mut self, time: DateTime<Utc>) -> Self {
|
||||
self.time = Some(time);
|
||||
self
|
||||
}
|
||||
}
|
||||
pub enum BlockBody {
|
||||
Msg(MsgBlock),
|
||||
Placeholder,
|
||||
}
|
||||
|
||||
pub struct MsgBlock {
|
||||
pub nick: Styled,
|
||||
pub lines: Vec<Styled>,
|
||||
}
|
||||
|
||||
/// Pre-layouted messages as a sequence of blocks.
|
||||
///
|
||||
/// These blocks are straightforward to render, but also provide a level of
|
||||
/// abstraction between the layouting and actual displaying of messages. This
|
||||
/// might be useful in the future to ensure the cursor is always on a visible
|
||||
/// message, for example.
|
||||
///
|
||||
/// The following equation describes the relationship between the
|
||||
/// [`Blocks::top_line`] and [`Blocks::bottom_line`] fields:
|
||||
///
|
||||
/// `bottom_line - top_line + 1 = sum of all heights`
|
||||
///
|
||||
/// This ensures that `top_line` is always the first line and `bottom_line` is
|
||||
/// always the last line in a nonempty [`Blocks`]. In an empty layout, the
|
||||
/// equation simplifies to
|
||||
///
|
||||
/// `top_line = bottom_line + 1`
|
||||
pub struct Blocks<I> {
|
||||
pub blocks: VecDeque<Block<I>>,
|
||||
/// The top line of the first block. Useful for prepending blocks,
|
||||
/// especially to empty [`Blocks`]s.
|
||||
pub top_line: i32,
|
||||
/// The bottom line of the last block. Useful for appending blocks,
|
||||
/// especially to empty [`Blocks`]s.
|
||||
pub bottom_line: i32,
|
||||
}
|
||||
|
||||
impl<I: PartialEq> Blocks<I> {
|
||||
pub fn new() -> Self {
|
||||
Self::new_below(0)
|
||||
}
|
||||
|
||||
/// Create a new [`Blocks`] such that prepending a single line will result
|
||||
/// in `top_line = bottom_line = line`.
|
||||
pub fn new_below(line: i32) -> Self {
|
||||
Self {
|
||||
blocks: VecDeque::new(),
|
||||
top_line: line + 1,
|
||||
bottom_line: line,
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_cursor(&mut self, id: &I) -> usize {
|
||||
let mut cursor = None;
|
||||
for (i, block) in self.blocks.iter_mut().enumerate() {
|
||||
if &block.id == id {
|
||||
block.cursor = true;
|
||||
if cursor.is_some() {
|
||||
panic!("more than one cursor in blocks");
|
||||
}
|
||||
cursor = Some(i);
|
||||
}
|
||||
}
|
||||
cursor.expect("no cursor in blocks")
|
||||
}
|
||||
|
||||
pub fn calculate_offsets_with_cursor(&mut self, cursor: &Cursor<I>, height: u16) {
|
||||
let cursor_index = self.mark_cursor(&cursor.id);
|
||||
let cursor_line = util::proportion_to_line(height, cursor.proportion);
|
||||
|
||||
// Propagate lines from cursor to both ends
|
||||
self.blocks[cursor_index].line = cursor_line;
|
||||
for i in (0..cursor_index).rev() {
|
||||
// let succ_line = self.0[i + 1].line;
|
||||
// let curr = &mut self.0[i];
|
||||
// curr.line = succ_line - curr.height;
|
||||
self.blocks[i].line = self.blocks[i + 1].line - self.blocks[i].height;
|
||||
}
|
||||
for i in (cursor_index + 1)..self.blocks.len() {
|
||||
// let pred = &self.0[i - 1];
|
||||
// self.0[i].line = pred.line + pred.height;
|
||||
self.blocks[i].line = self.blocks[i - 1].line + self.blocks[i - 1].height;
|
||||
}
|
||||
self.top_line = self.blocks.front().expect("blocks nonempty").line;
|
||||
let bottom = self.blocks.back().expect("blocks nonempty");
|
||||
self.bottom_line = bottom.line + bottom.height - 1;
|
||||
}
|
||||
|
||||
pub fn push_front(&mut self, mut block: Block<I>) {
|
||||
self.top_line -= block.height;
|
||||
block.line = self.top_line;
|
||||
self.blocks.push_front(block);
|
||||
}
|
||||
|
||||
pub fn push_back(&mut self, mut block: Block<I>) {
|
||||
block.line = self.bottom_line + 1;
|
||||
self.bottom_line += block.height;
|
||||
self.blocks.push_back(block);
|
||||
}
|
||||
|
||||
pub fn prepend(&mut self, mut layout: Self) {
|
||||
while let Some(block) = layout.blocks.pop_back() {
|
||||
self.push_front(block);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(&mut self, mut layout: Self) {
|
||||
while let Some(block) = layout.blocks.pop_front() {
|
||||
self.push_back(block);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn offset(&mut self, delta: i32) {
|
||||
self.top_line += delta;
|
||||
self.bottom_line += delta;
|
||||
for block in &mut self.blocks {
|
||||
block.line += delta;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find(&self, id: &I) -> Option<&Block<I>> {
|
||||
self.blocks.iter().find(|b| &b.id == id)
|
||||
}
|
||||
}
|
||||
381
src/ui/chat/tree/cursor.rs
Normal file
381
src/ui/chat/tree/cursor.rs
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
//! Moving the cursor around.
|
||||
|
||||
use toss::frame::{Frame, Size};
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
|
||||
use super::blocks::Blocks;
|
||||
use super::{util, Cursor, TreeView};
|
||||
|
||||
impl<M: Msg> TreeView<M> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn correct_cursor_offset<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
old_blocks: &Blocks<M::Id>,
|
||||
old_cursor_id: &Option<M::Id>,
|
||||
cursor: &mut Cursor<M::Id>,
|
||||
) {
|
||||
if let Some(block) = old_blocks.find(&cursor.id) {
|
||||
// The cursor is still visible in the old blocks, so we just need to
|
||||
// adjust the proportion such that the blocks stay still.
|
||||
cursor.proportion = util::line_to_proportion(size.height, block.line);
|
||||
} else if let Some(old_cursor_id) = old_cursor_id {
|
||||
// The cursor is not visible any more. However, we can estimate
|
||||
// whether it is above or below the previous cursor position by
|
||||
// lexicographically comparing both positions' paths.
|
||||
let old_path = store.path(old_cursor_id).await;
|
||||
let new_path = store.path(&cursor.id).await;
|
||||
if new_path < old_path {
|
||||
// Because we moved upwards, the cursor should appear at the top
|
||||
// of the screen.
|
||||
cursor.proportion = 0.0;
|
||||
} else {
|
||||
// Because we moved downwards, the cursor should appear at the
|
||||
// bottom of the screen.
|
||||
cursor.proportion = 1.0;
|
||||
}
|
||||
} else {
|
||||
// We were scrolled all the way to the bottom, so the cursor must
|
||||
// have been offscreen somewhere above.
|
||||
cursor.proportion = 0.0;
|
||||
}
|
||||
|
||||
// The cursor should be visible in its entirety on the screen now. If it
|
||||
// isn't, we need to scroll the screen such that the cursor becomes fully
|
||||
// visible again. To do this, we'll need to re-layout because the cursor
|
||||
// could've moved anywhere.
|
||||
let blocks = self.layout_blocks(store, Some(cursor), frame, size).await;
|
||||
let cursor_block = blocks.find(&cursor.id).expect("cursor must be in blocks");
|
||||
// First, ensure the cursor's last line is not below the bottom of the
|
||||
// screen. Then, ensure its top line is not above the top of the screen.
|
||||
// If the cursor has more lines than the screen, the user should still
|
||||
// see the top of the cursor so they can start reading its contents.
|
||||
let min_line = 0;
|
||||
let max_line = size.height as i32 - cursor_block.height;
|
||||
// Not using clamp because it is possible that max_line < min_line
|
||||
let cursor_line = cursor_block.line.min(max_line).max(min_line);
|
||||
cursor.proportion = util::line_to_proportion(size.height, cursor_line);
|
||||
|
||||
// There is no need to ensure the screen is not scrolled too far up or
|
||||
// down. The messages in `blocks` are already scrolled correctly and
|
||||
// this function will not scroll the wrong way. If the cursor moves too
|
||||
// far up, the screen will only scroll down, not further up. The same
|
||||
// goes for the other direction.
|
||||
}
|
||||
|
||||
fn find_parent(tree: &Tree<M>, id: &mut M::Id) -> bool {
|
||||
if let Some(parent) = tree.parent(id) {
|
||||
*id = parent;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_child(tree: &Tree<M>, id: &mut M::Id) -> bool {
|
||||
if let Some(child) = tree.children(id).and_then(|c| c.first()) {
|
||||
*id = child.clone();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn find_last_child(tree: &Tree<M>, id: &mut M::Id) -> bool {
|
||||
if let Some(child) = tree.children(id).and_then(|c| c.last()) {
|
||||
*id = child.clone();
|
||||
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<S: MsgStore<M>>(
|
||||
&self,
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> bool {
|
||||
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_tree_id) = store.prev_tree(tree.root()).await {
|
||||
*tree = store.tree(&prev_tree_id).await;
|
||||
*id = prev_tree_id;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<S: MsgStore<M>>(
|
||||
&self,
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> bool {
|
||||
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_tree_id) = store.next_tree(tree.root()).await {
|
||||
*tree = store.tree(&next_tree_id).await;
|
||||
*id = next_tree_id;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the previous message, or don't move if this is not possible.
|
||||
async fn find_prev_msg<S: MsgStore<M>>(
|
||||
&self,
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> bool {
|
||||
// Move to previous sibling, then to its last child
|
||||
// If not possible, move to parent
|
||||
if self.find_prev_sibling(store, tree, id).await {
|
||||
while Self::find_last_child(tree, id) {}
|
||||
true
|
||||
} else {
|
||||
Self::find_parent(tree, id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move to the next message, or don't move if this is not possible.
|
||||
async fn find_next_msg<S: MsgStore<M>>(
|
||||
&self,
|
||||
store: &S,
|
||||
tree: &mut Tree<M>,
|
||||
id: &mut M::Id,
|
||||
) -> bool {
|
||||
if Self::find_first_child(tree, id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.find_next_sibling(store, tree, id).await {
|
||||
return 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 true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn move_up<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: &mut Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) {
|
||||
let old_blocks = self
|
||||
.layout_blocks(store, cursor.as_ref(), frame, size)
|
||||
.await;
|
||||
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
// We have a cursor to move around
|
||||
let path = store.path(&cursor.id).await;
|
||||
let mut tree = store.tree(path.first()).await;
|
||||
self.find_prev_msg(store, &mut tree, &mut cursor.id).await;
|
||||
} else if let Some(last_tree) = store.last_tree().await {
|
||||
// We need to select the last message of the last tree
|
||||
let tree = store.tree(&last_tree).await;
|
||||
let mut id = last_tree;
|
||||
while Self::find_last_child(&tree, &mut id) {}
|
||||
*cursor = Some(Cursor::new(id));
|
||||
}
|
||||
// If neither condition holds, we can't set a cursor because there's no
|
||||
// message to move to.
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_down<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: &mut Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) {
|
||||
let old_blocks = self
|
||||
.layout_blocks(store, cursor.as_ref(), frame, size)
|
||||
.await;
|
||||
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
let path = store.path(&cursor.id).await;
|
||||
let mut tree = store.tree(path.first()).await;
|
||||
self.find_next_msg(store, &mut tree, &mut cursor.id).await;
|
||||
}
|
||||
// If that condition doesn't hold, we're already at the bottom in
|
||||
// cursor-less mode and can't move further down anyways.
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_up_sibling<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: &mut Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) {
|
||||
let old_blocks = self
|
||||
.layout_blocks(store, cursor.as_ref(), frame, size)
|
||||
.await;
|
||||
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
let path = store.path(&cursor.id).await;
|
||||
let mut tree = store.tree(path.first()).await;
|
||||
self.find_prev_sibling(store, &mut tree, &mut cursor.id)
|
||||
.await;
|
||||
} else if let Some(last_tree) = store.last_tree().await {
|
||||
// I think moving to the root of the last tree makes the most sense
|
||||
// here. Alternatively, we could just not move the cursor, but that
|
||||
// wouldn't be very useful.
|
||||
*cursor = Some(Cursor::new(last_tree));
|
||||
}
|
||||
// If neither condition holds, we can't set a cursor because there's no
|
||||
// message to move to.
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_down_sibling<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: &mut Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) {
|
||||
let old_blocks = self
|
||||
.layout_blocks(store, cursor.as_ref(), frame, size)
|
||||
.await;
|
||||
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
let path = store.path(&cursor.id).await;
|
||||
let mut tree = store.tree(path.first()).await;
|
||||
self.find_next_sibling(store, &mut tree, &mut cursor.id)
|
||||
.await;
|
||||
}
|
||||
// If that condition doesn't hold, we're already at the bottom in
|
||||
// cursor-less mode and can't move further down anyways.
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_to_first<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: &mut Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) {
|
||||
let old_blocks = self
|
||||
.layout_blocks(store, cursor.as_ref(), frame, size)
|
||||
.await;
|
||||
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
|
||||
|
||||
if let Some(tree_id) = store.first_tree().await {
|
||||
*cursor = Some(Cursor::new(tree_id));
|
||||
}
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_to_last<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: &mut Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) {
|
||||
let old_blocks = self
|
||||
.layout_blocks(store, cursor.as_ref(), frame, size)
|
||||
.await;
|
||||
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
|
||||
|
||||
if let Some(tree_id) = store.last_tree().await {
|
||||
let tree = store.tree(&tree_id).await;
|
||||
let mut id = tree_id;
|
||||
while Self::find_last_child(&tree, &mut id) {}
|
||||
*cursor = Some(Cursor::new(id));
|
||||
}
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO move_older[_unseen]
|
||||
// TODO move_newer[_unseen]
|
||||
|
||||
pub async fn center_cursor<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: &mut Option<Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) {
|
||||
if let Some(cursor) = cursor {
|
||||
cursor.proportion = 0.5;
|
||||
|
||||
// Correcting the offset just to make sure that this function
|
||||
// behaves nicely if the cursor has too many lines.
|
||||
let old_blocks = self.layout_blocks(store, Some(cursor), frame, size).await;
|
||||
let old_cursor_id = Some(cursor.id.clone());
|
||||
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
167
src/ui/chat/tree/layout.rs
Normal file
167
src/ui/chat/tree/layout.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
//! Arranging messages as blocks.
|
||||
|
||||
use toss::frame::{Frame, Size};
|
||||
|
||||
use crate::store::{Msg, MsgStore, Tree};
|
||||
|
||||
use super::blocks::{Block, Blocks};
|
||||
use super::util::{self, MIN_CONTENT_WIDTH};
|
||||
use super::{Cursor, TreeView};
|
||||
|
||||
fn msg_to_block<M: Msg>(frame: &mut Frame, size: Size, msg: &M, indent: usize) -> Block<M::Id> {
|
||||
let nick = msg.nick();
|
||||
let content = msg.content();
|
||||
|
||||
let content_width = size.width as i32 - util::after_nick(frame, indent, &nick.text());
|
||||
if content_width < MIN_CONTENT_WIDTH as i32 {
|
||||
Block::placeholder(msg.id(), indent).time(msg.time())
|
||||
} else {
|
||||
let content_width = content_width as usize;
|
||||
let breaks = frame.wrap(&content.text(), content_width);
|
||||
let lines = content.split_at_indices(&breaks);
|
||||
Block::msg(msg.id(), indent, msg.time(), nick, lines)
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_subtree<M: Msg>(
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
tree: &Tree<M>,
|
||||
indent: usize,
|
||||
id: &M::Id,
|
||||
result: &mut Blocks<M::Id>,
|
||||
) {
|
||||
let block = if let Some(msg) = tree.msg(id) {
|
||||
msg_to_block(frame, size, msg, indent)
|
||||
} else {
|
||||
Block::placeholder(id.clone(), indent)
|
||||
};
|
||||
result.push_back(block);
|
||||
|
||||
if let Some(children) = tree.children(id) {
|
||||
for child in children {
|
||||
layout_subtree(frame, size, tree, indent + 1, child, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_tree<M: Msg>(frame: &mut Frame, size: Size, tree: Tree<M>) -> Blocks<M::Id> {
|
||||
let mut blocks = Blocks::new();
|
||||
layout_subtree(frame, size, &tree, 0, tree.root(), &mut blocks);
|
||||
blocks
|
||||
}
|
||||
|
||||
impl<M: Msg> TreeView<M> {
|
||||
pub async fn expand_blocks_up<S: MsgStore<M>>(
|
||||
store: &S,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
blocks: &mut Blocks<M::Id>,
|
||||
tree_id: &mut Option<M::Id>,
|
||||
) {
|
||||
while blocks.top_line > 0 {
|
||||
*tree_id = if let Some(tree_id) = tree_id {
|
||||
store.prev_tree(tree_id).await
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(tree_id) = tree_id {
|
||||
let tree = store.tree(tree_id).await;
|
||||
blocks.prepend(layout_tree(frame, size, tree));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn expand_blocks_down<S: MsgStore<M>>(
|
||||
store: &S,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
blocks: &mut Blocks<M::Id>,
|
||||
tree_id: &mut Option<M::Id>,
|
||||
) {
|
||||
while blocks.bottom_line < size.height as i32 {
|
||||
*tree_id = if let Some(tree_id) = tree_id {
|
||||
store.next_tree(tree_id).await
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(tree_id) = tree_id {
|
||||
let tree = store.tree(tree_id).await;
|
||||
blocks.append(layout_tree(frame, size, tree));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Split up based on cursor presence
|
||||
pub async fn layout_blocks<S: MsgStore<M>>(
|
||||
&mut self,
|
||||
store: &S,
|
||||
cursor: Option<&Cursor<M::Id>>,
|
||||
frame: &mut Frame,
|
||||
size: Size,
|
||||
) -> Blocks<M::Id> {
|
||||
if let Some(cursor) = cursor {
|
||||
// TODO Ensure focus lies on cursor path, otherwise unfocus
|
||||
// TODO Unfold all messages on path to cursor
|
||||
|
||||
// Layout cursor subtree (with correct offsets based on cursor)
|
||||
let cursor_path = store.path(&cursor.id).await;
|
||||
let cursor_tree_id = cursor_path.first();
|
||||
let cursor_tree = store.tree(cursor_tree_id).await;
|
||||
let mut blocks = layout_tree(frame, size, cursor_tree);
|
||||
blocks.calculate_offsets_with_cursor(cursor, size.height);
|
||||
|
||||
// Expand upwards and downwards, ensuring the blocks are not
|
||||
// scrolled too far in any direction.
|
||||
//
|
||||
// If the blocks fill the screen, scrolling stops when the topmost
|
||||
// message is at the top of the screen or the bottommost message is
|
||||
// at the bottom. If they don't fill the screen, the bottommost
|
||||
// message should always be at the bottom.
|
||||
//
|
||||
// Because our helper functions always expand the blocks until they
|
||||
// reach the top or bottom of the screen, we can determine that
|
||||
// we're at the top/bottom if expansion stopped anywhere in the
|
||||
// middle of the screen.
|
||||
//
|
||||
// TODO Don't expand if there is a focus
|
||||
let mut top_tree_id = Some(cursor_tree_id.clone());
|
||||
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut top_tree_id).await;
|
||||
if blocks.top_line > 0 {
|
||||
blocks.offset(-blocks.top_line);
|
||||
}
|
||||
let mut bot_tree_id = Some(cursor_tree_id.clone());
|
||||
Self::expand_blocks_down(store, frame, size, &mut blocks, &mut bot_tree_id).await;
|
||||
if blocks.bottom_line < size.height as i32 - 1 {
|
||||
blocks.offset(size.height as i32 - 1 - blocks.bottom_line);
|
||||
}
|
||||
// If we only moved the blocks down, we need to expand upwards again
|
||||
// to make sure we fill the screen.
|
||||
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut top_tree_id).await;
|
||||
|
||||
blocks
|
||||
} else {
|
||||
// TODO Ensure there is no focus
|
||||
|
||||
// Start at the bottom of the screen
|
||||
let mut blocks = Blocks::new_below(size.height as i32 - 1);
|
||||
|
||||
// Expand upwards from last tree
|
||||
if let Some(last_tree_id) = store.last_tree().await {
|
||||
let last_tree = store.tree(&last_tree_id).await;
|
||||
blocks.prepend(layout_tree(frame, size, last_tree));
|
||||
|
||||
let mut tree_id = Some(last_tree_id);
|
||||
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut tree_id).await;
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/ui/chat/tree/render.rs
Normal file
95
src/ui/chat/tree/render.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Rendering blocks to a [`Frame`].
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use toss::frame::{Frame, Pos, Size};
|
||||
use toss::styled::Styled;
|
||||
|
||||
use crate::store::Msg;
|
||||
|
||||
use super::blocks::{Block, BlockBody, Blocks};
|
||||
use super::util::{
|
||||
self, style_indent, style_indent_inverted, style_placeholder, style_time, style_time_inverted,
|
||||
INDENT, PLACEHOLDER, TIME_EMPTY, TIME_FORMAT,
|
||||
};
|
||||
use super::TreeView;
|
||||
|
||||
fn render_time(frame: &mut Frame, x: i32, y: i32, cursor: bool, time: Option<DateTime<Utc>>) {
|
||||
let pos = Pos::new(x, y);
|
||||
|
||||
let style = if cursor {
|
||||
style_time_inverted()
|
||||
} else {
|
||||
style_time()
|
||||
};
|
||||
|
||||
if let Some(time) = time {
|
||||
let time = format!("{}", time.format(TIME_FORMAT));
|
||||
frame.write(pos, (&time, style));
|
||||
} else {
|
||||
frame.write(pos, (TIME_EMPTY, style));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_indent(frame: &mut Frame, x: i32, y: i32, cursor: bool, indent: usize) {
|
||||
let style = if cursor {
|
||||
style_indent_inverted()
|
||||
} else {
|
||||
style_indent()
|
||||
};
|
||||
|
||||
let mut styled = Styled::default();
|
||||
for _ in 0..indent {
|
||||
styled = styled.then((INDENT, style));
|
||||
}
|
||||
|
||||
frame.write(Pos::new(x + util::after_indent(0), y), styled);
|
||||
}
|
||||
|
||||
fn render_nick(frame: &mut Frame, x: i32, y: i32, indent: usize, nick: Styled) {
|
||||
let nick_pos = Pos::new(x + util::after_indent(indent), y);
|
||||
let styled = Styled::new("[").and_then(nick).then("]");
|
||||
frame.write(nick_pos, styled);
|
||||
}
|
||||
|
||||
fn render_block<M: Msg>(frame: &mut Frame, pos: Pos, size: Size, block: Block<M::Id>) {
|
||||
match block.body {
|
||||
BlockBody::Msg(msg) => {
|
||||
let after_nick = util::after_nick(frame, block.indent, &msg.nick.text());
|
||||
|
||||
for (i, line) in msg.lines.into_iter().enumerate() {
|
||||
let y = pos.y + block.line + i as i32;
|
||||
if y < 0 || y >= pos.y + size.height as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
render_indent(frame, pos.x, y, block.cursor, block.indent);
|
||||
render_time(frame, pos.x, y, block.cursor, block.time);
|
||||
render_nick(frame, pos.x, y, block.indent, msg.nick.clone());
|
||||
} else {
|
||||
render_indent(frame, pos.x, y, false, block.indent + 1);
|
||||
render_indent(frame, pos.x, y, block.cursor, block.indent);
|
||||
render_time(frame, pos.x, y, block.cursor, None);
|
||||
}
|
||||
|
||||
let line_pos = Pos::new(pos.x + after_nick, y);
|
||||
frame.write(line_pos, line);
|
||||
}
|
||||
}
|
||||
BlockBody::Placeholder => {
|
||||
let y = pos.y + block.line;
|
||||
render_time(frame, pos.x, y, block.cursor, block.time);
|
||||
render_indent(frame, pos.x, y, block.cursor, block.indent);
|
||||
let pos = Pos::new(pos.x + util::after_indent(block.indent), y);
|
||||
frame.write(pos, (PLACEHOLDER, style_placeholder()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Msg> TreeView<M> {
|
||||
pub fn render_blocks(frame: &mut Frame, pos: Pos, size: Size, layout: Blocks<M::Id>) {
|
||||
for block in layout.blocks {
|
||||
render_block::<M>(frame, pos, size, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/ui/chat/tree/util.rs
Normal file
56
src/ui/chat/tree/util.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Constants and helper functions.
|
||||
|
||||
use crossterm::style::{ContentStyle, Stylize};
|
||||
use toss::frame::Frame;
|
||||
|
||||
pub const TIME_FORMAT: &str = "%H:%M ";
|
||||
pub const TIME_EMPTY: &str = " ";
|
||||
pub const TIME_WIDTH: usize = 6;
|
||||
|
||||
pub fn style_time() -> ContentStyle {
|
||||
ContentStyle::default().grey()
|
||||
}
|
||||
|
||||
pub fn style_time_inverted() -> ContentStyle {
|
||||
ContentStyle::default().black().on_white()
|
||||
}
|
||||
|
||||
pub const INDENT: &str = "│ ";
|
||||
pub const INDENT_WIDTH: usize = 2;
|
||||
|
||||
pub fn style_indent() -> ContentStyle {
|
||||
ContentStyle::default().dark_grey()
|
||||
}
|
||||
|
||||
pub fn style_indent_inverted() -> ContentStyle {
|
||||
ContentStyle::default().black().on_white()
|
||||
}
|
||||
|
||||
pub const PLACEHOLDER: &str = "[...]";
|
||||
|
||||
pub fn style_placeholder() -> ContentStyle {
|
||||
ContentStyle::default().dark_grey()
|
||||
}
|
||||
|
||||
// Something like this should fit: [+, 1234 more]
|
||||
pub const MIN_CONTENT_WIDTH: usize = 14;
|
||||
|
||||
pub fn after_indent(indent: usize) -> i32 {
|
||||
(TIME_WIDTH + indent * INDENT_WIDTH) as i32
|
||||
}
|
||||
|
||||
pub fn after_nick(frame: &mut Frame, indent: usize, nick: &str) -> i32 {
|
||||
after_indent(indent) + 1 + frame.width(nick) as i32 + 2
|
||||
}
|
||||
|
||||
pub fn proportion_to_line(height: u16, proportion: f32) -> i32 {
|
||||
((height - 1) as f32 * proportion).round() as i32
|
||||
}
|
||||
|
||||
pub fn line_to_proportion(height: u16, line: i32) -> f32 {
|
||||
if height > 1 {
|
||||
line as f32 / (height - 1) as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,10 @@ use tokio::sync::mpsc;
|
|||
use toss::frame::{Frame, Pos, Size};
|
||||
use toss::terminal::Terminal;
|
||||
|
||||
use crate::chat::Chat;
|
||||
use crate::euph::{self, Status};
|
||||
use crate::vault::{EuphMsg, EuphVault};
|
||||
|
||||
use super::chat::Chat;
|
||||
use super::{util, UiEvent};
|
||||
|
||||
pub struct EuphRoom {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue