Panics · P07 Navigation: Wizard takes its handlers; stale places in tabs, crumbs, history, steps and pages mark none

This commit is contained in:
ZacharyZhang-NY committed 2026-10-06 17:06:25 -04:00
1 parent e2d880fb4f
commit 919f26fcc6
14 files changed
+261 -83

No files matched your search

+10 -7
View File
@@ -38,13 +38,16 @@ impl Crumb {
/// The level's neighbors, itself among them; a click lists them.
pub fn siblings(mut self, siblings: impl IntoIterator<Item = Choice>) -> Self {
self.siblings = siblings.into_iter().collect();
assert!(
self.siblings
.iter()
.any(|sibling| sibling.value == self.value),
"crumb {} is not among its siblings",
self.value
);
if !self
.siblings
.iter()
.any(|sibling| sibling.value == self.value)
{
log::error!(
"crumb {} is not among its siblings; none marked",
self.value
);
}
self
}
}
+11 -7
View File
@@ -29,11 +29,7 @@ impl BackForwardNavigation {
current: usize,
) -> Self {
let places: Vec<Choice> = places.into_iter().collect();
assert!(
current < places.len(),
"place {current} is past the last of {}",
places.len()
);
assert!(!places.is_empty(), "history has no places");
Self {
id: id.into(),
places,
@@ -52,6 +48,14 @@ impl BackForwardNavigation {
impl RenderOnce for BackForwardNavigation {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (current, last) = (self.current, self.places.len() - 1);
let valid = current <= last;
if !valid {
log::error!(
"history {:?}: place {current} of {}; none marked",
self.id,
last + 1
);
}
let go: OnGo = {
let (id, on_go) = (self.id.clone(), self.on_go);
Rc::new(move |to, window, cx| {
@@ -96,7 +100,7 @@ impl RenderOnce for BackForwardNavigation {
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Back")
.disabled(current == 0)
.disabled(!valid || current == 0)
.on_click(move |_, window, cx| back(current - 1, window, cx)),
)
.child(
@@ -104,7 +108,7 @@ impl RenderOnce for BackForwardNavigation {
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Forward")
.disabled(current == last)
.disabled(!valid || current == last)
.on_click(move |_, window, cx| ahead(current + 1, window, cx)),
)
.child(list)
+16 -4
View File
@@ -94,7 +94,19 @@ impl Pagination {
impl RenderOnce for Pagination {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let list = page_list(self.page, self.pages, self.near);
let valid = (1..=self.pages).contains(&self.page);
if !valid {
log::error!(
"pagination {:?}: page {} of {}; none marked",
self.id,
self.page,
self.pages
);
}
let list = match self.pages {
0 => Vec::new(),
pages => page_list(self.page.clamp(1, pages), pages, self.near),
};
let (page, pages) = (self.page, self.pages);
let go = {
let (id, on_change) = (self.id.clone(), self.on_change);
@@ -113,7 +125,7 @@ impl RenderOnce for Pagination {
);
let (back, ahead, jump) = (go.clone(), go.clone(), go.clone());
let to = i18n::text(cx, "pagination.jump", &[]);
let jump = self.jump.then(|| {
let jump = (self.jump && pages > 0).then(|| {
let words = div()
.whitespace_nowrap()
.text_size(small)
@@ -177,7 +189,7 @@ impl RenderOnce for Pagination {
.size(size)
.variant(ButtonVariant::Ghost)
.tooltip(i18n::text(cx, "pagination.previous", &[]))
.disabled(page == 1)
.disabled(!valid || page == 1)
.on_click(move |_, window, cx| back(page - 1, window, cx)),
),
)
@@ -188,7 +200,7 @@ impl RenderOnce for Pagination {
.size(size)
.variant(ButtonVariant::Ghost)
.tooltip(i18n::text(cx, "pagination.next", &[]))
.disabled(page == pages)
.disabled(!valid || page == pages)
.on_click(move |_, window, cx| ahead(page + 1, window, cx)),
),
)
+8 -4
View File
@@ -130,18 +130,22 @@ impl RenderOnce for CommandPalette {
let query = input.read(cx).text().trim().to_string();
let groups = if query.is_empty() {
let find = |value: &SharedString| {
self.groups
let found = self
.groups
.iter()
.flat_map(|(_, commands)| commands)
.find(|command| command.value == *value)
.unwrap_or_else(|| panic!("recent command {value} is not in the palette"))
.find(|command| command.value == *value);
if found.is_none() {
log::error!("command palette {:?}: recent {value} is gone", self.id);
}
found
};
let recent = Group {
title: Some(i18n::text(cx, "palette.recent", &[])),
rows: self
.recent
.iter()
.map(|value| find(value).row(Vec::new(), cx))
.filter_map(|value| Some(find(value)?.row(Vec::new(), cx)))
.collect(),
};
iter::once(recent)
+1 -1
View File
@@ -120,7 +120,7 @@ impl Render for Host {
Command::new("light", "Light theme"),
],
)
.recent(["dark"])
.recent(["dark", "retired"])
.on_run(got)
.into_any_element(),
Kind::Files => QuickOpen::new(
+26 -39
View File
@@ -33,15 +33,9 @@ impl Steps {
steps: impl IntoIterator<Item = Choice>,
current: usize,
) -> Self {
let steps: Vec<Choice> = steps.into_iter().collect();
assert!(
current < steps.len(),
"step {current} is past the last of {}",
steps.len()
);
Self {
id: id.into(),
steps,
steps: steps.into_iter().collect(),
current,
vertical: false,
on_select: None,
@@ -65,12 +59,16 @@ impl RenderOnce for Steps {
let id = self.id.clone();
let turn = motion::changes((id.clone(), "current"), self.current, window, cx);
let (current, vertical, count) = (self.current, self.vertical, self.steps.len());
let valid = current < count;
if !valid {
log::error!("steps {id:?}: step {current} of {count}; none marked");
}
let duration = motion::duration(motion::SLOW, cx);
let theme = cx.theme();
let colors = &theme.colors;
let mark = theme.control_height(ControlSize::Sm);
let line = |ix: usize| {
let filled = ix < current;
let filled = valid && ix < current;
let fill = div().bg(colors.accent).map(|fill| {
if vertical {
fill.w_full()
@@ -102,7 +100,7 @@ impl RenderOnce for Steps {
.child(fill)
};
let steps = self.steps.into_iter().enumerate().map(|(ix, step)| {
let (done, now) = (ix < current, ix == current);
let (done, now) = (valid && ix < current, valid && ix == current);
let circle = div()
.flex()
.flex_none()
@@ -217,31 +215,28 @@ pub struct Wizard {
content: Option<AnyElement>,
ready: bool,
headless: bool,
on_step: Option<OnStep>,
on_finish: Option<Run>,
on_step: OnStep,
on_finish: Run,
}
impl Wizard {
/// `on_step` runs with the step Back, Next or a finished step leads to; `on_finish` with Finish.
pub fn new(
id: impl Into<ElementId>,
steps: impl IntoIterator<Item = Choice>,
current: usize,
on_step: impl Fn(usize, &mut Window, &mut App) + 'static,
on_finish: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
let steps: Vec<Choice> = steps.into_iter().collect();
assert!(
current < steps.len(),
"step {current} is past the last of {}",
steps.len()
);
Self {
id: id.into(),
steps,
steps: steps.into_iter().collect(),
current,
content: None,
ready: true,
headless: false,
on_step: None,
on_finish: None,
on_step: Rc::new(on_step),
on_finish: Rc::new(on_finish),
}
}
@@ -262,30 +257,22 @@ impl Wizard {
self.ready = ready;
self
}
/// Runs with the step Back, Next or a finished step leads to.
pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_step = Some(Rc::new(handler));
self
}
pub fn on_finish(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_finish = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Wizard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (current, last) = (self.current, self.steps.len().saturating_sub(1));
let valid = current < self.steps.len();
if !valid {
log::error!(
"wizard {:?}: step {current} of {}",
self.id,
self.steps.len()
);
}
let turn = motion::changes((self.id.clone(), "content"), current, window, cx);
let id = self.id.clone();
let on_step = self
.on_step
.unwrap_or_else(|| panic!("wizard {id:?} has no on_step"));
let on_finish = self
.on_finish
.unwrap_or_else(|| panic!("wizard {id:?} has no on_finish"));
let (on_step, on_finish) = (self.on_step, self.on_finish);
let step: OnStep = {
let id = id.clone();
Rc::new(move |to, window, cx| {
@@ -322,7 +309,7 @@ impl RenderOnce for Wizard {
if current == last { "Finish" } else { "Next" },
)
.primary()
.disabled(!self.ready)
.disabled(!valid || !self.ready)
.focus_handle(&advance)
.on_click(move |_, window, cx| match current == last {
true => finish(window, cx),
@@ -352,7 +339,7 @@ impl RenderOnce for Wizard {
.child(
Button::new((id.clone(), "back"), "Back")
.variant(ButtonVariant::Ghost)
.disabled(current == 0)
.disabled(!valid || current == 0)
.on_click(move |_, window, cx| {
if current == 1 {
window.focus(&to_advance, cx);
+12 -8
View File
@@ -88,10 +88,7 @@ impl Tabs {
impl RenderOnce for Tabs {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let values: Vec<SharedString> = self.tabs.iter().map(|tab| tab.value.clone()).collect();
let chosen = values
.iter()
.position(|value| *value == self.selected)
.unwrap_or_else(|| panic!("tabs {:?} have no tab {}", self.id, self.selected));
let chosen = values.iter().position(|value| *value == self.selected);
let placement = self.placement;
let axis = match placement {
TabPlacement::Left => Axis::Vertical,
@@ -155,7 +152,7 @@ impl RenderOnce for Tabs {
.iter()
.enumerate()
.map(|(ix, tab)| {
let on = ix == chosen;
let on = Some(ix) == chosen;
let fg = match (tab.disabled, on) {
(true, _) => colors.fg_disabled,
(false, true) => colors.fg,
@@ -235,6 +232,9 @@ impl RenderOnce for Tabs {
TabPlacement::Left => strip.border_r_1().pr_1(),
})
.on_key_down(move |event, window, cx| {
if keys.is_empty() {
return;
}
let back = if axis == Axis::Vertical { "up" } else { "left" };
let ahead = if axis == Axis::Vertical {
"down"
@@ -242,15 +242,19 @@ impl RenderOnce for Tabs {
"right"
};
let key = event.keystroke.key.as_str();
let (from_back, from_ahead) = match chosen {
Some(at) => (at, at),
None => (0, keys.len() - 1),
};
let to = match key {
key if key == back => step(&keys, chosen, -1),
key if key == ahead => step(&keys, chosen, 1),
key if key == back => step(&keys, from_back, -1),
key if key == ahead => step(&keys, from_ahead, 1),
"home" => step(&keys, keys.len() - 1, 1),
"end" => step(&keys, 0, -1),
_ => return,
};
cx.stop_propagation();
if to != chosen {
if Some(to) != chosen && !keys[to].disabled {
key_pick(to, window, cx);
}
})
+9 -4
View File
@@ -1,4 +1,5 @@
mod pages;
mod stale;
mod tabs;
use gpui::{
@@ -83,14 +84,18 @@ impl Render for Flow {
Choice::new("plan", "Plan"),
Choice::new("confirm", "Confirm"),
];
Wizard::new("flow", steps, self.step)
.on_step(move |to, _, cx| {
Wizard::new(
"flow",
steps,
self.step,
move |to, _, cx| {
stepper.update(cx, |view, cx| {
view.step = to;
cx.notify();
})
})
.on_finish(move |_, cx| finisher.update(cx, |view, _| view.done = true))
},
move |_, cx| finisher.update(cx, |view, _| view.done = true),
)
}
}
+25
View File
@@ -117,3 +117,28 @@ fn the_jump_field_holds_the_last_page_at_every_size(cx: &mut TestAppContext) {
);
}
}
/// Pages the owner has not caught up with: `page` of `pages`.
struct Behind(usize, usize);
impl Render for Behind {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().child(Pagination::new("behind", self.0, self.1).jump())
}
}
#[gpui::test]
fn a_page_past_the_last_or_no_pages_marks_none(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Behind(7, 3));
cx.run_until_parked();
assert!(cx.debug_bounds("pagination-page-3").is_some());
assert!(cx.debug_bounds("pagination-page-7").is_none());
let (_, cx) = cx.add_window_view(|_, _| Behind(1, 0));
cx.run_until_parked();
assert!(cx.debug_bounds("pagination-page-1").is_none());
assert!(
cx.debug_bounds("number-root").is_none(),
"no field to jump with"
);
}
+112
View File
@@ -0,0 +1,112 @@
use gpui::{Context, IntoElement, Render, TestAppContext, Window};
use super::setup;
use crate::{
forms::Choice,
navigation::{BackForwardNavigation, Breadcrumb, Crumb, Steps, Tabs, Wizard},
};
/// Tabs that may lose every tab, or keep only disabled ones under a gone choice.
struct TabHost {
empty: bool,
disabled: bool,
picked: bool,
}
impl Render for TabHost {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let host = cx.entity();
let tabs = match (self.empty, self.disabled) {
(true, _) => Vec::new(),
(false, true) => vec![
Choice::new("a", "A").disabled(),
Choice::new("b", "B").disabled(),
],
(false, false) => vec![Choice::new("a", "A")],
};
let selected = if self.disabled { "gone" } else { "a" };
Tabs::new("tabs", tabs, selected)
.on_change(move |_, _, cx| host.update(cx, |host, _| host.picked = true))
}
}
#[gpui::test]
fn an_arrow_on_tabs_that_all_closed_does_nothing(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| TabHost {
empty: false,
disabled: false,
picked: false,
});
cx.update(|window, cx| window.focus_next(cx));
host.update(cx, |host, cx| {
host.empty = true;
cx.notify();
});
cx.run_until_parked();
cx.simulate_keystrokes("right");
assert!(!host.read_with(cx, |host, _| host.picked));
}
#[gpui::test]
fn an_arrow_never_picks_a_disabled_tab(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| TabHost {
empty: false,
disabled: true,
picked: false,
});
cx.update(|window, cx| window.focus_next(cx));
cx.simulate_keystrokes("right");
assert!(!host.read_with(cx, |host, _| host.picked));
}
#[derive(Clone, Copy)]
enum Kind {
Steps,
Wizard,
History,
Crumb,
}
/// A list of two that drops its second while the place still names it.
struct Shrinking {
kind: Kind,
shortened: bool,
}
impl Render for Shrinking {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let mut rows = vec![Choice::new("a", "A"), Choice::new("b", "B")];
if self.shortened {
rows.pop();
}
match self.kind {
Kind::Steps => Steps::new("steps", rows, 1).into_any_element(),
Kind::Wizard => {
Wizard::new("wizard", rows, 1, |_, _, _| {}, |_, _| {}).into_any_element()
}
Kind::History => BackForwardNavigation::new("history", rows, 1).into_any_element(),
Kind::Crumb => {
Breadcrumb::new("crumb", [Crumb::new("b", "B").siblings(rows)]).into_any_element()
}
}
}
}
#[gpui::test]
fn a_place_past_a_shortened_list_marks_none(cx: &mut TestAppContext) {
setup(cx);
for kind in [Kind::Steps, Kind::Wizard, Kind::History, Kind::Crumb] {
let (host, cx) = cx.add_window_view(move |_, _| Shrinking {
kind,
shortened: false,
});
cx.run_until_parked();
host.update(cx, |host, cx| {
host.shortened = true;
cx.notify();
});
cx.run_until_parked();
}
}
+17
View File
@@ -163,3 +163,20 @@ fn a_right_click_names_its_tab_and_keeps_the_choice(cx: &mut TestAppContext) {
);
assert_eq!(chosen, "c");
}
#[gpui::test]
fn tabs_whose_choice_closed_mark_none_and_arrows_start_at_the_ends(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Strip {
chosen: "gone".into(),
});
cx.update(|window, cx| window.focus_next(cx));
cx.simulate_keystrokes("right");
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "a");
view.update(cx, |view, cx| {
view.chosen = "gone".into();
cx.notify();
});
cx.simulate_keystrokes("left");
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c");
}