Compare commits
43 Commits
Author | SHA1 | Date | |
---|---|---|---|
fab114459b | |||
5f38edcb3b | |||
df4134829b | |||
e7f3eeb331 | |||
11bf79e95e | |||
e627e3b9a2 | |||
fa6c171916 | |||
115679d50d | |||
143f144e2c | |||
b3ab8d734f | |||
ecbf745106 | |||
8faf0d1902 | |||
f5be73b915 | |||
13965a7e67 | |||
61f30cfbf3 | |||
ab98d565dc | |||
fc8193f892 | |||
39134af79d | |||
221af7d1b9 | |||
43f71e9caa | |||
f0b2536e86 | |||
2385f33e6b | |||
f270ef25b4 | |||
f4f134870b | |||
9a422a6d7e | |||
d79f5d7527 | |||
501ccc007e | |||
9cef34eb5c | |||
1c8c6d3e3e | |||
c7124c1191 | |||
30b1e4282a | |||
b60834ebb3 | |||
8d6a076b59 | |||
9c9f21bc82 | |||
dd9028cbb2 | |||
e03970a931 | |||
f4ea84b862 | |||
56bab04a5a | |||
2856dd0504 | |||
fec449b933 | |||
929920edb2 | |||
dd404147ab | |||
9b29d4d9e5 |
@ -3,15 +3,7 @@
|
||||
{
|
||||
"user_id": "@alice:example.org",
|
||||
"password": "hunter2",
|
||||
// Optional: "device_name": "ratatrix on <hostname>",
|
||||
},
|
||||
],
|
||||
"keybindings": {
|
||||
"Home": {
|
||||
"<Ctrl-d>": "/quit",
|
||||
"<Ctrl-c>": "/quit",
|
||||
"<Ctrl-z>": "/suspend",
|
||||
"<Alt-left>": "/previous",
|
||||
"<Alt-right>": "/next",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
4
.config/config.toml
Normal file
4
.config/config.toml
Normal file
@ -0,0 +1,4 @@
|
||||
[[accounts]]
|
||||
user_id = "@alice:example.com"
|
||||
password = "hunter2"
|
||||
# Optional: device_name = "ratatrix on <hostname>"
|
113
.gitea/workflows/ci.yml
Normal file
113
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,113 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
# zstd is an optional dep of actions/cache@v3
|
||||
apt-get -y update
|
||||
apt-get -y install zstd
|
||||
|
||||
- name: Cache Rust
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.rustup/
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-1.73.0-lint
|
||||
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: '1.76.0'
|
||||
components: rustfmt
|
||||
override: true
|
||||
|
||||
- name: rustfmt
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
|
||||
- name: clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: ${{ matrix.default }} -- -Dwarnings
|
||||
|
||||
test:
|
||||
name: Build and test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
rust:
|
||||
- nightly
|
||||
- beta
|
||||
- '1.76.0'
|
||||
|
||||
features:
|
||||
- ''
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
# zstd is an optional dep of actions/cache@v3
|
||||
apt-get -y update
|
||||
apt-get -y install lld zstd
|
||||
|
||||
- name: Cache Rust
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.rustup/
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.rust }}-test
|
||||
|
||||
- name: Install rust (${{ matrix.rust }})
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
profile: minimal
|
||||
override: true
|
||||
|
||||
- name: Configure Cargo to use lld
|
||||
run: |
|
||||
echo '[build]' >> ~/.cargo/config
|
||||
echo 'rustflags = ["-Clink-args=-fuse-ld=lld"]' >> ~/.cargo/config
|
||||
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: build
|
||||
args: ${{ matrix.features }}
|
||||
|
||||
- name: Test
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: ${{ matrix.features }}
|
||||
|
||||
- name: Doc
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: doc
|
||||
args: ${{ matrix.features }} --no-deps
|
14
Cargo.toml
14
Cargo.toml
@ -24,6 +24,7 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter", "serde"] }
|
||||
clap = { version = "4.4.5", features = ["derive", "cargo", "wrap_help", "unicode", "string", "unstable-styles"] }
|
||||
|
||||
# Config
|
||||
bounded-integer = { version = "0.5.7", features = ["types"] }
|
||||
config = "0.13.3"
|
||||
derive_deref = "1.1.1"
|
||||
directories = "5.0.1"
|
||||
@ -31,6 +32,7 @@ hostname = "0.3.1"
|
||||
json5 = "0.4.1"
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
serde_json = "1.0.107"
|
||||
serde_path_to_error = "0.1.14"
|
||||
# TODO: switch to toml_edit to preserve (and write) doc comments
|
||||
|
||||
# Error handling
|
||||
@ -38,6 +40,12 @@ better-panic = "0.3.0"
|
||||
color-eyre = "0.6.2"
|
||||
human-panic = "1.2.0"
|
||||
|
||||
# Formatting
|
||||
hex = "0.4.3"
|
||||
html5ever = "0.26.0"
|
||||
html-escape = "0.2.13"
|
||||
markup5ever_rcdom = "0.2.0"
|
||||
|
||||
# Internal
|
||||
enum_dispatch = "0.3.12"
|
||||
inventory = "0.3"
|
||||
@ -56,8 +64,8 @@ sorted-vec = "0.8.3"
|
||||
eyeball = "0.8.7" # data structures observer returned by matrix-sdk-ui
|
||||
eyeball-im = "0.4.2" # immutable data structures observer returned by matrix-sdk-ui
|
||||
imbl = "2.0" # ditto
|
||||
matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", rev = "34060957855fdcb91af820a75df20774949e41be", features = ["eyre", "markdown"] }
|
||||
matrix-sdk-ui = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", rev = "34060957855fdcb91af820a75df20774949e41be" }
|
||||
matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", rev = "5c37acb81ce624d83be54b5140cd60399b556fb2", features = ["eyre", "markdown"] }
|
||||
matrix-sdk-ui = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", rev = "5c37acb81ce624d83be54b5140cd60399b556fb2" }
|
||||
#matrix-sdk = { path = "../matrix-rust-sdk/crates/matrix-sdk", features = ["eyre", "markdown"] }
|
||||
#matrix-sdk-ui = { path = "../matrix-rust-sdk/crates/matrix-sdk-ui" }
|
||||
|
||||
@ -65,7 +73,7 @@ matrix-sdk-ui = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", rev
|
||||
ansi-to-tui = "3.1.0"
|
||||
chrono = "0.4.31"
|
||||
crossterm = { version = "0.27.0", features = ["serde", "event-stream"] }
|
||||
ratatui = { version = "0.24.0", features = ["serde", "macros"] }
|
||||
ratatui = { version = "0.26.0", features = ["serde", "macros"] }
|
||||
strip-ansi-escapes = "0.2.0"
|
||||
tui-textarea = "0.3.0"
|
||||
unicode-width = "0.1"
|
||||
|
16
README.md
16
README.md
@ -1,2 +1,18 @@
|
||||
# ratatrix
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an account on [any Matrix homeserver](https://servers.joinmatrix.org/)
|
||||
2. Install Rust
|
||||
3. Copy `.config/config.toml` or `.config/config.json5` to `~/.config/ratatrix/`
|
||||
and fill the placeholders with your credentials
|
||||
4. `cargo run`
|
||||
|
||||
## Configuration
|
||||
|
||||
Ratatrix supports multiple configuration format: TOML, JSON/JSON5, YAML, and INI,
|
||||
you may choose whichever you prefer. If you don't have a preference, pick TOML.
|
||||
Example configurations for TOML and JSON5 are provided in `.config`.
|
||||
|
||||
Default values for all settings, and their documentation, can be found in
|
||||
`src/default_config.toml`.
|
||||
|
@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.73.0"
|
||||
channel = "1.76.0"
|
||||
|
@ -7,7 +7,6 @@ use serde::{
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Tick,
|
||||
/// Apply any pending update to the screen
|
||||
Render,
|
||||
/// Notify there is a pending update
|
||||
@ -20,7 +19,9 @@ pub enum Action {
|
||||
Error(String),
|
||||
PreviousBuffer,
|
||||
NextBuffer,
|
||||
ToBuffer(isize),
|
||||
RunCommand(String),
|
||||
MouseEvent(crossterm::event::MouseEvent),
|
||||
Help,
|
||||
}
|
||||
|
||||
@ -44,7 +45,6 @@ impl<'de> Deserialize<'de> for Action {
|
||||
E: de::Error,
|
||||
{
|
||||
match value {
|
||||
"Tick" => Ok(Action::Tick),
|
||||
"Render" => Ok(Action::Render),
|
||||
"Suspend" => Ok(Action::Suspend),
|
||||
"Resume" => Ok(Action::Resume),
|
||||
|
61
src/app.rs
61
src/app.rs
@ -39,10 +39,9 @@ use crate::{
|
||||
};
|
||||
|
||||
pub struct App {
|
||||
pub config: Config,
|
||||
pub config: Arc<Config>,
|
||||
pub clients: NonEmpty<matrix_sdk::Client>,
|
||||
pub commands: RataCommands,
|
||||
pub tick_rate: f64,
|
||||
pub frame_rate: f64,
|
||||
pub components: Vec<Box<dyn Component>>,
|
||||
pub buffers: Buffers,
|
||||
@ -53,15 +52,8 @@ pub struct App {
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub async fn new(
|
||||
tick_rate: f64,
|
||||
frame_rate: f64,
|
||||
log_receiver: mpsc::UnboundedReceiver<String>,
|
||||
) -> Result<Self> {
|
||||
let home = Home::new();
|
||||
let fps = FpsCounter::default();
|
||||
pub async fn new(frame_rate: f64, log_receiver: mpsc::UnboundedReceiver<String>) -> Result<Self> {
|
||||
let config = Config::new()?;
|
||||
let mode = Mode::Home;
|
||||
let datadir = config.config._data_dir.join("default");
|
||||
let future_clients = config.accounts.clone().map(|conf| {
|
||||
let datadir = datadir.clone();
|
||||
@ -123,8 +115,12 @@ impl App {
|
||||
}
|
||||
let clients = NonEmpty::collect(clients).expect("map on NonEmpty returned empty vec");
|
||||
|
||||
let config = Arc::new(config);
|
||||
let home = Home::new(config.clone());
|
||||
let fps = FpsCounter::default();
|
||||
let mode = Mode::Home;
|
||||
|
||||
let mut app = Self {
|
||||
tick_rate,
|
||||
frame_rate,
|
||||
components: vec![Box::new(home), Box::new(fps)],
|
||||
should_quit: Arc::new(AtomicBool::new(false)),
|
||||
@ -145,19 +141,14 @@ impl App {
|
||||
let (sync_responses_tx, mut sync_responses_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let mut tui = tui::Tui::new()?
|
||||
.tick_rate(self.tick_rate)
|
||||
.frame_rate(self.frame_rate);
|
||||
// tui.mouse(true);
|
||||
.frame_rate(self.frame_rate)
|
||||
.mouse(self.config.mouse.enable);
|
||||
tui.enter()?;
|
||||
|
||||
for component in self.components.iter_mut() {
|
||||
component.register_action_handler(action_tx.clone())?;
|
||||
}
|
||||
|
||||
for component in self.components.iter_mut() {
|
||||
component.register_config_handler(self.config.clone())?;
|
||||
}
|
||||
|
||||
for component in self.components.iter_mut() {
|
||||
component.init(tui.size()?)?;
|
||||
}
|
||||
@ -223,7 +214,7 @@ impl App {
|
||||
let (client, sync_response) = sync_response.expect("sync_responses_rx unexpectedly closed");
|
||||
self.handle_sync_response(&action_tx, client, sync_response).await.context("Error while handling sync response")?;
|
||||
}
|
||||
poll_updates = self.buffers.poll_updates() => {
|
||||
poll_updates = self.buffers.poll_updates_once() => {
|
||||
changes_since_last_render = true;
|
||||
}
|
||||
sync_result = sync_results.next() => {
|
||||
@ -234,13 +225,10 @@ impl App {
|
||||
}
|
||||
|
||||
while let Ok(action) = action_rx.try_recv() {
|
||||
if action != Action::Tick && action != Action::Render {
|
||||
if action != Action::Render {
|
||||
log::debug!("{action:?}");
|
||||
}
|
||||
match action {
|
||||
Action::Tick => {
|
||||
self.last_tick_key_events.borrow_mut().drain(..);
|
||||
},
|
||||
Action::Quit => self.should_quit.store(true, Ordering::Release),
|
||||
Action::Suspend => self.should_suspend = true,
|
||||
Action::Resume => self.should_suspend = false,
|
||||
@ -258,6 +246,10 @@ impl App {
|
||||
.buffers
|
||||
.set_active_index(self.buffers.active_index() as isize - 1)
|
||||
},
|
||||
Action::ToBuffer(buffer_id) => {
|
||||
changes_since_last_render = true;
|
||||
self.buffers.set_active_index(buffer_id)
|
||||
},
|
||||
Action::Resize(w, h) => {
|
||||
changes_since_last_render = true;
|
||||
tui.resize(Rect::new(0, 0, w, h))?;
|
||||
@ -308,9 +300,8 @@ impl App {
|
||||
tui.suspend()?;
|
||||
action_tx.send(Action::Resume)?;
|
||||
tui = tui::Tui::new()?
|
||||
.tick_rate(self.tick_rate)
|
||||
.frame_rate(self.frame_rate);
|
||||
// tui.mouse(true);
|
||||
.frame_rate(self.frame_rate)
|
||||
.mouse(self.config.mouse.enable);
|
||||
tui.enter()?;
|
||||
} else if self.should_quit.load(Ordering::Acquire) {
|
||||
tui.stop()?;
|
||||
@ -322,15 +313,23 @@ impl App {
|
||||
}
|
||||
|
||||
fn handle_tui_event(
|
||||
&self,
|
||||
&mut self,
|
||||
action_tx: &mpsc::UnboundedSender<Action>,
|
||||
e: tui::Event,
|
||||
) -> Result<()> {
|
||||
match e {
|
||||
tui::Event::Quit => action_tx.send(Action::Quit)?,
|
||||
tui::Event::Tick => action_tx.send(Action::Tick)?,
|
||||
tui::Event::Render => action_tx.send(Action::Render)?,
|
||||
tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
|
||||
tui::Event::Mouse(event) => {
|
||||
// Don't go through the action queue, we need to process mouse events immediately
|
||||
// or stuff may move on screen before we process the click
|
||||
for component in &mut self.components {
|
||||
if let Some(action) = component.handle_mouse_events(event)? {
|
||||
action_tx.send(action)?;
|
||||
}
|
||||
}
|
||||
},
|
||||
tui::Event::Key(key) => {
|
||||
if let Some(keymap) = self.config.keybindings.get(&self.mode) {
|
||||
if let Some(command_line) = keymap.get(&vec![key]) {
|
||||
@ -361,7 +360,7 @@ impl App {
|
||||
client: matrix_sdk::Client,
|
||||
sync_response: matrix_sdk::sync::SyncResponse,
|
||||
) -> Result<()> {
|
||||
let known_rooms: HashSet<matrix_sdk::ruma::OwnedRoomId> = self
|
||||
let known_rooms: HashSet<Arc<matrix_sdk::ruma::OwnedRoomId>> = self
|
||||
.buffers
|
||||
.iter()
|
||||
.flat_map(|buf| match buf.id() {
|
||||
@ -374,7 +373,7 @@ impl App {
|
||||
.join
|
||||
.keys()
|
||||
.filter(|room_id: &&matrix_sdk::ruma::OwnedRoomId| {
|
||||
!known_rooms.contains::<matrix_sdk::ruma::RoomId>(room_id.as_ref())
|
||||
!known_rooms.contains(&Arc::new((*room_id).clone()))
|
||||
})
|
||||
.map(|room_id| (client.clone(), room_id.to_owned()))
|
||||
.collect();
|
||||
@ -388,7 +387,7 @@ impl App {
|
||||
) {
|
||||
futures::future::join_all(
|
||||
rooms
|
||||
.map(|(client, room)| RoomBuffer::new(client, room))
|
||||
.map(|(client, room)| RoomBuffer::new(self.config.clone(), client, Arc::new(room)))
|
||||
.map(|fut| fut.map(|res| res.expect("Failed to create RoomBuffer at startup"))),
|
||||
)
|
||||
.await
|
||||
|
@ -25,14 +25,14 @@ use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
use super::{Buffer, BufferId, BufferItem, BufferItemContent};
|
||||
use super::{Buffer, BufferId, BufferItem, BufferItemContent, FullyReadStatus};
|
||||
use crate::widgets::Prerender;
|
||||
|
||||
/// Maximum number of log lines to be stored in memory
|
||||
const MAX_MEM_LOG_LINES: usize = 1000;
|
||||
|
||||
pub struct LogBuffer {
|
||||
lines: VecDeque<(String, Prerender)>,
|
||||
lines: VecDeque<(u64, String, Prerender)>,
|
||||
receiver: UnboundedReceiver<String>,
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ impl Buffer for LogBuffer {
|
||||
BufferId::Log
|
||||
}
|
||||
|
||||
async fn poll_updates(&mut self) {
|
||||
async fn poll_updates_once(&mut self) {
|
||||
let line = self
|
||||
.receiver
|
||||
.recv()
|
||||
@ -73,17 +73,38 @@ impl Buffer for LogBuffer {
|
||||
if self.lines.len() >= MAX_MEM_LOG_LINES {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.lines.push_back((line, Prerender::new()));
|
||||
let line_id = self
|
||||
.lines
|
||||
.back()
|
||||
.map(|(last_id, _, _)| last_id + 1)
|
||||
.unwrap_or(0);
|
||||
self.lines.push_back((line_id, line, Prerender::new()));
|
||||
}
|
||||
|
||||
fn content<'a>(&'a self) -> Box<dyn ExactSizeIterator<Item = BufferItem<'a>> + 'a> {
|
||||
use ansi_to_tui::IntoText;
|
||||
Box::new(self.lines.iter().rev().map(|(line, prerender)| BufferItem {
|
||||
content: BufferItemContent::Text(line.clone().into_text().unwrap_or_else(|e| {
|
||||
tracing::error!("Could not convert line from ANSI codes to ratatui: {}", e);
|
||||
Text::raw(line)
|
||||
})),
|
||||
prerender,
|
||||
}))
|
||||
Box::new(
|
||||
self
|
||||
.lines
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|(line_id, line, prerender)| BufferItem {
|
||||
content: BufferItemContent::SimpleText(line.clone().into_text().unwrap_or_else(|e| {
|
||||
tracing::error!("Could not convert line from ANSI codes to ratatui: {}", e);
|
||||
Text::raw(line)
|
||||
})),
|
||||
prerender,
|
||||
unique_id: Some(*line_id),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn unread_notification_counts(&self) -> matrix_sdk::sync::UnreadNotificationsCount {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn fully_read(&self) -> FullyReadStatus {
|
||||
// TODO
|
||||
FullyReadStatus::All
|
||||
}
|
||||
}
|
||||
|
@ -16,13 +16,17 @@
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use matrix_sdk::async_trait;
|
||||
use nonempty::NonEmpty;
|
||||
use ratatui::text::Text;
|
||||
use smallvec::SmallVec;
|
||||
use sorted_vec::SortedVec;
|
||||
use tokio::select;
|
||||
|
||||
use crate::widgets::Prerender;
|
||||
|
||||
@ -31,12 +35,28 @@ pub use log::LogBuffer;
|
||||
mod room;
|
||||
pub use room::RoomBuffer;
|
||||
|
||||
/// Maximum time before reordering the buffer list based on parent/child relationships.
|
||||
///
|
||||
/// Updates are not applied immediately in order to coalesce multiple changes happening
|
||||
/// in a row, as applying an update is (currently) computationally expensive.
|
||||
const UPDATE_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum FullyReadStatus {
|
||||
/// There are some unread messages
|
||||
Not,
|
||||
/// All messages are read, but some non-messages events are not
|
||||
OnlyMessages,
|
||||
/// All events are read
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum BufferId {
|
||||
/// The main/home buffer
|
||||
Log,
|
||||
/// Any Matrix room
|
||||
Room(matrix_sdk::ruma::OwnedRoomId),
|
||||
Room(Arc<matrix_sdk::ruma::OwnedRoomId>),
|
||||
}
|
||||
|
||||
/// Values should follow the ["Ordering of children within a space" algorithm](https://spec.matrix.org/v1.8/client-server-api/#ordering-of-children-within-a-space).
|
||||
@ -82,14 +102,22 @@ impl PartialOrd for BufferSortKey {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BufferItemContent<'buf> {
|
||||
Text(Text<'buf>),
|
||||
SimpleText(Text<'buf>),
|
||||
/// Pairs of `(padding, content)`
|
||||
Text(Vec<(String, Text<'buf>)>),
|
||||
Divider(Text<'buf>),
|
||||
Empty,
|
||||
}
|
||||
|
||||
// intentionally not Clone, because it would be ambiguous what to do
|
||||
// with the prerender if the content is updated only in one instance
|
||||
pub struct BufferItem<'buf> {
|
||||
pub content: BufferItemContent<'buf>,
|
||||
pub prerender: &'buf Prerender,
|
||||
/// Used to preserve position when other items are added or edited in recent history.
|
||||
///
|
||||
/// Only needs to be unique per-buffer
|
||||
pub unique_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -106,13 +134,16 @@ pub trait Buffer: Send + Sync + memuse::DynamicUsage {
|
||||
None
|
||||
}
|
||||
/// Returns if there are any updates to apply.
|
||||
async fn poll_updates(&mut self);
|
||||
async fn poll_updates_once(&mut self);
|
||||
fn content<'a>(&'a self) -> Box<dyn ExactSizeIterator<Item = BufferItem<'a>> + 'a>;
|
||||
/// Called when the user is being showned the oldest items this buffer returned.
|
||||
///
|
||||
/// This should return immediately, not waiting for anything to be loaded.
|
||||
fn request_back_pagination(&self, num: u16) {}
|
||||
|
||||
fn unread_notification_counts(&self) -> matrix_sdk::sync::UnreadNotificationsCount;
|
||||
fn fully_read(&self) -> FullyReadStatus;
|
||||
|
||||
fn dynamic_usage(&self) -> usize {
|
||||
memuse::DynamicUsage::dynamic_usage(self)
|
||||
}
|
||||
@ -123,6 +154,7 @@ pub trait Buffer: Send + Sync + memuse::DynamicUsage {
|
||||
|
||||
pub struct Buffers {
|
||||
buffers: Vec<Box<dyn Buffer>>,
|
||||
|
||||
/// Set of children of each buffer, sorted so that children explicitly listed by a
|
||||
/// space are sorted according to
|
||||
/// https://spec.matrix.org/v1.8/client-server-api/#ordering-of-children-within-a-space
|
||||
@ -140,7 +172,16 @@ pub struct Buffers {
|
||||
/// steal them, even if they are also their child (or it would cause buffers to move
|
||||
/// every time we re-sort the buffer list)
|
||||
attached_to_parent: HashSet<BufferId>,
|
||||
active_index: usize,
|
||||
|
||||
/// When the `buffers` list and `parents`/`children` maps should be recomputed to match
|
||||
/// actual relationships between buffers.
|
||||
///
|
||||
/// They are not recomputed every time, because the current implementation is
|
||||
/// computationally expensive.
|
||||
next_reorder: Option<Instant>,
|
||||
|
||||
/// Which buffer is currently selected in the UI.
|
||||
active_buffer: BufferId,
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
@ -150,28 +191,55 @@ impl Buffers {
|
||||
children: HashMap::new(),
|
||||
parents: HashMap::new(),
|
||||
attached_to_parent: HashSet::new(),
|
||||
active_index: 0,
|
||||
next_reorder: None,
|
||||
active_buffer: BufferId::Log,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn poll_updates(&mut self) {
|
||||
self
|
||||
.iter_mut()
|
||||
.map(|buf| buf.poll_updates())
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.next()
|
||||
.await
|
||||
.expect("poll_updates reached the end of the never-ending stream");
|
||||
pub async fn poll_updates_once(&mut self) {
|
||||
let reorder_now = {
|
||||
let next_reorder = self.next_reorder;
|
||||
let mut updates_future = self
|
||||
.iter_mut()
|
||||
.map(|buf| buf.poll_updates_once())
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
|
||||
// Reorder buffers in case we just got an update on space relationships
|
||||
// FIXME: do this only when needed
|
||||
let mut buffers = Vec::new();
|
||||
//self.children.clear();
|
||||
//self.parents.clear();
|
||||
self.attached_to_parent.clear();
|
||||
std::mem::swap(&mut self.buffers, &mut buffers);
|
||||
for buf in buffers.into_iter() {
|
||||
self.push(buf);
|
||||
let reorder_future = async {
|
||||
match next_reorder {
|
||||
Some(next_reorder) => tokio::time::sleep(next_reorder - Instant::now()).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
|
||||
select! {
|
||||
res = updates_future.next() => {
|
||||
res.expect("poll_updates_once reached the end of the never-ending stream");
|
||||
false
|
||||
},
|
||||
_ = reorder_future => {
|
||||
true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if reorder_now {
|
||||
// Reorder buffers in case we just got an update on space relationships
|
||||
// FIXME: do this only when needed
|
||||
let mut buffers = Vec::new();
|
||||
//self.children.clear();
|
||||
//self.parents.clear();
|
||||
self.attached_to_parent.clear();
|
||||
std::mem::swap(&mut self.buffers, &mut buffers);
|
||||
for buf in buffers.into_iter() {
|
||||
self.push(buf);
|
||||
}
|
||||
self.next_reorder = None;
|
||||
} else {
|
||||
// We got an update, schedule a reorder for later
|
||||
self.next_reorder = match self.next_reorder {
|
||||
None => Some(Instant::now() + UPDATE_INTERVAL),
|
||||
Some(next_reorder) => Some(next_reorder), // Keep the existing deadline
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,24 +383,40 @@ impl Buffers {
|
||||
}
|
||||
|
||||
pub fn active_index(&self) -> usize {
|
||||
self.active_index
|
||||
self
|
||||
.buffers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, buf)| buf.id() == self.active_buffer)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn set_active_index(&mut self, index: isize) {
|
||||
self.active_index = index.wrapping_rem_euclid(self.buffers.len() as isize) as usize;
|
||||
self.active_buffer =
|
||||
self.buffers[index.wrapping_rem_euclid(self.buffers.len() as isize) as usize].id();
|
||||
}
|
||||
|
||||
pub fn set_active_id(&mut self, id: BufferId) {
|
||||
self.active_buffer = id
|
||||
}
|
||||
|
||||
pub fn active_buffer(&self) -> &dyn Buffer {
|
||||
&**self
|
||||
.buffers
|
||||
.get(self.active_index)
|
||||
.expect("Active buffer index does not exist")
|
||||
.iter()
|
||||
.find(|buf| buf.id() == self.active_buffer)
|
||||
.unwrap_or(self.buffers.first().expect("No buffers"))
|
||||
}
|
||||
|
||||
pub fn active_buffer_mut(&mut self) -> &mut Box<dyn Buffer> {
|
||||
self
|
||||
match self
|
||||
.buffers
|
||||
.get_mut(self.active_index)
|
||||
.expect("Active buffer index does not exist")
|
||||
.iter()
|
||||
.position(|buf| buf.id() == self.active_buffer)
|
||||
{
|
||||
Some(i) => self.buffers.get_mut(i).expect("Active buffer disappeared"),
|
||||
None => self.buffers.first_mut().expect("No buffers"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,15 +7,6 @@ use crate::utils::version;
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version = version(), about)]
|
||||
pub struct Cli {
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
value_name = "FLOAT",
|
||||
help = "Tick rate, i.e. number of ticks per second",
|
||||
default_value_t = 1.0
|
||||
)]
|
||||
pub tick_rate: f64,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
|
@ -32,19 +32,6 @@ pub trait Component {
|
||||
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
/// Register a configuration handler that provides configuration settings if necessary.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Configuration settings.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<()>` - An Ok result or an error.
|
||||
#[allow(unused_variables)]
|
||||
fn register_config_handler(&mut self, config: Config) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
/// Initialize the component with a specified area if necessary.
|
||||
///
|
||||
/// # Arguments
|
||||
|
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::eyre::{Result, WrapErr};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
@ -22,17 +23,37 @@ use enum_dispatch::enum_dispatch;
|
||||
use ratatui::{prelude::*, widgets::*};
|
||||
|
||||
use crate::components::Action;
|
||||
use crate::config::{Config, ScrollAmount};
|
||||
use crate::widgets::prerender::{PrerenderInner, PrerenderValue};
|
||||
use crate::widgets::{
|
||||
BacklogItemWidget, BottomAlignedParagraph, Divider, EmptyWidget, OverlappableWidget,
|
||||
BacklogItemWidget, BottomAlignedContainer, BottomAlignedParagraph, Divider, EmptyWidget,
|
||||
OverlappableWidget,
|
||||
};
|
||||
|
||||
use super::Component;
|
||||
use crate::buffers::{BufferItem, BufferItemContent};
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Debug)]
|
||||
struct ScrollPosition {
|
||||
/// unique_id of the buffer item the scroll is relative to
|
||||
anchor: u64,
|
||||
/// number of lines from the top of the item where the bottom of the viewport is
|
||||
relative_scroll: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Backlog {
|
||||
scroll: u64,
|
||||
config: Arc<Config>,
|
||||
/// Used to compute scroll on PageUp/PageDown when configured to a percentage.
|
||||
last_height: u16,
|
||||
scroll_position: Option<ScrollPosition>,
|
||||
/// Fallback used if the scroll_position is missing or unusable
|
||||
absolute_scroll: u64,
|
||||
/// How many lines up (or down) the user requested the viewport to move since the last
|
||||
/// render.
|
||||
///
|
||||
/// This is applied to `scroll_position` and `fallback_scroll` on the next render.
|
||||
pending_scroll: i64,
|
||||
}
|
||||
|
||||
impl Component for Backlog {
|
||||
@ -43,16 +64,22 @@ impl Component for Backlog {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
} => {
|
||||
self.scroll_up(20); // TODO: use the component height
|
||||
} => match self.config.keyboard.scroll_page {
|
||||
ScrollAmount::Absolute(n) => self.scroll_up(n.into()),
|
||||
ScrollAmount::Percentage(n) => {
|
||||
self.scroll_up(u64::from(n) * u64::from(self.last_height) / 100)
|
||||
},
|
||||
},
|
||||
KeyEvent {
|
||||
code: KeyCode::PageDown,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
state: _,
|
||||
} => {
|
||||
self.scroll_down(20) // TODO: use the component height
|
||||
} => match self.config.keyboard.scroll_page {
|
||||
ScrollAmount::Absolute(n) => self.scroll_down(n.into()),
|
||||
ScrollAmount::Percentage(n) => {
|
||||
self.scroll_down(u64::from(n) * u64::from(self.last_height) / 100)
|
||||
},
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
@ -67,13 +94,15 @@ impl Component for Backlog {
|
||||
buffers: &crate::buffers::Buffers,
|
||||
) -> Result<()> {
|
||||
let active_buffer = buffers.active_buffer();
|
||||
let items = active_buffer.content();
|
||||
let undrawn_widgets_at_top = self.draw_items(frame.buffer_mut(), area, items)?;
|
||||
let undrawn_widgets_at_top =
|
||||
self.draw_items(frame.buffer_mut(), area, || active_buffer.content())?;
|
||||
|
||||
// We are reaching the end of the backlog we have locally, ask the buffer to fetch
|
||||
// more if it can
|
||||
if undrawn_widgets_at_top <= 100 {
|
||||
active_buffer.request_back_pagination(200);
|
||||
if undrawn_widgets_at_top <= self.config.history.min_prefetch
|
||||
&& self.config.history.prefetch > 0
|
||||
{
|
||||
active_buffer.request_back_pagination(self.config.history.prefetch);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -81,16 +110,36 @@ impl Component for Backlog {
|
||||
}
|
||||
|
||||
impl Backlog {
|
||||
pub fn new(config: Arc<Config>) -> Self {
|
||||
Backlog {
|
||||
config,
|
||||
last_height: 30, // Arbitrary default, only useful when user scrolls before first render
|
||||
scroll_position: None,
|
||||
absolute_scroll: 0,
|
||||
pending_scroll: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self, lines: u64) {
|
||||
self.scroll = self.scroll.saturating_add(lines);
|
||||
self.pending_scroll = self.pending_scroll.saturating_add(lines as i64);
|
||||
}
|
||||
pub fn scroll_down(&mut self, lines: u64) {
|
||||
self.scroll = self.scroll.saturating_sub(20);
|
||||
self.pending_scroll = self.pending_scroll.saturating_sub(lines as i64);
|
||||
}
|
||||
|
||||
fn build_widget<'a>(&self, content: BufferItemContent<'a>, scroll: u64) -> BacklogItemWidget<'a> {
|
||||
match content {
|
||||
BufferItemContent::Text(text) => BottomAlignedParagraph::new(text).scroll(scroll).into(),
|
||||
BufferItemContent::SimpleText(text) => {
|
||||
BottomAlignedParagraph::new(text).scroll(scroll).into()
|
||||
},
|
||||
BufferItemContent::Text(text) => BottomAlignedContainer::new(
|
||||
text
|
||||
.into_iter()
|
||||
.map(|(padding, text)| (padding, BottomAlignedParagraph::new(text)))
|
||||
.collect(),
|
||||
)
|
||||
.scroll(scroll)
|
||||
.into(),
|
||||
BufferItemContent::Divider(text) => {
|
||||
if scroll == 0 {
|
||||
Divider::new(Paragraph::new(text).alignment(Alignment::Center)).into()
|
||||
@ -102,52 +151,97 @@ impl Backlog {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_item_height(&self, item: &BufferItem<'_>, width: u16) -> u64 {
|
||||
match item.prerender.0.lock().unwrap().deref_mut() {
|
||||
Some(PrerenderInner {
|
||||
key,
|
||||
value: PrerenderValue::Rendered(buf),
|
||||
}) if *key == width => buf.area().height as u64,
|
||||
Some(PrerenderInner {
|
||||
key,
|
||||
value: PrerenderValue::NotRendered(height),
|
||||
}) if *key == width => *height,
|
||||
prerender => {
|
||||
let widget = self.build_widget(item.content.clone(), 0);
|
||||
// widget.height() needs to run the whole word-wrapping, which is almost as
|
||||
// expensive as the real render.
|
||||
// This is particularly wasteful, as the last widget.height() call here will
|
||||
// duplicate the work we do in widget.render_overlap() later in the loop.
|
||||
// Unfortunately I can't find a way to make it work because of the lifetimes
|
||||
// involved.
|
||||
let expected_height = widget.height(width);
|
||||
*prerender = Some(PrerenderInner {
|
||||
key: width,
|
||||
value: PrerenderValue::NotRendered(expected_height),
|
||||
});
|
||||
expected_height
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns how many items were not drawn because they are too high up the backlog
|
||||
/// (ie. older than the currently displayed items)
|
||||
pub fn draw_items<'a>(
|
||||
pub fn draw_items<'a, Items: ExactSizeIterator<Item = BufferItem<'a>>>(
|
||||
&mut self,
|
||||
frame_buffer: &mut Buffer,
|
||||
area: Rect,
|
||||
mut items: impl ExactSizeIterator<Item = BufferItem<'a>>,
|
||||
get_items: impl Fn() -> Items,
|
||||
) -> Result<usize> {
|
||||
let block = Block::new().borders(Borders::ALL);
|
||||
let mut text_area = block.inner(area);
|
||||
block.render(area, frame_buffer);
|
||||
self.last_height = text_area.height;
|
||||
|
||||
let mut scroll = self.scroll;
|
||||
// Recompute absolute scroll position if we are not at the bottom of the backlog
|
||||
if self.absolute_scroll != 0 || self.pending_scroll != 0 {
|
||||
if let Some(scroll_position) = self.scroll_position.as_ref() {
|
||||
if !get_items().any(|item| item.unique_id == Some(scroll_position.anchor)) {
|
||||
// The anchor doesn't exist anymore, invalidate it
|
||||
self.scroll_position = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(scroll_position) = self.scroll_position.as_ref() {
|
||||
// Compute the current absolute scroll from the anchor.
|
||||
let mut found_anchor = false;
|
||||
self.absolute_scroll = 0;
|
||||
for item in get_items() {
|
||||
self.absolute_scroll = self
|
||||
.absolute_scroll
|
||||
.saturating_add(self.get_item_height(&item, text_area.width));
|
||||
if item.unique_id == Some(scroll_position.anchor) {
|
||||
found_anchor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_anchor, "anchor disappeared between get_items() calls");
|
||||
self.absolute_scroll = self
|
||||
.absolute_scroll
|
||||
.saturating_add_signed(-scroll_position.relative_scroll);
|
||||
}
|
||||
|
||||
if self.pending_scroll != 0 {
|
||||
self.absolute_scroll = self
|
||||
.absolute_scroll
|
||||
.saturating_add_signed(self.pending_scroll);
|
||||
self.scroll_position = None;
|
||||
self.pending_scroll = 0;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(self.pending_scroll, 0, "pending_scroll was not applied");
|
||||
|
||||
let mut items = get_items();
|
||||
let mut scroll = self.absolute_scroll;
|
||||
|
||||
// Skip widgets at the bottom (if scrolled up), and render the first visible one
|
||||
loop {
|
||||
let Some(item) = items.next() else {
|
||||
break;
|
||||
};
|
||||
let expected_height = match item.prerender.0.lock().unwrap().deref_mut() {
|
||||
Some(PrerenderInner {
|
||||
key,
|
||||
value: PrerenderValue::Rendered(buf),
|
||||
}) if *key == text_area.width => buf.area().height as u64,
|
||||
Some(PrerenderInner {
|
||||
key,
|
||||
value: PrerenderValue::NotRendered(height),
|
||||
}) if *key == text_area.width => *height,
|
||||
prerender => {
|
||||
let widget = self.build_widget(item.content.clone(), 0);
|
||||
// widget.height() needs to run the whole word-wrapping, which is almost as
|
||||
// expensive as the real render.
|
||||
// This is particularly wasteful, as the last widget.height() call here will
|
||||
// duplicate the work we do in widget.render_overlap() later in the loop.
|
||||
// Unfortunately I can't find a way to make it work because of the lifetimes
|
||||
// involved.
|
||||
let expected_height = widget.height(text_area.width);
|
||||
*prerender = Some(PrerenderInner {
|
||||
key: text_area.width,
|
||||
value: PrerenderValue::NotRendered(expected_height),
|
||||
});
|
||||
expected_height
|
||||
},
|
||||
};
|
||||
let expected_height = self.get_item_height(&item, text_area.width);
|
||||
|
||||
if scroll.saturating_sub(expected_height) > text_area.height.into() {
|
||||
if scroll.saturating_sub(expected_height) > u64::from(text_area.height) {
|
||||
// Paragraph is too far down, not displayed
|
||||
scroll -= expected_height;
|
||||
continue;
|
||||
@ -158,6 +252,18 @@ impl Backlog {
|
||||
let (_, height) = widget.render_overlap(text_area, frame_buffer);
|
||||
text_area.height = text_area.height.saturating_sub(height);
|
||||
|
||||
if self.absolute_scroll != 0 && expected_height >= scroll {
|
||||
// If we are not at the bottom of the backlog and this is the first item up enough
|
||||
// to be visible, set it as anchor for next render
|
||||
if let Some(anchor) = item.unique_id {
|
||||
self.scroll_position = Some(ScrollPosition {
|
||||
anchor,
|
||||
relative_scroll: (expected_height - scroll) as i64, // legal because always positive
|
||||
});
|
||||
}
|
||||
// TODO: if item.unique_id is None, pick the next item that has an id
|
||||
}
|
||||
|
||||
scroll = scroll.saturating_sub(expected_height);
|
||||
if scroll == 0 {
|
||||
break;
|
||||
|
@ -14,7 +14,10 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::eyre::{Result, WrapErr};
|
||||
use crossterm::event::{MouseEvent, MouseEventKind};
|
||||
use ratatui::{prelude::*, widgets::*};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::sync::OnceCell;
|
||||
@ -22,17 +25,62 @@ use tokio::sync::OnceCell;
|
||||
use super::Component;
|
||||
use crate::{
|
||||
action::Action,
|
||||
buffers::{Buffer, Buffers, FullyReadStatus},
|
||||
config::{Config, KeyBindings},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Buflist {
|
||||
command_tx: Option<UnboundedSender<Action>>,
|
||||
config: OnceCell<Config>,
|
||||
config: Arc<Config>,
|
||||
|
||||
/// Updated by [`draw`], used by [`handle_mouse_events`] to find which buffer was clicked.
|
||||
last_area: Option<Rect>,
|
||||
last_num_buffers: Option<usize>,
|
||||
}
|
||||
impl Buflist {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
pub fn new(config: Arc<Config>) -> Self {
|
||||
Buflist {
|
||||
command_tx: None,
|
||||
config,
|
||||
last_area: None,
|
||||
last_num_buffers: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num_columns(&mut self, area: Rect, buffers: &Buffers) -> u16 {
|
||||
let borders_height = 2;
|
||||
buffers
|
||||
.len()
|
||||
.div_ceil((area.height - borders_height).into())
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX)
|
||||
}
|
||||
|
||||
pub fn borders_width(&self) -> u16 {
|
||||
2
|
||||
}
|
||||
|
||||
pub fn get_room_name_style(&self, buf: &dyn Buffer) -> Style {
|
||||
use matrix_sdk::sync::UnreadNotificationsCount;
|
||||
let config = &self.config.style.buflist;
|
||||
match buf.unread_notification_counts() {
|
||||
UnreadNotificationsCount {
|
||||
highlight_count: 0,
|
||||
notification_count: 0,
|
||||
} => match buf.fully_read() {
|
||||
FullyReadStatus::Not => *config.unread_message,
|
||||
FullyReadStatus::OnlyMessages => *config.unread_event,
|
||||
FullyReadStatus::All => *config.uneventful,
|
||||
},
|
||||
UnreadNotificationsCount {
|
||||
highlight_count: 0,
|
||||
notification_count: _,
|
||||
} => *config.notification,
|
||||
UnreadNotificationsCount {
|
||||
highlight_count: _,
|
||||
notification_count: _,
|
||||
} => *config.highlight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,11 +90,56 @@ impl Component for Buflist {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_config_handler(&mut self, config: Config) -> Result<()> {
|
||||
self
|
||||
.config
|
||||
.set(config)
|
||||
.context("Buflist config was already set")
|
||||
fn init(&mut self, area: Rect) -> Result<()> {
|
||||
self.last_area = Some(area);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
|
||||
match mouse {
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Up(_),
|
||||
column: x,
|
||||
row: y,
|
||||
modifiers: _,
|
||||
} => {
|
||||
let Some(last_area) = self.last_area else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !last_area.intersects(Rect {
|
||||
x,
|
||||
y,
|
||||
height: 1,
|
||||
width: 1,
|
||||
}) {
|
||||
// Clicked outside the buflist (or on the border)
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(last_num_buffers) = self.last_num_buffers else {
|
||||
return Ok(None);
|
||||
};
|
||||
let column = (x - last_area.x) / self.config.layout.buflist.column_width;
|
||||
let mut buffer_index: isize =
|
||||
(column as isize) * (last_area.height as isize) + ((y as isize) - (last_area.y as isize));
|
||||
let buffer_index_usize: usize = buffer_index.try_into().expect("negative buffer_index");
|
||||
if buffer_index_usize >= last_num_buffers {
|
||||
if self.config.layout.buflist.penultimate_right_overflow {
|
||||
// Clicked on the overflow of the last-but-one column
|
||||
buffer_index -= last_area.height as isize;
|
||||
} else {
|
||||
// Clicked past the last buffer
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let buffer_index_usize: usize = buffer_index.try_into().expect("negative buffer_index");
|
||||
if buffer_index_usize >= last_num_buffers {
|
||||
tracing::error!("[BUG] Unexpected click past the last buffer");
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Action::ToBuffer(buffer_index)))
|
||||
},
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, action: &Action) -> Result<Option<Action>> {
|
||||
@ -59,49 +152,90 @@ impl Component for Buflist {
|
||||
area: Rect,
|
||||
buffers: &crate::buffers::Buffers,
|
||||
) -> Result<()> {
|
||||
let block = Block::new().borders(Borders::ALL);
|
||||
let inner_area = block.inner(area);
|
||||
block.render(area, frame.buffer_mut());
|
||||
|
||||
let area = inner_area;
|
||||
self.last_area = Some(area);
|
||||
self.last_num_buffers = Some(buffers.len());
|
||||
|
||||
let num_digits = u32::min(5, buffers.len().ilog10() + 1) as usize;
|
||||
let right_pad = " ".repeat(area.width.into());
|
||||
let mut stack = Vec::new(); // List of parent buffers of the current one
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
buffers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, buf)| {
|
||||
match buffers
|
||||
.parents()
|
||||
.get(&buf.id())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|parent| stack.iter().position(|id| *id == *parent))
|
||||
.max() // if in multiple spaces, the last one is most likely to matter
|
||||
{
|
||||
Some(parent_position) => stack.truncate(parent_position + 1),
|
||||
None => stack.clear(),
|
||||
}
|
||||
stack.push(buf.id());
|
||||
let buf_number = format!("{}.", i + 1);
|
||||
let mut base_style = Style::default();
|
||||
if i == buffers.active_index() {
|
||||
base_style = base_style.on_blue();
|
||||
}
|
||||
let tree_pad = " ".repeat(stack.len() - 1);
|
||||
let buf_number_style = base_style.green();
|
||||
let left_pad = " ".repeat((num_digits + 1).saturating_sub(buf_number.len()));
|
||||
let mut buffers_iter = buffers.iter().enumerate();
|
||||
let max_columns = u16::min(
|
||||
self.config.layout.buflist.max_columns,
|
||||
area.width.div_ceil(self.config.layout.buflist.column_width),
|
||||
);
|
||||
let column_width = self.config.layout.buflist.column_width;
|
||||
for col in 0..max_columns {
|
||||
let x = area.y + col * column_width;
|
||||
let allow_overflow = if col == max_columns.saturating_sub(2) {
|
||||
self.config.layout.buflist.penultimate_right_overflow
|
||||
} else if col == max_columns - 1 {
|
||||
// Always allow the last column to overflow
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
for y in area.top()..area.bottom() {
|
||||
let Some((i, buf)) = buffers_iter.next() else {
|
||||
return Ok(());
|
||||
};
|
||||
match buffers
|
||||
.parents()
|
||||
.get(&buf.id())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|parent| stack.iter().position(|id| *id == *parent))
|
||||
.max() // if in multiple spaces, the last one is most likely to matter
|
||||
{
|
||||
Some(parent_position) => stack.truncate(parent_position + 1),
|
||||
None => stack.clear(),
|
||||
}
|
||||
stack.push(buf.id());
|
||||
let buf_number = format!("{}.", i + 1);
|
||||
let mut base_style = Style::default();
|
||||
let mut name_style = self.get_room_name_style(buf);
|
||||
if i == buffers.active_index() {
|
||||
base_style = base_style.on_blue();
|
||||
name_style = name_style.on_blue();
|
||||
} else {
|
||||
base_style = base_style.bg(Color::Reset);
|
||||
name_style = name_style.bg(Color::Reset);
|
||||
}
|
||||
let tree_pad = " ".repeat(stack.len() - 1);
|
||||
let buf_number_style = base_style.green();
|
||||
let left_pad = " ".repeat((num_digits + 1).saturating_sub(buf_number.len()));
|
||||
frame.render_widget(
|
||||
Paragraph::new::<Line<'_>>(
|
||||
vec![
|
||||
Span::styled(left_pad, base_style),
|
||||
Span::styled(buf_number, buf_number_style),
|
||||
Span::styled(tree_pad, base_style),
|
||||
Span::styled(buf.short_name(), base_style),
|
||||
Span::styled(buf.short_name(), name_style),
|
||||
Span::styled(right_pad.clone(), base_style),
|
||||
]
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<Line<'_>>>(),
|
||||
)
|
||||
.block(Block::new().borders(Borders::ALL)),
|
||||
area,
|
||||
);
|
||||
.into(),
|
||||
),
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width: if allow_overflow && y as usize > buffers.len() % (area.height as usize) {
|
||||
area.width
|
||||
} else {
|
||||
column_width
|
||||
},
|
||||
height: 2,
|
||||
}
|
||||
.intersection(area),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Do something with the remaining buffers, if any. eg. print "XX more buffers"
|
||||
// at the end or display a scrollbar
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -8,10 +8,6 @@ use crate::action::Action;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FpsCounter {
|
||||
app_start_time: Instant,
|
||||
app_frames: u32,
|
||||
app_fps: f64,
|
||||
|
||||
render_start_time: Instant,
|
||||
render_frames: u32,
|
||||
render_fps: f64,
|
||||
@ -26,27 +22,12 @@ impl Default for FpsCounter {
|
||||
impl FpsCounter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app_start_time: Instant::now(),
|
||||
app_frames: 0,
|
||||
app_fps: 0.0,
|
||||
render_start_time: Instant::now(),
|
||||
render_frames: 0,
|
||||
render_fps: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn app_tick(&mut self) -> Result<()> {
|
||||
self.app_frames += 1;
|
||||
let now = Instant::now();
|
||||
let elapsed = (now - self.app_start_time).as_secs_f64();
|
||||
if elapsed >= 1.0 {
|
||||
self.app_fps = self.app_frames as f64 / elapsed;
|
||||
self.app_start_time = now;
|
||||
self.app_frames = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_tick(&mut self) -> Result<()> {
|
||||
self.render_frames += 1;
|
||||
let now = Instant::now();
|
||||
@ -62,9 +43,6 @@ impl FpsCounter {
|
||||
|
||||
impl Component for FpsCounter {
|
||||
fn update(&mut self, action: &Action) -> Result<Option<Action>> {
|
||||
if let Action::Tick = action {
|
||||
self.app_tick()?
|
||||
};
|
||||
if let Action::Render = action {
|
||||
self.render_tick()?
|
||||
};
|
||||
@ -87,10 +65,7 @@ impl Component for FpsCounter {
|
||||
|
||||
let rect = rects[0];
|
||||
|
||||
let s = format!(
|
||||
"{:.2} ticks per sec (app) {:.2} frames per sec (render)",
|
||||
self.app_fps, self.render_fps
|
||||
);
|
||||
let s = format!("{:.2} frames per sec (render)", self.render_fps);
|
||||
let block = Block::default().title(block::Title::from(s.dim()).alignment(Alignment::Right));
|
||||
f.render_widget(block, rect);
|
||||
Ok(())
|
||||
|
@ -14,6 +14,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use color_eyre::eyre::{Result, WrapErr};
|
||||
@ -30,18 +31,23 @@ use crate::{
|
||||
config::{Config, KeyBindings},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Home {
|
||||
command_tx: Option<UnboundedSender<Action>>,
|
||||
config: OnceCell<Config>,
|
||||
config: Arc<Config>,
|
||||
buflist: Buflist,
|
||||
backlog: Backlog,
|
||||
textarea: TextArea<'static>,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
pub fn new() -> Self {
|
||||
let mut self_ = Self::default();
|
||||
pub fn new(config: Arc<Config>) -> Self {
|
||||
let mut self_ = Home {
|
||||
command_tx: None,
|
||||
buflist: Buflist::new(config.clone()),
|
||||
backlog: Backlog::new(config.clone()),
|
||||
textarea: TextArea::default(),
|
||||
config,
|
||||
};
|
||||
self_.configure_textarea();
|
||||
self_
|
||||
}
|
||||
@ -61,15 +67,6 @@ impl Component for Home {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_config_handler(&mut self, config: Config) -> Result<()> {
|
||||
self.buflist.register_config_handler(config.clone())?;
|
||||
self
|
||||
.config
|
||||
.set(config)
|
||||
.context("Home config was already set")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
|
||||
match key {
|
||||
KeyEvent {
|
||||
@ -109,6 +106,10 @@ impl Component for Home {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse_events(&mut self, mouse: crossterm::event::MouseEvent) -> Result<Option<Action>> {
|
||||
self.buflist.handle_mouse_events(mouse)
|
||||
}
|
||||
|
||||
fn update(&mut self, action: &Action) -> Result<Option<Action>> {
|
||||
self.buflist.update(action)?;
|
||||
Ok(None)
|
||||
@ -122,7 +123,17 @@ impl Component for Home {
|
||||
) -> Result<()> {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(vec![Constraint::Percentage(20), Constraint::Percentage(80)])
|
||||
.constraints(vec![
|
||||
Constraint::Length(
|
||||
self.config.layout.buflist.column_width
|
||||
* u16::min(
|
||||
self.buflist.num_columns(area, buffers),
|
||||
self.config.layout.buflist.max_columns,
|
||||
)
|
||||
+ self.buflist.borders_width(),
|
||||
),
|
||||
Constraint::Min(self.config.layout.backlog.min_width),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
self
|
||||
|
160
src/config.rs
160
src/config.rs
@ -1,5 +1,6 @@
|
||||
use std::{collections::HashMap, fmt, path::PathBuf};
|
||||
|
||||
use bounded_integer::BoundedU8;
|
||||
use color_eyre::eyre::Result;
|
||||
use config::Value;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
@ -13,6 +14,8 @@ use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::{action::Action, mode::Mode};
|
||||
|
||||
const DEFAULT_CONFIG: &str = include_str!("default_config.toml");
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
@ -38,24 +41,148 @@ fn default_device_name() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ScrollAmount {
|
||||
Percentage(BoundedU8<1, 100>),
|
||||
Absolute(u16),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ScrollAmount {
|
||||
fn deserialize<D>(deserializer: D) -> Result<ScrollAmount, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(ScrollAmountVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
struct ScrollAmountVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for ScrollAmountVisitor {
|
||||
type Value = ScrollAmount;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a positive integer or a percentage")
|
||||
}
|
||||
|
||||
fn visit_u16<E>(self, value: u16) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
Ok(ScrollAmount::Absolute(value))
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
let s: String = s.chars().filter(|c| !c.is_whitespace()).collect(); // strip whitespaces
|
||||
match s.strip_suffix('%') {
|
||||
Some(percent_str) => match percent_str.parse() {
|
||||
Ok(percent) => {
|
||||
if 0 < percent && percent <= 100 {
|
||||
Ok(ScrollAmount::Percentage(percent))
|
||||
} else {
|
||||
Err(E::invalid_value(
|
||||
de::Unexpected::Unsigned(percent.into()),
|
||||
&"integer between 1 and 100 (inclusive)",
|
||||
))
|
||||
}
|
||||
},
|
||||
Err(_) => Err(E::invalid_value(
|
||||
de::Unexpected::Other(percent_str),
|
||||
&"integer between 1 and 100 (inclusive)",
|
||||
)),
|
||||
},
|
||||
None => Err(E::invalid_value(
|
||||
de::Unexpected::Str(&s),
|
||||
&"integer or quoted percentage (ending with '%')",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct HistoryConfig {
|
||||
pub prefetch_unread: u16,
|
||||
pub max_prefetch_unread: usize,
|
||||
pub min_prefetch: usize,
|
||||
pub prefetch: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MouseConfig {
|
||||
pub enable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct KeyboardConfig {
|
||||
pub scroll_page: ScrollAmount,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct BuflistLayoutConfig {
|
||||
pub column_width: u16,
|
||||
pub max_columns: u16,
|
||||
pub penultimate_right_overflow: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct BacklogLayoutConfig {
|
||||
pub min_width: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LayoutConfig {
|
||||
pub buflist: BuflistLayoutConfig,
|
||||
pub backlog: BacklogLayoutConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct BuflistStylesConfig {
|
||||
pub highlight: StyleConfig,
|
||||
pub notification: StyleConfig,
|
||||
pub unread_message: StyleConfig,
|
||||
pub unread_event: StyleConfig,
|
||||
pub uneventful: StyleConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct UserStylesConfig {
|
||||
pub rotation: Vec<StyleConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct StylesConfig {
|
||||
pub buflist: BuflistStylesConfig,
|
||||
pub users: UserStylesConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default, flatten)]
|
||||
pub config: AppConfig,
|
||||
pub accounts: nonempty::NonEmpty<AccountConfig>,
|
||||
pub history: HistoryConfig,
|
||||
#[serde(default)]
|
||||
pub keybindings: KeyBindings,
|
||||
#[serde(default)]
|
||||
pub styles: Styles,
|
||||
pub keyboard: KeyboardConfig,
|
||||
pub mouse: MouseConfig,
|
||||
pub style: StylesConfig,
|
||||
pub layout: LayoutConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new() -> Result<Self, config::ConfigError> {
|
||||
pub fn new() -> Result<Self> {
|
||||
let data_dir = crate::utils::get_data_dir();
|
||||
let config_dir = crate::utils::get_config_dir();
|
||||
let mut builder = config::Config::builder()
|
||||
.set_default("_data_dir", data_dir.to_str().unwrap())?
|
||||
.set_default("_config_dir", config_dir.to_str().unwrap())?;
|
||||
.set_default("_config_dir", config_dir.to_str().unwrap())?
|
||||
.add_source(config::File::from_str(
|
||||
DEFAULT_CONFIG,
|
||||
config::FileFormat::Toml,
|
||||
));
|
||||
|
||||
let config_files = [
|
||||
("config.json5", config::FileFormat::Json5),
|
||||
@ -79,7 +206,7 @@ impl Config {
|
||||
log::error!("No configuration file found. Application may not behave as expected");
|
||||
}
|
||||
|
||||
builder.build()?.try_deserialize()
|
||||
Ok(serde_path_to_error::deserialize(builder.build()?)?)
|
||||
}
|
||||
}
|
||||
|
||||
@ -279,28 +406,17 @@ pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> {
|
||||
sequences.into_iter().map(parse_key_event).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deref, DerefMut)]
|
||||
pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
|
||||
#[derive(Clone, Debug, Deref)]
|
||||
pub struct StyleConfig(pub Style);
|
||||
|
||||
impl<'de> Deserialize<'de> for Styles {
|
||||
impl<'de> Deserialize<'de> for StyleConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
|
||||
|
||||
let styles = parsed_map
|
||||
.into_iter()
|
||||
.map(|(mode, inner_map)| {
|
||||
let converted_inner_map = inner_map
|
||||
.into_iter()
|
||||
.map(|(str, style)| (str, parse_style(&style)))
|
||||
.collect();
|
||||
(mode, converted_inner_map)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Styles(styles))
|
||||
Ok(StyleConfig(parse_style(&String::deserialize(
|
||||
deserializer,
|
||||
)?)))
|
||||
}
|
||||
}
|
||||
|
||||
|
80
src/default_config.toml
Normal file
80
src/default_config.toml
Normal file
@ -0,0 +1,80 @@
|
||||
# Key bindings in the main "Home" mode
|
||||
[keybindings.Home]
|
||||
"<Ctrl-d>" = "/quit"
|
||||
"<Ctrl-c>" = "/quit"
|
||||
"<Ctrl-z>" = "/suspend"
|
||||
"<Alt-left>" = "/previous"
|
||||
"<Alt-right>" = "/next"
|
||||
|
||||
[keyboard]
|
||||
# How much to scroll when pressing PageUp/PageDown. This can be either a number of lines
|
||||
# eg. 42) or a percentage wrapped in quotes (eg. "100%") of the screen size.
|
||||
scroll_page = "50%"
|
||||
|
||||
[mouse]
|
||||
enable = true
|
||||
|
||||
[history]
|
||||
# When there are unread events in a room, how many past events should be fetched
|
||||
# at once to check whether there are unread messages (as opposed to non-message
|
||||
# events), to find if the room should colored with style.buflist.unread_message or
|
||||
# style.buflist.unread_event
|
||||
prefetch_unread = 50
|
||||
# When do stop iteratively fetching pages of size $prefetch_unread when no unread
|
||||
# messages are found
|
||||
max_prefetch_unread = 1000
|
||||
|
||||
# How many older events to keep above the screen in case the user hits PageUp later.
|
||||
# Set to 0 to only fetch when the user reaches an unfetched message.
|
||||
min_prefetch = 100
|
||||
# When getting close to the oldest event we know about, how many events should be
|
||||
# fetched at once
|
||||
prefetch = 200
|
||||
|
||||
# Configuration for the list of rooms on the right.
|
||||
[layout.buflist]
|
||||
# How long room names can be before being truncated.
|
||||
column_width = 30
|
||||
# Maximum number of columns allowed. Rooms which do not fit will not be visible.
|
||||
# If the screen is not large enough, fewer columns may be displayed.
|
||||
max_columns = 3
|
||||
# If the last column has unused space at the end, allows the last-but-one column to use
|
||||
# it when its own room names are too long
|
||||
penultimate_right_overflow = true
|
||||
|
||||
[layout.backlog]
|
||||
# Minimum width of the main chat history area and text input.
|
||||
# This takes precedence over the buflist settings.
|
||||
min_width = 30
|
||||
|
||||
# Style of rooms in the buflist
|
||||
[style.buflist]
|
||||
# When your name is mentioned
|
||||
highlight = "bold yellow"
|
||||
# On other types of notifications
|
||||
notification = "bold green"
|
||||
# When no notification, but there is a new message
|
||||
unread_message = "red"
|
||||
# When no new message, but something changed (someone joined, left, topic changed, ...)
|
||||
unread_event = "bold"
|
||||
# If nothing happened since you last looked at the room
|
||||
uneventful = ""
|
||||
|
||||
# Style of usernames in chat logs
|
||||
[style.users]
|
||||
# Colors that can be assigned to users' names, picked semi-randomly.
|
||||
# Set an empty array to disable name coloration
|
||||
rotation = [
|
||||
"cyan",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"magenta",
|
||||
"default",
|
||||
"bold cyan",
|
||||
"bold red",
|
||||
"bold green",
|
||||
"bold blue",
|
||||
"bold magenta",
|
||||
"bold default",
|
||||
]
|
336
src/html/mod.rs
Normal file
336
src/html/mod.rs
Normal file
@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Valentin Lorentz
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::rc::Rc;
|
||||
|
||||
use html5ever::driver::parse_fragment;
|
||||
use html5ever::interface::QualName;
|
||||
use html5ever::tendril::TendrilSink;
|
||||
use html5ever::{local_name, namespace_url, ns, Attribute};
|
||||
use markup5ever_rcdom::{Handle, Node, NodeData, RcDom};
|
||||
use ratatui::style::{Color, Style, Stylize};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub fn escape_html<S: ?Sized + AsRef<str>>(s: &S) -> Cow<'_, str> {
|
||||
html_escape::encode_text_minimal(s)
|
||||
}
|
||||
|
||||
pub fn markup_colored_by_mxid<M: ?Sized + AsRef<str>, C: ?Sized + AsRef<str>>(
|
||||
mxid: &M,
|
||||
content: &C,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"<font data-ratatrix-colored-by-mxid="{}">{}</font>"#,
|
||||
html_escape::encode_double_quoted_attribute(mxid),
|
||||
content.as_ref()
|
||||
)
|
||||
}
|
||||
|
||||
fn count_digits(n: u16) -> u8 {
|
||||
f32::log10(n.into()).floor() as u8 + 1
|
||||
}
|
||||
|
||||
fn get_color_by_mxid(config: &Config, mxid: &str) -> Option<Style> {
|
||||
let rotation = &config.style.users.rotation;
|
||||
if rotation.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
mxid.hash(&mut hasher);
|
||||
let color_id: usize = (hasher.finish() & (usize::MAX as u64)) as usize;
|
||||
Some(*rotation[color_id % rotation.len()])
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex_color(s: &str) -> Option<Color> {
|
||||
// Should we implement a workaround for https://github.com/ratatui-org/ratatui/issues/475
|
||||
// here, or wait for Crossterm to fix it?
|
||||
let s = s.strip_prefix('#')?;
|
||||
let a: [u8; 3] = hex::FromHex::from_hex(s).ok()?;
|
||||
let a = [0, a[0], a[1], a[2]];
|
||||
let n = u32::from_be_bytes(a);
|
||||
Some(Color::Rgb(
|
||||
(n >> 16) as u8,
|
||||
((n & 0xFF00) >> 8) as u8,
|
||||
(n & 0xFF) as u8,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum ListState {
|
||||
#[default]
|
||||
None,
|
||||
Unordered,
|
||||
Ordered {
|
||||
counter: u16,
|
||||
digits: u8,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FormatState {
|
||||
style: Style,
|
||||
padding: String,
|
||||
list_state: ListState,
|
||||
}
|
||||
|
||||
fn format_tree(
|
||||
config: &Config,
|
||||
tree: Rc<Node>,
|
||||
state: &mut FormatState,
|
||||
text: &mut Vec<(String, Text<'static>)>,
|
||||
mut previous_sibling_is_block: bool,
|
||||
) -> bool {
|
||||
let mut state = match &tree.data {
|
||||
NodeData::Document
|
||||
| NodeData::Doctype { .. }
|
||||
| NodeData::Comment { .. }
|
||||
| NodeData::ProcessingInstruction { .. } => state.to_owned(),
|
||||
NodeData::Text { contents } => {
|
||||
let s: String = contents.clone().into_inner().into();
|
||||
let s = s.replace('\n', ""); // Lines are insignificant in HTML
|
||||
if previous_sibling_is_block && !s.is_empty() {
|
||||
text.push((state.padding.clone(), Text::raw("")));
|
||||
previous_sibling_is_block = false;
|
||||
}
|
||||
text
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.1
|
||||
.lines
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.spans
|
||||
.push(Span::styled(s, state.style));
|
||||
state.to_owned()
|
||||
},
|
||||
NodeData::Element {
|
||||
name: QualName {
|
||||
ns: ns!(html),
|
||||
local: name,
|
||||
..
|
||||
},
|
||||
attrs,
|
||||
..
|
||||
} => match *name {
|
||||
local_name!("font") | local_name!("span") => {
|
||||
let mut state = state.to_owned();
|
||||
for attr in attrs.borrow().iter() {
|
||||
match attr {
|
||||
Attribute {
|
||||
name:
|
||||
QualName {
|
||||
ns: ns!(html),
|
||||
local: name,
|
||||
..
|
||||
},
|
||||
value,
|
||||
}
|
||||
| Attribute {
|
||||
name:
|
||||
QualName {
|
||||
ns: ns!(),
|
||||
local: name,
|
||||
..
|
||||
},
|
||||
value,
|
||||
} => match name.as_ref() {
|
||||
"data-mx-color" => {
|
||||
if let Some(color) = parse_hex_color(value.as_ref()) {
|
||||
state.style.fg = Some(color);
|
||||
}
|
||||
},
|
||||
"data-mx-bg-color" => {
|
||||
if let Some(color) = parse_hex_color(value.as_ref()) {
|
||||
state.style.bg = Some(color);
|
||||
}
|
||||
},
|
||||
"data-ratatrix-colored-by-mxid" => {
|
||||
if let Some(style) = get_color_by_mxid(config, value.as_ref()) {
|
||||
state.style = state.style.patch(style);
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
state
|
||||
},
|
||||
local_name!("br") => {
|
||||
text.push((state.padding.clone(), Text::raw("")));
|
||||
state.to_owned()
|
||||
},
|
||||
local_name!("p") => {
|
||||
previous_sibling_is_block = true;
|
||||
state.to_owned()
|
||||
},
|
||||
local_name!("blockquote") => {
|
||||
previous_sibling_is_block = true;
|
||||
FormatState {
|
||||
padding: state.padding.to_owned() + "> ",
|
||||
..state.to_owned()
|
||||
}
|
||||
},
|
||||
local_name!("ul") => {
|
||||
previous_sibling_is_block = true;
|
||||
FormatState {
|
||||
list_state: ListState::Unordered,
|
||||
..state.to_owned()
|
||||
}
|
||||
},
|
||||
local_name!("ol") => {
|
||||
previous_sibling_is_block = true;
|
||||
// FIXME: <li> are not guaranteed to be direct children, are they?
|
||||
let items_count: u16 = tree
|
||||
.children
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|child| match &child.data {
|
||||
NodeData::Element {
|
||||
name:
|
||||
QualName {
|
||||
ns: ns!(html),
|
||||
local: local_name!("li"),
|
||||
..
|
||||
},
|
||||
..
|
||||
} => 1,
|
||||
_ => 0,
|
||||
})
|
||||
.sum();
|
||||
FormatState {
|
||||
list_state: ListState::Ordered {
|
||||
counter: 0,
|
||||
digits: count_digits(items_count),
|
||||
},
|
||||
..state.to_owned()
|
||||
}
|
||||
},
|
||||
local_name!("li") => {
|
||||
previous_sibling_is_block = false;
|
||||
match state.list_state {
|
||||
ListState::None => state.to_owned(),
|
||||
ListState::Unordered => {
|
||||
let mut line = Line::default();
|
||||
line.spans.push(Span::styled("* ", state.style));
|
||||
text.push((state.padding.to_owned(), line.into()));
|
||||
FormatState {
|
||||
padding: state.padding.to_owned() + " ",
|
||||
..state.to_owned()
|
||||
}
|
||||
},
|
||||
ListState::Ordered {
|
||||
ref mut counter,
|
||||
digits,
|
||||
} => {
|
||||
let mut line = Line::default();
|
||||
*counter += 1;
|
||||
line
|
||||
.spans
|
||||
.push(Span::styled(format!("{}. ", counter), state.style));
|
||||
if count_digits(*counter) != digits {
|
||||
line.spans.push(Span::styled(
|
||||
" ".repeat((digits - count_digits(*counter)).into()),
|
||||
state.style,
|
||||
));
|
||||
}
|
||||
text.push((state.padding.to_owned(), line.into()));
|
||||
FormatState {
|
||||
padding: state.padding.to_owned() + " " + &" ".repeat(digits.into()),
|
||||
..state.to_owned()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
local_name!("em") | local_name!("i") => FormatState {
|
||||
style: state.style.italic(),
|
||||
..state.to_owned()
|
||||
},
|
||||
local_name!("strong") | local_name!("b") => FormatState {
|
||||
style: state.style.bold(),
|
||||
..state.to_owned()
|
||||
},
|
||||
local_name!("u") => FormatState {
|
||||
style: state.style.underlined(),
|
||||
..state.to_owned()
|
||||
},
|
||||
_ => state.to_owned(),
|
||||
},
|
||||
NodeData::Element { .. } => state.to_owned(), // Element not in the HTML namespace
|
||||
};
|
||||
|
||||
for subtree in tree.children.borrow().iter() {
|
||||
previous_sibling_is_block = format_tree(
|
||||
config,
|
||||
subtree.clone(),
|
||||
&mut state,
|
||||
text,
|
||||
previous_sibling_is_block,
|
||||
);
|
||||
}
|
||||
|
||||
match &tree.data {
|
||||
NodeData::Document
|
||||
| NodeData::Doctype { .. }
|
||||
| NodeData::Comment { .. }
|
||||
| NodeData::ProcessingInstruction { .. } => {},
|
||||
NodeData::Text { .. } => {},
|
||||
NodeData::Element {
|
||||
name: QualName {
|
||||
ns: ns!(html),
|
||||
local: name,
|
||||
..
|
||||
},
|
||||
attrs,
|
||||
..
|
||||
} => match name.as_ref() {
|
||||
"p" | "blockquote" => {
|
||||
previous_sibling_is_block = true;
|
||||
},
|
||||
_ => {},
|
||||
},
|
||||
NodeData::Element { .. } => {},
|
||||
}
|
||||
|
||||
previous_sibling_is_block
|
||||
}
|
||||
|
||||
/// Returns a list of pairs `(padding, text)`.
|
||||
///
|
||||
/// When rendering, the padding should be added left of every line of the text.
|
||||
pub fn format_html(config: &Config, prefix: &'static str, s: &str) -> Vec<(String, Text<'static>)> {
|
||||
let tree = parse_fragment(
|
||||
RcDom::default(),
|
||||
Default::default(),
|
||||
QualName::new(None, ns!(html), local_name!("body")),
|
||||
Vec::new(),
|
||||
)
|
||||
.one(s)
|
||||
.document;
|
||||
let prefix = Text::raw(prefix);
|
||||
let mut state = FormatState {
|
||||
padding: " ".repeat(prefix.width() + 1), // TODO: make +1 configurable
|
||||
..Default::default()
|
||||
};
|
||||
let mut text = vec![("".to_owned(), prefix)];
|
||||
format_tree(config, tree, &mut state, &mut text, false);
|
||||
text
|
||||
}
|
@ -9,6 +9,7 @@ pub mod cli;
|
||||
pub mod commands;
|
||||
pub mod components;
|
||||
pub mod config;
|
||||
pub mod html;
|
||||
pub mod log;
|
||||
pub mod mode;
|
||||
pub mod plugins;
|
||||
|
@ -9,7 +9,7 @@ async fn tokio_main() -> Result<()> {
|
||||
initialize_panic_handler()?;
|
||||
|
||||
let args = Cli::parse();
|
||||
let mut app = App::new(args.tick_rate, args.frame_rate, mem_log).await?;
|
||||
let mut app = App::new(args.frame_rate, mem_log).await?;
|
||||
app.run().await?;
|
||||
|
||||
Ok(())
|
||||
|
17
src/tui.rs
17
src/tui.rs
@ -32,7 +32,6 @@ pub enum Event {
|
||||
Quit,
|
||||
Error,
|
||||
Closed,
|
||||
Tick,
|
||||
Render,
|
||||
FocusGained,
|
||||
FocusLost,
|
||||
@ -49,14 +48,12 @@ pub struct Tui {
|
||||
pub event_rx: UnboundedReceiver<Event>,
|
||||
pub event_tx: UnboundedSender<Event>,
|
||||
pub frame_rate: f64,
|
||||
pub tick_rate: f64,
|
||||
pub mouse: bool,
|
||||
pub paste: bool,
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
pub fn new() -> Result<Self> {
|
||||
let tick_rate = 4.0;
|
||||
let frame_rate = 60.0;
|
||||
let terminal = ratatui::Terminal::new(Backend::new(io()))?;
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
@ -71,17 +68,11 @@ impl Tui {
|
||||
event_rx,
|
||||
event_tx,
|
||||
frame_rate,
|
||||
tick_rate,
|
||||
mouse,
|
||||
paste,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tick_rate(mut self, tick_rate: f64) -> Self {
|
||||
self.tick_rate = tick_rate;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
|
||||
self.frame_rate = frame_rate;
|
||||
self
|
||||
@ -98,7 +89,6 @@ impl Tui {
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
|
||||
let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate);
|
||||
self.cancel();
|
||||
self.cancellation_token = CancellationToken::new();
|
||||
@ -106,12 +96,10 @@ impl Tui {
|
||||
let _event_tx = self.event_tx.clone();
|
||||
self.task = tokio::spawn(async move {
|
||||
let mut reader = crossterm::event::EventStream::new();
|
||||
let mut tick_interval = tokio::time::interval(tick_delay);
|
||||
let mut render_interval = tokio::time::interval(render_delay);
|
||||
render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
_event_tx.send(Event::Init).unwrap();
|
||||
loop {
|
||||
let tick_delay = tick_interval.tick();
|
||||
let render_delay = render_interval.tick();
|
||||
let crossterm_event = reader.next().fuse();
|
||||
tokio::select! {
|
||||
@ -150,9 +138,6 @@ impl Tui {
|
||||
None => {},
|
||||
}
|
||||
},
|
||||
_ = tick_delay => {
|
||||
_event_tx.send(Event::Tick).unwrap();
|
||||
},
|
||||
_ = render_delay => {
|
||||
_event_tx.send(Event::Render).unwrap();
|
||||
},
|
||||
|
133
src/widgets/bottom_aligned_container.rs
Normal file
133
src/widgets/bottom_aligned_container.rs
Normal file
@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Valentin Lorentz
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Paragraph, Widget};
|
||||
|
||||
use super::{BottomAlignedParagraph, OverlappableWidget};
|
||||
|
||||
/// Container of multiple [`BottomAlignedParagraph`]
|
||||
#[derive(Debug)]
|
||||
pub struct BottomAlignedContainer<'a> {
|
||||
/// Pairs of `(padding, content)`
|
||||
paragraphs: Vec<(String, BottomAlignedParagraph<'a>)>,
|
||||
/// Number of lines at the bottom that should not be rendered
|
||||
scroll: u64,
|
||||
}
|
||||
|
||||
impl<'a> BottomAlignedContainer<'a> {
|
||||
pub fn new(paragraphs: Vec<(String, BottomAlignedParagraph<'a>)>) -> BottomAlignedContainer<'a> {
|
||||
BottomAlignedContainer {
|
||||
paragraphs,
|
||||
scroll: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many lines should be skipped at the bottom
|
||||
///
|
||||
/// This is like [`Paragraph::scroll`](ratatui::widgets::Paragraph::scroll), but it's only vertical.
|
||||
pub fn scroll(mut self, offset: u64) -> BottomAlignedContainer<'a> {
|
||||
self.scroll = offset;
|
||||
self
|
||||
}
|
||||
|
||||
fn padding_width(padding: &Text<'_>, max_width: u16) -> u16 {
|
||||
usize::min(
|
||||
(max_width - 10).into(), // TODO: make the minimum content width (10) configurable
|
||||
padding.width(),
|
||||
) as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> OverlappableWidget for BottomAlignedContainer<'a> {
|
||||
fn height(&self, width: u16) -> u64 {
|
||||
self
|
||||
.paragraphs
|
||||
.iter()
|
||||
.map(|(padding, paragraph)| {
|
||||
paragraph.height(width - Self::padding_width(&Line::raw(padding).into(), width))
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn render_overlap(self, mut area: Rect, buf: &mut Buffer) -> (u16, u16) {
|
||||
if area.height == 0 {
|
||||
// Don't even bother
|
||||
return (0, 0);
|
||||
}
|
||||
let mut scroll = self.scroll;
|
||||
|
||||
let mut actual_width = 0u16;
|
||||
let mut actual_height = 0usize;
|
||||
|
||||
for (padding, paragraph) in self.paragraphs.into_iter().rev() {
|
||||
let padding: Text<'_> = Line::raw(&padding).into();
|
||||
let padding_width = Self::padding_width(&padding, area.width);
|
||||
assert_eq!(
|
||||
padding.height(),
|
||||
1,
|
||||
"Unexpected padding height: {} (padding={:?})",
|
||||
padding.height(),
|
||||
padding
|
||||
);
|
||||
|
||||
// FIXME: paragraph.height() is expensive because it needs to run line-wrapping,
|
||||
// and paragraph.render_overlap then needs to run it again twice.
|
||||
let paragraph_height = paragraph.height(area.width - padding_width);
|
||||
if paragraph_height <= scroll {
|
||||
// Paragraph is under the viewport, don't render it
|
||||
scroll -= paragraph_height;
|
||||
} else {
|
||||
let paragraph = paragraph.scroll(scroll);
|
||||
let (actual_paragraph_width, actual_paragraph_height) = paragraph.render_overlap(
|
||||
Rect {
|
||||
x: area.x + padding_width,
|
||||
width: area.width - padding_width,
|
||||
..area
|
||||
},
|
||||
buf,
|
||||
);
|
||||
scroll = 0;
|
||||
|
||||
// Write the padding on each line the paragraph was rendered on
|
||||
for y in (u16::max(
|
||||
area.top(),
|
||||
area.bottom().saturating_sub(actual_paragraph_height),
|
||||
))..area.bottom()
|
||||
{
|
||||
Paragraph::new(padding.clone()).render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
height: 1,
|
||||
width: padding_width,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
area.height = area.height.saturating_sub(actual_paragraph_height);
|
||||
actual_height = actual_height.saturating_add(actual_paragraph_height.into());
|
||||
actual_width = u16::max(actual_width, padding_width + actual_paragraph_width);
|
||||
if area.height == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(actual_width, actual_height as u16)
|
||||
}
|
||||
}
|
@ -18,6 +18,8 @@ use enum_dispatch::enum_dispatch;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
mod bottom_aligned_container;
|
||||
pub use bottom_aligned_container::BottomAlignedContainer;
|
||||
mod bottom_aligned_paragraph;
|
||||
pub use bottom_aligned_paragraph::BottomAlignedParagraph;
|
||||
|
||||
@ -47,6 +49,7 @@ pub trait OverlappableWidget {
|
||||
#[enum_dispatch(OverlappableWidget)]
|
||||
pub enum BacklogItemWidget<'a> {
|
||||
Paragraph(BottomAlignedParagraph<'a>),
|
||||
Container(BottomAlignedContainer<'a>),
|
||||
Divider(Divider<'a>),
|
||||
Empty(EmptyWidget),
|
||||
}
|
||||
|
@ -14,14 +14,25 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::prelude::*;
|
||||
|
||||
use ratatrix::buffers::{BufferItem, BufferItemContent};
|
||||
use ratatrix::components::Backlog;
|
||||
use ratatrix::config::Config;
|
||||
use ratatrix::widgets::Prerender;
|
||||
|
||||
fn config() -> Arc<Config> {
|
||||
std::env::set_var("RATATRIX_CONFIG", PathBuf::from(".config/"));
|
||||
let c = Config::new();
|
||||
std::env::remove_var("RATATRIX_CONFIG");
|
||||
Arc::new(c.unwrap())
|
||||
}
|
||||
|
||||
fn rect(x: u16, y: u16, width: u16, height: u16) -> Rect {
|
||||
Rect {
|
||||
x,
|
||||
@ -31,16 +42,34 @@ fn rect(x: u16, y: u16, width: u16, height: u16) -> Rect {
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! items_iter {
|
||||
[ $($item: expr),* ] => {
|
||||
|| vec![
|
||||
$(
|
||||
{
|
||||
let BufferItem { content, prerender, unique_id } = &$item;
|
||||
BufferItem {
|
||||
content: content.clone(),
|
||||
prerender,
|
||||
unique_id: *unique_id,
|
||||
}
|
||||
},
|
||||
)*
|
||||
].into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_item() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender = Prerender::new();
|
||||
let item = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hello")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hello")),
|
||||
prerender: &prerender,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 18, 8));
|
||||
bl.draw_items(&mut buf, rect(3, 2, 12, 4), vec![item].into_iter())
|
||||
bl.draw_items(&mut buf, rect(3, 2, 12, 4), items_iter![item])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -59,15 +88,16 @@ fn test_single_item() {
|
||||
|
||||
#[test]
|
||||
fn test_single_item_cached() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender = Prerender::new();
|
||||
let item = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hello")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hello")),
|
||||
prerender: &prerender,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 18, 8));
|
||||
let area = rect(3, 2, 12, 4);
|
||||
bl.draw_items(&mut buf, area, vec![item].into_iter())
|
||||
bl.draw_items(&mut buf, area, items_iter![item])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -86,11 +116,12 @@ fn test_single_item_cached() {
|
||||
assert_eq!(prerender.key(), Some(10));
|
||||
|
||||
let item = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hello")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hello")),
|
||||
prerender: &prerender,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 18, 8));
|
||||
bl.draw_items(&mut buf, area, vec![item].into_iter())
|
||||
bl.draw_items(&mut buf, area, items_iter![item])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
assert_eq!(buf, expected);
|
||||
@ -99,22 +130,24 @@ fn test_single_item_cached() {
|
||||
/// Checks that the prerender cache does not store empty columns to the right
|
||||
#[test]
|
||||
fn test_only_necessary_width() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let prerender2 = Prerender::new();
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi\nworld")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi\nworld")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw(":)")),
|
||||
content: BufferItemContent::SimpleText(Text::raw(":)")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut cell = ratatui::buffer::Cell::default();
|
||||
cell.set_char('.');
|
||||
let mut buf = Buffer::filled(rect(0, 0, 18, 7), &cell); // poisoned buffer
|
||||
let area = rect(3, 1, 12, 5);
|
||||
bl.draw_items(&mut buf, area, vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, area, items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -132,15 +165,17 @@ fn test_only_necessary_width() {
|
||||
assert_eq!(prerender1.key(), Some(10));
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi\nworld")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi\nworld")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw(":)")),
|
||||
content: BufferItemContent::SimpleText(Text::raw(":)")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 18, 7));
|
||||
bl.draw_items(&mut buf, area, vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, area, items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![
|
||||
@ -157,14 +192,15 @@ fn test_only_necessary_width() {
|
||||
|
||||
#[test]
|
||||
fn test_single_item_tight() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender = Prerender::new();
|
||||
let item = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hello")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hello")),
|
||||
prerender: &prerender,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 13, 7));
|
||||
bl.draw_items(&mut buf, rect(3, 2, 7, 3), vec![item].into_iter())
|
||||
bl.draw_items(&mut buf, rect(3, 2, 7, 3), items_iter![item])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -182,19 +218,21 @@ fn test_single_item_tight() {
|
||||
|
||||
#[test]
|
||||
fn test_two_items() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let prerender2 = Prerender::new();
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("world")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("world")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -212,20 +250,22 @@ fn test_two_items() {
|
||||
|
||||
#[test]
|
||||
fn test_two_items_scroll() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let prerender2 = Prerender::new();
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: Some(123),
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("world")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("world")),
|
||||
prerender: &prerender2,
|
||||
unique_id: Some(456),
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -243,15 +283,17 @@ fn test_two_items_scroll() {
|
||||
bl.scroll_up(1);
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: Some(123),
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("world")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("world")),
|
||||
prerender: &prerender2,
|
||||
unique_id: Some(456),
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -269,15 +311,17 @@ fn test_two_items_scroll() {
|
||||
bl.scroll_up(1);
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: Some(123),
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("world")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("world")),
|
||||
prerender: &prerender2,
|
||||
unique_id: Some(456),
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -295,19 +339,21 @@ fn test_two_items_scroll() {
|
||||
|
||||
#[test]
|
||||
fn test_two_items_multiline() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let prerender2 = Prerender::new();
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("world\n!")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("world\n!")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -325,19 +371,21 @@ fn test_two_items_multiline() {
|
||||
|
||||
#[test]
|
||||
fn test_two_items_tight() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let prerender2 = Prerender::new();
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("world")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("world")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 9, 6));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 7, 4), vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 7, 4), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
|
||||
@ -354,16 +402,17 @@ fn test_two_items_tight() {
|
||||
|
||||
#[test]
|
||||
fn test_cache_moved() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
|
||||
// Draw once
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), vec![item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![
|
||||
@ -379,16 +428,18 @@ fn test_cache_moved() {
|
||||
|
||||
// New item added at bottom
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("hi")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let prerender2 = Prerender::new();
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("world")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("world")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), vec![item2, item1].into_iter())
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![
|
||||
@ -405,28 +456,31 @@ fn test_cache_moved() {
|
||||
|
||||
#[test]
|
||||
fn test_overflow_and_scroll() {
|
||||
let mut bl = Backlog::default();
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let prerender2 = Prerender::new();
|
||||
let prerender3 = Prerender::new();
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line1 x")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line1 x")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let item3 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line5 z")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line5 z")),
|
||||
prerender: &prerender3,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(
|
||||
&mut buf,
|
||||
rect(1, 1, 12, 5),
|
||||
vec![item3, item2, item1].into_iter(),
|
||||
items_iter![item3, item2, item1],
|
||||
)
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
@ -444,22 +498,25 @@ fn test_overflow_and_scroll() {
|
||||
bl.scroll_up(1);
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line1 x")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line1 x")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let item3 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line5 z")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line5 z")),
|
||||
prerender: &prerender3,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(
|
||||
&mut buf,
|
||||
rect(1, 1, 12, 5),
|
||||
vec![item3, item2, item1].into_iter(),
|
||||
items_iter![item3, item2, item1],
|
||||
)
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
@ -477,22 +534,25 @@ fn test_overflow_and_scroll() {
|
||||
bl.scroll_up(1);
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line1 x")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line1 x")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let item3 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line5 z")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line5 z")),
|
||||
prerender: &prerender3,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(
|
||||
&mut buf,
|
||||
rect(1, 1, 12, 5),
|
||||
vec![item3, item2, item1].into_iter(),
|
||||
items_iter![item3, item2, item1],
|
||||
)
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
@ -509,22 +569,25 @@ fn test_overflow_and_scroll() {
|
||||
bl.scroll_up(1);
|
||||
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line1 x")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line1 x")),
|
||||
prerender: &prerender1,
|
||||
unique_id: None,
|
||||
};
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line2 y\nline3 y\nline4 y")),
|
||||
prerender: &prerender2,
|
||||
unique_id: None,
|
||||
};
|
||||
let item3 = BufferItem {
|
||||
content: BufferItemContent::Text(Text::raw("line5 z")),
|
||||
content: BufferItemContent::SimpleText(Text::raw("line5 z")),
|
||||
prerender: &prerender3,
|
||||
unique_id: None,
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(
|
||||
&mut buf,
|
||||
rect(1, 1, 12, 5),
|
||||
vec![item3, item2, item1].into_iter(),
|
||||
items_iter![item3, item2, item1],
|
||||
)
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
@ -539,3 +602,79 @@ fn test_overflow_and_scroll() {
|
||||
]);
|
||||
assert_eq!(buf, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrolledup_new_line() {
|
||||
let mut bl = Backlog::new(config());
|
||||
let prerender1 = Prerender::new();
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi\nworld")),
|
||||
prerender: &prerender1,
|
||||
unique_id: Some(123),
|
||||
};
|
||||
|
||||
// Draw once
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ┌──────────┐ ",
|
||||
" │ │ ",
|
||||
" │hi │ ",
|
||||
" │world │ ",
|
||||
" └──────────┘ ",
|
||||
" ",
|
||||
]);
|
||||
assert_eq!(buf, expected);
|
||||
|
||||
// Scroll up one line
|
||||
bl.scroll_up(1);
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi\nworld")),
|
||||
prerender: &prerender1,
|
||||
unique_id: Some(123),
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ┌──────────┐ ",
|
||||
" │ │ ",
|
||||
" │ │ ",
|
||||
" │hi │ ",
|
||||
" └──────────┘ ",
|
||||
" ",
|
||||
]);
|
||||
assert_eq!(buf, expected);
|
||||
|
||||
// New item added at bottom, displayed paragraph should not move up
|
||||
let item1 = BufferItem {
|
||||
content: BufferItemContent::SimpleText(Text::raw("hi\nworld")),
|
||||
prerender: &prerender1,
|
||||
unique_id: Some(123),
|
||||
};
|
||||
let prerender2 = Prerender::new();
|
||||
let item2 = BufferItem {
|
||||
content: BufferItemContent::SimpleText(Text::raw("!")),
|
||||
prerender: &prerender2,
|
||||
unique_id: Some(456),
|
||||
};
|
||||
let mut buf = Buffer::empty(rect(0, 0, 14, 7));
|
||||
bl.draw_items(&mut buf, rect(1, 1, 12, 5), items_iter![item2, item1])
|
||||
.context("Failed to draw")
|
||||
.unwrap();
|
||||
let expected = Buffer::with_lines(vec![
|
||||
" ",
|
||||
" ┌──────────┐ ",
|
||||
" │ │ ",
|
||||
" │ │ ",
|
||||
" │hi │ ",
|
||||
" └──────────┘ ",
|
||||
" ",
|
||||
]);
|
||||
assert_eq!(buf, expected);
|
||||
}
|
||||
|
Reference in New Issue
Block a user