Files
Ely-GPUI-Components/src/generative/compare.rs
T
ZacharyZhang-NY 03e36ab0ef Generative: a picture with nothing to open takes no focus
A done result tracks its focus only beside its press, so a grid with
no open handler leaves focus where it was. A decided comparison drops
its empty verdict row, so no gap is left under the sides.
2026-09-26 12:44:18 -04:00

203 lines
6.9 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
data_display::{Badge, Tone},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::Ellipsis,
};
/// Which answer won a comparison, or neither.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Side(usize),
Tie,
}
type OnVerdict = Rc<dyn Fn(Verdict, &mut Window, &mut App)>;
/// A side's heading: its model's name, or a letter while the comparison is blind and undecided.
pub(crate) fn heading(ix: usize, name: &SharedString, blind: bool, decided: bool) -> SharedString {
match blind && !decided {
true => format!("Model {}", char::from(b'A' + ix as u8)).into(),
false => name.clone(),
}
}
/// Answers from two to four models side by side, each under its model's name, with a way to pick the better or call a tie. Blind, the names stay letters until the verdict; after it, the winner takes accent. Narrow, the sides stack.
#[derive(IntoElement)]
pub struct ABCompareView {
id: ElementId,
sides: Vec<(SharedString, AnyElement)>,
blind: bool,
verdict: Option<Verdict>,
on_verdict: Option<OnVerdict>,
}
impl ABCompareView {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
sides: Vec::new(),
blind: false,
verdict: None,
on_verdict: None,
}
}
/// One model's answer under its name.
pub fn side(mut self, name: impl Into<SharedString>, answer: impl IntoElement) -> Self {
self.sides.push((name.into(), answer.into_any_element()));
self
}
/// Hides the names until the verdict.
pub fn blind(mut self) -> Self {
self.blind = true;
self
}
pub fn verdict(mut self, verdict: Verdict) -> Self {
self.verdict = Some(verdict);
self
}
pub fn on_verdict(
mut self,
handler: impl Fn(Verdict, &mut Window, &mut App) + 'static,
) -> Self {
self.on_verdict = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ABCompareView {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.sides.len();
assert!((2..=4).contains(&count), "a comparison of {count} sides");
if let Some(Verdict::Side(ix)) = self.verdict {
assert!(ix < count, "verdict for side {ix} of {count}");
}
let theme = cx.theme();
let colors = theme.colors.clone();
let decided = self.verdict.is_some();
let judge = |verdict: Verdict, label: SharedString, key: String| {
self.on_verdict.clone().map(|judge| {
Button::new((self.id.clone(), key), label)
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("comparison: {verdict:?}");
judge(verdict, window, cx)
})
})
};
let mut titles = Vec::new();
let sides: Vec<_> = self
.sides
.into_iter()
.enumerate()
.map(|(ix, (name, answer))| {
let won = self.verdict == Some(Verdict::Side(ix));
let title = heading(ix, &name, self.blind, decided);
titles.push(title.clone());
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.gap_3()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(if won { colors.accent } else { colors.border })
.bg(colors.surface)
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(Ellipsis::new(title)),
)
.when(won, |row| {
row.child(
div()
.flex_none()
.child(Badge::new("Preferred").tone(Tone::Accent)),
)
}),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(answer),
)
})
.collect();
let verdicts = match self.verdict {
Some(Verdict::Tie) => vec![div().child("Called a tie").into_any_element()],
Some(Verdict::Side(_)) => Vec::new(),
None => titles
.iter()
.enumerate()
.filter_map(|(ix, title)| {
judge(
Verdict::Side(ix),
format!("{title} is better").into(),
format!("better-{ix}"),
)
.map(IntoElement::into_any_element)
})
.chain(
judge(Verdict::Tie, "Tie".into(), "tie".into())
.map(IntoElement::into_any_element),
)
.collect(),
};
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(div().w_full().flex().flex_wrap().gap_3().children(sides))
.when(!verdicts.is_empty(), |column| {
column.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.children(verdicts),
)
})
}
}
#[cfg(test)]
mod tests {
use super::heading;
#[test]
fn a_blind_side_shows_a_letter_until_the_verdict() {
let name = "Orchid".into();
assert_eq!(heading(1, &name, true, false), "Model B");
assert_eq!(heading(1, &name, true, true), "Orchid");
assert_eq!(heading(0, &name, false, false), "Orchid");
}
}