use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use day_spec::{ToolbarItem, ToolbarPatch, ToolbarValue};
use crate::tree::{RNode, with_tree};
type ValueAction = Rc<dyn Fn(&ToolbarValue)>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Chrome {
Window(RNode),
Page(RNode),
}
struct PageFrame {
page: RNode,
active: Option<Rc<dyn Fn() -> bool>>,
column: day_spec::ToolbarColumn,
window: RNode,
}
struct Contribution {
chrome: Chrome,
seq: u64,
items: Vec<ToolbarItem>,
window: RNode,
document_window: Option<DocumentWindow>,
active: Option<Rc<dyn Fn() -> bool>>,
}
#[derive(Clone)]
pub struct DocumentWindow(Rc<Cell<RNode>>);
impl DocumentWindow {
pub fn new(root: RNode) -> Self {
Self(Rc::new(Cell::new(root)))
}
pub fn set(&self, root: RNode) {
if self.0.replace(root) != root {
schedule_recompose();
}
}
}
impl Contribution {
fn window(&self) -> RNode {
self.document_window
.as_ref()
.map_or(self.window, |w| w.0.get())
}
}
day_reactive::tls_slots! {
toolbar;
static VALUE_ACTIONS: RefCell<HashMap<u64, ValueAction>> = RefCell::new(HashMap::new());
static CONTRIBUTIONS: RefCell<HashMap<u64, Contribution>> = RefCell::new(HashMap::new());
static MODELS: RefCell<Vec<(Chrome, Vec<ToolbarItem>)>> = const { RefCell::new(Vec::new()) };
static SLOT_IDS: RefCell<HashMap<(RNode, String), u64>> = RefCell::new(HashMap::new());
static SLOT_TARGETS: RefCell<HashMap<u64, u64>> = RefCell::new(HashMap::new());
static DELIVERED: RefCell<HashMap<RNode, Vec<ToolbarItem>>> = RefCell::new(HashMap::new());
static RECOMPOSE_PENDING: Cell<bool> = const { Cell::new(false) };
static RECOMPOSING: Cell<bool> = const { Cell::new(false) };
static RECOMPOSE_AGAIN: Cell<bool> = const { Cell::new(false) };
static NEXT_TOKEN: Cell<u64> = const { Cell::new(1) };
static BUILDING: Cell<Option<RNode>> = const { Cell::new(None) };
static PAGE_STACK: RefCell<Vec<PageFrame>> = RefCell::new(Vec::new());
}
pub(crate) fn with_window<R>(root: RNode, f: impl FnOnce() -> R) -> R {
struct Restore(Option<RNode>);
impl Drop for Restore {
fn drop(&mut self) {
BUILDING.with(|b| b.set(self.0));
}
}
let _restore = Restore(BUILDING.with(|b| b.replace(Some(root))));
f()
}
pub fn with_page<R>(page: RNode, f: impl FnOnce() -> R) -> R {
with_page_in(
page,
None,
day_spec::ToolbarColumn::Detail,
window_being_built(),
f,
)
}
pub fn with_page_in<R>(
page: RNode,
active: Option<Rc<dyn Fn() -> bool>>,
column: day_spec::ToolbarColumn,
window: RNode,
f: impl FnOnce() -> R,
) -> R {
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
PAGE_STACK.with(|s| {
s.borrow_mut().pop();
});
}
}
PAGE_STACK.with(|s| {
s.borrow_mut().push(PageFrame {
page,
active,
column,
window,
})
});
let _restore = Restore;
f()
}
pub fn with_page_gated<R>(
page: RNode,
active: Option<Rc<dyn Fn() -> bool>>,
column: day_spec::ToolbarColumn,
f: impl FnOnce() -> R,
) -> R {
with_page_in(page, active, column, window_being_built(), f)
}
pub fn current_page_window() -> RNode {
PAGE_STACK
.with(|s| s.borrow().last().map(|f| f.window))
.unwrap_or_else(window_being_built)
}
pub fn current_page_gate() -> Option<Rc<dyn Fn() -> bool>> {
PAGE_STACK.with(|s| s.borrow().last().and_then(|f| f.active.clone()))
}
pub fn current_page_column() -> day_spec::ToolbarColumn {
PAGE_STACK.with(|s| {
s.borrow()
.last()
.map(|f| f.column)
.unwrap_or(day_spec::ToolbarColumn::Window)
})
}
pub fn window_being_built() -> RNode {
BUILDING
.with(|b| b.get())
.unwrap_or_else(|| with_tree(|t| t.root_node()))
}
pub fn current_chrome() -> Chrome {
match PAGE_STACK.with(|s| s.borrow().last().map(|f| f.page)) {
Some(page) => Chrome::Page(page),
None => Chrome::Window(window_being_built()),
}
}
pub fn register_toolbar_value(f: Rc<dyn Fn(&ToolbarValue)>) -> u64 {
let id = crate::menu::next_action_id();
VALUE_ACTIONS.with(|m| m.borrow_mut().insert(id, f));
id
}
pub fn dispatch_toolbar_value(action: u64, value: &ToolbarValue) {
mirror_value(action, value);
let target = resolve_slot(action);
let f = VALUE_ACTIONS.with(|m| m.borrow().get(&target).cloned());
if let Some(f) = f {
day_reactive::batch(|| f(value));
}
}
pub fn synthesize_toolbar_value(action: u64, value: &ToolbarValue) {
if let ToolbarValue::Text(text) = value {
let target = MODELS.with(|m| {
m.borrow().iter().find_map(|(c, items)| match c {
Chrome::Window(root) => items
.iter()
.find(|i| i.action == action)
.map(|i| (*root, i.id.clone())),
Chrome::Page(_) => None,
})
});
if let Some((root, item)) = target {
let patch = ToolbarPatch::Text {
item,
text: text.clone(),
};
if with_tree(|t| t.patch_window_toolbar(root, patch.clone())) {
deliver_value(root, &patch);
}
}
}
dispatch_toolbar_value(action, value);
}
pub(crate) fn resolve_slot(id: u64) -> u64 {
SLOT_TARGETS.with(|m| m.borrow().get(&id).copied().unwrap_or(id))
}
pub fn register_contribution(chrome: Chrome, items: Vec<ToolbarItem>) -> u64 {
register_contribution_gated(chrome, items, None)
}
pub fn register_contribution_gated(
chrome: Chrome,
items: Vec<ToolbarItem>,
active: Option<Rc<dyn Fn() -> bool>>,
) -> u64 {
let token = NEXT_TOKEN.with(|n| {
let t = n.get();
n.set(t + 1);
t
});
let window = current_page_window();
CONTRIBUTIONS.with(|m| {
m.borrow_mut().insert(
token,
Contribution {
chrome,
seq: token,
items,
window,
document_window: day_reactive::Scope::current().use_context::<DocumentWindow>(),
active,
},
)
});
let _ = chrome;
schedule_recompose();
token
}
fn merged_window(root: RNode) -> Vec<ToolbarItem> {
let mut live: Vec<(u64, Vec<ToolbarItem>)> = CONTRIBUTIONS.with(|m| {
m.borrow()
.values()
.filter(|c| c.window() == root)
.filter(|c| {
c.active
.as_ref()
.is_none_or(|f| day_reactive::untrack(|| f()))
})
.map(|c| (c.seq, c.items.clone()))
.collect()
});
live.sort_by_key(|(seq, _)| *seq);
let mut out: Vec<ToolbarItem> = live.into_iter().flat_map(|(_, items)| items).collect();
out.sort_by_key(|i| placement_rank(i.placement));
out
}
pub fn update_contribution(token: u64, items: Vec<ToolbarItem>) {
let old = CONTRIBUTIONS.with(|m| {
let mut m = m.borrow_mut();
let c = m.get_mut(&token)?;
Some(std::mem::replace(&mut c.items, items))
});
if let Some(old) = old {
forget_closures(&old);
schedule_recompose();
}
}
pub fn relabel_contribution(token: u64, texts: &[(String, Option<String>)]) {
let Some(mut items) = CONTRIBUTIONS.with(|m| m.borrow().get(&token).map(|c| c.items.clone()))
else {
return;
};
for (item, (label, tooltip)) in items.iter_mut().zip(texts) {
item.label = label.clone();
item.tooltip = tooltip.clone();
}
update_contribution(token, items);
}
pub fn unregister_contribution(token: u64) {
let old = CONTRIBUTIONS.with(|m| m.borrow_mut().remove(&token).map(|c| c.items));
if let Some(old) = old {
forget_closures(&old);
schedule_recompose();
}
}
fn forget_closures(items: &[ToolbarItem]) {
let live: std::collections::HashSet<u64> = CONTRIBUTIONS.with(|m| {
m.borrow()
.values()
.flat_map(|c| c.items.iter().map(|i| i.action))
.collect()
});
for item in items
.iter()
.filter(|i| i.action != 0 && !live.contains(&i.action))
{
VALUE_ACTIONS.with(|m| m.borrow_mut().remove(&item.action));
if matches!(
item.kind,
day_spec::ToolbarItemKind::Button | day_spec::ToolbarItemKind::Menu { .. }
) {
crate::menu::forget_action(item.action);
}
}
}
fn placement_rank(p: day_spec::ToolbarPlacement) -> u8 {
use day_spec::ToolbarPlacement as P;
match p {
P::Navigation => 0,
P::Principal => 1,
P::Automatic => 2,
P::Primary => 3,
P::Secondary | P::Bottom => 4,
}
}
fn mirror_value(action: u64, value: &ToolbarValue) {
let Some((chrome, id)) = MODELS.with(|m| {
let m = m.borrow();
let found = |window: bool| {
m.iter().find_map(|(c, items)| {
(matches!(c, Chrome::Window(_)) == window)
.then(|| items.iter().find(|i| i.action == action))
.flatten()
.map(|i| (*c, i.id.clone()))
})
};
found(true).or_else(|| found(false))
}) else {
return;
};
let patch = match value {
ToolbarValue::Text(text) => ToolbarPatch::Text {
item: id,
text: text.clone(),
},
ToolbarValue::On(on) => ToolbarPatch::On { item: id, on: *on },
ToolbarValue::Selected(index) => ToolbarPatch::Selected {
item: id,
index: *index,
},
ToolbarValue::Submit(_) => return,
};
CONTRIBUTIONS.with(|m| {
for c in m.borrow_mut().values_mut().filter(|c| match chrome {
Chrome::Window(root) => c.window() == root,
Chrome::Page(_) => c.chrome == chrome,
}) {
apply_to_model(&mut c.items, &patch);
}
});
MODELS.with(|m| {
if let Some((_, items)) = m.borrow_mut().iter_mut().find(|(c, _)| *c == chrome) {
apply_to_model(items, &patch);
}
});
if let Chrome::Window(root) = chrome {
deliver_value(root, &patch);
}
}
fn schedule_recompose() {
if RECOMPOSE_PENDING.with(|p| p.replace(true)) {
return;
}
day_reactive::at_turn_end(recompose_windows);
}
fn recompose_windows() {
recompose_serially(recompose_windows_inner);
}
fn recompose_serially(mut apply: impl FnMut()) {
if RECOMPOSING.with(|busy| busy.replace(true)) {
RECOMPOSE_AGAIN.with(|again| again.set(true));
return;
}
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
RECOMPOSING.with(|busy| busy.set(false));
RECOMPOSE_AGAIN.with(|again| again.set(false));
}
}
let _reset = Reset;
loop {
RECOMPOSE_AGAIN.with(|again| again.set(false));
apply();
if !RECOMPOSE_AGAIN.with(|again| again.get()) {
break;
}
}
}
fn recompose_windows_inner() {
RECOMPOSE_PENDING.with(|p| p.set(false));
let roots: Vec<RNode> = {
let mut v: Vec<RNode> =
CONTRIBUTIONS.with(|m| m.borrow().values().map(|c| c.window()).collect());
v.extend(MODELS.with(|m| {
m.borrow()
.iter()
.filter_map(|(c, _)| match c {
Chrome::Window(r) => Some(*r),
Chrome::Page(_) => None,
})
.collect::<Vec<_>>()
}));
v.sort();
v.dedup();
v
};
for root in roots {
let items = slot_model(root, unique_ids(merged_window(root)));
MODELS.with(|m| {
let mut m = m.borrow_mut();
match m.iter_mut().find(|(c, _)| *c == Chrome::Window(root)) {
Some(entry) => entry.1 = items.clone(),
None => m.push((Chrome::Window(root), items.clone())),
}
});
let prev = DELIVERED.with(|d| d.borrow().get(&root).cloned().unwrap_or_default());
let (ops, patches) = diff_bar(&prev, &items);
if ops.is_empty() && patches.is_empty() {
continue;
}
debug_assert!(edit_is_ordered(&ops), "toolbar edit out of order: {ops:?}");
let delivered = ops.is_empty() || with_tree(|t| t.edit_window_toolbar(root, ops));
if !delivered {
continue;
}
for patch in patches {
with_tree(|t| t.patch_window_toolbar(root, patch));
}
DELIVERED.with(|d| d.borrow_mut().insert(root, items));
}
}
fn edit_is_ordered(ops: &[day_spec::ToolbarOp]) -> bool {
use day_spec::ToolbarOp as O;
let first_insert = ops
.iter()
.position(|o| matches!(o, O::Insert { .. }))
.unwrap_or(ops.len());
let inserts: Vec<usize> = ops[first_insert..]
.iter()
.filter_map(|o| match o {
O::Insert { index, .. } => Some(*index),
O::Remove { .. } => None,
})
.collect();
inserts.len() == ops.len() - first_insert && inserts.windows(2).all(|w| w[0] < w[1])
}
fn deliver_value(root: RNode, patch: &ToolbarPatch) {
DELIVERED.with(|d| {
if let Some(items) = d.borrow_mut().get_mut(&root) {
patch.apply_to(items);
}
});
}
fn unique_ids(items: Vec<ToolbarItem>) -> Vec<ToolbarItem> {
let mut seen = std::collections::HashSet::new();
items
.into_iter()
.filter(|i| {
let fresh = seen.insert(i.id.clone());
if !fresh {
log::warn!(
"toolbar item id {:?} appears twice in one window's bar; only the first is \
shown (docs/toolbars.md: ids are unique within a bar)",
i.id
);
}
fresh
})
.collect()
}
fn diff_bar(
prev: &[ToolbarItem],
next: &[ToolbarItem],
) -> (Vec<day_spec::ToolbarOp>, Vec<ToolbarPatch>) {
use day_spec::ToolbarOp;
let matched: Vec<Option<usize>> = next
.iter()
.map(|n| {
prev.iter()
.position(|p| p.id == n.id)
.filter(|&at| shape_of(&prev[at]) == shape_of(n))
})
.collect();
let kept = longest_increasing(&matched);
let mut ops: Vec<ToolbarOp> = prev
.iter()
.enumerate()
.filter(|(at, _)| !kept.iter().any(|&k| matched[k] == Some(*at)))
.map(|(_, p)| ToolbarOp::Remove { id: p.id.clone() })
.collect();
let mut patches = Vec::new();
for (index, item) in next.iter().enumerate() {
if kept.contains(&index) {
let before = &prev[matched[index].unwrap_or_default()];
patches.extend(value_patches(before, item));
} else {
ops.push(ToolbarOp::Insert {
index,
item: item.clone(),
});
}
}
(ops, patches)
}
fn longest_increasing(seq: &[Option<usize>]) -> Vec<usize> {
let mut tails: Vec<usize> = Vec::new();
let mut back: Vec<Option<usize>> = vec![None; seq.len()];
for (i, v) in seq.iter().enumerate() {
let Some(v) = *v else { continue };
let k = tails.partition_point(|&t| seq[t].unwrap_or_default() < v);
back[i] = k.checked_sub(1).map(|p| tails[p]);
if k == tails.len() {
tails.push(i);
} else {
tails[k] = i;
}
}
let mut out = Vec::new();
let mut at = tails.last().copied();
while let Some(i) = at {
out.push(i);
at = back[i];
}
out.reverse();
out
}
fn value_patches(was: &ToolbarItem, now: &ToolbarItem) -> Vec<ToolbarPatch> {
use day_spec::ToolbarItemKind as K;
let item = now.id.clone();
let mut out = Vec::new();
match (&was.kind, &now.kind) {
(
K::Search {
text: t0,
suggestions: s0,
placeholder: p0,
},
K::Search {
text: t1,
suggestions: s1,
placeholder: p1,
},
) => {
if t0 != t1 {
out.push(ToolbarPatch::Text {
item: item.clone(),
text: t1.clone(),
});
}
if s0 != s1 {
out.push(ToolbarPatch::Suggestions {
item: item.clone(),
list: s1.clone(),
});
}
if p0 != p1 {
out.push(ToolbarPatch::Placeholder {
item: item.clone(),
text: p1.clone(),
});
}
}
(K::Toggle { on: a }, K::Toggle { on: b }) if a != b => out.push(ToolbarPatch::On {
item: item.clone(),
on: *b,
}),
(K::Segmented { selected: a, .. }, K::Segmented { selected: b, .. }) if a != b => {
out.push(ToolbarPatch::Selected {
item: item.clone(),
index: *b,
})
}
_ => {}
}
if was.label != now.label || was.tooltip != now.tooltip {
out.push(ToolbarPatch::Label {
item: item.clone(),
label: now.label.clone(),
tooltip: now.tooltip.clone(),
});
}
if was.enabled != now.enabled {
out.push(ToolbarPatch::Enabled {
item,
on: now.enabled,
});
}
out
}
fn slot_model(root: RNode, mut items: Vec<ToolbarItem>) -> Vec<ToolbarItem> {
let mut live: Vec<u64> = Vec::new();
for item in &mut items {
let key = item.id.clone();
item.action = slot_for(root, &key, item.action, &mut live);
if let day_spec::ToolbarItemKind::Menu { items: entries } = &mut item.kind {
slot_menu(root, &key, entries, &mut live);
}
}
let keep: std::collections::HashSet<u64> = live.into_iter().collect();
SLOT_IDS.with(|ids| {
SLOT_TARGETS.with(|t| {
let mut t = t.borrow_mut();
for ((r, _), slot) in ids.borrow().iter() {
if *r == root && !keep.contains(slot) {
t.remove(slot);
}
}
})
});
items
}
fn slot_menu(root: RNode, parent: &str, entries: &mut [day_spec::MenuItem], live: &mut Vec<u64>) {
for (i, entry) in entries.iter_mut().enumerate() {
let key = format!("{parent}/{i}");
match entry {
day_spec::MenuItem::Action { action, .. } => {
*action = slot_for(root, &key, *action, live);
}
day_spec::MenuItem::Submenu { items, .. } => slot_menu(root, &key, items, live),
day_spec::MenuItem::Separator => {}
}
}
}
fn slot_for(root: RNode, key: &str, target: u64, live: &mut Vec<u64>) -> u64 {
if target == 0 {
return 0;
}
let slot = SLOT_IDS.with(|ids| {
*ids.borrow_mut()
.entry((root, key.to_string()))
.or_insert_with(crate::menu::next_action_id)
});
SLOT_TARGETS.with(|t| t.borrow_mut().insert(slot, target));
live.push(slot);
slot
}
pub fn chrome_changed() {
schedule_recompose();
}
pub fn patch_toolbar(patch: ToolbarPatch) {
let owner = MODELS.with(|m| {
m.borrow()
.iter()
.find(|(_, items)| items.iter().any(|i| i.id == patch.item()))
.map(|(c, _)| *c)
});
if let Some(chrome) = owner {
patch_chrome(chrome, patch);
}
}
pub fn patch_chrome(chrome: Chrome, patch: ToolbarPatch) {
let window = CONTRIBUTIONS.with(|m| {
let mut m = m.borrow_mut();
let mut window = None;
for c in m.values_mut() {
if c.chrome == chrome {
apply_to_model(&mut c.items, &patch);
window = Some(c.window());
}
}
window
});
let Some(root) = window else { return };
MODELS.with(|m| {
let mut m = m.borrow_mut();
if let Some((_, items)) = m.iter_mut().find(|(c, _)| *c == Chrome::Window(root)) {
apply_to_model(items, &patch);
}
});
if with_tree(|t| t.patch_window_toolbar(root, patch.clone())) {
deliver_value(root, &patch);
}
}
fn apply_to_model(items: &mut [ToolbarItem], patch: &ToolbarPatch) {
patch.apply_to(items);
}
fn shape_of(item: &ToolbarItem) -> ToolbarItem {
use day_spec::ToolbarItemKind as K;
let mut i = item.clone();
i.enabled = true;
i.label.clear();
i.tooltip = None;
match &mut i.kind {
K::Search {
text,
suggestions,
placeholder,
} => {
text.clear();
suggestions.clear();
placeholder.clear();
}
K::Toggle { on } => *on = false,
K::Segmented { selected, .. } => *selected = 0,
_ => {}
}
i
}
pub fn toolbar_model() -> Vec<ToolbarItem> {
MODELS.with(|m| m.borrow().iter().flat_map(|(_, i)| i.clone()).collect())
}
pub fn toggle_sidebar(host: RNode) -> bool {
with_tree(|t| t.toggle_sidebar(host))
}
pub(crate) fn forget_window(root: RNode) {
let gone: Vec<ToolbarItem> = CONTRIBUTIONS.with(|m| {
let mut m = m.borrow_mut();
let mut gone = Vec::new();
m.retain(|_, c| {
let mine = c.window() == root;
if mine {
gone.extend(c.items.clone());
}
!mine
});
gone
});
forget_closures(&gone);
MODELS.with(|m| {
m.borrow_mut()
.retain(|(c, _)| !matches!(c, Chrome::Window(r) if *r == root))
});
DELIVERED.with(|d| {
d.borrow_mut().remove(&root);
});
SLOT_IDS.with(|ids| {
SLOT_TARGETS.with(|t| {
let mut t = t.borrow_mut();
ids.borrow_mut().retain(|(r, _), slot| {
if *r == root {
t.remove(slot);
}
*r != root
});
})
});
}
pub fn reset_toolbars() {
MODELS.with(|m| m.borrow_mut().clear());
CONTRIBUTIONS.with(|m| m.borrow_mut().clear());
VALUE_ACTIONS.with(|m| m.borrow_mut().clear());
SLOT_IDS.with(|m| m.borrow_mut().clear());
SLOT_TARGETS.with(|m| m.borrow_mut().clear());
DELIVERED.with(|m| m.borrow_mut().clear());
RECOMPOSE_PENDING.with(|p| p.set(false));
PAGE_STACK.with(|s| s.borrow_mut().clear());
BUILDING.with(|b| b.set(None));
}
pub fn focus_toolbar_search() -> bool {
let root = crate::windows::focused_root();
let has = MODELS.with(|models| {
models.borrow().iter().any(|(chrome, items)| {
*chrome == Chrome::Window(root)
&& items.iter().any(|item| item.id == day_spec::SEARCH_ITEM_ID)
})
});
if has {
with_tree(|t| {
t.patch_window_toolbar(
root,
ToolbarPatch::Focus {
item: day_spec::SEARCH_ITEM_ID.to_owned(),
},
)
});
}
has
}
#[cfg(test)]
mod tests {
#[test]
fn native_events_recompose_after_the_delivered_baseline_is_recorded() {
let passes = std::cell::Cell::new(0);
let delivered = std::cell::Cell::new(false);
super::recompose_serially(|| {
passes.set(passes.get() + 1);
if passes.get() == 1 {
super::recompose_serially(|| panic!("must not diff the stale baseline"));
super::recompose_serially(|| panic!("nested requests must coalesce"));
delivered.set(true);
} else {
assert!(delivered.get());
}
});
assert_eq!(passes.get(), 2);
super::recompose_serially(|| passes.set(passes.get() + 1));
assert_eq!(passes.get(), 3, "the guard releases after a settled update");
}
use super::*;
use day_spec::{ToolbarItemKind as K, ToolbarMirror, ToolbarOp};
fn item(id: &str, label: &str) -> ToolbarItem {
ToolbarItem {
id: id.into(),
kind: K::Button,
label: label.into(),
tooltip: None,
icon: None,
enabled: true,
action: 0,
placement: day_spec::ToolbarPlacement::Automatic,
label_style: day_spec::LabelStyle::Automatic,
prominent: false,
column: day_spec::ToolbarColumn::Window,
}
}
fn check(prev: &[ToolbarItem], next: &[ToolbarItem]) -> Vec<ToolbarOp> {
let (ops, patches) = diff_bar(prev, next);
let mut mirror = ToolbarMirror::default();
for (i, p) in prev.iter().enumerate() {
mirror.insert(i, p.clone());
}
let first_insert = ops
.iter()
.position(|o| matches!(o, ToolbarOp::Insert { .. }))
.unwrap_or(ops.len());
assert!(
ops[first_insert..]
.iter()
.all(|o| matches!(o, ToolbarOp::Insert { .. }))
);
for op in &ops {
mirror.apply(op);
}
for p in &patches {
mirror.patch(p);
}
assert_eq!(mirror.items(), next);
ops
}
#[test]
fn identical_bars_need_no_edit() {
let bar = vec![item("a", "A"), item("b", "B")];
assert!(check(&bar, &bar).is_empty());
}
#[test]
fn inserts_and_removes_touch_only_their_items() {
let prev = vec![item("a", "A"), item("b", "B"), item("search", "S")];
let next = vec![item("a", "A"), item("c", "C"), item("search", "S")];
let ops = check(&prev, &next);
assert_eq!(ops.len(), 2);
assert!(ops.iter().all(|o| match o {
ToolbarOp::Remove { id } => id == "b",
ToolbarOp::Insert { item, index } => item.id == "c" && *index == 1,
}));
}
#[test]
fn a_reorder_moves_the_fewest_items() {
let prev = vec![
item("a", "A"),
item("b", "B"),
item("c", "C"),
item("d", "D"),
];
let next = vec![
item("d", "D"),
item("a", "A"),
item("b", "B"),
item("c", "C"),
];
let ops = check(&prev, &next);
assert_eq!(ops.len(), 2, "{ops:?}");
}
#[test]
fn a_value_change_is_a_patch_and_a_label_change_an_edit() {
let mut toggled = item("t", "T");
toggled.kind = K::Toggle { on: false };
let mut on = toggled.clone();
on.kind = K::Toggle { on: true };
on.enabled = false;
let (ops, patches) = diff_bar(std::slice::from_ref(&toggled), std::slice::from_ref(&on));
assert!(ops.is_empty());
assert_eq!(patches.len(), 2);
check(&[item("x", "Old")], &[item("x", "New")]);
}
#[test]
fn longest_increasing_skips_unmatched() {
assert_eq!(
longest_increasing(&[Some(2), None, Some(0), Some(1), Some(3)]),
vec![2, 3, 4]
);
assert!(longest_increasing(&[None, None]).is_empty());
}
}