Add collapse option for mailboxes in sidebar menu

Closes #130

Feature request: collapsible folders with total counter #130 https://git.meli.delivery/meli/meli/issues/130
pull/144/head
Manos Pitsidianakis 2022-08-15 16:32:28 +03:00
parent 4a79b2021d
commit b716e4383e
5 changed files with 251 additions and 45 deletions

View File

@ -407,6 +407,11 @@ Show a different name for this mailbox in the UI
Load this mailbox on startup Load this mailbox on startup
.\" default value .\" default value
.Pq Em true .Pq Em true
.It Ic collapsed Ar boolean
.Pq Em optional
Collapse this mailbox subtree in menu.
.\" default value
.Pq Em false
.It Ic subscribe Ar boolean .It Ic subscribe Ar boolean
.Pq Em optional .Pq Em optional
Watch this mailbox for updates Watch this mailbox for updates
@ -691,6 +696,10 @@ Go to previous mailbox.
Open selected mailbox Open selected mailbox
.\" default value .\" default value
.Pq Em Enter .Pq Em Enter
.It Ic toggle_mailbox_collapse
Toggle mailbox visibility in menu.
.\" default value
.Pq Em Space
.It Ic search .It Ic search
Search within list of e-mails. Search within list of e-mails.
.\" default value .\" default value

View File

@ -24,7 +24,7 @@ use crate::conf::accounts::JobRequest;
use crate::types::segment_tree::SegmentTree; use crate::types::segment_tree::SegmentTree;
use melib::backends::EnvelopeHashBatch; use melib::backends::EnvelopeHashBatch;
use smallvec::SmallVec; use smallvec::SmallVec;
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::convert::TryFrom; use std::convert::TryFrom;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
@ -161,12 +161,23 @@ column_str!(struct SubjectString(String));
column_str!(struct FlagString(String)); column_str!(struct FlagString(String));
column_str!(struct TagString(String, SmallVec<[Option<Color>; 8]>)); column_str!(struct TagString(String, SmallVec<[Option<Color>; 8]>));
#[derive(Debug)]
struct MailboxMenuEntry {
depth: usize,
indentation: u32,
has_sibling: bool,
visible: bool,
collapsed: bool,
mailbox_hash: MailboxHash,
}
#[derive(Debug)] #[derive(Debug)]
struct AccountMenuEntry { struct AccountMenuEntry {
name: String, name: String,
hash: AccountHash, hash: AccountHash,
index: usize, index: usize,
entries: SmallVec<[(usize, u32, bool, MailboxHash); 16]>, visible: bool,
entries: SmallVec<[MailboxMenuEntry; 16]>,
} }
pub trait MailListingTrait: ListingTrait { pub trait MailListingTrait: ListingTrait {
@ -768,6 +779,18 @@ impl Component for Listing {
if self.cursor_pos.0 == account_index { if self.cursor_pos.0 == account_index {
self.change_account(context); self.change_account(context);
} else { } else {
let previous_collapsed_mailboxes: BTreeSet<MailboxHash> = self.accounts
[account_index]
.entries
.iter()
.filter_map(|e| {
if e.collapsed {
Some(e.mailbox_hash)
} else {
None
}
})
.collect::<_>();
self.accounts[account_index].entries = context.accounts[&*account_hash] self.accounts[account_index].entries = context.accounts[&*account_hash]
.list_mailboxes() .list_mailboxes()
.into_iter() .into_iter()
@ -776,7 +799,18 @@ impl Component for Listing {
.ref_mailbox .ref_mailbox
.is_subscribed() .is_subscribed()
}) })
.map(|f| (f.depth, f.indentation, f.has_sibling, f.hash)) .map(|f| MailboxMenuEntry {
depth: f.depth,
indentation: f.indentation,
has_sibling: f.has_sibling,
mailbox_hash: f.hash,
visible: true,
collapsed: if previous_collapsed_mailboxes.is_empty() {
context.accounts[&*account_hash][&f.hash].conf.collapsed
} else {
previous_collapsed_mailboxes.contains(&f.hash)
},
})
.collect::<_>(); .collect::<_>();
self.set_dirty(true); self.set_dirty(true);
self.menu_content.empty(); self.menu_content.empty();
@ -795,6 +829,18 @@ impl Component for Listing {
.get_index_of(account_hash) .get_index_of(account_hash)
.expect("Invalid account_hash in UIEventMailbox{Delete,Create}"); .expect("Invalid account_hash in UIEventMailbox{Delete,Create}");
self.menu_content.empty(); self.menu_content.empty();
let previous_collapsed_mailboxes: BTreeSet<MailboxHash> = self.accounts
[account_index]
.entries
.iter()
.filter_map(|e| {
if e.collapsed {
Some(e.mailbox_hash)
} else {
None
}
})
.collect::<_>();
self.accounts[account_index].entries = context.accounts[&*account_hash] self.accounts[account_index].entries = context.accounts[&*account_hash]
.list_mailboxes() .list_mailboxes()
.into_iter() .into_iter()
@ -803,7 +849,14 @@ impl Component for Listing {
.ref_mailbox .ref_mailbox
.is_subscribed() .is_subscribed()
}) })
.map(|f| (f.depth, f.indentation, f.has_sibling, f.hash)) .map(|f| MailboxMenuEntry {
depth: f.depth,
indentation: f.indentation,
has_sibling: f.has_sibling,
mailbox_hash: f.hash,
visible: true,
collapsed: previous_collapsed_mailboxes.contains(&f.hash),
})
.collect::<_>(); .collect::<_>();
let mut fallback = 0; let mut fallback = 0;
if let MenuEntryCursor::Mailbox(ref mut cur) = self.cursor_pos.1 { if let MenuEntryCursor::Mailbox(ref mut cur) = self.cursor_pos.1 {
@ -821,7 +874,7 @@ impl Component for Listing {
.process_event(&mut UIEvent::VisibilityChange(false), context); .process_event(&mut UIEvent::VisibilityChange(false), context);
self.component.set_coordinates(( self.component.set_coordinates((
self.accounts[self.cursor_pos.0].hash, self.accounts[self.cursor_pos.0].hash,
self.accounts[self.cursor_pos.0].entries[fallback].3, self.accounts[self.cursor_pos.0].entries[fallback].mailbox_hash,
)); ));
self.component.refresh_mailbox(context, true); self.component.refresh_mailbox(context, true);
} }
@ -840,7 +893,7 @@ impl Component for Listing {
self.set_dirty(true); self.set_dirty(true);
} }
UIEvent::Action(Action::ViewMailbox(ref idx)) => { UIEvent::Action(Action::ViewMailbox(ref idx)) => {
if let Some((_, _, _, mailbox_hash)) = if let Some(MailboxMenuEntry { mailbox_hash, .. }) =
self.accounts[self.cursor_pos.0].entries.get(*idx) self.accounts[self.cursor_pos.0].entries.get(*idx)
{ {
let account_hash = self.accounts[self.cursor_pos.0].hash; let account_hash = self.accounts[self.cursor_pos.0].hash;
@ -1311,6 +1364,33 @@ impl Component for Listing {
))); )));
return true; return true;
} }
UIEvent::Input(ref k)
if shortcut!(
k == shortcuts[Listing::DESCRIPTION]["toggle_mailbox_collapse"]
) && matches!(self.menu_cursor_pos.1, MenuEntryCursor::Mailbox(_)) =>
{
let target_mailbox_idx =
if let MenuEntryCursor::Mailbox(idx) = self.menu_cursor_pos.1 {
idx
} else {
return false;
};
if let Some(target) = self.accounts[self.menu_cursor_pos.0]
.entries
.get_mut(target_mailbox_idx)
{
target.collapsed = !(target.collapsed);
self.dirty = true;
self.menu_content.empty();
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
return true;
}
return false;
}
UIEvent::Input(ref k) UIEvent::Input(ref k)
if shortcut!(k == shortcuts[Listing::DESCRIPTION]["open_mailbox"]) => if shortcut!(k == shortcuts[Listing::DESCRIPTION]["open_mailbox"]) =>
{ {
@ -1371,13 +1451,22 @@ impl Component for Listing {
return true; return true;
} }
} }
(_, MenuEntryCursor::Mailbox(ref mut mailbox_idx)) => { (
ref account_cursor,
MenuEntryCursor::Mailbox(ref mut mailbox_idx),
) => loop {
if *mailbox_idx > 0 { if *mailbox_idx > 0 {
*mailbox_idx -= 1; *mailbox_idx -= 1;
if self.accounts[*account_cursor].entries[*mailbox_idx]
.visible
{
break;
}
} else { } else {
self.menu_cursor_pos.1 = MenuEntryCursor::Status; self.menu_cursor_pos.1 = MenuEntryCursor::Status;
break;
} }
} },
} }
amount -= 1; amount -= 1;
@ -1407,18 +1496,24 @@ impl Component for Listing {
( (
ref mut account_cursor, ref mut account_cursor,
MenuEntryCursor::Mailbox(ref mut mailbox_idx), MenuEntryCursor::Mailbox(ref mut mailbox_idx),
) => { ) => loop {
if (*mailbox_idx + 1) if (*mailbox_idx + 1)
< self.accounts[*account_cursor].entries.len() < self.accounts[*account_cursor].entries.len()
{ {
*mailbox_idx += 1; *mailbox_idx += 1;
if self.accounts[*account_cursor].entries[*mailbox_idx]
.visible
{
break;
}
} else if *account_cursor + 1 < self.accounts.len() { } else if *account_cursor + 1 < self.accounts.len() {
*account_cursor += 1; *account_cursor += 1;
self.menu_cursor_pos.1 = MenuEntryCursor::Status; self.menu_cursor_pos.1 = MenuEntryCursor::Status;
break;
} else { } else {
return true; return true;
} }
} },
} }
amount -= 1; amount -= 1;
@ -1648,7 +1743,7 @@ impl Component for Listing {
fn get_status(&self, context: &Context) -> String { fn get_status(&self, context: &Context) -> String {
let mailbox_hash = match self.cursor_pos.1 { let mailbox_hash = match self.cursor_pos.1 {
MenuEntryCursor::Mailbox(idx) => { MenuEntryCursor::Mailbox(idx) => {
if let Some((_, _, _, mailbox_hash)) = if let Some(MailboxMenuEntry { mailbox_hash, .. }) =
self.accounts[self.cursor_pos.0].entries.get(idx) self.accounts[self.cursor_pos.0].entries.get(idx)
{ {
*mailbox_hash *mailbox_hash
@ -1695,17 +1790,25 @@ impl Listing {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, (h, a))| { .map(|(i, (h, a))| {
let entries: SmallVec<[(usize, u32, bool, MailboxHash); 16]> = a let entries: SmallVec<[MailboxMenuEntry; 16]> = a
.list_mailboxes() .list_mailboxes()
.into_iter() .into_iter()
.filter(|mailbox_node| a[&mailbox_node.hash].ref_mailbox.is_subscribed()) .filter(|mailbox_node| a[&mailbox_node.hash].ref_mailbox.is_subscribed())
.map(|f| (f.depth, f.indentation, f.has_sibling, f.hash)) .map(|f| MailboxMenuEntry {
depth: f.depth,
indentation: f.indentation,
has_sibling: f.has_sibling,
mailbox_hash: f.hash,
visible: true,
collapsed: a[&f.hash].conf.collapsed,
})
.collect::<_>(); .collect::<_>();
AccountMenuEntry { AccountMenuEntry {
name: a.name().to_string(), name: a.name().to_string(),
hash: *h, hash: *h,
index: i, index: i,
visible: true,
entries, entries,
} }
}) })
@ -1848,6 +1951,19 @@ impl Listing {
*/ */
fn print_account(&mut self, area: Area, aidx: usize, context: &mut Context) -> usize { fn print_account(&mut self, area: Area, aidx: usize, context: &mut Context) -> usize {
debug_assert!(is_valid_area!(area)); debug_assert!(is_valid_area!(area));
#[derive(Copy, Debug, Clone)]
struct Line {
visible: bool,
collapsed: bool,
depth: usize,
inc: usize,
indentation: u32,
has_sibling: bool,
mailbox_idx: MailboxHash,
count: Option<usize>,
collapsed_count: Option<usize>,
}
// Each entry and its index in the account // Each entry and its index in the account
let mailboxes: HashMap<MailboxHash, Mailbox> = context.accounts[self.accounts[aidx].index] let mailboxes: HashMap<MailboxHash, Mailbox> = context.accounts[self.accounts[aidx].index]
.mailbox_entries .mailbox_entries
@ -1865,25 +1981,47 @@ impl Listing {
let must_highlight_account: bool = cursor.0 == self.accounts[aidx].index; let must_highlight_account: bool = cursor.0 == self.accounts[aidx].index;
let mut lines: Vec<(usize, usize, u32, bool, MailboxHash, Option<usize>)> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
for (i, &(depth, indentation, has_sibling, mailbox_hash)) in for (
self.accounts[aidx].entries.iter().enumerate() i,
&MailboxMenuEntry {
depth,
indentation,
has_sibling,
mailbox_hash,
visible,
collapsed,
},
) in self.accounts[aidx].entries.iter().enumerate()
{ {
if mailboxes[&mailbox_hash].is_subscribed() { if mailboxes[&mailbox_hash].is_subscribed() {
match context.accounts[self.accounts[aidx].index][&mailbox_hash].status { match context.accounts[self.accounts[aidx].index][&mailbox_hash].status {
crate::conf::accounts::MailboxStatus::Failed(_) => { crate::conf::accounts::MailboxStatus::Failed(_) => {
lines.push((depth, i, indentation, has_sibling, mailbox_hash, None)); lines.push(Line {
} visible,
_ => { collapsed,
lines.push((
depth, depth,
i, inc: i,
indentation, indentation,
has_sibling, has_sibling,
mailbox_hash, mailbox_idx: mailbox_hash,
mailboxes[&mailbox_hash].count().ok().map(|(v, _)| v), count: None,
)); collapsed_count: None,
});
}
_ => {
lines.push(Line {
visible,
collapsed,
depth,
inc: i,
indentation,
has_sibling,
mailbox_idx: mailbox_hash,
count: mailboxes[&mailbox_hash].count().ok().map(|(v, _)| v),
collapsed_count: None,
});
} }
} }
} }
@ -1931,10 +2069,44 @@ impl Listing {
let mut idx = 0; let mut idx = 0;
let mut branches = String::with_capacity(16); let mut branches = String::with_capacity(16);
for y in get_y(upper_left) + 1..get_y(bottom_right) { // What depth to skip if a mailbox is toggled to collapse
// The value should be the collapsed mailbox's indentation, so that its children are not
// visible.
let mut skip: Option<usize> = None;
let mut skipped_counter: usize = 0;
'grid_loop: for y in get_y(upper_left) + 1..get_y(bottom_right) {
if idx == lines_len { if idx == lines_len {
break; break;
} }
let mut l = lines[idx];
while let Some(p) = skip {
if l.depth > p {
self.accounts[aidx].entries[idx].visible = false;
idx += 1;
skipped_counter += 1;
if idx >= lines.len() {
break 'grid_loop;
}
l = lines[idx];
} else {
skip = None;
}
}
self.accounts[aidx].entries[idx].visible = true;
if l.collapsed {
skip = Some(l.depth);
// Calculate total unseen from hidden children mailboxes
let mut idx = idx + 1;
let mut counter = 0;
while idx < lines.len() {
if lines[idx].depth <= l.depth {
break;
}
counter += lines[idx].count.unwrap_or(0);
idx += 1;
}
l.collapsed_count = Some(counter);
}
let (att, index_att, unread_count_att) = if must_highlight_account { let (att, index_att, unread_count_att) = if must_highlight_account {
if match cursor.1 { if match cursor.1 {
MenuEntryCursor::Mailbox(c) => c == idx, MenuEntryCursor::Mailbox(c) => c == idx,
@ -1970,7 +2142,6 @@ impl Listing {
) )
}; };
let (depth, inc, indentation, has_sibling, mailbox_idx, count) = lines[idx];
/* Calculate how many columns the mailbox index tags should occupy with right alignment, /* Calculate how many columns the mailbox index tags should occupy with right alignment,
* eg. * eg.
* 1 * 1
@ -2025,7 +2196,7 @@ impl Listing {
.unwrap_or(" "); .unwrap_or(" ");
let (x, _) = write_string_to_grid( let (x, _) = write_string_to_grid(
&format!("{:>width$}", inc, width = total_mailbox_no_digits), &format!("{:>width$}", l.inc, width = total_mailbox_no_digits),
&mut self.menu_content, &mut self.menu_content,
index_att.fg, index_att.fg,
index_att.bg, index_att.bg,
@ -2036,18 +2207,18 @@ impl Listing {
{ {
branches.clear(); branches.clear();
branches.push_str(no_sibling_str); branches.push_str(no_sibling_str);
let leading_zeros = indentation.leading_zeros(); let leading_zeros = l.indentation.leading_zeros();
let mut o = 1_u32.wrapping_shl(31_u32.saturating_sub(leading_zeros)); let mut o = 1_u32.wrapping_shl(31_u32.saturating_sub(leading_zeros));
for _ in 0..(32_u32.saturating_sub(leading_zeros)) { for _ in 0..(32_u32.saturating_sub(leading_zeros)) {
if indentation & o > 0 { if l.indentation & o > 0 {
branches.push_str(has_sibling_str); branches.push_str(has_sibling_str);
} else { } else {
branches.push_str(no_sibling_str); branches.push_str(no_sibling_str);
} }
o >>= 1; o >>= 1;
} }
if depth > 0 { if l.depth > 0 {
if has_sibling { if l.has_sibling {
branches.push_str(has_sibling_leaf_str); branches.push_str(has_sibling_leaf_str);
} else { } else {
branches.push_str(no_sibling_leaf_str); branches.push_str(no_sibling_leaf_str);
@ -2064,7 +2235,7 @@ impl Listing {
None, None,
); );
let (x, _) = write_string_to_grid( let (x, _) = write_string_to_grid(
context.accounts[self.accounts[aidx].index].mailbox_entries[&mailbox_idx].name(), context.accounts[self.accounts[aidx].index].mailbox_entries[&l.mailbox_idx].name(),
&mut self.menu_content, &mut self.menu_content,
att.fg, att.fg,
att.bg, att.bg,
@ -2074,14 +2245,15 @@ impl Listing {
); );
/* Unread message count */ /* Unread message count */
let count_string = if let Some(c) = count { let count_string = match (l.count, l.collapsed_count) {
if c > 0 { (None, None) => " ...".to_string(),
format!(" {}", c) (Some(0), None) => String::new(),
} else { (Some(0), Some(0)) | (None, Some(0)) => " v".to_string(),
String::new() (Some(0), Some(coll)) => format!(" ({}) v", coll),
} (Some(c), Some(0)) => format!(" {} v", c),
} else { (Some(c), Some(coll)) => format!(" {} ({}) v", c, coll),
" ...".to_string() (Some(c), None) => format!(" {}", c),
(None, Some(coll)) => format!(" ({}) v", coll),
}; };
let (x, _) = write_string_to_grid( let (x, _) = write_string_to_grid(
@ -2090,7 +2262,7 @@ impl Listing {
unread_count_att.fg, unread_count_att.fg,
unread_count_att.bg, unread_count_att.bg,
unread_count_att.attrs unread_count_att.attrs
| if count.unwrap_or(0) > 0 { | if l.count.unwrap_or(0) > 0 {
Attr::BOLD Attr::BOLD
} else { } else {
Attr::DEFAULT Attr::DEFAULT
@ -2116,12 +2288,23 @@ impl Listing {
if idx == 0 { if idx == 0 {
0 0
} else { } else {
idx - 1 idx - 1 - skipped_counter
} }
} }
fn change_account(&mut self, context: &mut Context) { fn change_account(&mut self, context: &mut Context) {
let account_hash = context.accounts[self.cursor_pos.0].hash(); let account_hash = context.accounts[self.cursor_pos.0].hash();
let previous_collapsed_mailboxes: BTreeSet<MailboxHash> = self.accounts[self.cursor_pos.0]
.entries
.iter()
.filter_map(|e| {
if e.collapsed {
Some(e.mailbox_hash)
} else {
None
}
})
.collect::<_>();
self.accounts[self.cursor_pos.0].entries = context.accounts[self.cursor_pos.0] self.accounts[self.cursor_pos.0].entries = context.accounts[self.cursor_pos.0]
.list_mailboxes() .list_mailboxes()
.into_iter() .into_iter()
@ -2130,12 +2313,23 @@ impl Listing {
.ref_mailbox .ref_mailbox
.is_subscribed() .is_subscribed()
}) })
.map(|f| (f.depth, f.indentation, f.has_sibling, f.hash)) .map(|f| MailboxMenuEntry {
depth: f.depth,
indentation: f.indentation,
has_sibling: f.has_sibling,
mailbox_hash: f.hash,
visible: true,
collapsed: if previous_collapsed_mailboxes.is_empty() {
context.accounts[self.cursor_pos.0][&f.hash].conf.collapsed
} else {
previous_collapsed_mailboxes.contains(&f.hash)
},
})
.collect::<_>(); .collect::<_>();
match self.cursor_pos.1 { match self.cursor_pos.1 {
MenuEntryCursor::Mailbox(idx) => { MenuEntryCursor::Mailbox(idx) => {
/* Account might have no mailboxes yet if it's offline */ /* Account might have no mailboxes yet if it's offline */
if let Some((_, _, _, mailbox_hash)) = if let Some(MailboxMenuEntry { mailbox_hash, .. }) =
self.accounts[self.cursor_pos.0].entries.get(idx) self.accounts[self.cursor_pos.0].entries.get(idx)
{ {
self.component self.component

View File

@ -135,6 +135,8 @@ pub struct MailUIConf {
pub struct FileMailboxConf { pub struct FileMailboxConf {
#[serde(flatten)] #[serde(flatten)]
pub conf_override: MailUIConf, pub conf_override: MailUIConf,
#[serde(default = "false_val")]
pub collapsed: bool,
#[serde(flatten)] #[serde(flatten)]
pub mailbox_conf: MailboxConf, pub mailbox_conf: MailboxConf,
} }

View File

@ -2389,6 +2389,6 @@ fn build_mailboxes_order(
} }
} }
rec(node, &mailbox_entries, 0, 0, false); rec(node, &mailbox_entries, 1, 0, false);
} }
} }

View File

@ -176,6 +176,7 @@ shortcut_key_values! { "listing",
prev_account |> "Go to previous account." |> Key::Char('l'), prev_account |> "Go to previous account." |> Key::Char('l'),
prev_mailbox |> "Go to previous mailbox." |> Key::Char('K'), prev_mailbox |> "Go to previous mailbox." |> Key::Char('K'),
open_mailbox |> "Open selected mailbox" |> Key::Char('\n'), open_mailbox |> "Open selected mailbox" |> Key::Char('\n'),
toggle_mailbox_collapse |> "Toggle mailbox collapse in menu." |> Key::Char(' '),
prev_page |> "Go to previous page." |> Key::PageUp, prev_page |> "Go to previous page." |> Key::PageUp,
search |> "Search within list of e-mails." |> Key::Char('/'), search |> "Search within list of e-mails." |> Key::Char('/'),
refresh |> "Manually request a mailbox refresh." |> Key::F(5), refresh |> "Manually request a mailbox refresh." |> Key::F(5),