From c76c2eaf5c1cecf8e4bbfb43abc59a03bb0775e4 Mon Sep 17 00:00:00 2001 From: Michael Zhang Date: Thu, 11 Mar 2021 06:58:37 -0600 Subject: [PATCH] Restructured how UI works so there's views --- imap/src/lib.rs | 6 + src/main.rs | 2 +- src/ui/colon_prompt.rs | 1 - src/ui/input.rs | 4 - src/ui/{mail_tab.rs => mail_view.rs} | 130 +++++++++++---------- src/ui/mod.rs | 166 ++++++++++++++++++++++++++- src/ui/windows.rs | 40 ++++++- 7 files changed, 273 insertions(+), 76 deletions(-) rename src/ui/{mail_tab.rs => mail_view.rs} (92%) diff --git a/imap/src/lib.rs b/imap/src/lib.rs index 119f681..2697a91 100644 --- a/imap/src/lib.rs +++ b/imap/src/lib.rs @@ -5,6 +5,12 @@ //! extensions. Although its primary purpose is to be used in panorama, it should be usable for //! general-purpose IMAP usage. See the [client][crate::client] module for more information on how //! to get started with a client quickly. +//! +//! RFCs: +//! +//! - RFC3501 (IMAP4) : work-in-progress +//! - RFC2177 (IDLE) : implemented +//! - RFC5256 (SORT / THREAD) : planned #[macro_use] extern crate anyhow; diff --git a/src/main.rs b/src/main.rs index 47a9511..8e76558 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,7 +99,7 @@ fn run_ui( let localset = LocalSet::new(); localset.spawn_local(async { - ui::run_ui(stdout, exit_tx, mail2ui_rx) + ui::run_ui2(stdout, exit_tx, mail2ui_rx) .unwrap_or_else(report_err) .await; }); diff --git a/src/ui/colon_prompt.rs b/src/ui/colon_prompt.rs index ca86f51..4bedb1f 100644 --- a/src/ui/colon_prompt.rs +++ b/src/ui/colon_prompt.rs @@ -27,7 +27,6 @@ impl HandlesInput for ColonPrompt { let KeyEvent { code, .. } = evt; match code { KeyCode::Esc => return Ok(InputResult::Pop), - // KeyCode::Char('q') => return Ok(InputResult::Pop), KeyCode::Char(c) => { let mut b = [0; 2]; self.value += c.encode_utf8(&mut b); diff --git a/src/ui/input.rs b/src/ui/input.rs index 618df8e..a0d70fc 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -43,10 +43,6 @@ impl HandlesInput for BaseInputHandler { KeyCode::Char(':') => { let colon_prompt = Box::new(ColonPrompt::init(term)); return Ok(InputResult::Push(colon_prompt)); - // let rect = term.size()?; - // term.set_cursor(1, rect.height - 1)?; - // term.show_cursor()?; - // colon_prompt = Some(ColonPrompt::default()); } _ => {} } diff --git a/src/ui/mail_tab.rs b/src/ui/mail_view.rs similarity index 92% rename from src/ui/mail_tab.rs rename to src/ui/mail_view.rs index 9b87371..cfc6488 100644 --- a/src/ui/mail_tab.rs +++ b/src/ui/mail_view.rs @@ -17,10 +17,10 @@ use tui::{ use crate::mail::EmailMetadata; -use super::FrameType; +use super::{FrameType, HandlesInput, Window, UI}; -#[derive(Default)] -pub struct MailTabState { +#[derive(Default, Debug)] +pub struct MailView { pub folders: Vec, pub message_uids: Vec, pub message_map: HashMap, @@ -29,71 +29,20 @@ pub struct MailTabState { pub change: Arc, } -fn humanize_timestamp(date: DateTime) -> String { - let now = Local::now(); - let diff = now - date; +impl HandlesInput for MailView {} - if diff < Duration::days(1) { - HumanTime::from(date).to_string() - } else if date.year() == now.year() { - date.format("%b %e %T").to_string() - } else { - date.to_rfc2822() - } -} - -impl MailTabState { - pub fn move_down(&mut self) { - if self.message_uids.is_empty() { - return; - } - let len = self.message_uids.len(); - if let Some(selected) = self.message_list.selected() { - if selected + 1 < len { - self.message_list.select(Some(selected + 1)); - } - } else { - self.message_list.select(Some(0)); - } +impl Window for MailView { + fn name(&self) -> String { + String::from("email") } - pub fn move_up(&mut self) { - if self.message_uids.is_empty() { - return; - } - let len = self.message_uids.len(); - if let Some(selected) = self.message_list.selected() { - if selected >= 1 { - self.message_list.select(Some(selected - 1)); - } - } else { - self.message_list.select(Some(len - 1)); - } - } - - pub fn render(&mut self, f: &mut FrameType, area: Rect) { + fn draw(&self, f: FrameType, area: Rect, ui: &UI) { let chunks = Layout::default() .direction(Direction::Horizontal) .margin(0) .constraints([Constraint::Length(20), Constraint::Max(5000)]) .split(area); - // make the change - if self - .change - .compare_exchange(-1, 0, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - self.move_up(); - } - if self - .change - .compare_exchange(1, 0, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - self.move_down(); - } - // folder list let items = self .folders @@ -155,6 +104,67 @@ impl MailTabState { .highlight_style(Style::default().bg(Color::DarkGray)); f.render_widget(dirlist, chunks[0]); - f.render_stateful_widget(table, chunks[1], &mut self.message_list); + f.render_widget(table, chunks[1]); + } +} + +fn humanize_timestamp(date: DateTime) -> String { + let now = Local::now(); + let diff = now - date; + + if diff < Duration::days(1) { + HumanTime::from(date).to_string() + } else if date.year() == now.year() { + date.format("%b %e %T").to_string() + } else { + date.to_rfc2822() + } +} + +impl MailView { + pub fn move_down(&mut self) { + if self.message_uids.is_empty() { + return; + } + let len = self.message_uids.len(); + if let Some(selected) = self.message_list.selected() { + if selected + 1 < len { + self.message_list.select(Some(selected + 1)); + } + } else { + self.message_list.select(Some(0)); + } + } + + pub fn move_up(&mut self) { + if self.message_uids.is_empty() { + return; + } + let len = self.message_uids.len(); + if let Some(selected) = self.message_list.selected() { + if selected >= 1 { + self.message_list.select(Some(selected - 1)); + } + } else { + self.message_list.select(Some(len - 1)); + } + } + + pub fn update(&mut self) { + // make the change + if self + .change + .compare_exchange(-1, 0, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + self.move_up(); + } + if self + .change + .compare_exchange(1, 0, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + self.move_down(); + } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index dc542ae..3bb842a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,7 +3,7 @@ mod colon_prompt; mod input; mod keybinds; -mod mail_tab; +mod mail_view; mod messages; mod windows; @@ -41,15 +41,171 @@ use crate::mail::{EmailMetadata, MailEvent}; use self::colon_prompt::ColonPrompt; use self::input::{BaseInputHandler, HandlesInput, InputResult}; -use self::mail_tab::MailTabState; +use self::mail_view::MailView; pub(crate) use self::messages::*; use self::windows::*; -pub(crate) type FrameType<'a, 'b> = Frame<'a, CrosstermBackend<&'b mut Stdout>>; +pub(crate) type FrameType<'a, 'b, 'c> = &'c mut Frame<'a, CrosstermBackend<&'b mut Stdout>>; pub(crate) type TermType<'a, 'b> = &'b mut Terminal>; const FRAME_DURATION: Duration = Duration::from_millis(17); +/// Main entrypoint for the UI +pub async fn run_ui2( + mut stdout: Stdout, + exit_tx: mpsc::Sender<()>, + mut mail2ui_rx: mpsc::UnboundedReceiver, +) -> Result<()> { + execute!(stdout, cursor::Hide, terminal::EnterAlternateScreen)?; + terminal::enable_raw_mode()?; + + let backend = CrosstermBackend::new(&mut stdout); + let mut term = Terminal::new(backend)?; + + let should_exit = Arc::new(AtomicBool::new(false)); + + let mut ui = UI { + should_exit: should_exit.clone(), + window_layout: WindowLayout::default(), + windows: HashMap::new(), + page_names: HashMap::new(), + }; + + ui.open_window(MailView::default()); + + // let mut input_states: Vec> = vec![]; + + while !should_exit.load(Ordering::Relaxed) { + term.draw(|f| { + ui.draw(f); + })?; + + // handle events coming from the UI + if event::poll(FRAME_DURATION)? { + let event = event::read()?; + ui.process_event(event)?; + } + + select! { + // got an event from the mail thread + evt = mail2ui_rx.recv().fuse() => if let Some(evt) = evt { + ui.process_mail_event(evt); + }, + + // wait for approx 60fps + _ = time::sleep(FRAME_DURATION).fuse() => {}, + } + } + + mem::drop(term); + mem::drop(ui); + + execute!( + stdout, + style::ResetColor, + cursor::Show, + terminal::LeaveAlternateScreen + )?; + terminal::disable_raw_mode()?; + + exit_tx.send(()).await?; + Ok(()) +} + +/// UI +pub struct UI { + should_exit: Arc, + window_layout: WindowLayout, + windows: HashMap>, + page_names: HashMap, +} + +impl UI { + fn draw(&mut self, f: FrameType) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(0) + .constraints([Constraint::Max(5000), Constraint::Length(1)]) + .split(f.size()); + + let pages = self.window_layout.list_pages(); + + // draw a list of pages at the bottom + let titles = self + .window_layout + .list_pages() + .iter() + .enumerate() + .map(|(i, id)| { + self.page_names + .get(id) + .cloned() + .unwrap_or_else(|| i.to_string()) + }) + .map(|s| Spans::from(s)) + .collect(); + let tabs = Tabs::new(titles).style(Style::default().bg(Color::DarkGray)); + f.render_widget(tabs, chunks[1]); + + // render all other windows + let visible = self.window_layout.visible_windows(chunks[0]); + for (layout_id, area) in visible.into_iter() { + if let Some(window) = self.windows.get(&layout_id) { + debug!("drawing window {:?}", window.name()); + window.draw(f, area, self); + } + } + } + + fn open_window(&mut self, window: impl Window) { + debug!("opened window {:?}", window.name()); + let (layout_id, page_id) = self.window_layout.new_page(); + + let window = Box::new(window); + self.windows.insert(layout_id, window); + } + + /// Main entrypoint for handling any kind of event coming from the terminal + fn process_event(&mut self, evt: Event) -> Result<()> { + if let Event::Key(evt) = evt { + if let KeyEvent { + code: KeyCode::Char('q'), + .. + } = evt + { + self.should_exit.store(true, Ordering::Relaxed); + } + + // handle states in the state stack + // although this is written in a for loop, every case except one should break + // let mut should_pop = false; + // for input_state in input_states.iter_mut().rev() { + // match input_state.handle_key(&mut term, evt)? { + // InputResult::Ok => break, + // InputResult::Push(state) => { + // input_states.push(state); + // break; + // } + // InputResult::Pop => { + // should_pop = true; + // break; + // } + // } + // } + + // if should_pop { + // input_states.pop(); + // } + } + + Ok(()) + } + + fn process_mail_event(&mut self, evt: MailEvent) { + debug!("received mail event: {:?}", evt); + } +} + /// Main entrypoint for the UI pub async fn run_ui( mut stdout: Stdout, @@ -61,7 +217,7 @@ pub async fn run_ui( let backend = CrosstermBackend::new(&mut stdout); let mut term = Terminal::new(backend)?; - let mut mail_tab = MailTabState::default(); + let mut mail_tab = MailView::default(); // state stack for handling inputs let should_exit = Arc::new(AtomicBool::new(false)); @@ -101,7 +257,7 @@ pub async fn run_ui( f.render_widget(tabs, chunks[0]); // this is the main mail tab - mail_tab.render(f, chunks[1]); + // mail_tab.render(f, chunks[1]); // this is the status bar if let Some(last_state) = input_states.last() { diff --git a/src/ui/windows.rs b/src/ui/windows.rs index 83de86e..12666a6 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -2,13 +2,33 @@ use std::collections::{HashMap, HashSet, VecDeque}; use tui::layout::Rect; -#[derive(Copy, Clone, PartialEq, Eq, Hash)] +use super::{FrameType, HandlesInput, UI}; + +pub trait Window: HandlesInput { + // Return some kind of name + fn name(&self) -> String; + + // Main draw function + fn draw(&self, f: FrameType, area: Rect, ui: &UI); + + /// Draw function, except the window is not the actively focused one + /// + /// By default, this just calls the regular draw function but the window may choose to perform + /// a less intensive draw if it's known to not be active + fn draw_inactive(&mut self, f: FrameType, area: Rect, ui: &UI) { + self.draw(f, area, ui); + } +} + +downcast_rs::impl_downcast!(Window); + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct LayoutId(usize); -#[derive(Copy, Clone, PartialEq, Eq, Hash)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct PageId(usize); -#[derive(Default)] +#[derive(Default, Debug)] pub struct WindowLayout { ctr: usize, currently_active: Option, @@ -31,6 +51,12 @@ impl WindowLayout { self.ctr += 1; self.pages.insert(pid, pg); self.page_order.push(pid); + self.ids.insert(id, pid); + + if let None = self.currently_active { + self.currently_active = Some(id); + } + (id, pid) } @@ -38,8 +64,8 @@ impl WindowLayout { &self.page_order } - /// Get a set of all windows visible on the current page - pub fn visible_windows(&self) -> HashMap { + /// Get a set of all windows visible on the current page, given the size of the allotted space + pub fn visible_windows(&self, area: Rect) -> HashMap { let mut map = HashMap::new(); if let Some(page) = self .currently_active @@ -52,17 +78,21 @@ impl WindowLayout { while !q.is_empty() { let front = q.pop_front().expect("not empty"); + // TODO: how to subdivide properly? + map.insert(front, area); } } map } } +#[derive(Debug)] struct PageGraph { root: LayoutId, adj: HashMap>, } +#[derive(Debug)] enum Dir { H, V,