Files
Ely-GPUI-Components/src/onboarding/feedback.rs
T
ZacharyZhang-NY fe1b00d414 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.
2026-09-27 15:05:06 -04:00

190 lines
5.6 KiB
Rust

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(&note))
.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();
})
}),
),
)
}