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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
//! Alert — port of `@heroui/alert`.
use gpui::{
px, AnyElement, App, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
SharedString, Styled, Window,
};
use herogpui_core::Color;
use herogpui_theme::ActiveTheme;
use crate::icons;
/// HeroUI Alert.
///
/// v3.2.4's API table carries only `status`/`className`/`children`: the
/// migration guide explicitly removes `isClosable`, `onClose` and
/// `closeButtonProps`, so a close affordance is composed by the caller as an
/// ordinary child (a `CloseButton`) instead of being built in.
#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
#[derive(IntoElement)]
pub struct Alert {
title: SharedString,
description: Option<SharedString>,
color: Color,
/// `Alert.Indicator` children replace the status glyph while retaining
/// the pinned 24px indicator box and status-owned default fallback.
indicator: Option<AnyElement>,
/// Composed children — v3's "Additional content like buttons, close
/// button, etc.", appended after the content column.
children: Vec<AnyElement>,
/// The `sx` slot, refined over the root style at the end of render.
sx: Option<Box<gpui::StyleRefinement>>,
/// The corner radius, in place of the owning `control_radius` helper.
radius: Option<Pixels>,
}
impl Alert {
/// `status` — the v3 name for `color`; the values are the same
/// semantic roles.
pub fn status(mut self, status: Color) -> Self {
self.color = status;
self
}
/// Creates an alert with the given title.
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
description: None,
color: Color::Default,
indicator: None,
children: Vec::new(),
sx: None,
radius: None,
}
}
/// Sets the description shown under the title.
pub fn description(mut self, d: impl Into<SharedString>) -> Self {
self.description = Some(d.into());
self
}
/// Replaces the default status glyph inside the indicator slot. The
/// caller owns the child content; the surrounding box keeps HeroUI's
/// 4px inset and fixed 16px glyph footprint.
pub fn indicator(mut self, content: impl IntoElement) -> Self {
self.indicator = Some(content.into_any_element());
self
}
/// The corner radius, in place of the owning `control_radius` helper. Not a
/// v3 prop; the removed v2 `radius` prop is prohibited and this is a
/// per-component repository extension.
pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
self.radius = Some(radius.into());
self
}
/// The one slot for caller-owned low-level styling: GPUI's styling methods
/// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
/// applied to the alert's root element after every value the status and
/// the active theme chose, so they win.
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
crate::util::refine_sx(&mut self.sx, style);
self
}
}
impl ParentElement for Alert {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
// End content; see the struct doc for the v3 composition rationale.
self.children.extend(elements);
}
}
impl RenderOnce for Alert {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let sem = cx.role(self.color);
let colors = cx.colors();
let layout = cx.layout();
// v3 dropped Alert's `variant`: the color role is the only axis.
// `.alert` is `bg-surface` for every status -- the role never paints
// the container.
let bg = colors.surface.background;
let fg = colors.foreground;
// `.alert--default` paints the title *and* the indicator
// `text-foreground`; every status paints `text-{role}-soft-foreground`.
let role_fg = if self.color == Color::Default {
colors.foreground
} else {
sem.soft_foreground(colors.foreground)
};
// `alert.tsx`'s `getDefaultIcon`: accent and the `default` fall-through
// both draw the Info glyph; success the circled check (the pinned
// `SuccessIcon`), warning the triangle, danger the circle-exclamation.
let glyph = match self.color {
Color::Default | Color::Accent => icons::INFO_CIRCLE,
Color::Success => icons::CHECK_CIRCLE,
Color::Warning => icons::WARNING_TRIANGLE,
Color::Danger => icons::CIRCLE_EXCLAMATION,
};
let radius = self
.radius
.unwrap_or_else(|| crate::util::control_radius(cx));
let mut alert = gpui::div()
.flex()
.items_start()
.justify_start()
.gap(px(16.))
.w_full()
.px(px(16.))
.py(px(12.))
.rounded(radius)
.bg(bg)
.text_color(fg)
.debug_selector(|| "alert-root".to_owned());
// `shadow-surface` is the surface elevation token; dark mode leaves
// the token empty, and GPUI 0.2.2 paints an empty shadow list as
// nothing, so the token is applied unconditionally.
alert = alert.shadow(layout.surface_shadow.clone());
let default_indicator_glyph = gpui::svg()
.size(px(16.))
.path(glyph)
.text_color(role_fg)
.flex_shrink_0();
let indicator_glyph = self
.indicator
.unwrap_or_else(|| default_indicator_glyph.into_any_element());
alert = alert.child(
// `.alert__indicator` is a `p-1` box around the 16px glyph, centered.
gpui::div()
.p(px(4.))
.flex()
.items_center()
.justify_center()
.flex_shrink_0()
.debug_selector(|| "alert-indicator".to_owned())
.child(indicator_glyph),
);
// `.alert__content` is the column that holds the title and the
// description, beside the indicator. It carries no gap: the pinned
// rule is only `flex h-full grow flex-col items-start`.
// `min_w_0` is what lets the description wrap. A flex item's automatic
// minimum size is its content's, and gpui measures a text child's
// minimum as the whole string, so `grow` alone pushed the column --
// and the copy -- straight out through the alert's right edge.
let mut text_col = gpui::div()
.flex()
.flex_col()
.items_start()
.flex_1()
.min_w_0();
text_col = text_col.child(
gpui::div()
.text_size(px(14.)) // `.alert__title` is `text-sm leading-6 font-medium`.
.line_height(px(24.))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(role_fg)
.debug_selector(|| "alert-title".to_owned())
.child(self.title.to_string()),
);
text_col = text_col.debug_selector(|| "alert-content".to_owned());
if let Some(desc) = self.description {
text_col = text_col.child(
gpui::div()
// `.alert__description` is `text-sm`.
.text_size(px(14.))
// `text-sm`'s own line height is 20px.
.line_height(px(20.))
.text_color(colors.muted)
.debug_selector(|| "alert-description".to_owned())
.child(desc.to_string()),
);
}
alert = alert.child(text_col);
// Composed children go last; see the struct doc.
alert = alert.children(self.children);
alert = crate::util::apply_sx(alert, &self.sx);
alert
}
}
// The pinned `.alert` is `bg-surface` for every status: the role paints the
// indicator and the title only, never the container. A soft wash looks
// plausible on screen, so the check is mechanical.
#[cfg(test)]
mod painted_tokens {
fn implementation() -> &'static str {
include_str!("alert.rs")
.split("#[cfg(test)]")
.next()
.expect("the implementation section is always present")
}
#[test]
fn the_alert_container_is_always_surface() {
let source = implementation();
assert!(
source.contains("let bg = colors.surface.background;"),
"every status must paint `bg-surface` (pinned `.alert`)"
);
assert!(
!source.contains("sem.soft()"),
"no alert container may paint a role soft background"
);
}
#[test]
fn the_indicator_follows_the_status_soft_foreground() {
let source = implementation();
// The glyph's own `let` declaration is the structural unit: everything
// between `let indicator_glyph = gpui::svg()` and the statement's
// terminating `;` is the full builder chain that paints the glyph.
let chain = source
.split("let default_indicator_glyph = gpui::svg()")
.nth(1)
.expect("the indicator must paint the glyph as a declared svg element")
.split(';')
.next()
.expect("the declaration must terminate");
assert!(
chain.contains(".text_color(role_fg)"),
"the indicator must paint the same token as the title: \
`text-foreground` on default, `text-{{role}}-soft-foreground` \
otherwise (pinned `.alert__indicator`)"
);
assert!(
!chain.contains("colors.muted"),
"the default indicator is `text-foreground`, not the muted tone"
);
}
#[test]
fn custom_indicator_keeps_the_shared_indicator_box() {
let source = implementation();
assert!(source.contains("pub fn indicator(mut self, content: impl IntoElement)"));
assert!(source.contains(".indicator\n .unwrap_or_else"));
assert!(source.contains(".p(px(4.))"));
assert!(source.contains(".size(px(16.))"));
}
}
crate::util::impl_component_styled!(Alert);