Skip to main content

gpui_ai/
image_generation.rs

1//! Progressive reveal for image generation.
2
3use crate::motion::{MotionTokens, VisibleAnimationExt as _};
4use crate::theme::SemanticStyledExt as _;
5use gpui::{
6    AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
7    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
8    Window, div, prelude::FluentBuilder as _, px,
9};
10use gpui_component::{ActiveTheme as _, Icon, IconName, StyledExt as _, h_flex, v_flex};
11
12/// An image-generation frame: shimmer while working, top-down reveal as
13/// progress advances, the finished image when done.
14///
15/// The component is presentation-only: the caller owns generation progress
16/// (`0.0..=1.0`) and supplies the image element when (or as) it is
17/// available via [`Self::image`].
18///
19/// # Example
20///
21/// ```no_run
22/// # use gpui_ai::prelude::*;
23/// # use gpui::{img, Styled};
24/// # fn example(generation_progress: f32) {
25/// ImageGeneration::new("gen-1")
26///     .label("A lighthouse at dusk, oil on canvas")
27///     .progress(generation_progress)
28///     .image(img("out/lighthouse.png").size_full());
29/// # }
30/// ```
31#[derive(IntoElement)]
32pub struct ImageGeneration {
33    id: ElementId,
34    style: StyleRefinement,
35    width: Pixels,
36    height: Pixels,
37    progress: f32,
38    label: Option<SharedString>,
39    image: Option<AnyElement>,
40}
41
42/// Default frame width: a 3:2 media box that fits a chat column.
43/// Both dimensions are builder-overridable per instance.
44const DEFAULT_FRAME_WIDTH: gpui::Pixels = px(240.);
45
46/// Default frame height for the 3:2 media box.
47const DEFAULT_FRAME_HEIGHT: gpui::Pixels = px(160.);
48
49impl ImageGeneration {
50    /// Creates a 240×160 frame at zero progress.
51    pub fn new(id: impl Into<ElementId>) -> Self {
52        Self {
53            id: id.into(),
54            style: StyleRefinement::default(),
55            width: DEFAULT_FRAME_WIDTH,
56            height: DEFAULT_FRAME_HEIGHT,
57            progress: 0.0,
58            label: None,
59            image: None,
60        }
61    }
62
63    /// Sets the frame dimensions.
64    pub fn frame(mut self, width: impl Into<Pixels>, height: impl Into<Pixels>) -> Self {
65        self.width = width.into();
66        self.height = height.into();
67        self
68    }
69
70    /// Sets generation progress in `0.0..=1.0` (clamped). Below `1.0` the
71    /// frame stays partially veiled with a percentage readout.
72    pub fn progress(mut self, progress: f32) -> Self {
73        self.progress = progress.clamp(0.0, 1.0);
74        self
75    }
76
77    /// Sets the prompt or caption shown under the frame.
78    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
79        self.label = Some(label.into());
80        self
81    }
82
83    /// Supplies the image element (typically `gpui::img(...)`). The caller
84    /// sizes it; `size_full()` fills the frame.
85    pub fn image(mut self, image: impl IntoElement) -> Self {
86        self.image = Some(image.into_any_element());
87        self
88    }
89}
90
91impl Styled for ImageGeneration {
92    fn style(&mut self) -> &mut StyleRefinement {
93        &mut self.style
94    }
95}
96
97impl RenderOnce for ImageGeneration {
98    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
99        let full = crate::motion::motion_is_full(cx);
100        let tokens = cx.theme().semantic_tokens();
101        let generating = self.progress < 1.0;
102        let veil_height = self.height * (1.0 - self.progress);
103        let has_image = self.image.is_some();
104        let accessibility_label = self
105            .label
106            .clone()
107            .unwrap_or_else(|| "Image generation".into());
108
109        v_flex()
110            .id(self.id)
111            .role(Role::ProgressIndicator)
112            .aria_label(accessibility_label)
113            .aria_min_numeric_value(0.)
114            .aria_max_numeric_value(100.)
115            .aria_numeric_value((self.progress * 100.0) as f64)
116            .gap(tokens.spacing.xs)
117            .child(
118                div()
119                    .relative()
120                    .w(self.width)
121                    .h(self.height)
122                    .overflow_hidden()
123                    .border_1()
124                    .border_color(cx.theme().border)
125                    .rounded(tokens.radius.lg)
126                    .bg(cx.theme().muted.opacity(0.4))
127                    .map(|this| match self.image {
128                        Some(image) => this.child(div().size_full().child(image)),
129                        None => this.child(
130                            // Placeholder: a centered icon breathing until
131                            // pixels arrive.
132                            div()
133                                .size_full()
134                                .flex()
135                                .items_center()
136                                .justify_center()
137                                .child(
138                                    Icon::new(IconName::Palette)
139                                        .text_color(cx.theme().muted_foreground),
140                                )
141                                .with_visible_animation(
142                                    "image-pulse",
143                                    // Frame demand: active only on this
144                                    // placeholder branch. Once the caller
145                                    // supplies an image the animated element
146                                    // is not built at all, so a finished
147                                    // frame demands nothing. Reduced motion
148                                    // holds delta at 0 — a fully opaque icon.
149                                    MotionTokens::read(cx).image_pulse().looping(),
150                                    move |this, delta| {
151                                        let delta = if full { delta } else { 0.0 };
152                                        let wave = (delta * 2.0 - 1.0).abs();
153                                        this.opacity(0.35 + 0.65 * wave)
154                                    },
155                                ),
156                        ),
157                    })
158                    .when(generating && has_image, |this| {
159                        // Top-down reveal: the unrevealed remainder stays
160                        // veiled in the theme background.
161                        this.child(
162                            div()
163                                .absolute()
164                                .left_0()
165                                .right_0()
166                                .bottom_0()
167                                .h(veil_height)
168                                .bg(cx.theme().background.opacity(0.85)),
169                        )
170                    })
171                    .when(generating, |this| {
172                        this.child(
173                            div()
174                                .absolute()
175                                .bottom(tokens.spacing.xs)
176                                .right(tokens.spacing.xs)
177                                .px(tokens.spacing.xs)
178                                .py(tokens.spacing.xxs)
179                                .rounded(tokens.radius.full)
180                                .bg(cx.theme().background.opacity(0.8))
181                                .text_token(tokens.typography.xs)
182                                .font_family(cx.theme().mono_font_family.clone())
183                                .text_color(cx.theme().muted_foreground)
184                                .child(format!("{:.0}%", self.progress * 100.0)),
185                        )
186                    }),
187            )
188            .when_some(self.label, |this, label| {
189                this.child(
190                    h_flex().w(self.width).child(
191                        div()
192                            .truncate()
193                            .text_token(tokens.typography.xs)
194                            .italic()
195                            .text_color(cx.theme().muted_foreground)
196                            .child(label),
197                    ),
198                )
199            })
200            .refine_style(&self.style)
201    }
202}