A raise inside another was namespaced by its place in the layer's queue, so one that came before it moved its id path and rebuilt its keyed state and focus: an open date popup would lose its month when a tip appeared. raise now takes its owner's id and draws under it, and float and float_height pass the owner's id on from every call site. A test holds a focused box in a raise while another comes before it.
449 lines
14 KiB
Rust
449 lines
14 KiB
Rust
use std::{cell::RefCell, rc::Rc};
|
|
|
|
use gpui::{
|
|
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, FontWeight, InteractiveElement,
|
|
IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, Subscription,
|
|
Window, anchored, canvas, div, point, prelude::*, size,
|
|
};
|
|
|
|
use crate::{
|
|
buttons::{Button, ButtonVariant},
|
|
forms::{Run, float_height, surface},
|
|
motion,
|
|
primitives::{FocusScope, give_back, raise, take_focus},
|
|
theme::{ActiveTheme, Radius, TextSize},
|
|
};
|
|
|
|
/// One stop of a tour: the box to light, a title and a line.
|
|
#[derive(Clone, Debug)]
|
|
pub struct TourStep {
|
|
target: Bounds<Pixels>,
|
|
title: SharedString,
|
|
body: SharedString,
|
|
}
|
|
|
|
impl TourStep {
|
|
/// `target` is window coordinates, as `primitives::Measure` reports them.
|
|
pub fn new(
|
|
target: Bounds<Pixels>,
|
|
title: impl Into<SharedString>,
|
|
body: impl Into<SharedString>,
|
|
) -> Self {
|
|
Self {
|
|
target,
|
|
title: title.into(),
|
|
body: body.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Escape from anywhere in the window, the lit target included, closes through the latest close.
|
|
struct Watch {
|
|
close: Rc<RefCell<Run>>,
|
|
_escape: Subscription,
|
|
}
|
|
|
|
/// Where the lit box glides from, and the card's measured height.
|
|
#[derive(Default)]
|
|
struct Lit {
|
|
from: Bounds<Pixels>,
|
|
to: Bounds<Pixels>,
|
|
card: Pixels,
|
|
}
|
|
|
|
fn lerp(from: Bounds<Pixels>, to: Bounds<Pixels>, t: f32) -> Bounds<Pixels> {
|
|
let mix = |a: Pixels, b: Pixels| a + (b - a) * t;
|
|
Bounds::new(
|
|
point(
|
|
mix(from.origin.x, to.origin.x),
|
|
mix(from.origin.y, to.origin.y),
|
|
),
|
|
size(
|
|
mix(from.size.width, to.size.width),
|
|
mix(from.size.height, to.size.height),
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Dims the window around the lit box, rings it, and floats `card` beside it. The box glides when the target moves.
|
|
fn light(
|
|
id: &ElementId,
|
|
target: Bounds<Pixels>,
|
|
card: AnyElement,
|
|
close: Run,
|
|
keys: impl Fn(&str, &mut Window, &mut App) -> bool + 'static,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> AnyElement {
|
|
let gap = cx.theme().float_gap().to_pixels(window.rem_size());
|
|
let target = target.dilate(gap);
|
|
let state = window.use_keyed_state((id.clone(), "lit"), cx, |_, _| Lit {
|
|
from: target,
|
|
to: target,
|
|
card: Pixels::ZERO,
|
|
});
|
|
if state.read(cx).to != target {
|
|
state.update(cx, |lit, _| {
|
|
lit.from = lit.to;
|
|
lit.to = target;
|
|
});
|
|
}
|
|
let (from, card_height) = (state.read(cx).from, state.read(cx).card);
|
|
let turn = motion::changes((id.clone(), "target"), target, window, cx);
|
|
let theme = cx.theme();
|
|
let viewport = window.viewport_size();
|
|
let colors = theme.colors.clone();
|
|
let radius = theme.radius(Radius::Md);
|
|
let frame = div().size_full().with_animation(
|
|
(id.clone(), format!("glide-{turn}")),
|
|
Animation::new(motion::duration(motion::SLOW, cx)).with_easing(motion::ease_in_out_cubic),
|
|
move |frame, t| {
|
|
let lit = if turn == 0 {
|
|
target
|
|
} else {
|
|
lerp(from, target, t)
|
|
};
|
|
let dim = |left: Pixels, top: Pixels, width: Pixels, height: Pixels| {
|
|
div()
|
|
.absolute()
|
|
.left(left)
|
|
.top(top)
|
|
.w(width.max(Pixels::ZERO))
|
|
.h(height.max(Pixels::ZERO))
|
|
.bg(colors.backdrop)
|
|
.occlude()
|
|
};
|
|
frame
|
|
.child(dim(Pixels::ZERO, Pixels::ZERO, viewport.width, lit.top()))
|
|
.child(dim(
|
|
Pixels::ZERO,
|
|
lit.bottom(),
|
|
viewport.width,
|
|
viewport.height - lit.bottom(),
|
|
))
|
|
.child(dim(Pixels::ZERO, lit.top(), lit.left(), lit.size.height))
|
|
.child(dim(
|
|
lit.right(),
|
|
lit.top(),
|
|
viewport.width - lit.right(),
|
|
lit.size.height,
|
|
))
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.left(lit.left())
|
|
.top(lit.top())
|
|
.w(lit.size.width)
|
|
.h(lit.size.height)
|
|
.rounded(radius)
|
|
.border_2()
|
|
.border_color(colors.accent),
|
|
)
|
|
},
|
|
);
|
|
let measure = state.clone();
|
|
let watch = window.use_keyed_state((id.clone(), "escape"), cx, |window, cx| {
|
|
let latest = Rc::new(RefCell::new(close.clone()));
|
|
let (home, current) = (window.window_handle(), latest.clone());
|
|
let _escape = cx.intercept_keystrokes(move |event, window, cx| {
|
|
let stroke = &event.keystroke;
|
|
if window.window_handle() == home
|
|
&& stroke.key == "escape"
|
|
&& !stroke.modifiers.modified()
|
|
{
|
|
cx.stop_propagation();
|
|
let close = current.borrow().clone();
|
|
close(window, cx);
|
|
}
|
|
});
|
|
Watch {
|
|
close: latest,
|
|
_escape,
|
|
}
|
|
});
|
|
*watch.read(cx).close.borrow_mut() = close;
|
|
let card = div()
|
|
.id((id.clone(), "card"))
|
|
.relative()
|
|
.occlude()
|
|
.on_key_down(move |event, window, cx| {
|
|
if keys(event.keystroke.key.as_str(), window, cx) {
|
|
cx.stop_propagation();
|
|
}
|
|
})
|
|
.child(card)
|
|
.child(
|
|
canvas(
|
|
move |bounds, window, cx| {
|
|
if measure.read(cx).card != bounds.size.height {
|
|
measure.update(cx, |lit, cx| {
|
|
lit.card = bounds.size.height;
|
|
cx.notify();
|
|
window.request_animation_frame();
|
|
});
|
|
}
|
|
},
|
|
|_, _, _, _| {},
|
|
)
|
|
.absolute()
|
|
.top_0()
|
|
.left_0()
|
|
.size_full(),
|
|
);
|
|
div()
|
|
.child(
|
|
raise(
|
|
(id.clone(), "raised"),
|
|
anchored()
|
|
.position(Point::default())
|
|
.child(div().w(viewport.width).h(viewport.height).child(frame)),
|
|
)
|
|
.with_priority(1),
|
|
)
|
|
.child(float_height(
|
|
(id.clone(), "card"),
|
|
target,
|
|
card_height,
|
|
card,
|
|
window,
|
|
cx,
|
|
))
|
|
.into_any_element()
|
|
}
|
|
|
|
/// The card's words: a title, then a line.
|
|
fn words(title: SharedString, body: SharedString, cx: &App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.child(div().font_weight(FontWeight::SEMIBOLD).child(title))
|
|
.child(
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.text_color(theme.colors.fg_muted)
|
|
.child(body),
|
|
)
|
|
}
|
|
|
|
/// Dims the window around one box and sets a card beside it: a title, a line and one button. Escape or the button closes it.
|
|
#[derive(IntoElement)]
|
|
pub struct Spotlight {
|
|
id: ElementId,
|
|
step: TourStep,
|
|
label: SharedString,
|
|
on_close: Run,
|
|
}
|
|
|
|
impl Spotlight {
|
|
pub fn new(
|
|
id: impl Into<ElementId>,
|
|
step: TourStep,
|
|
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
step,
|
|
label: "Got it".into(),
|
|
on_close: Rc::new(on_close),
|
|
}
|
|
}
|
|
|
|
/// The button's label, Got it unless set.
|
|
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
|
self.label = label.into();
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for Spotlight {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
|
|
let focus = takeover.read(cx).focus.clone();
|
|
let close: Run = {
|
|
let (id, on_close) = (self.id.clone(), self.on_close);
|
|
Rc::new(move |window, cx| {
|
|
log::info!("spotlight {id:?}: closed");
|
|
give_back(&takeover, window, cx);
|
|
on_close(window, cx)
|
|
})
|
|
};
|
|
let button = close.clone();
|
|
let card = FocusScope::new(&focus).trap().child(
|
|
surface((self.id.clone(), "surface"), cx)
|
|
.p_4()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_3()
|
|
.max_w(cx.theme().dialog_width())
|
|
.child(words(self.step.title, self.step.body, cx))
|
|
.child(
|
|
div().flex().justify_end().child(
|
|
Button::new((self.id.clone(), "done"), self.label)
|
|
.primary()
|
|
.on_click(move |_, window, cx| button(window, cx)),
|
|
),
|
|
),
|
|
);
|
|
light(
|
|
&self.id,
|
|
self.step.target,
|
|
card.into_any_element(),
|
|
close,
|
|
|_, _, _| false,
|
|
window,
|
|
cx,
|
|
)
|
|
}
|
|
}
|
|
|
|
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
|
|
|
/// Spotlights one step after another: Back, Next and Done, the count, and Skip. Left and Right step too. The lit box glides between targets.
|
|
#[derive(IntoElement)]
|
|
pub struct Tour {
|
|
id: ElementId,
|
|
steps: Vec<TourStep>,
|
|
at: usize,
|
|
on_step: Option<OnStep>,
|
|
on_close: Run,
|
|
}
|
|
|
|
impl Tour {
|
|
/// Render it while open; the owner keeps `at` and moves it in `on_step`. Done and Skip both close.
|
|
pub fn new(
|
|
id: impl Into<ElementId>,
|
|
steps: impl IntoIterator<Item = TourStep>,
|
|
at: usize,
|
|
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
let steps: Vec<TourStep> = steps.into_iter().collect();
|
|
assert!(
|
|
at < steps.len(),
|
|
"step {at} is past the last of {}",
|
|
steps.len()
|
|
);
|
|
Self {
|
|
id: id.into(),
|
|
steps,
|
|
at,
|
|
on_step: None,
|
|
on_close: Rc::new(on_close),
|
|
}
|
|
}
|
|
|
|
pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
|
self.on_step = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for Tour {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let (at, count) = (self.at, self.steps.len());
|
|
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
|
|
let focus = takeover.read(cx).focus.clone();
|
|
let close: Run = {
|
|
let (id, on_close) = (self.id.clone(), self.on_close);
|
|
Rc::new(move |window, cx| {
|
|
log::info!("tour {id:?}: closed at step {at}");
|
|
give_back(&takeover, window, cx);
|
|
on_close(window, cx)
|
|
})
|
|
};
|
|
let step: OnStep = {
|
|
let (id, on_step) = (self.id.clone(), self.on_step);
|
|
Rc::new(move |to, window, cx| {
|
|
log::info!("tour {id:?}: step {to}");
|
|
if let Some(on_step) = &on_step {
|
|
on_step(to, window, cx);
|
|
}
|
|
})
|
|
};
|
|
let (back, next) = (at.checked_sub(1), (at + 1 < count).then_some(at + 1));
|
|
let current = self.steps[at].clone();
|
|
let turn = motion::changes((self.id.clone(), "at"), at, window, cx);
|
|
let theme = cx.theme();
|
|
let (skip, done, back_step, next_step, keyed) = (
|
|
close.clone(),
|
|
close.clone(),
|
|
step.clone(),
|
|
step.clone(),
|
|
step,
|
|
);
|
|
let footer = div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.text_size(theme.text_size(TextSize::Xs))
|
|
.text_color(theme.colors.fg_subtle)
|
|
.child(format!("{} of {count}", at + 1)),
|
|
)
|
|
.child(
|
|
Button::new((self.id.clone(), "skip"), "Skip")
|
|
.variant(ButtonVariant::Ghost)
|
|
.on_click(move |_, window, cx| skip(window, cx)),
|
|
)
|
|
.when_some(back, |row, to| {
|
|
row.child(
|
|
Button::new((self.id.clone(), "back"), "Back")
|
|
.on_click(move |_, window, cx| back_step(to, window, cx)),
|
|
)
|
|
})
|
|
.child(
|
|
Button::new(
|
|
(self.id.clone(), "primary"),
|
|
if next.is_some() { "Next" } else { "Done" },
|
|
)
|
|
.primary()
|
|
.on_click(move |_, window, cx| match next {
|
|
Some(to) => next_step(to, window, cx),
|
|
None => done(window, cx),
|
|
}),
|
|
);
|
|
let card = FocusScope::new(&focus).trap().child(
|
|
surface((self.id.clone(), "surface"), cx)
|
|
.p_4()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_4()
|
|
.max_w(theme.dialog_width())
|
|
.child(
|
|
div()
|
|
.child(words(current.title, current.body, cx))
|
|
.with_animation(
|
|
(self.id.clone(), format!("words-{turn}")),
|
|
Animation::new(motion::duration(motion::BASE, cx))
|
|
.with_easing(motion::ease_out_cubic),
|
|
move |words, t| if turn == 0 { words } else { words.opacity(t) },
|
|
),
|
|
)
|
|
.child(footer),
|
|
);
|
|
let keys = move |key: &str, window: &mut Window, cx: &mut App| {
|
|
let to = match key {
|
|
"left" => back,
|
|
"right" => next,
|
|
_ => return false,
|
|
};
|
|
if let Some(to) = to {
|
|
keyed(to, window, cx);
|
|
}
|
|
true
|
|
};
|
|
light(
|
|
&self.id,
|
|
current.target,
|
|
card.into_any_element(),
|
|
close,
|
|
keys,
|
|
window,
|
|
cx,
|
|
)
|
|
}
|
|
}
|