├── .gitignore ├── rumatui-tui ├── .gitignore ├── tests │ ├── size.rs │ ├── chart.rs │ ├── block.rs │ ├── gauge.rs │ └── paragraph.rs ├── Cargo.toml ├── src │ ├── widgets │ │ ├── canvas │ │ │ ├── points.rs │ │ │ ├── map.rs │ │ │ ├── rectangle.rs │ │ │ ├── line.rs │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── scroll.rs │ │ ├── tabs.rs │ │ ├── gauge.rs │ │ ├── sparkline.rs │ │ ├── barchart.rs │ │ ├── block.rs │ │ ├── list.rs │ │ └── paragraph.rs │ ├── backend │ │ ├── mod.rs │ │ ├── test.rs │ │ ├── rustbox.rs │ │ ├── crossterm.rs │ │ ├── termion.rs │ │ └── curses.rs │ ├── style.rs │ ├── symbols.rs │ ├── lib.rs │ └── terminal.rs ├── LICENSE └── README.md ├── .github └── FUNDING.yml ├── resources ├── rumatui.gif ├── small_logo.gif ├── small_logo.png ├── rumatui-login.gif ├── rumatui_logo.jpg └── rumatui-notice.gif ├── src ├── widgets │ ├── message │ │ └── mod.rs │ ├── mod.rs │ ├── error.rs │ ├── utils.rs │ ├── register.rs │ ├── login.rs │ └── room_search.rs ├── config.rs ├── client │ └── ruma_ext │ │ ├── reaction.rs │ │ ├── message.rs │ │ ├── auth.rs │ │ └── mod.rs ├── log.rs ├── ui_loop.rs ├── error.rs └── main.rs ├── .travis.yml ├── test_data ├── reaction.json └── message_edit.json ├── LICENSE-MIT.txt ├── Cargo.toml ├── README.md ├── CHANGELOG.md └── LICENSE-APACHE.txt /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | syncdata 3 | .vscode 4 | flamegraph.svg 5 | perf.* -------------------------------------------------------------------------------- /rumatui-tui/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | *.log 4 | *.rs.rustfmt 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: DevinR528 4 | -------------------------------------------------------------------------------- /resources/rumatui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevinR528/rumatui/HEAD/resources/rumatui.gif -------------------------------------------------------------------------------- /resources/small_logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevinR528/rumatui/HEAD/resources/small_logo.gif -------------------------------------------------------------------------------- /resources/small_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevinR528/rumatui/HEAD/resources/small_logo.png -------------------------------------------------------------------------------- /resources/rumatui-login.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevinR528/rumatui/HEAD/resources/rumatui-login.gif -------------------------------------------------------------------------------- /resources/rumatui_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevinR528/rumatui/HEAD/resources/rumatui_logo.jpg -------------------------------------------------------------------------------- /resources/rumatui-notice.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DevinR528/rumatui/HEAD/resources/rumatui-notice.gif -------------------------------------------------------------------------------- /src/widgets/message/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod ctrl_char; 2 | pub mod msgs; 3 | 4 | pub use msgs::{Message, MessageWidget}; 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | sudo: false 3 | rust: 4 | - stable 5 | - nightly 6 | matrix: 7 | allow_failures: 8 | - rust: nightly 9 | fast_finish: true 10 | script: 11 | - cargo test --all 12 | -------------------------------------------------------------------------------- /rumatui-tui/tests/size.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::backend::{Backend, TestBackend}; 2 | use rumatui_tui::Terminal; 3 | 4 | #[test] 5 | fn buffer_size_limited() { 6 | let backend = TestBackend::new(400, 400); 7 | let terminal = Terminal::new(backend).unwrap(); 8 | let size = terminal.backend().size().unwrap(); 9 | assert_eq!(size.width, 255); 10 | assert_eq!(size.height, 255); 11 | } 12 | -------------------------------------------------------------------------------- /test_data/reaction.json: -------------------------------------------------------------------------------- 1 | { 2 | "content": { 3 | "m.relates_to": { 4 | "event_id": "$MDit176PkuBlpP7S6c64iuf74KC2HqZ3peV1NrV4PKA", 5 | "key": "👍", 6 | "rel_type": "m.annotation" 7 | } 8 | }, 9 | "event_id": "$QZn9xEx72PUfd2tAGFH-FFgsffZlVMobk47Tl5Lpdtg", 10 | "origin_server_ts": 1590275813161, 11 | "sender": "@devinr528:matrix.org", 12 | "type": "m.reaction", 13 | "unsigned": { 14 | "age": 85 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use tokio::fs as async_fs; 3 | 4 | use crate::error::Result; 5 | 6 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 7 | pub struct Configs { 8 | device_id: String, 9 | db_version: usize, 10 | } 11 | 12 | impl Configs { 13 | pub(crate) async fn load() -> Result { 14 | let mut path = crate::RUMATUI_DIR.as_ref().unwrap().to_path_buf(); 15 | path.push(".configs.json"); 16 | 17 | let json = async_fs::read_to_string(path).await?; 18 | serde_json::from_str(&json).map_err(Into::into) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /rumatui-tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rumatui-tui" 3 | version = "0.8.2" 4 | authors = ["Florian Dehau "] 5 | description = """ 6 | A library to build rich terminal user interfaces or dashboards 7 | """ 8 | keywords = ["tui", "terminal", "dashboard"] 9 | repository = "https://github.com/fdehau/tui-rs" 10 | license = "MIT" 11 | edition = "2018" 12 | 13 | [features] 14 | default = ["termion"] 15 | 16 | [dependencies] 17 | bitflags = "1.0" 18 | cassowary = "0.3" 19 | itertools = "0.9" 20 | either = "1.5" 21 | unicode-segmentation = "1.2" 22 | unicode-width = "0.1" 23 | termion = { version = "1.5", optional = true } 24 | 25 | [dev-dependencies] 26 | rand = "0.7" 27 | argh = "0.1" 28 | -------------------------------------------------------------------------------- /test_data/message_edit.json: -------------------------------------------------------------------------------- 1 | { 2 | "content": { 3 | "body": " * f fjkdslasdf $$$$$$$$$$$$$$$$$$$$$$$$$$$$", 4 | "m.new_content": { 5 | "body": "f fjkdslasdf $$$$$$$$$$$$$$$$$$$$$$$$$$$$", 6 | "msgtype": "m.text" 7 | }, 8 | "m.relates_to": { 9 | "event_id": "$MbS0nMfvub-CPbytp7KRmExAp3oVfdjWOvf2ifG1zWI", 10 | "rel_type": "m.replace" 11 | }, 12 | "msgtype": "m.text" 13 | }, 14 | "event_id": "$xXL9cVB_10jkpxUFTsubeusygV0yv5b_63ADjgiQnOA", 15 | "origin_server_ts": 1590262659984, 16 | "sender": "@devinr528:matrix.org", 17 | "type": "m.room.message", 18 | "unsigned": { 19 | "age": 85 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use rumatui_tui::{backend::Backend, layout::Rect, Frame, Terminal}; 4 | 5 | pub mod app; 6 | pub mod chat; 7 | mod error; 8 | pub mod login; 9 | pub mod message; 10 | pub mod register; 11 | pub mod room_search; 12 | pub mod rooms; 13 | pub mod utils; 14 | 15 | pub trait RenderWidget { 16 | fn render(&mut self, f: &mut Frame, area: Rect) 17 | where 18 | B: Backend; 19 | } 20 | 21 | pub trait DrawWidget { 22 | fn draw(&mut self, terminal: &mut Terminal) -> io::Result<()> 23 | where 24 | B: Backend + Send; 25 | fn draw_with(&mut self, _terminal: &mut Terminal, _area: Rect) -> io::Result<()> 26 | where 27 | B: Backend, 28 | { 29 | Ok(()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/client/ruma_ext/reaction.rs: -------------------------------------------------------------------------------- 1 | use matrix_sdk::identifiers::EventId; 2 | 3 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 4 | #[serde(tag = "rel_type")] 5 | pub enum ReactionEventContent { 6 | #[serde(rename = "m.annotation")] 7 | Annotation { 8 | /// The event this reaction relates to. 9 | event_id: EventId, 10 | /// The displayable content of the reaction. 11 | key: String, 12 | }, 13 | } 14 | 15 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 16 | pub struct ExtraReactionEventContent { 17 | /// The actual event content is nested within the field with an event type as it's 18 | /// JSON field name "m.relates_to". 19 | #[serde(rename = "m.relates_to")] 20 | pub relates_to: ReactionEventContent, 21 | } 22 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/canvas/points.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | style::Color, 3 | widgets::canvas::{Painter, Shape}, 4 | }; 5 | 6 | /// A shape to draw a group of points with the given color 7 | #[derive(Debug, Clone)] 8 | pub struct Points<'a> { 9 | pub coords: &'a [(f64, f64)], 10 | pub color: Color, 11 | } 12 | 13 | impl<'a> Shape for Points<'a> { 14 | fn draw(&self, painter: &mut Painter) { 15 | for (x, y) in self.coords { 16 | if let Some((x, y)) = painter.get_point(*x, *y) { 17 | painter.paint(x, y, self.color); 18 | } 19 | } 20 | } 21 | } 22 | 23 | impl<'a> Default for Points<'a> { 24 | fn default() -> Points<'a> { 25 | Points { 26 | coords: &[], 27 | color: Color::Reset, 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, io, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use tracing_appender::{ 7 | non_blocking, 8 | non_blocking::{NonBlocking, WorkerGuard}, 9 | }; 10 | 11 | #[derive(Clone, Debug)] 12 | pub struct LogWriter { 13 | path: PathBuf, 14 | } 15 | 16 | impl LogWriter { 17 | pub fn spawn_logger>(path: P) -> (NonBlocking, WorkerGuard) { 18 | let log = LogWriter { 19 | path: path.as_ref().to_path_buf(), 20 | }; 21 | non_blocking(log) 22 | } 23 | } 24 | 25 | impl io::Write for LogWriter { 26 | fn write(&mut self, buf: &[u8]) -> io::Result { 27 | let mut file = fs::OpenOptions::new() 28 | .append(true) 29 | .create(true) 30 | .open(&self.path)?; 31 | 32 | file.write_all(buf).map(|_| buf.len()) 33 | } 34 | 35 | fn flush(&mut self) -> io::Result<()> { 36 | Ok(()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE-MIT.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 2 | 3 | Devin Ragotzy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rumatui-tui/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Florian Dehau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/canvas/map.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | style::Color, 3 | widgets::canvas::{ 4 | world::{WORLD_HIGH_RESOLUTION, WORLD_LOW_RESOLUTION}, 5 | Painter, Shape, 6 | }, 7 | }; 8 | 9 | #[derive(Debug, Clone, Copy)] 10 | pub enum MapResolution { 11 | Low, 12 | High, 13 | } 14 | 15 | impl MapResolution { 16 | fn data(self) -> &'static [(f64, f64)] { 17 | match self { 18 | MapResolution::Low => &WORLD_LOW_RESOLUTION, 19 | MapResolution::High => &WORLD_HIGH_RESOLUTION, 20 | } 21 | } 22 | } 23 | 24 | /// Shape to draw a world map with the given resolution and color 25 | #[derive(Debug, Clone)] 26 | pub struct Map { 27 | pub resolution: MapResolution, 28 | pub color: Color, 29 | } 30 | 31 | impl Default for Map { 32 | fn default() -> Map { 33 | Map { 34 | resolution: MapResolution::Low, 35 | color: Color::Reset, 36 | } 37 | } 38 | } 39 | 40 | impl Shape for Map { 41 | fn draw(&self, painter: &mut Painter) { 42 | for (x, y) in self.resolution.data() { 43 | if let Some((x, y)) = painter.get_point(*x, *y) { 44 | painter.paint(x, y, self.color); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /rumatui-tui/src/backend/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crate::buffer::Cell; 4 | use crate::layout::Rect; 5 | 6 | #[cfg(feature = "rustbox")] 7 | mod rustbox; 8 | #[cfg(feature = "rustbox")] 9 | pub use self::rustbox::RustboxBackend; 10 | 11 | #[cfg(feature = "termion")] 12 | mod termion; 13 | #[cfg(feature = "termion")] 14 | pub use self::termion::TermionBackend; 15 | 16 | #[cfg(feature = "crossterm")] 17 | mod crossterm; 18 | #[cfg(feature = "crossterm")] 19 | pub use self::crossterm::CrosstermBackend; 20 | 21 | #[cfg(feature = "curses")] 22 | mod curses; 23 | #[cfg(feature = "curses")] 24 | pub use self::curses::CursesBackend; 25 | 26 | mod test; 27 | pub use self::test::TestBackend; 28 | 29 | pub trait Backend { 30 | fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> 31 | where 32 | I: Iterator; 33 | fn hide_cursor(&mut self) -> Result<(), io::Error>; 34 | fn show_cursor(&mut self) -> Result<(), io::Error>; 35 | fn get_cursor(&mut self) -> Result<(u16, u16), io::Error>; 36 | fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error>; 37 | fn clear(&mut self) -> Result<(), io::Error>; 38 | fn size(&self) -> Result; 39 | fn flush(&mut self) -> Result<(), io::Error>; 40 | } 41 | -------------------------------------------------------------------------------- /rumatui-tui/tests/chart.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::backend::TestBackend; 2 | use rumatui_tui::layout::Rect; 3 | use rumatui_tui::style::{Color, Style}; 4 | use rumatui_tui::widgets::{Axis, Block, Borders, Chart, Dataset, Marker}; 5 | use rumatui_tui::Terminal; 6 | 7 | #[test] 8 | fn zero_axes_ok() { 9 | let backend = TestBackend::new(100, 100); 10 | let mut terminal = Terminal::new(backend).unwrap(); 11 | 12 | terminal 13 | .draw(|mut f| { 14 | let datasets = [Dataset::default() 15 | .marker(Marker::Braille) 16 | .style(Style::default().fg(Color::Magenta)) 17 | .data(&[(0.0, 0.0)])]; 18 | let chart = Chart::default() 19 | .block(Block::default().title("Plot").borders(Borders::ALL)) 20 | .x_axis(Axis::default().bounds([0.0, 0.0]).labels(&["0.0", "1.0"])) 21 | .y_axis(Axis::default().bounds([0.0, 1.0]).labels(&["0.0", "1.0"])) 22 | .datasets(&datasets); 23 | f.render_widget( 24 | chart, 25 | Rect { 26 | x: 0, 27 | y: 0, 28 | width: 100, 29 | height: 100, 30 | }, 31 | ); 32 | }) 33 | .unwrap(); 34 | } 35 | -------------------------------------------------------------------------------- /rumatui-tui/tests/block.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::backend::TestBackend; 2 | use rumatui_tui::buffer::Buffer; 3 | use rumatui_tui::layout::Rect; 4 | use rumatui_tui::style::{Color, Style}; 5 | use rumatui_tui::widgets::{Block, Borders}; 6 | use rumatui_tui::Terminal; 7 | 8 | #[test] 9 | fn it_draws_a_block() { 10 | let backend = TestBackend::new(10, 10); 11 | let mut terminal = Terminal::new(backend).unwrap(); 12 | terminal 13 | .draw(|mut f| { 14 | let block = Block::default() 15 | .title("Title") 16 | .borders(Borders::ALL) 17 | .title_style(Style::default().fg(Color::LightBlue)); 18 | f.render_widget( 19 | block, 20 | Rect { 21 | x: 0, 22 | y: 0, 23 | width: 8, 24 | height: 8, 25 | }, 26 | ); 27 | }) 28 | .unwrap(); 29 | let mut expected = Buffer::with_lines(vec![ 30 | "┌Title─┐ ", 31 | "│ │ ", 32 | "│ │ ", 33 | "│ │ ", 34 | "│ │ ", 35 | "│ │ ", 36 | "│ │ ", 37 | "└──────┘ ", 38 | " ", 39 | " ", 40 | ]); 41 | for x in 1..=5 { 42 | expected.get_mut(x, 0).set_fg(Color::LightBlue); 43 | } 44 | assert_eq!(&expected, terminal.backend().buffer()); 45 | } 46 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/canvas/rectangle.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | style::Color, 3 | widgets::canvas::{Line, Painter, Shape}, 4 | }; 5 | 6 | /// Shape to draw a rectangle from a `Rect` with the given color 7 | #[derive(Debug, Clone)] 8 | pub struct Rectangle { 9 | pub x: f64, 10 | pub y: f64, 11 | pub width: f64, 12 | pub height: f64, 13 | pub color: Color, 14 | } 15 | 16 | impl Shape for Rectangle { 17 | fn draw(&self, painter: &mut Painter) { 18 | let lines: [Line; 4] = [ 19 | Line { 20 | x1: self.x, 21 | y1: self.y, 22 | x2: self.x, 23 | y2: self.y + self.height, 24 | color: self.color, 25 | }, 26 | Line { 27 | x1: self.x, 28 | y1: self.y + self.height, 29 | x2: self.x + self.width, 30 | y2: self.y + self.height, 31 | color: self.color, 32 | }, 33 | Line { 34 | x1: self.x + self.width, 35 | y1: self.y, 36 | x2: self.x + self.width, 37 | y2: self.y + self.height, 38 | color: self.color, 39 | }, 40 | Line { 41 | x1: self.x, 42 | y1: self.y, 43 | x2: self.x + self.width, 44 | y2: self.y, 45 | color: self.color, 46 | }, 47 | ]; 48 | for line in &lines { 49 | line.draw(painter); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client/ruma_ext/message.rs: -------------------------------------------------------------------------------- 1 | use matrix_sdk::identifiers::EventId; 2 | 3 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 4 | pub struct RelatesTo { 5 | /// The unique identifier for the event. 6 | pub event_id: EventId, 7 | 8 | /// RelatesTo is not represented as an enum so we store the type here. 9 | pub rel_type: String, 10 | } 11 | 12 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 13 | pub struct MessageReplacement { 14 | /// The plain text body of the new message. 15 | pub body: String, 16 | 17 | /// The format used in the `formatted_body`. Currently only `org.matrix.custom.html` is 18 | /// supported. 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | pub format: Option, 21 | 22 | /// The formatted version of the `body`. This is required if `format` is specified. 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub formatted_body: Option, 25 | 26 | /// Since this type is not an enum we just hold the message type directly. 27 | pub msgtype: String, 28 | } 29 | 30 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 31 | pub struct EditEventContent { 32 | /// The plain text body of the new message. 33 | pub body: String, 34 | 35 | /// The event's new content. 36 | #[serde(rename = "m.new_content")] 37 | pub new_content: MessageReplacement, 38 | 39 | /// The RelatesTo struct, holds an EventId and relates_to type. 40 | #[serde(rename = "m.relates_to")] 41 | pub relates_to: RelatesTo, 42 | } 43 | 44 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 45 | #[serde(tag = "msgtype")] 46 | pub enum ExtraMessageEventContent { 47 | #[serde(rename = "m.text")] 48 | EditEvent(EditEventContent), 49 | } 50 | -------------------------------------------------------------------------------- /rumatui-tui/tests/gauge.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::backend::TestBackend; 2 | use rumatui_tui::buffer::Buffer; 3 | use rumatui_tui::layout::{Constraint, Direction, Layout}; 4 | use rumatui_tui::widgets::{Block, Borders, Gauge}; 5 | use rumatui_tui::Terminal; 6 | 7 | #[test] 8 | fn gauge_render() { 9 | let backend = TestBackend::new(40, 10); 10 | let mut terminal = Terminal::new(backend).unwrap(); 11 | terminal 12 | .draw(|mut f| { 13 | let chunks = Layout::default() 14 | .direction(Direction::Vertical) 15 | .margin(2) 16 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) 17 | .split(f.size()); 18 | 19 | let gauge = Gauge::default() 20 | .block(Block::default().title("Percentage").borders(Borders::ALL)) 21 | .percent(43); 22 | f.render_widget(gauge, chunks[0]); 23 | let gauge = Gauge::default() 24 | .block(Block::default().title("Ratio").borders(Borders::ALL)) 25 | .ratio(0.211_313_934_313_1); 26 | f.render_widget(gauge, chunks[1]); 27 | }) 28 | .unwrap(); 29 | let expected = Buffer::with_lines(vec![ 30 | " ", 31 | " ", 32 | " ┌Percentage────────────────────────┐ ", 33 | " │ 43% │ ", 34 | " └──────────────────────────────────┘ ", 35 | " ┌Ratio─────────────────────────────┐ ", 36 | " │ 21% │ ", 37 | " └──────────────────────────────────┘ ", 38 | " ", 39 | " ", 40 | ]); 41 | assert_eq!(&expected, terminal.backend().buffer()); 42 | } 43 | -------------------------------------------------------------------------------- /rumatui-tui/src/backend/test.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use crate::buffer::{Buffer, Cell}; 3 | use crate::layout::Rect; 4 | use std::io; 5 | 6 | #[derive(Debug)] 7 | pub struct TestBackend { 8 | width: u16, 9 | buffer: Buffer, 10 | height: u16, 11 | cursor: bool, 12 | pos: (u16, u16), 13 | } 14 | 15 | impl TestBackend { 16 | pub fn new(width: u16, height: u16) -> TestBackend { 17 | TestBackend { 18 | width, 19 | height, 20 | buffer: Buffer::empty(Rect::new(0, 0, width, height)), 21 | cursor: false, 22 | pos: (0, 0), 23 | } 24 | } 25 | 26 | pub fn buffer(&self) -> &Buffer { 27 | &self.buffer 28 | } 29 | } 30 | 31 | impl Backend for TestBackend { 32 | fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> 33 | where 34 | I: Iterator, 35 | { 36 | for (x, y, c) in content { 37 | let cell = self.buffer.get_mut(x, y); 38 | cell.symbol = c.symbol.clone(); 39 | cell.style = c.style; 40 | } 41 | Ok(()) 42 | } 43 | fn hide_cursor(&mut self) -> Result<(), io::Error> { 44 | self.cursor = false; 45 | Ok(()) 46 | } 47 | fn show_cursor(&mut self) -> Result<(), io::Error> { 48 | self.cursor = true; 49 | Ok(()) 50 | } 51 | fn get_cursor(&mut self) -> Result<(u16, u16), io::Error> { 52 | Ok(self.pos) 53 | } 54 | fn set_cursor(&mut self, x: u16, y: u16) -> Result<(), io::Error> { 55 | self.pos = (x, y); 56 | Ok(()) 57 | } 58 | fn clear(&mut self) -> Result<(), io::Error> { 59 | Ok(()) 60 | } 61 | fn size(&self) -> Result { 62 | Ok(Rect::new(0, 0, self.width, self.height)) 63 | } 64 | fn flush(&mut self) -> Result<(), io::Error> { 65 | Ok(()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/widgets/error.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::{ 2 | backend::Backend, 3 | layout::{Constraint, Direction, Layout, Rect}, 4 | style::{Color, Style}, 5 | widgets::{Block, Borders, Paragraph, Text}, 6 | Frame, 7 | }; 8 | 9 | use crate::{error::Error, widgets::RenderWidget}; 10 | 11 | #[derive(Debug)] 12 | pub struct ErrorWidget<'e>(pub &'e Error); 13 | 14 | impl<'e> ErrorWidget<'e> { 15 | pub fn new(error: &'e Error) -> Self { 16 | ErrorWidget(error) 17 | } 18 | } 19 | 20 | impl<'e> RenderWidget for ErrorWidget<'e> { 21 | fn render(&mut self, f: &mut Frame, _area: Rect) 22 | where 23 | B: Backend, 24 | { 25 | let vert_chunks = Layout::default() 26 | .direction(Direction::Vertical) 27 | .constraints( 28 | [ 29 | Constraint::Percentage(25), 30 | Constraint::Percentage(50), 31 | Constraint::Percentage(25), 32 | ] 33 | .as_ref(), 34 | ) 35 | .split(f.size()); 36 | 37 | let chunks = Layout::default() 38 | .direction(Direction::Horizontal) 39 | .constraints( 40 | [ 41 | Constraint::Percentage(30), 42 | Constraint::Percentage(40), 43 | Constraint::Percentage(30), 44 | ] 45 | .as_ref(), 46 | ) 47 | .split(vert_chunks[1]); 48 | 49 | let txt = [Text::styled( 50 | self.0.to_string(), 51 | Style::default().fg(Color::Red), 52 | )]; 53 | let p = Paragraph::new(txt.iter()) 54 | .block( 55 | Block::default() 56 | .title("Error") 57 | .borders(Borders::ALL) 58 | .border_style(Style::default().fg(Color::Red)), 59 | ) 60 | .wrap(true); 61 | f.render_widget(p, chunks[1]) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/widgets/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{self, Display}, 3 | io::{self, ErrorKind, Write}, 4 | }; 5 | 6 | use comrak; 7 | use mdcat::{self, ResourceAccess, Settings, TerminalCapabilities, TerminalSize}; 8 | use pulldown_cmark::{Options, Parser}; 9 | use syntect::parsing::SyntaxSet; 10 | 11 | use crate::error::{Error, Result}; 12 | 13 | #[derive(Default)] 14 | pub struct Writer(Vec); 15 | 16 | impl Write for Writer { 17 | #[inline] 18 | fn write(&mut self, buf: &[u8]) -> io::Result { 19 | self.0.extend_from_slice(buf); 20 | Ok(buf.len()) 21 | } 22 | #[inline] 23 | fn flush(&mut self) -> io::Result<()> { 24 | Ok(()) 25 | } 26 | } 27 | impl Display for Writer { 28 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 29 | if let Ok(s) = String::from_utf8(self.0.clone()) { 30 | write!(f, "{}", s) 31 | } else { 32 | writeln!(f, "to string failed") 33 | } 34 | } 35 | } 36 | 37 | pub(crate) fn markdown_to_terminal(input: &str) -> Result { 38 | let mut options = Options::empty(); 39 | options.insert(Options::ENABLE_TASKLISTS); 40 | options.insert(Options::ENABLE_STRIKETHROUGH); 41 | let parser = Parser::new_ext(&input, options); 42 | let syntax_set = SyntaxSet::load_defaults_nonewlines(); 43 | 44 | let settings = Settings { 45 | terminal_capabilities: TerminalCapabilities::detect(), 46 | terminal_size: TerminalSize::detect().ok_or(Error::from(io::Error::new( 47 | ErrorKind::Other, 48 | "could not detect terminal", 49 | )))?, 50 | resource_access: ResourceAccess::LocalOnly, 51 | syntax_set, 52 | }; 53 | let mut w = Writer::default(); 54 | mdcat::push_tty(&settings, &mut w, &std::path::Path::new("/"), parser) 55 | .map_err(|e| Error::from(io::Error::new(ErrorKind::Other, e.to_string())))?; 56 | 57 | Ok(w.to_string()) 58 | } 59 | 60 | pub(crate) fn markdown_to_html(input: &str) -> String { 61 | comrak::markdown_to_html(input, &comrak::ComrakOptions::default()) 62 | } 63 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rumatui" 3 | version = "0.1.19" 4 | authors = ["Devin R "] 5 | license = "MIT/Apache-2.0" 6 | description = "A click-able command-line Matrix client." 7 | repository = "https://github.com/DevinR528/rumatui" 8 | keywords = ["chat", "matrix", "ruma", "matrix-rust-sdk", "tui"] 9 | categories = ["command-line-utilities"] 10 | edition = "2018" 11 | readme = "README.md" 12 | # at some point exclude this ?? 13 | # exclude = ["resources"] 14 | 15 | [dependencies] 16 | async-trait = "0.1.30" 17 | chrono = "0.4" 18 | comrak = "0.7.0" 19 | crossbeam-channel = "0.4.2" 20 | dirs = "2.0.2" 21 | failure = "0.1.7" 22 | itertools = "0.9.0" 23 | js_int = "0.1.5" 24 | lazy_static = "1.4.0" 25 | sublime_fuzzy = "0.6.0" 26 | 27 | matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", rev = "037d62b" } 28 | 29 | mdcat = "0.18.2" 30 | muncher = "0.6.1" 31 | regex = "1.3.9" 32 | 33 | # depend on the same version matrix-sdk depends on so if matrix-sdk updates this does to 34 | ruma-api = { git = "https://github.com/ruma/ruma", rev = "848b22568106d05c5444f3fe46070d5aa16e422b" } 35 | 36 | serde = { version = "1.0.111", features = ["derive"] } 37 | serde_json = "1.0.52" 38 | termion = "1.5.5" 39 | tokio = { version = "0.2.21", features = ["macros", "rt-threaded", "sync"] } 40 | tracing-appender = "0.1" 41 | tracing-subscriber = { version = "0.2.7", features = ["fmt"] } 42 | tracing = "0.1.5" 43 | 44 | # crates.io published fork of tui 45 | rumatui-tui = "0.8.2" 46 | webbrowser = "0.5.2" 47 | url = "2.1.1" 48 | uuid = { version = "0.8.1", features = ["v4"] } 49 | 50 | [dependencies.pulldown-cmark] 51 | version = "0.7.1" 52 | default-features = false 53 | features = ['simd'] 54 | 55 | [dependencies.syntect] 56 | # onig rust fails to build w/o clang currently so pin syntect, 57 | # see and GH-90 58 | version = "4.1.1" 59 | default-features = false 60 | features = ["parsing", "assets", "dump-load"] 61 | 62 | [workspace] 63 | members = ["rumatui-tui"] 64 | 65 | # For flamegraph COMMENT OUT 66 | # [profile.release] 67 | # debug = true -------------------------------------------------------------------------------- /src/ui_loop.rs: -------------------------------------------------------------------------------- 1 | use std::{io, sync::mpsc, thread, time::Duration}; 2 | 3 | use termion::{ 4 | event::{Event as TermEvent, Key}, 5 | input::{MouseTerminal, TermRead}, 6 | raw::IntoRawMode, 7 | }; 8 | 9 | pub enum Event { 10 | Input(I), 11 | Tick, 12 | } 13 | 14 | /// A small event handler that wrap termion input and tick events. Each event 15 | /// type is handled in its own thread and returned to a common `Receiver` 16 | pub struct UiEventHandle { 17 | recv: mpsc::Receiver>, 18 | input_handle: thread::JoinHandle<()>, 19 | tick_handle: thread::JoinHandle<()>, 20 | } 21 | 22 | #[derive(Debug, Clone, Copy)] 23 | pub struct Config { 24 | pub exit_key: Key, 25 | pub tick_rate: Duration, 26 | } 27 | 28 | impl UiEventHandle { 29 | pub fn with_config(cfg: Config) -> Self { 30 | let (send, recv) = mpsc::channel(); 31 | 32 | let stdout = io::stdout().into_raw_mode().unwrap(); 33 | let _stdout = MouseTerminal::from(stdout); 34 | 35 | let input_handle = { 36 | let send = send.clone(); 37 | thread::spawn(move || { 38 | let stdin = io::stdin(); 39 | for ev in stdin.events() { 40 | let ev = ev.unwrap(); 41 | 42 | if let TermEvent::Key(Key::Char('q')) = ev { 43 | return; 44 | } 45 | 46 | if send.send(Event::Input(ev)).is_err() { 47 | return; 48 | } 49 | } 50 | }) 51 | }; 52 | let tick_handle = { 53 | thread::spawn(move || loop { 54 | if let Err(_e) = send.send(Event::Tick) { 55 | return; 56 | } 57 | thread::sleep(cfg.tick_rate); 58 | }) 59 | }; 60 | 61 | UiEventHandle { 62 | recv, 63 | input_handle, 64 | tick_handle, 65 | } 66 | } 67 | 68 | pub fn next(&self) -> Result, mpsc::RecvError> { 69 | self.recv.recv() 70 | } 71 | 72 | #[allow(dead_code)] 73 | pub fn shutdown(self) { 74 | let _ = self.input_handle.join(); 75 | let _ = self.tick_handle.join(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | use bitflags::bitflags; 2 | use std::borrow::Cow; 3 | 4 | mod barchart; 5 | mod block; 6 | pub mod canvas; 7 | mod chart; 8 | mod gauge; 9 | mod list; 10 | mod paragraph; 11 | mod reflow; 12 | mod scroll; 13 | mod sparkline; 14 | mod table; 15 | mod tabs; 16 | 17 | pub use self::barchart::BarChart; 18 | pub use self::block::{Block, BorderType}; 19 | pub use self::chart::{Axis, Chart, Dataset, GraphType, Marker}; 20 | pub use self::gauge::Gauge; 21 | pub use self::list::{List, ListState}; 22 | pub use self::paragraph::Paragraph; 23 | pub use self::sparkline::Sparkline; 24 | pub use self::table::{Row, Table, TableState}; 25 | pub use self::tabs::Tabs; 26 | 27 | use crate::buffer::Buffer; 28 | use crate::layout::Rect; 29 | use crate::style::Style; 30 | 31 | bitflags! { 32 | /// Bitflags that can be composed to set the visible borders essentially on the block widget. 33 | pub struct Borders: u32 { 34 | /// Show no border (default) 35 | const NONE = 0b0000_0001; 36 | /// Show the top border 37 | const TOP = 0b0000_0010; 38 | /// Show the right border 39 | const RIGHT = 0b0000_0100; 40 | /// Show the bottom border 41 | const BOTTOM = 0b000_1000; 42 | /// Show the left border 43 | const LEFT = 0b0001_0000; 44 | /// Show all borders 45 | const ALL = Self::TOP.bits | Self::RIGHT.bits | Self::BOTTOM.bits | Self::LEFT.bits; 46 | } 47 | } 48 | 49 | #[derive(Clone, Debug, PartialEq)] 50 | pub enum Text<'b> { 51 | Raw(Cow<'b, str>), 52 | Styled(Cow<'b, str>, Style), 53 | } 54 | 55 | impl<'b> Text<'b> { 56 | pub fn raw>>(data: D) -> Text<'b> { 57 | Text::Raw(data.into()) 58 | } 59 | 60 | pub fn styled>>(data: D, style: Style) -> Text<'b> { 61 | Text::Styled(data.into(), style) 62 | } 63 | } 64 | 65 | /// Base requirements for a Widget 66 | pub trait Widget { 67 | /// Draws the current state of the widget in the given buffer. That the only method required to 68 | /// implement a custom widget. 69 | fn render(self, area: Rect, buf: &mut Buffer); 70 | } 71 | 72 | pub trait StatefulWidget { 73 | type State; 74 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State); 75 | } 76 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/canvas/line.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | style::Color, 3 | widgets::canvas::{Painter, Shape}, 4 | }; 5 | 6 | /// Shape to draw a line from (x1, y1) to (x2, y2) with the given color 7 | #[derive(Debug, Clone)] 8 | pub struct Line { 9 | pub x1: f64, 10 | pub y1: f64, 11 | pub x2: f64, 12 | pub y2: f64, 13 | pub color: Color, 14 | } 15 | 16 | impl Shape for Line { 17 | fn draw(&self, painter: &mut Painter) { 18 | let (x1, y1) = match painter.get_point(self.x1, self.y1) { 19 | Some(c) => c, 20 | None => return, 21 | }; 22 | let (x2, y2) = match painter.get_point(self.x2, self.y2) { 23 | Some(c) => c, 24 | None => return, 25 | }; 26 | let (dx, x_range) = if x2 >= x1 { 27 | (x2 - x1, x1..=x2) 28 | } else { 29 | (x1 - x2, x2..=x1) 30 | }; 31 | let (dy, y_range) = if y2 >= y1 { 32 | (y2 - y1, y1..=y2) 33 | } else { 34 | (y1 - y2, y2..=y1) 35 | }; 36 | 37 | if dx == 0 { 38 | for y in y_range { 39 | painter.paint(x1, y, self.color); 40 | } 41 | } else if dy == 0 { 42 | for x in x_range { 43 | painter.paint(x, y1, self.color); 44 | } 45 | } else if dy < dx { 46 | if x1 > x2 { 47 | draw_line_low(painter, x2, y2, x1, y1, self.color); 48 | } else { 49 | draw_line_low(painter, x1, y1, x2, y2, self.color); 50 | } 51 | } else if y1 > y2 { 52 | draw_line_high(painter, x2, y2, x1, y1, self.color); 53 | } else { 54 | draw_line_high(painter, x1, y1, x2, y2, self.color); 55 | } 56 | } 57 | } 58 | 59 | fn draw_line_low(painter: &mut Painter, x1: usize, y1: usize, x2: usize, y2: usize, color: Color) { 60 | let dx = (x2 - x1) as isize; 61 | let dy = (y2 as isize - y1 as isize).abs(); 62 | let mut d = 2 * dy - dx; 63 | let mut y = y1; 64 | for x in x1..=x2 { 65 | painter.paint(x, y, color); 66 | if d > 0 { 67 | y = if y1 > y2 { y - 1 } else { y + 1 }; 68 | d -= 2 * dx; 69 | } 70 | d += 2 * dy; 71 | } 72 | } 73 | 74 | fn draw_line_high(painter: &mut Painter, x1: usize, y1: usize, x2: usize, y2: usize, color: Color) { 75 | let dx = (x2 as isize - x1 as isize).abs(); 76 | let dy = (y2 - y1) as isize; 77 | let mut d = 2 * dx - dy; 78 | let mut x = x1; 79 | for y in y1..=y2 { 80 | painter.paint(x, y, color); 81 | if d > 0 { 82 | x = if x1 > x2 { x - 1 } else { x + 1 }; 83 | d -= 2 * dy; 84 | } 85 | d += 2 * dx; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/client/ruma_ext/auth.rs: -------------------------------------------------------------------------------- 1 | use matrix_sdk::{ 2 | api::r0::{account::register::Response as RegisterResponse, uiaa::UiaaResponse}, 3 | identifiers::{DeviceId, UserId}, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Clone, Debug, Deserialize, Serialize)] 8 | pub struct SessionObj { 9 | pub session: String, 10 | } 11 | 12 | ruma_api::ruma_api! { 13 | metadata: { 14 | description: "Send session pings to the server during UIAA.", 15 | method: POST, 16 | name: "register", 17 | path: "/_matrix/client/r0/register?kind=user", 18 | rate_limited: true, 19 | requires_authentication: false, 20 | } 21 | 22 | request: { 23 | pub auth: SessionObj, 24 | } 25 | 26 | response: { 27 | #[serde(skip_serializing_if = "Option::is_none")] 28 | pub access_token: Option, 29 | pub user_id: UserId, 30 | pub device_id: Option>, 31 | } 32 | 33 | error: UiaaResponse 34 | } 35 | 36 | impl Into for Response { 37 | fn into(self) -> RegisterResponse { 38 | RegisterResponse { 39 | access_token: self.access_token, 40 | user_id: self.user_id, 41 | device_id: self.device_id, 42 | } 43 | } 44 | } 45 | 46 | pub mod dummy { 47 | use matrix_sdk::{ 48 | api::r0::{account::register::Response as RegisterResponse, uiaa::UiaaResponse}, 49 | identifiers::{DeviceId, UserId}, 50 | }; 51 | 52 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] 53 | pub struct Dummy { 54 | #[serde(rename = "type")] 55 | pub ev_type: String, 56 | pub session: String, 57 | } 58 | 59 | ruma_api::ruma_api! { 60 | metadata: { 61 | description: "Send session pings to the server during UIAA.", 62 | method: POST, 63 | name: "register", 64 | path: "/_matrix/client/r0/register?kind=user", 65 | rate_limited: true, 66 | requires_authentication: false, 67 | } 68 | 69 | request: { 70 | pub auth: Dummy, 71 | } 72 | 73 | response: { 74 | #[serde(skip_serializing_if = "Option::is_none")] 75 | pub access_token: Option, 76 | pub user_id: UserId, 77 | pub device_id: Option>, 78 | } 79 | 80 | error: UiaaResponse 81 | } 82 | 83 | impl Into for Response { 84 | fn into(self) -> RegisterResponse { 85 | RegisterResponse { 86 | access_token: self.access_token, 87 | user_id: self.user_id, 88 | device_id: self.device_id, 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RumaTui 2 | ![rumatui-logo](https://github.com/DevinR528/RumaTui/blob/master/resources/small_logo.gif) 3 | ## A command-line Matrix client. 4 | [![Build Status](https://travis-ci.com/DevinR528/rumatui.svg?branch=master)](https://travis-ci.com/DevinR528/rumatui) 5 | [![Latest Version](https://img.shields.io/crates/v/rumatui.svg)](https://crates.io/crates/rumatui) 6 | [![#rumatui](https://img.shields.io/badge/matrix-%23rumatui-purple?style=flat-square)](https://matrix.to/#/#rumatui:matrix.org) 7 | 8 | Work In Progress. A Matrix client written using [tui.rs](https://github.com/fdehau/tui-rs) and [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) to provide a clickable cli to chat. 9 | 10 | ![rumatui-demo](https://github.com/DevinR528/rumatui/blob/master/resources/rumatui-notice.gif) 11 | 12 | This project is still very much a work in progress. Please file issues, but I will preemptively say the error messages need work, and the code needs to be refactored to be a little more reader-friendly. Thanks for giving it a go! 13 | 14 | # Install 15 | For the latest and greatest 16 | ```bash 17 | cargo install --git https://github.com/DevinR528/rumatui 18 | ``` 19 | 20 | Or the slightly safer approach but with fewer features (see [CHANGELOG](https://github.com/DevinR528/rumatui/blob/master/CHANGELOG.md#0113-alpha)) 21 | ```bash 22 | cargo install rumatui --version 0.1.13-alpha 23 | ``` 24 | 25 | # Run 26 | ```bash 27 | rumatui [HOMESERVER | OPTIONS] 28 | ``` 29 | It can be run [torified](https://gitlab.torproject.org/legacy/trac/-/wikis/doc/TorifyHOWTO) with [torsocks](https://gitlab.torproject.org/legacy/trac/-/wikis/doc/torsocks): 30 | ``` 31 | torsocks rumatui [HOMESERVER | OPTIONS] 32 | ``` 33 | ### Options 34 | * -h or --help Prints help information 35 | * -v or -verbose Will create a log of the session at '~/.rumatui/logs.json' 36 | 37 | If no `homeserver` is specified, matrix.org is used. 38 | 39 | # Use 40 | 41 | Most of `rumatui` is click-able however, there are a few buttons that can be used (this is a terminal after all). 42 | 43 | * Esc will exit `rumatui` 44 | * Up/down arrow toggles login/register selected text box 45 | * Enter still works for all buttons except the decline/accept invite 46 | * Ctrl-s sends a message 47 | * Delete leaves and forgets the selected room 48 | * Left/right arrows, while at the login window, toggles login/register window 49 | * Left arrow, while at the main chat window, brings up the room search window 50 | * Enter, while in the room search window, starts the search 51 | * Ctrl-d, while a room is selected in the room search window, joins the room 52 | 53 | #### License 54 | 55 | Licensed under either of Apache License, Version 56 | 2.0 or MIT license at your option. 57 | 58 | 59 |
60 | 61 | 62 | Unless you explicitly state otherwise, any contribution intentionally submitted 63 | for inclusion in this project by you, as defined in the Apache-2.0 license, 64 | shall be dual licensed as above, without any additional terms or conditions. 65 | 66 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [Unreleased] 2 | 3 | * Fix performance regression caused by `widgets::utils::markdown_to_terminal` 4 | * Ctrl-k room filtering (Thanks to [zwieberl](https://github.com/zwieberl)) 5 | * Tab selects next text box (same as down arrow) 6 | 7 | # [0.1.19] 8 | 9 | * Update matrix-rust-sdk to a version (rev 037d62b) that uses ruma mono repo (rev 848b225) 10 | * Default to `https` over `http` 11 | * Fix device ID generation on every login 12 | * rumatui keeps track of each device's device_id 13 | * Add ability to log to `~/.rumatui/log.json` using `RUST_LOG` env var or `-v/--verbose` cli arguments 14 | * Add memory for send message textbox 15 | * When switching rooms whatever has been typed for that room will be kept when the user returns 16 | 17 | # [0.1.17] 18 | 19 | * Ignore all `widgets::ctrl_chars` tests for Nix packaging 20 | * Use matrix-org/matrix-rust-sdk at master for sdk dependency 21 | 22 | # [0.1.16] 23 | 24 | * Add help output `rumatui [-h/--help]` 25 | * Add license files to the release package 26 | 27 | # Release 0.1.15 28 | 29 | ### Bug Fixes 30 | 31 | * Remove http proxy left in from testing 32 | 33 | ## 0.1.14 34 | 35 | * Room search is now available 36 | * Public rooms can be joined from the room search window 37 | * A user can register from the new register window 38 | * This features complete User Interactive Authentication by opening a web browser 39 | * Message edits are shown 40 | * When markdown is part of the message they are properly formatted 41 | * Reactions display under the respective message 42 | * Redaction events are handled for reactions (emoji) and messages 43 | * Update dependency 44 | * `muncher` 0.6.0 -> 0.6.1 45 | 46 | * Note: the above features are only for displaying received events 47 | `rumatui` can not yet send these events 48 | 49 | ### Bug Fixes 50 | 51 | * Send read receipts to mark the correct read message (it was sending random event ids) 52 | * Send `read_marker` events instead of `read_receipt` 53 | 54 | # Pre-release 55 | 56 | ## 0.1.13-alpha 57 | 58 | * Errors are now displayed with more helpful messages 59 | * Using internal Error type instead of `anyhow::Error` 60 | * Send a message with Ctrl-s 61 | * Update dependencies 62 | * `mdcat` 0.15 -> 0.18.2 63 | * `serde` 1.0.111 -> 1.0.111 64 | * `regex` 1.3.7 -> 1.3.9 65 | * `tokio` 0.2.20 -> 0.2.21 66 | 67 | ## 0.1.12-alpha 68 | * Display membership status when updated 69 | * Join a room you are invited to 70 | * Client sends read receipts to server 71 | * Display when messages have been read 72 | * Leave a room by pressing Delete key (this should probably be a Ctrl-some key deal...) 73 | * Specify homeserver to join on startup (before the login screen) 74 | * Simply run `rumatui [HOMESERVER]`, defaults to "http://matrix.org" 75 | * Displays errors, albeit not very helpful or specific 76 | * Receive and display messages 77 | * formatted messages display as rendered markdown 78 | * Send messages 79 | * local echo is removed 80 | * Send textbox grows as more lines of text are added 81 | * Selectable rooms list 82 | * change rooms using the arrow keys, making this clickable may be difficult 83 | * Login widget is click/arrow key navigable 84 | * hides password 85 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/scroll.rs: -------------------------------------------------------------------------------- 1 | // All credit goes to https://github.com/clevinson/tui-rs. His fork and work 2 | // on scrolling is represented here with a few changes. 3 | 4 | use std::cell::Cell; 5 | use std::rc::Rc; 6 | 7 | use crate::widgets::reflow::{LineComposer, Styled}; 8 | 9 | pub trait Scroller<'t> { 10 | fn next_line(&mut self) -> Option>; 11 | } 12 | 13 | pub enum ScrolledLine<'t> { 14 | Overflow, 15 | Line(Vec>, u16), 16 | } 17 | 18 | pub struct OffsetScroller<'t, 'lc> { 19 | next_line_offset: u16, 20 | line_composer: Box + 'lc>, 21 | } 22 | 23 | impl<'t, 'lc> OffsetScroller<'t, 'lc> { 24 | pub fn new( 25 | scroll_offset: u16, 26 | line_composer: Box + 'lc>, 27 | ) -> OffsetScroller<'t, 'lc> { 28 | OffsetScroller { 29 | next_line_offset: scroll_offset, 30 | line_composer, 31 | } 32 | } 33 | } 34 | 35 | impl<'t, 'lc> Scroller<'t> for OffsetScroller<'t, 'lc> { 36 | fn next_line(&mut self) -> Option> { 37 | if self.next_line_offset > 0 { 38 | for _ in 0..self.next_line_offset { 39 | self.line_composer.next_line(); 40 | } 41 | self.next_line_offset = 0; 42 | } 43 | self.line_composer 44 | .next_line() 45 | .map(|(line, line_width)| ScrolledLine::Line(line.to_vec(), line_width)) 46 | .or(Some(ScrolledLine::Overflow)) 47 | } 48 | } 49 | 50 | pub struct TailScroller<'t> { 51 | next_line_offset: i16, 52 | all_lines: Vec<(Vec>, u16)>, 53 | } 54 | 55 | impl<'t, 'lc> TailScroller<'t> { 56 | pub fn new( 57 | scroll_offset: u16, 58 | mut line_composer: Box + 'lc>, 59 | text_area_height: u16, 60 | has_overflown: Rc>, 61 | ) -> TailScroller<'t> { 62 | let mut all_lines = line_composer.collect_lines(); 63 | all_lines.reverse(); 64 | let num_lines = all_lines.len() as u16; 65 | 66 | // scrolling up back in history past the top of the content results 67 | // in a ScrollLine::Overflow, so as to allow for the renderer to 68 | // draw a special scroll_overflow_char on each subsequent line 69 | let next_line_offset = if num_lines <= text_area_height { 70 | if num_lines + 2 >= text_area_height { 71 | has_overflown.set(true); 72 | } 73 | // if content doesn't fill the text_area_height, 74 | // scrolling should be reverse of normal 75 | // behavior 76 | -(scroll_offset as i16) 77 | } else { 78 | has_overflown.set(true); 79 | // default ScrollFrom::Bottom behavior, 80 | // scroll == 0 floats content to bottom, 81 | // scroll > 0 scrolling up, back in history 82 | num_lines as i16 - (text_area_height + scroll_offset) as i16 83 | }; 84 | 85 | TailScroller { 86 | next_line_offset, 87 | all_lines, 88 | } 89 | } 90 | } 91 | 92 | impl<'t> Scroller<'t> for TailScroller<'t> { 93 | fn next_line(&mut self) -> Option> { 94 | if self.next_line_offset < 0 { 95 | self.next_line_offset += 1; 96 | Some(ScrolledLine::Overflow) 97 | } else { 98 | if self.next_line_offset > 0 { 99 | for _ in 0..self.next_line_offset { 100 | self.all_lines.pop(); 101 | } 102 | self.next_line_offset = 0; 103 | } 104 | self.all_lines 105 | .pop() 106 | .map(|(line, line_width)| ScrolledLine::Line(line, line_width)) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /rumatui-tui/README.md: -------------------------------------------------------------------------------- 1 | # tui-rs 2 | 3 | [![Build Status](https://github.com/fdehau/tui-rs/workflows/CI/badge.svg)](https://github.com/fdehau/tui-rs/actions?query=workflow%3ACI+) 4 | [![Crate Status](https://img.shields.io/crates/v/tui.svg)](https://crates.io/crates/tui) 5 | [![Docs Status](https://docs.rs/tui/badge.svg)](https://docs.rs/crate/tui/) 6 | 7 | Demo cast under Linux Termite with Inconsolata font 12pt 8 | 9 | `tui-rs` is a [Rust](https://www.rust-lang.org) library to build rich terminal 10 | user interfaces and dashboards. It is heavily inspired by the `Javascript` 11 | library [blessed-contrib](https://github.com/yaronn/blessed-contrib) and the 12 | `Go` library [termui](https://github.com/gizak/termui). 13 | 14 | The library itself supports four different backends to draw to the terminal. You 15 | can either choose from: 16 | 17 | - [termion](https://github.com/ticki/termion) 18 | - [rustbox](https://github.com/gchp/rustbox) 19 | - [crossterm](https://github.com/crossterm-rs/crossterm) 20 | - [pancurses](https://github.com/ihalila/pancurses) 21 | 22 | However, some features may only be available in one of the four. 23 | 24 | The library is based on the principle of immediate rendering with intermediate 25 | buffers. This means that at each new frame you should build all widgets that are 26 | supposed to be part of the UI. While providing a great flexibility for rich and 27 | interactive UI, this may introduce overhead for highly dynamic content. So, the 28 | implementation try to minimize the number of ansi escapes sequences generated to 29 | draw the updated UI. In practice, given the speed of `Rust` the overhead rather 30 | comes from the terminal emulator than the library itself. 31 | 32 | Moreover, the library does not provide any input handling nor any event system and 33 | you may rely on the previously cited libraries to achieve such features. 34 | 35 | ### [Documentation](https://docs.rs/tui) 36 | 37 | ### Demo 38 | 39 | The demo shown in the gif can be run with all available backends 40 | (`exmples/*_demo.rs` files). For example to see the `termion` version one could 41 | run: 42 | 43 | ``` 44 | cargo run --example termion_demo --release -- --tick-rate 200 45 | ``` 46 | 47 | where `tick-rate` is the UI refresh rate in ms. 48 | 49 | The UI code is in [examples/demo/ui.rs](examples/demo/ui.rs) while the 50 | application state is in [examples/demo/app.rs](examples/demo/app.rs). 51 | 52 | Beware that the `termion_demo` only works on Unix platforms. If you are a Windows user, 53 | you can see the same demo using the `crossterm` backend with the following command: 54 | 55 | ``` 56 | cargo run --example crossterm_demo --no-default-features --features="crossterm" --release -- --tick-rate 200 57 | ``` 58 | 59 | ### Widgets 60 | 61 | The library comes with the following list of widgets: 62 | 63 | * [Block](examples/block.rs) 64 | * [Gauge](examples/gauge.rs) 65 | * [Sparkline](examples/sparkline.rs) 66 | * [Chart](examples/chart.rs) 67 | * [BarChart](examples/barchart.rs) 68 | * [List](examples/list.rs) 69 | * [Table](examples/table.rs) 70 | * [Paragraph](examples/paragraph.rs) 71 | * [Canvas (with line, point cloud, map)](examples/canvas.rs) 72 | * [Tabs](examples/tabs.rs) 73 | 74 | Click on each item to see the source of the example. Run the examples with with 75 | cargo (e.g. to run the demo `cargo run --example demo`), and quit by pressing `q`. 76 | 77 | You can run all examples by running `make run-examples`. 78 | 79 | ### Third-party widgets 80 | 81 | * [tui-logger](https://github.com/gin66/tui-logger) 82 | 83 | ### Apps using tui 84 | 85 | * [spotify-tui](https://github.com/Rigellute/spotify-tui) 86 | * [bandwhich](https://github.com/imsnif/bandwhich) 87 | * [ytop](https://github.com/cjbassi/ytop) 88 | * [zenith](https://github.com/bvaisvil/zenith) 89 | * [bottom](https://github.com/ClementTsang/bottom) 90 | * [oha](https://github.com/hatoo/oha) 91 | 92 | ### Alternatives 93 | 94 | You might want to checkout [Cursive](https://github.com/gyscos/Cursive) for an 95 | alternative solution to build text user interfaces in Rust. 96 | 97 | ## License 98 | 99 | [MIT](LICENSE) 100 | -------------------------------------------------------------------------------- /rumatui-tui/src/backend/rustbox.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | backend::Backend, 3 | buffer::Cell, 4 | layout::Rect, 5 | style::{Color, Modifier}, 6 | }; 7 | use std::io; 8 | 9 | pub struct RustboxBackend { 10 | rustbox: rustbox::RustBox, 11 | } 12 | 13 | impl RustboxBackend { 14 | pub fn new() -> Result { 15 | let rustbox = rustbox::RustBox::init(Default::default())?; 16 | Ok(RustboxBackend { rustbox }) 17 | } 18 | 19 | pub fn with_rustbox(instance: rustbox::RustBox) -> RustboxBackend { 20 | RustboxBackend { rustbox: instance } 21 | } 22 | 23 | pub fn rustbox(&self) -> &rustbox::RustBox { 24 | &self.rustbox 25 | } 26 | } 27 | 28 | impl Backend for RustboxBackend { 29 | fn draw<'a, I>(&mut self, content: I) -> Result<(), io::Error> 30 | where 31 | I: Iterator, 32 | { 33 | for (x, y, cell) in content { 34 | self.rustbox.print( 35 | x as usize, 36 | y as usize, 37 | cell.style.modifier.into(), 38 | cell.style.fg.into(), 39 | cell.style.bg.into(), 40 | &cell.symbol, 41 | ); 42 | } 43 | Ok(()) 44 | } 45 | fn hide_cursor(&mut self) -> Result<(), io::Error> { 46 | Ok(()) 47 | } 48 | fn show_cursor(&mut self) -> Result<(), io::Error> { 49 | Ok(()) 50 | } 51 | fn get_cursor(&mut self) -> io::Result<(u16, u16)> { 52 | Err(io::Error::from(io::ErrorKind::Other)) 53 | } 54 | fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { 55 | self.rustbox.set_cursor(x as isize, y as isize); 56 | Ok(()) 57 | } 58 | fn clear(&mut self) -> Result<(), io::Error> { 59 | self.rustbox.clear(); 60 | Ok(()) 61 | } 62 | fn size(&self) -> Result { 63 | let term_width = self.rustbox.width(); 64 | let term_height = self.rustbox.height(); 65 | let max = u16::max_value(); 66 | Ok(Rect::new( 67 | 0, 68 | 0, 69 | if term_width > usize::from(max) { 70 | max 71 | } else { 72 | term_width as u16 73 | }, 74 | if term_height > usize::from(max) { 75 | max 76 | } else { 77 | term_height as u16 78 | }, 79 | )) 80 | } 81 | fn flush(&mut self) -> Result<(), io::Error> { 82 | self.rustbox.present(); 83 | Ok(()) 84 | } 85 | } 86 | 87 | fn rgb_to_byte(r: u8, g: u8, b: u8) -> u16 { 88 | u16::from((r & 0xC0) + ((g & 0xE0) >> 2) + ((b & 0xE0) >> 5)) 89 | } 90 | 91 | impl Into for Color { 92 | fn into(self) -> rustbox::Color { 93 | match self { 94 | Color::Reset => rustbox::Color::Default, 95 | Color::Black | Color::Gray | Color::DarkGray => rustbox::Color::Black, 96 | Color::Red | Color::LightRed => rustbox::Color::Red, 97 | Color::Green | Color::LightGreen => rustbox::Color::Green, 98 | Color::Yellow | Color::LightYellow => rustbox::Color::Yellow, 99 | Color::Magenta | Color::LightMagenta => rustbox::Color::Magenta, 100 | Color::Cyan | Color::LightCyan => rustbox::Color::Cyan, 101 | Color::White => rustbox::Color::White, 102 | Color::Blue | Color::LightBlue => rustbox::Color::Blue, 103 | Color::Indexed(i) => rustbox::Color::Byte(u16::from(i)), 104 | Color::Rgb(r, g, b) => rustbox::Color::Byte(rgb_to_byte(r, g, b)), 105 | } 106 | } 107 | } 108 | 109 | impl Into for Modifier { 110 | fn into(self) -> rustbox::Style { 111 | let mut result = rustbox::Style::empty(); 112 | if self.contains(Modifier::BOLD) { 113 | result.insert(rustbox::RB_BOLD); 114 | } 115 | if self.contains(Modifier::UNDERLINED) { 116 | result.insert(rustbox::RB_UNDERLINE); 117 | } 118 | if self.contains(Modifier::REVERSED) { 119 | result.insert(rustbox::RB_REVERSE); 120 | } 121 | result 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/tabs.rs: -------------------------------------------------------------------------------- 1 | use unicode_width::UnicodeWidthStr; 2 | 3 | use crate::buffer::Buffer; 4 | use crate::layout::Rect; 5 | use crate::style::Style; 6 | use crate::symbols::line; 7 | use crate::widgets::{Block, Widget}; 8 | 9 | /// A widget to display available tabs in a multiple panels context. 10 | /// 11 | /// # Examples 12 | /// 13 | /// ``` 14 | /// # use rumatui_tui::widgets::{Block, Borders, Tabs}; 15 | /// # use rumatui_tui::style::{Style, Color}; 16 | /// # use rumatui_tui::symbols::{DOT}; 17 | /// Tabs::default() 18 | /// .block(Block::default().title("Tabs").borders(Borders::ALL)) 19 | /// .titles(&["Tab1", "Tab2", "Tab3", "Tab4"]) 20 | /// .style(Style::default().fg(Color::White)) 21 | /// .highlight_style(Style::default().fg(Color::Yellow)) 22 | /// .divider(DOT); 23 | /// ``` 24 | pub struct Tabs<'a, T> 25 | where 26 | T: AsRef + 'a, 27 | { 28 | /// A block to wrap this widget in if necessary 29 | block: Option>, 30 | /// One title for each tab 31 | titles: &'a [T], 32 | /// The index of the selected tabs 33 | selected: usize, 34 | /// The style used to draw the text 35 | style: Style, 36 | /// The style used to display the selected item 37 | highlight_style: Style, 38 | /// Tab divider 39 | divider: &'a str, 40 | } 41 | 42 | impl<'a, T> Default for Tabs<'a, T> 43 | where 44 | T: AsRef, 45 | { 46 | fn default() -> Tabs<'a, T> { 47 | Tabs { 48 | block: None, 49 | titles: &[], 50 | selected: 0, 51 | style: Default::default(), 52 | highlight_style: Default::default(), 53 | divider: line::VERTICAL, 54 | } 55 | } 56 | } 57 | 58 | impl<'a, T> Tabs<'a, T> 59 | where 60 | T: AsRef, 61 | { 62 | pub fn block(mut self, block: Block<'a>) -> Tabs<'a, T> { 63 | self.block = Some(block); 64 | self 65 | } 66 | 67 | pub fn titles(mut self, titles: &'a [T]) -> Tabs<'a, T> { 68 | self.titles = titles; 69 | self 70 | } 71 | 72 | pub fn select(mut self, selected: usize) -> Tabs<'a, T> { 73 | self.selected = selected; 74 | self 75 | } 76 | 77 | pub fn style(mut self, style: Style) -> Tabs<'a, T> { 78 | self.style = style; 79 | self 80 | } 81 | 82 | pub fn highlight_style(mut self, style: Style) -> Tabs<'a, T> { 83 | self.highlight_style = style; 84 | self 85 | } 86 | 87 | pub fn divider(mut self, divider: &'a str) -> Tabs<'a, T> { 88 | self.divider = divider; 89 | self 90 | } 91 | } 92 | 93 | impl<'a, T> Widget for Tabs<'a, T> 94 | where 95 | T: AsRef, 96 | { 97 | fn render(mut self, area: Rect, buf: &mut Buffer) { 98 | let tabs_area = match self.block { 99 | Some(ref mut b) => { 100 | b.render(area, buf); 101 | b.inner(area) 102 | } 103 | None => area, 104 | }; 105 | 106 | if tabs_area.height < 1 { 107 | return; 108 | } 109 | 110 | buf.set_background(tabs_area, self.style.bg); 111 | 112 | let mut x = tabs_area.left(); 113 | let titles_length = self.titles.len(); 114 | let divider_width = self.divider.width() as u16; 115 | for (title, style, last_title) in self.titles.iter().enumerate().map(|(i, t)| { 116 | let lt = i + 1 == titles_length; 117 | if i == self.selected { 118 | (t, self.highlight_style, lt) 119 | } else { 120 | (t, self.style, lt) 121 | } 122 | }) { 123 | x += 1; 124 | if x > tabs_area.right() { 125 | break; 126 | } else { 127 | buf.set_string(x, tabs_area.top(), title.as_ref(), style); 128 | x += title.as_ref().width() as u16 + 1; 129 | if x >= tabs_area.right() || last_title { 130 | break; 131 | } else { 132 | buf.set_string(x, tabs_area.top(), self.divider, self.style); 133 | x += divider_width; 134 | } 135 | } 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/gauge.rs: -------------------------------------------------------------------------------- 1 | use unicode_width::UnicodeWidthStr; 2 | 3 | use crate::buffer::Buffer; 4 | use crate::layout::Rect; 5 | use crate::style::{Color, Style}; 6 | use crate::widgets::{Block, Widget}; 7 | 8 | /// A widget to display a task progress. 9 | /// 10 | /// # Examples: 11 | /// 12 | /// ``` 13 | /// # use rumatui_tui::widgets::{Widget, Gauge, Block, Borders}; 14 | /// # use rumatui_tui::style::{Style, Color, Modifier}; 15 | /// Gauge::default() 16 | /// .block(Block::default().borders(Borders::ALL).title("Progress")) 17 | /// .style(Style::default().fg(Color::White).bg(Color::Black).modifier(Modifier::ITALIC)) 18 | /// .percent(20); 19 | /// ``` 20 | pub struct Gauge<'a> { 21 | block: Option>, 22 | ratio: f64, 23 | label: Option<&'a str>, 24 | style: Style, 25 | } 26 | 27 | impl<'a> Default for Gauge<'a> { 28 | fn default() -> Gauge<'a> { 29 | Gauge { 30 | block: None, 31 | ratio: 0.0, 32 | label: None, 33 | style: Default::default(), 34 | } 35 | } 36 | } 37 | 38 | impl<'a> Gauge<'a> { 39 | pub fn block(mut self, block: Block<'a>) -> Gauge<'a> { 40 | self.block = Some(block); 41 | self 42 | } 43 | 44 | pub fn percent(mut self, percent: u16) -> Gauge<'a> { 45 | assert!( 46 | percent <= 100, 47 | "Percentage should be between 0 and 100 inclusively." 48 | ); 49 | self.ratio = f64::from(percent) / 100.0; 50 | self 51 | } 52 | 53 | /// Sets ratio ([0.0, 1.0]) directly. 54 | pub fn ratio(mut self, ratio: f64) -> Gauge<'a> { 55 | assert!( 56 | ratio <= 1.0 && ratio >= 0.0, 57 | "Ratio should be between 0 and 1 inclusively." 58 | ); 59 | self.ratio = ratio; 60 | self 61 | } 62 | 63 | pub fn label(mut self, string: &'a str) -> Gauge<'a> { 64 | self.label = Some(string); 65 | self 66 | } 67 | 68 | pub fn style(mut self, style: Style) -> Gauge<'a> { 69 | self.style = style; 70 | self 71 | } 72 | } 73 | 74 | impl<'a> Widget for Gauge<'a> { 75 | fn render(mut self, area: Rect, buf: &mut Buffer) { 76 | let gauge_area = match self.block { 77 | Some(ref mut b) => { 78 | b.render(area, buf); 79 | b.inner(area) 80 | } 81 | None => area, 82 | }; 83 | if gauge_area.height < 1 { 84 | return; 85 | } 86 | 87 | if self.style.bg != Color::Reset { 88 | buf.set_background(gauge_area, self.style.bg); 89 | } 90 | 91 | let center = gauge_area.height / 2 + gauge_area.top(); 92 | let width = (f64::from(gauge_area.width) * self.ratio).round() as u16; 93 | let end = gauge_area.left() + width; 94 | for y in gauge_area.top()..gauge_area.bottom() { 95 | // Gauge 96 | for x in gauge_area.left()..end { 97 | buf.get_mut(x, y).set_symbol(" "); 98 | } 99 | 100 | if y == center { 101 | // Label 102 | let precent_label = format!("{}%", (self.ratio * 100.0).round()); 103 | let label = self.label.unwrap_or(&precent_label); 104 | let label_width = label.width() as u16; 105 | let middle = (gauge_area.width - label_width) / 2 + gauge_area.left(); 106 | buf.set_string(middle, y, label, self.style); 107 | } 108 | 109 | // Fix colors 110 | for x in gauge_area.left()..end { 111 | buf.get_mut(x, y) 112 | .set_fg(self.style.bg) 113 | .set_bg(self.style.fg); 114 | } 115 | } 116 | } 117 | } 118 | 119 | #[cfg(test)] 120 | mod tests { 121 | use super::*; 122 | 123 | #[test] 124 | #[should_panic] 125 | fn gauge_invalid_percentage() { 126 | Gauge::default().percent(110); 127 | } 128 | 129 | #[test] 130 | #[should_panic] 131 | fn gauge_invalid_ratio_upper_bound() { 132 | Gauge::default().ratio(1.1); 133 | } 134 | 135 | #[test] 136 | #[should_panic] 137 | fn gauge_invalid_ratio_lower_bound() { 138 | Gauge::default().ratio(-0.5); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /rumatui-tui/src/style.rs: -------------------------------------------------------------------------------- 1 | use bitflags::bitflags; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq)] 4 | pub enum Color { 5 | Reset, 6 | Black, 7 | Red, 8 | Green, 9 | Yellow, 10 | Blue, 11 | Magenta, 12 | Cyan, 13 | Gray, 14 | DarkGray, 15 | LightRed, 16 | LightGreen, 17 | LightYellow, 18 | LightBlue, 19 | LightMagenta, 20 | LightCyan, 21 | White, 22 | Rgb(u8, u8, u8), 23 | Indexed(u8), 24 | } 25 | 26 | impl Color { 27 | /// Returns a short code associated with the color, used for debug purpose 28 | /// only 29 | pub(crate) fn code(self) -> &'static str { 30 | match self { 31 | Color::Reset => "X", 32 | Color::Black => "b", 33 | Color::Red => "r", 34 | Color::Green => "c", 35 | Color::Yellow => "y", 36 | Color::Blue => "b", 37 | Color::Magenta => "m", 38 | Color::Cyan => "c", 39 | Color::Gray => "w", 40 | Color::DarkGray => "B", 41 | Color::LightRed => "R", 42 | Color::LightGreen => "G", 43 | Color::LightYellow => "Y", 44 | Color::LightBlue => "B", 45 | Color::LightMagenta => "M", 46 | Color::LightCyan => "C", 47 | Color::White => "W", 48 | Color::Indexed(_) => "i", 49 | Color::Rgb(_, _, _) => "o", 50 | } 51 | } 52 | } 53 | 54 | bitflags! { 55 | pub struct Modifier: u16 { 56 | const BOLD = 0b0000_0000_0001; 57 | const DIM = 0b0000_0000_0010; 58 | const ITALIC = 0b0000_0000_0100; 59 | const UNDERLINED = 0b0000_0000_1000; 60 | const SLOW_BLINK = 0b0000_0001_0000; 61 | const RAPID_BLINK = 0b0000_0010_0000; 62 | const REVERSED = 0b0000_0100_0000; 63 | const HIDDEN = 0b0000_1000_0000; 64 | const CROSSED_OUT = 0b0001_0000_0000; 65 | } 66 | } 67 | 68 | impl Modifier { 69 | /// Returns a short code associated with the color, used for debug purpose 70 | /// only 71 | pub(crate) fn code(self) -> String { 72 | use std::fmt::Write; 73 | 74 | let mut result = String::new(); 75 | 76 | if self.contains(Modifier::BOLD) { 77 | write!(result, "BO").unwrap(); 78 | } 79 | if self.contains(Modifier::DIM) { 80 | write!(result, "DI").unwrap(); 81 | } 82 | if self.contains(Modifier::ITALIC) { 83 | write!(result, "IT").unwrap(); 84 | } 85 | if self.contains(Modifier::UNDERLINED) { 86 | write!(result, "UN").unwrap(); 87 | } 88 | if self.contains(Modifier::SLOW_BLINK) { 89 | write!(result, "SL").unwrap(); 90 | } 91 | if self.contains(Modifier::RAPID_BLINK) { 92 | write!(result, "RA").unwrap(); 93 | } 94 | if self.contains(Modifier::REVERSED) { 95 | write!(result, "RE").unwrap(); 96 | } 97 | if self.contains(Modifier::HIDDEN) { 98 | write!(result, "HI").unwrap(); 99 | } 100 | if self.contains(Modifier::CROSSED_OUT) { 101 | write!(result, "CR").unwrap(); 102 | } 103 | 104 | result 105 | } 106 | } 107 | 108 | #[derive(Debug, Clone, Copy, PartialEq)] 109 | pub struct Style { 110 | pub fg: Color, 111 | pub bg: Color, 112 | pub modifier: Modifier, 113 | } 114 | 115 | impl Default for Style { 116 | fn default() -> Style { 117 | Style::new() 118 | } 119 | } 120 | 121 | impl Style { 122 | pub const fn new() -> Self { 123 | Style { 124 | fg: Color::Reset, 125 | bg: Color::Reset, 126 | modifier: Modifier::empty(), 127 | } 128 | } 129 | pub fn reset(&mut self) { 130 | self.fg = Color::Reset; 131 | self.bg = Color::Reset; 132 | self.modifier = Modifier::empty(); 133 | } 134 | 135 | pub const fn fg(mut self, color: Color) -> Style { 136 | self.fg = color; 137 | self 138 | } 139 | pub const fn bg(mut self, color: Color) -> Style { 140 | self.bg = color; 141 | self 142 | } 143 | pub const fn modifier(mut self, modifier: Modifier) -> Style { 144 | self.modifier = modifier; 145 | self 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/sparkline.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::min; 2 | 3 | use crate::buffer::Buffer; 4 | use crate::layout::Rect; 5 | use crate::style::Style; 6 | use crate::symbols::bar; 7 | use crate::widgets::{Block, Widget}; 8 | 9 | /// Widget to render a sparkline over one or more lines. 10 | /// 11 | /// # Examples 12 | /// 13 | /// ``` 14 | /// # use rumatui_tui::widgets::{Block, Borders, Sparkline}; 15 | /// # use rumatui_tui::style::{Style, Color}; 16 | /// Sparkline::default() 17 | /// .block(Block::default().title("Sparkline").borders(Borders::ALL)) 18 | /// .data(&[0, 2, 3, 4, 1, 4, 10]) 19 | /// .max(5) 20 | /// .style(Style::default().fg(Color::Red).bg(Color::White)); 21 | /// ``` 22 | pub struct Sparkline<'a> { 23 | /// A block to wrap the widget in 24 | block: Option>, 25 | /// Widget style 26 | style: Style, 27 | /// A slice of the data to display 28 | data: &'a [u64], 29 | /// The maximum value to take to compute the maximum bar height (if nothing is specified, the 30 | /// widget uses the max of the dataset) 31 | max: Option, 32 | } 33 | 34 | impl<'a> Default for Sparkline<'a> { 35 | fn default() -> Sparkline<'a> { 36 | Sparkline { 37 | block: None, 38 | style: Default::default(), 39 | data: &[], 40 | max: None, 41 | } 42 | } 43 | } 44 | 45 | impl<'a> Sparkline<'a> { 46 | pub fn block(mut self, block: Block<'a>) -> Sparkline<'a> { 47 | self.block = Some(block); 48 | self 49 | } 50 | 51 | pub fn style(mut self, style: Style) -> Sparkline<'a> { 52 | self.style = style; 53 | self 54 | } 55 | 56 | pub fn data(mut self, data: &'a [u64]) -> Sparkline<'a> { 57 | self.data = data; 58 | self 59 | } 60 | 61 | pub fn max(mut self, max: u64) -> Sparkline<'a> { 62 | self.max = Some(max); 63 | self 64 | } 65 | } 66 | 67 | impl<'a> Widget for Sparkline<'a> { 68 | fn render(mut self, area: Rect, buf: &mut Buffer) { 69 | let spark_area = match self.block { 70 | Some(ref mut b) => { 71 | b.render(area, buf); 72 | b.inner(area) 73 | } 74 | None => area, 75 | }; 76 | 77 | if spark_area.height < 1 { 78 | return; 79 | } 80 | 81 | let max = match self.max { 82 | Some(v) => v, 83 | None => *self.data.iter().max().unwrap_or(&1u64), 84 | }; 85 | let max_index = min(spark_area.width as usize, self.data.len()); 86 | let mut data = self 87 | .data 88 | .iter() 89 | .take(max_index) 90 | .map(|e| { 91 | if max != 0 { 92 | e * u64::from(spark_area.height) * 8 / max 93 | } else { 94 | 0 95 | } 96 | }) 97 | .collect::>(); 98 | for j in (0..spark_area.height).rev() { 99 | for (i, d) in data.iter_mut().enumerate() { 100 | let symbol = match *d { 101 | 0 => " ", 102 | 1 => bar::ONE_EIGHTH, 103 | 2 => bar::ONE_QUARTER, 104 | 3 => bar::THREE_EIGHTHS, 105 | 4 => bar::HALF, 106 | 5 => bar::FIVE_EIGHTHS, 107 | 6 => bar::THREE_QUARTERS, 108 | 7 => bar::SEVEN_EIGHTHS, 109 | _ => bar::FULL, 110 | }; 111 | buf.get_mut(spark_area.left() + i as u16, spark_area.top() + j) 112 | .set_symbol(symbol) 113 | .set_fg(self.style.fg) 114 | .set_bg(self.style.bg); 115 | 116 | if *d > 8 { 117 | *d -= 8; 118 | } else { 119 | *d = 0; 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | #[cfg(test)] 127 | mod tests { 128 | use super::*; 129 | 130 | #[test] 131 | fn it_does_not_panic_if_max_is_zero() { 132 | let widget = Sparkline::default().data(&[0, 0, 0]); 133 | let area = Rect::new(0, 0, 3, 1); 134 | let mut buffer = Buffer::empty(area); 135 | widget.render(area, &mut buffer); 136 | } 137 | 138 | #[test] 139 | fn it_does_not_panic_if_max_is_set_to_zero() { 140 | let widget = Sparkline::default().data(&[0, 1, 2]).max(0); 141 | let area = Rect::new(0, 0, 3, 1); 142 | let mut buffer = Buffer::empty(area); 143 | widget.render(area, &mut buffer); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/client/ruma_ext/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::time::SystemTime; 3 | 4 | use serde_json::Value as JsonValue; 5 | 6 | use matrix_sdk::identifiers::{EventId, RoomId, UserId}; 7 | 8 | pub mod auth; 9 | pub mod message; 10 | pub mod reaction; 11 | 12 | pub use message::ExtraMessageEventContent; 13 | pub use reaction::ExtraReactionEventContent; 14 | 15 | pub type RumaUnsupportedEvent = RumaUnsupportedRoomEvent; 16 | 17 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 18 | #[serde(tag = "type")] 19 | pub enum ExtraRoomEventContent { 20 | #[serde(rename = "m.room.message")] 21 | Message { content: ExtraMessageEventContent }, 22 | #[serde(rename = "m.reaction")] 23 | Reaction { content: ExtraReactionEventContent }, 24 | } 25 | 26 | #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 27 | #[serde(bound = "C: serde::de::DeserializeOwned + serde::Serialize")] 28 | pub struct RumaUnsupportedRoomEvent { 29 | /// The event's content. 30 | #[serde(flatten)] 31 | pub content: C, 32 | 33 | /// The unique identifier for the event. 34 | pub event_id: EventId, 35 | 36 | /// Time on originating homeserver when this event was sent. 37 | #[serde(with = "ms_since_unix_epoch")] 38 | pub origin_server_ts: SystemTime, 39 | 40 | /// The unique identifier for the room associated with this event. 41 | #[serde(skip_serializing_if = "Option::is_none")] 42 | pub room_id: Option, 43 | 44 | /// The unique identifier for the user who sent this event. 45 | pub sender: UserId, 46 | 47 | /// Additional key-value pairs not signed by the homeserver. 48 | #[serde(skip_serializing_if = "BTreeMap::is_empty")] 49 | pub unsigned: BTreeMap, 50 | } 51 | 52 | /// Taken from ruma_serde as opposed to adding a dependency for two functions. 53 | /// 54 | /// Converts `js_int::UInt` to a `SystemTime` when deserializing and vice versa when 55 | /// serializing. 56 | mod ms_since_unix_epoch { 57 | use std::{ 58 | convert::TryFrom, 59 | time::{Duration, SystemTime, UNIX_EPOCH}, 60 | }; 61 | 62 | use js_int::UInt; 63 | use serde::{ 64 | de::{Deserialize, Deserializer}, 65 | ser::{Error, Serialize, Serializer}, 66 | }; 67 | 68 | /// Serialize a SystemTime. 69 | /// 70 | /// Will fail if integer is greater than the maximum integer that can be unambiguously represented 71 | /// by an f64. 72 | pub fn serialize(time: &SystemTime, serializer: S) -> Result 73 | where 74 | S: Serializer, 75 | { 76 | // If this unwrap fails, the system this is executed is completely broken. 77 | let time_since_epoch = time.duration_since(UNIX_EPOCH).unwrap(); 78 | match UInt::try_from(time_since_epoch.as_millis()) { 79 | Ok(uint) => uint.serialize(serializer), 80 | Err(err) => Err(S::Error::custom(err)), 81 | } 82 | } 83 | 84 | /// Deserializes a SystemTime. 85 | /// 86 | /// Will fail if integer is greater than the maximum integer that can be unambiguously represented 87 | /// by an f64. 88 | pub fn deserialize<'de, D>(deserializer: D) -> Result 89 | where 90 | D: Deserializer<'de>, 91 | { 92 | let millis = UInt::deserialize(deserializer)?; 93 | Ok(UNIX_EPOCH + Duration::from_millis(millis.into())) 94 | } 95 | } 96 | 97 | #[test] 98 | fn test_message_edit_event() { 99 | use matrix_sdk::events::EventJson; 100 | 101 | let ev = serde_json::from_str::>(include_str!( 102 | "../../../test_data/message_edit.json" 103 | )) 104 | .unwrap() 105 | .deserialize() 106 | .unwrap(); 107 | 108 | let json = serde_json::to_string_pretty(&ev).unwrap(); 109 | assert_eq!( 110 | ev, 111 | serde_json::from_str::>(&json) 112 | .unwrap() 113 | .deserialize() 114 | .unwrap() 115 | ) 116 | } 117 | 118 | #[test] 119 | fn test_reaction_event() { 120 | use matrix_sdk::events::EventJson; 121 | 122 | let ev = serde_json::from_str::>(include_str!( 123 | "../../../test_data/reaction.json" 124 | )) 125 | .unwrap() 126 | .deserialize() 127 | .unwrap(); 128 | 129 | let json = serde_json::to_string_pretty(&ev).unwrap(); 130 | assert_eq!( 131 | ev, 132 | serde_json::from_str::>(&json) 133 | .unwrap() 134 | .deserialize() 135 | .unwrap() 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /rumatui-tui/src/symbols.rs: -------------------------------------------------------------------------------- 1 | pub mod block { 2 | pub const FULL: &str = "█"; 3 | pub const SEVEN_EIGHTHS: &str = "▉"; 4 | pub const THREE_QUARTERS: &str = "▊"; 5 | pub const FIVE_EIGHTHS: &str = "▋"; 6 | pub const HALF: &str = "▌"; 7 | pub const THREE_EIGHTHS: &str = "▍"; 8 | pub const ONE_QUARTER: &str = "▎"; 9 | pub const ONE_EIGHTH: &str = "▏"; 10 | } 11 | 12 | pub mod bar { 13 | pub const FULL: &str = "█"; 14 | pub const SEVEN_EIGHTHS: &str = "▇"; 15 | pub const THREE_QUARTERS: &str = "▆"; 16 | pub const FIVE_EIGHTHS: &str = "▅"; 17 | pub const HALF: &str = "▄"; 18 | pub const THREE_EIGHTHS: &str = "▃"; 19 | pub const ONE_QUARTER: &str = "▂"; 20 | pub const ONE_EIGHTH: &str = "▁"; 21 | } 22 | 23 | pub mod line { 24 | pub const VERTICAL: &str = "│"; 25 | pub const DOUBLE_VERTICAL: &str = "║"; 26 | pub const THICK_VERTICAL: &str = "┃"; 27 | 28 | pub const HORIZONTAL: &str = "─"; 29 | pub const DOUBLE_HORIZONTAL: &str = "═"; 30 | pub const THICK_HORIZONTAL: &str = "━"; 31 | 32 | pub const TOP_RIGHT: &str = "┐"; 33 | pub const ROUNDED_TOP_RIGHT: &str = "╮"; 34 | pub const DOUBLE_TOP_RIGHT: &str = "╗"; 35 | pub const THICK_TOP_RIGHT: &str = "┓"; 36 | 37 | pub const TOP_LEFT: &str = "┌"; 38 | pub const ROUNDED_TOP_LEFT: &str = "╭"; 39 | pub const DOUBLE_TOP_LEFT: &str = "╔"; 40 | pub const THICK_TOP_LEFT: &str = "┏"; 41 | 42 | pub const BOTTOM_RIGHT: &str = "┘"; 43 | pub const ROUNDED_BOTTOM_RIGHT: &str = "╯"; 44 | pub const DOUBLE_BOTTOM_RIGHT: &str = "╝"; 45 | pub const THICK_BOTTOM_RIGHT: &str = "┛"; 46 | 47 | pub const BOTTOM_LEFT: &str = "└"; 48 | pub const ROUNDED_BOTTOM_LEFT: &str = "╰"; 49 | pub const DOUBLE_BOTTOM_LEFT: &str = "╚"; 50 | pub const THICK_BOTTOM_LEFT: &str = "┗"; 51 | 52 | pub const VERTICAL_LEFT: &str = "┤"; 53 | pub const DOUBLE_VERTICAL_LEFT: &str = "╣"; 54 | pub const THICK_VERTICAL_LEFT: &str = "┫"; 55 | 56 | pub const VERTICAL_RIGHT: &str = "├"; 57 | pub const DOUBLE_VERTICAL_RIGHT: &str = "╠"; 58 | pub const THICK_VERTICAL_RIGHT: &str = "┣"; 59 | 60 | pub const HORIZONTAL_DOWN: &str = "┬"; 61 | pub const DOUBLE_HORIZONTAL_DOWN: &str = "╦"; 62 | pub const THICK_HORIZONTAL_DOWN: &str = "┳"; 63 | 64 | pub const HORIZONTAL_UP: &str = "┴"; 65 | pub const DOUBLE_HORIZONTAL_UP: &str = "╩"; 66 | pub const THICK_HORIZONTAL_UP: &str = "┻"; 67 | 68 | pub const CROSS: &str = "┼"; 69 | pub const DOUBLE_CROSS: &str = "╬"; 70 | pub const THICK_CROSS: &str = "╋"; 71 | 72 | pub struct Set { 73 | pub vertical: &'static str, 74 | pub horizontal: &'static str, 75 | pub top_right: &'static str, 76 | pub top_left: &'static str, 77 | pub bottom_right: &'static str, 78 | pub bottom_left: &'static str, 79 | pub vertical_left: &'static str, 80 | pub vertical_right: &'static str, 81 | pub horizontal_down: &'static str, 82 | pub horizontal_up: &'static str, 83 | pub cross: &'static str, 84 | } 85 | 86 | pub const NORMAL: Set = Set { 87 | vertical: VERTICAL, 88 | horizontal: HORIZONTAL, 89 | top_right: TOP_RIGHT, 90 | top_left: TOP_LEFT, 91 | bottom_right: BOTTOM_RIGHT, 92 | bottom_left: BOTTOM_LEFT, 93 | vertical_left: VERTICAL_LEFT, 94 | vertical_right: VERTICAL_RIGHT, 95 | horizontal_down: HORIZONTAL_DOWN, 96 | horizontal_up: HORIZONTAL_UP, 97 | cross: CROSS, 98 | }; 99 | 100 | pub const ROUNDED: Set = Set { 101 | top_right: ROUNDED_TOP_RIGHT, 102 | top_left: ROUNDED_TOP_LEFT, 103 | bottom_right: ROUNDED_BOTTOM_RIGHT, 104 | bottom_left: ROUNDED_BOTTOM_LEFT, 105 | ..NORMAL 106 | }; 107 | 108 | pub const DOUBLE: Set = Set { 109 | vertical: DOUBLE_VERTICAL, 110 | horizontal: DOUBLE_HORIZONTAL, 111 | top_right: DOUBLE_TOP_RIGHT, 112 | top_left: DOUBLE_TOP_LEFT, 113 | bottom_right: DOUBLE_BOTTOM_RIGHT, 114 | bottom_left: DOUBLE_BOTTOM_LEFT, 115 | vertical_left: DOUBLE_VERTICAL_LEFT, 116 | vertical_right: DOUBLE_VERTICAL_RIGHT, 117 | horizontal_down: DOUBLE_HORIZONTAL_DOWN, 118 | horizontal_up: DOUBLE_HORIZONTAL_UP, 119 | cross: DOUBLE_CROSS, 120 | }; 121 | 122 | pub const THICK: Set = Set { 123 | vertical: THICK_VERTICAL, 124 | horizontal: THICK_HORIZONTAL, 125 | top_right: THICK_TOP_RIGHT, 126 | top_left: THICK_TOP_LEFT, 127 | bottom_right: THICK_BOTTOM_RIGHT, 128 | bottom_left: THICK_BOTTOM_LEFT, 129 | vertical_left: THICK_VERTICAL_LEFT, 130 | vertical_right: THICK_VERTICAL_RIGHT, 131 | horizontal_down: THICK_HORIZONTAL_DOWN, 132 | horizontal_up: THICK_HORIZONTAL_UP, 133 | cross: THICK_CROSS, 134 | }; 135 | } 136 | 137 | pub const DOT: &str = "•"; 138 | -------------------------------------------------------------------------------- /rumatui-tui/tests/paragraph.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::backend::TestBackend; 2 | use rumatui_tui::buffer::Buffer; 3 | use rumatui_tui::layout::Alignment; 4 | use rumatui_tui::widgets::{Block, Borders, Paragraph, Text}; 5 | use rumatui_tui::Terminal; 6 | 7 | const SAMPLE_STRING: &str = "The library is based on the principle of immediate rendering with \ 8 | intermediate buffers. This means that at each new frame you should build all widgets that are \ 9 | supposed to be part of the UI. While providing a great flexibility for rich and \ 10 | interactive UI, this may introduce overhead for highly dynamic content."; 11 | 12 | #[test] 13 | fn paragraph_render_wrap() { 14 | let render = |alignment| { 15 | let backend = TestBackend::new(20, 10); 16 | let mut terminal = Terminal::new(backend).unwrap(); 17 | 18 | terminal 19 | .draw(|mut f| { 20 | let size = f.size(); 21 | let text = [Text::raw(SAMPLE_STRING)]; 22 | let paragraph = Paragraph::new(text.iter()) 23 | .block(Block::default().borders(Borders::ALL)) 24 | .alignment(alignment) 25 | .wrap(true); 26 | f.render_widget(paragraph, size); 27 | }) 28 | .unwrap(); 29 | terminal.backend().buffer().clone() 30 | }; 31 | 32 | assert_eq!( 33 | render(Alignment::Left), 34 | Buffer::with_lines(vec![ 35 | "┌──────────────────┐", 36 | "│The library is │", 37 | "│based on the │", 38 | "│principle of │", 39 | "│immediate │", 40 | "│rendering with │", 41 | "│intermediate │", 42 | "│buffers. This │", 43 | "│means that at each│", 44 | "└──────────────────┘", 45 | ]) 46 | ); 47 | assert_eq!( 48 | render(Alignment::Right), 49 | Buffer::with_lines(vec![ 50 | "┌──────────────────┐", 51 | "│ The library is│", 52 | "│ based on the│", 53 | "│ principle of│", 54 | "│ immediate│", 55 | "│ rendering with│", 56 | "│ intermediate│", 57 | "│ buffers. This│", 58 | "│means that at each│", 59 | "└──────────────────┘", 60 | ]) 61 | ); 62 | assert_eq!( 63 | render(Alignment::Center), 64 | Buffer::with_lines(vec![ 65 | "┌──────────────────┐", 66 | "│ The library is │", 67 | "│ based on the │", 68 | "│ principle of │", 69 | "│ immediate │", 70 | "│ rendering with │", 71 | "│ intermediate │", 72 | "│ buffers. This │", 73 | "│means that at each│", 74 | "└──────────────────┘", 75 | ]) 76 | ); 77 | } 78 | 79 | #[test] 80 | fn paragraph_render_double_width() { 81 | let backend = TestBackend::new(10, 10); 82 | let mut terminal = Terminal::new(backend).unwrap(); 83 | 84 | let s = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点では、"; 85 | terminal 86 | .draw(|mut f| { 87 | let size = f.size(); 88 | let text = [Text::raw(s)]; 89 | let paragraph = Paragraph::new(text.iter()) 90 | .block(Block::default().borders(Borders::ALL)) 91 | .wrap(true); 92 | f.render_widget(paragraph, size); 93 | }) 94 | .unwrap(); 95 | 96 | let expected = Buffer::with_lines(vec![ 97 | "┌────────┐", 98 | "│コンピュ│", 99 | "│ータ上で│", 100 | "│文字を扱│", 101 | "│う場合、│", 102 | "│典型的に│", 103 | "│は文字に│", 104 | "│よる通信│", 105 | "│を行う場│", 106 | "└────────┘", 107 | ]); 108 | assert_eq!(&expected, terminal.backend().buffer()); 109 | } 110 | 111 | #[test] 112 | fn paragraph_render_mixed_width() { 113 | let backend = TestBackend::new(10, 7); 114 | let mut terminal = Terminal::new(backend).unwrap(); 115 | 116 | let s = "aコンピュータ上で文字を扱う場合、"; 117 | terminal 118 | .draw(|mut f| { 119 | let size = f.size(); 120 | let text = [Text::raw(s)]; 121 | let paragraph = Paragraph::new(text.iter()) 122 | .block(Block::default().borders(Borders::ALL)) 123 | .wrap(true); 124 | f.render_widget(paragraph, size); 125 | }) 126 | .unwrap(); 127 | 128 | let expected = Buffer::with_lines(vec![ 129 | // The internal width is 8 so only 4 slots for double-width characters. 130 | "┌────────┐", 131 | "│aコンピ │", // Here we have 1 latin character so only 3 double-width ones can fit. 132 | "│ュータ上│", 133 | "│で文字を│", 134 | "│扱う場合│", 135 | "│、 │", 136 | "└────────┘", 137 | ]); 138 | assert_eq!(&expected, terminal.backend().buffer()); 139 | } 140 | -------------------------------------------------------------------------------- /rumatui-tui/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! [tui](https://github.com/fdehau/tui-rs) is a library used to build rich 2 | //! terminal users interfaces and dashboards. 3 | //! 4 | //! ![](https://raw.githubusercontent.com/fdehau/tui-rs/master/assets/demo.gif) 5 | //! 6 | //! # Get started 7 | //! 8 | //! ## Adding `tui` as a dependency 9 | //! 10 | //! ```toml 11 | //! [dependencies] 12 | //! tui = "0.8" 13 | //! termion = "1.5" 14 | //! ``` 15 | //! 16 | //! The crate is using the `termion` backend by default but if for example you want your 17 | //! application to work on Windows, you might want to use the `crossterm` backend instead. This can 18 | //! be done by changing your dependencies specification to the following: 19 | //! 20 | //! ```toml 21 | //! [dependencies] 22 | //! crossterm = "0.14" 23 | //! tui = { version = "0.8", default-features = false, features = ['crossterm'] } 24 | //! ``` 25 | //! 26 | //! The same logic applies for all other available backends. 27 | //! 28 | //! ## Creating a `Terminal` 29 | //! 30 | //! Every application using `tui` should start by instantiating a `Terminal`. It is a light 31 | //! abstraction over available backends that provides basic functionalities such as clearing the 32 | //! screen, hiding the cursor, etc. 33 | //! 34 | //! ```rust,no_run 35 | //! use std::io; 36 | //! use rumatui_tui::Terminal; 37 | //! use rumatui_tui::backend::TermionBackend; 38 | //! use termion::raw::IntoRawMode; 39 | //! 40 | //! fn main() -> Result<(), io::Error> { 41 | //! let stdout = io::stdout().into_raw_mode()?; 42 | //! let backend = TermionBackend::new(stdout); 43 | //! let mut terminal = Terminal::new(backend)?; 44 | //! Ok(()) 45 | //! } 46 | //! ``` 47 | //! 48 | //! If you had previously chosen `rustbox` as a backend, the terminal can be created in a similar 49 | //! way: 50 | //! 51 | //! ```rust,ignore 52 | //! use rumatui_tui::Terminal; 53 | //! use rumatui_tui::backend::RustboxBackend; 54 | //! 55 | //! fn main() -> Result<(), io::Error> { 56 | //! let backend = RustboxBackend::new()?; 57 | //! let mut terminal = Terminal::new(backend); 58 | //! Ok(()) 59 | //! } 60 | //! ``` 61 | //! 62 | //! You may also refer to the examples to find out how to create a `Terminal` for each available 63 | //! backend. 64 | //! 65 | //! ## Building a User Interface (UI) 66 | //! 67 | //! Every component of your interface will be implementing the `Widget` trait. The library comes 68 | //! with a predefined set of widgets that should meet most of your use cases. You are also free to 69 | //! implement your own. 70 | //! 71 | //! Each widget follows a builder pattern API providing a default configuration along with methods 72 | //! to customize them. The widget is then registered using its `render` method that take a `Frame` 73 | //! instance and an area to draw to. 74 | //! 75 | //! The following example renders a block of the size of the terminal: 76 | //! 77 | //! ```rust,no_run 78 | //! use std::io; 79 | //! use termion::raw::IntoRawMode; 80 | //! use rumatui_tui::Terminal; 81 | //! use rumatui_tui::backend::TermionBackend; 82 | //! use rumatui_tui::widgets::{Widget, Block, Borders}; 83 | //! use rumatui_tui::layout::{Layout, Constraint, Direction}; 84 | //! 85 | //! fn main() -> Result<(), io::Error> { 86 | //! let stdout = io::stdout().into_raw_mode()?; 87 | //! let backend = TermionBackend::new(stdout); 88 | //! let mut terminal = Terminal::new(backend)?; 89 | //! terminal.draw(|mut f| { 90 | //! let size = f.size(); 91 | //! let block = Block::default() 92 | //! .title("Block") 93 | //! .borders(Borders::ALL); 94 | //! f.render_widget(block, size); 95 | //! }) 96 | //! } 97 | //! ``` 98 | //! 99 | //! ## Layout 100 | //! 101 | //! The library comes with a basic yet useful layout management object called `Layout`. As you may 102 | //! see below and in the examples, the library makes heavy use of the builder pattern to provide 103 | //! full customization. And `Layout` is no exception: 104 | //! 105 | //! ```rust,no_run 106 | //! use std::io; 107 | //! use termion::raw::IntoRawMode; 108 | //! use rumatui_tui::Terminal; 109 | //! use rumatui_tui::backend::TermionBackend; 110 | //! use rumatui_tui::widgets::{Widget, Block, Borders}; 111 | //! use rumatui_tui::layout::{Layout, Constraint, Direction}; 112 | //! 113 | //! fn main() -> Result<(), io::Error> { 114 | //! let stdout = io::stdout().into_raw_mode()?; 115 | //! let backend = TermionBackend::new(stdout); 116 | //! let mut terminal = Terminal::new(backend)?; 117 | //! terminal.draw(|mut f| { 118 | //! let chunks = Layout::default() 119 | //! .direction(Direction::Vertical) 120 | //! .margin(1) 121 | //! .constraints( 122 | //! [ 123 | //! Constraint::Percentage(10), 124 | //! Constraint::Percentage(80), 125 | //! Constraint::Percentage(10) 126 | //! ].as_ref() 127 | //! ) 128 | //! .split(f.size()); 129 | //! let block = Block::default() 130 | //! .title("Block") 131 | //! .borders(Borders::ALL); 132 | //! f.render_widget(block, chunks[0]); 133 | //! let block = Block::default() 134 | //! .title("Block 2") 135 | //! .borders(Borders::ALL); 136 | //! f.render_widget(block, chunks[1]); 137 | //! }) 138 | //! } 139 | //! ``` 140 | //! 141 | //! This let you describe responsive terminal UI by nesting layouts. You should note that by 142 | //! default the computed layout tries to fill the available space completely. So if for any reason 143 | //! you might need a blank space somewhere, try to pass an additional constraint and don't use the 144 | //! corresponding area. 145 | 146 | #![deny(warnings)] 147 | 148 | pub mod backend; 149 | pub mod buffer; 150 | pub mod layout; 151 | pub mod style; 152 | pub mod symbols; 153 | pub mod terminal; 154 | pub mod widgets; 155 | 156 | pub use self::terminal::{Frame, Terminal}; 157 | -------------------------------------------------------------------------------- /rumatui-tui/src/terminal.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crate::backend::Backend; 4 | use crate::buffer::Buffer; 5 | use crate::layout::Rect; 6 | use crate::widgets::{StatefulWidget, Widget}; 7 | 8 | /// Interface to the terminal backed by Termion 9 | #[derive(Debug)] 10 | pub struct Terminal 11 | where 12 | B: Backend, 13 | { 14 | backend: B, 15 | /// Holds the results of the current and previous draw calls. The two are compared at the end 16 | /// of each draw pass to output the necessary updates to the terminal 17 | buffers: [Buffer; 2], 18 | /// Index of the current buffer in the previous array 19 | current: usize, 20 | /// Whether the cursor is currently hidden 21 | hidden_cursor: bool, 22 | /// Terminal size used for rendering. 23 | known_size: Rect, 24 | } 25 | 26 | /// Represents a consistent terminal interface for rendering. 27 | pub struct Frame<'a, B: 'a> 28 | where 29 | B: Backend, 30 | { 31 | terminal: &'a mut Terminal, 32 | } 33 | 34 | impl<'a, B> Frame<'a, B> 35 | where 36 | B: Backend, 37 | { 38 | /// Terminal size, guaranteed not to change when rendering. 39 | pub fn size(&self) -> Rect { 40 | self.terminal.known_size 41 | } 42 | 43 | /// Calls the draw method of a given widget on the current buffer 44 | pub fn render_widget(&mut self, widget: W, area: Rect) 45 | where 46 | W: Widget, 47 | { 48 | widget.render(area, self.terminal.current_buffer_mut()); 49 | } 50 | 51 | pub fn render_stateful_widget(&mut self, widget: W, area: Rect, state: &mut W::State) 52 | where 53 | W: StatefulWidget, 54 | { 55 | widget.render(area, self.terminal.current_buffer_mut(), state); 56 | } 57 | } 58 | 59 | impl Drop for Terminal 60 | where 61 | B: Backend, 62 | { 63 | fn drop(&mut self) { 64 | // Attempt to restore the cursor state 65 | if self.hidden_cursor { 66 | if let Err(err) = self.show_cursor() { 67 | eprintln!("Failed to show the cursor: {}", err); 68 | } 69 | } 70 | } 71 | } 72 | 73 | impl Terminal 74 | where 75 | B: Backend, 76 | { 77 | /// Wrapper around Termion initialization. Each buffer is initialized with a blank string and 78 | /// default colors for the foreground and the background 79 | pub fn new(backend: B) -> io::Result> { 80 | let size = backend.size()?; 81 | Ok(Terminal { 82 | backend, 83 | buffers: [Buffer::empty(size), Buffer::empty(size)], 84 | current: 0, 85 | hidden_cursor: false, 86 | known_size: size, 87 | }) 88 | } 89 | 90 | /// Get a Frame object which provides a consistent view into the terminal state for rendering. 91 | pub fn get_frame(&mut self) -> Frame { 92 | Frame { terminal: self } 93 | } 94 | 95 | pub fn current_buffer_mut(&mut self) -> &mut Buffer { 96 | &mut self.buffers[self.current] 97 | } 98 | 99 | pub fn backend(&self) -> &B { 100 | &self.backend 101 | } 102 | 103 | pub fn backend_mut(&mut self) -> &mut B { 104 | &mut self.backend 105 | } 106 | 107 | /// Obtains a difference between the previous and the current buffer and passes it to the 108 | /// current backend for drawing. 109 | pub fn flush(&mut self) -> io::Result<()> { 110 | let previous_buffer = &self.buffers[1 - self.current]; 111 | let current_buffer = &self.buffers[self.current]; 112 | let updates = previous_buffer.diff(current_buffer); 113 | self.backend.draw(updates.into_iter()) 114 | } 115 | 116 | /// Updates the Terminal so that internal buffers match the requested size. Requested size will 117 | /// be saved so the size can remain consistent when rendering. 118 | /// This leads to a full clear of the screen. 119 | pub fn resize(&mut self, area: Rect) -> io::Result<()> { 120 | self.buffers[self.current].resize(area); 121 | self.buffers[1 - self.current].reset(); 122 | self.buffers[1 - self.current].resize(area); 123 | self.known_size = area; 124 | self.backend.clear() 125 | } 126 | 127 | /// Queries the backend for size and resizes if it doesn't match the previous size. 128 | pub fn autoresize(&mut self) -> io::Result<()> { 129 | let size = self.size()?; 130 | if self.known_size != size { 131 | self.resize(size)?; 132 | } 133 | Ok(()) 134 | } 135 | 136 | /// Synchronizes terminal size, calls the rendering closure, flushes the current internal state 137 | /// and prepares for the next draw call. 138 | pub fn draw(&mut self, f: F) -> io::Result<()> 139 | where 140 | F: FnOnce(Frame), 141 | { 142 | // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets 143 | // and the terminal (if growing), which may OOB. 144 | self.autoresize()?; 145 | 146 | f(self.get_frame()); 147 | 148 | // Draw to stdout 149 | self.flush()?; 150 | 151 | // Swap buffers 152 | self.buffers[1 - self.current].reset(); 153 | self.current = 1 - self.current; 154 | 155 | // Flush 156 | self.backend.flush()?; 157 | Ok(()) 158 | } 159 | 160 | pub fn hide_cursor(&mut self) -> io::Result<()> { 161 | self.backend.hide_cursor()?; 162 | self.hidden_cursor = true; 163 | Ok(()) 164 | } 165 | pub fn show_cursor(&mut self) -> io::Result<()> { 166 | self.backend.show_cursor()?; 167 | self.hidden_cursor = false; 168 | Ok(()) 169 | } 170 | pub fn get_cursor(&mut self) -> io::Result<(u16, u16)> { 171 | self.backend.get_cursor() 172 | } 173 | pub fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { 174 | self.backend.set_cursor(x, y) 175 | } 176 | pub fn clear(&mut self) -> io::Result<()> { 177 | self.backend.clear() 178 | } 179 | /// Queries the real size of the backend. 180 | pub fn size(&self) -> io::Result { 181 | self.backend.size() 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/widgets/register.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::{ 2 | backend::Backend, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | widgets::{Block, Borders, Paragraph, Text}, 6 | Frame, 7 | }; 8 | use termion::event::MouseButton; 9 | 10 | use crate::widgets::{login::Loading, RenderWidget}; 11 | 12 | #[repr(u8)] 13 | #[derive(Clone, Copy, Debug, PartialEq)] 14 | pub enum RegisterSelect { 15 | Username = 0, 16 | Password, 17 | } 18 | impl Default for RegisterSelect { 19 | fn default() -> Self { 20 | Self::Username 21 | } 22 | } 23 | #[derive(Clone, Debug, Default)] 24 | pub struct Register { 25 | pub selected: RegisterSelect, 26 | pub username: String, 27 | pub password: String, 28 | } 29 | 30 | #[derive(Clone, Debug, Default)] 31 | pub struct RegisterWidget { 32 | user_area: Rect, 33 | password_area: Rect, 34 | pub register: Register, 35 | pub registering: bool, 36 | pub registered: bool, 37 | pub waiting: Loading, 38 | pub homeserver: Option, 39 | } 40 | 41 | impl RegisterWidget { 42 | pub(crate) fn try_register(&self) -> bool { 43 | RegisterSelect::Password == self.register.selected 44 | && !self.register.password.is_empty() 45 | && !self.register.username.is_empty() 46 | } 47 | 48 | pub(crate) fn clear_register(&mut self) { 49 | // self.register.username.clear(); 50 | // self.register.password.clear(); 51 | } 52 | 53 | /// If right mouse button and clicked within the area of the username or 54 | /// password field the respective text box is selected. 55 | pub fn on_click(&mut self, btn: MouseButton, x: u16, y: u16) { 56 | if let MouseButton::Left = btn { 57 | if self.user_area.intersects(Rect::new(x, y, 1, 1)) { 58 | self.register.selected = RegisterSelect::Username; 59 | } else if self.password_area.intersects(Rect::new(x, y, 1, 1)) { 60 | self.register.selected = RegisterSelect::Password; 61 | } 62 | } 63 | } 64 | } 65 | 66 | impl RenderWidget for RegisterWidget { 67 | fn render(&mut self, f: &mut Frame, area: Rect) 68 | where 69 | B: Backend, 70 | { 71 | let chunks = Layout::default() 72 | .horizontal_margin(40) 73 | .constraints( 74 | [ 75 | Constraint::Percentage(15), 76 | Constraint::Percentage(60), 77 | Constraint::Percentage(15), 78 | ] 79 | .as_ref(), 80 | ) 81 | .split(area); 82 | 83 | let server = self.homeserver.as_deref().unwrap_or("matrix.org"); 84 | let register = &format!("Register account on {}", server); 85 | let blk = Block::default() 86 | .title(register) 87 | .title_style(Style::default().fg(Color::Green).modifier(Modifier::BOLD)) 88 | .borders(Borders::ALL); 89 | f.render_widget(blk, chunks[1]); 90 | 91 | let height_chunk = Layout::default() 92 | .direction(Direction::Vertical) 93 | .constraints( 94 | [ 95 | Constraint::Percentage(20), 96 | Constraint::Percentage(30), 97 | Constraint::Percentage(30), 98 | Constraint::Percentage(20), 99 | ] 100 | .as_ref(), 101 | ) 102 | .split(chunks[1]); 103 | 104 | let width_chunk1 = Layout::default() 105 | .direction(Direction::Horizontal) 106 | .constraints( 107 | [ 108 | Constraint::Percentage(25), 109 | Constraint::Percentage(50), 110 | Constraint::Percentage(25), 111 | ] 112 | .as_ref(), 113 | ) 114 | .split(height_chunk[1]); 115 | 116 | if self.registering { 117 | self.waiting.tick(width_chunk1[1].width); 118 | let blk = Block::default() 119 | .title("Registering") 120 | .border_style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)) 121 | .borders(Borders::ALL); 122 | 123 | let t = [Text::styled( 124 | "*".repeat(self.waiting.count), 125 | Style::default().fg(Color::Magenta), 126 | )]; 127 | let p = Paragraph::new(t.iter()) 128 | .block(blk) 129 | .alignment(Alignment::Center); 130 | 131 | f.render_widget(p, width_chunk1[1]); 132 | } else { 133 | let (high_user, high_pass) = if self.register.selected == RegisterSelect::Username { 134 | ( 135 | Block::default() 136 | .title("User Name") 137 | .border_style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)) 138 | .borders(Borders::ALL), 139 | Block::default().title("Password").borders(Borders::ALL), 140 | ) 141 | } else { 142 | ( 143 | Block::default().title("User Name").borders(Borders::ALL), 144 | Block::default() 145 | .title("Password") 146 | .border_style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)) 147 | .borders(Borders::ALL), 148 | ) 149 | }; 150 | 151 | // password width using password height 152 | let width_chunk2 = Layout::default() 153 | .direction(Direction::Horizontal) 154 | .constraints( 155 | [ 156 | Constraint::Percentage(25), 157 | Constraint::Percentage(50), 158 | Constraint::Percentage(25), 159 | ] 160 | .as_ref(), 161 | ) 162 | .split(height_chunk[2]); 163 | 164 | self.user_area = width_chunk1[1]; 165 | self.password_area = width_chunk2[1]; 166 | 167 | // User name 168 | let t = [Text::styled( 169 | &self.register.username, 170 | Style::default().fg(Color::Cyan), 171 | )]; 172 | let p = Paragraph::new(t.iter()).block(high_user); 173 | 174 | f.render_widget(p, width_chunk1[1]); 175 | 176 | // Password from here down 177 | let t2 = [Text::styled( 178 | "*".repeat(self.register.password.len()), 179 | Style::default().fg(Color::Cyan), 180 | )]; 181 | let p2 = Paragraph::new(t2.iter()).block(high_pass); 182 | 183 | f.render_widget(p2, width_chunk2[1]) 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/barchart.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::{max, min}; 2 | 3 | use unicode_width::UnicodeWidthStr; 4 | 5 | use crate::buffer::Buffer; 6 | use crate::layout::Rect; 7 | use crate::style::Style; 8 | use crate::symbols::bar; 9 | use crate::widgets::{Block, Widget}; 10 | 11 | /// Display multiple bars in a single widgets 12 | /// 13 | /// # Examples 14 | /// 15 | /// ``` 16 | /// # use rumatui_tui::widgets::{Block, Borders, BarChart}; 17 | /// # use rumatui_tui::style::{Style, Color, Modifier}; 18 | /// BarChart::default() 19 | /// .block(Block::default().title("BarChart").borders(Borders::ALL)) 20 | /// .bar_width(3) 21 | /// .bar_gap(1) 22 | /// .style(Style::default().fg(Color::Yellow).bg(Color::Red)) 23 | /// .value_style(Style::default().fg(Color::Red).modifier(Modifier::BOLD)) 24 | /// .label_style(Style::default().fg(Color::White)) 25 | /// .data(&[("B0", 0), ("B1", 2), ("B2", 4), ("B3", 3)]) 26 | /// .max(4); 27 | /// ``` 28 | pub struct BarChart<'a> { 29 | /// Block to wrap the widget in 30 | block: Option>, 31 | /// The width of each bar 32 | bar_width: u16, 33 | /// The gap between each bar 34 | bar_gap: u16, 35 | /// Style of the values printed at the bottom of each bar 36 | value_style: Style, 37 | /// Style of the labels printed under each bar 38 | label_style: Style, 39 | /// Style for the widget 40 | style: Style, 41 | /// Slice of (label, value) pair to plot on the chart 42 | data: &'a [(&'a str, u64)], 43 | /// Value necessary for a bar to reach the maximum height (if no value is specified, 44 | /// the maximum value in the data is taken as reference) 45 | max: Option, 46 | /// Values to display on the bar (computed when the data is passed to the widget) 47 | values: Vec, 48 | } 49 | 50 | impl<'a> Default for BarChart<'a> { 51 | fn default() -> BarChart<'a> { 52 | BarChart { 53 | block: None, 54 | max: None, 55 | data: &[], 56 | values: Vec::new(), 57 | bar_width: 1, 58 | bar_gap: 1, 59 | value_style: Default::default(), 60 | label_style: Default::default(), 61 | style: Default::default(), 62 | } 63 | } 64 | } 65 | 66 | impl<'a> BarChart<'a> { 67 | pub fn data(mut self, data: &'a [(&'a str, u64)]) -> BarChart<'a> { 68 | self.data = data; 69 | self.values = Vec::with_capacity(self.data.len()); 70 | for &(_, v) in self.data { 71 | self.values.push(format!("{}", v)); 72 | } 73 | self 74 | } 75 | 76 | pub fn block(mut self, block: Block<'a>) -> BarChart<'a> { 77 | self.block = Some(block); 78 | self 79 | } 80 | pub fn max(mut self, max: u64) -> BarChart<'a> { 81 | self.max = Some(max); 82 | self 83 | } 84 | 85 | pub fn bar_width(mut self, width: u16) -> BarChart<'a> { 86 | self.bar_width = width; 87 | self 88 | } 89 | pub fn bar_gap(mut self, gap: u16) -> BarChart<'a> { 90 | self.bar_gap = gap; 91 | self 92 | } 93 | pub fn value_style(mut self, style: Style) -> BarChart<'a> { 94 | self.value_style = style; 95 | self 96 | } 97 | pub fn label_style(mut self, style: Style) -> BarChart<'a> { 98 | self.label_style = style; 99 | self 100 | } 101 | pub fn style(mut self, style: Style) -> BarChart<'a> { 102 | self.style = style; 103 | self 104 | } 105 | } 106 | 107 | impl<'a> Widget for BarChart<'a> { 108 | fn render(mut self, area: Rect, buf: &mut Buffer) { 109 | let chart_area = match self.block { 110 | Some(ref mut b) => { 111 | b.render(area, buf); 112 | b.inner(area) 113 | } 114 | None => area, 115 | }; 116 | 117 | if chart_area.height < 2 { 118 | return; 119 | } 120 | 121 | buf.set_background(chart_area, self.style.bg); 122 | 123 | let max = self 124 | .max 125 | .unwrap_or_else(|| self.data.iter().fold(0, |acc, &(_, v)| max(v, acc))); 126 | let max_index = min( 127 | (chart_area.width / (self.bar_width + self.bar_gap)) as usize, 128 | self.data.len(), 129 | ); 130 | let mut data = self 131 | .data 132 | .iter() 133 | .take(max_index) 134 | .map(|&(l, v)| { 135 | ( 136 | l, 137 | v * u64::from(chart_area.height) * 8 / std::cmp::max(max, 1), 138 | ) 139 | }) 140 | .collect::>(); 141 | for j in (0..chart_area.height - 1).rev() { 142 | for (i, d) in data.iter_mut().enumerate() { 143 | let symbol = match d.1 { 144 | 0 => " ", 145 | 1 => bar::ONE_EIGHTH, 146 | 2 => bar::ONE_QUARTER, 147 | 3 => bar::THREE_EIGHTHS, 148 | 4 => bar::HALF, 149 | 5 => bar::FIVE_EIGHTHS, 150 | 6 => bar::THREE_QUARTERS, 151 | 7 => bar::SEVEN_EIGHTHS, 152 | _ => bar::FULL, 153 | }; 154 | 155 | for x in 0..self.bar_width { 156 | buf.get_mut( 157 | chart_area.left() + i as u16 * (self.bar_width + self.bar_gap) + x, 158 | chart_area.top() + j, 159 | ) 160 | .set_symbol(symbol) 161 | .set_style(self.style); 162 | } 163 | 164 | if d.1 > 8 { 165 | d.1 -= 8; 166 | } else { 167 | d.1 = 0; 168 | } 169 | } 170 | } 171 | 172 | for (i, &(label, value)) in self.data.iter().take(max_index).enumerate() { 173 | if value != 0 { 174 | let value_label = &self.values[i]; 175 | let width = value_label.width() as u16; 176 | if width < self.bar_width { 177 | buf.set_string( 178 | chart_area.left() 179 | + i as u16 * (self.bar_width + self.bar_gap) 180 | + (self.bar_width - width) / 2, 181 | chart_area.bottom() - 2, 182 | value_label, 183 | self.value_style, 184 | ); 185 | } 186 | } 187 | buf.set_stringn( 188 | chart_area.left() + i as u16 * (self.bar_width + self.bar_gap), 189 | chart_area.bottom() - 1, 190 | label, 191 | self.bar_width as usize, 192 | self.label_style, 193 | ); 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/block.rs: -------------------------------------------------------------------------------- 1 | use crate::buffer::Buffer; 2 | use crate::layout::Rect; 3 | use crate::style::Style; 4 | use crate::symbols::line; 5 | use crate::widgets::{Borders, Widget}; 6 | 7 | #[derive(Debug, Copy, Clone)] 8 | pub enum BorderType { 9 | Plain, 10 | Rounded, 11 | Double, 12 | Thick, 13 | } 14 | 15 | impl BorderType { 16 | pub fn line_symbols(border_type: BorderType) -> line::Set { 17 | match border_type { 18 | BorderType::Plain => line::NORMAL, 19 | BorderType::Rounded => line::ROUNDED, 20 | BorderType::Double => line::DOUBLE, 21 | BorderType::Thick => line::THICK, 22 | } 23 | } 24 | } 25 | 26 | /// Base widget to be used with all upper level ones. It may be used to display a box border around 27 | /// the widget and/or add a title. 28 | /// 29 | /// # Examples 30 | /// 31 | /// ``` 32 | /// # use rumatui_tui::widgets::{Block, BorderType, Borders}; 33 | /// # use rumatui_tui::style::{Style, Color}; 34 | /// Block::default() 35 | /// .title("Block") 36 | /// .title_style(Style::default().fg(Color::Red)) 37 | /// .borders(Borders::LEFT | Borders::RIGHT) 38 | /// .border_style(Style::default().fg(Color::White)) 39 | /// .border_type(BorderType::Rounded) 40 | /// .style(Style::default().bg(Color::Black)); 41 | /// ``` 42 | #[derive(Clone, Copy)] 43 | pub struct Block<'a> { 44 | /// Optional title place on the upper left of the block 45 | title: Option<&'a str>, 46 | /// Title style 47 | title_style: Style, 48 | /// Visible borders 49 | borders: Borders, 50 | /// Border style 51 | border_style: Style, 52 | /// Type of the border. The default is plain lines but one can choose to have rounded corners 53 | /// or doubled lines instead. 54 | border_type: BorderType, 55 | /// Widget style 56 | style: Style, 57 | } 58 | 59 | impl<'a> Default for Block<'a> { 60 | fn default() -> Block<'a> { 61 | Block { 62 | title: None, 63 | title_style: Default::default(), 64 | borders: Borders::NONE, 65 | border_style: Default::default(), 66 | border_type: BorderType::Plain, 67 | style: Default::default(), 68 | } 69 | } 70 | } 71 | 72 | impl<'a> Block<'a> { 73 | pub fn title(mut self, title: &'a str) -> Block<'a> { 74 | self.title = Some(title); 75 | self 76 | } 77 | 78 | pub fn title_style(mut self, style: Style) -> Block<'a> { 79 | self.title_style = style; 80 | self 81 | } 82 | 83 | pub fn border_style(mut self, style: Style) -> Block<'a> { 84 | self.border_style = style; 85 | self 86 | } 87 | 88 | pub fn style(mut self, style: Style) -> Block<'a> { 89 | self.style = style; 90 | self 91 | } 92 | 93 | pub fn borders(mut self, flag: Borders) -> Block<'a> { 94 | self.borders = flag; 95 | self 96 | } 97 | 98 | pub fn border_type(mut self, border_type: BorderType) -> Block<'a> { 99 | self.border_type = border_type; 100 | self 101 | } 102 | 103 | /// Compute the inner area of a block based on its border visibility rules. 104 | pub fn inner(&self, area: Rect) -> Rect { 105 | if area.width < 2 || area.height < 2 { 106 | return Rect::default(); 107 | } 108 | let mut inner = area; 109 | if self.borders.intersects(Borders::LEFT) { 110 | inner.x += 1; 111 | inner.width -= 1; 112 | } 113 | if self.borders.intersects(Borders::TOP) || self.title.is_some() { 114 | inner.y += 1; 115 | inner.height -= 1; 116 | } 117 | if self.borders.intersects(Borders::RIGHT) { 118 | inner.width -= 1; 119 | } 120 | if self.borders.intersects(Borders::BOTTOM) { 121 | inner.height -= 1; 122 | } 123 | inner 124 | } 125 | } 126 | 127 | impl<'a> Widget for Block<'a> { 128 | fn render(self, area: Rect, buf: &mut Buffer) { 129 | if area.width < 2 || area.height < 2 { 130 | return; 131 | } 132 | 133 | buf.set_background(area, self.style.bg); 134 | 135 | let symbols = BorderType::line_symbols(self.border_type); 136 | // Sides 137 | if self.borders.intersects(Borders::LEFT) { 138 | for y in area.top()..area.bottom() { 139 | buf.get_mut(area.left(), y) 140 | .set_symbol(symbols.vertical) 141 | .set_style(self.border_style); 142 | } 143 | } 144 | if self.borders.intersects(Borders::TOP) { 145 | for x in area.left()..area.right() { 146 | buf.get_mut(x, area.top()) 147 | .set_symbol(symbols.horizontal) 148 | .set_style(self.border_style); 149 | } 150 | } 151 | if self.borders.intersects(Borders::RIGHT) { 152 | let x = area.right() - 1; 153 | for y in area.top()..area.bottom() { 154 | buf.get_mut(x, y) 155 | .set_symbol(symbols.vertical) 156 | .set_style(self.border_style); 157 | } 158 | } 159 | if self.borders.intersects(Borders::BOTTOM) { 160 | let y = area.bottom() - 1; 161 | for x in area.left()..area.right() { 162 | buf.get_mut(x, y) 163 | .set_symbol(symbols.horizontal) 164 | .set_style(self.border_style); 165 | } 166 | } 167 | 168 | // Corners 169 | if self.borders.contains(Borders::LEFT | Borders::TOP) { 170 | buf.get_mut(area.left(), area.top()) 171 | .set_symbol(symbols.top_left) 172 | .set_style(self.border_style); 173 | } 174 | if self.borders.contains(Borders::RIGHT | Borders::TOP) { 175 | buf.get_mut(area.right() - 1, area.top()) 176 | .set_symbol(symbols.top_right) 177 | .set_style(self.border_style); 178 | } 179 | if self.borders.contains(Borders::LEFT | Borders::BOTTOM) { 180 | buf.get_mut(area.left(), area.bottom() - 1) 181 | .set_symbol(symbols.bottom_left) 182 | .set_style(self.border_style); 183 | } 184 | if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) { 185 | buf.get_mut(area.right() - 1, area.bottom() - 1) 186 | .set_symbol(symbols.bottom_right) 187 | .set_style(self.border_style); 188 | } 189 | 190 | if let Some(title) = self.title { 191 | let lx = if self.borders.intersects(Borders::LEFT) { 192 | 1 193 | } else { 194 | 0 195 | }; 196 | let rx = if self.borders.intersects(Borders::RIGHT) { 197 | 1 198 | } else { 199 | 0 200 | }; 201 | let width = area.width - lx - rx; 202 | buf.set_stringn( 203 | area.left() + lx, 204 | area.top(), 205 | title, 206 | width as usize, 207 | self.title_style, 208 | ); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/widgets/login.rs: -------------------------------------------------------------------------------- 1 | use rumatui_tui::{ 2 | backend::Backend, 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Modifier, Style}, 5 | widgets::{Block, Borders, Paragraph, Text}, 6 | Frame, 7 | }; 8 | use termion::event::MouseButton; 9 | 10 | use crate::widgets::RenderWidget; 11 | 12 | #[derive(Clone, Copy, Debug)] 13 | pub struct Loading { 14 | pub count: usize, 15 | pub add: bool, 16 | } 17 | 18 | impl Default for Loading { 19 | fn default() -> Self { 20 | Self { 21 | count: 1, 22 | add: true, 23 | } 24 | } 25 | } 26 | 27 | impl Loading { 28 | pub fn tick(&mut self, max: u16) { 29 | let max = (max - 1) as usize; 30 | if self.count > max { 31 | self.add = false; 32 | } 33 | if self.count == 1 { 34 | self.add = true; 35 | } 36 | 37 | if self.add { 38 | self.count += 1; 39 | } else { 40 | self.count -= 1; 41 | } 42 | } 43 | } 44 | #[repr(u8)] 45 | #[derive(Clone, Copy, Debug, PartialEq)] 46 | pub enum LoginSelect { 47 | Username = 0, 48 | Password, 49 | } 50 | impl Default for LoginSelect { 51 | fn default() -> Self { 52 | Self::Username 53 | } 54 | } 55 | #[derive(Clone, Debug, Default)] 56 | pub struct Login { 57 | pub selected: LoginSelect, 58 | pub username: String, 59 | pub password: String, 60 | } 61 | 62 | #[derive(Clone, Debug, Default)] 63 | pub struct LoginWidget { 64 | user_area: Rect, 65 | password_area: Rect, 66 | pub login: Login, 67 | pub logging_in: bool, 68 | pub logged_in: bool, 69 | pub waiting: Loading, 70 | pub homeserver: Option, 71 | } 72 | 73 | impl LoginWidget { 74 | pub(crate) fn try_login(&self) -> bool { 75 | LoginSelect::Password == self.login.selected 76 | && !self.login.password.is_empty() 77 | && !self.login.username.is_empty() 78 | } 79 | 80 | pub(crate) fn clear_login(&mut self) { 81 | // self.login.username.clear(); 82 | // self.login.password.clear(); 83 | } 84 | 85 | /// If right mouse button and clicked within the area of the username or 86 | /// password field the respective text box is selected. 87 | pub fn on_click(&mut self, btn: MouseButton, x: u16, y: u16) { 88 | if let MouseButton::Left = btn { 89 | if self.user_area.intersects(Rect::new(x, y, 1, 1)) { 90 | self.login.selected = LoginSelect::Username; 91 | } else if self.password_area.intersects(Rect::new(x, y, 1, 1)) { 92 | self.login.selected = LoginSelect::Password; 93 | } 94 | } 95 | } 96 | } 97 | 98 | impl RenderWidget for LoginWidget { 99 | fn render(&mut self, f: &mut Frame, area: Rect) 100 | where 101 | B: Backend, 102 | { 103 | let chunks = Layout::default() 104 | .horizontal_margin(40) 105 | .constraints( 106 | [ 107 | Constraint::Percentage(15), 108 | Constraint::Percentage(60), 109 | Constraint::Percentage(15), 110 | ] 111 | .as_ref(), 112 | ) 113 | .split(area); 114 | 115 | let server = self.homeserver.as_deref().unwrap_or("matrix.org"); 116 | let login = &format!("Log in to {}", server); 117 | let blk = Block::default() 118 | .title(login) 119 | .title_style(Style::default().fg(Color::Green).modifier(Modifier::BOLD)) 120 | .borders(Borders::ALL); 121 | f.render_widget(blk, chunks[1]); 122 | 123 | let height_chunk = Layout::default() 124 | .direction(Direction::Vertical) 125 | .constraints( 126 | [ 127 | Constraint::Percentage(20), 128 | Constraint::Percentage(30), 129 | Constraint::Percentage(30), 130 | Constraint::Percentage(20), 131 | ] 132 | .as_ref(), 133 | ) 134 | .split(chunks[1]); 135 | 136 | let width_chunk1 = Layout::default() 137 | .direction(Direction::Horizontal) 138 | .constraints( 139 | [ 140 | Constraint::Percentage(25), 141 | Constraint::Percentage(50), 142 | Constraint::Percentage(25), 143 | ] 144 | .as_ref(), 145 | ) 146 | .split(height_chunk[1]); 147 | 148 | if self.logging_in { 149 | self.waiting.tick(width_chunk1[1].width); 150 | let blk = Block::default() 151 | .title("Logging in") 152 | .border_style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)) 153 | .borders(Borders::ALL); 154 | 155 | let t = [Text::styled( 156 | "*".repeat(self.waiting.count), 157 | Style::default().fg(Color::Magenta), 158 | )]; 159 | let p = Paragraph::new(t.iter()) 160 | .block(blk) 161 | .alignment(Alignment::Center); 162 | 163 | f.render_widget(p, width_chunk1[1]); 164 | } else { 165 | let (high_user, high_pass) = if self.login.selected == LoginSelect::Username { 166 | ( 167 | Block::default() 168 | .title("User Name") 169 | .border_style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)) 170 | .borders(Borders::ALL), 171 | Block::default().title("Password").borders(Borders::ALL), 172 | ) 173 | } else { 174 | ( 175 | Block::default().title("User Name").borders(Borders::ALL), 176 | Block::default() 177 | .title("Password") 178 | .border_style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)) 179 | .borders(Borders::ALL), 180 | ) 181 | }; 182 | 183 | // password width using password height 184 | let width_chunk2 = Layout::default() 185 | .direction(Direction::Horizontal) 186 | .constraints( 187 | [ 188 | Constraint::Percentage(25), 189 | Constraint::Percentage(50), 190 | Constraint::Percentage(25), 191 | ] 192 | .as_ref(), 193 | ) 194 | .split(height_chunk[2]); 195 | 196 | self.user_area = width_chunk1[1]; 197 | self.password_area = width_chunk2[1]; 198 | 199 | // User name 200 | let t = [Text::styled( 201 | &self.login.username, 202 | Style::default().fg(Color::Cyan), 203 | )]; 204 | let p = Paragraph::new(t.iter()).block(high_user); 205 | 206 | f.render_widget(p, width_chunk1[1]); 207 | 208 | // Password from here down 209 | let t2 = [Text::styled( 210 | "*".repeat(self.login.password.len()), 211 | Style::default().fg(Color::Cyan), 212 | )]; 213 | let p2 = Paragraph::new(t2.iter()).block(high_pass); 214 | 215 | f.render_widget(p2, width_chunk2[1]) 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/list.rs: -------------------------------------------------------------------------------- 1 | use std::iter::{self, Iterator}; 2 | 3 | use unicode_width::UnicodeWidthStr; 4 | 5 | use crate::buffer::Buffer; 6 | use crate::layout::{Corner, Rect}; 7 | use crate::style::Style; 8 | use crate::widgets::{Block, StatefulWidget, Text, Widget}; 9 | 10 | #[derive(Copy, Clone, Debug)] 11 | pub struct ListState { 12 | offset: usize, 13 | selected: Option, 14 | } 15 | 16 | impl Default for ListState { 17 | fn default() -> ListState { 18 | ListState { 19 | offset: 0, 20 | selected: None, 21 | } 22 | } 23 | } 24 | 25 | impl ListState { 26 | pub fn selected(&self) -> Option { 27 | self.selected 28 | } 29 | 30 | pub fn select(&mut self, index: Option) { 31 | self.selected = index; 32 | if index.is_none() { 33 | self.offset = 0; 34 | } 35 | } 36 | } 37 | 38 | /// A widget to display several items among which one can be selected (optional) 39 | /// 40 | /// # Examples 41 | /// 42 | /// ``` 43 | /// # use rumatui_tui::widgets::{Block, Borders, List, Text}; 44 | /// # use rumatui_tui::style::{Style, Color, Modifier}; 45 | /// let items = ["Item 1", "Item 2", "Item 3"].iter().map(|i| Text::raw(*i)); 46 | /// List::new(items) 47 | /// .block(Block::default().title("List").borders(Borders::ALL)) 48 | /// .style(Style::default().fg(Color::White)) 49 | /// .highlight_style(Style::default().modifier(Modifier::ITALIC)) 50 | /// .highlight_symbol(">>"); 51 | /// ``` 52 | pub struct List<'b, L> 53 | where 54 | L: Iterator>, 55 | { 56 | block: Option>, 57 | items: L, 58 | start_corner: Corner, 59 | /// Base style of the widget 60 | style: Style, 61 | /// Style used to render selected item 62 | highlight_style: Style, 63 | /// Symbol in front of the selected item (Shift all items to the right) 64 | highlight_symbol: Option<&'b str>, 65 | } 66 | 67 | impl<'b, L> Default for List<'b, L> 68 | where 69 | L: Iterator> + Default, 70 | { 71 | fn default() -> List<'b, L> { 72 | List { 73 | block: None, 74 | items: L::default(), 75 | style: Default::default(), 76 | start_corner: Corner::TopLeft, 77 | highlight_style: Style::default(), 78 | highlight_symbol: None, 79 | } 80 | } 81 | } 82 | 83 | impl<'b, L> List<'b, L> 84 | where 85 | L: Iterator>, 86 | { 87 | pub fn new(items: L) -> List<'b, L> { 88 | List { 89 | block: None, 90 | items, 91 | style: Default::default(), 92 | start_corner: Corner::TopLeft, 93 | highlight_style: Style::default(), 94 | highlight_symbol: None, 95 | } 96 | } 97 | 98 | pub fn block(mut self, block: Block<'b>) -> List<'b, L> { 99 | self.block = Some(block); 100 | self 101 | } 102 | 103 | pub fn items(mut self, items: I) -> List<'b, L> 104 | where 105 | I: IntoIterator, IntoIter = L>, 106 | { 107 | self.items = items.into_iter(); 108 | self 109 | } 110 | 111 | pub fn style(mut self, style: Style) -> List<'b, L> { 112 | self.style = style; 113 | self 114 | } 115 | 116 | pub fn highlight_symbol(mut self, highlight_symbol: &'b str) -> List<'b, L> { 117 | self.highlight_symbol = Some(highlight_symbol); 118 | self 119 | } 120 | 121 | pub fn highlight_style(mut self, highlight_style: Style) -> List<'b, L> { 122 | self.highlight_style = highlight_style; 123 | self 124 | } 125 | 126 | pub fn start_corner(mut self, corner: Corner) -> List<'b, L> { 127 | self.start_corner = corner; 128 | self 129 | } 130 | } 131 | 132 | impl<'b, L> StatefulWidget for List<'b, L> 133 | where 134 | L: Iterator>, 135 | { 136 | type State = ListState; 137 | 138 | fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 139 | let list_area = match self.block { 140 | Some(ref mut b) => { 141 | b.render(area, buf); 142 | b.inner(area) 143 | } 144 | None => area, 145 | }; 146 | 147 | if list_area.width < 1 || list_area.height < 1 { 148 | return; 149 | } 150 | 151 | let list_height = list_area.height as usize; 152 | 153 | buf.set_background(list_area, self.style.bg); 154 | 155 | // Use highlight_style only if something is selected 156 | let (selected, highlight_style) = match state.selected { 157 | Some(i) => (Some(i), self.highlight_style), 158 | None => (None, self.style), 159 | }; 160 | let highlight_symbol = self.highlight_symbol.unwrap_or(""); 161 | let blank_symbol = iter::repeat(" ") 162 | .take(highlight_symbol.width()) 163 | .collect::(); 164 | 165 | // Make sure the list show the selected item 166 | state.offset = if let Some(selected) = selected { 167 | if selected >= list_height + state.offset - 1 { 168 | selected + 1 - list_height 169 | } else if selected < state.offset { 170 | selected 171 | } else { 172 | state.offset 173 | } 174 | } else { 175 | 0 176 | }; 177 | 178 | for (i, item) in self 179 | .items 180 | .skip(state.offset) 181 | .enumerate() 182 | .take(list_area.height as usize) 183 | { 184 | let (x, y) = match self.start_corner { 185 | Corner::TopLeft => (list_area.left(), list_area.top() + i as u16), 186 | Corner::BottomLeft => (list_area.left(), list_area.bottom() - (i + 1) as u16), 187 | // Not supported 188 | _ => (list_area.left(), list_area.top() + i as u16), 189 | }; 190 | let (x, style) = if let Some(s) = selected { 191 | if s == i + state.offset { 192 | let (x, _) = buf.set_stringn( 193 | x, 194 | y, 195 | highlight_symbol, 196 | list_area.width as usize, 197 | highlight_style, 198 | ); 199 | (x + 1, Some(highlight_style)) 200 | } else { 201 | let (x, _) = buf.set_stringn( 202 | x, 203 | y, 204 | &blank_symbol, 205 | list_area.width as usize, 206 | highlight_style, 207 | ); 208 | (x + 1, None) 209 | } 210 | } else { 211 | (x, None) 212 | }; 213 | match item { 214 | Text::Raw(ref v) => { 215 | buf.set_stringn( 216 | x, 217 | y, 218 | v, 219 | list_area.width as usize, 220 | style.unwrap_or(self.style), 221 | ); 222 | } 223 | Text::Styled(ref v, s) => { 224 | buf.set_stringn(x, y, v, list_area.width as usize, style.unwrap_or(s)); 225 | } 226 | }; 227 | } 228 | } 229 | } 230 | 231 | impl<'b, L> Widget for List<'b, L> 232 | where 233 | L: Iterator>, 234 | { 235 | fn render(self, area: Rect, buf: &mut Buffer) { 236 | let mut state = ListState::default(); 237 | StatefulWidget::render(self, area, buf, &mut state); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error conditions. 2 | 3 | use std::{fmt, io}; 4 | 5 | use matrix_sdk::{ 6 | api::{error::ErrorKind, Error as RumaApiError}, 7 | BaseError as MatrixBaseError, Error as MatrixError, FromHttpResponseError as RumaResponseError, 8 | IntoHttpError, ServerError, 9 | }; 10 | use serde_json::Error as JsonError; 11 | use tokio::sync::mpsc::error::SendError; 12 | use url::ParseError; 13 | 14 | use crate::client::client_loop::UserRequest; 15 | 16 | /// Result type for rumatui. 17 | /// 18 | /// Holds more information about the specific error in the forum of `tui::Text`. 19 | /// This allows the `Error` to easily be displayed. 20 | pub type Result = std::result::Result; 21 | 22 | const AUTH_MSG: &str = r#"You tried to reach an endpoint that requires authentication. 23 | 24 | This is most likely a bug in `rumatui` or one of it's dependencies."#; 25 | 26 | const LOGIN_MSG: &str = r#"The user name or password entered did not match any know user. 27 | 28 | Make sure you are logging in on the correct server (rumatui defaults to 'http://matrix.org')."#; 29 | 30 | /// Internal representation of errors. 31 | #[derive(Debug)] 32 | pub enum Error { 33 | Encryption(String), 34 | RumaResponse(String), 35 | RumaRequest(String), 36 | Json(String), 37 | SerdeJson(JsonError), 38 | Io(String), 39 | UrlParseError(String), 40 | SerDeError(String), 41 | Matrix(String), 42 | NeedAuth(String), 43 | Unknown(String), 44 | Channel(String), 45 | MatrixUiaaError(MatrixError), 46 | Rumatui(&'static str), 47 | } 48 | 49 | impl fmt::Display for Error { 50 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 51 | match self { 52 | Self::Encryption(msg) => write!(f, "{}", msg), 53 | Self::RumaResponse(msg) => write!( 54 | f, 55 | "An error occurred with a response from the server.\n{}", 56 | msg 57 | ), 58 | Self::RumaRequest(msg) => write!( 59 | f, 60 | "An error occurred with a request to the server.\n{}", 61 | msg 62 | ), 63 | Self::Io(msg) => write!(f, "An IO error occurred.\n{}", msg), 64 | Self::Json(msg) => write!(f, "An error occurred parsing a JSON object.\n{}", msg), 65 | // TODO use the methods on serde_json error 66 | Self::SerdeJson(msg) => write!(f, "An error occurred parsing a JSON object.\n{}", msg), 67 | Self::UrlParseError(msg) => { 68 | write!(f, "An error occurred while parsing a url.\n{}", msg) 69 | } 70 | Self::SerDeError(msg) => write!( 71 | f, 72 | "An error occurred while serializing or deserializing.\n{}", 73 | msg 74 | ), 75 | Self::Matrix(msg) => write!( 76 | f, 77 | "An error occurred in the matrix client library.\n{}", 78 | msg 79 | ), 80 | Self::NeedAuth(msg) => write!(f, "Authentication is required.\n{}", msg), 81 | Self::Unknown(msg) => write!(f, "An error occurred.\n{}", msg), 82 | Self::Channel(msg) => write!( 83 | f, 84 | "The receiving end of a channel shutdown while still receiving messages.\n{}", 85 | msg 86 | ), 87 | Self::MatrixUiaaError(err) => write!(f, "whoaaaa {}", err), 88 | Self::Rumatui(msg) => write!(f, "An error occurred in `rumatui`.\n{}", msg), 89 | } 90 | } 91 | } 92 | 93 | impl std::error::Error for Error {} 94 | 95 | /// This is the most important error conversion as most of the user facing errors are here. 96 | impl From for Error { 97 | #[allow(clippy::useless_format)] 98 | fn from(error: MatrixError) -> Self { 99 | match error { 100 | MatrixError::AuthenticationRequired => Error::NeedAuth(AUTH_MSG.to_string()), 101 | MatrixError::RumaResponse(http) => match http { 102 | RumaResponseError::Http(server) => match server { 103 | // This should be the most common error kind and some should be recoverable. 104 | // TODO there are numerous ErrorKind's for `match kind { ... } deal with them 105 | // fix the LOGIN_MSG it is not always accurate 106 | ServerError::Known(RumaApiError { kind, message, .. }) => match kind { 107 | ErrorKind::Forbidden => Error::RumaResponse(LOGIN_MSG.to_string()), 108 | ErrorKind::UserInUse => Error::RumaResponse(format!("{}", message)), 109 | _ => Error::RumaResponse(format!("{}", message)), 110 | }, 111 | ServerError::Unknown(err) => Error::Unknown(format!("{}", err)), 112 | }, 113 | RumaResponseError::Deserialization(err) => Error::SerDeError(format!("{}", err)), 114 | _ => panic!("ruma-client-api errors have changed rumatui BUG"), 115 | }, 116 | MatrixError::MatrixError(err) => match err { 117 | MatrixBaseError::StateStore(err) => Error::Matrix(err), 118 | MatrixBaseError::SerdeJson(err) => Error::SerdeJson(err), 119 | MatrixBaseError::AuthenticationRequired => Error::NeedAuth( 120 | "An unauthenticated request was made that requires authentication".into(), 121 | ), 122 | MatrixBaseError::IoError(err) => Error::Io(format!("{}", err)), 123 | MatrixBaseError::MegolmError(err) => Error::Encryption(format!("{}", err)), 124 | MatrixBaseError::OlmError(err) => Error::Encryption(format!("{}", err)), 125 | }, 126 | MatrixError::UiaaError(_) => Error::MatrixUiaaError(error), 127 | _ => Error::Unknown("connection to the server was lost or not established".into()), 128 | } 129 | } 130 | } 131 | 132 | impl From for Error { 133 | fn from(err: MatrixBaseError) -> Self { 134 | match err { 135 | MatrixBaseError::StateStore(err) => Error::Matrix(err), 136 | MatrixBaseError::SerdeJson(err) => Error::SerdeJson(err), 137 | MatrixBaseError::AuthenticationRequired => Error::NeedAuth( 138 | "An unauthenticated request was made that requires authentication".into(), 139 | ), 140 | MatrixBaseError::IoError(err) => Error::Io(format!("{}", err)), 141 | MatrixBaseError::MegolmError(err) => Error::Encryption(format!("{}", err)), 142 | MatrixBaseError::OlmError(err) => Error::Encryption(format!("{}", err)), 143 | } 144 | } 145 | } 146 | 147 | impl From for Error { 148 | fn from(error: IntoHttpError) -> Self { 149 | let text = format!("{}", error); 150 | Self::RumaRequest(text) 151 | } 152 | } 153 | 154 | impl From> for Error { 155 | fn from(error: SendError) -> Self { 156 | let text = format!("{}", error); 157 | Self::RumaRequest(text) 158 | } 159 | } 160 | 161 | impl From for Error { 162 | fn from(error: ParseError) -> Self { 163 | let text = format!("{}", error); 164 | Self::RumaRequest(text) 165 | } 166 | } 167 | 168 | impl From for Error { 169 | fn from(error: JsonError) -> Self { 170 | Error::SerdeJson(error) 171 | } 172 | } 173 | 174 | impl From for Error { 175 | fn from(error: io::Error) -> Self { 176 | let text = format!("{}", error); 177 | Self::RumaRequest(text) 178 | } 179 | } 180 | 181 | // This impl satisfies the conversion of the ".rumatui" folder path 182 | // lazy_static from Result -> &Path as the error is Err(&io::Error) 183 | impl From<&io::Error> for Error { 184 | fn from(error: &io::Error) -> Self { 185 | let text = format!("{}", error); 186 | Self::RumaRequest(text) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/paragraph.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | use std::rc::Rc; 3 | 4 | use either::Either; 5 | use unicode_segmentation::UnicodeSegmentation; 6 | use unicode_width::UnicodeWidthStr; 7 | 8 | use crate::buffer::Buffer; 9 | use crate::layout::{Alignment, Rect, ScrollMode}; 10 | use crate::style::Style; 11 | use crate::widgets::reflow::{LineComposer, LineTruncator, Styled, WordWrapper}; 12 | use crate::widgets::scroll::{OffsetScroller, ScrolledLine, Scroller, TailScroller}; 13 | use crate::widgets::{Block, Text, Widget}; 14 | 15 | fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 { 16 | match alignment { 17 | Alignment::Center => (text_area_width / 2).saturating_sub(line_width / 2), 18 | Alignment::Right => text_area_width.saturating_sub(line_width), 19 | Alignment::Left => 0, 20 | } 21 | } 22 | 23 | /// A widget to display some text. 24 | /// 25 | /// # Examples 26 | /// 27 | /// ``` 28 | /// # use rumatui_tui::widgets::{Block, Borders, Paragraph, Text}; 29 | /// # use rumatui_tui::style::{Style, Color}; 30 | /// # use rumatui_tui::layout::{Alignment}; 31 | /// let text = [ 32 | /// Text::raw("First line\n"), 33 | /// Text::styled("Second line\n", Style::default().fg(Color::Red)) 34 | /// ]; 35 | /// Paragraph::new(text.iter()) 36 | /// .block(Block::default().title("Paragraph").borders(Borders::ALL)) 37 | /// .style(Style::default().fg(Color::White).bg(Color::Black)) 38 | /// .alignment(Alignment::Center) 39 | /// .wrap(true); 40 | /// ``` 41 | pub struct Paragraph<'a, 't, T> 42 | where 43 | T: Iterator>, 44 | { 45 | /// A block to wrap the widget in 46 | block: Option>, 47 | /// Widget style 48 | style: Style, 49 | /// Wrap the text or not 50 | wrapping: bool, 51 | /// The text to display 52 | text: T, 53 | /// Should we parse the text for embedded commands 54 | raw: bool, 55 | /// Scroll offset in number of lines 56 | scroll: u16, 57 | /// Indicates if scroll offset starts from top or bottom of content 58 | scroll_mode: ScrollMode, 59 | scroll_overflow_char: Option, 60 | /// Aligenment of the text 61 | alignment: Alignment, 62 | /// A flag that is passed in to inform the caller when the buffer 63 | /// has overflown. 64 | has_overflown: Option>>, 65 | at_top: Option>>, 66 | } 67 | 68 | impl<'a, 't, T> Paragraph<'a, 't, T> 69 | where 70 | T: Iterator>, 71 | { 72 | pub fn new(text: T) -> Paragraph<'a, 't, T> { 73 | Paragraph { 74 | block: None, 75 | style: Default::default(), 76 | wrapping: false, 77 | raw: false, 78 | text, 79 | scroll: 0, 80 | scroll_mode: ScrollMode::Normal, 81 | scroll_overflow_char: None, 82 | alignment: Alignment::Left, 83 | has_overflown: None, 84 | at_top: None, 85 | } 86 | } 87 | 88 | pub fn block(mut self, block: Block<'a>) -> Paragraph<'a, 't, T> { 89 | self.block = Some(block); 90 | self 91 | } 92 | 93 | pub fn style(mut self, style: Style) -> Paragraph<'a, 't, T> { 94 | self.style = style; 95 | self 96 | } 97 | 98 | pub fn wrap(mut self, flag: bool) -> Paragraph<'a, 't, T> { 99 | self.wrapping = flag; 100 | self 101 | } 102 | 103 | pub fn raw(mut self, flag: bool) -> Paragraph<'a, 't, T> { 104 | self.raw = flag; 105 | self 106 | } 107 | 108 | pub fn scroll(mut self, offset: u16) -> Paragraph<'a, 't, T> { 109 | self.scroll = offset; 110 | self 111 | } 112 | 113 | pub fn scroll_mode(mut self, scroll_mode: ScrollMode) -> Paragraph<'a, 't, T> { 114 | self.scroll_mode = scroll_mode; 115 | self 116 | } 117 | 118 | pub fn scroll_overflow_char( 119 | mut self, 120 | scroll_overflow_char: Option, 121 | ) -> Paragraph<'a, 't, T> { 122 | self.scroll_overflow_char = scroll_overflow_char; 123 | self 124 | } 125 | 126 | pub fn alignment(mut self, alignment: Alignment) -> Paragraph<'a, 't, T> { 127 | self.alignment = alignment; 128 | self 129 | } 130 | 131 | pub fn did_overflow(mut self, over: Rc>) -> Paragraph<'a, 't, T> { 132 | self.has_overflown = Some(over); 133 | self 134 | } 135 | 136 | pub fn at_top(mut self, top: Rc>) -> Paragraph<'a, 't, T> { 137 | self.at_top = Some(top); 138 | self 139 | } 140 | } 141 | 142 | impl<'a, 't, 'b, T> Widget for Paragraph<'a, 't, T> 143 | where 144 | T: Iterator>, 145 | { 146 | fn render(mut self, area: Rect, buf: &mut Buffer) { 147 | let text_area = match self.block { 148 | Some(ref mut b) => { 149 | b.render(area, buf); 150 | b.inner(area) 151 | } 152 | None => area, 153 | }; 154 | 155 | if text_area.height < 1 { 156 | return; 157 | } 158 | 159 | buf.set_background(text_area, self.style.bg); 160 | 161 | let style = self.style; 162 | let mut styled = self.text.by_ref().flat_map(|t| match *t { 163 | Text::Raw(ref d) => { 164 | let data: &'t str = d; // coerce to &str 165 | Either::Left(UnicodeSegmentation::graphemes(data, true).map(|g| Styled(g, style))) 166 | } 167 | Text::Styled(ref d, s) => { 168 | let data: &'t str = d; // coerce to &str 169 | Either::Right(UnicodeSegmentation::graphemes(data, true).map(move |g| Styled(g, s))) 170 | } 171 | }); 172 | 173 | let line_composer: Box = if self.wrapping { 174 | Box::new(WordWrapper::new(&mut styled, text_area.width)) 175 | } else { 176 | Box::new(LineTruncator::new(&mut styled, text_area.width)) 177 | }; 178 | 179 | let mut scrolled_lines: Box> = match self.scroll_mode { 180 | ScrollMode::Normal => { 181 | let scroller = OffsetScroller::new(self.scroll, line_composer); 182 | Box::new(scroller) 183 | } 184 | ScrollMode::Tail => { 185 | let over = self 186 | .has_overflown 187 | .unwrap_or_else(|| Rc::new(Cell::new(false))); 188 | 189 | let scroller = TailScroller::new( 190 | self.scroll, 191 | line_composer, 192 | text_area.height, 193 | Rc::clone(&over), 194 | ); 195 | Box::new(scroller) 196 | } 197 | }; 198 | 199 | for y in 0..text_area.height { 200 | match scrolled_lines.next_line() { 201 | Some(ScrolledLine::Line(current_line, current_line_width)) => { 202 | let mut x = 203 | get_line_offset(current_line_width, text_area.width, self.alignment); 204 | for Styled(symbol, style) in current_line { 205 | buf.get_mut(text_area.left() + x, text_area.top() + y) 206 | .set_symbol(symbol) 207 | .set_style(style); 208 | x += symbol.width() as u16; 209 | } 210 | } 211 | Some(ScrolledLine::Overflow) => { 212 | if let Some(top) = self.at_top.as_ref() { 213 | top.set(true); 214 | } 215 | 216 | if let Some(c) = self.scroll_overflow_char { 217 | buf.get_mut(text_area.left(), text_area.top() + y) 218 | .set_symbol(&c.to_string()) 219 | .set_style(style); 220 | } 221 | } 222 | None => {} 223 | } 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow( 3 | clippy::single_component_path_imports, 4 | clippy::or_fun_call, 5 | clippy::single_match 6 | )] 7 | 8 | use std::{env, fs, io, path::Path, process, time::Duration}; 9 | 10 | use rumatui_tui::{backend::TermionBackend, Terminal}; 11 | use termion::{ 12 | event::{Event as TermEvent, Key, MouseButton, MouseEvent}, 13 | input::MouseTerminal, 14 | raw::IntoRawMode, 15 | }; 16 | use tracing_subscriber::{self as tracer, EnvFilter}; 17 | 18 | mod client; 19 | mod config; 20 | mod error; 21 | mod log; 22 | mod ui_loop; 23 | mod widgets; 24 | 25 | use ui_loop::{Config, Event, UiEventHandle}; 26 | use widgets::{app::AppWidget, DrawWidget}; 27 | 28 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 29 | 30 | lazy_static::lazy_static! { 31 | pub static ref RUMATUI_DIR: std::io::Result = { 32 | let mut path = dirs::home_dir() 33 | .ok_or(std::io::Error::new( 34 | std::io::ErrorKind::NotFound, 35 | "no home directory found", 36 | ))?; 37 | path.push(".rumatui"); 38 | Ok(path) 39 | }; 40 | } 41 | 42 | // TODO create a versioning scheme for the "DB" 43 | /// Check for and create if needed the `/.rumatui` folder 44 | fn create_rumatui_folder() -> Result<(), failure::Error> { 45 | let path: &Path = RUMATUI_DIR.as_ref().unwrap(); 46 | 47 | if !path.exists() { 48 | fs::create_dir_all(path)?; 49 | } 50 | Ok(()) 51 | } 52 | 53 | fn parse_args(args: env::Args) -> (String, bool) { 54 | // skip binary path 55 | let args = args.skip(1).collect::>(); 56 | if args 57 | .iter() 58 | .any(|arg| arg.contains("help") || arg.contains("-h")) 59 | { 60 | print_help(); 61 | process::exit(0) 62 | } 63 | 64 | // TODO avoid all this somehow. The `match` below needs &str and no auto deref'ing happens 65 | // so find a way to make this all a bit neater?? 66 | let args: Vec<&str> = args.iter().map(|s| &**s).collect(); 67 | 68 | match args.as_slice() { 69 | [] => (String::new(), false), 70 | [arg] if *arg == "-v" || *arg == "--verbose" => (String::new(), true), 71 | [arg] => (arg.to_string(), false), 72 | [a, b, c @ ..] => { 73 | let verbose = 74 | *b == "-v" || *b == "--verbose" || c.contains(&"-v") || c.contains(&"--verbose"); 75 | (a.to_string(), verbose) 76 | } 77 | } 78 | } 79 | 80 | fn main() -> Result<(), failure::Error> { 81 | create_rumatui_folder()?; 82 | // when this is "" empty matrix.org is used 83 | let (server, verbose) = parse_args(env::args()); 84 | let log_level = if verbose { 85 | EnvFilter::new("info").to_string() 86 | } else { 87 | EnvFilter::DEFAULT_ENV.to_string() 88 | }; 89 | 90 | let mut runtime = tokio::runtime::Builder::new() 91 | .basic_scheduler() 92 | .threaded_scheduler() 93 | .enable_all() 94 | .build() 95 | .unwrap(); 96 | 97 | let path: &std::path::Path = RUMATUI_DIR 98 | .as_ref() 99 | .map_err(|e| failure::format_err!("home dir not found: {}", e))?; 100 | let mut path = std::path::PathBuf::from(path); 101 | path.push("logs.json"); 102 | 103 | let (logger, _guard) = log::LogWriter::spawn_logger(&path); 104 | tracer::fmt() 105 | .with_writer(logger) 106 | .json() 107 | .with_env_filter(log_level) 108 | .init(); 109 | // .try_init() 110 | // .unwrap(); // they return `` 111 | 112 | let executor = runtime.handle().clone(); 113 | runtime.block_on(async { 114 | let mut app = AppWidget::new(executor, &server).await; 115 | let events = UiEventHandle::with_config(Config { 116 | tick_rate: Duration::from_millis(60), 117 | exit_key: termion::event::Key::Ctrl('q'), 118 | }); 119 | let stdout = io::stdout().into_raw_mode()?; 120 | let stdout = MouseTerminal::from(stdout); 121 | let backend = TermionBackend::new(stdout); 122 | let mut terminal = Terminal::new(backend)?; 123 | terminal.clear()?; 124 | terminal.hide_cursor()?; 125 | loop { 126 | app.draw(&mut terminal)?; 127 | 128 | if let Some(_er) = app.error.take() { 129 | while let Event::Tick = events.next()? {} 130 | } 131 | 132 | match events.next()? { 133 | Event::Input(event) => match event { 134 | TermEvent::Key(key) => { 135 | app.on_notifications().await; 136 | 137 | match key { 138 | Key::Ctrl(c) if c == 'c' => app.should_quit = true, 139 | Key::Ctrl(c) if c == 'q' => app.should_quit = true, 140 | Key::Ctrl(c) if c == 's' => app.on_send().await, 141 | Key::Ctrl(c) if c == 'd' => app.on_ctrl_d().await, 142 | Key::Ctrl(c) if c == 'k' => app.on_ctrl_k().await, 143 | Key::Up => app.on_up().await, 144 | Key::Down => app.on_down().await, 145 | Key::Left => app.on_left(), 146 | Key::Right => app.on_right(), 147 | Key::Backspace => app.on_backspace(), 148 | Key::Delete => app.on_delete().await, 149 | Key::Char(c) if c == '\t' => app.on_down().await, 150 | Key::Char(c) => app.on_key(c).await, 151 | Key::Esc => app.should_quit = true, 152 | _ => {} 153 | } 154 | } 155 | TermEvent::Mouse(m) => { 156 | app.on_notifications().await; 157 | 158 | match m { 159 | MouseEvent::Press(btn, x, y) if btn == MouseButton::WheelUp => { 160 | app.on_scroll_up(x, y).await 161 | } 162 | MouseEvent::Press(btn, x, y) if btn == MouseButton::WheelDown => { 163 | app.on_scroll_down(x, y).await 164 | } 165 | MouseEvent::Press(btn, x, y) => app.on_click(btn, x, y).await, 166 | MouseEvent::Release(_, _) => {} 167 | MouseEvent::Hold(_, _) => {} 168 | } 169 | } 170 | TermEvent::Unsupported(_) => {} 171 | }, 172 | Event::Tick => { 173 | app.on_tick(&events).await; 174 | } 175 | } 176 | 177 | if app.should_quit { 178 | terminal.clear()?; 179 | app.on_quit().await; 180 | break; 181 | } 182 | } 183 | Ok(()) 184 | }) 185 | } 186 | 187 | #[rustfmt::skip] 188 | #[allow(clippy::print_literal)] 189 | fn print_help() { 190 | println!( 191 | "rumatui {} \n\n{}{}{}{}{}{}{}", 192 | VERSION, 193 | "USAGE:\n", 194 | " rumatui [HOMESERVER]\n\n", 195 | "OPTIONS:\n", 196 | " -h, --help Prints help information\n", 197 | " -v, --verbose Will create a log of the session at '~/.rumatui/logs.json'\n\n", 198 | "KEY-BINDINGS:", 199 | r#" 200 | * Esc will exit `rumatui` 201 | * Enter still works for all buttons except the decline/accept invite 202 | * Ctrl-s sends a message 203 | * Delete leaves and forgets the selected room 204 | * Left/right arrows, while at the login window, toggles login/register window 205 | * Left arrow, while at the main chat window, brings up the room search window 206 | * Enter, while in the room search window, starts the search 207 | * Ctrl-d, while a room is selected in the room search window, joins the room 208 | "#, 209 | ) 210 | } 211 | -------------------------------------------------------------------------------- /rumatui-tui/src/backend/crossterm.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt, 3 | io::{self, Write}, 4 | }; 5 | 6 | use crossterm::{ 7 | cursor::{Hide, MoveTo, Show}, 8 | execute, queue, 9 | style::{ 10 | Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor, 11 | SetForegroundColor, 12 | }, 13 | terminal::{self, Clear, ClearType}, 14 | }; 15 | 16 | use crate::backend::Backend; 17 | use crate::style::{Color, Modifier}; 18 | use crate::{buffer::Cell, layout::Rect, style}; 19 | 20 | pub struct CrosstermBackend { 21 | buffer: W, 22 | } 23 | 24 | impl CrosstermBackend 25 | where 26 | W: Write, 27 | { 28 | pub fn new(buffer: W) -> CrosstermBackend { 29 | CrosstermBackend { buffer } 30 | } 31 | } 32 | 33 | impl Write for CrosstermBackend 34 | where 35 | W: Write, 36 | { 37 | fn write(&mut self, buf: &[u8]) -> io::Result { 38 | self.buffer.write(buf) 39 | } 40 | 41 | fn flush(&mut self) -> io::Result<()> { 42 | self.buffer.flush() 43 | } 44 | } 45 | 46 | impl Backend for CrosstermBackend 47 | where 48 | W: Write, 49 | { 50 | fn draw<'a, I>(&mut self, content: I) -> io::Result<()> 51 | where 52 | I: Iterator, 53 | { 54 | use fmt::Write; 55 | 56 | let mut string = String::with_capacity(content.size_hint().0 * 3); 57 | let mut style = style::Style::default(); 58 | let mut last_y = 0; 59 | let mut last_x = 0; 60 | let mut inst = 0; 61 | 62 | for (x, y, cell) in content { 63 | if y != last_y || x != last_x + 1 || inst == 0 { 64 | map_error(queue!(string, MoveTo(x, y)))?; 65 | } 66 | last_x = x; 67 | last_y = y; 68 | if cell.style.modifier != style.modifier { 69 | let diff = ModifierDiff { 70 | from: style.modifier, 71 | to: cell.style.modifier, 72 | }; 73 | diff.queue(&mut string)?; 74 | inst += 1; 75 | style.modifier = cell.style.modifier; 76 | } 77 | if cell.style.fg != style.fg { 78 | let color = CColor::from(cell.style.fg); 79 | map_error(queue!(string, SetForegroundColor(color)))?; 80 | style.fg = cell.style.fg; 81 | inst += 1; 82 | } 83 | if cell.style.bg != style.bg { 84 | let color = CColor::from(cell.style.bg); 85 | map_error(queue!(string, SetBackgroundColor(color)))?; 86 | style.bg = cell.style.bg; 87 | inst += 1; 88 | } 89 | 90 | string.push_str(&cell.symbol); 91 | inst += 1; 92 | } 93 | 94 | map_error(queue!( 95 | self.buffer, 96 | Print(string), 97 | SetForegroundColor(CColor::Reset), 98 | SetBackgroundColor(CColor::Reset), 99 | SetAttribute(CAttribute::Reset) 100 | )) 101 | } 102 | 103 | fn hide_cursor(&mut self) -> io::Result<()> { 104 | map_error(execute!(self.buffer, Hide)) 105 | } 106 | 107 | fn show_cursor(&mut self) -> io::Result<()> { 108 | map_error(execute!(self.buffer, Show)) 109 | } 110 | 111 | fn get_cursor(&mut self) -> io::Result<(u16, u16)> { 112 | crossterm::cursor::position() 113 | .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) 114 | } 115 | 116 | fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { 117 | map_error(execute!(self.buffer, MoveTo(x, y))) 118 | } 119 | 120 | fn clear(&mut self) -> io::Result<()> { 121 | map_error(execute!(self.buffer, Clear(ClearType::All))) 122 | } 123 | 124 | fn size(&self) -> io::Result { 125 | let (width, height) = 126 | terminal::size().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; 127 | 128 | Ok(Rect::new(0, 0, width, height)) 129 | } 130 | 131 | fn flush(&mut self) -> io::Result<()> { 132 | self.buffer.flush() 133 | } 134 | } 135 | 136 | fn map_error(error: crossterm::Result<()>) -> io::Result<()> { 137 | error.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string())) 138 | } 139 | 140 | impl From for CColor { 141 | fn from(color: Color) -> Self { 142 | match color { 143 | Color::Reset => CColor::Reset, 144 | Color::Black => CColor::Black, 145 | Color::Red => CColor::DarkRed, 146 | Color::Green => CColor::DarkGreen, 147 | Color::Yellow => CColor::DarkYellow, 148 | Color::Blue => CColor::DarkBlue, 149 | Color::Magenta => CColor::DarkMagenta, 150 | Color::Cyan => CColor::DarkCyan, 151 | Color::Gray => CColor::Grey, 152 | Color::DarkGray => CColor::DarkGrey, 153 | Color::LightRed => CColor::Red, 154 | Color::LightGreen => CColor::Green, 155 | Color::LightBlue => CColor::Blue, 156 | Color::LightYellow => CColor::Yellow, 157 | Color::LightMagenta => CColor::Magenta, 158 | Color::LightCyan => CColor::Cyan, 159 | Color::White => CColor::White, 160 | Color::Indexed(i) => CColor::AnsiValue(i), 161 | Color::Rgb(r, g, b) => CColor::Rgb { r, g, b }, 162 | } 163 | } 164 | } 165 | 166 | #[derive(Debug)] 167 | struct ModifierDiff { 168 | pub from: Modifier, 169 | pub to: Modifier, 170 | } 171 | 172 | #[cfg(unix)] 173 | impl ModifierDiff { 174 | fn queue(&self, mut w: W) -> io::Result<()> 175 | where 176 | W: fmt::Write, 177 | { 178 | //use crossterm::Attribute; 179 | let removed = self.from - self.to; 180 | if removed.contains(Modifier::REVERSED) { 181 | map_error(queue!(w, SetAttribute(CAttribute::NoReverse)))?; 182 | } 183 | if removed.contains(Modifier::BOLD) { 184 | map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; 185 | if self.to.contains(Modifier::DIM) { 186 | map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; 187 | } 188 | } 189 | if removed.contains(Modifier::ITALIC) { 190 | map_error(queue!(w, SetAttribute(CAttribute::NoItalic)))?; 191 | } 192 | if removed.contains(Modifier::UNDERLINED) { 193 | map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?; 194 | } 195 | if removed.contains(Modifier::DIM) { 196 | map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; 197 | } 198 | if removed.contains(Modifier::CROSSED_OUT) { 199 | map_error(queue!(w, SetAttribute(CAttribute::NotCrossedOut)))?; 200 | } 201 | if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { 202 | map_error(queue!(w, SetAttribute(CAttribute::NoBlink)))?; 203 | } 204 | 205 | let added = self.to - self.from; 206 | if added.contains(Modifier::REVERSED) { 207 | map_error(queue!(w, SetAttribute(CAttribute::Reverse)))?; 208 | } 209 | if added.contains(Modifier::BOLD) { 210 | map_error(queue!(w, SetAttribute(CAttribute::Bold)))?; 211 | } 212 | if added.contains(Modifier::ITALIC) { 213 | map_error(queue!(w, SetAttribute(CAttribute::Italic)))?; 214 | } 215 | if added.contains(Modifier::UNDERLINED) { 216 | map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?; 217 | } 218 | if added.contains(Modifier::DIM) { 219 | map_error(queue!(w, SetAttribute(CAttribute::Dim)))?; 220 | } 221 | if added.contains(Modifier::CROSSED_OUT) { 222 | map_error(queue!(w, SetAttribute(CAttribute::CrossedOut)))?; 223 | } 224 | if added.contains(Modifier::SLOW_BLINK) { 225 | map_error(queue!(w, SetAttribute(CAttribute::SlowBlink)))?; 226 | } 227 | if added.contains(Modifier::RAPID_BLINK) { 228 | map_error(queue!(w, SetAttribute(CAttribute::RapidBlink)))?; 229 | } 230 | 231 | Ok(()) 232 | } 233 | } 234 | 235 | #[cfg(windows)] 236 | impl ModifierDiff { 237 | fn queue(&self, mut w: W) -> io::Result<()> 238 | where 239 | W: fmt::Write, 240 | { 241 | let removed = self.from - self.to; 242 | if removed.contains(Modifier::BOLD) { 243 | map_error(queue!(w, SetAttribute(CAttribute::NormalIntensity)))?; 244 | } 245 | if removed.contains(Modifier::UNDERLINED) { 246 | map_error(queue!(w, SetAttribute(CAttribute::NoUnderline)))?; 247 | } 248 | 249 | let added = self.to - self.from; 250 | if added.contains(Modifier::BOLD) { 251 | map_error(queue!(w, SetAttribute(CAttribute::Bold)))?; 252 | } 253 | if added.contains(Modifier::UNDERLINED) { 254 | map_error(queue!(w, SetAttribute(CAttribute::Underlined)))?; 255 | } 256 | Ok(()) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/widgets/room_search.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | use matrix_sdk::{ 4 | api::r0::directory::{ 5 | get_public_rooms_filtered::{self, RoomNetwork}, 6 | PublicRoomsChunk, 7 | }, 8 | identifiers::RoomId, 9 | }; 10 | use rumatui_tui::{ 11 | backend::Backend, 12 | layout::{Constraint, Direction, Layout, Rect}, 13 | style::{Color, Modifier, Style}, 14 | widgets::{Block, Borders, List, ListState as ListTrack, Paragraph, Text}, 15 | Frame, 16 | }; 17 | 18 | use crate::widgets::{rooms::ListState, RenderWidget}; 19 | 20 | #[derive(Clone, Debug, Default)] 21 | pub struct RoomSearchWidget { 22 | /// This is the RoomId of the last used room, the room to show on startup. 23 | pub(crate) current_room: Rc>>, 24 | /// List of displayable room name and room id 25 | names: ListState, 26 | list_state: ListTrack, 27 | search_term: String, 28 | next_batch_tkn: Option, 29 | area: Rect, 30 | } 31 | 32 | impl RoomSearchWidget { 33 | pub(crate) fn try_room_search(&self) -> bool { 34 | !self.search_term.is_empty() 35 | } 36 | 37 | pub(crate) fn search_term(&self) -> &str { 38 | &self.search_term 39 | } 40 | 41 | pub(crate) fn next_batch_tkn(&self) -> Option<&str> { 42 | self.next_batch_tkn.as_deref() 43 | } 44 | 45 | pub(crate) fn set_current_room_id( 46 | &mut self, 47 | room: Rc>>, 48 | ) -> Rc>> { 49 | let copy = Rc::clone(&room); 50 | self.current_room = room; 51 | copy 52 | } 53 | 54 | pub(crate) fn push_search_text(&mut self, ch: char) { 55 | // TODO only push if it meets criteria? 56 | self.search_term.push(ch); 57 | } 58 | 59 | pub(crate) fn pop_search_text(&mut self) { 60 | self.search_term.pop(); 61 | } 62 | 63 | pub(crate) fn clear_search_result(&mut self) { 64 | self.names.clear(); 65 | } 66 | 67 | pub(crate) fn selected_room(&self) -> Option { 68 | self.names.get_selected().map(|r| r.room_id.clone()) 69 | } 70 | 71 | pub(crate) fn room_search_results(&mut self, response: get_public_rooms_filtered::Response) { 72 | self.next_batch_tkn = response.next_batch.clone(); 73 | // TODO only push if it meets criteria? 74 | for room in response.chunk { 75 | self.names.items.push(room); 76 | } 77 | } 78 | 79 | pub fn on_scroll_up(&mut self, x: u16, y: u16) -> bool { 80 | if self.area.intersects(Rect::new(x, y, 1, 1)) { 81 | self.select_previous(); 82 | return true; 83 | } 84 | false 85 | } 86 | 87 | pub fn on_scroll_down(&mut self, x: u16, y: u16) -> bool { 88 | if self.area.intersects(Rect::new(x, y, 1, 1)) { 89 | self.select_next(); 90 | return true; 91 | } 92 | false 93 | } 94 | 95 | /// Moves selection down the list 96 | pub fn select_next(&mut self) { 97 | self.names.select_next(); 98 | } 99 | 100 | /// Moves the selection up the list 101 | pub fn select_previous(&mut self) { 102 | self.names.select_previous(); 103 | self.list_state.select(Some(self.names.selected_idx())) 104 | } 105 | 106 | /// Passes the remembered filter, room network, and since token to make 107 | /// the room search request again. 108 | pub fn next_request(&mut self) -> Option<(String, RoomNetwork, String)> { 109 | if let Some(tkn) = self.next_batch_tkn() { 110 | Some(( 111 | self.search_term.to_string(), 112 | RoomNetwork::Matrix, 113 | tkn.to_string(), 114 | )) 115 | } else { 116 | None 117 | } 118 | } 119 | } 120 | 121 | impl RenderWidget for RoomSearchWidget { 122 | fn render(&mut self, f: &mut Frame, area: Rect) 123 | where 124 | B: Backend, 125 | { 126 | let chunks = Layout::default() 127 | .constraints( 128 | [ 129 | Constraint::Percentage(20), 130 | Constraint::Percentage(70), 131 | Constraint::Percentage(10), 132 | ] 133 | .as_ref(), 134 | ) 135 | .direction(Direction::Vertical) 136 | .split(area); 137 | 138 | // set the area of the scroll-able window (the rooms list) 139 | self.area = chunks[1]; 140 | 141 | let mut details = String::new(); 142 | let mut found_topic = None::; 143 | 144 | let list_height = area.height as usize; 145 | // Use highlight_style only if something is selected 146 | let selected = self.names.selected; 147 | let highlight_style = Style::default() 148 | .fg(Color::LightGreen) 149 | .modifier(Modifier::BOLD); 150 | let highlight_symbol = ">>"; 151 | // Make sure the list show the selected item 152 | let offset = { 153 | if selected >= list_height { 154 | selected - list_height + 1 155 | } else { 156 | 0 157 | } 158 | }; 159 | 160 | // Render items 161 | let items = self 162 | .names 163 | .items 164 | .iter() 165 | .enumerate() 166 | .map(|(i, room)| { 167 | let name = if let Some(name) = &room.name { 168 | name.to_string() 169 | } else if let Some(canonical) = &room.canonical_alias { 170 | canonical.to_string() 171 | } else { 172 | room.aliases 173 | .first() 174 | .map(|id| id.alias().to_string()) 175 | .unwrap_or(format!( 176 | "room with {} members #{}", 177 | room.num_joined_members, i 178 | )) 179 | }; 180 | if i == selected { 181 | found_topic = room.topic.clone(); 182 | details = format!( 183 | "Can guests participate: {} Members: {}", 184 | if room.guest_can_join { "yes" } else { "no" }, 185 | room.num_joined_members 186 | ); 187 | let style = Style::default() 188 | .bg(highlight_style.bg) 189 | .fg(highlight_style.fg) 190 | .modifier(highlight_style.modifier); 191 | Text::styled(format!("{} {}", highlight_symbol, name), style) 192 | } else { 193 | let style = Style::default().fg(Color::Blue); 194 | Text::styled(format!(" {}", name), style) 195 | } 196 | }) 197 | .skip(offset as usize); 198 | let list = List::new(items) 199 | .block( 200 | Block::default() 201 | .borders(Borders::ALL) 202 | .title("Public Rooms") 203 | .border_style(Style::default().fg(Color::Green).modifier(Modifier::BOLD)) 204 | .title_style(Style::default().fg(Color::Yellow).modifier(Modifier::BOLD)), 205 | ) 206 | .style(Style::default().fg(Color::Magenta).modifier(Modifier::BOLD)); 207 | f.render_widget(list, chunks[1]); 208 | 209 | let mut topic = found_topic.unwrap_or_default(); 210 | topic.push_str(" "); 211 | 212 | let t = vec![ 213 | Text::styled(&topic, Style::default().fg(Color::Blue)), 214 | Text::styled(&details, Style::default().fg(Color::LightGreen)), 215 | ]; 216 | let room_topic = Paragraph::new(t.iter()) 217 | .block( 218 | Block::default() 219 | .borders(Borders::ALL) 220 | .border_style(Style::default().fg(Color::Green).modifier(Modifier::BOLD)) 221 | .title("Room Topic") 222 | .title_style(Style::default().fg(Color::Yellow).modifier(Modifier::BOLD)), 223 | ) 224 | .wrap(true); 225 | f.render_widget(room_topic, chunks[0]); 226 | 227 | let t3 = vec![ 228 | Text::styled(&self.search_term, Style::default().fg(Color::Blue)), 229 | Text::styled( 230 | "<", 231 | Style::default() 232 | .fg(Color::LightGreen) 233 | .modifier(Modifier::RAPID_BLINK), 234 | ), 235 | ]; 236 | let text_box = Paragraph::new(t3.iter()) 237 | .block( 238 | Block::default() 239 | .borders(Borders::ALL) 240 | .border_style(Style::default().fg(Color::Green).modifier(Modifier::BOLD)) 241 | .title("Send") 242 | .title_style(Style::default().fg(Color::Yellow).modifier(Modifier::BOLD)), 243 | ) 244 | .wrap(true); 245 | 246 | f.render_widget(text_box, chunks[2]); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /rumatui-tui/src/backend/termion.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::io; 3 | use std::io::Write; 4 | 5 | use super::Backend; 6 | use crate::buffer::Cell; 7 | use crate::layout::Rect; 8 | use crate::style; 9 | 10 | pub struct TermionBackend 11 | where 12 | W: Write, 13 | { 14 | stdout: W, 15 | } 16 | 17 | impl TermionBackend 18 | where 19 | W: Write, 20 | { 21 | pub fn new(stdout: W) -> TermionBackend { 22 | TermionBackend { stdout } 23 | } 24 | } 25 | 26 | impl Write for TermionBackend 27 | where 28 | W: Write, 29 | { 30 | fn write(&mut self, buf: &[u8]) -> io::Result { 31 | self.stdout.write(buf) 32 | } 33 | 34 | fn flush(&mut self) -> io::Result<()> { 35 | self.stdout.flush() 36 | } 37 | } 38 | 39 | impl Backend for TermionBackend 40 | where 41 | W: Write, 42 | { 43 | /// Clears the entire screen and move the cursor to the top left of the screen 44 | fn clear(&mut self) -> io::Result<()> { 45 | write!(self.stdout, "{}", termion::clear::All)?; 46 | write!(self.stdout, "{}", termion::cursor::Goto(1, 1))?; 47 | self.stdout.flush() 48 | } 49 | 50 | /// Hides cursor 51 | fn hide_cursor(&mut self) -> io::Result<()> { 52 | write!(self.stdout, "{}", termion::cursor::Hide)?; 53 | self.stdout.flush() 54 | } 55 | 56 | /// Shows cursor 57 | fn show_cursor(&mut self) -> io::Result<()> { 58 | write!(self.stdout, "{}", termion::cursor::Show)?; 59 | self.stdout.flush() 60 | } 61 | 62 | /// Gets cursor position (0-based index) 63 | fn get_cursor(&mut self) -> io::Result<(u16, u16)> { 64 | termion::cursor::DetectCursorPos::cursor_pos(&mut self.stdout).map(|(x, y)| (x - 1, y - 1)) 65 | } 66 | 67 | /// Sets cursor position (0-based index) 68 | fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { 69 | write!(self.stdout, "{}", termion::cursor::Goto(x + 1, y + 1))?; 70 | self.stdout.flush() 71 | } 72 | 73 | fn draw<'a, I>(&mut self, content: I) -> io::Result<()> 74 | where 75 | I: Iterator, 76 | { 77 | use std::fmt::Write; 78 | 79 | let mut string = String::with_capacity(content.size_hint().0 * 3); 80 | let mut style = style::Style::default(); 81 | let mut last_y = 0; 82 | let mut last_x = 0; 83 | let mut inst = 0; 84 | for (x, y, cell) in content { 85 | if y != last_y || x != last_x + 1 || inst == 0 { 86 | write!(string, "{}", termion::cursor::Goto(x + 1, y + 1)).unwrap(); 87 | inst += 1; 88 | } 89 | last_x = x; 90 | last_y = y; 91 | if cell.style.modifier != style.modifier { 92 | write!( 93 | string, 94 | "{}", 95 | ModifierDiff { 96 | from: style.modifier, 97 | to: cell.style.modifier 98 | } 99 | ) 100 | .unwrap(); 101 | style.modifier = cell.style.modifier; 102 | inst += 1; 103 | } 104 | if cell.style.fg != style.fg { 105 | write!(string, "{}", Fg(cell.style.fg)).unwrap(); 106 | style.fg = cell.style.fg; 107 | inst += 1; 108 | } 109 | if cell.style.bg != style.bg { 110 | write!(string, "{}", Bg(cell.style.bg)).unwrap(); 111 | style.bg = cell.style.bg; 112 | inst += 1; 113 | } 114 | string.push_str(&cell.symbol); 115 | inst += 1; 116 | } 117 | write!( 118 | self.stdout, 119 | "{}{}{}{}", 120 | string, 121 | Fg(style::Color::Reset), 122 | Bg(style::Color::Reset), 123 | termion::style::Reset, 124 | ) 125 | } 126 | 127 | /// Return the size of the terminal 128 | fn size(&self) -> io::Result { 129 | let terminal = termion::terminal_size()?; 130 | Ok(Rect::new(0, 0, terminal.0, terminal.1)) 131 | } 132 | 133 | fn flush(&mut self) -> io::Result<()> { 134 | self.stdout.flush() 135 | } 136 | } 137 | 138 | struct Fg(style::Color); 139 | 140 | struct Bg(style::Color); 141 | 142 | struct ModifierDiff { 143 | from: style::Modifier, 144 | to: style::Modifier, 145 | } 146 | 147 | impl fmt::Display for Fg { 148 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 149 | use termion::color::Color; 150 | match self.0 { 151 | style::Color::Reset => termion::color::Reset.write_fg(f), 152 | style::Color::Black => termion::color::Black.write_fg(f), 153 | style::Color::Red => termion::color::Red.write_fg(f), 154 | style::Color::Green => termion::color::Green.write_fg(f), 155 | style::Color::Yellow => termion::color::Yellow.write_fg(f), 156 | style::Color::Blue => termion::color::Blue.write_fg(f), 157 | style::Color::Magenta => termion::color::Magenta.write_fg(f), 158 | style::Color::Cyan => termion::color::Cyan.write_fg(f), 159 | style::Color::Gray => termion::color::White.write_fg(f), 160 | style::Color::DarkGray => termion::color::LightBlack.write_fg(f), 161 | style::Color::LightRed => termion::color::LightRed.write_fg(f), 162 | style::Color::LightGreen => termion::color::LightGreen.write_fg(f), 163 | style::Color::LightBlue => termion::color::LightBlue.write_fg(f), 164 | style::Color::LightYellow => termion::color::LightYellow.write_fg(f), 165 | style::Color::LightMagenta => termion::color::LightMagenta.write_fg(f), 166 | style::Color::LightCyan => termion::color::LightCyan.write_fg(f), 167 | style::Color::White => termion::color::LightWhite.write_fg(f), 168 | style::Color::Indexed(i) => termion::color::AnsiValue(i).write_fg(f), 169 | style::Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_fg(f), 170 | } 171 | } 172 | } 173 | impl fmt::Display for Bg { 174 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 175 | use termion::color::Color; 176 | match self.0 { 177 | style::Color::Reset => termion::color::Reset.write_bg(f), 178 | style::Color::Black => termion::color::Black.write_bg(f), 179 | style::Color::Red => termion::color::Red.write_bg(f), 180 | style::Color::Green => termion::color::Green.write_bg(f), 181 | style::Color::Yellow => termion::color::Yellow.write_bg(f), 182 | style::Color::Blue => termion::color::Blue.write_bg(f), 183 | style::Color::Magenta => termion::color::Magenta.write_bg(f), 184 | style::Color::Cyan => termion::color::Cyan.write_bg(f), 185 | style::Color::Gray => termion::color::White.write_bg(f), 186 | style::Color::DarkGray => termion::color::LightBlack.write_bg(f), 187 | style::Color::LightRed => termion::color::LightRed.write_bg(f), 188 | style::Color::LightGreen => termion::color::LightGreen.write_bg(f), 189 | style::Color::LightBlue => termion::color::LightBlue.write_bg(f), 190 | style::Color::LightYellow => termion::color::LightYellow.write_bg(f), 191 | style::Color::LightMagenta => termion::color::LightMagenta.write_bg(f), 192 | style::Color::LightCyan => termion::color::LightCyan.write_bg(f), 193 | style::Color::White => termion::color::LightWhite.write_bg(f), 194 | style::Color::Indexed(i) => termion::color::AnsiValue(i).write_bg(f), 195 | style::Color::Rgb(r, g, b) => termion::color::Rgb(r, g, b).write_bg(f), 196 | } 197 | } 198 | } 199 | 200 | impl fmt::Display for ModifierDiff { 201 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 202 | let remove = self.from - self.to; 203 | if remove.contains(style::Modifier::REVERSED) { 204 | write!(f, "{}", termion::style::NoInvert)?; 205 | } 206 | if remove.contains(style::Modifier::BOLD) { 207 | // XXX: the termion NoBold flag actually enables double-underline on ECMA-48 compliant 208 | // terminals, and NoFaint additionally disables bold... so we use this trick to get 209 | // the right semantics. 210 | write!(f, "{}", termion::style::NoFaint)?; 211 | 212 | if self.to.contains(style::Modifier::DIM) { 213 | write!(f, "{}", termion::style::Faint)?; 214 | } 215 | } 216 | if remove.contains(style::Modifier::ITALIC) { 217 | write!(f, "{}", termion::style::NoItalic)?; 218 | } 219 | if remove.contains(style::Modifier::UNDERLINED) { 220 | write!(f, "{}", termion::style::NoUnderline)?; 221 | } 222 | if remove.contains(style::Modifier::DIM) { 223 | write!(f, "{}", termion::style::NoFaint)?; 224 | 225 | // XXX: the NoFaint flag additionally disables bold as well, so we need to re-enable it 226 | // here if we want it. 227 | if self.to.contains(style::Modifier::BOLD) { 228 | write!(f, "{}", termion::style::Bold)?; 229 | } 230 | } 231 | if remove.contains(style::Modifier::CROSSED_OUT) { 232 | write!(f, "{}", termion::style::NoCrossedOut)?; 233 | } 234 | if remove.contains(style::Modifier::SLOW_BLINK) 235 | || remove.contains(style::Modifier::RAPID_BLINK) 236 | { 237 | write!(f, "{}", termion::style::NoBlink)?; 238 | } 239 | 240 | let add = self.to - self.from; 241 | if add.contains(style::Modifier::REVERSED) { 242 | write!(f, "{}", termion::style::Invert)?; 243 | } 244 | if add.contains(style::Modifier::BOLD) { 245 | write!(f, "{}", termion::style::Bold)?; 246 | } 247 | if add.contains(style::Modifier::ITALIC) { 248 | write!(f, "{}", termion::style::Italic)?; 249 | } 250 | if add.contains(style::Modifier::UNDERLINED) { 251 | write!(f, "{}", termion::style::Underline)?; 252 | } 253 | if add.contains(style::Modifier::DIM) { 254 | write!(f, "{}", termion::style::Faint)?; 255 | } 256 | if add.contains(style::Modifier::CROSSED_OUT) { 257 | write!(f, "{}", termion::style::CrossedOut)?; 258 | } 259 | if add.contains(style::Modifier::SLOW_BLINK) || add.contains(style::Modifier::RAPID_BLINK) { 260 | write!(f, "{}", termion::style::Blink)?; 261 | } 262 | 263 | Ok(()) 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /rumatui-tui/src/backend/curses.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crate::backend::Backend; 4 | use crate::buffer::Cell; 5 | use crate::layout::Rect; 6 | use crate::style::{Color, Modifier, Style}; 7 | use crate::symbols::{bar, block}; 8 | #[cfg(unix)] 9 | use crate::symbols::{line, DOT}; 10 | #[cfg(unix)] 11 | use pancurses::{chtype, ToChtype}; 12 | use unicode_segmentation::UnicodeSegmentation; 13 | 14 | pub struct CursesBackend { 15 | curses: easycurses::EasyCurses, 16 | } 17 | 18 | impl CursesBackend { 19 | pub fn new() -> Option { 20 | let curses = easycurses::EasyCurses::initialize_system()?; 21 | Some(CursesBackend { curses }) 22 | } 23 | 24 | pub fn with_curses(curses: easycurses::EasyCurses) -> CursesBackend { 25 | CursesBackend { curses } 26 | } 27 | 28 | pub fn get_curses(&self) -> &easycurses::EasyCurses { 29 | &self.curses 30 | } 31 | 32 | pub fn get_curses_mut(&mut self) -> &mut easycurses::EasyCurses { 33 | &mut self.curses 34 | } 35 | } 36 | 37 | impl Backend for CursesBackend { 38 | fn draw<'a, I>(&mut self, content: I) -> io::Result<()> 39 | where 40 | I: Iterator, 41 | { 42 | let mut last_col = 0; 43 | let mut last_row = 0; 44 | let mut style = Style { 45 | fg: Color::Reset, 46 | bg: Color::Reset, 47 | modifier: Modifier::empty(), 48 | }; 49 | let mut curses_style = CursesStyle { 50 | fg: easycurses::Color::White, 51 | bg: easycurses::Color::Black, 52 | }; 53 | let mut update_color = false; 54 | for (col, row, cell) in content { 55 | // eprintln!("{:?}", cell); 56 | if row != last_row || col != last_col + 1 { 57 | self.curses.move_rc(i32::from(row), i32::from(col)); 58 | } 59 | last_col = col; 60 | last_row = row; 61 | if cell.style.modifier != style.modifier { 62 | apply_modifier_diff(&mut self.curses.win, style.modifier, cell.style.modifier); 63 | style.modifier = cell.style.modifier; 64 | }; 65 | if cell.style.fg != style.fg { 66 | update_color = true; 67 | if let Some(ccolor) = cell.style.fg.into() { 68 | style.fg = cell.style.fg; 69 | curses_style.fg = ccolor; 70 | } else { 71 | style.fg = Color::White; 72 | curses_style.fg = easycurses::Color::White; 73 | } 74 | }; 75 | if cell.style.bg != style.bg { 76 | update_color = true; 77 | if let Some(ccolor) = cell.style.bg.into() { 78 | style.bg = cell.style.bg; 79 | curses_style.bg = ccolor; 80 | } else { 81 | style.bg = Color::Black; 82 | curses_style.bg = easycurses::Color::Black; 83 | } 84 | }; 85 | if update_color { 86 | self.curses 87 | .set_color_pair(easycurses::ColorPair::new(curses_style.fg, curses_style.bg)); 88 | }; 89 | update_color = false; 90 | draw(&mut self.curses, cell.symbol.as_str()); 91 | } 92 | self.curses.win.attrset(pancurses::Attribute::Normal); 93 | self.curses.set_color_pair(easycurses::ColorPair::new( 94 | easycurses::Color::White, 95 | easycurses::Color::Black, 96 | )); 97 | Ok(()) 98 | } 99 | fn hide_cursor(&mut self) -> io::Result<()> { 100 | self.curses 101 | .set_cursor_visibility(easycurses::CursorVisibility::Invisible); 102 | Ok(()) 103 | } 104 | fn show_cursor(&mut self) -> io::Result<()> { 105 | self.curses 106 | .set_cursor_visibility(easycurses::CursorVisibility::Visible); 107 | Ok(()) 108 | } 109 | fn get_cursor(&mut self) -> io::Result<(u16, u16)> { 110 | let (y, x) = self.curses.get_cursor_rc(); 111 | Ok((x as u16, y as u16)) 112 | } 113 | fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { 114 | self.curses.move_rc(i32::from(y), i32::from(x)); 115 | Ok(()) 116 | } 117 | fn clear(&mut self) -> io::Result<()> { 118 | self.curses.clear(); 119 | // self.curses.refresh(); 120 | Ok(()) 121 | } 122 | fn size(&self) -> Result { 123 | let (nrows, ncols) = self.curses.get_row_col_count(); 124 | Ok(Rect::new(0, 0, ncols as u16, nrows as u16)) 125 | } 126 | fn flush(&mut self) -> io::Result<()> { 127 | self.curses.refresh(); 128 | Ok(()) 129 | } 130 | } 131 | 132 | struct CursesStyle { 133 | fg: easycurses::Color, 134 | bg: easycurses::Color, 135 | } 136 | 137 | #[cfg(unix)] 138 | /// Deals with lack of unicode support for ncurses on unix 139 | fn draw(curses: &mut easycurses::EasyCurses, symbol: &str) { 140 | for grapheme in symbol.graphemes(true) { 141 | let ch = match grapheme { 142 | line::TOP_RIGHT => pancurses::ACS_URCORNER(), 143 | line::VERTICAL => pancurses::ACS_VLINE(), 144 | line::HORIZONTAL => pancurses::ACS_HLINE(), 145 | line::TOP_LEFT => pancurses::ACS_ULCORNER(), 146 | line::BOTTOM_RIGHT => pancurses::ACS_LRCORNER(), 147 | line::BOTTOM_LEFT => pancurses::ACS_LLCORNER(), 148 | line::VERTICAL_LEFT => pancurses::ACS_RTEE(), 149 | line::VERTICAL_RIGHT => pancurses::ACS_LTEE(), 150 | line::HORIZONTAL_DOWN => pancurses::ACS_TTEE(), 151 | line::HORIZONTAL_UP => pancurses::ACS_BTEE(), 152 | block::FULL => pancurses::ACS_BLOCK(), 153 | block::SEVEN_EIGHTHS => pancurses::ACS_BLOCK(), 154 | block::THREE_QUARTERS => pancurses::ACS_BLOCK(), 155 | block::FIVE_EIGHTHS => pancurses::ACS_BLOCK(), 156 | block::HALF => pancurses::ACS_BLOCK(), 157 | block::THREE_EIGHTHS => ' ' as chtype, 158 | block::ONE_QUARTER => ' ' as chtype, 159 | block::ONE_EIGHTH => ' ' as chtype, 160 | bar::SEVEN_EIGHTHS => pancurses::ACS_BLOCK(), 161 | bar::THREE_QUARTERS => pancurses::ACS_BLOCK(), 162 | bar::FIVE_EIGHTHS => pancurses::ACS_BLOCK(), 163 | bar::HALF => pancurses::ACS_BLOCK(), 164 | bar::THREE_EIGHTHS => pancurses::ACS_S9(), 165 | bar::ONE_QUARTER => pancurses::ACS_S9(), 166 | bar::ONE_EIGHTH => pancurses::ACS_S9(), 167 | DOT => pancurses::ACS_BULLET(), 168 | unicode_char => { 169 | if unicode_char.is_ascii() { 170 | let mut chars = unicode_char.chars(); 171 | if let Some(ch) = chars.next() { 172 | ch.to_chtype() 173 | } else { 174 | pancurses::ACS_BLOCK() 175 | } 176 | } else { 177 | pancurses::ACS_BLOCK() 178 | } 179 | } 180 | }; 181 | curses.win.addch(ch); 182 | } 183 | } 184 | 185 | #[cfg(windows)] 186 | fn draw(curses: &mut easycurses::EasyCurses, symbol: &str) { 187 | for grapheme in symbol.graphemes(true) { 188 | let ch = match grapheme { 189 | block::SEVEN_EIGHTHS => block::FULL, 190 | block::THREE_QUARTERS => block::FULL, 191 | block::FIVE_EIGHTHS => block::HALF, 192 | block::THREE_EIGHTHS => block::HALF, 193 | block::ONE_QUARTER => block::HALF, 194 | block::ONE_EIGHTH => " ", 195 | bar::SEVEN_EIGHTHS => bar::FULL, 196 | bar::THREE_QUARTERS => bar::FULL, 197 | bar::FIVE_EIGHTHS => bar::HALF, 198 | bar::THREE_EIGHTHS => bar::HALF, 199 | bar::ONE_QUARTER => bar::HALF, 200 | bar::ONE_EIGHTH => " ", 201 | ch => ch, 202 | }; 203 | // curses.win.addch(ch); 204 | curses.print(ch); 205 | } 206 | } 207 | 208 | impl From for Option { 209 | fn from(color: Color) -> Option { 210 | match color { 211 | Color::Reset => None, 212 | Color::Black => Some(easycurses::Color::Black), 213 | Color::Red | Color::LightRed => Some(easycurses::Color::Red), 214 | Color::Green | Color::LightGreen => Some(easycurses::Color::Green), 215 | Color::Yellow | Color::LightYellow => Some(easycurses::Color::Yellow), 216 | Color::Magenta | Color::LightMagenta => Some(easycurses::Color::Magenta), 217 | Color::Cyan | Color::LightCyan => Some(easycurses::Color::Cyan), 218 | Color::White | Color::Gray | Color::DarkGray => Some(easycurses::Color::White), 219 | Color::Blue | Color::LightBlue => Some(easycurses::Color::Blue), 220 | Color::Indexed(_) => None, 221 | Color::Rgb(_, _, _) => None, 222 | } 223 | } 224 | } 225 | 226 | fn apply_modifier_diff(win: &mut pancurses::Window, from: Modifier, to: Modifier) { 227 | remove_modifier(win, from - to); 228 | add_modifier(win, to - from); 229 | } 230 | 231 | fn remove_modifier(win: &mut pancurses::Window, remove: Modifier) { 232 | if remove.contains(Modifier::BOLD) { 233 | win.attroff(pancurses::Attribute::Bold); 234 | } 235 | if remove.contains(Modifier::DIM) { 236 | win.attroff(pancurses::Attribute::Dim); 237 | } 238 | if remove.contains(Modifier::ITALIC) { 239 | win.attroff(pancurses::Attribute::Italic); 240 | } 241 | if remove.contains(Modifier::UNDERLINED) { 242 | win.attroff(pancurses::Attribute::Underline); 243 | } 244 | if remove.contains(Modifier::SLOW_BLINK) || remove.contains(Modifier::RAPID_BLINK) { 245 | win.attroff(pancurses::Attribute::Blink); 246 | } 247 | if remove.contains(Modifier::REVERSED) { 248 | win.attroff(pancurses::Attribute::Reverse); 249 | } 250 | if remove.contains(Modifier::HIDDEN) { 251 | win.attroff(pancurses::Attribute::Invisible); 252 | } 253 | if remove.contains(Modifier::CROSSED_OUT) { 254 | win.attroff(pancurses::Attribute::Strikeout); 255 | } 256 | } 257 | 258 | fn add_modifier(win: &mut pancurses::Window, add: Modifier) { 259 | if add.contains(Modifier::BOLD) { 260 | win.attron(pancurses::Attribute::Bold); 261 | } 262 | if add.contains(Modifier::DIM) { 263 | win.attron(pancurses::Attribute::Dim); 264 | } 265 | if add.contains(Modifier::ITALIC) { 266 | win.attron(pancurses::Attribute::Italic); 267 | } 268 | if add.contains(Modifier::UNDERLINED) { 269 | win.attron(pancurses::Attribute::Underline); 270 | } 271 | if add.contains(Modifier::SLOW_BLINK) || add.contains(Modifier::RAPID_BLINK) { 272 | win.attron(pancurses::Attribute::Blink); 273 | } 274 | if add.contains(Modifier::REVERSED) { 275 | win.attron(pancurses::Attribute::Reverse); 276 | } 277 | if add.contains(Modifier::HIDDEN) { 278 | win.attron(pancurses::Attribute::Invisible); 279 | } 280 | if add.contains(Modifier::CROSSED_OUT) { 281 | win.attron(pancurses::Attribute::Strikeout); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /LICENSE-APACHE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Devin Ragotzy 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /rumatui-tui/src/widgets/canvas/mod.rs: -------------------------------------------------------------------------------- 1 | mod line; 2 | mod map; 3 | mod points; 4 | mod rectangle; 5 | mod world; 6 | 7 | pub use self::line::Line; 8 | pub use self::map::{Map, MapResolution}; 9 | pub use self::points::Points; 10 | pub use self::rectangle::Rectangle; 11 | 12 | use crate::{ 13 | buffer::Buffer, 14 | layout::Rect, 15 | style::{Color, Style}, 16 | widgets::{Block, Widget}, 17 | }; 18 | use std::fmt::Debug; 19 | 20 | pub const DOTS: [[u16; 2]; 4] = [ 21 | [0x0001, 0x0008], 22 | [0x0002, 0x0010], 23 | [0x0004, 0x0020], 24 | [0x0040, 0x0080], 25 | ]; 26 | pub const BRAILLE_OFFSET: u16 = 0x2800; 27 | pub const BRAILLE_BLANK: char = '⠀'; 28 | 29 | /// Interface for all shapes that may be drawn on a Canvas widget. 30 | pub trait Shape { 31 | fn draw(&self, painter: &mut Painter); 32 | } 33 | 34 | /// Label to draw some text on the canvas 35 | #[derive(Debug, Clone)] 36 | pub struct Label<'a> { 37 | pub x: f64, 38 | pub y: f64, 39 | pub text: &'a str, 40 | pub color: Color, 41 | } 42 | 43 | #[derive(Debug, Clone)] 44 | struct Layer { 45 | string: String, 46 | colors: Vec, 47 | } 48 | 49 | #[derive(Debug, Clone)] 50 | struct Grid { 51 | cells: Vec, 52 | colors: Vec, 53 | } 54 | 55 | impl Grid { 56 | fn new(width: usize, height: usize) -> Grid { 57 | Grid { 58 | cells: vec![BRAILLE_OFFSET; width * height], 59 | colors: vec![Color::Reset; width * height], 60 | } 61 | } 62 | 63 | fn save(&self) -> Layer { 64 | Layer { 65 | string: String::from_utf16(&self.cells).unwrap(), 66 | colors: self.colors.clone(), 67 | } 68 | } 69 | 70 | fn reset(&mut self) { 71 | for c in &mut self.cells { 72 | *c = BRAILLE_OFFSET; 73 | } 74 | for c in &mut self.colors { 75 | *c = Color::Reset; 76 | } 77 | } 78 | } 79 | 80 | #[derive(Debug)] 81 | pub struct Painter<'a, 'b> { 82 | context: &'a mut Context<'b>, 83 | resolution: [f64; 2], 84 | } 85 | 86 | impl<'a, 'b> Painter<'a, 'b> { 87 | /// Convert the (x, y) coordinates to location of a braille dot on the grid 88 | /// 89 | /// # Examples: 90 | /// ``` 91 | /// use rumatui_tui::widgets::canvas::{Painter, Context}; 92 | /// 93 | /// let mut ctx = Context::new(2, 2, [1.0, 2.0], [0.0, 2.0]); 94 | /// let mut painter = Painter::from(&mut ctx); 95 | /// let point = painter.get_point(1.0, 0.0); 96 | /// assert_eq!(point, Some((0, 7))); 97 | /// let point = painter.get_point(1.5, 1.0); 98 | /// assert_eq!(point, Some((1, 3))); 99 | /// let point = painter.get_point(0.0, 0.0); 100 | /// assert_eq!(point, None); 101 | /// let point = painter.get_point(2.0, 2.0); 102 | /// assert_eq!(point, Some((3, 0))); 103 | /// let point = painter.get_point(1.0, 2.0); 104 | /// assert_eq!(point, Some((0, 0))); 105 | /// ``` 106 | pub fn get_point(&self, x: f64, y: f64) -> Option<(usize, usize)> { 107 | let left = self.context.x_bounds[0]; 108 | let right = self.context.x_bounds[1]; 109 | let top = self.context.y_bounds[1]; 110 | let bottom = self.context.y_bounds[0]; 111 | if x < left || x > right || y < bottom || y > top { 112 | return None; 113 | } 114 | let width = (self.context.x_bounds[1] - self.context.x_bounds[0]).abs(); 115 | let height = (self.context.y_bounds[1] - self.context.y_bounds[0]).abs(); 116 | let x = ((x - left) * self.resolution[0] / width) as usize; 117 | let y = ((top - y) * self.resolution[1] / height) as usize; 118 | Some((x, y)) 119 | } 120 | 121 | /// Paint a braille dot 122 | /// 123 | /// # Examples: 124 | /// ``` 125 | /// use rumatui_tui::{style::Color, widgets::canvas::{Painter, Context}}; 126 | /// 127 | /// let mut ctx = Context::new(1, 1, [0.0, 2.0], [0.0, 2.0]); 128 | /// let mut painter = Painter::from(&mut ctx); 129 | /// let cell = painter.paint(1, 3, Color::Red); 130 | /// ``` 131 | pub fn paint(&mut self, x: usize, y: usize, color: Color) { 132 | let index = y / 4 * self.context.width as usize + x / 2; 133 | if let Some(c) = self.context.grid.cells.get_mut(index) { 134 | *c |= DOTS[y % 4][x % 2]; 135 | } 136 | if let Some(c) = self.context.grid.colors.get_mut(index) { 137 | *c = color; 138 | } 139 | } 140 | } 141 | 142 | impl<'a, 'b> From<&'a mut Context<'b>> for Painter<'a, 'b> { 143 | fn from(context: &'a mut Context<'b>) -> Painter<'a, 'b> { 144 | Painter { 145 | resolution: [ 146 | f64::from(context.width) * 2.0 - 1.0, 147 | f64::from(context.height) * 4.0 - 1.0, 148 | ], 149 | context, 150 | } 151 | } 152 | } 153 | 154 | /// Holds the state of the Canvas when painting to it. 155 | #[derive(Debug, Clone)] 156 | pub struct Context<'a> { 157 | width: u16, 158 | height: u16, 159 | x_bounds: [f64; 2], 160 | y_bounds: [f64; 2], 161 | grid: Grid, 162 | dirty: bool, 163 | layers: Vec, 164 | labels: Vec>, 165 | } 166 | 167 | impl<'a> Context<'a> { 168 | pub fn new(width: u16, height: u16, x_bounds: [f64; 2], y_bounds: [f64; 2]) -> Context<'a> { 169 | Context { 170 | width, 171 | height, 172 | x_bounds, 173 | y_bounds, 174 | grid: Grid::new(width as usize, height as usize), 175 | dirty: false, 176 | layers: Vec::new(), 177 | labels: Vec::new(), 178 | } 179 | } 180 | 181 | /// Draw any object that may implement the Shape trait 182 | pub fn draw(&mut self, shape: &S) 183 | where 184 | S: Shape, 185 | { 186 | self.dirty = true; 187 | let mut painter = Painter::from(self); 188 | shape.draw(&mut painter); 189 | } 190 | 191 | /// Go one layer above in the canvas. 192 | pub fn layer(&mut self) { 193 | self.layers.push(self.grid.save()); 194 | self.grid.reset(); 195 | self.dirty = false; 196 | } 197 | 198 | /// Print a string on the canvas at the given position 199 | pub fn print(&mut self, x: f64, y: f64, text: &'a str, color: Color) { 200 | self.labels.push(Label { x, y, text, color }); 201 | } 202 | 203 | /// Push the last layer if necessary 204 | fn finish(&mut self) { 205 | if self.dirty { 206 | self.layer() 207 | } 208 | } 209 | } 210 | 211 | /// The Canvas widget may be used to draw more detailed figures using braille patterns (each 212 | /// cell can have a braille character in 8 different positions). 213 | /// # Examples 214 | /// 215 | /// ``` 216 | /// # use rumatui_tui::widgets::{Block, Borders}; 217 | /// # use rumatui_tui::layout::Rect; 218 | /// # use rumatui_tui::widgets::canvas::{Canvas, Shape, Line, Rectangle, Map, MapResolution}; 219 | /// # use rumatui_tui::style::Color; 220 | /// Canvas::default() 221 | /// .block(Block::default().title("Canvas").borders(Borders::ALL)) 222 | /// .x_bounds([-180.0, 180.0]) 223 | /// .y_bounds([-90.0, 90.0]) 224 | /// .paint(|ctx| { 225 | /// ctx.draw(&Map { 226 | /// resolution: MapResolution::High, 227 | /// color: Color::White 228 | /// }); 229 | /// ctx.layer(); 230 | /// ctx.draw(&Line { 231 | /// x1: 0.0, 232 | /// y1: 10.0, 233 | /// x2: 10.0, 234 | /// y2: 10.0, 235 | /// color: Color::White, 236 | /// }); 237 | /// ctx.draw(&Rectangle { 238 | /// x: 10.0, 239 | /// y: 20.0, 240 | /// width: 10.0, 241 | /// height: 10.0, 242 | /// color: Color::Red 243 | /// }); 244 | /// }); 245 | /// ``` 246 | pub struct Canvas<'a, F> 247 | where 248 | F: Fn(&mut Context), 249 | { 250 | block: Option>, 251 | x_bounds: [f64; 2], 252 | y_bounds: [f64; 2], 253 | painter: Option, 254 | background_color: Color, 255 | } 256 | 257 | impl<'a, F> Default for Canvas<'a, F> 258 | where 259 | F: Fn(&mut Context), 260 | { 261 | fn default() -> Canvas<'a, F> { 262 | Canvas { 263 | block: None, 264 | x_bounds: [0.0, 0.0], 265 | y_bounds: [0.0, 0.0], 266 | painter: None, 267 | background_color: Color::Reset, 268 | } 269 | } 270 | } 271 | 272 | impl<'a, F> Canvas<'a, F> 273 | where 274 | F: Fn(&mut Context), 275 | { 276 | pub fn block(mut self, block: Block<'a>) -> Canvas<'a, F> { 277 | self.block = Some(block); 278 | self 279 | } 280 | pub fn x_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> { 281 | self.x_bounds = bounds; 282 | self 283 | } 284 | pub fn y_bounds(mut self, bounds: [f64; 2]) -> Canvas<'a, F> { 285 | self.y_bounds = bounds; 286 | self 287 | } 288 | 289 | /// Store the closure that will be used to draw to the Canvas 290 | pub fn paint(mut self, f: F) -> Canvas<'a, F> { 291 | self.painter = Some(f); 292 | self 293 | } 294 | 295 | pub fn background_color(mut self, color: Color) -> Canvas<'a, F> { 296 | self.background_color = color; 297 | self 298 | } 299 | } 300 | 301 | impl<'a, F> Widget for Canvas<'a, F> 302 | where 303 | F: Fn(&mut Context), 304 | { 305 | fn render(mut self, area: Rect, buf: &mut Buffer) { 306 | let canvas_area = match self.block { 307 | Some(ref mut b) => { 308 | b.render(area, buf); 309 | b.inner(area) 310 | } 311 | None => area, 312 | }; 313 | 314 | let width = canvas_area.width as usize; 315 | 316 | let painter = match self.painter { 317 | Some(ref p) => p, 318 | None => return, 319 | }; 320 | 321 | // Create a blank context that match the size of the canvas 322 | let mut ctx = Context::new( 323 | canvas_area.width, 324 | canvas_area.height, 325 | self.x_bounds, 326 | self.y_bounds, 327 | ); 328 | // Paint to this context 329 | painter(&mut ctx); 330 | ctx.finish(); 331 | 332 | // Retreive painted points for each layer 333 | for layer in ctx.layers { 334 | for (i, (ch, color)) in layer 335 | .string 336 | .chars() 337 | .zip(layer.colors.into_iter()) 338 | .enumerate() 339 | { 340 | if ch != BRAILLE_BLANK { 341 | let (x, y) = (i % width, i / width); 342 | buf.get_mut(x as u16 + canvas_area.left(), y as u16 + canvas_area.top()) 343 | .set_char(ch) 344 | .set_fg(color) 345 | .set_bg(self.background_color); 346 | } 347 | } 348 | } 349 | 350 | // Finally draw the labels 351 | let style = Style::default().bg(self.background_color); 352 | let left = self.x_bounds[0]; 353 | let right = self.x_bounds[1]; 354 | let top = self.y_bounds[1]; 355 | let bottom = self.y_bounds[0]; 356 | let width = (self.x_bounds[1] - self.x_bounds[0]).abs(); 357 | let height = (self.y_bounds[1] - self.y_bounds[0]).abs(); 358 | let resolution = { 359 | let width = f64::from(canvas_area.width - 1); 360 | let height = f64::from(canvas_area.height - 1); 361 | (width, height) 362 | }; 363 | for label in ctx 364 | .labels 365 | .iter() 366 | .filter(|l| l.x >= left && l.x <= right && l.y <= top && l.y >= bottom) 367 | { 368 | let x = ((label.x - left) * resolution.0 / width) as u16 + canvas_area.left(); 369 | let y = ((top - label.y) * resolution.1 / height) as u16 + canvas_area.top(); 370 | buf.set_stringn( 371 | x, 372 | y, 373 | label.text, 374 | (canvas_area.right() - x) as usize, 375 | style.fg(label.color), 376 | ); 377 | } 378 | } 379 | } 380 | --------------------------------------------------------------------------------