Files
Ely-GPUI-Components/src/misc/poll.rs
T
ZacharyZhang-NYandClaude Opus 5.5 6e02a0cce3 Misc · Asking and learning: a poll, a survey, a quiz and flashcards
misc::Poll turns to results once the owner holds the vote, with Change
vote on Vote's stop. misc::Survey asks on one page and sends when each
question is answered, thanking in Submit's place and focus. misc::Quiz
checks, goes on, scores and starts again on one button. misc::Flashcards
turn on a press and go round the deck, each face fading in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 02:38:33 -04:00

226 lines
8.6 KiB
Rust

use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, RadioGroup},
motion::ProgressBar,
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize, TextSize},
typography::{Ellipsis, format, tabular},
};
type OnVote = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
/// A question with options to vote on, one each. Once the owner holds the vote, the options turn to results: each one's share of the votes as a bar, the vote marked, and the count. Vote and Change vote share one focus handle.
#[derive(IntoElement)]
pub struct Poll {
id: ElementId,
question: SharedString,
options: Vec<SharedString>,
votes: Vec<u64>,
voted: Option<usize>,
on_vote: Option<OnVote>,
}
impl Poll {
pub fn new(
id: impl Into<ElementId>,
question: impl Into<SharedString>,
options: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let options: Vec<SharedString> = options.into_iter().map(Into::into).collect();
Self {
id: id.into(),
question: question.into(),
votes: vec![0; options.len()],
options,
voted: None,
on_vote: None,
}
}
/// Each option's count, in order.
pub fn votes(mut self, votes: impl IntoIterator<Item = u64>) -> Self {
self.votes = votes.into_iter().collect();
self
}
/// The option this viewer voted for, as the owner holds it.
pub fn voted(mut self, option: usize) -> Self {
self.voted = Some(option);
self
}
/// Runs with the option voted for, or none when Change vote takes the vote back.
pub fn on_vote(
mut self,
handler: impl Fn(Option<usize>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_vote = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Poll {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let count = self.options.len();
assert!(count > 0, "poll {id:?} has no options");
assert_eq!(
self.votes.len(),
count,
"poll {id:?} has {} counts for {count} options",
self.votes.len()
);
if let Some(voted) = self.voted {
assert!(
voted < count,
"poll {id:?}: vote {voted} of {count} options"
);
}
let on_vote = self
.on_vote
.unwrap_or_else(|| panic!("poll {id:?} has no on_vote"));
let pick = window.use_keyed_state((id.clone(), "pick"), cx, |_, _| None::<usize>);
if self.voted.is_some() && *pick.read(cx) != self.voted {
pick.update(cx, |pick, _| *pick = self.voted);
}
let action = tab_stop((id.clone(), "action").into(), true, window, cx);
let theme = cx.theme();
let colors = &theme.colors;
let picked = *pick.read(cx);
let body =
match self.voted {
None => {
let choices = self
.options
.iter()
.enumerate()
.map(|(ix, label)| Choice::new(ix.to_string(), label.clone()));
let chose = pick.clone();
let group = RadioGroup::new((id.clone(), "options"), choices).on_change(
move |value, _, cx| {
let ix = value.parse().expect("an option's place");
chose.update(cx, |pick, cx| {
*pick = Some(ix);
cx.notify();
})
},
);
match picked {
Some(ix) => group.selected(ix.to_string()),
None => group,
}
.into_any_element()
}
Some(voted) => {
let total: u64 = self.votes.iter().sum();
let rows = self.options.iter().zip(&self.votes).enumerate().map(
|(ix, (label, votes))| {
let share = match total {
0 => 0.0,
_ => *votes as f32 / total as f32,
};
let percent = format::percent(share as f64, 0, false);
div()
.debug_selector(|| format!("poll-{label}-{percent}"))
.flex()
.flex_col()
.gap_1()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child(Ellipsis::new(label.clone())),
)
.when(ix == voted, |row| {
row.child(
Icon::new(IconName::CircleCheck)
.size(IconSize::Sm)
.color(colors.accent),
)
})
.child(
tabular(div())
.flex_none()
.text_color(colors.fg_muted)
.child(percent.clone()),
),
)
.child(ProgressBar::new((id.clone(), format!("bar-{ix}")), share))
},
);
div()
.flex()
.flex_col()
.gap_3()
.children(rows)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(format::plural(total, "vote", "votes")),
)
.into_any_element()
}
};
let button = match self.voted {
None => {
let (focus, on_vote) = (action.clone(), on_vote.clone());
Button::new((id.clone(), "vote"), "Vote")
.variant(ButtonVariant::Primary)
.focus_handle(&action)
.disabled(picked.is_none())
.on_click(move |_, window, cx| {
let ix = picked.expect("Vote waits for a pick");
log::info!("poll: voted for option {ix}");
on_vote(Some(ix), window, cx);
window.focus(&focus);
})
}
Some(_) => {
let focus = action.clone();
Button::new((id.clone(), "change"), "Change vote")
.focus_handle(&action)
.on_click(move |_, window, cx| {
log::info!("poll: vote taken back");
on_vote(None, window, cx);
window.focus(&focus);
})
}
};
div()
.flex()
.flex_col()
.gap_3()
.child(
div().flex().child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.font_weight(FontWeight::SEMIBOLD)
.child(self.question),
),
)
.child(body)
.child(
div()
.debug_selector(|| "poll-action".into())
.flex()
.child(button),
)
}
}