Restructured how UI works so there's views

This commit is contained in:
Michael Zhang 2021-03-11 06:58:37 -06:00
parent ba5d07db91
commit c76c2eaf5c
Signed by: michael
GPG key ID: BDA47A31A3C8EE6B
7 changed files with 273 additions and 76 deletions

View file

@ -5,6 +5,12 @@
//! extensions. Although its primary purpose is to be used in panorama, it should be usable for //! 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 //! general-purpose IMAP usage. See the [client][crate::client] module for more information on how
//! to get started with a client quickly. //! to get started with a client quickly.
//!
//! RFCs:
//!
//! - RFC3501 (IMAP4) : work-in-progress
//! - RFC2177 (IDLE) : implemented
//! - RFC5256 (SORT / THREAD) : planned
#[macro_use] #[macro_use]
extern crate anyhow; extern crate anyhow;

View file

@ -99,7 +99,7 @@ fn run_ui(
let localset = LocalSet::new(); let localset = LocalSet::new();
localset.spawn_local(async { 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) .unwrap_or_else(report_err)
.await; .await;
}); });

View file

@ -27,7 +27,6 @@ impl HandlesInput for ColonPrompt {
let KeyEvent { code, .. } = evt; let KeyEvent { code, .. } = evt;
match code { match code {
KeyCode::Esc => return Ok(InputResult::Pop), KeyCode::Esc => return Ok(InputResult::Pop),
// KeyCode::Char('q') => return Ok(InputResult::Pop),
KeyCode::Char(c) => { KeyCode::Char(c) => {
let mut b = [0; 2]; let mut b = [0; 2];
self.value += c.encode_utf8(&mut b); self.value += c.encode_utf8(&mut b);

View file

@ -43,10 +43,6 @@ impl HandlesInput for BaseInputHandler {
KeyCode::Char(':') => { KeyCode::Char(':') => {
let colon_prompt = Box::new(ColonPrompt::init(term)); let colon_prompt = Box::new(ColonPrompt::init(term));
return Ok(InputResult::Push(colon_prompt)); 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());
} }
_ => {} _ => {}
} }

View file

@ -17,10 +17,10 @@ use tui::{
use crate::mail::EmailMetadata; use crate::mail::EmailMetadata;
use super::FrameType; use super::{FrameType, HandlesInput, Window, UI};
#[derive(Default)] #[derive(Default, Debug)]
pub struct MailTabState { pub struct MailView {
pub folders: Vec<String>, pub folders: Vec<String>,
pub message_uids: Vec<u32>, pub message_uids: Vec<u32>,
pub message_map: HashMap<u32, EmailMetadata>, pub message_map: HashMap<u32, EmailMetadata>,
@ -29,71 +29,20 @@ pub struct MailTabState {
pub change: Arc<AtomicI8>, pub change: Arc<AtomicI8>,
} }
fn humanize_timestamp(date: DateTime<Local>) -> String { impl HandlesInput for MailView {}
let now = Local::now();
let diff = now - date;
if diff < Duration::days(1) { impl Window for MailView {
HumanTime::from(date).to_string() fn name(&self) -> String {
} else if date.year() == now.year() { String::from("email")
date.format("%b %e %T").to_string()
} else {
date.to_rfc2822()
}
} }
impl MailTabState { fn draw(&self, f: FrameType, area: Rect, ui: &UI) {
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 render(&mut self, f: &mut FrameType, area: Rect) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.margin(0) .margin(0)
.constraints([Constraint::Length(20), Constraint::Max(5000)]) .constraints([Constraint::Length(20), Constraint::Max(5000)])
.split(area); .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 // folder list
let items = self let items = self
.folders .folders
@ -155,6 +104,67 @@ impl MailTabState {
.highlight_style(Style::default().bg(Color::DarkGray)); .highlight_style(Style::default().bg(Color::DarkGray));
f.render_widget(dirlist, chunks[0]); 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<Local>) -> 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();
}
} }
} }

View file

@ -3,7 +3,7 @@
mod colon_prompt; mod colon_prompt;
mod input; mod input;
mod keybinds; mod keybinds;
mod mail_tab; mod mail_view;
mod messages; mod messages;
mod windows; mod windows;
@ -41,15 +41,171 @@ use crate::mail::{EmailMetadata, MailEvent};
use self::colon_prompt::ColonPrompt; use self::colon_prompt::ColonPrompt;
use self::input::{BaseInputHandler, HandlesInput, InputResult}; use self::input::{BaseInputHandler, HandlesInput, InputResult};
use self::mail_tab::MailTabState; use self::mail_view::MailView;
pub(crate) use self::messages::*; pub(crate) use self::messages::*;
use self::windows::*; 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<CrosstermBackend<&'a mut Stdout>>; pub(crate) type TermType<'a, 'b> = &'b mut Terminal<CrosstermBackend<&'a mut Stdout>>;
const FRAME_DURATION: Duration = Duration::from_millis(17); 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<MailEvent>,
) -> 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<Box<dyn HandlesInput>> = 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<AtomicBool>,
window_layout: WindowLayout,
windows: HashMap<LayoutId, Box<dyn Window>>,
page_names: HashMap<PageId, String>,
}
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 /// Main entrypoint for the UI
pub async fn run_ui( pub async fn run_ui(
mut stdout: Stdout, mut stdout: Stdout,
@ -61,7 +217,7 @@ pub async fn run_ui(
let backend = CrosstermBackend::new(&mut stdout); let backend = CrosstermBackend::new(&mut stdout);
let mut term = Terminal::new(backend)?; let mut term = Terminal::new(backend)?;
let mut mail_tab = MailTabState::default(); let mut mail_tab = MailView::default();
// state stack for handling inputs // state stack for handling inputs
let should_exit = Arc::new(AtomicBool::new(false)); let should_exit = Arc::new(AtomicBool::new(false));
@ -101,7 +257,7 @@ pub async fn run_ui(
f.render_widget(tabs, chunks[0]); f.render_widget(tabs, chunks[0]);
// this is the main mail tab // this is the main mail tab
mail_tab.render(f, chunks[1]); // mail_tab.render(f, chunks[1]);
// this is the status bar // this is the status bar
if let Some(last_state) = input_states.last() { if let Some(last_state) = input_states.last() {

View file

@ -2,13 +2,33 @@ use std::collections::{HashMap, HashSet, VecDeque};
use tui::layout::Rect; 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); pub struct LayoutId(usize);
#[derive(Copy, Clone, PartialEq, Eq, Hash)] #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct PageId(usize); pub struct PageId(usize);
#[derive(Default)] #[derive(Default, Debug)]
pub struct WindowLayout { pub struct WindowLayout {
ctr: usize, ctr: usize,
currently_active: Option<LayoutId>, currently_active: Option<LayoutId>,
@ -31,6 +51,12 @@ impl WindowLayout {
self.ctr += 1; self.ctr += 1;
self.pages.insert(pid, pg); self.pages.insert(pid, pg);
self.page_order.push(pid); self.page_order.push(pid);
self.ids.insert(id, pid);
if let None = self.currently_active {
self.currently_active = Some(id);
}
(id, pid) (id, pid)
} }
@ -38,8 +64,8 @@ impl WindowLayout {
&self.page_order &self.page_order
} }
/// Get a set of all windows visible on the current page /// Get a set of all windows visible on the current page, given the size of the allotted space
pub fn visible_windows(&self) -> HashMap<LayoutId, Rect> { pub fn visible_windows(&self, area: Rect) -> HashMap<LayoutId, Rect> {
let mut map = HashMap::new(); let mut map = HashMap::new();
if let Some(page) = self if let Some(page) = self
.currently_active .currently_active
@ -52,17 +78,21 @@ impl WindowLayout {
while !q.is_empty() { while !q.is_empty() {
let front = q.pop_front().expect("not empty"); let front = q.pop_front().expect("not empty");
// TODO: how to subdivide properly?
map.insert(front, area);
} }
} }
map map
} }
} }
#[derive(Debug)]
struct PageGraph { struct PageGraph {
root: LayoutId, root: LayoutId,
adj: HashMap<LayoutId, HashSet<(LayoutId, Dir)>>, adj: HashMap<LayoutId, HashSet<(LayoutId, Dir)>>,
} }
#[derive(Debug)]
enum Dir { enum Dir {
H, H,
V, V,