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.
This commit is contained in:
1 parent
d4ba8f7037
commit
fe1b00d414
20 files changed
+1662
-122
No files matched your search
@@ -77,6 +77,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- gpui clicks a focused element when Enter or Space is released. An overlay that hands focus back picks on release too; a pick on press returns focus first, and the release clicks the opener again. An edit in place that hands focus back keeps its text on Enter's release (`forms::InlineEdit`). The press itself bubbles, so a container that takes Space leaves a focused button its own: `overlays::media_button` stops it, and a button in a `SelectableList` row stops Space and Enter at its box (`mail::MailItem`'s star, `messaging::PinnedMessages`' Unpin).
|
||||
- A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere. A thread's message keeps one handle folded and open, a Tab stop only while folded (`mail::MailThreadView`).
|
||||
- A button whose words and action change with a mode keeps one handle through `Button::focus_handle`, and the component moves focus to it or back to its field as the mode turns (`project::TimeTracker`).
|
||||
- A button that waits on the owner shows it with `loading`, which takes no press and stays a Tab stop, so focus holds through the wait; `disabled` is for what is not ready, and a disabled button is no stop (`onboarding::ContactSupport`).
|
||||
- A help mark, ?, is `primitives::HelpTooltip`; FormLabel's help shows it.
|
||||
- A panel that opens from something other than a button goes through `overlays::Popover::with_opener`, which hands the opener its toggle; focus, placement and every way out stay in the popover (`calendar::EventPopover`'s chip). Its content gets a close that hands focus back: a press that ends the panel's work closes it before the owner acts, or a panel whose host leaves the tree strands focus (`onboarding::Hotspot`'s Got it, EventPopover's Edit and Delete).
|
||||
- A flow in steps goes through `navigation::Wizard`: Back, Next, Finish and the step's motion live there. Next and Finish share one focus handle, and focus moves to it when Back reaches the first step or a finished step is picked. `onboarding::OnboardingWizard` leaves out its numbered steps (`headless`) and shows a count over a bar in as many parts.
|
||||
- A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-frown"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 10V9" />
|
||||
<path d="M9 10V9" />
|
||||
<path d="M9 16a5 5 0 016 0" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-laugh"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 10V9" />
|
||||
<path d="M7.084 14.302a5.12 5.12 0 009.833 0 .24.24 0 00-.235-.302H7.32a.24.24 0 00-.235.302" />
|
||||
<path d="M9 10V9" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-meh"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 10V9" />
|
||||
<path d="M8 16h8" />
|
||||
<path d="M9 10V9" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,313 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::{Change, Release},
|
||||
feedback::EmptyState,
|
||||
forms::{Choice, FormField, Input, TextInput},
|
||||
onboarding::{
|
||||
ContactSupport, FeedbackWidget, HelpArticle, HelpPanel, InlineHelp,
|
||||
KeyboardShortcutCheatsheet, WhatsNewDialog,
|
||||
},
|
||||
primitives::{HelpTooltip, IconName},
|
||||
settings::Shortcut,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
use gpui::{
|
||||
App, Entity, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window, div,
|
||||
prelude::*, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
/// The help demos: the article open, whether the panel shows, and which dialog is up.
|
||||
#[derive(Default)]
|
||||
struct Help {
|
||||
open: Option<SharedString>,
|
||||
hidden: bool,
|
||||
dialog: Option<Dialog>,
|
||||
sent: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Dialog {
|
||||
Shortcuts,
|
||||
WhatsNew,
|
||||
}
|
||||
|
||||
fn field(
|
||||
key: &'static str,
|
||||
hint: &'static str,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<TextInput> {
|
||||
window.use_keyed_state(key, cx, move |window, cx| {
|
||||
TextInput::new(window, cx).placeholder(hint)
|
||||
})
|
||||
}
|
||||
|
||||
fn article(key: &str, title: &str, summary: &str, body: &str) -> HelpArticle {
|
||||
HelpArticle {
|
||||
key: key.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
summary: summary.to_string().into(),
|
||||
body: body.to_string().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn articles() -> [HelpArticle; 4] {
|
||||
[
|
||||
article(
|
||||
"start",
|
||||
"Getting started",
|
||||
"Make a project and invite your team.",
|
||||
"Press **New project** in the sidebar, give it a name, and pick who can see it.\n\nThen [invite your team](help:team).",
|
||||
),
|
||||
article(
|
||||
"team",
|
||||
"Invite your team",
|
||||
"Bring people in by email.",
|
||||
"Open **Settings › Members** and press **Invite**. Each person gets a link that works for seven days.",
|
||||
),
|
||||
article(
|
||||
"billing",
|
||||
"Change billing",
|
||||
"Who can, and where.",
|
||||
"Only admins can change billing. Open **Settings › Billing** to change the plan or the card.",
|
||||
),
|
||||
article(
|
||||
"keys",
|
||||
"Work from the keyboard",
|
||||
"The shortcuts worth learning first.",
|
||||
"Press `cmd-k` to open anything, and `?` for every shortcut.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn shortcut(group: &str, name: &str, keys: &str) -> Shortcut {
|
||||
Shortcut {
|
||||
group: group.to_string().into(),
|
||||
name: name.to_string().into(),
|
||||
keys: keys.to_string().into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tips(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-help", Help::default, window, cx);
|
||||
let billing = field(
|
||||
"onboarding-billing-email",
|
||||
"billing@example.com",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let theme = cx.theme();
|
||||
let more = state.clone();
|
||||
section(
|
||||
"HelpTooltip · InlineHelp · EmptyStateWithAction",
|
||||
"A help mark beside a name shows its tip on hover; FormLabel's help is one. A line of help sits where it is needed, and Learn more opens the article in the panel below. An empty state with an action is feedback::EmptyState.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_start()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.w(px(320.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().font_weight(FontWeight::SEMIBOLD).child("Retention"))
|
||||
.child(HelpTooltip::new("onboarding-retention-help", "Deleted items stay 30 days before they are gone.")),
|
||||
)
|
||||
.child(
|
||||
FormField::new("onboarding-billing-field", "Billing email")
|
||||
.help("Invoices go here as well as to the owner.")
|
||||
.child(Input::new(&billing)),
|
||||
)
|
||||
.child(
|
||||
InlineHelp::new("onboarding-billing-hint", "Only admins can change billing.").on_more(move |_, cx| {
|
||||
change(&more, cx, |help| {
|
||||
help.open = Some("billing".into());
|
||||
help.hidden = false;
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(320.)).text_size(theme.text_size(TextSize::Sm)).child(
|
||||
EmptyState::new("onboarding-empty", IconName::FolderOpen, "No projects yet")
|
||||
.body("Projects hold your work and who can see it.")
|
||||
.action(
|
||||
Button::new("onboarding-empty-new", "New project")
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(|_, _, _| log::info!("gallery: new project")),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn panel(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-help", Help::default, window, cx);
|
||||
let search = field("onboarding-help-search", "Search help", window, cx);
|
||||
let (open, hidden) = (state.read(cx).open.clone(), state.read(cx).hidden);
|
||||
let theme = cx.theme();
|
||||
let [opener, keys, close, show] = [(); 4].map(|_| state.clone());
|
||||
let body = match hidden {
|
||||
true => Button::new("onboarding-help-show", "Show help")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| change(&show, cx, |help| help.hidden = false))
|
||||
.into_any_element(),
|
||||
false => div()
|
||||
.h(px(460.))
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.child(
|
||||
HelpPanel::new("onboarding-help-panel", articles(), &search, open)
|
||||
.on_open(move |key, _, cx| {
|
||||
let key = key.cloned();
|
||||
change(&opener, cx, |help| help.open = key)
|
||||
})
|
||||
.on_contact(|_, _| log::info!("gallery: contact support"))
|
||||
.on_shortcuts(move |_, cx| {
|
||||
change(&keys, cx, |help| help.dialog = Some(Dialog::Shortcuts))
|
||||
})
|
||||
.on_close(move |_, cx| change(&close, cx, |help| help.hidden = true)),
|
||||
)
|
||||
.into_any_element(),
|
||||
};
|
||||
section(
|
||||
"HelpPanel / DocsSidebar",
|
||||
"Help beside the work: a search over the articles, one article with the way back, and links at its foot. A link to help:<key> opens another article.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("onboarding-help-panel", div().w(px(320.)).child(body)))
|
||||
}
|
||||
|
||||
pub fn dialogs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-help", Help::default, window, cx);
|
||||
let search = field("onboarding-keys-search", "Search shortcuts", window, cx);
|
||||
let dialog = state.read(cx).dialog;
|
||||
let [keys, news, shut, shut_news] = [(); 4].map(|_| state.clone());
|
||||
let open = match dialog {
|
||||
Some(Dialog::Shortcuts) => Some(
|
||||
KeyboardShortcutCheatsheet::new(
|
||||
"onboarding-cheatsheet",
|
||||
[
|
||||
shortcut("General", "Open anything", "cmd-k"),
|
||||
shortcut("General", "Every shortcut", "?"),
|
||||
shortcut("General", "Settings", "cmd-,"),
|
||||
shortcut("Editing", "Undo", "cmd-z"),
|
||||
shortcut("Editing", "Redo", "cmd-shift-z"),
|
||||
shortcut("Editing", "Find", "cmd-f"),
|
||||
shortcut("View", "Split view", "cmd-\\"),
|
||||
shortcut("View", "Zoom in", "cmd-="),
|
||||
],
|
||||
&search,
|
||||
move |_, cx| change(&shut, cx, |help| help.dialog = None),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(Dialog::WhatsNew) => Some(
|
||||
WhatsNewDialog::new("onboarding-whats-new", move |_, cx| {
|
||||
change(&shut_news, cx, |help| help.dialog = None)
|
||||
})
|
||||
.release(
|
||||
Release::new("2.4", "Sep 25, 2026")
|
||||
.summary("Split view, and a calmer sidebar.")
|
||||
.change(Change::Added, "Split view: two files side by side.")
|
||||
.change(Change::Added, "Pin a view to the toolbar.")
|
||||
.change(Change::Fixed, "Search keeps its place after an edit."),
|
||||
)
|
||||
.release(
|
||||
Release::new("2.3", "Sep 9, 2026")
|
||||
.change(Change::Changed, "The sidebar folds with a narrow window.")
|
||||
.change(Change::Fixed, "Sync resumes after sleep."),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
section(
|
||||
"KeyboardShortcutCheatsheet · WhatsNewDialog",
|
||||
"Every shortcut in one dialog, its search taking focus as it opens; and what changed, newest first.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_3()
|
||||
.child(probe(
|
||||
"onboarding-shortcuts",
|
||||
Button::new("onboarding-shortcuts", "Keyboard shortcuts")
|
||||
.icon(IconName::Keyboard)
|
||||
.on_click(move |_, _, cx| change(&keys, cx, |help| help.dialog = Some(Dialog::Shortcuts))),
|
||||
))
|
||||
.child(probe(
|
||||
"onboarding-whats-new",
|
||||
Button::new("onboarding-whats-new", "What's new")
|
||||
.icon(IconName::Sparkles)
|
||||
.on_click(move |_, _, cx| change(&news, cx, |help| help.dialog = Some(Dialog::WhatsNew))),
|
||||
)),
|
||||
)
|
||||
.children(open)
|
||||
}
|
||||
|
||||
pub fn feedback(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let state = keep("onboarding-help", Help::default, window, cx);
|
||||
let sent = state.read(cx).sent;
|
||||
let done = state.clone();
|
||||
let topics = [
|
||||
Choice::new("billing", "Billing"),
|
||||
Choice::new("bug", "Something broke"),
|
||||
Choice::new("account", "My account"),
|
||||
Choice::new("other", "Something else"),
|
||||
];
|
||||
section(
|
||||
"FeedbackWidget · ContactSupport",
|
||||
"Four faces and a note, sent with one press; and a message to people, sent once it has a topic and words.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_start()
|
||||
.gap_6()
|
||||
.child(probe(
|
||||
"onboarding-feedback",
|
||||
FeedbackWidget::new("onboarding-feedback")
|
||||
.on_send(|sentiment, note, _, _| log::info!("gallery: feedback {} {note}", sentiment.key())),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.w(px(360.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
ContactSupport::new("onboarding-support", topics)
|
||||
.address("help@example.com")
|
||||
.on_send(move |topic, _, _, cx| {
|
||||
log::info!("gallery: support about {topic}");
|
||||
change(&done, cx, |help| help.sent = true)
|
||||
}),
|
||||
)
|
||||
.when(sent, |column| column.child(div().text_sm().child("Sent. We answer within a day."))),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use gpui::{AnyElement, App, Window, div, prelude::*};
|
||||
|
||||
mod flows;
|
||||
mod help;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
@@ -9,7 +10,7 @@ pub const PAGE: Page = Page {
|
||||
number: 37,
|
||||
slug: "onboarding",
|
||||
title: "Onboarding & Help",
|
||||
summary: "A first run in steps, what is new called out, and first things to do.",
|
||||
summary: "A first run in steps, what is new called out, first things to do, and help where it is needed.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
@@ -27,6 +28,25 @@ const SCRIPT: &[Step] = &[
|
||||
Step::Wait(200),
|
||||
Step::Shot("hotspot-tip"),
|
||||
Step::Key("escape"),
|
||||
Step::DownAt("onboarding-help-panel", 120.0, 124.0),
|
||||
Step::UpAt("onboarding-help-panel", 120.0, 124.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("help-article"),
|
||||
Step::DownAt("onboarding-shortcuts", 20.0, 16.0),
|
||||
Step::UpAt("onboarding-shortcuts", 20.0, 16.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("cheatsheet"),
|
||||
Step::Key("escape"),
|
||||
Step::DownAt("onboarding-whats-new", 20.0, 16.0),
|
||||
Step::UpAt("onboarding-whats-new", 20.0, 16.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("whats-new"),
|
||||
Step::Key("escape"),
|
||||
Step::DownAt("onboarding-feedback", 20.0, 16.0),
|
||||
Step::UpAt("onboarding-feedback", 20.0, 16.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("feedback"),
|
||||
Step::Key("escape"),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
@@ -34,5 +54,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(flows::wizard(window, cx))
|
||||
.child(flows::highlights(window, cx))
|
||||
.child(flows::checklist(window, cx))
|
||||
.child(help::tips(window, cx))
|
||||
.child(help::panel(window, cx))
|
||||
.child(help::dialogs(window, cx))
|
||||
.child(help::feedback(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -208,14 +208,14 @@
|
||||
|
||||
## T37b Onboarding · Help
|
||||
|
||||
- [ ] HelpTooltip
|
||||
- [ ] HelpPanel / DocsSidebar
|
||||
- [ ] KeyboardShortcutCheatsheet
|
||||
- [ ] WhatsNewDialog
|
||||
- [ ] FeedbackWidget
|
||||
- [ ] ContactSupport
|
||||
- [ ] InlineHelp
|
||||
- [ ] EmptyStateWithAction → `feedback::EmptyState`
|
||||
- [x] HelpTooltip → `primitives::HelpTooltip`, the mark FormLabel's help shows
|
||||
- [x] HelpPanel / DocsSidebar
|
||||
- [x] KeyboardShortcutCheatsheet — `settings::KeyboardShortcutsList` in an `overlays::Dialog`
|
||||
- [x] WhatsNewDialog — `data_display::Changelog` in an `overlays::Dialog`
|
||||
- [x] FeedbackWidget
|
||||
- [x] ContactSupport
|
||||
- [x] InlineHelp
|
||||
- [x] EmptyStateWithAction → `feedback::EmptyState`, whose `action` adds its buttons
|
||||
|
||||
## T38 Interaction — `src/interaction`
|
||||
|
||||
|
||||
Reference in new issue
Block a user