Files
mail/src/sidebar_model.rs
T

1609 lines
57 KiB
Rust

//! Visible sidebar projection and batched, retained Slint model updates.
use super::*;
pub(super) type SidebarModel = crate::retained_model::RetainedModel<SidebarRow>;
const MAX_ANIMATED_SIDEBAR_ROWS: usize = 256;
// Keep closing descendants in the virtualized model until their heights have
// sprung to zero. The canonical projection still contains only expanded rows.
#[derive(Default)]
pub(super) struct SidebarAnimation {
target: RefCell<Vec<SidebarRow>>,
generation: Cell<u64>,
}
impl SidebarAnimation {
pub(super) fn reconcile(
self: &Rc<Self>,
model: &Rc<SidebarModel>,
rows: Vec<SidebarRow>,
animate: bool,
) {
let previous = self.target.borrow();
let changed = animate && accordion_changed(&previous, &rows);
let changed_row_count = if changed {
let previous_keys = previous
.iter()
.map(|row| row.key.as_str())
.collect::<HashSet<_>>();
let next_keys = rows
.iter()
.map(|row| row.key.as_str())
.collect::<HashSet<_>>();
previous_keys.symmetric_difference(&next_keys).count()
} else {
0
};
drop(previous);
*self.target.borrow_mut() = rows.clone();
// Only explicit accordion toggles animate. Search and data refreshes
// replace the projection immediately, so stale rows cannot linger.
// Large trees keep the retained model's batched range update path.
if !changed || changed_row_count > MAX_ANIMATED_SIDEBAR_ROWS {
self.generation.set(self.generation.get().wrapping_add(1));
model.reconcile(rows);
return;
}
let current = model.iter().collect::<Vec<_>>();
let projected = transition_rows(current, rows, true);
let generation = self.generation.get().wrapping_add(1);
self.generation.set(generation);
model.reconcile(projected);
let animation = Rc::downgrade(self);
let model = Rc::downgrade(model);
Timer::single_shot(Duration::from_millis(260), move || {
if let (Some(animation), Some(model)) = (animation.upgrade(), model.upgrade())
&& animation.generation.get() == generation
{
model.reconcile(animation.target.borrow().clone());
}
});
}
}
fn accordion_changed(before: &[SidebarRow], after: &[SidebarRow]) -> bool {
let previous = before
.iter()
.map(|row| (row.key.as_str(), row))
.collect::<HashMap<_, _>>();
after.iter().any(|row| {
previous.get(row.key.as_str()).is_some_and(|old| {
(matches!(
row.kind,
SidebarRowKind::UnifiedSection
| SidebarRowKind::Account
| SidebarRowKind::CategoriesSection
| SidebarRowKind::GlobalLabelsSection
| SidebarRowKind::AccountLabelsSection
) && old.open != row.open)
|| (row.kind == SidebarRowKind::Folder
&& old.mailbox.expanded != row.mailbox.expanded)
|| (row.kind == SidebarRowKind::AccountLabel
&& old.label.expanded != row.label.expanded)
})
})
}
fn transition_rows(
current: Vec<SidebarRow>,
rows: Vec<SidebarRow>,
reveal_new: bool,
) -> Vec<SidebarRow> {
let current_keys = current
.iter()
.map(|row| row.key.clone())
.collect::<HashSet<_>>();
let target_keys = rows
.iter()
.map(|row| row.key.clone())
.collect::<HashSet<_>>();
let mut before = HashMap::<slint::SharedString, Vec<SidebarRow>>::new();
let mut trailing = Vec::new();
let mut next = None;
for mut row in current.into_iter().rev() {
if target_keys.contains(row.key.as_str()) {
next = Some(row.key.clone());
} else {
row.show_row = false;
row.reveal_row = false;
if let Some(key) = &next {
before.entry(key.clone()).or_default().push(row);
} else {
trailing.push(row);
}
}
}
let mut result = Vec::with_capacity(
rows.len() + before.values().map(Vec::len).sum::<usize>() + trailing.len(),
);
for mut row in rows {
if let Some(mut closing) = before.remove(row.key.as_str()) {
closing.reverse();
result.extend(closing);
}
row.reveal_row = reveal_new && !current_keys.contains(row.key.as_str());
result.push(row);
}
trailing.reverse();
result.extend(trailing);
result
}
impl SidebarModel {
fn reconcile(&self, rows: Vec<SidebarRow>) {
self.reconcile_by(rows, |row| row.key.clone(), same_row);
}
}
fn collapse_new_account_label_sections(
account_ids: impl IntoIterator<Item = i64>,
initialized: &mut HashSet<i64>,
collapsed: &mut HashSet<String>,
) {
for account_id in account_ids {
if initialized.insert(account_id) {
collapsed.insert(format!("account-labels:{account_id}"));
}
}
}
fn collapse_new_folder_sections(
folder_ids: impl IntoIterator<Item = i64>,
initialized: &mut HashSet<i64>,
collapsed: &mut HashSet<i64>,
) {
for folder_id in folder_ids {
if initialized.insert(folder_id) {
collapsed.insert(folder_id);
}
}
}
// Slint image handles can compare unequal even when both are empty. Compare
// absent avatars semantically, otherwise every ordinary folder emits a change.
fn same_row(a: &SidebarRow, b: &SidebarRow) -> bool {
let MailboxRow {
account_id,
folder_id,
parent_folder_id,
depth,
has_children,
expanded,
is_standard,
is_selectable,
can_create_children,
can_rename,
can_delete,
label_has_emoji,
label,
scope,
context,
detail,
avatar,
avatar_image,
has_avatar,
is_account,
count,
color,
has_custom_color,
} = &a.mailbox;
let other = &b.mailbox;
a.key == b.key
&& a.kind == b.kind
&& a.open == b.open
&& a.show_row == b.show_row
&& a.reveal_row == b.reveal_row
&& a.label == b.label
&& *account_id == other.account_id
&& *folder_id == other.folder_id
&& *parent_folder_id == other.parent_folder_id
&& *depth == other.depth
&& *has_children == other.has_children
&& *expanded == other.expanded
&& *is_standard == other.is_standard
&& *is_selectable == other.is_selectable
&& *can_create_children == other.can_create_children
&& *can_rename == other.can_rename
&& *can_delete == other.can_delete
&& *label_has_emoji == other.label_has_emoji
&& *label == other.label
&& *scope == other.scope
&& *context == other.context
&& *detail == other.detail
&& *avatar == other.avatar
&& *has_avatar == other.has_avatar
&& *is_account == other.is_account
&& *count == other.count
&& *color == other.color
&& *has_custom_color == other.has_custom_color
&& (!has_avatar || *avatar_image == other.avatar_image)
}
pub(super) fn refresh_sidebar(state: &Rc<RefCell<InboxState>>) {
refresh_sidebar_with_motion(state, false);
}
pub(super) fn refresh_sidebar_with_motion(state: &Rc<RefCell<InboxState>>, animate: bool) {
let mut state = state.borrow_mut();
let account_ids = state
.mailboxes
.iter()
.filter(|mailbox| mailbox.is_account)
.map(|mailbox| mailbox.account_id)
.collect::<Vec<_>>();
let folder_ids = state
.mailboxes
.iter()
.filter(|mailbox| !mailbox.is_account && mailbox.has_children && mailbox.folder_id >= 0)
.map(|mailbox| mailbox.folder_id)
.collect::<Vec<_>>();
let InboxState {
initialized_sidebar_accounts,
initialized_sidebar_folders,
collapsed_sidebar_sections,
collapsed_folder_ids,
..
} = &mut *state;
collapse_new_account_label_sections(
account_ids,
initialized_sidebar_accounts,
collapsed_sidebar_sections,
);
collapse_new_folder_sections(
folder_ids,
initialized_sidebar_folders,
collapsed_folder_ids,
);
let query = state.folder_filter.trim();
let filtering = !query.is_empty();
let no_collapsed_folders = HashSet::new();
let mailboxes = make_mailbox_rows(
&state.mailboxes,
&state.profile_avatar_images,
&state.labels,
if filtering {
&no_collapsed_folders
} else {
&state.collapsed_folder_ids
},
);
let account_names = mailboxes
.iter()
.filter(|mailbox| mailbox.is_account)
.map(|mailbox| (mailbox.account_id, mailbox.label.clone()))
.collect::<HashMap<_, _>>();
let mut labels = make_label_rows(&state.labels, None, "");
for label in &mut labels {
if let Some(name) = account_names.get(&label.account_id) {
label.account_name = name.clone();
} else if label.is_global {
label.account_name = "All accounts".into();
}
}
let gmail_account_ids = state
.connected_accounts
.iter()
.filter(|account| account.provider == Provider::Gmail)
.filter_map(|account| i32::try_from(account.id).ok())
.collect::<HashSet<_>>();
let rows = if filtering {
make_filtered_sidebar_rows(mailboxes, labels, query, &gmail_account_ids)
} else {
make_sidebar_rows(
mailboxes,
make_mailbox_rows(
&state.unified_mailboxes,
&state.profile_avatar_images,
&state.labels,
&no_collapsed_folders,
),
labels,
&gmail_account_ids,
&state.collapsed_sidebar_sections,
)
};
// Model notifications can synchronously inspect application state.
let model = Rc::clone(&state.sidebar_rows);
let animation = Rc::clone(&state.sidebar_animation);
drop(state);
animation.reconcile(&model, rows, animate);
}
fn make_filtered_sidebar_rows(
mailboxes: Vec<MailboxRow>,
labels: Vec<MailLabelRow>,
query: &str,
gmail_account_ids: &HashSet<i32>,
) -> Vec<SidebarRow> {
use SidebarRowKind as Kind;
let query = query.to_lowercase();
let mut accounts = Vec::new();
let mut folders: HashMap<i32, Vec<MailboxRow>> = HashMap::new();
for mailbox in mailboxes {
if mailbox.is_account {
accounts.push(mailbox);
} else if mailbox.label.to_lowercase().contains(&query) {
folders.entry(mailbox.account_id).or_default().push(mailbox);
}
}
let mut account_labels: HashMap<i32, Vec<MailLabelRow>> = HashMap::new();
let mut global_labels = Vec::new();
let mut categories = Vec::new();
for mut label in labels
.into_iter()
.filter(|label| label.name.to_lowercase().contains(&query))
{
label.display_name = label.name.clone();
label.depth = 0;
label.has_children = false;
label.expanded = true;
label.can_create_children = gmail_account_ids.contains(&label.account_id);
if label.is_auto {
categories.push(label);
} else if label.is_global {
global_labels.push(label);
} else {
account_labels
.entry(label.account_id)
.or_default()
.push(label);
}
}
let mut rows = Vec::new();
if !categories.is_empty() {
rows.push(section(
Kind::CategoriesSection,
"categories",
&HashSet::new(),
));
rows.extend(categories.into_iter().map(|label| SidebarRow {
key: format!("category:{}", label.id).into(),
label,
..row(Kind::Category, "")
}));
}
if !global_labels.is_empty() {
rows.push(section(
Kind::GlobalLabelsSection,
"global-labels",
&HashSet::new(),
));
rows.extend(global_labels.into_iter().map(|label| SidebarRow {
key: format!("global-label:{}", label.id).into(),
label,
..row(Kind::GlobalLabel, "")
}));
}
for account in accounts {
let account_id = account.account_id;
let matches = folders.remove(&account_id).unwrap_or_default();
let label_matches = account_labels.remove(&account_id).unwrap_or_default();
if matches.is_empty() && label_matches.is_empty() {
continue;
}
rows.push(SidebarRow {
open: true,
mailbox: account,
..row(Kind::Account, format!("account:{account_id}"))
});
rows.extend(matches.into_iter().map(|mut mailbox| {
// Search results are a flat projection; their original hierarchy
// remains untouched and returns when the filter is cleared.
mailbox.depth = 0;
mailbox.has_children = false;
let key = format!("mailbox:{account_id}:{}", mailbox.scope);
SidebarRow {
mailbox,
..row(Kind::Folder, key)
}
}));
if !label_matches.is_empty() {
rows.push(SidebarRow {
mailbox: MailboxRow {
account_id,
..Default::default()
},
open: true,
..row(
Kind::AccountLabelsSection,
format!("account-labels:{account_id}"),
)
});
rows.extend(label_matches.into_iter().map(|label| SidebarRow {
key: format!("account-label:{account_id}:{}", label.id).into(),
label,
..row(Kind::AccountLabel, "")
}));
}
}
rows
}
fn row(kind: SidebarRowKind, key: impl Into<slint::SharedString>) -> SidebarRow {
SidebarRow {
kind,
key: key.into(),
show_row: true,
..Default::default()
}
}
fn section(kind: SidebarRowKind, key: &str, collapsed: &HashSet<String>) -> SidebarRow {
SidebarRow {
open: !collapsed.contains(key),
..row(kind, key)
}
}
fn account_label_rows(
labels: &[MailLabelRow],
account_id: i32,
nested: bool,
collapsed: &HashSet<String>,
) -> Vec<SidebarRow> {
use SidebarRowKind as Kind;
let account_labels = labels
.iter()
.filter(|label| label.account_id == account_id)
.cloned()
.collect::<Vec<_>>();
if !nested {
return account_labels
.into_iter()
.map(|mut label| {
label.display_name = label.name.clone();
SidebarRow {
key: format!("account-label:{account_id}:{}", label.id).into(),
label,
..row(Kind::AccountLabel, "")
}
})
.collect();
}
let ids_by_name = account_labels
.iter()
.map(|label| (label.name.to_string(), label.id))
.collect::<HashMap<_, _>>();
let mut children: HashMap<Option<i32>, Vec<MailLabelRow>> = HashMap::new();
for label in account_labels {
let parent_id = label
.name
.rsplit_once('/')
.and_then(|(parent, _)| ids_by_name.get(parent).copied());
children.entry(parent_id).or_default().push(label);
}
let mut stack = children
.remove(&None)
.unwrap_or_default()
.into_iter()
.rev()
.map(|label| (label, 0))
.collect::<Vec<_>>();
let mut rows = Vec::new();
while let Some((mut label, depth)) = stack.pop() {
let key = format!("gmail-label:{account_id}:{}", label.id);
let descendants = children.remove(&Some(label.id)).unwrap_or_default();
label.display_name = if depth > 0 {
label
.name
.rsplit_once('/')
.filter(|(_, leaf)| !leaf.is_empty())
.map(|(_, leaf)| leaf.into())
.unwrap_or_else(|| label.name.clone())
} else {
label.name.clone()
};
label.depth = depth;
label.has_children = !descendants.is_empty();
label.expanded = !collapsed.contains(&key);
label.can_create_children = true;
rows.push(SidebarRow {
key: key.clone().into(),
label,
..row(Kind::AccountLabel, "")
});
if !collapsed.contains(&key) {
stack.extend(
descendants
.into_iter()
.rev()
.map(|child| (child, depth.saturating_add(1))),
);
}
}
rows
}
fn make_sidebar_rows(
mailboxes: Vec<MailboxRow>,
unified: Vec<MailboxRow>,
labels: Vec<MailLabelRow>,
gmail_account_ids: &HashSet<i32>,
collapsed: &HashSet<String>,
) -> Vec<SidebarRow> {
use SidebarRowKind as Kind;
let mut rows = vec![row(Kind::UnifiedHeading, "unified-heading")];
rows.push(row(Kind::UnifiedInbox, "unified-inbox"));
rows.extend(unified.into_iter().map(|mailbox| SidebarRow {
key: format!("unified:{}", mailbox.scope).into(),
mailbox,
..row(Kind::UnifiedMailbox, "")
}));
rows.push(section(Kind::CategoriesSection, "categories", collapsed));
if !collapsed.contains("categories") {
rows.push(row(Kind::Important, "important"));
rows.push(row(Kind::Other, "other"));
rows.extend(
labels
.iter()
.filter(|label| label.is_auto)
.map(|label| SidebarRow {
label: label.clone(),
..row(Kind::Category, format!("category:{}", label.id))
}),
);
}
if labels.iter().any(|label| label.is_global) {
rows.push(section(
Kind::GlobalLabelsSection,
"global-labels",
collapsed,
));
if !collapsed.contains("global-labels") {
rows.extend(
labels
.iter()
.filter(|label| label.is_global)
.map(|label| SidebarRow {
key: format!("global-label:{}", label.id).into(),
label: label.clone(),
..row(Kind::GlobalLabel, "")
}),
);
}
}
rows.push(row(Kind::AccountsHeading, "accounts-heading"));
// Group once by stable account id, not display text. Preserve account and
// folder order even when providers return interleaved mailbox entries.
let mut accounts = Vec::new();
let mut folders: HashMap<i32, Vec<MailboxRow>> = HashMap::new();
for mailbox in mailboxes {
if mailbox.is_account {
accounts.push(mailbox);
} else {
folders.entry(mailbox.account_id).or_default().push(mailbox);
}
}
for account in accounts {
let account_id = account.account_id;
let account_name = account.label.clone();
let can_create_top_level_mailbox = account.can_create_children;
let key = format!("account:{account_id}");
let open = !collapsed.contains(&key);
rows.push(SidebarRow {
mailbox: account,
..section(Kind::Account, &key, collapsed)
});
if open {
rows.extend(
folders
.remove(&account_id)
.unwrap_or_default()
.into_iter()
.map(|mailbox| SidebarRow {
key: format!("mailbox:{account_id}:{}", mailbox.scope).into(),
mailbox,
..row(Kind::Folder, "")
}),
);
if !gmail_account_ids.contains(&account_id) && can_create_top_level_mailbox {
rows.push(SidebarRow {
mailbox: MailboxRow {
account_id,
folder_id: -1,
..Default::default()
},
..row(Kind::NewFolder, format!("new-folder:{account_id}"))
});
}
let label_key = format!("account-labels:{account_id}");
if gmail_account_ids.contains(&account_id)
|| labels.iter().any(|label| label.account_id == account_id)
{
rows.push(SidebarRow {
mailbox: MailboxRow {
account_id,
..Default::default()
},
..section(Kind::AccountLabelsSection, &label_key, collapsed)
});
if !collapsed.contains(&label_key) {
rows.extend(account_label_rows(
&labels,
account_id,
gmail_account_ids.contains(&account_id),
collapsed,
));
if gmail_account_ids.contains(&account_id) {
rows.push(SidebarRow {
mailbox: MailboxRow {
account_id,
context: account_name.clone(),
..Default::default()
},
..row(Kind::NewLabel, format!("new-label:{account_id}"))
});
}
}
}
}
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
use SidebarRowKind as Kind;
use slint::private_unstable_api::re_exports::{
ModelChangeListener, ModelChangeListenerContainer,
};
use std::pin::Pin;
fn assert_rows_equal(actual: &[SidebarRow], expected: &[SidebarRow]) {
assert_eq!(actual.len(), expected.len());
for (a, b) in actual.iter().zip(expected) {
assert!(same_row(a, b), "{a:?} != {b:?}");
}
}
fn accounts(count: i32, folders: i32) -> Vec<MailboxRow> {
(1..=count)
.flat_map(|account_id| {
let account = MailboxRow {
account_id,
is_account: true,
can_create_children: true,
label: "Same display name".into(),
context: "Same display name".into(),
..Default::default()
};
std::iter::once(account).chain((0..folders).map(move |folder| MailboxRow {
account_id,
folder_id: account_id * folders + folder,
scope: format!("Folder:{}", account_id * folders + folder).into(),
label: format!("Folder {folder}").into(),
context: "Same display name".into(),
depth: folder % 3,
has_children: folder % 3 == 0,
expanded: true,
..Default::default()
}))
})
.collect()
}
fn defaults() -> HashSet<String> {
HashSet::from(["categories".into(), "global-labels".into()])
}
#[test]
fn new_account_labels_start_collapsed_without_collapsing_accounts() {
let mut initialized = HashSet::new();
let mut collapsed = defaults();
collapse_new_account_label_sections([1, 2], &mut initialized, &mut collapsed);
assert!(!collapsed.contains("account:1"));
assert!(collapsed.contains("account-labels:1"));
assert!(!collapsed.contains("account:2"));
assert!(collapsed.contains("account-labels:2"));
collapsed.remove("account-labels:1");
collapse_new_account_label_sections([1, 2, 3], &mut initialized, &mut collapsed);
assert!(!collapsed.contains("account:1"));
assert!(!collapsed.contains("account-labels:1"));
assert!(!collapsed.contains("account:3"));
assert!(collapsed.contains("account-labels:3"));
}
#[test]
fn new_folder_branches_start_collapsed_without_overriding_user_expansion() {
let mut initialized = HashSet::new();
let mut collapsed = HashSet::new();
collapse_new_folder_sections([10, 20], &mut initialized, &mut collapsed);
assert_eq!(collapsed, HashSet::from([10, 20]));
collapsed.remove(&10);
collapse_new_folder_sections([10, 20, 30], &mut initialized, &mut collapsed);
assert_eq!(collapsed, HashSet::from([20, 30]));
}
#[test]
fn every_folder_occurs_once_and_stays_with_its_account() {
for (account_count, folder_count) in [(1, 750), (5, 150), (50, 150)] {
let rows = make_sidebar_rows(
accounts(account_count, folder_count),
vec![],
vec![],
&HashSet::new(),
&defaults(),
);
assert_eq!(
rows.iter().filter(|row| row.kind == Kind::Folder).count(),
(account_count * folder_count) as usize
);
assert_eq!(
rows.iter()
.map(|row| &row.key)
.collect::<HashSet<_>>()
.len(),
rows.len()
);
let mut account = 0;
for entry in rows {
match entry.kind {
Kind::Account => account = entry.mailbox.account_id,
Kind::Folder | Kind::NewFolder => assert_eq!(entry.mailbox.account_id, account),
_ => {}
}
}
}
}
#[test]
fn account_order_and_interleaved_folders_use_ids_not_display_names() {
let data = accounts(2, 2);
let interleaved = [3, 1, 4, 0, 5, 2].map(|i| data[i].clone()).to_vec();
let rows = make_sidebar_rows(interleaved, vec![], vec![], &HashSet::new(), &defaults());
let ids: Vec<_> = rows
.iter()
.filter(|row| row.kind == Kind::Account || row.kind == Kind::Folder)
.map(|row| (row.kind, row.mailbox.account_id, row.mailbox.folder_id))
.collect();
assert_eq!(
ids,
vec![
(Kind::Account, 2, 0),
(Kind::Folder, 2, 4),
(Kind::Folder, 2, 5),
(Kind::Account, 1, 0),
(Kind::Folder, 1, 2),
(Kind::Folder, 1, 3)
]
);
}
#[test]
fn same_named_labels_remain_distinct_under_their_accounts() {
let labels = vec![
MailLabelRow {
id: 11,
account_id: 1,
name: "Travel".into(),
..Default::default()
},
MailLabelRow {
id: 22,
account_id: 2,
name: "Travel".into(),
..Default::default()
},
];
let rows = make_sidebar_rows(
accounts(2, 1),
vec![],
labels,
&HashSet::new(),
&HashSet::new(),
);
let account_labels = rows
.iter()
.filter(|row| row.kind == Kind::AccountLabel)
.map(|row| (row.label.account_id, row.label.id, row.label.name.clone()))
.collect::<Vec<_>>();
assert_eq!(account_labels.len(), 2);
assert_eq!(account_labels[0].0, 1);
assert_eq!(account_labels[0].1, 11);
assert_eq!(account_labels[0].2, "Travel");
assert_eq!(account_labels[1].0, 2);
assert_eq!(account_labels[1].1, 22);
assert_eq!(account_labels[1].2, "Travel");
}
#[test]
fn gmail_creates_labels_while_other_accounts_create_folders() {
let rows = make_sidebar_rows(
accounts(2, 0),
vec![],
vec![],
&HashSet::from([1]),
&HashSet::new(),
);
assert!(
rows.iter()
.any(|row| { row.kind == Kind::NewLabel && row.mailbox.account_id == 1 })
);
assert!(
!rows
.iter()
.any(|row| { row.kind == Kind::NewFolder && row.mailbox.account_id == 1 })
);
assert!(
rows.iter()
.any(|row| { row.kind == Kind::NewFolder && row.mailbox.account_id == 2 })
);
assert!(
!rows
.iter()
.any(|row| { row.kind == Kind::NewLabel && row.mailbox.account_id == 2 })
);
}
#[test]
fn account_without_root_creation_right_has_no_new_folder_action() {
let mut rows_input = accounts(1, 0);
rows_input[0].can_create_children = false;
let rows = make_sidebar_rows(rows_input, vec![], vec![], &HashSet::new(), &HashSet::new());
assert!(!rows.iter().any(|row| row.kind == Kind::NewFolder));
}
#[test]
fn gmail_labels_form_a_collapsible_tree_without_inventing_parents() {
let label = |id, name: &str| MailLabelRow {
id,
account_id: 1,
name: name.into(),
display_name: name.into(),
..Default::default()
};
let labels = vec![
label(1, "Projects"),
label(2, "Projects/Launch"),
label(3, "Projects/Launch/Design"),
label(4, "Orphan/Child"),
];
let rows = make_sidebar_rows(
accounts(1, 0),
vec![],
labels.clone(),
&HashSet::from([1]),
&HashSet::new(),
);
let visible = rows
.iter()
.filter(|row| row.kind == Kind::AccountLabel)
.map(|row| {
(
row.label.id,
row.label.display_name.to_string(),
row.label.depth,
row.label.has_children,
)
})
.collect::<Vec<_>>();
assert_eq!(
visible,
[
(1, "Projects".into(), 0, true),
(2, "Launch".into(), 1, true),
(3, "Design".into(), 2, false),
(4, "Orphan/Child".into(), 0, false),
]
);
let collapsed = make_sidebar_rows(
accounts(1, 0),
vec![],
labels,
&HashSet::from([1]),
&HashSet::from(["gmail-label:1:1".into()]),
);
assert_eq!(
collapsed
.iter()
.filter(|row| row.kind == Kind::AccountLabel)
.map(|row| row.label.id)
.collect::<Vec<_>>(),
[1, 4]
);
assert!(
!collapsed
.iter()
.find(|row| row.label.id == 1)
.unwrap()
.label
.expanded
);
}
#[test]
fn sidebar_filter_finds_account_and_global_labels() {
let labels = vec![
MailLabelRow {
id: 11,
account_id: 1,
name: "Travel".into(),
..Default::default()
},
MailLabelRow {
id: 22,
name: "Travel plans".into(),
is_global: true,
..Default::default()
},
];
let rows = make_filtered_sidebar_rows(accounts(2, 0), labels, "travel", &HashSet::new());
assert!(
rows.iter()
.any(|row| row.kind == Kind::AccountLabel && row.label.id == 11)
);
assert!(
rows.iter()
.any(|row| row.kind == Kind::GlobalLabel && row.label.id == 22)
);
}
#[test]
fn section_state_survives_parent_collapse_refresh_and_reorder() {
let labels = vec![
MailLabelRow {
id: 1,
is_auto: true,
..Default::default()
},
MailLabelRow {
id: 2,
is_global: true,
..Default::default()
},
];
let mut collapsed = HashSet::from(["account:1".into()]);
let open = make_sidebar_rows(
accounts(2, 3),
vec![],
labels.clone(),
&HashSet::new(),
&collapsed,
);
assert!(open.iter().any(|row| row.kind == Kind::Category));
assert!(open.iter().any(|row| row.kind == Kind::GlobalLabel));
assert!(
!open.iter().any(|row| row.mailbox.account_id == 1
&& matches!(row.kind, Kind::Folder | Kind::NewFolder))
);
collapsed.insert("categories".into());
collapsed.insert("global-labels".into());
let closed = make_sidebar_rows(
accounts(2, 3),
vec![],
labels.clone(),
&HashSet::new(),
&collapsed,
);
assert!(
!closed
.iter()
.any(|row| matches!(row.kind, Kind::Category | Kind::GlobalLabel))
);
collapsed.remove("categories");
collapsed.remove("global-labels");
assert_rows_equal(
&open,
&make_sidebar_rows(accounts(2, 3), vec![], labels, &HashSet::new(), &collapsed),
);
let mut reordered = accounts(2, 3);
reordered.rotate_left(4);
let rows = make_sidebar_rows(reordered, vec![], vec![], &HashSet::new(), &collapsed);
assert!(!rows.iter().find(|row| row.key == "account:1").unwrap().open);
assert!(rows.iter().find(|row| row.key == "account:2").unwrap().open);
}
#[test]
fn folder_filter_is_case_insensitive_and_keeps_account_context() {
let mut data = accounts(2, 3);
data[2].label = "Receipts".into();
data[5].label = "RECEIPTS 2025".into();
let rows = make_filtered_sidebar_rows(data, vec![], "receipts", &HashSet::new());
assert_eq!(
rows.iter().map(|row| row.kind).collect::<Vec<_>>(),
vec![Kind::Account, Kind::Folder, Kind::Account, Kind::Folder]
);
assert!(
rows.iter()
.filter(|row| row.kind == Kind::Account)
.all(|row| row.open)
);
assert!(
rows.iter()
.filter(|row| row.kind == Kind::Folder)
.all(|row| { row.mailbox.depth == 0 && !row.mailbox.has_children })
);
}
#[derive(Debug, PartialEq)]
enum Event {
Added(usize, usize),
Removed(usize, usize),
Changed(usize),
}
struct Observer {
model: Rc<SidebarModel>,
events: Rc<RefCell<Vec<Event>>>,
}
impl Observer {
fn record(&self, event: Event) {
// Notifications must arrive after mutation with no outstanding borrow.
for i in 0..self.model.row_count() {
assert!(self.model.row_data(i).is_some());
}
self.events.borrow_mut().push(event);
}
}
impl ModelChangeListener for Observer {
fn row_changed(self: Pin<&Self>, row: usize) {
self.record(Event::Changed(row));
}
fn row_added(self: Pin<&Self>, index: usize, count: usize) {
self.record(Event::Added(index, count));
}
fn row_removed(self: Pin<&Self>, index: usize, count: usize) {
self.record(Event::Removed(index, count));
}
fn reset(self: Pin<&Self>) {
panic!("sidebar must not reset its model");
}
}
#[test]
fn refresh_is_silent_and_large_expansion_uses_one_range_notification() {
let model = Rc::new(SidebarModel::default());
let events = Rc::new(RefCell::new(Vec::new()));
let observer = Box::pin(ModelChangeListenerContainer::new(Observer {
model: model.clone(),
events: events.clone(),
}));
model
.model_tracker()
.attach_peer(observer.as_ref().model_peer());
let closed = vec![
section(
Kind::Account,
"account:1",
&HashSet::from(["account:1".into()]),
),
row(Kind::Folder, "tail"),
];
model.reconcile(closed.clone());
events.borrow_mut().clear();
model.reconcile(closed.clone());
assert!(events.borrow().is_empty());
let mut expanded = vec![section(Kind::Account, "account:1", &HashSet::new())];
expanded.extend((0..10_000).map(|id| row(Kind::Folder, format!("folder:{id}"))));
expanded.push(row(Kind::Folder, "tail"));
model.reconcile(expanded.clone());
assert_eq!(
*events.borrow(),
vec![Event::Added(1, 10_000), Event::Changed(0)]
);
events.borrow_mut().clear();
expanded[500].mailbox.count = "99".into();
model.reconcile(expanded.clone());
assert_eq!(*events.borrow(), vec![Event::Changed(500)]);
events.borrow_mut().clear();
model.reconcile(closed.clone());
assert_eq!(
*events.borrow(),
vec![Event::Removed(1, 10_000), Event::Changed(0)]
);
assert_rows_equal(&model.iter().collect::<Vec<_>>(), &closed);
}
#[test]
fn reconciliation_handles_empty_replacement_and_reordering() {
let model = SidebarModel::default();
for keys in [
vec![],
vec!["a", "b", "c"],
vec!["c", "a", "b"],
vec!["x", "y"],
vec![],
vec!["tail"],
] {
let rows: Vec<_> = keys.into_iter().map(|key| row(Kind::Folder, key)).collect();
model.reconcile(rows.clone());
assert_rows_equal(&model.iter().collect::<Vec<_>>(), &rows);
}
}
#[test]
fn accordion_transition_keeps_closing_rows_in_order_and_reveals_new_rows() {
let account = section(Kind::Account, "account:1", &HashSet::new());
let mut closed_account = account.clone();
closed_account.open = false;
let category = section(Kind::CategoriesSection, "categories", &HashSet::new());
let mut closed_category = category.clone();
closed_category.open = false;
let open = vec![
category.clone(),
row(Kind::Category, "category:1"),
account.clone(),
row(Kind::Folder, "folder:1"),
row(Kind::Folder, "folder:2"),
row(Kind::Account, "account:2"),
];
let closed = vec![
closed_category,
closed_account,
row(Kind::Account, "account:2"),
];
assert!(accordion_changed(&open, &closed));
let closing = transition_rows(open.clone(), closed.clone(), true);
assert_eq!(
closing
.iter()
.map(|row| row.key.as_str())
.collect::<Vec<_>>(),
[
"categories",
"category:1",
"account:1",
"folder:1",
"folder:2",
"account:2"
]
);
assert!(
closing
.iter()
.filter(|row| !row.show_row)
.all(|row| { matches!(row.key.as_str(), "category:1" | "folder:1" | "folder:2") })
);
assert_eq!(closing.iter().filter(|row| !row.show_row).count(), 3);
let reopening = transition_rows(closing, open, true);
assert!(reopening.iter().all(|row| row.show_row && !row.reveal_row));
let fresh = transition_rows(closed, reopening, true);
assert!(
fresh
.iter()
.filter(|row| row.reveal_row)
.all(|row| { matches!(row.key.as_str(), "category:1" | "folder:1" | "folder:2") })
);
assert_eq!(fresh.iter().filter(|row| row.reveal_row).count(), 3);
let mut folder = row(Kind::Folder, "folder:tree");
folder.mailbox.expanded = true;
let mut collapsed_folder = folder.clone();
collapsed_folder.mailbox.expanded = false;
assert!(accordion_changed(&[folder], &[collapsed_folder]));
let mut label = row(Kind::AccountLabel, "gmail-label:1:7");
label.label.expanded = true;
let mut collapsed_label = label.clone();
collapsed_label.label.expanded = false;
assert!(accordion_changed(&[label], &[collapsed_label]));
}
#[test]
fn large_accordion_uses_batched_model_reconciliation() {
let model = Rc::new(SidebarModel::default());
let animation = Rc::new(SidebarAnimation::default());
let mut open = vec![section(Kind::Account, "account:1", &HashSet::new())];
open.extend((0..1_000).map(|id| row(Kind::Folder, format!("folder:{id}"))));
animation.reconcile(&model, open, false);
let closed = vec![section(
Kind::Account,
"account:1",
&HashSet::from(["account:1".into()]),
)];
animation.reconcile(&model, closed.clone(), true);
assert_eq!(model.row_count(), 1);
assert_rows_equal(&model.iter().collect::<Vec<_>>(), &closed);
}
#[test]
fn large_same_size_replacement_does_not_retain_old_rows() {
let model = Rc::new(SidebarModel::default());
let animation = Rc::new(SidebarAnimation::default());
let mut previous = vec![section(Kind::Account, "account:1", &HashSet::new())];
previous.extend((0..300).map(|id| row(Kind::Folder, format!("old:{id}"))));
animation.reconcile(&model, previous, false);
let mut replacement = vec![section(
Kind::Account,
"account:1",
&HashSet::from(["account:1".into()]),
)];
replacement.extend((0..300).map(|id| row(Kind::Folder, format!("new:{id}"))));
animation.reconcile(&model, replacement.clone(), true);
assert_rows_equal(&model.iter().collect::<Vec<_>>(), &replacement);
}
#[test]
fn refresh_cancels_closing_rows_and_rapid_reopen_restores_them() {
let model = Rc::new(SidebarModel::default());
let animation = Rc::new(SidebarAnimation::default());
let open = vec![
section(Kind::CategoriesSection, "categories", &HashSet::new()),
row(Kind::Important, "important"),
row(Kind::Other, "other"),
row(Kind::AccountsHeading, "accounts-heading"),
];
let closed = vec![
section(
Kind::CategoriesSection,
"categories",
&HashSet::from(["categories".into()]),
),
row(Kind::AccountsHeading, "accounts-heading"),
];
animation.reconcile(&model, open.clone(), false);
animation.reconcile(&model, closed.clone(), true);
assert_eq!(model.iter().filter(|row| !row.show_row).count(), 2);
animation.reconcile(&model, open.clone(), true);
assert_rows_equal(&model.iter().collect::<Vec<_>>(), &open);
animation.reconcile(&model, closed, true);
let filtered = vec![row(Kind::Account, "account:1")];
animation.reconcile(&model, filtered.clone(), false);
assert_rows_equal(&model.iter().collect::<Vec<_>>(), &filtered);
}
}
#[cfg(test)]
mod interaction_tests {
use super::*;
use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType};
use slint::platform::{Platform, PointerEventButton, WindowAdapter, WindowEvent};
struct Headless(Rc<MinimalSoftwareWindow>, Rc<Cell<Duration>>);
impl Platform for Headless {
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, slint::PlatformError> {
Ok(self.0.clone())
}
fn duration_since_start(&self) -> Duration {
self.1.get()
}
}
#[test]
fn workspace_sidebars_survive_scrolling_and_layout_recreation() {
let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer);
let clock = Rc::new(Cell::new(Duration::ZERO));
slint::platform::set_platform(Box::new(Headless(window.clone(), clock.clone()))).unwrap();
let app = AppWindow::new().unwrap();
app.set_startup_ready(true);
app.set_startup_hydrated(true);
app.set_connected_accounts(ModelRc::new(VecModel::from(vec![AccountRow {
id: 1,
..Default::default()
}])));
app.window().set_size(slint::PhysicalSize::new(1320, 800));
let mut mailboxes = vec![MailboxRow {
account_id: 1,
label: "Large account".into(),
is_account: true,
..Default::default()
}];
mailboxes.extend((0..1_000).map(|id| MailboxRow {
account_id: 1,
folder_id: id,
scope: format!("Folder:{id}").into(),
label: format!("Folder {id}").into(),
expanded: true,
..Default::default()
}));
let collapsed = Rc::new(RefCell::new(HashSet::from([
"categories".into(),
"global-labels".into(),
])));
let model = Rc::new(SidebarModel::default());
let animation = Rc::new(SidebarAnimation::default());
animation.reconcile(
&model,
make_sidebar_rows(
mailboxes.clone(),
vec![],
vec![],
&HashSet::new(),
&collapsed.borrow(),
),
false,
);
app.set_sidebar_rows(model.clone().into());
let model_for_click = model.clone();
let animation_for_click = animation.clone();
let collapsed_for_click = collapsed.clone();
let motion_for_click = app.as_weak();
app.on_toggle_sidebar_section(move |key, open| {
if open {
collapsed_for_click.borrow_mut().remove(key.as_str());
} else {
collapsed_for_click.borrow_mut().insert(key.to_string());
}
let animate = motion_for_click
.upgrade()
.is_some_and(|app| app.global::<MotionSettings>().get_enabled());
animation_for_click.reconcile(
&model_for_click,
make_sidebar_rows(
mailboxes.clone(),
vec![],
vec![],
&HashSet::new(),
&collapsed_for_click.borrow(),
),
animate,
);
});
app.show().unwrap();
let draw_after = |elapsed: Duration| {
clock.set(clock.get() + elapsed);
slint::platform::update_timers_and_animations();
window.request_redraw();
let mut frame = Vec::new();
window.draw_if_needed(|renderer| {
let size = window.size();
let mut pixels =
vec![slint::Rgb8Pixel::default(); (size.width * size.height) as usize];
renderer.render(&mut pixels, size.width as usize);
if let Some(directory) = std::env::var_os("FLECTAR_SIDEBAR_TEST_SCREENSHOTS") {
let directory = PathBuf::from(directory);
std::fs::create_dir_all(&directory).unwrap();
let bytes: Vec<u8> = pixels.iter().flat_map(|p| [p.r, p.g, p.b]).collect();
image::save_buffer(
directory.join(format!("frame-{:05}.png", clock.get().as_millis())),
&bytes,
size.width,
size.height,
image::ColorType::Rgb8,
)
.unwrap();
}
frame = pixels;
});
frame
};
let draw = || {
draw_after(Duration::from_secs(1));
};
let dispatch_click = |x, y| {
let position = slint::LogicalPosition::new(x, y);
app.window().dispatch_event(WindowEvent::PointerPressed {
position,
button: PointerEventButton::Left,
});
app.window().dispatch_event(WindowEvent::PointerReleased {
position,
button: PointerEventButton::Left,
});
};
let click = |x, y| {
dispatch_click(x, y);
draw();
};
let closed_frame = draw_after(Duration::from_secs(1));
assert!(model.row_count() > 1_000);
const CATEGORY_HEADER_CENTER_Y: f32 = 169.;
const ACCOUNT_HEADER_CENTER_Y: f32 = 232.;
dispatch_click(140., CATEGORY_HEADER_CENTER_Y);
assert!(!collapsed.borrow().contains("categories"));
assert!(
model
.iter()
.any(|row| row.kind == SidebarRowKind::Important && row.reveal_row)
);
draw_after(Duration::from_millis(16));
let opening_frame = draw_after(Duration::from_millis(100));
let opened_frame = draw_after(Duration::from_millis(250));
let sidebar_region = |pixels: &[slint::Rgb8Pixel]| {
pixels
.chunks(1320)
.skip(180)
.take(130)
.flat_map(|line| line[..250].iter().map(|pixel| (pixel.r, pixel.g, pixel.b)))
.collect::<Vec<_>>()
};
assert_ne!(
sidebar_region(&opening_frame),
sidebar_region(&closed_frame)
);
assert_ne!(
sidebar_region(&opening_frame),
sidebar_region(&opened_frame)
);
dispatch_click(140., CATEGORY_HEADER_CENTER_Y);
assert!(collapsed.borrow().contains("categories"));
assert!(
model
.iter()
.any(|row| row.kind == SidebarRowKind::Important && !row.show_row)
);
let closing_frame = draw_after(Duration::from_millis(100));
assert_ne!(
sidebar_region(&closing_frame),
sidebar_region(&closed_frame)
);
assert_ne!(
sidebar_region(&closing_frame),
sidebar_region(&opened_frame)
);
draw();
app.global::<MotionSettings>().set_enabled(false);
dispatch_click(140., CATEGORY_HEADER_CENTER_Y);
assert!(!collapsed.borrow().contains("categories"));
assert!(model.iter().all(|row| !row.reveal_row));
dispatch_click(140., CATEGORY_HEADER_CENTER_Y);
assert!(collapsed.borrow().contains("categories"));
assert!(model.iter().all(|row| row.show_row));
app.global::<MotionSettings>().set_enabled(true);
draw();
click(140., ACCOUNT_HEADER_CENTER_Y);
assert!(collapsed.borrow().contains("account:1"));
assert!(model.row_count() < 10);
// Hiding/showing the whole sidebar and switching desktop layouts
// destroys delegates, but must retain the same account expansion state.
app.set_mail_sidebar_collapsed(true);
draw();
app.set_mail_sidebar_collapsed(false);
app.set_workspace_layout("minimal".into());
draw();
app.set_workspace_layout("default".into());
draw();
assert!(collapsed.borrow().contains("account:1"));
click(140., ACCOUNT_HEADER_CENTER_Y);
assert!(!collapsed.borrow().contains("account:1"));
assert!(model.row_count() > 1_000);
// Virtualized rows are recreated after scrolling in both directions.
// Variable-height ListView estimates can change while scrolling. Use
// a delta larger than the whole tree when returning to the top.
for delta_y in [-10_000., 1_000_000.] {
app.window().dispatch_event(WindowEvent::PointerScrolled {
position: slint::LogicalPosition::new(140., 500.),
delta_x: 0.,
delta_y,
});
draw();
}
click(140., CATEGORY_HEADER_CENTER_Y);
assert!(!collapsed.borrow().contains("categories"));
app.window().set_size(slint::PhysicalSize::new(390, 844));
draw();
click(31., 29.); // Open the compact folder drawer.
click(140., CATEGORY_HEADER_CENTER_Y);
assert!(collapsed.borrow().contains("categories"));
click(340., 200.); // Close the drawer through its backdrop.
click(31., 29.);
click(140., ACCOUNT_HEADER_CENTER_Y);
assert!(collapsed.borrow().contains("account:1"));
// Contacts uses the same retained update machinery and shared row controls.
app.window().set_size(slint::PhysicalSize::new(1320, 800));
app.set_active_view("contacts".into());
app.set_connected_accounts(ModelRc::new(VecModel::from(
(1..=750)
.map(|id| AccountRow {
id,
name: format!("Account {id}").into(),
email: format!("account{id}@example.com").into(),
..Default::default()
})
.collect::<Vec<_>>(),
)));
let contacts = Rc::new(RefCell::new(contacts::ContactDirectoryState::new(
vec![],
false,
)));
app.set_contact_sidebar_rows(contacts.borrow().sidebar_rows.clone().into());
contacts::apply_contact_rows(&app, &contacts);
let contacts_for_click = contacts.clone();
let weak = app.as_weak();
app.on_toggle_contact_section(move |key, open| {
if open {
contacts_for_click
.borrow_mut()
.collapsed_sections
.remove(key.as_str());
} else {
contacts_for_click
.borrow_mut()
.collapsed_sections
.insert(key.to_string());
}
contacts::refresh_contact_sidebar(&weak.unwrap(), &contacts_for_click);
});
let selected_scope = Rc::new(RefCell::new(String::new()));
let selected_for_click = selected_scope.clone();
app.on_select_contact_scope(move |scope| {
*selected_for_click.borrow_mut() = scope.to_string()
});
draw();
const CONTACT_FAVORITES_CENTER_Y: f32 = 182.;
// The account disclosure follows the unified heading/section, three
// unified scopes, and the accounts heading. Keep the click centered
// in the 45 px Account 1 row rather than on the heading above it.
const CONTACT_ACCOUNT_SECTION_CENTER_Y: f32 = 303.;
click(140., CONTACT_FAVORITES_CENTER_Y);
assert_eq!(*selected_scope.borrow(), "Favorites");
click(140., CONTACT_ACCOUNT_SECTION_CENTER_Y);
assert!(contacts.borrow().collapsed_sections.contains("account:1"));
for delta_y in [-10_000., 1_000_000.] {
app.window().dispatch_event(WindowEvent::PointerScrolled {
position: slint::LogicalPosition::new(140., 500.),
delta_x: 0.,
delta_y,
});
draw();
}
app.set_contacts_sidebar_collapsed(true);
draw();
app.set_contacts_sidebar_collapsed(false);
draw();
assert!(contacts.borrow().collapsed_sections.contains("account:1"));
click(140., CONTACT_ACCOUNT_SECTION_CENTER_Y);
assert!(!contacts.borrow().collapsed_sections.contains("account:1"));
// Calendar source toggles survive recycling and unrelated date changes.
let today = chrono::NaiveDate::from_ymd_opt(2026, 9, 8).unwrap();
let mut calendar = calendar::LocalCalendarState::new(today);
calendar.sources = (1..=750)
.map(|id| calendar::LocalCalendarSource {
id,
account_id: 1,
name: format!("Calendar {id}"),
color: String::new(),
read_only: false,
enabled: true,
is_default: id == 1,
last_synced_at: None,
})
.collect();
let calendar = Rc::new(RefCell::new(calendar));
app.set_calendar_sources(calendar.borrow().source_rows.clone().into());
calendar::apply_calendar(&app, &calendar.borrow(), today);
app.set_active_view("calendar".into());
let calendar_for_click = calendar.clone();
let weak = app.as_weak();
app.on_calendar_set_source_enabled(move |id, enabled| {
let mut state = calendar_for_click.borrow_mut();
state
.sources
.iter_mut()
.find(|source| source.id == i64::from(id))
.unwrap()
.enabled = enabled;
calendar::apply_calendar(&weak.unwrap(), &state, today);
});
draw();
click(82., 372.);
assert!(!calendar.borrow().sources[0].enabled);
for delta_y in [-10_000., 1_000_000.] {
app.window().dispatch_event(WindowEvent::PointerScrolled {
position: slint::LogicalPosition::new(140., 500.),
delta_x: 0.,
delta_y,
});
draw();
}
calendar.borrow_mut().selected_date += chrono::Duration::days(1);
calendar::apply_calendar(&app, &calendar.borrow(), today);
app.set_calendar_sidebar_collapsed(true);
draw();
app.set_calendar_sidebar_collapsed(false);
draw();
app.set_active_view("contacts".into());
draw();
app.set_active_view("calendar".into());
draw();
assert!(!calendar.borrow().source_rows.row_data(0).unwrap().enabled);
click(82., 372.);
assert!(calendar.borrow().sources[0].enabled);
app.hide().unwrap();
}
}