Files
Ely-GPUI-Components/src/misc/survey.rs
T
ZacharyZhang-NY 4c3ed1bbb9 Port · Build: Zed's gpui at 1a28cff
gpui and gpui_platform now come from Zed's repo by git at 1a28cff,
the code gpui-pre 0.3.7 republishes, so the gallery can later build
for the browser. gpui takes stacker, as 0.2.2 always did, and
gpui_platform takes font-kit, without which macOS draws no text;
runtime_shaders moves to gpui_platform.

The API moved: focus, blur and Tab steps take cx, Corner is Anchor,
shaped lines paint with an alignment, defer_draw takes a content
mask, the inspector registers a factory, async updates return their
value, and wrapped lines are shared, not cloned. Updates through a
window context go through the window, so a closed window still ends
them as it did.

Where the new gpui behaves differently, Ely keeps its old intent.
Holds and reveals under a resting pointer survive typing through
gpui's input-modality-independent hover; previews the keyboard also
moves keep the default, and the calendar's leave ignores a key press.
Enter reaches a button inside the code editor, since gpui now arms a
key click on the press. The event logger skips a lone modifier. A
video and an image grid keep their shape in a flex column under
taffy 0.13, as a picture does.

AGENTS.md records the new stack and the rules this port changes; the
rest of the old gpui and taffy rules are re-tested in T44c.
2026-09-28 20:30:17 -04:00

281 lines
10 KiB
Rust

use std::rc::Rc;
use gpui::{
App, AppContext as _, ElementId, Entity, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{CheckboxGroup, Choice, Input, RadioGroup, TextInput},
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize, Radius, TextSize},
};
type OnSubmit = Rc<dyn Fn(&[Answer], &mut Window, &mut App)>;
/// What a survey asks, and how it is answered.
#[derive(Clone)]
pub enum Question {
/// One of the options.
Choice(SharedString, Vec<SharedString>),
/// Any of the options, one at least.
Several(SharedString, Vec<SharedString>),
/// Words.
Text(SharedString),
}
impl Question {
fn title(&self) -> &SharedString {
match self {
Question::Choice(title, _) | Question::Several(title, _) | Question::Text(title) => {
title
}
}
}
}
/// An answer, by the options' places or in words.
#[derive(Clone, Debug, PartialEq)]
pub enum Answer {
Choice(usize),
Several(Vec<usize>),
Text(SharedString),
}
/// What the survey holds: the answers picked, a field for each question in words, whether it was sent, and whether Submit came with an answer missing.
struct Sheet {
picked: Vec<Option<Answer>>,
fields: Vec<Option<Entity<TextInput>>>,
sent: bool,
nudged: bool,
}
/// Questions on one page, each answered by one option, several, or words. Submit hands the answers to the owner once every one is answered, and asks for the rest before; then the survey thanks the viewer in Submit's place and focus.
#[derive(IntoElement)]
pub struct Survey {
id: ElementId,
questions: Vec<Question>,
on_submit: Option<OnSubmit>,
}
impl Survey {
pub fn new(id: impl Into<ElementId>, questions: impl IntoIterator<Item = Question>) -> Self {
Self {
id: id.into(),
questions: questions.into_iter().collect(),
on_submit: None,
}
}
/// Runs with every answer, in the order of the questions.
pub fn on_submit(
mut self,
handler: impl Fn(&[Answer], &mut Window, &mut App) + 'static,
) -> Self {
self.on_submit = Some(Rc::new(handler));
self
}
}
/// Each question's answer as it stands, none where it waits for one.
fn answers(questions: &[Question], sheet: &Sheet, cx: &App) -> Vec<Option<Answer>> {
questions
.iter()
.enumerate()
.map(|(ix, question)| match question {
Question::Text(_) => {
let field = sheet.fields[ix].as_ref().expect("a field for words");
let words = field.read(cx).text().trim().to_string();
(!words.is_empty()).then(|| Answer::Text(words.into()))
}
_ => sheet.picked[ix].clone(),
})
.collect()
}
/// Options as choices keyed by their places.
fn choices(options: &[SharedString]) -> impl Iterator<Item = Choice> + '_ {
options
.iter()
.enumerate()
.map(|(ix, label)| Choice::new(ix.to_string(), label.clone()))
}
fn place(value: &SharedString) -> usize {
value.parse().expect("an option's place")
}
impl RenderOnce for Survey {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
assert!(!self.questions.is_empty(), "survey {id:?} has no questions");
let on_submit = self
.on_submit
.unwrap_or_else(|| panic!("survey {id:?} has no on_submit"));
let questions = Rc::new(self.questions);
let sheet = window.use_keyed_state((id.clone(), "sheet"), cx, {
let questions = questions.clone();
move |window, cx| Sheet {
picked: vec![None; questions.len()],
fields: questions
.iter()
.map(|question| {
matches!(question, Question::Text(_))
.then(|| cx.new(|cx| TextInput::new(window, cx).multi_line(2, 5)))
})
.collect(),
sent: false,
nudged: false,
}
});
assert_eq!(
sheet.read(cx).picked.len(),
questions.len(),
"survey {id:?} changed its questions"
);
let sent = sheet.read(cx).sent;
let action = tab_stop((id.clone(), "submit").into(), !sent, window, cx);
let theme = cx.theme();
let colors = &theme.colors;
if sent {
return div()
.id((id, "sent"))
.debug_selector(|| "survey-sent".into())
.track_focus(&action)
.flex()
.items_center()
.gap_2()
.p_4()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.focus_ring(cx)
.child(
Icon::new(IconName::CircleCheck)
.size(IconSize::Sm)
.color(colors.success),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child("Thanks. Your answers were sent."),
)
.into_any_element();
}
let now = answers(&questions, sheet.read(cx), cx);
let complete = now.iter().all(Option::is_some);
let items = questions.iter().enumerate().map(|(ix, question)| {
let key = (id.clone(), format!("question-{ix}"));
let set = sheet.clone();
let input = match question {
Question::Choice(_, options) => {
let group =
RadioGroup::new(key, choices(options)).on_change(move |value, _, cx| {
set.update(cx, |sheet, cx| {
sheet.picked[ix] = Some(Answer::Choice(place(value)));
cx.notify();
})
});
match &now[ix] {
Some(Answer::Choice(option)) => group.selected(option.to_string()),
_ => group,
}
.into_any_element()
}
Question::Several(_, options) => {
let ticked = match &now[ix] {
Some(Answer::Several(ticked)) => {
ticked.iter().map(usize::to_string).collect()
}
_ => Vec::new(),
};
CheckboxGroup::new(key, choices(options))
.selected(ticked)
.on_change(move |values, _, cx| {
let ticked: Vec<usize> = values.iter().map(place).collect();
set.update(cx, |sheet, cx| {
sheet.picked[ix] =
(!ticked.is_empty()).then_some(Answer::Several(ticked));
cx.notify();
})
})
.into_any_element()
}
Question::Text(_) => {
let field = sheet.read(cx).fields[ix]
.clone()
.expect("a field for words");
Input::new(&field).into_any_element()
}
};
div()
.flex()
.flex_col()
.gap_2()
.child(
div().flex().child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(question.title().clone()),
),
)
.child(input)
});
let nudged = sheet.read(cx).nudged && !complete;
let (submitted, focus, asked) = (sheet.clone(), action.clone(), questions.clone());
div()
.flex()
.flex_col()
.gap_5()
.text_size(theme.text_size(TextSize::Sm))
.children(items)
.when(nudged, |survey| {
survey.child(
div()
.debug_selector(|| "survey-answer-first".into())
.text_color(colors.danger)
.child("Answer each question first."),
)
})
.child(
div()
.debug_selector(|| "survey-action".into())
.flex()
.child(
Button::new((id, "submit-button"), "Submit")
.variant(ButtonVariant::Primary)
.focus_handle(&action)
.on_click(move |_, window, cx| {
let given: Option<Vec<Answer>> =
answers(&asked, submitted.read(cx), cx)
.into_iter()
.collect();
let Some(given) = given else {
log::info!("survey: an answer is missing");
submitted.update(cx, |sheet, cx| {
sheet.nudged = true;
cx.notify();
});
return;
};
log::info!("survey: sent {} answers", given.len());
on_submit(&given, window, cx);
submitted.update(cx, |sheet, cx| {
sheet.sent = true;
cx.notify();
});
window.focus(&focus, cx);
}),
),
)
.into_any_element()
}
}