T09b overlays: lightbox, spotlight and tour, floating toolbar, peek; images fill their box; overlays take focus back when its element leaves the tree
This commit is contained in:
18 files changed
+1754
-228
No files matched your search
@@ -0,0 +1,169 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnimationExt, AnyElement, App, Corner, ElementId, Entity, FontWeight, InteractiveElement,
|
||||
IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, anchored, canvas,
|
||||
deferred, div, point, prelude::*,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
forms::{Run, TextInput, surface},
|
||||
layout::Collapsible,
|
||||
motion,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
/// Tools that float over a text field's selection while it has one and holds focus. Its buttons leave focus in the field.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FloatingToolbar {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
tools: SmallVec<[AnyElement; 4]>,
|
||||
}
|
||||
|
||||
impl FloatingToolbar {
|
||||
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
tools: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for FloatingToolbar {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.tools.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FloatingToolbar {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let seen = window.use_keyed_state((self.id.clone(), "seen"), cx, |_, _| None);
|
||||
let input = self.field.read(cx);
|
||||
let selection = input.selection();
|
||||
let shown = !selection.is_empty() && input.focus().is_focused(window);
|
||||
let start = input.bounds_for(selection.start);
|
||||
let anchor = start.filter(|_| shown);
|
||||
let (field, from) = (self.field.clone(), selection.start);
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.child(canvas(
|
||||
|_, _, _| {},
|
||||
move |_, _, _, cx| {
|
||||
let now = field.read(cx).bounds_for(from);
|
||||
if now != start {
|
||||
log::debug!("floating toolbar: the selection moved to {now:?}");
|
||||
seen.update(cx, |seen, cx| {
|
||||
*seen = now;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
))
|
||||
.when_some(anchor, |host, anchor| {
|
||||
let lift = cx.theme().float_gap().to_pixels(window.rem_size());
|
||||
host.child(
|
||||
deferred(
|
||||
anchored()
|
||||
.position(anchor.origin - point(gpui::Pixels::ZERO, lift))
|
||||
.anchor(Corner::BottomLeft)
|
||||
.snap_to_window()
|
||||
.child(
|
||||
surface((self.id.clone(), "bar"), cx)
|
||||
.occlude()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_0p5()
|
||||
.p_1()
|
||||
.children(self.tools)
|
||||
.with_animation(
|
||||
(self.id.clone(), "in"),
|
||||
gpui::Animation::new(motion::duration(motion::FAST, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
|bar, t| bar.opacity(t).mb(motion::NUDGE * (1.0 - t)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.with_priority(1),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel that opens in the flow under a line, the way an editor peeks at a definition: a title bar with a close button over the owner's content.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Peek {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
open: bool,
|
||||
body: SmallVec<[AnyElement; 2]>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl Peek {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
open: bool,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
open,
|
||||
body: SmallVec::new(),
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Peek {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.body.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Peek {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let (id, close) = (self.id.clone(), self.on_close);
|
||||
Collapsible::new(self.id.clone(), self.open).child(
|
||||
div()
|
||||
.my_2()
|
||||
.border_l_2()
|
||||
.border_color(colors.accent)
|
||||
.bg(colors.surface)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.h(theme.control_height(ControlSize::Lg))
|
||||
.pl_3()
|
||||
.pr_1()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(self.title)
|
||||
.child(
|
||||
IconButton::new((self.id.clone(), "close"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close")
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("peek {id:?}: closed");
|
||||
close(window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().p_3().children(self.body)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, Div, ElementId, ImageSource, InteractiveElement, IntoElement,
|
||||
MouseButton, ObjectFit, ParentElement, Pixels, Point, RenderOnce, SharedString, Size, Stateful,
|
||||
StatefulInteractiveElement, Styled, Window, anchored, canvas, deferred, div, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::Run,
|
||||
motion,
|
||||
primitives::{FocusRing, FocusScope, Icon, IconName, Image, give_back, take_focus},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
|
||||
};
|
||||
|
||||
/// One picture in a lightbox, and the line under it.
|
||||
#[derive(Clone)]
|
||||
pub struct Slide {
|
||||
source: ImageSource,
|
||||
caption: SharedString,
|
||||
}
|
||||
|
||||
impl Slide {
|
||||
pub fn new(source: impl Into<ImageSource>, caption: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
source: source.into(),
|
||||
caption: caption.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
|
||||
/// A round button drawn for the dark layer: light icon, a faint fill on hover, dim when it has nowhere to go.
|
||||
fn media_button(
|
||||
id: (ElementId, &'static str),
|
||||
icon: IconName,
|
||||
enabled: bool,
|
||||
run: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let theme = cx.theme();
|
||||
let light = theme.colors.on_media;
|
||||
div()
|
||||
.id(id)
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(theme.control_height(ControlSize::Lg))
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.child(Icon::new(icon).size(IconSize::Md).color(if enabled {
|
||||
light
|
||||
} else {
|
||||
light.opacity(0.3)
|
||||
}))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.when(enabled, |button| {
|
||||
button
|
||||
.cursor_pointer()
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.hover(|style| style.bg(light.opacity(0.12)))
|
||||
.on_click(move |_, window, cx| run(window, cx))
|
||||
})
|
||||
}
|
||||
|
||||
/// Pictures over a dark layer, one at a time. Left and Right or the side buttons step through them; Escape, the close button or a press on the dark closes it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Lightbox {
|
||||
id: ElementId,
|
||||
slides: Vec<Slide>,
|
||||
at: usize,
|
||||
on_step: Option<OnStep>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl Lightbox {
|
||||
/// Render it while open; the owner keeps `at` and moves it in `on_step`.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
slides: impl IntoIterator<Item = Slide>,
|
||||
at: usize,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
let slides: Vec<Slide> = slides.into_iter().collect();
|
||||
assert!(
|
||||
at < slides.len(),
|
||||
"slide {at} is past the last of {}",
|
||||
slides.len()
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
slides,
|
||||
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 Lightbox {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (at, count) = (self.at, self.slides.len());
|
||||
let turn = motion::changes((self.id.clone(), "at"), at, window, cx);
|
||||
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
let area = window.use_keyed_state((self.id.clone(), "area"), cx, |_, _| {
|
||||
Size::<Pixels>::default()
|
||||
});
|
||||
let room = *area.read(cx);
|
||||
let close: Run = {
|
||||
let (id, on_close) = (self.id.clone(), self.on_close);
|
||||
Rc::new(move |window, cx| {
|
||||
log::info!("lightbox {id:?}: closed");
|
||||
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!("lightbox {id:?}: slide {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 side = |name: &'static str, icon: IconName, to: Option<usize>, cx: &App| {
|
||||
let step = step.clone();
|
||||
media_button(
|
||||
(self.id.clone(), name),
|
||||
icon,
|
||||
to.is_some(),
|
||||
move |window, cx| {
|
||||
if let Some(to) = to {
|
||||
step(to, window, cx);
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let slide = self.slides[at].clone();
|
||||
let picture = div()
|
||||
.size_full()
|
||||
.child(
|
||||
Image::new((self.id.clone(), format!("slide-{at}")), slide.source)
|
||||
.fit(ObjectFit::Contain)
|
||||
.w(room.width)
|
||||
.h(room.height),
|
||||
)
|
||||
.with_animation(
|
||||
(self.id.clone(), format!("turn-{turn}")),
|
||||
Animation::new(motion::duration(motion::BASE, cx))
|
||||
.with_easing(motion::ease_out_cubic),
|
||||
move |picture, t| {
|
||||
if turn == 0 {
|
||||
picture
|
||||
} else {
|
||||
picture.opacity(t)
|
||||
}
|
||||
},
|
||||
);
|
||||
let (keys, escape, press, button) = (step.clone(), close.clone(), close.clone(), close);
|
||||
let viewport = window.viewport_size();
|
||||
let stage = div()
|
||||
.id((self.id.clone(), "stage"))
|
||||
.w(viewport.width)
|
||||
.h(viewport.height)
|
||||
.bg(colors.media_backdrop)
|
||||
.text_color(colors.on_media)
|
||||
.occlude()
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| press(window, cx))
|
||||
.on_key_down(move |event, window, cx| {
|
||||
match (event.keystroke.key.as_str(), back, next) {
|
||||
("left", Some(to), _) | ("right", _, Some(to)) => keys(to, window, cx),
|
||||
("escape", _, _) => escape(window, cx),
|
||||
_ => return,
|
||||
}
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.child(
|
||||
FocusScope::new(&focus)
|
||||
.trap()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(div().flex().justify_end().p_3().child(media_button(
|
||||
(self.id.clone(), "close"),
|
||||
IconName::X,
|
||||
true,
|
||||
move |window, cx| button(window, cx),
|
||||
cx,
|
||||
)))
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.left_16()
|
||||
.right_16()
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
||||
cx.stop_propagation()
|
||||
})
|
||||
.child(picture)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
if area.read(cx) != &bounds.size {
|
||||
area.update(cx, |area, cx| {
|
||||
*area = bounds.size;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.left_3()
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(side("back", IconName::ChevronLeft, back, cx)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.right_3()
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(side("next", IconName::ChevronRight, next, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.p_5()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(slide.caption)
|
||||
.child(
|
||||
div()
|
||||
.text_color(colors.on_media.opacity(0.6))
|
||||
.child(format!("{} / {count}", at + 1)),
|
||||
),
|
||||
),
|
||||
);
|
||||
deferred(anchored().position(Point::default()).child(stage)).with_priority(1)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
mod dialog;
|
||||
mod dialogs;
|
||||
mod floats;
|
||||
mod hover;
|
||||
mod lightbox;
|
||||
mod popover;
|
||||
mod spotlight;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use dialog::{Close, Dialog};
|
||||
pub use dialogs::{AlertDialog, ConfirmDialog, PromptDialog};
|
||||
pub use floats::{FloatingToolbar, Peek};
|
||||
pub use hover::HoverCard;
|
||||
pub use lightbox::{Lightbox, Slide};
|
||||
pub use popover::Popover;
|
||||
pub use spotlight::{Spotlight, Tour, TourStep};
|
||||
@@ -0,0 +1,439 @@
|
||||
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, deferred, div, point, prelude::*, size,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{Run, float_height, surface},
|
||||
motion,
|
||||
primitives::{FocusScope, give_back, 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, _, cx| {
|
||||
if measure.read(cx).card != bounds.size.height {
|
||||
measure.update(cx, |lit, cx| {
|
||||
lit.card = bounds.size.height;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
);
|
||||
div()
|
||||
.child(
|
||||
deferred(
|
||||
anchored()
|
||||
.position(Point::default())
|
||||
.child(div().w(viewport.width).h(viewport.height).child(frame)),
|
||||
)
|
||||
.with_priority(1),
|
||||
)
|
||||
.child(float_height(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use std::path::Path;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||
KeyBinding, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div,
|
||||
point, px, size,
|
||||
};
|
||||
|
||||
use super::{click, press, settle};
|
||||
use crate::{
|
||||
buttons::{Button, IconButton},
|
||||
forms::{Input, TextInput},
|
||||
overlays::{FloatingToolbar, Lightbox, Peek, Slide, Spotlight, Tour, TourStep},
|
||||
primitives::{FocusNext, FocusScope, IconName},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Open {
|
||||
Lightbox,
|
||||
Spotlight,
|
||||
SpotField,
|
||||
Tour,
|
||||
Peek,
|
||||
}
|
||||
|
||||
/// Opens one guide at a time over a page with a target button, recording what happens.
|
||||
struct Guides {
|
||||
root: FocusHandle,
|
||||
before: FocusHandle,
|
||||
field: Entity<TextInput>,
|
||||
open: Option<Open>,
|
||||
at: usize,
|
||||
log: Vec<String>,
|
||||
lower: bool,
|
||||
}
|
||||
|
||||
fn target() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(90.0), px(28.0)))
|
||||
}
|
||||
|
||||
impl Render for Guides {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let note = |what: &'static str, cx: &mut Context<Self>| {
|
||||
let view = cx.entity();
|
||||
move |_: &mut Window, cx: &mut gpui::App| {
|
||||
view.update(cx, |guides, _| guides.log.push(what.into()))
|
||||
}
|
||||
};
|
||||
let (shut, stepped) = (cx.entity(), cx.entity());
|
||||
let shut = move |_: &mut Window, cx: &mut gpui::App| {
|
||||
shut.update(cx, |guides, cx| {
|
||||
guides.open = None;
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
let step = move |to: usize, _: &mut Window, cx: &mut gpui::App| {
|
||||
stepped.update(cx, |guides, cx| {
|
||||
guides.at = to;
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
let slides = ["a", "b", "c"].map(|name| Slide::new(Path::new("/missing/ely.png"), name));
|
||||
let steps = [
|
||||
TourStep::new(target(), "Here", "The first stop"),
|
||||
TourStep::new(
|
||||
Bounds::new(point(px(200.0), px(300.0)), size(px(90.0), px(28.0))),
|
||||
"There",
|
||||
"The second stop",
|
||||
),
|
||||
];
|
||||
let (pressed, bold) = (note("target", cx), note("bold", cx));
|
||||
let overlay = self.open.map(|open| match open {
|
||||
Open::Lightbox => Lightbox::new("light", slides, self.at, shut)
|
||||
.on_step(step)
|
||||
.into_any_element(),
|
||||
Open::Spotlight => Spotlight::new("spot", steps[0].clone(), shut).into_any_element(),
|
||||
Open::SpotField => Spotlight::new(
|
||||
"spot",
|
||||
TourStep::new(
|
||||
Bounds::new(point(px(0.0), px(188.0)), size(px(300.0), px(28.0))),
|
||||
"Type here",
|
||||
"The field takes focus",
|
||||
),
|
||||
shut,
|
||||
)
|
||||
.into_any_element(),
|
||||
Open::Tour => Tour::new("tour", steps, self.at, shut)
|
||||
.on_step(step)
|
||||
.into_any_element(),
|
||||
Open::Peek => Peek::new("peek", "Definition", true, shut)
|
||||
.child("fn peek()")
|
||||
.into_any_element(),
|
||||
});
|
||||
FocusScope::new(&self.root)
|
||||
.size_full()
|
||||
.child(
|
||||
Button::new("target", "Target").on_click(move |_, window, cx| pressed(window, cx)),
|
||||
)
|
||||
.child(div().id("before").track_focus(&self.before))
|
||||
.child(div().h(px(if self.lower { 260.0 } else { 160.0 })))
|
||||
.child(
|
||||
div().w(px(300.0)).child(Input::new(&self.field)).child(
|
||||
FloatingToolbar::new("tools", &self.field).child(
|
||||
IconButton::new("bold", IconName::Bold)
|
||||
.on_click(move |_, window, cx| bold(window, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.children(overlay)
|
||||
}
|
||||
}
|
||||
|
||||
fn guides(cx: &mut TestAppContext) -> (Entity<Guides>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
crate::forms::bind_keys(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|window, cx| Guides {
|
||||
root: cx.focus_handle(),
|
||||
before: cx.focus_handle(),
|
||||
field: cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx);
|
||||
input.set_text("hello world", cx);
|
||||
input
|
||||
}),
|
||||
open: None,
|
||||
at: 0,
|
||||
log: Vec::new(),
|
||||
lower: false,
|
||||
});
|
||||
settle(cx);
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
fn open(view: &Entity<Guides>, which: Open, cx: &mut VisualTestContext) {
|
||||
let before = view.read_with(cx, |guides, _| guides.before.clone());
|
||||
cx.update(|window, _| window.focus(&before));
|
||||
view.update(cx, |guides, cx| {
|
||||
guides.open = Some(which);
|
||||
guides.at = 0;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn state(view: &Entity<Guides>, cx: &mut VisualTestContext) -> (Option<Open>, usize, Vec<String>) {
|
||||
view.read_with(cx, |guides, _| (guides.open, guides.at, guides.log.clone()))
|
||||
}
|
||||
|
||||
fn before_focused(view: &Entity<Guides>, cx: &mut VisualTestContext) -> bool {
|
||||
let before = view.read_with(cx, |guides, _| guides.before.clone());
|
||||
cx.update(|window, _| before.is_focused(window))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_lightbox_steps_with_arrows_and_escape_hands_focus_back(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Lightbox, cx);
|
||||
for key in ["right", "right", "right", "left"] {
|
||||
press(key, cx);
|
||||
}
|
||||
assert_eq!(state(&view, cx).1, 1);
|
||||
press("escape", cx);
|
||||
assert!(state(&view, cx).0.is_none());
|
||||
assert!(before_focused(&view, cx));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_dark_closes_the_lightbox_and_the_picture_does_not(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Lightbox, cx);
|
||||
let middle = cx.update(|window, _| window.viewport_size());
|
||||
click(
|
||||
f32::from(middle.width) / 2.0,
|
||||
f32::from(middle.height) / 2.0,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(state(&view, cx).0, Some(Open::Lightbox));
|
||||
click(4.0, f32::from(middle.height) - 4.0, cx);
|
||||
assert!(state(&view, cx).0.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_tour_walks_by_arrows_and_buttons_and_done_closes(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Tour, cx);
|
||||
press("right", cx);
|
||||
assert_eq!(state(&view, cx).1, 1);
|
||||
press("left", cx);
|
||||
assert_eq!(state(&view, cx).1, 0);
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(state(&view, cx).1, 1, "Next moved on");
|
||||
press("enter", cx);
|
||||
assert!(
|
||||
state(&view, cx).0.is_none(),
|
||||
"focus stayed on the button, now Done"
|
||||
);
|
||||
assert!(before_focused(&view, cx));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_spotlight_passes_presses_through_its_hole_only(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Spotlight, cx);
|
||||
click(40.0, 14.0, cx);
|
||||
assert_eq!(
|
||||
state(&view, cx).2,
|
||||
["target"],
|
||||
"the lit target took the press"
|
||||
);
|
||||
click(40.0, 200.0, cx);
|
||||
assert_eq!(state(&view, cx).0, Some(Open::Spotlight), "the dim held");
|
||||
press("escape", cx);
|
||||
assert!(state(&view, cx).0.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_toolbar_floats_over_a_selection_only(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
let field = view.read_with(cx, |guides, _| guides.field.clone());
|
||||
let focus = field.read_with(cx, |input, _| input.focus().clone());
|
||||
cx.update(|window, _| window.focus(&focus));
|
||||
settle(cx);
|
||||
let start = field
|
||||
.read_with(cx, |input, _| input.bounds_for(0))
|
||||
.expect("the field laid out");
|
||||
let bold = (
|
||||
f32::from(start.left()) + 16.0,
|
||||
f32::from(start.top()) - 22.0,
|
||||
);
|
||||
click(bold.0, bold.1, cx);
|
||||
assert!(state(&view, cx).2.is_empty(), "no selection, no toolbar");
|
||||
cx.update(|window, _| window.focus(&focus));
|
||||
press("secondary-a", cx);
|
||||
click(bold.0, bold.1, cx);
|
||||
assert_eq!(state(&view, cx).2, ["bold"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_peek_closes_from_its_button(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Peek, cx);
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
for _ in 0..3 {
|
||||
window.focus_next();
|
||||
}
|
||||
});
|
||||
press("enter", cx);
|
||||
assert!(state(&view, cx).0.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn escape_closes_a_spotlight_while_its_target_holds_focus(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::SpotField, cx);
|
||||
click(40.0, 200.0, cx);
|
||||
press("escape", cx);
|
||||
assert!(state(&view, cx).0.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_lightbox_keeps_focus_when_its_button_disables(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Lightbox, cx);
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(state(&view, cx).1, 2, "Next walked to the end");
|
||||
press("escape", cx);
|
||||
assert!(state(&view, cx).0.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_on_a_dead_arrow_keeps_the_lightbox(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Lightbox, cx);
|
||||
let screen = cx.update(|window, _| window.viewport_size());
|
||||
click(28.0, f32::from(screen.height) / 2.0, cx);
|
||||
assert_eq!(state(&view, cx).0, Some(Open::Lightbox));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_toolbar_follows_its_field(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
let field = view.read_with(cx, |guides, _| guides.field.clone());
|
||||
let focus = field.read_with(cx, |input, _| input.focus().clone());
|
||||
cx.update(|window, _| window.focus(&focus));
|
||||
press("secondary-a", cx);
|
||||
view.update(cx, |guides, cx| {
|
||||
guides.lower = true;
|
||||
cx.notify();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let start = field
|
||||
.read_with(cx, |input, _| input.bounds_for(0))
|
||||
.expect("the field laid out");
|
||||
click(
|
||||
f32::from(start.left()) + 16.0,
|
||||
f32::from(start.top()) - 22.0,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(state(&view, cx).2, ["bold"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn back_to_the_first_step_keeps_the_tour_keys(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Tour, cx);
|
||||
press("right", cx);
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(state(&view, cx).1, 0, "Back went to the first step");
|
||||
press("right", cx);
|
||||
assert_eq!(state(&view, cx).1, 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn left_from_back_keeps_the_tour_keys(cx: &mut TestAppContext) {
|
||||
let (view, cx) = guides(cx);
|
||||
open(&view, Open::Tour, cx);
|
||||
press("right", cx);
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("left", cx);
|
||||
assert_eq!(state(&view, cx).1, 0);
|
||||
press("right", cx);
|
||||
assert_eq!(state(&view, cx).1, 1);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use gpui::{
|
||||
VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
|
||||
mod guides;
|
||||
|
||||
use super::{AlertDialog, ConfirmDialog, Dialog, HoverCard, Popover, PromptDialog};
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
@@ -130,13 +132,13 @@ impl Render for Stage {
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
pub(super) fn settle(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
pub(super) fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
@@ -145,7 +147,7 @@ fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn click(x: f32, y: f32, cx: &mut VisualTestContext) {
|
||||
pub(super) fn click(x: f32, y: f32, cx: &mut VisualTestContext) {
|
||||
let at = point(px(x), px(y));
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
+18
-5
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, StyleRefinement, Styled, Window, actions, div,
|
||||
ParentElement, RenderOnce, StyleRefinement, Styled, Subscription, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
@@ -100,18 +100,27 @@ pub(crate) struct Takeover {
|
||||
pub focus: FocusHandle,
|
||||
previous: Option<FocusHandle>,
|
||||
taken: bool,
|
||||
returned: bool,
|
||||
_lost: Subscription,
|
||||
}
|
||||
|
||||
/// Keyed focus for an overlay; focused on its first render.
|
||||
/// Keyed focus for an overlay; focused on its first render, and again whenever the focused element leaves the tree.
|
||||
pub(crate) fn take_focus(
|
||||
key: impl Into<gpui::ElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Takeover> {
|
||||
let state = window.use_keyed_state(key, cx, |_, cx| Takeover {
|
||||
let state = window.use_keyed_state(key, cx, |window, cx| Takeover {
|
||||
focus: cx.focus_handle(),
|
||||
previous: None,
|
||||
taken: false,
|
||||
returned: false,
|
||||
_lost: cx.on_focus_lost(window, |takeover: &mut Takeover, window, _| {
|
||||
if !takeover.returned {
|
||||
log::info!("focus: its element left the tree; the overlay takes it back");
|
||||
window.focus(&takeover.focus);
|
||||
}
|
||||
}),
|
||||
});
|
||||
if !state.read(cx).taken {
|
||||
let previous = window.focused(cx);
|
||||
@@ -125,8 +134,12 @@ pub(crate) fn take_focus(
|
||||
}
|
||||
|
||||
/// Returns focus to whatever held it before the overlay opened.
|
||||
pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &App) {
|
||||
match state.read(cx).previous.clone() {
|
||||
pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &mut App) {
|
||||
let previous = state.update(cx, |takeover, _| {
|
||||
takeover.returned = true;
|
||||
takeover.previous.clone()
|
||||
});
|
||||
match previous {
|
||||
Some(previous) => {
|
||||
window.focus(&previous);
|
||||
log::info!("focus: handed back after an overlay");
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
|
||||
const PULSE: Duration = Duration::from_millis(1600);
|
||||
|
||||
/// Themed `img()`; a `Cover` crop keeps square corners.
|
||||
/// Themed `img()` that fills the box it is given; a `Cover` crop keeps square corners.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Image {
|
||||
id: ElementId,
|
||||
@@ -82,6 +82,8 @@ impl RenderOnce for Image {
|
||||
};
|
||||
let mut picture = img(source)
|
||||
.id(self.id)
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.size_full()
|
||||
.object_fit(self.fit)
|
||||
.with_loading(move || {
|
||||
@@ -100,6 +102,6 @@ impl RenderOnce for Image {
|
||||
})
|
||||
.with_fallback(move || mark(IconName::ImageOff).into_any_element());
|
||||
picture.style().corner_radii = radii;
|
||||
self.base.overflow_hidden().child(picture)
|
||||
self.base.relative().overflow_hidden().child(picture)
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ mixable!(
|
||||
danger_subtle: Hsla,
|
||||
info_subtle: Hsla,
|
||||
backdrop: Hsla,
|
||||
media_backdrop: Hsla,
|
||||
shadow: Hsla,
|
||||
tooltip_bg: Hsla,
|
||||
tooltip_fg: Hsla,
|
||||
@@ -144,6 +145,7 @@ impl Palette {
|
||||
danger_subtle: c(0xffefec),
|
||||
info_subtle: c(0xebf4ff),
|
||||
backdrop: rgba(0x18161352).into(),
|
||||
media_backdrop: rgba(0x0e0d0bf8).into(),
|
||||
shadow: c(0x181613),
|
||||
tooltip_bg: c(0x181613),
|
||||
tooltip_fg: c(0xfcfaf7),
|
||||
@@ -209,6 +211,7 @@ impl Palette {
|
||||
danger_subtle: c(0x3a1d1b),
|
||||
info_subtle: c(0x19273a),
|
||||
backdrop: rgba(0x00000080).into(),
|
||||
media_backdrop: rgba(0x000000f8).into(),
|
||||
shadow: c(0x000000),
|
||||
tooltip_bg: c(0x373533),
|
||||
tooltip_fg: c(0xf3f1f0),
|
||||
|
||||
@@ -233,6 +233,11 @@ impl Theme {
|
||||
(px_to_rems(84.0), px_to_rems(40.0))
|
||||
}
|
||||
|
||||
/// Room between a box and what floats by it: a spotlight's ring, a selection's toolbar.
|
||||
pub fn float_gap(&self) -> Rems {
|
||||
px_to_rems(6.0)
|
||||
}
|
||||
|
||||
/// Width of a dialog's card.
|
||||
pub fn dialog_width(&self) -> Rems {
|
||||
px_to_rems(440.0)
|
||||
|
||||
Reference in new issue
Block a user