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