.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
4 | Konoha
5 | A user-friendly TUI client for Matrix written in Rust!
6 |
7 | # Notice: The client is currently not usable and is only hosted on GitHub for version control.
8 |
9 | ## If you want a working terminal client I would suggest [rumatui](https://github.com/DevinR528/rumatui)
10 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "locked": {
5 | "lastModified": 1620759905,
6 | "narHash": "sha256-WiyWawrgmyN0EdmiHyG2V+fqReiVi8bM9cRdMaKQOFg=",
7 | "owner": "numtide",
8 | "repo": "flake-utils",
9 | "rev": "b543720b25df6ffdfcf9227afafc5b8c1fabfae8",
10 | "type": "github"
11 | },
12 | "original": {
13 | "owner": "numtide",
14 | "repo": "flake-utils",
15 | "type": "github"
16 | }
17 | },
18 | "flake-utils_2": {
19 | "locked": {
20 | "lastModified": 1614513358,
21 | "narHash": "sha256-LakhOx3S1dRjnh0b5Dg3mbZyH0ToC9I8Y2wKSkBaTzU=",
22 | "owner": "numtide",
23 | "repo": "flake-utils",
24 | "rev": "5466c5bbece17adaab2d82fae80b46e807611bf3",
25 | "type": "github"
26 | },
27 | "original": {
28 | "owner": "numtide",
29 | "repo": "flake-utils",
30 | "type": "github"
31 | }
32 | },
33 | "nixpkgs": {
34 | "locked": {
35 | "lastModified": 1621552131,
36 | "narHash": "sha256-AD/AEXv+QOYAg0PIqMYv2nbGOGTIwfOGKtz3rE+y+Tc=",
37 | "owner": "nixos",
38 | "repo": "nixpkgs",
39 | "rev": "d42cd445dde587e9a993cd9434cb43da07c4c5de",
40 | "type": "github"
41 | },
42 | "original": {
43 | "owner": "nixos",
44 | "ref": "nixos-unstable",
45 | "repo": "nixpkgs",
46 | "type": "github"
47 | }
48 | },
49 | "nixpkgs_2": {
50 | "locked": {
51 | "lastModified": 1617325113,
52 | "narHash": "sha256-GksR0nvGxfZ79T91UUtWjjccxazv6Yh/MvEJ82v1Xmw=",
53 | "owner": "nixos",
54 | "repo": "nixpkgs",
55 | "rev": "54c1e44240d8a527a8f4892608c4bce5440c3ecb",
56 | "type": "github"
57 | },
58 | "original": {
59 | "owner": "NixOS",
60 | "repo": "nixpkgs",
61 | "type": "github"
62 | }
63 | },
64 | "root": {
65 | "inputs": {
66 | "flake-utils": "flake-utils",
67 | "nixpkgs": "nixpkgs",
68 | "rust-overlay": "rust-overlay"
69 | }
70 | },
71 | "rust-overlay": {
72 | "inputs": {
73 | "flake-utils": "flake-utils_2",
74 | "nixpkgs": "nixpkgs_2"
75 | },
76 | "locked": {
77 | "lastModified": 1621564177,
78 | "narHash": "sha256-b9q+/LMIMIL2zq0PSt2cYFnDnMHNlYxoSsXxptLe3ac=",
79 | "owner": "oxalica",
80 | "repo": "rust-overlay",
81 | "rev": "98e706440e250f6e5c5a7f179b0fc1e7f6270bd5",
82 | "type": "github"
83 | },
84 | "original": {
85 | "owner": "oxalica",
86 | "repo": "rust-overlay",
87 | "type": "github"
88 | }
89 | }
90 | },
91 | "root": "root",
92 | "version": 7
93 | }
94 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "A user-friendly TUI client for Matrix written in Rust.";
3 |
4 | inputs = {
5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
6 | rust-overlay.url = "github:oxalica/rust-overlay";
7 | flake-utils.url = "github:numtide/flake-utils";
8 | };
9 |
10 | outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
11 | flake-utils.lib.eachDefaultSystem (system:
12 | let
13 | overlays = [ (import rust-overlay) ];
14 | pkgs = import nixpkgs {
15 | inherit system overlays;
16 | };
17 | nightly = pkgs.rust-bin.nightly.latest.default.override {
18 | extensions = [ "rust-src" ];
19 | };
20 | in
21 | {
22 | devShell = pkgs.mkShell {
23 | buildInputs = with pkgs; [
24 | nightly
25 | cargo
26 | cargo-edit
27 | cargo-watch
28 | rustfmt
29 | clippy
30 | openssl
31 | pkg-config
32 | cmake
33 | ];
34 |
35 | RUST_SRC_PATH = "${nightly}/lib/rustlib/src/rust/library";
36 | # RUST_BACKTRACE = 1;
37 | };
38 | }
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/context.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | sync::mpsc::{self, Receiver, SendError, Sender},
3 | time::Duration,
4 | };
5 |
6 | use tokio::task::JoinHandle;
7 |
8 | use super::{
9 | ui::prelude::{Menu, Popup},
10 | App,
11 | };
12 | use crate::client::{auth::AuthCreds, Client, ClientNotification};
13 |
14 | pub enum Notification {
15 | QuitApplication(bool),
16 | SetLogin(AuthCreds),
17 | ShowPopup(Popup),
18 | HidePopup,
19 | SwitchMenu(Box),
20 | ClientError(String),
21 | }
22 |
23 | #[derive(Debug, Clone, Default)]
24 | pub struct ContextSettings {
25 | pub hide_help: bool,
26 | pub quit_application: bool,
27 | pub login_details: Option,
28 | }
29 |
30 | impl ContextSettings {
31 | pub fn toggle_help(&mut self) {
32 | self.hide_help = !self.hide_help;
33 | }
34 | }
35 |
36 | pub struct Context {
37 | notification_sender: Sender,
38 | client_notification_sender: Option>,
39 | pub settings: ContextSettings,
40 | }
41 |
42 | impl Context {
43 | pub fn new() -> (Self, Receiver) {
44 | let (notification_sender, notification_rec) = mpsc::channel();
45 |
46 | let this = Self {
47 | notification_sender,
48 | client_notification_sender: None,
49 | settings: ContextSettings::default(),
50 | };
51 |
52 | (this, notification_rec)
53 | }
54 |
55 | pub fn send_client_notification(
56 | &self,
57 | notification: ClientNotification,
58 | ) -> Result<(), SendError> {
59 | if let Some(sender) = &self.client_notification_sender {
60 | sender.send(notification)
61 | } else {
62 | Ok(())
63 | }
64 | }
65 |
66 | pub fn send_notification(
67 | &self,
68 | notification: Notification,
69 | ) -> Result<(), SendError> {
70 | self.notification_sender.send(notification)
71 | }
72 |
73 | pub fn start_client(&mut self, credentials: AuthCreds) -> JoinHandle<()> {
74 | let sender = self.notification_sender.clone();
75 | let (mut client, sender) = Client::new(credentials, sender);
76 | let handle = tokio::task::spawn(async move { client.login().await });
77 |
78 | self.client_notification_sender = Some(sender);
79 | handle
80 | }
81 | }
82 |
83 | pub fn handle_notification(receiver: &Receiver, app: &mut App) {
84 | if let Ok(notification) = receiver.recv_timeout(Duration::from_millis(0)) {
85 | app.on_notification(notification);
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/app/event.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | sync::mpsc::{self, Receiver},
3 | thread::{self, JoinHandle},
4 | time::{Duration, Instant},
5 | };
6 |
7 | use crossterm::event::{self, Event as CTEvent, KeyEvent, MouseEvent};
8 |
9 | use super::App;
10 |
11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 | pub enum Event {
13 | Key(KeyEvent),
14 | Mouse(MouseEvent),
15 | Tick,
16 | }
17 |
18 | pub fn handle_event(receiver: &Receiver, app: &mut App) {
19 | if let Ok(event) = receiver.recv_timeout(Duration::ZERO) {
20 | match event {
21 | Event::Key(key) => app.on_key_press(key),
22 | Event::Mouse(event) => app.on_mouse(event),
23 | Event::Tick => app.on_tick(),
24 | }
25 | }
26 | }
27 |
28 | pub struct EventReceiver {
29 | pub key_handle: JoinHandle<()>,
30 | pub tick_handle: JoinHandle<()>,
31 | pub receiver: Receiver,
32 | }
33 |
34 | pub fn spawn_event_listener(tick_ms: u64) -> EventReceiver {
35 | let (sr, receiver) = mpsc::channel();
36 |
37 | let key_handle = {
38 | let sr = sr.clone();
39 | thread::spawn(move || {
40 | let tick_rate = Duration::from_millis(tick_ms);
41 | let mut last_tick = Instant::now();
42 |
43 | loop {
44 | let timeout = tick_rate
45 | .checked_sub(last_tick.elapsed())
46 | .unwrap_or_else(|| Duration::from_millis(0));
47 |
48 | if event::poll(timeout).unwrap_or_default() {
49 | if let Ok(event) = event::read() {
50 | let term_event = match event {
51 | CTEvent::Key(event) => Event::Key(event),
52 | CTEvent::Mouse(event) => Event::Mouse(event),
53 | CTEvent::Resize(..) => continue,
54 | };
55 |
56 | if let Err(_why) = sr.send(term_event) {
57 | // Handle why
58 | }
59 | }
60 | }
61 | if last_tick.elapsed() >= tick_rate {
62 | last_tick = Instant::now();
63 | }
64 | }
65 | })
66 | };
67 |
68 | let tick_rate = Duration::from_millis(tick_ms);
69 | let tick_handle = {
70 | thread::spawn(move || loop {
71 | if let Err(_why) = sr.send(Event::Tick) {
72 | // Handle why
73 | }
74 | thread::sleep(tick_rate)
75 | })
76 | };
77 |
78 | EventReceiver {
79 | key_handle,
80 | tick_handle,
81 | receiver,
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/app/helper.rs:
--------------------------------------------------------------------------------
1 | use std::io::Stdout;
2 |
3 | use clap::crate_name;
4 | use crossterm::event::{KeyCode, KeyModifiers};
5 | use tui::{
6 | backend::CrosstermBackend,
7 | layout::{Alignment, Constraint, Direction, Layout, Rect},
8 | widgets::{Block, Borders, Paragraph},
9 | Frame,
10 | };
11 |
12 | pub type CrosstermFrame<'a> = Frame<'a, CrosstermBackend>;
13 |
14 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15 | pub struct Spacing {
16 | pub top: u16,
17 | pub bottom: u16,
18 | pub left: u16,
19 | pub right: u16,
20 | }
21 |
22 | impl Spacing {
23 | pub fn new(top: u16, bottom: u16, left: u16, right: u16) -> Self {
24 | Self {
25 | top,
26 | bottom,
27 | left,
28 | right,
29 | }
30 | }
31 | }
32 |
33 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34 | pub enum CenterPosition {
35 | Percentage(u16, u16),
36 | AbsoluteInner(u16, u16),
37 | AbsoluteOutter(u16, u16),
38 | }
39 |
40 | pub fn centered_rect(position: CenterPosition, base: Rect) -> Rect {
41 | let (constraints_x, constraints_y) = match position {
42 | CenterPosition::AbsoluteInner(x, y) => (
43 | [
44 | Constraint::Length((base.width - x) / 2),
45 | Constraint::Length(x),
46 | Constraint::Length((base.width - x) / 2),
47 | ],
48 | [
49 | Constraint::Length((base.height - y) / 2),
50 | Constraint::Length(y),
51 | Constraint::Length((base.height - y) / 2),
52 | ],
53 | ),
54 | CenterPosition::AbsoluteOutter(x, y) => (
55 | [
56 | Constraint::Length(x),
57 | Constraint::Length(base.width - (x * 2)),
58 | Constraint::Length(x),
59 | ],
60 | [
61 | Constraint::Length(y),
62 | Constraint::Length(base.height - (y * 2)),
63 | Constraint::Length(y),
64 | ],
65 | ),
66 | CenterPosition::Percentage(x, y) => (
67 | [
68 | Constraint::Percentage((100 - x) / 2),
69 | Constraint::Percentage(x),
70 | Constraint::Percentage((100 - x) / 2),
71 | ],
72 | [
73 | Constraint::Percentage((100 - y) / 2),
74 | Constraint::Percentage(y),
75 | Constraint::Percentage((100 - y) / 2),
76 | ],
77 | ),
78 | };
79 | let popup_layout = Layout::default()
80 | .direction(Direction::Vertical)
81 | .constraints(constraints_y.as_ref())
82 | .split(base);
83 |
84 | Layout::default()
85 | .direction(Direction::Horizontal)
86 | .constraints(constraints_x.as_ref())
87 | .split(popup_layout[1])[1]
88 | }
89 |
90 | pub fn centered_line(
91 | width: u16,
92 | height: u16,
93 | top_padding: u16,
94 | base: Rect,
95 | ) -> Rect {
96 | let (constraints_x, constraints_y) = (
97 | [
98 | Constraint::Length((base.width - width) / 2),
99 | Constraint::Length(width),
100 | Constraint::Length((base.width - width) / 2),
101 | ],
102 | [
103 | Constraint::Length(top_padding),
104 | Constraint::Length(height),
105 | Constraint::Length(base.height - height - top_padding),
106 | ],
107 | );
108 |
109 | let popup_layout = Layout::default()
110 | .direction(Direction::Vertical)
111 | .constraints(constraints_y.as_ref())
112 | .split(base);
113 |
114 | Layout::default()
115 | .direction(Direction::Horizontal)
116 | .constraints(constraints_x.as_ref())
117 | .split(popup_layout[1])[1]
118 | }
119 |
120 | pub fn split_rect(
121 | left_percentage: u16,
122 | direction: Direction,
123 | rect: Rect,
124 | ) -> [Rect; 2] {
125 | let split = Layout::default()
126 | .direction(direction)
127 | .constraints([
128 | Constraint::Percentage(left_percentage),
129 | Constraint::Percentage(100 - left_percentage),
130 | ])
131 | .split(rect);
132 |
133 | [split[0], split[1]]
134 | }
135 |
136 | pub fn expand_area(mut area: Rect, spacing: Spacing) -> Rect {
137 | area.x -= spacing.left;
138 | area.y -= spacing.top;
139 | area.width += spacing.left + spacing.right;
140 | area.height += spacing.top + spacing.bottom;
141 |
142 | area
143 | }
144 |
145 | pub fn shrink_area(mut area: Rect, spacing: Spacing) -> Rect {
146 | area.x += spacing.left;
147 | area.y += spacing.top;
148 | area.width -= spacing.left + spacing.right;
149 | area.height -= spacing.top + spacing.bottom;
150 |
151 | area
152 | }
153 |
154 | // TODO: Tidy this
155 | // Returns the rect in which the rest of the app can be
156 | // drawn
157 | pub fn draw_help_menu(
158 | frame: &mut CrosstermFrame,
159 | mut menu_help: Vec<(KeyModifiers, KeyCode, String)>,
160 | max_size: Rect,
161 | ) -> Rect {
162 | menu_help.insert(
163 | 0,
164 | (
165 | KeyModifiers::CONTROL,
166 | KeyCode::Char('h'),
167 | "Toggle help menu".to_string(),
168 | ),
169 | );
170 | menu_help.insert(
171 | 1,
172 | (
173 | KeyModifiers::CONTROL,
174 | KeyCode::Char('d'),
175 | format!("Exit {}", crate_name!()),
176 | ),
177 | );
178 | let mapped = menu_help.into_iter().map(|(mods, key, msg)| {
179 | let mut mod_str = String::new();
180 | if mods.contains(KeyModifiers::ALT) {
181 | mod_str += "Alt+";
182 | }
183 | if mods.contains(KeyModifiers::CONTROL) {
184 | mod_str += "Ctrl+";
185 | }
186 | if mods.contains(KeyModifiers::SHIFT) {
187 | mod_str += "Shft+";
188 | }
189 |
190 | mod_str += format!("{} - {}", keycode_to_str(key), msg).as_ref();
191 |
192 | mod_str
193 | });
194 | let seperator = ", ";
195 | let mut text = mapped.collect::>().join(seperator);
196 | let mut split = split_text(&text, seperator, max_size.width as usize - 6);
197 |
198 | let mut lines = split.len() as u16;
199 | let mut longest = split
200 | .iter()
201 | .map(|line| line.len())
202 | .reduce(|l1, l2| l1.max(l2))
203 | .unwrap_or_else(|| text.len()) as u16;
204 |
205 | if longest + 4 > max_size.width {
206 | text = "Size too small to draw".to_string();
207 | split = split_text(&text, " ", max_size.width as usize - 4);
208 |
209 | lines = split.len() as u16;
210 | longest = split
211 | .iter()
212 | .map(|line| line.len())
213 | .reduce(|l1, l2| l1.max(l2))
214 | .unwrap_or_else(|| text.len()) as u16;
215 | }
216 |
217 | let layouts = Layout::default()
218 | .direction(Direction::Vertical)
219 | .constraints([Constraint::Min(0), Constraint::Length(lines + 2)])
220 | .split(max_size);
221 | let top = layouts[0];
222 | let bottom = layouts[1];
223 |
224 | // Append 4 extra; 2 for padding and 2 for the border
225 | let layout = centered_line(longest + 4, lines + 2, 0, bottom);
226 |
227 | let help_block = Block::default().title("Help").borders(Borders::ALL);
228 | frame.render_widget(help_block, layout);
229 |
230 | for (idx, line) in split.iter().enumerate() {
231 | let layout = centered_line(longest, 1, idx as u16 + 1, bottom);
232 | let paragraph =
233 | Paragraph::new(line.as_ref()).alignment(Alignment::Center);
234 | frame.render_widget(paragraph, layout);
235 | }
236 |
237 | top
238 | }
239 |
240 | fn keycode_to_str(key: KeyCode) -> String {
241 | match key {
242 | KeyCode::F(x) => format!("F{}", x),
243 | KeyCode::Char(x) => x.to_string().to_uppercase(),
244 | KeyCode::Up => "Up".to_string(),
245 | KeyCode::Down => "Down".to_string(),
246 | KeyCode::Left => "Left".to_string(),
247 | KeyCode::Right => "Right".to_string(),
248 | KeyCode::Delete => "Delete".to_string(),
249 | KeyCode::End => "End".to_string(),
250 | KeyCode::Enter => "Enter".to_string(),
251 | KeyCode::Esc => "Escape".to_string(),
252 | KeyCode::Home => "Home".to_string(),
253 | KeyCode::Insert => "Insert".to_string(),
254 | KeyCode::PageUp => "Page Up".to_string(),
255 | KeyCode::PageDown => "Page Down".to_string(),
256 | KeyCode::Tab => "Tab".to_string(),
257 | KeyCode::BackTab => "Backtab".to_string(),
258 | KeyCode::Backspace => "Backspace".to_string(),
259 | KeyCode::Null => String::default(),
260 | }
261 | }
262 |
263 | pub fn split_text(text: &str, sep: &str, max_size: usize) -> Vec {
264 | let mut output = Vec::new();
265 | let mut input_remaining = text.to_string();
266 |
267 | while input_remaining.len() > max_size {
268 | let (split, remaining) =
269 | input_remaining.split_at(input_remaining.len().min(max_size));
270 |
271 | if split.contains(sep) {
272 | let (split, split_remaining) = split.rsplit_once(sep).unwrap();
273 | output.push(split.trim_matches(' ').to_string());
274 | input_remaining = (split_remaining.to_string() + remaining)
275 | .trim_matches(' ')
276 | .to_string();
277 | } else if remaining.starts_with(' ') || split.ends_with(' ') {
278 | output.push(split.trim_matches(' ').to_string());
279 | input_remaining = remaining.trim_matches(' ').to_string();
280 | }
281 | }
282 |
283 | output.push(input_remaining);
284 |
285 | output
286 | }
287 |
--------------------------------------------------------------------------------
/src/app/mod.rs:
--------------------------------------------------------------------------------
1 | use std::io::stdout;
2 |
3 | use crossterm::{
4 | event::{
5 | DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent,
6 | KeyModifiers, MouseEvent,
7 | },
8 | execute,
9 | terminal::{
10 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen,
11 | LeaveAlternateScreen,
12 | },
13 | };
14 | use tokio::task::JoinHandle;
15 | use tui::{
16 | backend::CrosstermBackend,
17 | widgets::{Block, BorderType, Borders, Clear, Paragraph},
18 | Terminal,
19 | };
20 |
21 | use self::{
22 | context::{Context, Notification},
23 | event::Event,
24 | helper::{draw_help_menu, expand_area, split_text, CrosstermFrame},
25 | ui::prelude::{
26 | message::PopupMessageBuilder, new_confirm_popup, AuthenticateMenu,
27 | Menu, Popup,
28 | },
29 | };
30 | use crate::{
31 | app::{
32 | context::handle_notification,
33 | event::{handle_event, spawn_event_listener},
34 | helper::Spacing,
35 | },
36 | error::Result,
37 | };
38 |
39 | pub mod context;
40 | pub mod event;
41 | mod helper;
42 | pub mod ui;
43 |
44 | pub struct App {
45 | pub client_handle: Option>,
46 | pub context: Context,
47 | pub menu: Box,
48 | pub popup: Option,
49 | }
50 |
51 | impl App {
52 | pub fn new(context: Context) -> Self {
53 | Self {
54 | context,
55 | client_handle: None,
56 | menu: Box::new(AuthenticateMenu::default()),
57 | popup: None,
58 | }
59 | }
60 |
61 | pub fn draw(&mut self, frame: &mut CrosstermFrame) {
62 | let area = if !self.context.settings.hide_help {
63 | let help_message = if let Some(popup) = &mut self.popup {
64 | popup.get_help_message(&self.context)
65 | } else {
66 | self.menu.get_help_message(&self.context)
67 | };
68 |
69 | draw_help_menu(frame, help_message, frame.size())
70 | } else {
71 | frame.size()
72 | };
73 |
74 | let (min_width, min_height) = self.menu.get_minimum_size();
75 | if min_width > area.width || min_height > area.height {
76 | let text = split_text(
77 | "Please resize your screen so there is more space to draw!",
78 | " ",
79 | area.width as usize - 2,
80 | )
81 | .join("\n");
82 |
83 | let block = Block::default().title("Error").borders(Borders::ALL);
84 | let error = Paragraph::new(text).block(block);
85 | frame.render_widget(error, area);
86 | } else {
87 | self.menu.draw(frame, area, &self.context);
88 | }
89 |
90 | if let Some(popup) = &mut self.popup {
91 | let popup_area = popup.get_area(area);
92 | let popup_block = Block::default()
93 | .borders(Borders::ALL)
94 | .border_type(BorderType::Rounded);
95 |
96 | let popup_border =
97 | expand_area(popup_area, Spacing::new(1, 1, 1, 1));
98 | frame.render_widget(Clear, popup_border);
99 | frame.render_widget(popup_block, popup_border);
100 |
101 | popup.draw(frame, popup_area, &self.context);
102 | }
103 | }
104 |
105 | pub fn on_key_press(&mut self, key: KeyEvent) {
106 | if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL
107 | {
108 | self.context.settings.toggle_help();
109 | return;
110 | }
111 |
112 | if key.code == KeyCode::Char('d')
113 | && key.modifiers == KeyModifiers::CONTROL
114 | {
115 | // TODO: Logging
116 | let _ = self
117 | .context
118 | .send_notification(Notification::QuitApplication(true));
119 | return;
120 | }
121 |
122 | if let Some(popup) = &mut self.popup {
123 | popup.on_event(Event::Key(key), &self.context);
124 | } else {
125 | self.menu.on_event(Event::Key(key), &self.context);
126 | }
127 | }
128 |
129 | pub fn on_mouse(&mut self, event: MouseEvent) {
130 | if let Some(popup) = &mut self.popup {
131 | popup.on_event(Event::Mouse(event), &self.context);
132 | } else {
133 | self.menu.on_event(Event::Mouse(event), &self.context);
134 | }
135 | }
136 |
137 | pub fn on_tick(&mut self) {
138 | if let Some(popup) = &mut self.popup {
139 | popup.on_event(Event::Tick, &self.context);
140 | }
141 |
142 | self.menu.on_event(Event::Tick, &self.context);
143 | }
144 |
145 | pub fn on_notification(&mut self, notification: Notification) {
146 | match notification {
147 | Notification::QuitApplication(show_confirm) => {
148 | if show_confirm {
149 | self.popup = Some(new_confirm_popup(
150 | "Are you sure you want to exit?",
151 | |ctx| {
152 | // TODO: Logging
153 | let _ = ctx.send_notification(
154 | Notification::QuitApplication(false),
155 | );
156 | },
157 | ))
158 | } else {
159 | self.context.settings.quit_application = true;
160 | }
161 | },
162 | Notification::SetLogin(login) => {
163 | self.client_handle = Some(self.context.start_client(login))
164 | },
165 | Notification::ShowPopup(popup) => self.popup = Some(popup),
166 | Notification::HidePopup => self.popup = None,
167 | Notification::SwitchMenu(menu) => self.menu = menu,
168 | Notification::ClientError(why) => {
169 | let popup = PopupMessageBuilder::new(why)
170 | .set_title(Some("Error"))
171 | .to_popup();
172 |
173 | // TODO: Logging
174 | let _ = self
175 | .context
176 | .send_notification(Notification::ShowPopup(popup));
177 | },
178 | }
179 | }
180 | }
181 |
182 | pub fn start_app() -> Result<()> {
183 | let (context, noti_rec) = Context::new();
184 |
185 | enable_raw_mode().expect("Unable to enable raw mode.");
186 |
187 | let mut out = stdout();
188 | execute!(out, EnterAlternateScreen, EnableMouseCapture)
189 | .expect("Unable to enter new screen.");
190 |
191 | let backend = CrosstermBackend::new(out);
192 | let mut term = Terminal::new(backend)?;
193 |
194 | let mut app = App::new(context);
195 |
196 | term.clear().expect("Unable to clean terminal.");
197 |
198 | let key_listen_timout = 100;
199 | let event_rec = spawn_event_listener(key_listen_timout);
200 |
201 | loop {
202 | term.draw(|f| app.draw(f)).unwrap();
203 |
204 | handle_event(&event_rec.receiver, &mut app);
205 | handle_notification(¬i_rec, &mut app);
206 |
207 | if app.context.settings.quit_application {
208 | break;
209 | }
210 | }
211 |
212 | let mut out = stdout();
213 | disable_raw_mode().expect("Unable to disable raw mode.");
214 | execute!(out, LeaveAlternateScreen, DisableMouseCapture)
215 | .expect("Unable to restore screen.");
216 |
217 | Ok(())
218 | }
219 |
--------------------------------------------------------------------------------
/src/app/ui/menu/authentication.rs:
--------------------------------------------------------------------------------
1 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2 | use lazy_static::lazy_static;
3 | use regex::Regex;
4 | use tui::{
5 | layout::{Alignment, Constraint, Direction, Layout, Rect},
6 | widgets::{Block, Borders},
7 | };
8 |
9 | use super::Menu;
10 | use crate::{
11 | app::{
12 | context::{Context, Notification},
13 | event::Event,
14 | helper::{self, split_rect, CenterPosition, CrosstermFrame},
15 | ui::prelude::{
16 | message::PopupMessageBuilder, ButtonWidget, LabeledInputWidget,
17 | ValidationType, Widget,
18 | },
19 | },
20 | client::auth::AuthCreds,
21 | };
22 |
23 | lazy_static! {
24 | static ref USERNAME_REGEX: Regex = Regex::new(
25 | "^@?(?P[a-zA-Z0-9_\\-\\.=/]{2,16}):\
26 | (?P([a-zA-Z\\d-]+\\.){1,}[a-z]+)$"
27 | )
28 | .unwrap();
29 | }
30 |
31 | #[derive(Clone)]
32 | pub struct AuthenticateMenu {
33 | focus_index: u8,
34 | username: LabeledInputWidget,
35 | password: LabeledInputWidget,
36 | submit: ButtonWidget,
37 | }
38 |
39 | impl Default for AuthenticateMenu {
40 | fn default() -> Self {
41 | let username = LabeledInputWidget::new("Username")
42 | .set_selected(true)
43 | .set_validation(ValidationType::Functional(|username| {
44 | USERNAME_REGEX.is_match(&username)
45 | }))
46 | .to_owned();
47 |
48 | let password = LabeledInputWidget::new("Password")
49 | .set_secret(true)
50 | .set_validation(ValidationType::Functional(|password| {
51 | !password.is_empty()
52 | }))
53 | .to_owned();
54 |
55 | let submit = ButtonWidget::new("Login", |_| {});
56 |
57 | Self {
58 | focus_index: 0,
59 | username,
60 | password,
61 | submit,
62 | }
63 | }
64 | }
65 |
66 | impl Menu for AuthenticateMenu {
67 | fn on_event(&mut self, event: Event, ctx: &Context) {
68 | match event {
69 | Event::Tick => self.on_tick(ctx),
70 | Event::Key(key) => self.handle_key(key, ctx),
71 | _ => {},
72 | }
73 | }
74 |
75 | fn get_help_message(
76 | &mut self,
77 | _ctx: &Context,
78 | ) -> Vec<(KeyModifiers, KeyCode, String)> {
79 | vec![
80 | (KeyModifiers::NONE, KeyCode::Up, "Select up".to_string()),
81 | (KeyModifiers::NONE, KeyCode::Down, "Select down".to_string()),
82 | (
83 | KeyModifiers::NONE,
84 | KeyCode::Enter,
85 | "Submit login".to_string(),
86 | ),
87 | (KeyModifiers::NONE, KeyCode::Tab, "Next field".to_string()),
88 | ]
89 | }
90 |
91 | fn draw(
92 | &mut self,
93 | frame: &mut CrosstermFrame,
94 | max_size: Rect,
95 | ctx: &Context,
96 | ) {
97 | if max_size.width >= 42 {
98 | // If help menu is shown, lower the max
99 | // size by 3 so that it
100 | // doesn't move when toggling the menu
101 | let max_size = if !ctx.settings.hide_help {
102 | Layout::default()
103 | .direction(Direction::Vertical)
104 | .constraints([Constraint::Length(3), Constraint::Min(1)])
105 | .split(max_size)[1]
106 | } else {
107 | max_size
108 | };
109 |
110 | let size = helper::centered_rect(
111 | CenterPosition::AbsoluteInner(40, 5),
112 | max_size,
113 | );
114 |
115 | let button_chunk = split_rect(
116 | 40,
117 | Direction::Horizontal,
118 | helper::centered_line(36, 1, 3, size),
119 | )[1];
120 |
121 | self.username
122 | .render(helper::centered_line(36, 1, 1, size), frame);
123 | self.password
124 | .render(helper::centered_line(36, 1, 2, size), frame);
125 | self.submit.render(button_chunk, frame);
126 |
127 | let frame_block = Block::default()
128 | .title("Login to Matrix")
129 | .borders(Borders::ALL);
130 | frame.render_widget(frame_block, size);
131 | } else {
132 | }
133 | }
134 |
135 | fn get_minimum_size(&mut self) -> (u16, u16) {
136 | (42, 6)
137 | }
138 | }
139 |
140 | impl AuthenticateMenu {
141 | pub fn new(credentials: AuthCreds) -> Self {
142 | let mut default = Self::default();
143 | default.username.input.set_value(format!(
144 | "@{}:{}",
145 | credentials.username, credentials.homeserver
146 | ));
147 | default.password.input.set_value(credentials.password);
148 |
149 | default
150 | }
151 |
152 | fn on_tick(&mut self, ctx: &Context) {
153 | self.username.on_tick(ctx);
154 | self.password.on_tick(ctx);
155 | self.submit.on_tick(ctx);
156 | }
157 |
158 | fn handle_key(&mut self, key: KeyEvent, ctx: &Context) {
159 | match key.code {
160 | KeyCode::Up | KeyCode::BackTab => {
161 | if self.focus_index == 0 {
162 | self.focus_index = 2;
163 | } else {
164 | self.focus_index -= 1;
165 | }
166 |
167 | self.username.set_selected(self.focus_index == 0);
168 | self.password.set_selected(self.focus_index == 1);
169 | self.submit.set_selected(self.focus_index == 2);
170 |
171 | return;
172 | },
173 | KeyCode::Down | KeyCode::Tab => {
174 | self.focus_index += 1;
175 | self.focus_index %= 3;
176 |
177 | self.username.set_selected(self.focus_index == 0);
178 | self.password.set_selected(self.focus_index == 1);
179 | self.submit.set_selected(self.focus_index == 2);
180 |
181 | return;
182 | },
183 | KeyCode::Enter => {
184 | let error = if !self.username.input.is_valid() {
185 | Some("Username should match '@user:domain'.")
186 | } else if !self.password.input.is_valid() {
187 | Some("No password specified.")
188 | } else {
189 | None
190 | };
191 |
192 | if let Some(msg) = error {
193 | let mut popup_builder = PopupMessageBuilder::new(msg);
194 | let popup = popup_builder
195 | .set_title(Some("Invalid Credentials"))
196 | .set_message_align(Alignment::Center)
197 | .to_popup();
198 | // TODO: Logging
199 | let _ =
200 | ctx.send_notification(Notification::ShowPopup(popup));
201 |
202 | return;
203 | }
204 |
205 | let capture = USERNAME_REGEX
206 | .captures(&self.username.input.value)
207 | .expect("Couldn't capture username regex.");
208 |
209 | let un_group = capture.name("un").unwrap();
210 | let username = un_group.as_str().to_string();
211 |
212 | let hs_group = capture.name("hs").unwrap();
213 | let homeserver = hs_group.as_str().to_string();
214 |
215 | let credentials = AuthCreds {
216 | username,
217 | homeserver,
218 | password: self.password.input.value.clone(),
219 | };
220 |
221 | // TODO: Logging
222 | let _ =
223 | ctx.send_notification(Notification::SetLogin(credentials));
224 | },
225 | _ => {},
226 | }
227 |
228 | self.username.on_key(ctx, key);
229 | self.password.on_key(ctx, key);
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/src/app/ui/menu/loading.rs:
--------------------------------------------------------------------------------
1 | use crossterm::event::{KeyCode, KeyModifiers};
2 | use tui::{
3 | layout::{Alignment, Constraint, Direction, Layout, Rect},
4 | widgets::Paragraph,
5 | };
6 |
7 | use super::{Event, Menu};
8 | use crate::app::{
9 | context::Context,
10 | helper::{centered_rect, CenterPosition, CrosstermFrame},
11 | };
12 |
13 | #[derive(Debug, Clone)]
14 | pub struct LoadingMenu {
15 | text: String,
16 | tick: u16,
17 | progress: u16,
18 | }
19 |
20 | // Ticks per progress
21 | static BAR_TICK_SPEED: u16 = 1;
22 | static BAR_LENGTH: u16 = 20;
23 |
24 | impl Menu for LoadingMenu {
25 | fn on_event(&mut self, event: Event, ctx: &Context) {
26 | if Event::Tick == event {
27 | self.on_tick(ctx)
28 | }
29 | }
30 |
31 | fn get_help_message(
32 | &mut self,
33 | _ctx: &Context,
34 | ) -> Vec<(KeyModifiers, KeyCode, String)> {
35 | vec![]
36 | }
37 |
38 | fn draw(
39 | &mut self,
40 | frame: &mut CrosstermFrame,
41 | max_size: Rect,
42 | _ctx: &Context,
43 | ) {
44 | let width = (BAR_LENGTH + 2).max(self.text.len() as u16);
45 | let text_height = self.text.split('\n').count() as u16;
46 | let height = 2 + text_height;
47 |
48 | let center = centered_rect(
49 | CenterPosition::AbsoluteInner(width, height),
50 | max_size,
51 | );
52 | let chunks = Layout::default()
53 | .direction(Direction::Vertical)
54 | .constraints([
55 | Constraint::Length(text_height),
56 | Constraint::Length(1), // Padding in the middle
57 | Constraint::Length(1),
58 | ])
59 | .split(center);
60 |
61 | let title =
62 | Paragraph::new(self.text.clone()).alignment(Alignment::Center);
63 | frame.render_widget(title, chunks[0]);
64 |
65 | let progress_bar_text = {
66 | let len = BAR_LENGTH as usize;
67 | let tick = self.progress as usize;
68 | if tick <= len {
69 | "█".repeat(tick) + &" ".repeat(len - tick)
70 | } else {
71 | let tick = tick - len;
72 | " ".repeat(tick) + &"█".repeat(len - tick)
73 | }
74 | };
75 | let progress_bar =
76 | Paragraph::new(progress_bar_text).alignment(Alignment::Center);
77 | frame.render_widget(progress_bar, chunks[2]);
78 | }
79 |
80 | fn get_minimum_size(&mut self) -> (u16, u16) {
81 | let split = self.text.split('\n').collect::>();
82 | let longest = split
83 | .iter()
84 | .map(|line| line.len())
85 | .reduce(|l1, l2| l1.max(l2))
86 | .unwrap_or_else(|| self.text.len()) as u16;
87 |
88 | let min_width = (BAR_LENGTH + 2).max(longest);
89 | let min_height = split.len() as u16;
90 |
91 | (min_width, min_height)
92 | }
93 | }
94 |
95 | impl LoadingMenu {
96 | pub fn new(text: T) -> Self {
97 | Self {
98 | text: text.to_string(),
99 | tick: 0,
100 | progress: 0,
101 | }
102 | }
103 |
104 | fn on_tick(&mut self, _ctx: &Context) {
105 | self.tick += 1;
106 | self.tick %= BAR_TICK_SPEED;
107 | if self.tick == 0 {
108 | self.progress += 1;
109 | }
110 | self.progress %= BAR_LENGTH * 2;
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/app/ui/menu/mod.rs:
--------------------------------------------------------------------------------
1 | use std::ops::DerefMut;
2 |
3 | use crossterm::event::{KeyCode, KeyModifiers};
4 | use tui::layout::Rect;
5 |
6 | use super::super::{context::Context, helper::CrosstermFrame};
7 | use crate::app::event::Event;
8 |
9 | pub mod authentication;
10 | pub mod loading;
11 |
12 | pub trait Menu {
13 | fn on_event(&mut self, event: Event, ctx: &Context);
14 |
15 | fn draw(
16 | &mut self,
17 | frame: &mut CrosstermFrame,
18 | max_size: Rect,
19 | ctx: &Context,
20 | );
21 |
22 | fn get_help_message(
23 | &mut self,
24 | ctx: &Context,
25 | ) -> Vec<(KeyModifiers, KeyCode, String)>;
26 |
27 | fn get_minimum_size(&mut self) -> (u16, u16);
28 | }
29 |
30 | impl Menu for Box {
31 | fn on_event(&mut self, event: Event, ctx: &Context) {
32 | self.deref_mut().on_event(event, ctx)
33 | }
34 |
35 | fn draw(
36 | &mut self,
37 | frame: &mut CrosstermFrame,
38 | max_size: Rect,
39 | ctx: &Context,
40 | ) {
41 | self.deref_mut().draw(frame, max_size, ctx)
42 | }
43 |
44 | fn get_help_message(
45 | &mut self,
46 | ctx: &Context,
47 | ) -> Vec<(KeyModifiers, KeyCode, String)> {
48 | self.deref_mut().get_help_message(ctx)
49 | }
50 |
51 | fn get_minimum_size(&mut self) -> (u16, u16) {
52 | self.deref_mut().get_minimum_size()
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/app/ui/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod menu;
2 | pub mod popup;
3 | pub mod prelude;
4 | pub mod widget;
5 |
--------------------------------------------------------------------------------
/src/app/ui/popup/confirmation.rs:
--------------------------------------------------------------------------------
1 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2 | use lazy_static::lazy_static;
3 | use tui::{
4 | layout::{Alignment, Constraint, Direction, Layout, Rect},
5 | style::{Modifier, Style},
6 | widgets::{Paragraph, Wrap},
7 | };
8 |
9 | use super::{Popup, PopupArea, PopupPosition};
10 | use crate::app::{
11 | context::{Context, Notification},
12 | event::Event,
13 | helper::{shrink_area, split_rect, CrosstermFrame, Spacing},
14 | ui::prelude::{ButtonWidget, Menu, Widget},
15 | };
16 |
17 | lazy_static! {
18 | static ref CONFIRM_TITLE: String = "Confirm".to_string();
19 | static ref TITLE_SPACING: Spacing = Spacing::new(0, 0, 8, 8);
20 | static ref MESSAGE_SPACING: Spacing = Spacing::new(1, 1, 4, 4);
21 | }
22 |
23 | pub fn new_confirm_popup(
24 | message: T,
25 | callback: fn(&Context),
26 | ) -> Popup {
27 | let area = {
28 | let (message_width, message_height) =
29 | format_padding(&message.to_string(), TITLE_SPACING.to_owned());
30 | let (title_width, title_height) =
31 | format_padding(&CONFIRM_TITLE, MESSAGE_SPACING.to_owned());
32 |
33 | let width = title_width.max(message_width);
34 | // 1 for button line
35 | let height = title_height + message_height + 1;
36 |
37 | PopupArea::Absolute(width, height, PopupPosition::Center)
38 | };
39 |
40 | Popup {
41 | menu: Box::new(ConfirmMenu::new(message.to_string(), callback)),
42 | area,
43 | }
44 | }
45 |
46 | struct ConfirmMenu {
47 | message: String,
48 | cancel_button: ButtonWidget,
49 | confirm_button: ButtonWidget,
50 | focus_index: u8,
51 | }
52 |
53 | impl ConfirmMenu {
54 | fn new(message: String, callback: fn(&Context)) -> Self {
55 | let cancel_button = ButtonWidget::new("Cancel", |_| {})
56 | .set_selected(true)
57 | .to_owned();
58 | let confirm_button = ButtonWidget::new("Confirm", callback);
59 |
60 | Self {
61 | message,
62 | cancel_button,
63 | confirm_button,
64 | focus_index: 0,
65 | }
66 | }
67 |
68 | fn handle_key(&mut self, key: KeyEvent, ctx: &Context) {
69 | match key.code {
70 | KeyCode::Left => {
71 | if self.focus_index == 0 {
72 | self.focus_index = 1;
73 | } else {
74 | self.focus_index -= 1;
75 | }
76 |
77 | self.cancel_button.set_selected(self.focus_index == 0);
78 | self.confirm_button.set_selected(self.focus_index == 1);
79 | },
80 | KeyCode::Right => {
81 | self.focus_index += 1;
82 | self.focus_index %= 2;
83 |
84 | self.cancel_button.set_selected(self.focus_index == 0);
85 | self.confirm_button.set_selected(self.focus_index == 1);
86 | },
87 | KeyCode::Enter => {
88 | self.cancel_button.on_key(ctx, key);
89 | self.confirm_button.on_key(ctx, key);
90 |
91 | // TODO: Logging
92 | let _ = ctx.send_notification(Notification::HidePopup);
93 | },
94 | _ => {},
95 | }
96 | }
97 | }
98 |
99 | impl Menu for ConfirmMenu {
100 | fn on_event(&mut self, event: Event, ctx: &Context) {
101 | if let Event::Key(key) = event {
102 | self.handle_key(key, ctx);
103 | }
104 | }
105 |
106 | fn get_help_message(
107 | &mut self,
108 | _ctx: &Context,
109 | ) -> Vec<(KeyModifiers, KeyCode, String)> {
110 | vec![
111 | (KeyModifiers::NONE, KeyCode::Left, "Select left".to_string()),
112 | (
113 | KeyModifiers::NONE,
114 | KeyCode::Right,
115 | "Select right".to_string(),
116 | ),
117 | (
118 | KeyModifiers::NONE,
119 | KeyCode::Enter,
120 | "Confirm selection".to_string(),
121 | ),
122 | ]
123 | }
124 |
125 | fn draw(
126 | &mut self,
127 | frame: &mut CrosstermFrame,
128 | mut max_size: Rect,
129 | _ctx: &Context,
130 | ) {
131 | let (_title_width, title_height) =
132 | format_padding(&CONFIRM_TITLE.to_owned(), TITLE_SPACING.to_owned());
133 | let split = Layout::default()
134 | .constraints([
135 | Constraint::Length(title_height),
136 | Constraint::Min(1),
137 | Constraint::Length(1),
138 | ])
139 | .direction(tui::layout::Direction::Vertical)
140 | .split(max_size);
141 |
142 | let title_block = Paragraph::new(CONFIRM_TITLE.to_owned())
143 | .wrap(Wrap {
144 | trim: true,
145 | })
146 | .alignment(Alignment::Center)
147 | .style(Style::default().add_modifier(Modifier::BOLD));
148 | frame.render_widget(
149 | title_block,
150 | shrink_area(split[0], TITLE_SPACING.to_owned()),
151 | );
152 |
153 | max_size = split[1];
154 |
155 | let block = Paragraph::new(self.message.clone())
156 | .wrap(Wrap {
157 | trim: false,
158 | })
159 | .alignment(Alignment::Center);
160 | frame.render_widget(
161 | block,
162 | shrink_area(max_size, MESSAGE_SPACING.to_owned()),
163 | );
164 |
165 | let button_split = split_rect(50, Direction::Horizontal, split[2]);
166 | self.confirm_button.render(button_split[0], frame);
167 | self.cancel_button.render(button_split[1], frame);
168 | }
169 |
170 | fn get_minimum_size(&mut self) -> (u16, u16) {
171 | // TODO: Placeholder
172 | (0, 0)
173 | }
174 | }
175 |
176 | fn format_padding(message: &str, padding: Spacing) -> (u16, u16) {
177 | let lines = message.split('\n');
178 | let longest_line = lines
179 | .clone()
180 | .map(|line| line.len())
181 | .reduce(|curr_len, new_len| new_len.max(curr_len))
182 | .unwrap_or(0);
183 |
184 | let width = longest_line as u16 + padding.left + padding.right;
185 | let height = lines.count() as u16 + padding.top + padding.bottom;
186 |
187 | (width, height)
188 | }
189 |
--------------------------------------------------------------------------------
/src/app/ui/popup/message.rs:
--------------------------------------------------------------------------------
1 | use crossterm::event::{KeyCode, KeyModifiers};
2 | use tui::{
3 | layout::{Alignment, Constraint, Layout, Rect},
4 | style::{Modifier, Style},
5 | widgets::{Paragraph, Wrap},
6 | };
7 |
8 | use super::{Popup, PopupArea, PopupPosition};
9 | use crate::app::{
10 | context::Context,
11 | event::Event,
12 | helper::{shrink_area, CrosstermFrame, Spacing},
13 | ui::prelude::Menu,
14 | };
15 |
16 | #[derive(Debug, Clone)]
17 | pub struct PopupMessageBuilder {
18 | title: Option,
19 | title_align: Alignment,
20 | message: String,
21 | message_align: Alignment,
22 | position: PopupPosition,
23 | message_padding: Spacing,
24 | title_padding: Spacing,
25 | }
26 |
27 | #[allow(dead_code)]
28 | impl PopupMessageBuilder {
29 | pub fn new(message: T) -> Self
30 | where
31 | T: ToString, {
32 | Self {
33 | title: None,
34 | title_align: Alignment::Center,
35 | message: message.to_string(),
36 | message_align: Alignment::Left,
37 | position: PopupPosition::Center,
38 | message_padding: Spacing::new(1, 1, 4, 4),
39 | title_padding: Spacing::default(),
40 | }
41 | }
42 |
43 | pub fn to_popup(&self) -> Popup {
44 | let area = {
45 | let (message_width, message_height) =
46 | format_padding(&self.message, self.message_padding);
47 | let (title_width, title_height) = if let Some(title) = &self.title {
48 | format_padding(title, self.title_padding)
49 | } else {
50 | (0, 0)
51 | };
52 |
53 | let width = title_width.max(message_width);
54 | let height = title_height + message_height;
55 |
56 | PopupArea::Absolute(width, height, self.position)
57 | };
58 |
59 | Popup {
60 | menu: Box::new(MessageMenu {
61 | message: self.message.clone(),
62 | message_align: self.message_align,
63 | message_padding: self.message_padding,
64 | title: self.title.clone(),
65 | title_align: self.title_align,
66 | title_padding: self.title_padding,
67 | }),
68 | area,
69 | }
70 | }
71 |
72 | pub fn set_title(&mut self, title: Option) -> &mut Self
73 | where
74 | T: ToString, {
75 | self.title = title.map(|title| title.to_string());
76 | self
77 | }
78 |
79 | pub fn set_title_align(&mut self, title_align: Alignment) -> &mut Self {
80 | self.title_align = title_align;
81 | self
82 | }
83 |
84 | pub fn set_message(&mut self, message: T) -> &mut Self
85 | where
86 | T: ToString, {
87 | self.message = message.to_string();
88 | self
89 | }
90 |
91 | pub fn set_message_align(&mut self, message_align: Alignment) -> &mut Self {
92 | self.message_align = message_align;
93 | self
94 | }
95 |
96 | pub fn set_message_padding(
97 | &mut self,
98 | top: u16,
99 | bottom: u16,
100 | left: u16,
101 | right: u16,
102 | ) -> &mut Self {
103 | self.message_padding = Spacing {
104 | top,
105 | bottom,
106 | left,
107 | right,
108 | };
109 | self
110 | }
111 |
112 | pub fn set_title_padding(
113 | &mut self,
114 | top: u16,
115 | bottom: u16,
116 | left: u16,
117 | right: u16,
118 | ) -> &mut Self {
119 | self.title_padding = Spacing {
120 | top,
121 | bottom,
122 | left,
123 | right,
124 | };
125 | self
126 | }
127 | }
128 |
129 | struct MessageMenu {
130 | message: String,
131 | message_align: Alignment,
132 | title: Option,
133 | title_align: Alignment,
134 | message_padding: Spacing,
135 | title_padding: Spacing,
136 | }
137 |
138 | impl Menu for MessageMenu {
139 | fn draw(
140 | &mut self,
141 | frame: &mut CrosstermFrame,
142 | mut max_size: Rect,
143 | _ctx: &Context,
144 | ) {
145 | if let Some(title) = &self.title {
146 | let (_title_width, title_height) =
147 | format_padding(title, self.title_padding);
148 | let split = Layout::default()
149 | .constraints([
150 | Constraint::Length(title_height),
151 | Constraint::Min(1),
152 | ])
153 | .direction(tui::layout::Direction::Vertical)
154 | .split(max_size);
155 |
156 | let title_block = Paragraph::new(title.clone())
157 | .wrap(Wrap {
158 | trim: true,
159 | })
160 | .alignment(self.title_align)
161 | .style(Style::default().add_modifier(Modifier::BOLD));
162 | frame.render_widget(
163 | title_block,
164 | shrink_area(split[0], self.title_padding),
165 | );
166 |
167 | max_size = split[1];
168 | }
169 |
170 | let block = Paragraph::new(self.message.clone())
171 | .wrap(Wrap {
172 | trim: false,
173 | })
174 | .alignment(self.message_align);
175 | frame.render_widget(block, shrink_area(max_size, self.message_padding));
176 | }
177 |
178 | fn on_event(&mut self, event: Event, ctx: &Context) {
179 | if let Event::Key(key) = event {
180 | if key.code == KeyCode::Esc {
181 | // TODO: Logging
182 | let _ = ctx.send_notification(
183 | crate::app::context::Notification::HidePopup,
184 | );
185 | }
186 | }
187 | }
188 |
189 | fn get_help_message(
190 | &mut self,
191 | _ctx: &Context,
192 | ) -> Vec<(KeyModifiers, KeyCode, String)> {
193 | vec![(KeyModifiers::NONE, KeyCode::Esc, "Close popup".to_string())]
194 | }
195 |
196 | fn get_minimum_size(&mut self) -> (u16, u16) {
197 | // TODO: Padding
198 | (0, 0)
199 | }
200 | }
201 |
202 | fn format_padding(message: &str, padding: Spacing) -> (u16, u16) {
203 | let lines = message.split('\n');
204 | let longest_line = lines
205 | .clone()
206 | .map(|line| line.len())
207 | .reduce(|curr_len, new_len| new_len.max(curr_len))
208 | .unwrap_or(0);
209 |
210 | let width = longest_line as u16 + padding.left + padding.right;
211 | let height = lines.count() as u16 + padding.top + padding.bottom;
212 |
213 | (width, height)
214 | }
215 |
--------------------------------------------------------------------------------
/src/app/ui/popup/mod.rs:
--------------------------------------------------------------------------------
1 | use crossterm::event::{KeyCode, KeyModifiers};
2 | use tui::layout::{Constraint, Direction, Layout, Rect};
3 |
4 | use super::prelude::Menu;
5 | use crate::app::{
6 | context::Context,
7 | event::Event,
8 | helper::{centered_rect, CenterPosition, CrosstermFrame},
9 | };
10 |
11 | pub mod confirmation;
12 | pub mod message;
13 |
14 | #[allow(dead_code)]
15 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
16 | pub enum PopupPosition {
17 | TopLeft,
18 | Top,
19 | TopRight,
20 | Left,
21 | Center,
22 | Right,
23 | BottomLeft,
24 | Bottom,
25 | BottomRight,
26 | }
27 |
28 | impl Default for PopupPosition {
29 | fn default() -> Self {
30 | Self::Center
31 | }
32 | }
33 |
34 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
35 | pub enum PopupArea {
36 | // The absolute x, y and position
37 | Absolute(u16, u16, PopupPosition),
38 | // Takes in the maximum size (usually screen
39 | // size minus the help menu) and return the
40 | // desired size
41 | Dynamic(fn(Rect) -> Rect),
42 | //
43 | }
44 |
45 | impl Default for PopupArea {
46 | fn default() -> Self {
47 | Self::Dynamic(|area| {
48 | centered_rect(CenterPosition::Percentage(60, 40), area)
49 | })
50 | }
51 | }
52 |
53 | pub struct Popup {
54 | menu: Box,
55 | area: PopupArea,
56 | }
57 |
58 | impl Popup {
59 | pub fn get_area(&self, frame_size: Rect) -> Rect {
60 | match self.area {
61 | PopupArea::Dynamic(func) => func(frame_size),
62 | PopupArea::Absolute(width, height, pos) => {
63 | let max_size = centered_rect(
64 | CenterPosition::AbsoluteOutter(2, 2),
65 | frame_size,
66 | );
67 |
68 | match pos {
69 | PopupPosition::TopLeft => {
70 | let top = Layout::default()
71 | .direction(Direction::Vertical)
72 | .constraints([
73 | Constraint::Length(height),
74 | Constraint::Min(0),
75 | ])
76 | .split(max_size)[0];
77 |
78 | Layout::default()
79 | .direction(Direction::Horizontal)
80 | .constraints([
81 | Constraint::Length(width),
82 | Constraint::Min(0),
83 | ])
84 | .split(top)[0]
85 | },
86 | PopupPosition::Top => {
87 | let top = Layout::default()
88 | .direction(Direction::Vertical)
89 | .constraints([
90 | Constraint::Length(height),
91 | Constraint::Min(0),
92 | ])
93 | .split(max_size)[0];
94 |
95 | centered_rect(
96 | CenterPosition::AbsoluteInner(width, height),
97 | top,
98 | )
99 | },
100 | PopupPosition::TopRight => {
101 | let top = Layout::default()
102 | .direction(Direction::Vertical)
103 | .constraints([
104 | Constraint::Length(height),
105 | Constraint::Min(0),
106 | ])
107 | .split(max_size)[0];
108 |
109 | Layout::default()
110 | .direction(Direction::Horizontal)
111 | .constraints([
112 | Constraint::Min(0),
113 | Constraint::Length(width),
114 | ])
115 | .split(top)[1]
116 | },
117 | PopupPosition::Left => {
118 | let left = Layout::default()
119 | .direction(Direction::Horizontal)
120 | .constraints([
121 | Constraint::Length(width),
122 | Constraint::Min(0),
123 | ])
124 | .split(max_size)[0];
125 |
126 | centered_rect(
127 | CenterPosition::AbsoluteInner(width, height),
128 | left,
129 | )
130 | },
131 | PopupPosition::Center => centered_rect(
132 | CenterPosition::AbsoluteInner(width, height),
133 | max_size,
134 | ),
135 | PopupPosition::Right => {
136 | let right = Layout::default()
137 | .direction(Direction::Horizontal)
138 | .constraints([
139 | Constraint::Min(0),
140 | Constraint::Length(width),
141 | ])
142 | .split(max_size)[1];
143 |
144 | centered_rect(
145 | CenterPosition::AbsoluteInner(width, height),
146 | right,
147 | )
148 | },
149 | PopupPosition::BottomLeft => {
150 | let bottom = Layout::default()
151 | .direction(Direction::Vertical)
152 | .constraints([
153 | Constraint::Min(0),
154 | Constraint::Length(height),
155 | ])
156 | .split(max_size)[1];
157 |
158 | Layout::default()
159 | .direction(Direction::Horizontal)
160 | .constraints([
161 | Constraint::Length(width),
162 | Constraint::Min(0),
163 | ])
164 | .split(bottom)[0]
165 | },
166 | PopupPosition::Bottom => {
167 | let bottom = Layout::default()
168 | .direction(Direction::Vertical)
169 | .constraints([
170 | Constraint::Min(0),
171 | Constraint::Length(height),
172 | ])
173 | .split(max_size)[1];
174 |
175 | centered_rect(
176 | CenterPosition::AbsoluteInner(width, height),
177 | bottom,
178 | )
179 | },
180 | PopupPosition::BottomRight => {
181 | let bottom = Layout::default()
182 | .direction(Direction::Vertical)
183 | .constraints([
184 | Constraint::Min(0),
185 | Constraint::Length(height),
186 | ])
187 | .split(max_size)[1];
188 |
189 | Layout::default()
190 | .direction(Direction::Horizontal)
191 | .constraints([
192 | Constraint::Min(0),
193 | Constraint::Length(width),
194 | ])
195 | .split(bottom)[1]
196 | },
197 | }
198 | },
199 | }
200 | }
201 | }
202 |
203 | impl Menu for Popup {
204 | fn draw(
205 | &mut self,
206 | frame: &mut CrosstermFrame,
207 | max_size: Rect,
208 | ctx: &Context,
209 | ) {
210 | self.menu.draw(frame, max_size, ctx)
211 | }
212 |
213 | fn on_event(&mut self, event: Event, ctx: &Context) {
214 | self.menu.on_event(event, ctx)
215 | }
216 |
217 | fn get_help_message(
218 | &mut self,
219 | ctx: &Context,
220 | ) -> Vec<(KeyModifiers, KeyCode, String)> {
221 | self.menu.get_help_message(ctx)
222 | }
223 |
224 | fn get_minimum_size(&mut self) -> (u16, u16) {
225 | self.menu.get_minimum_size()
226 | }
227 | }
228 |
--------------------------------------------------------------------------------
/src/app/ui/prelude.rs:
--------------------------------------------------------------------------------
1 | pub use super::{
2 | menu::{authentication::*, loading::*, *},
3 | popup::{confirmation::*, *},
4 | widget::{button::*, input::*, *},
5 | };
6 |
--------------------------------------------------------------------------------
/src/app/ui/widget/button.rs:
--------------------------------------------------------------------------------
1 | use crossterm::event::{KeyCode, KeyEvent};
2 | use tui::{
3 | layout::{Alignment, Rect},
4 | style::{Modifier, Style},
5 | widgets::Paragraph,
6 | };
7 |
8 | use super::Widget;
9 | use crate::app::{context::Context, helper::CrosstermFrame};
10 |
11 | #[derive(Clone)]
12 | pub struct ButtonWidget {
13 | pub text: String,
14 | pub submit_fn: fn(&Context),
15 | pub selected: bool,
16 | pub enabled: bool,
17 | pub inner_padding: usize,
18 | pub outter_padding: usize,
19 | pub alignment: Alignment,
20 | }
21 |
22 | #[allow(dead_code)]
23 | impl ButtonWidget {
24 | pub fn new(text: T, submit_fn: fn(&Context)) -> Self {
25 | Self {
26 | text: text.to_string(),
27 | submit_fn,
28 | ..Default::default()
29 | }
30 | }
31 |
32 | pub fn set_enabled(&mut self, enabled: bool) -> &mut Self {
33 | self.enabled = enabled;
34 | self
35 | }
36 |
37 | pub fn set_selected(&mut self, selected: bool) -> &mut Self {
38 | if selected != self.selected {
39 | self.on_focus(selected);
40 | }
41 | self
42 | }
43 |
44 | pub fn set_inner_padding(&mut self, inner_padding: usize) -> &mut Self {
45 | self.inner_padding = inner_padding;
46 | self
47 | }
48 |
49 | pub fn set_outter_padding(&mut self, outter_padding: usize) -> &mut Self {
50 | self.outter_padding = outter_padding;
51 | self
52 | }
53 |
54 | pub fn set_alignment(&mut self, alignment: Alignment) -> &mut Self {
55 | self.alignment = alignment;
56 | self
57 | }
58 | }
59 |
60 | impl Default for ButtonWidget {
61 | fn default() -> Self {
62 | Self {
63 | text: String::default(),
64 | submit_fn: |_| {},
65 | selected: bool::default(),
66 | enabled: true,
67 | inner_padding: usize::default(),
68 | outter_padding: usize::default(),
69 | alignment: Alignment::Center,
70 | }
71 | }
72 | }
73 |
74 | impl Widget for ButtonWidget {
75 | fn render(&mut self, area: Rect, frame: &mut CrosstermFrame) {
76 | let inner_padding = " ".repeat(self.inner_padding);
77 | let outter_padding = " ".repeat(self.outter_padding);
78 |
79 | let mut label = format!(
80 | "{}[{}{}{}]{}",
81 | outter_padding,
82 | inner_padding,
83 | self.text,
84 | inner_padding,
85 | outter_padding
86 | );
87 |
88 | if label.len() < area.width as usize {
89 | let difference = area.width as usize - label.len();
90 | match self.alignment {
91 | Alignment::Left => {
92 | label = label + &" ".repeat(difference);
93 | },
94 | Alignment::Center => {
95 | let left = if difference % 2 == 0 {
96 | difference / 2
97 | } else {
98 | (difference + 1) / 2
99 | };
100 | label = " ".repeat(left)
101 | + &label
102 | + &" ".repeat(difference - left);
103 | },
104 | Alignment::Right => {
105 | label = " ".repeat(difference) + &label;
106 | },
107 | }
108 | }
109 |
110 | let mut style = Style::default();
111 | if self.selected {
112 | if self.enabled {
113 | style = style.add_modifier(Modifier::BOLD);
114 | }
115 | } else if !self.enabled {
116 | style = style.add_modifier(Modifier::DIM);
117 | };
118 |
119 | let block = Paragraph::new(label).style(style);
120 | frame.render_widget(block, area);
121 | }
122 |
123 | fn on_key(&mut self, ctx: &Context, key: KeyEvent) {
124 | if self.selected && self.enabled && KeyCode::Enter == key.code {
125 | (self.submit_fn)(ctx);
126 | }
127 | }
128 |
129 | fn on_tick(&mut self, _ctx: &Context) {}
130 |
131 | fn on_focus(&mut self, arrive: bool) {
132 | self.selected = arrive;
133 | }
134 |
135 | fn has_focus(&mut self) -> bool {
136 | self.selected
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/app/ui/widget/input.rs:
--------------------------------------------------------------------------------
1 | use crossterm::event::{KeyCode, KeyEvent};
2 | use tui::{
3 | layout::{Alignment, Direction, Rect},
4 | style::{Color, Style},
5 | widgets::Paragraph,
6 | };
7 |
8 | use super::Widget;
9 | use crate::app::{
10 | context::Context,
11 | helper::{split_rect, CrosstermFrame},
12 | };
13 |
14 | const CURSOR_BLINK_TICKS: u8 = 6;
15 |
16 | #[derive(Debug, Clone)]
17 | pub enum ValidationType {
18 | Manual(bool),
19 | Functional(fn(String) -> bool),
20 | }
21 |
22 | impl Default for ValidationType {
23 | fn default() -> Self {
24 | Self::Manual(true)
25 | }
26 | }
27 |
28 | #[derive(Debug, Clone, Default)]
29 | pub struct InputWidget {
30 | pub value: String,
31 | pub max_len: usize,
32 | pub secret: bool,
33 | pub validation: ValidationType,
34 | pub selected: bool,
35 | pub cursor_pos: usize,
36 | scroll_pos: usize,
37 | tick_count: u8,
38 | }
39 |
40 | #[allow(dead_code)]
41 | impl InputWidget {
42 | pub fn set_value(&mut self, value: T) -> &mut Self {
43 | self.value = value.to_string();
44 | self
45 | }
46 |
47 | pub fn set_max_len(&mut self, len: usize) -> &mut Self {
48 | self.max_len = len;
49 | self
50 | }
51 |
52 | pub fn set_secret(&mut self, secret: bool) -> &mut Self {
53 | self.secret = secret;
54 | self
55 | }
56 |
57 | pub fn set_validation(&mut self, validation: ValidationType) -> &mut Self {
58 | self.validation = validation;
59 | self
60 | }
61 |
62 | pub fn set_selected(&mut self, selected: bool) -> &mut Self {
63 | if selected != self.selected {
64 | self.on_focus(selected);
65 | }
66 | self
67 | }
68 |
69 | pub fn set_cursor_pos(&mut self, pos: usize) -> &mut Self {
70 | self.cursor_pos = pos;
71 | self
72 | }
73 |
74 | pub fn is_valid(&mut self) -> bool {
75 | match self.validation {
76 | ValidationType::Manual(value) => value,
77 | ValidationType::Functional(func) => func(self.value.clone()),
78 | }
79 | }
80 | }
81 |
82 | impl Widget for InputWidget {
83 | fn render(&mut self, area: Rect, frame: &mut CrosstermFrame) {
84 | let max_len = area.width as usize;
85 |
86 | let mut value_text = if self.secret {
87 | "*".repeat(self.value.len())
88 | } else {
89 | self.value.clone()
90 | };
91 |
92 | self.scroll_pos = if value_text.len() < max_len {
93 | 0
94 | } else if self.cursor_pos < self.scroll_pos {
95 | self.cursor_pos
96 | } else if max_len + self.scroll_pos <= self.cursor_pos {
97 | self.cursor_pos - max_len + 1
98 | } else {
99 | self.scroll_pos
100 | };
101 |
102 | if self.scroll_pos > 0 {
103 | let start_pos = self.scroll_pos;
104 | let end_pos = (start_pos + max_len).min(value_text.len());
105 | value_text = value_text[start_pos..end_pos].to_string();
106 | }
107 |
108 | let placeholder_text = "_".repeat(
109 | // use min to ensure value isn't over max_len
110 | max_len - value_text.len().min(max_len),
111 | );
112 |
113 | let mut text = format!("{}{}", value_text, placeholder_text);
114 |
115 | if self.selected && self.tick_count < CURSOR_BLINK_TICKS {
116 | // TODO: Better way to do this
117 | let mut chars: Vec = text.chars().collect();
118 | let cursor_pos = self.cursor_pos - self.scroll_pos;
119 |
120 | // Ensure that no panic
121 | if chars.get(cursor_pos as usize).is_some() {
122 | chars.remove(cursor_pos);
123 | chars.insert(cursor_pos, '█');
124 |
125 | text = String::default();
126 | for c in chars {
127 | text += &c.to_string();
128 | }
129 | }
130 | }
131 |
132 | let block =
133 | Paragraph::new(text).style(Style::default().fg(
134 | if self.is_valid() { Color::Indexed(8) } else { Color::Red },
135 | ));
136 |
137 | frame.render_widget(block, area);
138 | }
139 |
140 | fn on_key(&mut self, _ctx: &Context, key: KeyEvent) {
141 | if self.selected {
142 | match key.code {
143 | KeyCode::Char(key) => {
144 | let split = (
145 | self.value
146 | .chars()
147 | .take(self.cursor_pos)
148 | .collect::(),
149 | self.value
150 | .chars()
151 | .skip(self.cursor_pos)
152 | .collect::(),
153 | );
154 | self.value = format!("{}{}{}", split.0, key, split.1);
155 |
156 | self.cursor_pos += 1;
157 | },
158 | KeyCode::Left => self.cursor_pos = self.cursor_pos.max(1) - 1,
159 | KeyCode::Right => {
160 | self.cursor_pos =
161 | (self.cursor_pos + 1).min(self.value.len())
162 | },
163 | KeyCode::Backspace => {
164 | if self.cursor_pos != 0 {
165 | self.cursor_pos -= 1;
166 | self.value.remove(self.cursor_pos);
167 | }
168 | },
169 | KeyCode::Delete => {
170 | if self.value.len() > self.cursor_pos {
171 | self.value.remove(self.cursor_pos);
172 | }
173 | },
174 | _ => {
175 | // Return so tick count doesn't get
176 | // reset
177 | return;
178 | },
179 | }
180 |
181 | self.tick_count = 0;
182 | }
183 | }
184 |
185 | fn on_tick(&mut self, _ctx: &Context) {
186 | if self.selected {
187 | self.tick_count += 1;
188 | self.tick_count %= CURSOR_BLINK_TICKS * 2;
189 | } else {
190 | self.tick_count = 0;
191 | }
192 | }
193 |
194 | fn on_focus(&mut self, arrive: bool) {
195 | self.selected = arrive;
196 | }
197 |
198 | fn has_focus(&mut self) -> bool {
199 | self.selected
200 | }
201 | }
202 |
203 | #[derive(Debug, Clone)]
204 | pub struct LabeledInputWidget {
205 | pub label: String,
206 | pub label_align: Alignment,
207 | pub split_percentage: u16,
208 | pub input: InputWidget,
209 | }
210 |
211 | #[allow(dead_code)]
212 | impl LabeledInputWidget {
213 | pub fn new(label: T) -> Self {
214 | Self {
215 | label: label.to_string(),
216 | label_align: Alignment::Left,
217 | split_percentage: 40,
218 | input: InputWidget::default(),
219 | }
220 | }
221 |
222 | pub fn set_selected(&mut self, selected: bool) -> &mut Self {
223 | if selected != self.input.selected {
224 | self.on_focus(selected);
225 | }
226 | self
227 | }
228 |
229 | pub fn set_alignment(&mut self, alignment: Alignment) -> &mut Self {
230 | self.label_align = alignment;
231 | self
232 | }
233 |
234 | pub fn set_split(&mut self, percentage: u16) -> &mut Self {
235 | self.split_percentage = percentage;
236 | self
237 | }
238 |
239 | pub fn set_input(
240 | &mut self,
241 | input_fn: fn(&mut InputWidget) -> &mut InputWidget,
242 | ) -> &mut Self {
243 | self.input = input_fn(&mut self.input).clone();
244 | self
245 | }
246 |
247 | pub fn set_validation(&mut self, validation: ValidationType) -> &mut Self {
248 | self.input.set_validation(validation);
249 | self
250 | }
251 |
252 | pub fn set_secret(&mut self, secret: bool) -> &mut Self {
253 | self.input.set_secret(secret);
254 | self
255 | }
256 | }
257 |
258 | impl Widget for LabeledInputWidget {
259 | fn on_tick(&mut self, ctx: &Context) {
260 | self.input.on_tick(ctx);
261 | }
262 |
263 | fn on_key(&mut self, ctx: &Context, key: KeyEvent) {
264 | self.input.on_key(ctx, key);
265 | }
266 |
267 | fn render(&mut self, area: Rect, frame: &mut CrosstermFrame) {
268 | let split =
269 | split_rect(self.split_percentage, Direction::Horizontal, area);
270 |
271 | let label =
272 | Paragraph::new(self.label.clone()).alignment(self.label_align);
273 | frame.render_widget(label, split[0]);
274 | self.input.render(split[1], frame);
275 | }
276 |
277 | fn on_focus(&mut self, arrive: bool) {
278 | self.input.on_focus(arrive);
279 | }
280 |
281 | fn has_focus(&mut self) -> bool {
282 | self.input.selected
283 | }
284 | }
285 |
--------------------------------------------------------------------------------
/src/app/ui/widget/mod.rs:
--------------------------------------------------------------------------------
1 | use std::ops::DerefMut;
2 |
3 | use crossterm::event::KeyEvent;
4 | use tui::layout::Rect;
5 |
6 | use crate::app::{context::Context, helper::CrosstermFrame};
7 |
8 | pub mod button;
9 | pub mod input;
10 |
11 | pub trait Widget {
12 | fn on_key(&mut self, ctx: &Context, key: KeyEvent);
13 | fn on_tick(&mut self, ctx: &Context);
14 |
15 | fn render(&mut self, area: Rect, frame: &mut CrosstermFrame);
16 | fn has_focus(&mut self) -> bool;
17 | fn on_focus(&mut self, arrive: bool);
18 | }
19 |
20 | impl Widget for Box {
21 | fn render(&mut self, area: Rect, frame: &mut CrosstermFrame) {
22 | self.deref_mut().render(area, frame)
23 | }
24 |
25 | fn on_key(&mut self, ctx: &Context, key: KeyEvent) {
26 | self.deref_mut().on_key(ctx, key)
27 | }
28 |
29 | fn on_tick(&mut self, ctx: &Context) {
30 | self.deref_mut().on_tick(ctx)
31 | }
32 |
33 | fn has_focus(&mut self) -> bool {
34 | self.deref_mut().has_focus()
35 | }
36 |
37 | fn on_focus(&mut self, arrive: bool) {
38 | self.deref_mut().on_focus(arrive)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/client/auth.rs:
--------------------------------------------------------------------------------
1 | use matrix_sdk::{Client as MatrixClient, ClientConfig};
2 | use serde::Deserialize;
3 | use url::Url;
4 |
5 | use super::{context::ClientSettings, CLIENT_ID};
6 | use crate::{fs::DATA_DIRECTORY, handle_login_section};
7 |
8 | #[derive(Debug, Clone)]
9 | pub struct AuthCreds {
10 | pub username: String,
11 | pub homeserver: String,
12 | pub password: String,
13 | }
14 |
15 | pub async fn get_home_server(
16 | settings: &ClientSettings,
17 | credentials: &AuthCreds,
18 | ) -> Result {
19 | let url = format!(
20 | "https://{}/.well-known/matrix/client",
21 | credentials.homeserver
22 | );
23 |
24 | let result = handle_login_section!(
25 | settings,
26 | reqwest::get(url).await,
27 | "Unable to connect to home server."
28 | );
29 |
30 | let text = handle_login_section!(
31 | settings,
32 | result.text().await,
33 | "Unable to get home server response."
34 | );
35 |
36 | let home_server = handle_login_section!(
37 | settings,
38 | serde_json::from_str::(&text),
39 | "Unable to parse home server response."
40 | );
41 |
42 | let url = handle_login_section!(
43 | settings,
44 | Url::parse(&home_server.homeserver.url),
45 | "Home server returned malformed URL."
46 | );
47 |
48 | Ok(url)
49 | }
50 |
51 | pub async fn login(
52 | settings: &ClientSettings,
53 | credentials: &AuthCreds,
54 | home_server: Url,
55 | ) -> Result {
56 | let store_path = DATA_DIRECTORY.as_ref().unwrap();
57 | let config = ClientConfig::default().store_path(store_path);
58 |
59 | let client = MatrixClient::new_with_config(home_server, config).unwrap();
60 | let login = client
61 | .login(
62 | &credentials.username.to_lowercase(),
63 | &credentials.password,
64 | None,
65 | Some(&CLIENT_ID),
66 | )
67 | .await;
68 |
69 | handle_login_section!(
70 | settings,
71 | login,
72 | "Unable to login with provided credentials."
73 | );
74 |
75 | Ok(client)
76 | }
77 |
78 | #[derive(Deserialize, Debug)]
79 | struct UrlWrapper {
80 | #[serde(rename = "base_url")]
81 | url: String,
82 | }
83 |
84 | #[derive(Deserialize, Debug)]
85 | struct HomeServerResponse {
86 | #[serde(rename = "m.homeserver")]
87 | homeserver: UrlWrapper,
88 | #[serde(rename = "m.identity_server")]
89 | identity_server: UrlWrapper,
90 | }
91 |
--------------------------------------------------------------------------------
/src/client/context.rs:
--------------------------------------------------------------------------------
1 | use std::sync::mpsc::{Receiver, SendError, Sender};
2 |
3 | use super::ClientNotification;
4 | use crate::app::context::Notification;
5 |
6 | #[derive(Debug, Default, Clone)]
7 | pub struct ClientSettings {
8 | pub verbose: bool,
9 | }
10 |
11 | pub struct Context {
12 | sender: Sender,
13 | receiver: Receiver,
14 | pub settings: ClientSettings,
15 | }
16 |
17 | impl Context {
18 | pub fn new(
19 | sender: Sender,
20 | receiver: Receiver,
21 | ) -> Self {
22 | Self {
23 | sender,
24 | receiver,
25 | settings: ClientSettings::default(),
26 | }
27 | }
28 |
29 | pub fn send_notification(
30 | &self,
31 | notification: Notification,
32 | ) -> Result<(), SendError> {
33 | self.sender.send(notification)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/client/event.rs:
--------------------------------------------------------------------------------
1 | use matrix_sdk::EventHandler;
2 |
3 | pub struct EventCallback;
4 |
5 | impl EventHandler for EventCallback {}
6 |
--------------------------------------------------------------------------------
/src/client/macros.rs:
--------------------------------------------------------------------------------
1 | #[macro_export]
2 | macro_rules! handle_login {
3 | ($client:expr, $val:expr, $msg:expr) => {{
4 | // TODO: Logging
5 | let menu = LoadingMenu::new($msg);
6 | let notification = Notification::SwitchMenu(Box::new(menu));
7 | let _ = $client.context.send_notification(notification);
8 |
9 | match $val.await {
10 | Ok(val) => val,
11 | Err(why) => {
12 | // TODO: Logging
13 | let menu = AuthenticateMenu::new($client.credentials.clone());
14 | let notification = Notification::SwitchMenu(Box::new(menu));
15 | let _ = $client.context.send_notification(notification);
16 |
17 | // TODO: Logging
18 | let notification = Notification::ClientError(why);
19 | let _ = $client.context.send_notification(notification);
20 |
21 | return;
22 | },
23 | }
24 | }};
25 | }
26 |
27 | #[macro_export]
28 | macro_rules! handle_login_section {
29 | ($ctx:expr, $val:expr, $msg:expr) => {
30 | match $val {
31 | Ok(value) => value,
32 | Err(why) => {
33 | return Err(if $ctx.verbose {
34 | format!("{}\n{}", $msg, why)
35 | } else {
36 | $msg.to_string()
37 | });
38 | },
39 | }
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/src/client/mod.rs:
--------------------------------------------------------------------------------
1 | use std::sync::mpsc::{self, Sender};
2 |
3 | use clap::{crate_name, crate_version};
4 | use lazy_static::lazy_static;
5 | use matrix_sdk::SyncSettings;
6 |
7 | use self::{
8 | auth::{get_home_server, login, AuthCreds},
9 | context::Context,
10 | };
11 | use crate::{
12 | app::{
13 | context::Notification,
14 | ui::prelude::{AuthenticateMenu, LoadingMenu},
15 | },
16 | handle_login,
17 | };
18 |
19 | pub mod auth;
20 | mod context;
21 | mod event;
22 | pub mod macros;
23 |
24 | pub enum ClientNotification {
25 | Test,
26 | }
27 |
28 | lazy_static! {
29 | pub static ref CLIENT_ID: String = format!(
30 | "{} v{} ({})",
31 | crate_name!(),
32 | crate_version!(),
33 | if cfg!(windows) {
34 | "Windows"
35 | } else if cfg!(macos) {
36 | "macOS"
37 | } else {
38 | "Linux"
39 | }
40 | );
41 | }
42 |
43 | pub struct Client {
44 | credentials: AuthCreds,
45 | pub context: Context,
46 | }
47 |
48 | impl Client {
49 | pub fn new(
50 | credentials: AuthCreds,
51 | sender: Sender,
52 | ) -> (Self, Sender) {
53 | let (app_sender, receiver) = mpsc::channel();
54 | let context = Context::new(sender, receiver);
55 |
56 | (
57 | Self {
58 | credentials,
59 | context,
60 | },
61 | app_sender,
62 | )
63 | }
64 |
65 | pub async fn login(&mut self) {
66 | let settings = &self.context.settings;
67 |
68 | let home_server = handle_login!(
69 | self,
70 | get_home_server(settings, &self.credentials),
71 | "Fetching home server"
72 | );
73 |
74 | let client = handle_login!(
75 | self,
76 | login(&settings, &self.credentials, home_server),
77 | "Logging in"
78 | );
79 |
80 | // TODO: Logging
81 | let menu = LoadingMenu::new("Syncing data (this may take a while)");
82 | let notification = Notification::SwitchMenu(Box::new(menu));
83 | let _ = self.context.send_notification(notification);
84 |
85 | client.sync(SyncSettings::default()).await;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/error.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | error::Error as StdError,
3 | fmt::{self, Write},
4 | io::{stdout, Error as IoError},
5 | panic::PanicInfo,
6 | result::Result as StdResult,
7 | };
8 |
9 | use backtrace::Backtrace;
10 | use crossterm::{
11 | event::DisableMouseCapture,
12 | execute,
13 | terminal::{disable_raw_mode, LeaveAlternateScreen},
14 | };
15 |
16 | use crate::fs::{save_log, LogType};
17 |
18 | pub type Result = StdResult;
19 |
20 | #[derive(Debug)]
21 | pub enum Error {
22 | ConfigError(String),
23 | OtherError(String),
24 | IoError(IoError),
25 | }
26 |
27 | impl fmt::Display for Error {
28 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 | match self {
30 | Self::OtherError(s) | Self::ConfigError(s) => f.write_str(s),
31 | Self::IoError(e) => fmt::Display::fmt(e, f),
32 | }
33 | }
34 | }
35 |
36 | impl StdError for Error {
37 | fn cause(&self) -> Option<&dyn StdError> {
38 | match self {
39 | Self::IoError(e) => Some(e),
40 | _ => None,
41 | }
42 | }
43 | }
44 |
45 | impl From for Error {
46 | fn from(e: IoError) -> Self {
47 | Self::IoError(e)
48 | }
49 | }
50 |
51 | impl From<&str> for Error {
52 | fn from(msg: &str) -> Self {
53 | Self::OtherError(msg.to_string())
54 | }
55 | }
56 |
57 | // This was heavily inspired by https://crates.io/crates/human-panic
58 | pub fn handle_panic(info: &PanicInfo) {
59 | disable_raw_mode().expect("Unable to disable raw mode.");
60 | execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)
61 | .expect("Unable to restore screen.");
62 |
63 | let name = env!("CARGO_PKG_NAME");
64 | let version = env!("CARGO_PKG_VERSION");
65 | let repo = env!("CARGO_PKG_REPOSITORY");
66 |
67 | let os_info = os_info::get().to_string();
68 |
69 | let trace = get_trace();
70 | let panic_message = info
71 | .message()
72 | .map(|m| format!("{}", m))
73 | .map_or_else(|| "Unknown".to_string(), |msg| msg);
74 |
75 | let mut buffer = String::new();
76 |
77 | let _ = write!(
78 | buffer,
79 | "Version: {}\n\
80 | System: {}\n\
81 | Cause: {}\n\
82 | \n\
83 | Trace: \n\
84 | {}",
85 | version, os_info, panic_message, trace
86 | );
87 |
88 | let path = match save_log(LogType::Crash, buffer) {
89 | Ok(path) => format!("A trace log has been saved to '{}'.", path),
90 | Err(why) => format!("Error creating log file: {}", why),
91 | };
92 |
93 | let submit_url = format!(
94 | "{}/issues/new\
95 | ?assignees=L3afMe\
96 | &template=panic-report.md\
97 | &title=[BUG]%20{}",
98 | repo,
99 | urlencoding::encode(&panic_message),
100 | );
101 |
102 | println!(
103 | "Oh, no! It seems like {} has crashed, this is kind of embarrassing.\n\
104 | \n\
105 | {}\n\
106 | \n\
107 | Due to the nature of {}, we respect your privacy and as such\n\
108 | crash reports are never sent automatically. If you would like\n\
109 | to help up diagnose this issue, please submit an issue at \n\
110 | {}\n\
111 | \n\
112 | The report contains some basic information about your system\n\
113 | like the OS and arch type, this can help with diagnosing what\n\
114 | went wrong, if you don't want this to be sent feel free to\n\
115 | remove it before submitting.\n",
116 | name,
117 | path,
118 | name,
119 | submit_url,
120 | );
121 | }
122 |
123 | fn get_trace() -> String {
124 | // https://github.com/rust-cli/human-panic/blob/master/src/report.rs#L47-L51
125 | const SKIP_FRAMES_NUM: usize = 8;
126 | const HEX_WIDTH: usize = std::mem::size_of::() + 2;
127 |
128 | let mut trace = String::new();
129 |
130 | for (idx, frame) in Backtrace::new()
131 | .frames()
132 | .iter()
133 | .skip(SKIP_FRAMES_NUM)
134 | .enumerate()
135 | {
136 | let newline = if idx == 0 { "" } else { "\n" };
137 | let ip = frame.ip();
138 | let _ = write!(trace, "{}{:3}: {:3$?}", newline, idx, ip, HEX_WIDTH);
139 |
140 | let symbols = frame.symbols();
141 | if symbols.is_empty() {
142 | let _ = write!(trace, " - ");
143 | continue;
144 | }
145 |
146 | for (idx, symbol) in symbols.iter().enumerate() {
147 | if idx != 0 {
148 | let _ = write!(trace, "\n ");
149 | }
150 |
151 | let name = symbol.name().map_or_else(
152 | || "".to_string(),
153 | |symbol| symbol.to_string(),
154 | );
155 | let _ = write!(trace, " - {}", name);
156 |
157 | if let (Some(file), Some(line), Some(col)) =
158 | (symbol.filename(), symbol.lineno(), symbol.colno())
159 | {
160 | let _ = write!(
161 | trace,
162 | "\n at {}:{}:{}",
163 | file.display(),
164 | line,
165 | col,
166 | );
167 | }
168 | }
169 | }
170 |
171 | trace
172 | }
173 |
--------------------------------------------------------------------------------
/src/fs.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | fs::{create_dir_all, File},
3 | io::Write,
4 | path::PathBuf,
5 | };
6 |
7 | use chrono::Local;
8 | use clap::crate_name;
9 | use lazy_static::lazy_static;
10 |
11 | use crate::error::{Error, Result};
12 |
13 | lazy_static! {
14 | pub static ref CONFIG_DIRECTORY: Result = {
15 | let path = dirs::config_dir()
16 | .ok_or_else(|| Error::ConfigError(String::new()))?
17 | .join(format!(".{}", crate_name!()));
18 |
19 | Ok(path)
20 | };
21 | pub static ref DATA_DIRECTORY: Result = {
22 | let path = dirs::data_dir()
23 | .ok_or_else(|| Error::ConfigError(String::new()))?
24 | .join(crate_name!());
25 |
26 | Ok(path)
27 | };
28 | pub static ref CACHE_DIRECTORY: Result = {
29 | let path = dirs::cache_dir()
30 | .ok_or_else(|| Error::ConfigError(String::new()))?
31 | .join(crate_name!());
32 |
33 | Ok(path)
34 | };
35 | }
36 |
37 | pub fn create_directories() -> Result<()> {
38 | let config_dir = CONFIG_DIRECTORY.as_ref().map_err(|_| {
39 | Error::ConfigError("unable to get config directory".to_string())
40 | })?;
41 | if !config_dir.exists() {
42 | create_dir_all(config_dir)?;
43 | }
44 |
45 | let cache_dir = CACHE_DIRECTORY.as_ref().map_err(|_| {
46 | Error::ConfigError("unable to get cache directory".to_string())
47 | })?;
48 | if !cache_dir.exists() {
49 | create_dir_all(cache_dir)?;
50 | }
51 |
52 | let data_dir = DATA_DIRECTORY.as_ref().map_err(|_| {
53 | Error::ConfigError("unable to get data directory".to_string())
54 | })?;
55 | if !data_dir.exists() {
56 | create_dir_all(data_dir)?;
57 | }
58 |
59 | Ok(())
60 | }
61 |
62 | // More logs to come
63 | pub enum LogType {
64 | Crash,
65 | }
66 |
67 | pub fn save_log(log_type: LogType, log: String) -> Result {
68 | let mut dir = DATA_DIRECTORY.as_ref().unwrap().clone();
69 | dir.push("logs");
70 |
71 | if !dir.exists() {
72 | create_dir_all(&dir)?;
73 | }
74 |
75 | let now = Local::now().format("%Y-%m-%d_%H-%M-%S%.3f");
76 |
77 | match log_type {
78 | LogType::Crash => {
79 | dir.push(format!("crash-report_{}.log", now));
80 |
81 | let mut file = File::create(&dir)?;
82 | file.write_all(log.as_bytes())?;
83 |
84 | Ok(dir.display().to_string())
85 | },
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | #![feature(panic_info_message)]
2 |
3 | use crate::error::handle_panic;
4 |
5 | mod app;
6 | mod client;
7 | mod error;
8 | mod fs;
9 |
10 | #[tokio::main]
11 | async fn main() {
12 | if let Err(why) = run().await {
13 | eprintln!("{}", why);
14 | }
15 | }
16 |
17 | async fn run() -> error::Result<()> {
18 | if std::env::var("RUST_BACKTRACE").is_err() {
19 | std::panic::set_hook(Box::new(handle_panic));
20 | }
21 |
22 | fs::create_directories()?;
23 |
24 | app::start_app()
25 | }
26 |
--------------------------------------------------------------------------------