Onboarding · Help: help panel, cheatsheet, what's new, feedback, support, inline help
HelpPanel searches articles and opens one, its body in Markdown, with a way back; focus follows to All articles and back to the search, a link to help:<key> opens another, and Contact support and Keyboard shortcuts wait at its foot. KeyboardShortcutCheatsheet puts settings::KeyboardShortcutsList in a dialog with its search focused; WhatsNewDialog puts data_display::Changelog in one with Got it. FeedbackWidget asks how it is going with four faces and a note, and thanks. ContactSupport sends a topic and a message, spins while the owner sends, and empties the message with focus back in it. InlineHelp is a quiet line with Learn more. HelpTooltip moves to primitives, where FormLabel's help mark shows it; ToggleGroup keys its toggles by its own id. EmptyStateWithAction points to feedback::EmptyState. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a066179699
commit
5f5e2f73e1
20 files changed
+1662
-122
No files matched your search
@@ -275,7 +275,14 @@ impl RenderOnce for ToggleGroup {
|
||||
change(&next, window, cx);
|
||||
}
|
||||
});
|
||||
toggle_face(("toggle", ix).into(), item, on, self.size, press, cx)
|
||||
toggle_face(
|
||||
(self.id.clone(), format!("toggle-{ix}")).into(),
|
||||
item,
|
||||
on,
|
||||
self.size,
|
||||
press,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
div()
|
||||
|
||||
+3
-13
@@ -2,15 +2,14 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
prelude::*,
|
||||
ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::{options::Run, text::Submit};
|
||||
use crate::{
|
||||
motion,
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
primitives::{HelpTooltip, Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
typography::{Caption, Heading, Label},
|
||||
};
|
||||
@@ -159,16 +158,7 @@ impl RenderOnce for FormLabel {
|
||||
label.child(div().text_color(colors.danger).child("*"))
|
||||
})
|
||||
.when_some(self.help, |label, help| {
|
||||
label.child(
|
||||
div()
|
||||
.id((self.id, "help"))
|
||||
.child(
|
||||
Icon::new(IconName::CircleHelp)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.tooltip(Tooltip::text(help)),
|
||||
)
|
||||
label.child(HelpTooltip::new((self.id, "help"), help))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
data_display::{Changelog, Release},
|
||||
forms::{Run, TextInput},
|
||||
overlays::Dialog,
|
||||
settings::{KeyboardShortcutsList, Shortcut},
|
||||
};
|
||||
|
||||
/// Every shortcut in a dialog: settings::KeyboardShortcutsList with its search, which takes focus as the dialog opens. Escape closes it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct KeyboardShortcutCheatsheet {
|
||||
id: ElementId,
|
||||
shortcuts: Vec<Shortcut>,
|
||||
search: Entity<TextInput>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl KeyboardShortcutCheatsheet {
|
||||
/// Render it while open; `search` is the find field, which the owner keeps.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
shortcuts: impl IntoIterator<Item = Shortcut>,
|
||||
search: &Entity<TextInput>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
shortcuts: shortcuts.into_iter().collect(),
|
||||
search: search.clone(),
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for KeyboardShortcutCheatsheet {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let first = self.search.read(cx).focus().clone();
|
||||
let on_close = self.on_close;
|
||||
Dialog::new(self.id, "Keyboard shortcuts", move |window, cx| {
|
||||
log::info!("shortcut cheatsheet: closed");
|
||||
on_close(window, cx)
|
||||
})
|
||||
.focus_first(first)
|
||||
.child(KeyboardShortcutsList::new(self.shortcuts, &self.search))
|
||||
}
|
||||
}
|
||||
|
||||
/// What changed, in a dialog: the releases on data_display::Changelog, newest first, then Got it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WhatsNewDialog {
|
||||
id: ElementId,
|
||||
releases: Vec<Release>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl WhatsNewDialog {
|
||||
/// Render it while open; `on_close` runs on Got it, Escape or a press on the scrim.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
on_close: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
releases: Vec::new(),
|
||||
on_close: Rc::new(on_close),
|
||||
}
|
||||
}
|
||||
|
||||
/// A release, newest first.
|
||||
pub fn release(mut self, release: Release) -> Self {
|
||||
self.releases.push(release);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WhatsNewDialog {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
assert!(
|
||||
!self.releases.is_empty(),
|
||||
"what's new {id:?} has no release"
|
||||
);
|
||||
let changelog = self
|
||||
.releases
|
||||
.into_iter()
|
||||
.fold(Changelog::new(), Changelog::release);
|
||||
let on_close = self.on_close;
|
||||
let done = (id.clone(), "done");
|
||||
Dialog::new(id, "What's new", move |window, cx| {
|
||||
log::info!("what's new: closed");
|
||||
on_close(window, cx)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "whats-new-releases".into())
|
||||
.child(changelog),
|
||||
)
|
||||
.action(move |close| {
|
||||
div().debug_selector(|| "whats-new-done".into()).child(
|
||||
Button::new(done, "Got it")
|
||||
.primary()
|
||||
.on_click(move |_, window, cx| close(window, cx)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, ToggleGroup, ToggleItem},
|
||||
forms::{Input, TextInput},
|
||||
overlays::Popover,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
|
||||
};
|
||||
|
||||
/// How it is going, from bad to great.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Sentiment {
|
||||
Bad,
|
||||
Okay,
|
||||
Good,
|
||||
Great,
|
||||
}
|
||||
|
||||
impl Sentiment {
|
||||
const ALL: [Sentiment; 4] = [Self::Bad, Self::Okay, Self::Good, Self::Great];
|
||||
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bad => "bad",
|
||||
Self::Okay => "okay",
|
||||
Self::Good => "good",
|
||||
Self::Great => "great",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(self) -> IconName {
|
||||
match self {
|
||||
Self::Bad => IconName::Frown,
|
||||
Self::Okay => IconName::Meh,
|
||||
Self::Good => IconName::Smile,
|
||||
Self::Great => IconName::Laugh,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bad => "Not good",
|
||||
Self::Okay => "Okay",
|
||||
Self::Good => "Good",
|
||||
Self::Great => "Great",
|
||||
}
|
||||
}
|
||||
|
||||
fn of(key: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|each| each.key() == key)
|
||||
.unwrap_or_else(|| panic!("no sentiment {key}"))
|
||||
}
|
||||
}
|
||||
|
||||
type OnFeedback = Rc<dyn Fn(Sentiment, &str, &mut Window, &mut App)>;
|
||||
|
||||
/// What the form holds while open.
|
||||
#[derive(Default)]
|
||||
struct Draft {
|
||||
sentiment: Option<Sentiment>,
|
||||
sent: bool,
|
||||
}
|
||||
|
||||
/// A Feedback button that opens a short form: how it is going, as four faces, and a note. Send waits for a face, hands both on, then says thanks. Each opening starts fresh.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FeedbackWidget {
|
||||
id: ElementId,
|
||||
on_send: Option<OnFeedback>,
|
||||
}
|
||||
|
||||
impl FeedbackWidget {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
on_send: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs with the face picked and the note, trimmed.
|
||||
pub fn on_send(
|
||||
mut self,
|
||||
handler: impl Fn(Sentiment, &str, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_send = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FeedbackWidget {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let on_send = self
|
||||
.on_send
|
||||
.unwrap_or_else(|| panic!("feedback widget {id:?} has no on_send"));
|
||||
Popover::new(id.clone(), "Feedback", move |window, cx| {
|
||||
form(id, on_send, window, cx)
|
||||
})
|
||||
.icon(IconName::MessageSquare)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
}
|
||||
}
|
||||
|
||||
fn form(
|
||||
id: ElementId,
|
||||
on_send: OnFeedback,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement + use<> {
|
||||
let draft = window.use_keyed_state((id.clone(), "draft"), cx, |_, _| Draft::default());
|
||||
let note = window.use_keyed_state((id.clone(), "note"), cx, |window, cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(2, 5)
|
||||
.placeholder("Tell us more (optional)")
|
||||
});
|
||||
let theme = cx.theme();
|
||||
let width = theme.tooltip_max_width();
|
||||
let (sentiment, sent) = (draft.read(cx).sentiment, draft.read(cx).sent);
|
||||
if sent {
|
||||
return div()
|
||||
.debug_selector(|| "feedback-thanks".into())
|
||||
.w(width)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
Icon::new(IconName::CircleCheck)
|
||||
.size(IconSize::Sm)
|
||||
.color(theme.colors.success),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child("Thanks for telling us."));
|
||||
}
|
||||
let faces = Sentiment::ALL.into_iter().fold(
|
||||
ToggleGroup::new((id.clone(), "faces")).selected(sentiment.map(Sentiment::key)),
|
||||
|faces, each| {
|
||||
faces.item(
|
||||
ToggleItem::new(each.key())
|
||||
.icon(each.icon())
|
||||
.tooltip(each.name()),
|
||||
)
|
||||
},
|
||||
);
|
||||
let (pick, send, typed) = (draft.clone(), draft, note.clone());
|
||||
div()
|
||||
.w(width)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child("How is it going?"),
|
||||
)
|
||||
.child(faces.on_change(move |keys, _, cx| {
|
||||
let chosen = keys.first().map(|key: &SharedString| Sentiment::of(key));
|
||||
pick.update(cx, |draft, cx| {
|
||||
draft.sentiment = chosen;
|
||||
cx.notify();
|
||||
})
|
||||
}))
|
||||
.child(Input::new(¬e))
|
||||
.child(
|
||||
div().flex().justify_end().child(
|
||||
Button::new((id.clone(), "send"), "Send")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(sentiment.is_none())
|
||||
.on_click(move |_, window, cx| {
|
||||
let chosen = sentiment.expect("Send rests until a face is picked");
|
||||
let words = typed.read(cx).text().trim().to_string();
|
||||
log::info!("feedback widget: sent {}", chosen.key());
|
||||
on_send(chosen, &words, window, cx);
|
||||
send.update(cx, |draft, cx| {
|
||||
draft.sent = true;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
documents::MarkdownRenderer,
|
||||
forms::{Input, Run, TextInput},
|
||||
layout::on_axis,
|
||||
primitives::{FocusRing, Icon, IconName, tab_stop},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// A help article: its key, its title, a line on it, and its body in Markdown. A link to `help:<key>` in a body opens that article.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HelpArticle {
|
||||
pub key: SharedString,
|
||||
pub title: SharedString,
|
||||
pub summary: SharedString,
|
||||
pub body: SharedString,
|
||||
}
|
||||
|
||||
/// The articles whose title, line or body hold every word of `query`, in order.
|
||||
pub fn found<'a>(articles: &'a [HelpArticle], query: &str) -> Vec<&'a HelpArticle> {
|
||||
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
|
||||
articles
|
||||
.iter()
|
||||
.filter(|article| {
|
||||
let text =
|
||||
format!("{} {} {}", article.title, article.summary, article.body).to_lowercase();
|
||||
words.iter().all(|word| text.contains(word.as_str()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where a link in an article leads.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Link<'a> {
|
||||
Article(&'a str),
|
||||
Web(&'a str),
|
||||
}
|
||||
|
||||
fn link(url: &str) -> Link<'_> {
|
||||
match url.strip_prefix("help:") {
|
||||
Some(key) => Link::Article(key),
|
||||
None => Link::Web(url),
|
||||
}
|
||||
}
|
||||
|
||||
type OnOpen = Rc<dyn Fn(Option<&SharedString>, &mut Window, &mut App)>;
|
||||
|
||||
/// Help beside the work: a search over the articles and their list, or one article with the way back to all. Focus follows: an article opened takes it to All articles, and the way back takes it to the search. Contact support and Keyboard shortcuts wait at its foot when the owner handles them. It fills its box, which the host sizes.
|
||||
#[derive(IntoElement)]
|
||||
pub struct HelpPanel {
|
||||
id: ElementId,
|
||||
articles: Vec<HelpArticle>,
|
||||
search: Entity<TextInput>,
|
||||
open: Option<SharedString>,
|
||||
on_open: Option<OnOpen>,
|
||||
on_close: Option<Run>,
|
||||
on_contact: Option<Run>,
|
||||
on_shortcuts: Option<Run>,
|
||||
}
|
||||
|
||||
impl HelpPanel {
|
||||
/// `search` is the find field, which the owner keeps; `open` names the article shown, if any.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
articles: impl IntoIterator<Item = HelpArticle>,
|
||||
search: &Entity<TextInput>,
|
||||
open: Option<SharedString>,
|
||||
) -> Self {
|
||||
let articles: Vec<HelpArticle> = articles.into_iter().collect();
|
||||
if let Some(key) = &open {
|
||||
assert!(
|
||||
articles.iter().any(|article| &article.key == key),
|
||||
"help panel: no article {key}"
|
||||
);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
articles,
|
||||
search: search.clone(),
|
||||
open,
|
||||
on_open: None,
|
||||
on_close: None,
|
||||
on_contact: None,
|
||||
on_shortcuts: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs with the article to show, or none for the list.
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(Option<&SharedString>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows a close button.
|
||||
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_close = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows Contact support at the foot.
|
||||
pub fn on_contact(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_contact = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows Keyboard shortcuts at the foot.
|
||||
pub fn on_shortcuts(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_shortcuts = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A button at the panel's foot.
|
||||
fn foot_button(
|
||||
id: &ElementId,
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
icon: IconName,
|
||||
run: Run,
|
||||
) -> Button {
|
||||
Button::new((id.clone(), key), label)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.icon(icon)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("help panel: {key}");
|
||||
run(window, cx)
|
||||
})
|
||||
}
|
||||
|
||||
impl RenderOnce for HelpPanel {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let on_open = self
|
||||
.on_open
|
||||
.unwrap_or_else(|| panic!("help panel {id:?} has no on_open"));
|
||||
let back_focus = tab_stop((id.clone(), "back").into(), true, window, cx);
|
||||
let back_handle = back_focus.clone();
|
||||
let search_focus = self.search.read(cx).focus().clone();
|
||||
let on_open: OnOpen = Rc::new(move |key, window, cx| {
|
||||
match key {
|
||||
Some(_) => window.focus(&back_focus),
|
||||
None => window.focus(&search_focus),
|
||||
}
|
||||
on_open(key, window, cx)
|
||||
});
|
||||
let theme = cx.theme();
|
||||
let close = self.on_close.map(|run| {
|
||||
IconButton::new((id.clone(), "close"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Close help")
|
||||
.on_click(move |_, window, cx| run(window, cx))
|
||||
});
|
||||
let head = div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(div().font_weight(FontWeight::SEMIBOLD).child("Help"))
|
||||
.children(close);
|
||||
let shown = self
|
||||
.open
|
||||
.as_ref()
|
||||
.and_then(|key| self.articles.iter().find(|article| &article.key == key));
|
||||
let body = match shown {
|
||||
Some(article) => {
|
||||
let (back, follow) = (on_open.clone(), on_open.clone());
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div().flex().child(
|
||||
Button::new((id.clone(), "back"), "All articles")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.icon(IconName::ChevronLeft)
|
||||
.focus_handle(&back_handle)
|
||||
.on_click(move |_, window, cx| back(None, window, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().flex().child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_size(theme.text_size(TextSize::Lg))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(article.title.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
MarkdownRenderer::new(
|
||||
(id.clone(), format!("article-{}", article.key)),
|
||||
article.body.clone(),
|
||||
)
|
||||
.text_size(TextSize::Sm)
|
||||
.on_link(move |url, window, cx| match link(url) {
|
||||
Link::Article(key) => {
|
||||
log::info!("help panel: link to {key}");
|
||||
follow(Some(&key.to_string().into()), window, cx)
|
||||
}
|
||||
Link::Web(url) => cx.open_url(url),
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
None => {
|
||||
let query = self.search.read(cx).text().trim().to_string();
|
||||
let listed = found(&self.articles, &query);
|
||||
let none = listed.is_empty();
|
||||
let rows = listed.iter().map(|article| {
|
||||
let (open, key) = (on_open.clone(), article.key.clone());
|
||||
div()
|
||||
.id((id.clone(), format!("row-{}", article.key)))
|
||||
.flex()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(Ellipsis::new(article.title.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(article.summary.clone()),
|
||||
),
|
||||
)
|
||||
.px_2()
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(theme.colors.hover))
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("help panel: open {key}");
|
||||
open(Some(&key), window, cx)
|
||||
})
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
Input::new(&self.search)
|
||||
.prefix(Icon::new(IconName::Search).size(IconSize::Sm)),
|
||||
)
|
||||
.child(div().flex().flex_col().gap_0p5().children(rows))
|
||||
.children(none.then(|| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(format!("No article answers to \"{query}\"."))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let contact = self.on_contact.map(|run| {
|
||||
foot_button(
|
||||
&id,
|
||||
"contact",
|
||||
"Contact support",
|
||||
IconName::MessageSquare,
|
||||
run,
|
||||
)
|
||||
});
|
||||
let shortcuts = self.on_shortcuts.map(|run| {
|
||||
foot_button(
|
||||
&id,
|
||||
"shortcuts",
|
||||
"Keyboard shortcuts",
|
||||
IconName::Keyboard,
|
||||
run,
|
||||
)
|
||||
});
|
||||
let foot = (contact.is_some() || shortcuts.is_some()).then(|| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.px_2()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(theme.colors.border)
|
||||
.children(contact)
|
||||
.children(shortcuts)
|
||||
});
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(head)
|
||||
.child(on_axis(
|
||||
div()
|
||||
.id((id.clone(), "body"))
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.p_4()
|
||||
.child(body),
|
||||
))
|
||||
.children(foot)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{HelpArticle, Link, found, link};
|
||||
|
||||
#[test]
|
||||
fn every_word_answers_in_the_title_the_line_or_the_body() {
|
||||
let article = |key: &str, title: &str, body: &str| HelpArticle {
|
||||
key: key.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
summary: "A line".into(),
|
||||
body: body.to_string().into(),
|
||||
};
|
||||
let articles = [
|
||||
article("start", "Getting started", "Make a project."),
|
||||
article("team", "Invite your team", "Send an invitation by email."),
|
||||
];
|
||||
let keys = |query: &str| {
|
||||
found(&articles, query)
|
||||
.iter()
|
||||
.map(|article| article.key.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
keys("invite EMAIL"),
|
||||
["team"],
|
||||
"across the title and the body, in any case"
|
||||
);
|
||||
assert_eq!(keys("project team"), Vec::<String>::new());
|
||||
assert_eq!(keys(""), ["start", "team"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_help_link_opens_an_article_and_the_rest_go_to_the_web() {
|
||||
assert_eq!(link("help:team"), Link::Article("team"));
|
||||
assert_eq!(
|
||||
link("https://example.com/help"),
|
||||
Link::Web("https://example.com/help")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
mod checklist;
|
||||
mod dialogs;
|
||||
mod feedback;
|
||||
mod help;
|
||||
mod highlight;
|
||||
mod hotspot;
|
||||
mod support;
|
||||
mod wizard;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use checklist::{SetupChecklist, SetupTask};
|
||||
pub use dialogs::{KeyboardShortcutCheatsheet, WhatsNewDialog};
|
||||
pub use feedback::{FeedbackWidget, Sentiment};
|
||||
pub use help::{HelpArticle, HelpPanel, found};
|
||||
pub use highlight::FeatureHighlight;
|
||||
pub use hotspot::Hotspot;
|
||||
pub use support::{ContactSupport, InlineHelp};
|
||||
pub use wizard::{OnboardingStep, OnboardingWizard};
|
||||
@@ -0,0 +1,183 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{Choice, FormField, Input, Run, Select, TextInput},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
typography::literal,
|
||||
};
|
||||
|
||||
type OnMessage = Rc<dyn Fn(&SharedString, &str, &mut Window, &mut App)>;
|
||||
|
||||
/// A way to reach people: a topic and a message, and Send once both are given. It rests while the owner sends, and empties the message once sent, with focus back in it. With `address`, the address to write to instead shows under it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ContactSupport {
|
||||
id: ElementId,
|
||||
topics: Vec<Choice>,
|
||||
busy: bool,
|
||||
address: Option<SharedString>,
|
||||
on_send: Option<OnMessage>,
|
||||
}
|
||||
|
||||
impl ContactSupport {
|
||||
pub fn new(id: impl Into<ElementId>, topics: impl IntoIterator<Item = Choice>) -> Self {
|
||||
let topics: Vec<Choice> = topics.into_iter().collect();
|
||||
assert!(!topics.is_empty(), "contact support needs a topic");
|
||||
Self {
|
||||
id: id.into(),
|
||||
topics,
|
||||
busy: false,
|
||||
address: None,
|
||||
on_send: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// While the owner sends: Send spins and takes no press, keeping its focus.
|
||||
pub fn busy(mut self, busy: bool) -> Self {
|
||||
self.busy = busy;
|
||||
self
|
||||
}
|
||||
|
||||
/// The address to write to instead.
|
||||
pub fn address(mut self, address: impl Into<SharedString>) -> Self {
|
||||
self.address = Some(address.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Runs with the topic's key and the message, trimmed.
|
||||
pub fn on_send(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &str, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_send = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ContactSupport {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let on_send = self
|
||||
.on_send
|
||||
.unwrap_or_else(|| panic!("contact support {id:?} has no on_send"));
|
||||
let topic = window.use_keyed_state((id.clone(), "topic"), cx, |_, _| None::<SharedString>);
|
||||
let message = window.use_keyed_state((id.clone(), "message"), cx, |window, cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(4, 8)
|
||||
.placeholder("What happened, and what you expected")
|
||||
});
|
||||
let chosen = topic.read(cx).clone();
|
||||
let words = message.read(cx).text().trim().to_string();
|
||||
let ready = chosen.is_some() && !words.is_empty();
|
||||
let theme = cx.theme();
|
||||
let (picked, emptied, field) = (topic, message.clone(), message.read(cx).focus().clone());
|
||||
let mut select = Select::new((id.clone(), "topic-select"), self.topics)
|
||||
.placeholder("Choose a topic")
|
||||
.on_change(move |key, _, cx| {
|
||||
let key = key.clone();
|
||||
picked.update(cx, |topic, cx| {
|
||||
*topic = Some(key);
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
if let Some(key) = &chosen {
|
||||
select = select.selected(key.clone());
|
||||
}
|
||||
let send = move |window: &mut Window, cx: &mut App| {
|
||||
let key = chosen.clone().expect("Send rests until a topic is chosen");
|
||||
log::info!("contact support: send about {key}");
|
||||
on_send(&key, &words, window, cx);
|
||||
emptied.update(cx, |field, cx| field.set_text("", cx));
|
||||
window.focus(&field);
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(FormField::new((id.clone(), "topic-field"), "Topic").child(select))
|
||||
.child(
|
||||
FormField::new((id.clone(), "message-field"), "Message")
|
||||
.child(Input::new(&message)),
|
||||
)
|
||||
.child(
|
||||
div().flex().child(
|
||||
Button::new((id.clone(), "send"), "Send message")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.loading(self.busy)
|
||||
.disabled(!ready)
|
||||
.on_click(move |_, window, cx| send(window, cx)),
|
||||
),
|
||||
)
|
||||
.children(self.address.map(|address| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_x_1()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child("Or write to")
|
||||
.child(literal(div()).text_color(theme.colors.fg).child(address))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A quiet line of help beside what it explains; with `on_more`, Learn more follows it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct InlineHelp {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
on_more: Option<Run>,
|
||||
}
|
||||
|
||||
impl InlineHelp {
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
on_more: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows Learn more, which runs `handler`.
|
||||
pub fn on_more(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_more = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for InlineHelp {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let id = self.id;
|
||||
let more = self.on_more.map(|run| {
|
||||
div().flex().child(
|
||||
Button::new((id.clone(), "more"), "Learn more")
|
||||
.variant(ButtonVariant::Link)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("inline help: learn more");
|
||||
run(window, cx)
|
||||
}),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(
|
||||
div().flex_none().pt_0p5().child(
|
||||
Icon::new(IconName::Info)
|
||||
.size(IconSize::Sm)
|
||||
.color(theme.colors.fg_subtle),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child(self.text).children(more))
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,14 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, FocusHandle, IntoElement, KeyBinding, ParentElement, Render,
|
||||
Styled, TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement, TestAppContext, div, px};
|
||||
|
||||
use super::{
|
||||
FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask,
|
||||
};
|
||||
use super::{Bench, bench, edit, said, say, tab, tap};
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
forms,
|
||||
primitives::{FocusNext, FocusScope, IconName},
|
||||
theme::Theme,
|
||||
onboarding::{
|
||||
FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask,
|
||||
},
|
||||
primitives::{FocusScope, IconName},
|
||||
};
|
||||
|
||||
type Part = fn(&Bench, Entity<Bench>) -> AnyElement;
|
||||
|
||||
/// A view that shows one onboarding part, keeps what it heard, and holds the owner's step, readiness and what it hid, with the root focus an app keeps.
|
||||
struct Bench {
|
||||
part: Part,
|
||||
said: Vec<String>,
|
||||
root: FocusHandle,
|
||||
hidden: bool,
|
||||
step: usize,
|
||||
ready: bool,
|
||||
all_done: bool,
|
||||
}
|
||||
|
||||
impl Render for Bench {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(360.0))
|
||||
.p_4()
|
||||
.child((self.part)(self, cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn bench(part: Part, cx: &mut TestAppContext) -> (Entity<Bench>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
forms::bind_keys(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|_, cx| Bench {
|
||||
part,
|
||||
said: Vec::new(),
|
||||
root: cx.focus_handle(),
|
||||
hidden: false,
|
||||
step: 0,
|
||||
ready: true,
|
||||
all_done: false,
|
||||
});
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn say(owner: &Entity<Bench>, words: String, cx: &mut App) {
|
||||
owner.update(cx, |bench, cx| {
|
||||
bench.said.push(words);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn said(host: &Entity<Bench>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |bench, _| bench.said.clone())
|
||||
}
|
||||
|
||||
fn edit(host: &Entity<Bench>, cx: &mut VisualTestContext, change: impl FnOnce(&mut Bench)) {
|
||||
host.update(cx, |bench, cx| {
|
||||
change(bench);
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Focus on the `stops`th Tab stop from the top.
|
||||
fn tab(stops: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
(0..stops).for_each(|_| window.focus_next());
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn tap(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn wizard(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
|
||||
let steps = ["name", "invite", "theme"].map(|key| OnboardingStep {
|
||||
key: key.into(),
|
||||
@@ -0,0 +1,232 @@
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement, Styled, TestAppContext, div, px};
|
||||
|
||||
use super::{Bench, bench, edit, said, say, tab, tap, write};
|
||||
use crate::{
|
||||
data_display::{Change, Release},
|
||||
forms::Choice,
|
||||
onboarding::{
|
||||
ContactSupport, FeedbackWidget, HelpArticle, HelpPanel, InlineHelp,
|
||||
KeyboardShortcutCheatsheet, WhatsNewDialog,
|
||||
},
|
||||
settings::Shortcut,
|
||||
};
|
||||
|
||||
fn article(key: &str, title: &str, body: &str) -> HelpArticle {
|
||||
HelpArticle {
|
||||
key: key.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
summary: "A line on it".into(),
|
||||
body: body.to_string().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn panel(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
|
||||
let articles = [
|
||||
article("start", "Getting started", "Make a project."),
|
||||
article("team", "Invite your team", "Send an invitation by email."),
|
||||
];
|
||||
let [opener, contact, keys, close] = [(); 4].map(|_| owner.clone());
|
||||
div()
|
||||
.h(px(420.0))
|
||||
.child(
|
||||
HelpPanel::new("help", articles, &bench.search, bench.open.clone())
|
||||
.on_open(move |key, _, cx| {
|
||||
let key = key.cloned();
|
||||
opener.update(cx, |bench, cx| {
|
||||
bench.said.push(format!(
|
||||
"open {}",
|
||||
key.as_ref().map_or("none", |key| key.as_ref())
|
||||
));
|
||||
bench.open = key;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_contact(move |_, cx| say(&contact, "contact".into(), cx))
|
||||
.on_shortcuts(move |_, cx| say(&keys, "shortcuts".into(), cx))
|
||||
.on_close(move |_, cx| say(&close, "close".into(), cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops on the list: close, the search, each article, then the foot; on an article: close, All articles, then the foot. An article opened by key takes focus to All articles, and the way back takes it to the search, where words narrow the list.
|
||||
#[gpui::test]
|
||||
fn an_article_opens_from_the_list_and_focus_follows(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(panel, cx);
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
tap("space", cx);
|
||||
write("email", cx);
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
tab(4, cx);
|
||||
tap("space", cx);
|
||||
tab(1, cx);
|
||||
tap("space", cx);
|
||||
assert_eq!(
|
||||
said(&host, cx),
|
||||
[
|
||||
"open start",
|
||||
"open none",
|
||||
"open team",
|
||||
"contact",
|
||||
"shortcuts",
|
||||
"close"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn cheatsheet(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
|
||||
let shortcut = |name: &str, keys: &str| Shortcut {
|
||||
group: "General".into(),
|
||||
name: name.to_string().into(),
|
||||
keys: keys.to_string().into(),
|
||||
};
|
||||
let dialog = bench.dialog.then(|| {
|
||||
KeyboardShortcutCheatsheet::new(
|
||||
"keys",
|
||||
[shortcut("Undo", "cmd-z"), shortcut("Find", "cmd-f")],
|
||||
&bench.search,
|
||||
move |_, cx| say(&owner, "closed".into(), cx),
|
||||
)
|
||||
});
|
||||
div().children(dialog).into_any_element()
|
||||
}
|
||||
|
||||
/// The search takes focus as the dialog opens, and Escape closes it.
|
||||
#[gpui::test]
|
||||
fn the_cheatsheet_opens_on_its_search_and_escape_closes_it(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(cheatsheet, cx);
|
||||
edit(&host, cx, |bench| bench.dialog = true);
|
||||
write("undo", cx);
|
||||
let typed = host.read_with(cx, |bench, cx| bench.search.read(cx).text().to_string());
|
||||
assert_eq!(typed, "undo");
|
||||
tap("escape", cx);
|
||||
assert_eq!(said(&host, cx), ["closed"]);
|
||||
}
|
||||
|
||||
fn whats_new(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
|
||||
let dialog = bench.dialog.then(|| {
|
||||
WhatsNewDialog::new("new", move |_, cx| say(&owner, "closed".into(), cx))
|
||||
.release(Release::new("2.4", "27 Sep 2026").change(Change::Added, "Split view"))
|
||||
});
|
||||
div().children(dialog).into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the close button, then Got it.
|
||||
#[gpui::test]
|
||||
fn whats_new_lists_the_releases_and_got_it_closes(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(whats_new, cx);
|
||||
edit(&host, cx, |bench| bench.dialog = true);
|
||||
assert!(cx.debug_bounds("whats-new-releases").is_some());
|
||||
assert!(cx.debug_bounds("whats-new-done").is_some());
|
||||
tap("tab", cx);
|
||||
tap("tab", cx);
|
||||
tap("space", cx);
|
||||
assert_eq!(said(&host, cx), ["closed"]);
|
||||
}
|
||||
|
||||
fn feedback(_: &Bench, owner: Entity<Bench>) -> AnyElement {
|
||||
FeedbackWidget::new("feedback")
|
||||
.on_send(move |sentiment, note, _, cx| {
|
||||
say(&owner, format!("{} {note}", sentiment.key()), cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops in the form: the four faces, the note, then Send once a face is picked.
|
||||
#[gpui::test]
|
||||
fn feedback_waits_for_a_face_and_says_thanks(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(feedback, cx);
|
||||
tab(1, cx);
|
||||
tap("enter", cx);
|
||||
for _ in 0..5 {
|
||||
tap("tab", cx);
|
||||
}
|
||||
write(" Fast ", cx);
|
||||
for _ in 0..3 {
|
||||
tap("tab", cx);
|
||||
}
|
||||
tap("space", cx);
|
||||
for _ in 0..3 {
|
||||
tap("tab", cx);
|
||||
}
|
||||
tap("space", cx);
|
||||
assert_eq!(
|
||||
said(&host, cx),
|
||||
["good Fast"],
|
||||
"Send rested past the note, so the third Tab reached Good"
|
||||
);
|
||||
assert!(cx.debug_bounds("feedback-thanks").is_some());
|
||||
}
|
||||
|
||||
fn support(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
|
||||
let topics = [
|
||||
Choice::new("billing", "Billing"),
|
||||
Choice::new("bug", "Something broke"),
|
||||
];
|
||||
ContactSupport::new("support", topics)
|
||||
.busy(bench.busy)
|
||||
.address("help@example.com")
|
||||
.on_send(move |topic, message, _, cx| say(&owner, format!("{topic}: {message}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the topic, the message, then Send once both are given; the message empties once sent, with focus back in it.
|
||||
#[gpui::test]
|
||||
fn a_message_goes_with_its_topic_and_empties(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(support, cx);
|
||||
tab(2, cx);
|
||||
write("It froze", cx);
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
assert!(
|
||||
said(&host, cx).is_empty(),
|
||||
"no topic yet, so Send rests and the third stop wraps to the topic"
|
||||
);
|
||||
tab(1, cx);
|
||||
for key in ["escape", "enter", "down", "enter"] {
|
||||
tap(key, cx);
|
||||
}
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
write("Again", cx);
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
assert_eq!(said(&host, cx), ["bug: It froze", "bug: Again"]);
|
||||
}
|
||||
|
||||
/// While the owner sends, Send spins and takes no press, yet stays the third stop and keeps its focus: once sending ends, the same press sends.
|
||||
#[gpui::test]
|
||||
fn a_message_waits_while_busy(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(support, cx);
|
||||
edit(&host, cx, |bench| bench.busy = true);
|
||||
tab(1, cx);
|
||||
for key in ["enter", "down", "enter"] {
|
||||
tap(key, cx);
|
||||
}
|
||||
tab(2, cx);
|
||||
write("It froze", cx);
|
||||
tab(3, cx);
|
||||
tap("space", cx);
|
||||
assert!(said(&host, cx).is_empty(), "Send takes no press while busy");
|
||||
edit(&host, cx, |bench| bench.busy = false);
|
||||
tap("space", cx);
|
||||
assert_eq!(said(&host, cx), ["bug: It froze"]);
|
||||
}
|
||||
|
||||
fn inline(_: &Bench, owner: Entity<Bench>) -> AnyElement {
|
||||
InlineHelp::new("hint", "Only admins can change billing.")
|
||||
.on_more(move |_, cx| say(&owner, "more".into(), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: Learn more.
|
||||
#[gpui::test]
|
||||
fn inline_help_offers_more(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(inline, cx);
|
||||
tab(1, cx);
|
||||
tap("space", cx);
|
||||
assert_eq!(said(&host, cx), ["more"]);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, FocusHandle, IntoElement, KeyBinding,
|
||||
ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div,
|
||||
px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
forms::{self, TextInput},
|
||||
primitives::FocusNext,
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
mod flows;
|
||||
mod help;
|
||||
|
||||
type Part = fn(&Bench, Entity<Bench>) -> AnyElement;
|
||||
|
||||
/// A view that shows one onboarding part, keeps what it heard, and holds what the owner decides: the step, readiness, what it hid, the open article and dialog, and a find field, with the root focus an app keeps.
|
||||
struct Bench {
|
||||
part: Part,
|
||||
said: Vec<String>,
|
||||
root: FocusHandle,
|
||||
hidden: bool,
|
||||
step: usize,
|
||||
ready: bool,
|
||||
all_done: bool,
|
||||
open: Option<SharedString>,
|
||||
dialog: bool,
|
||||
busy: bool,
|
||||
search: Entity<TextInput>,
|
||||
}
|
||||
|
||||
impl Render for Bench {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(360.0))
|
||||
.p_4()
|
||||
.child((self.part)(self, cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn bench(part: Part, cx: &mut TestAppContext) -> (Entity<Bench>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
forms::bind_keys(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|window, cx| Bench {
|
||||
part,
|
||||
said: Vec::new(),
|
||||
root: cx.focus_handle(),
|
||||
hidden: false,
|
||||
step: 0,
|
||||
ready: true,
|
||||
all_done: false,
|
||||
open: None,
|
||||
dialog: false,
|
||||
busy: false,
|
||||
search: cx.new(|cx| TextInput::new(window, cx)),
|
||||
});
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn say(owner: &Entity<Bench>, words: String, cx: &mut App) {
|
||||
owner.update(cx, |bench, cx| {
|
||||
bench.said.push(words);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn said(host: &Entity<Bench>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |bench, _| bench.said.clone())
|
||||
}
|
||||
|
||||
fn edit(host: &Entity<Bench>, cx: &mut VisualTestContext, change: impl FnOnce(&mut Bench)) {
|
||||
host.update(cx, |bench, cx| {
|
||||
change(bench);
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Focus on the `stops`th Tab stop from the top.
|
||||
fn tab(stops: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
(0..stops).for_each(|_| window.focus_next());
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn tap(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn write(text: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_input(text);
|
||||
settle(cx);
|
||||
}
|
||||
@@ -162,6 +162,7 @@ icons! {
|
||||
Folder => "folder",
|
||||
Forward => "forward",
|
||||
Frame => "frame",
|
||||
Frown => "frown",
|
||||
Fullscreen => "fullscreen",
|
||||
Gauge => "gauge",
|
||||
GitBranch => "git-branch",
|
||||
@@ -195,6 +196,7 @@ icons! {
|
||||
Key => "key",
|
||||
Keyboard => "keyboard",
|
||||
Languages => "languages",
|
||||
Laugh => "laugh",
|
||||
Layers => "layers",
|
||||
LayoutDashboard => "layout-dashboard",
|
||||
LayoutGrid => "layout-grid",
|
||||
@@ -214,6 +216,7 @@ icons! {
|
||||
MapPin => "map-pin",
|
||||
Map => "map",
|
||||
Maximize2 => "maximize-2",
|
||||
Meh => "meh",
|
||||
MemoryStick => "memory-stick",
|
||||
Menu => "menu",
|
||||
Merge => "merge",
|
||||
|
||||
@@ -29,4 +29,4 @@ pub use layer::{Raised, raise};
|
||||
pub use measure::{IntersectionObserver, Measure};
|
||||
pub use pressable::Pressable;
|
||||
pub use severity::Severity;
|
||||
pub use tooltip::{Tooltip, TooltipTrigger};
|
||||
pub use tooltip::{HelpTooltip, Tooltip, TooltipTrigger};
|
||||
@@ -7,10 +7,10 @@ use gpui::{
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use super::raise;
|
||||
use super::{Icon, IconName, raise};
|
||||
use crate::{
|
||||
motion,
|
||||
theme::{ActiveTheme, Elevation, Radius, TextSize},
|
||||
theme::{ActiveTheme, Elevation, IconSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
|
||||
@@ -193,3 +193,33 @@ fn hide(trigger: &mut TriggerState, cx: &mut Context<TriggerState>) {
|
||||
trigger._pending = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A help mark, ?, whose tip shows on hover.
|
||||
#[derive(IntoElement)]
|
||||
pub struct HelpTooltip {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl HelpTooltip {
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for HelpTooltip {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.id(self.id)
|
||||
.flex_none()
|
||||
.child(
|
||||
Icon::new(IconName::CircleHelp)
|
||||
.size(IconSize::Xs)
|
||||
.color(cx.theme().colors.fg_subtle),
|
||||
)
|
||||
.tooltip(Tooltip::text(self.text))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user