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}