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>
226 lines
8.6 KiB
Rust
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),
|
|
)
|
|
}
|
|
}
|