guise-ui 1.4.0

A component library for gpui, Zed's GPU-accelerated UI framework: a themed palette, sizing tokens, 130+ composable components, a reactive state layer, and an in-app Safari-style inspector.
Documentation
//! `AISources` — the list a reply's citations point at.
//!
//! Citations are only checkable if the reader can get from the number to the
//! thing. This is that list: numbered to match, each row clickable, each
//! carrying the excerpt the model actually used when the provider returned
//! one — because "it says so on this page" and "it says so in this sentence"
//! are different claims.

use std::rc::Rc;

use gpui::prelude::*;
use gpui::{div, px, App, IntoElement, SharedString, Window};

use super::AISource;
use crate::devtools::Probed;
use crate::icon::{Icon, IconName};
use crate::theme::{theme, ColorName, Size};

/// Called with the 0-based index of the source that was clicked.
type SourceHandler = Rc<dyn Fn(usize, &mut Window, &mut App) + 'static>;

/// A numbered list of sources.
#[derive(IntoElement)]
pub struct AISources {
    sources: Vec<AISource>,
    title: Option<SharedString>,
    size: Size,
    /// Show each source's excerpt under its title.
    excerpts: bool,
    on_open: Option<SourceHandler>,
}

impl AISources {
    pub fn new(sources: impl IntoIterator<Item = AISource>) -> Self {
        AISources {
            sources: sources.into_iter().collect(),
            title: None,
            size: Size::Xs,
            excerpts: false,
            on_open: None,
        }
    }

    /// A heading above the list. Defaults to none, since a reply that ends in
    /// a numbered list of links rarely needs one.
    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
        self.title = Some(title.into());
        self
    }

    pub fn size(mut self, size: Size) -> Self {
        self.size = size;
        self
    }

    /// Show the passage each source contributed, where there is one.
    pub fn excerpts(mut self, excerpts: bool) -> Self {
        self.excerpts = excerpts;
        self
    }

    /// Handle a click on a row. Without one the list is not interactive —
    /// guise has no opinion on how a host opens a URL.
    pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
        self.on_open = Some(Rc::new(handler));
        self
    }
}

impl RenderOnce for AISources {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let t = theme(cx);
        let font = t.font_size(self.size);
        let dimmed = t.dimmed().hsla();
        let text_color = t.text().hsla();
        let border = t.border().hsla();
        let surface = t.surface_hover().alpha(0.5);
        let radius = t.radius(Size::Sm);

        let mut list = div().flex().flex_col().gap(px(4.0)).w_full();
        if let Some(title) = self.title {
            list = list.child(
                div()
                    .flex()
                    .items_center()
                    .gap(px(6.0))
                    .text_size(px(font))
                    .text_color(dimmed)
                    .child(
                        Icon::new(IconName::BookOpen)
                            .size(Size::Xs)
                            .color(ColorName::Gray),
                    )
                    .child(title),
            );
        }

        for (index, source) in self.sources.into_iter().enumerate() {
            let handler = self.on_open.clone();
            list = list.child(
                div()
                    .id(("guise-ai-source", index))
                    .flex()
                    .flex_row()
                    .items_start()
                    .gap(px(8.0))
                    .w_full()
                    .px(px(8.0))
                    .py(px(6.0))
                    .rounded(px(radius))
                    .border_1()
                    .border_color(border)
                    .text_size(px(font))
                    .when(handler.is_some(), |row| {
                        row.cursor_pointer().hover(move |row| row.bg(surface))
                    })
                    .child(
                        div()
                            .flex_none()
                            .min_w(px(font * 1.4))
                            .text_color(dimmed)
                            .child(SharedString::from((index + 1).to_string())),
                    )
                    .child(
                        div()
                            .flex()
                            .flex_col()
                            .gap(px(1.0))
                            .flex_1()
                            .min_w(px(0.0))
                            .child(
                                div()
                                    .text_color(text_color)
                                    .child(SharedString::from(source.title)),
                            )
                            .child(
                                div()
                                    .text_color(dimmed)
                                    .child(SharedString::from(source.location)),
                            )
                            .when_some(
                                source.excerpt.filter(|_| self.excerpts),
                                |column, excerpt| {
                                    column.child(
                                        div()
                                            .mt(px(4.0))
                                            .pl(px(8.0))
                                            .border_l_2()
                                            .border_color(border)
                                            .text_color(dimmed)
                                            .child(SharedString::from(excerpt)),
                                    )
                                },
                            ),
                    )
                    .when_some(handler, |row, handler| {
                        row.on_click(move |_event, window, cx| handler(index, window, cx))
                    }),
            );
        }
        list.probe("AISources")
    }
}