use crate::{icons, stack};
use gpui::{
Anchor, AnyElement, ElementId, IntoElement, Pixels, Point, SharedString, div, prelude::*, px,
};
use icons::Icon;
use motion::{self as motion, AnimationExt as _, Fade, PULSE, Painter};
use theme::{TextStyle, Theme, Typeset, hairline, ink};
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Loadable<T> {
#[default]
Idle,
Loading,
Ready(T),
Error(String),
}
impl<T> Loadable<T> {
pub fn ready(&self) -> Option<&T> {
match self {
Loadable::Ready(value) => Some(value),
_ => None,
}
}
pub fn is_loading(&self) -> bool {
matches!(self, Loadable::Loading)
}
pub fn error(&self) -> Option<&str> {
match self {
Loadable::Error(message) => Some(message),
_ => None,
}
}
}
pub struct Popup<T> {
inner: Option<(T, Option<web_time::Instant>)>,
pressed_while_open: bool,
generation: u64,
}
impl<T> Default for Popup<T> {
fn default() -> Self {
Self {
inner: None,
pressed_while_open: false,
generation: 0,
}
}
}
impl<T> Popup<T> {
pub fn open(&mut self, value: T) {
self.generation = self.generation.wrapping_add(1);
self.inner = Some((value, None));
}
pub fn is_open(&self) -> bool {
matches!(self.inner, Some((_, None)))
}
pub fn is_closing(&self) -> bool {
matches!(self.inner, Some((_, Some(_))))
}
pub fn closing_since(&self) -> Option<web_time::Instant> {
match &self.inner {
Some((_, Some(since))) => Some(*since),
_ => None,
}
}
pub fn get(&self) -> Option<&T> {
self.inner.as_ref().map(|(value, _)| value)
}
pub fn as_open(&self) -> Option<&T> {
match &self.inner {
Some((value, None)) => Some(value),
_ => None,
}
}
pub fn open_mut(&mut self) -> Option<&mut T> {
match &mut self.inner {
Some((value, None)) => Some(value),
_ => None,
}
}
pub fn close(&mut self) {
self.inner = None;
}
pub fn begin_close(&mut self) -> bool {
match &mut self.inner {
Some((_, closing @ None)) => {
self.generation = self.generation.wrapping_add(1);
*closing = Some(web_time::Instant::now());
true
}
_ => false,
}
}
pub fn note_trigger_press(&mut self) {
self.note_trigger_press_matching(|_| true);
}
pub fn note_trigger_press_matching(&mut self, owns: impl FnOnce(&T) -> bool) {
self.pressed_while_open = self.inner.as_ref().is_some_and(|(value, _)| owns(value));
}
pub fn take_press_was_open(&mut self) -> bool {
std::mem::take(&mut self.pressed_while_open)
}
pub fn finish_close(&mut self) {
if matches!(&self.inner, Some((_, Some(_)))) {
self.inner = None;
}
}
}
pub fn reap_popup<V: 'static, T: 'static>(
view: &mut V,
cx: &mut gpui::Context<V>,
popup: impl Fn(&mut V) -> &mut Popup<T> + 'static,
) {
let generation = popup(view).generation;
cx.spawn(async move |view, cx| {
cx.background_executor()
.timer(
motion::MENU_OUT
.total()
.mul_f32(motion::speed_scale())
.saturating_add(std::time::Duration::from_millis(20)),
)
.await;
view.update(cx, |view, cx| {
let popup = popup(view);
if popup.generation == generation && popup.is_closing() {
popup.finish_close();
cx.notify();
}
})
.ok();
})
.detach();
}
pub fn close_popup<V: 'static, T: 'static>(
view: &mut V,
cx: &mut gpui::Context<V>,
popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
) {
if popup(view).begin_close() {
reap_popup(view, cx, popup);
cx.notify();
}
}
pub fn dismiss_on_out<V: 'static, T: 'static, E: gpui::InteractiveElement>(
el: E,
popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
cx: &gpui::Context<V>,
) -> E {
el.on_mouse_down_out(cx.listener(move |view, _: &gpui::MouseDownEvent, _, cx| {
close_popup(view, cx, popup);
cx.stop_propagation();
}))
}
pub fn menu_trigger<V: 'static, T: 'static, E: gpui::StatefulInteractiveElement>(
el: E,
popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
value: impl Fn(&gpui::ClickEvent) -> T + 'static,
cx: &gpui::Context<V>,
) -> E {
menu_trigger_matching(el, popup, |_| true, value, cx)
}
pub fn menu_trigger_matching<V: 'static, T: 'static, E: gpui::StatefulInteractiveElement>(
el: E,
popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
owns: impl Fn(&T) -> bool + 'static,
value: impl Fn(&gpui::ClickEvent) -> T + 'static,
cx: &gpui::Context<V>,
) -> E {
trigger_press_matching(el, popup, owns, cx).on_click(cx.listener(
move |view, event: &gpui::ClickEvent, _, cx| {
if popup(view).take_press_was_open() {
close_popup(view, cx, popup);
} else {
popup(view).open(value(event));
}
cx.notify();
},
))
}
pub fn trigger_press<V: 'static, T: 'static, E: gpui::InteractiveElement>(
el: E,
popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
cx: &gpui::Context<V>,
) -> E {
trigger_press_matching(el, popup, |_| true, cx)
}
pub fn trigger_press_matching<V: 'static, T: 'static, E: gpui::InteractiveElement>(
el: E,
popup: impl Fn(&mut V) -> &mut Popup<T> + Copy + 'static,
owns: impl Fn(&T) -> bool + 'static,
cx: &gpui::Context<V>,
) -> E {
el.capture_any_mouse_down(cx.listener(move |view, _: &gpui::MouseDownEvent, _, _| {
popup(view).note_trigger_press_matching(&owns);
}))
}
pub fn menu_step(active: Option<usize>, count: usize, delta: isize) -> Option<usize> {
if count == 0 {
return None;
}
let count_i = count as isize;
let next = match active {
None => {
if delta >= 0 {
0
} else {
count_i - 1
}
}
Some(at) => (at as isize + delta).rem_euclid(count_i),
};
Some(next as usize)
}
pub fn match_rank(query: &str, label: &str) -> Option<usize> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Some(1);
}
let label = label.to_lowercase();
if label.starts_with(&query) {
Some(0)
} else if label.contains(&query) {
Some(1)
} else {
None
}
}
pub fn filter_indices<S: AsRef<str>>(query: &str, labels: &[S]) -> Vec<usize> {
let mut ranked: Vec<(usize, usize)> = labels
.iter()
.enumerate()
.filter_map(|(ix, label)| match_rank(query, label.as_ref()).map(|rank| (rank, ix)))
.collect();
ranked.sort_by_key(|&(rank, ix)| (rank, ix));
ranked.into_iter().map(|(_, ix)| ix).collect()
}
pub struct Filter {
items: Vec<SharedString>,
filtered: Vec<usize>,
active: Option<usize>,
}
impl Filter {
pub fn new(items: Vec<SharedString>) -> Self {
let filtered: Vec<usize> = (0..items.len()).collect();
let active = (!filtered.is_empty()).then_some(0);
Self {
items,
filtered,
active,
}
}
pub fn items(&self) -> &[SharedString] {
&self.items
}
pub fn filtered(&self) -> &[usize] {
&self.filtered
}
pub fn active(&self) -> Option<usize> {
self.active
}
pub fn refilter(&mut self, query: &str) {
self.filtered = filter_indices(query, &self.items);
self.active = (!self.filtered.is_empty()).then_some(0);
}
pub fn step(&mut self, delta: isize) {
self.active = menu_step(self.active, self.filtered.len(), delta);
}
pub fn set_active(&mut self, position: usize) {
if position < self.filtered.len() {
self.active = Some(position);
}
}
pub fn active_item(&self) -> Option<usize> {
self.active
.and_then(|position| self.filtered.get(position))
.copied()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuKey {
Up,
Down,
Enter,
ModEnter,
Escape,
Backspace,
Other,
}
pub fn classify_key(key: &str, cmd: bool, ctrl: bool) -> MenuKey {
match key {
"up" => MenuKey::Up,
"down" => MenuKey::Down,
"n" if ctrl => MenuKey::Down,
"p" if ctrl => MenuKey::Up,
"enter" if cmd || ctrl => MenuKey::ModEnter,
"enter" => MenuKey::Enter,
"escape" => MenuKey::Escape,
"backspace" => MenuKey::Backspace,
_ => MenuKey::Other,
}
}
pub(crate) const MENU_PAD: f32 = 4.0;
pub fn popover_card(theme: &Theme) -> gpui::Div {
let card = div()
.rounded(px(Theme::surface_radius()))
.p(px(MENU_PAD))
.overflow_hidden()
.text_style(TextStyle::Body)
.text_color(theme.text);
if theme.glass {
card
} else {
card.bg(theme.surface_overlay)
.border_1()
.border_color(hairline(0.10))
.shadow_lg()
}
}
pub fn popover_card_flush(theme: &Theme) -> gpui::Div {
popover_card(theme).p(px(0.0))
}
fn pinned_layer(layer: AnyElement) -> AnyElement {
div()
.absolute()
.top_0()
.left_0()
.size_0()
.child(layer)
.into_any_element()
}
fn exit_progress(since: web_time::Instant) -> f32 {
let total = motion::MENU_OUT
.total()
.mul_f32(motion::speed_scale())
.as_secs_f32();
let raw = if total <= 0.0 {
1.0
} else {
(since.elapsed().as_secs_f32() / total).clamp(0.0, 1.0)
};
motion::MENU_OUT.progress(raw)
}
fn material_menu(content: AnyElement) -> AnyElement {
crate::surface::popover(Theme::surface_radius(), content).into_any_element()
}
fn menu_motion(id: SharedString, exit: Option<f32>, inner: gpui::Div) -> AnyElement {
if let Some(t) = exit {
let inner = inner.relative().child(div().absolute().inset_0().occlude());
motion::menu_out(SharedString::from(format!("{id}-out")), t, inner).into_any_element()
} else {
motion::menu_in(id, inner).into_any_element()
}
}
fn menu_layer(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<web_time::Instant>,
anchor: Anchor,
position: Option<Point<Pixels>>,
gap: f32,
) -> AnyElement {
let content = material_menu(content);
let inner = match anchor {
Anchor::BottomLeft | Anchor::BottomRight => div().occlude().pb(px(gap)),
_ => div().occlude().pt(px(gap)),
}
.child(content);
let mut layer = gpui::anchored()
.anchor(anchor)
.snap_to_window_with_margin(px(8.0));
if let Some(position) = position {
layer = layer.position(position);
}
gpui::deferred(layer.child(menu_motion(id.into(), closing.map(exit_progress), inner)))
.priority(1)
.into_any_element()
}
pub fn anchored_menu(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<web_time::Instant>,
) -> AnyElement {
pinned_layer(menu_layer(id, content, closing, Anchor::TopLeft, None, 6.0))
}
pub fn anchored_menu_below(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<web_time::Instant>,
) -> AnyElement {
anchored_menu_below_gap(id, content, closing, 6.0)
}
pub fn anchored_menu_below_gap(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<web_time::Instant>,
gap: f32,
) -> AnyElement {
div()
.absolute()
.bottom_0()
.left_0()
.size_0()
.child(menu_layer(id, content, closing, Anchor::TopLeft, None, gap))
.into_any_element()
}
pub fn anchored_submenu(id: impl Into<SharedString>, content: AnyElement) -> AnyElement {
div()
.absolute()
.top(px(-MENU_PAD))
.right(px(-MENU_PAD))
.size_0()
.child(menu_layer(id, content, None, Anchor::TopLeft, None, 0.0))
.into_any_element()
}
pub fn anchored_menu_above(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<web_time::Instant>,
) -> AnyElement {
pinned_layer(menu_layer(
id,
content,
closing,
Anchor::BottomLeft,
None,
6.0,
))
}
pub fn anchored_menu_above_at(
id: impl Into<SharedString>,
position: Point<Pixels>,
content: AnyElement,
closing: Option<web_time::Instant>,
) -> AnyElement {
div()
.absolute()
.left(position.x)
.top(position.y)
.size_0()
.child(anchored_menu_above(id, content, closing))
.into_any_element()
}
pub fn anchored_menu_above_end(
id: impl Into<SharedString>,
content: AnyElement,
closing: Option<web_time::Instant>,
) -> AnyElement {
div()
.absolute()
.top_0()
.right_0()
.size_0()
.child(menu_layer(
id,
content,
closing,
Anchor::BottomRight,
None,
6.0,
))
.into_any_element()
}
pub fn menu_at(
id: impl Into<SharedString>,
position: Point<Pixels>,
content: AnyElement,
closing: Option<web_time::Instant>,
) -> AnyElement {
menu_layer(id, content, closing, Anchor::TopLeft, Some(position), 0.0)
}
pub(crate) fn scrim_alpha(alpha_dark: f32) -> gpui::Hsla {
theme::scrim(alpha_dark)
}
pub fn modal(
id: impl Into<ElementId>,
viewport: gpui::Size<Pixels>,
card: AnyElement,
on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> AnyElement {
modal_with(id, viewport, card, DIALOG_RADIUS, 0.6, on_dismiss)
}
pub fn modal_glass(
id: impl Into<ElementId>,
viewport: gpui::Size<Pixels>,
card: AnyElement,
on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> AnyElement {
modal_with(
id,
viewport,
card,
Theme::surface_radius(),
0.35,
on_dismiss,
)
}
fn modal_with(
id: impl Into<ElementId>,
viewport: gpui::Size<Pixels>,
card: AnyElement,
corner_radius: f32,
scrim: f32,
on_dismiss: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> AnyElement {
let card = crate::surface::popover(corner_radius, card).into_any_element();
gpui::deferred(
gpui::anchored()
.position(gpui::point(px(0.0), px(0.0)))
.child(
div()
.occlude()
.w(viewport.width)
.h(viewport.height)
.bg(scrim_alpha(scrim))
.flex()
.items_center()
.justify_center()
.child(motion::dialog_in(
id,
div().child(card).on_mouse_down_out(on_dismiss),
)),
),
)
.priority(2)
.into_any_element()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Left,
Right,
Bottom,
}
impl Side {
fn axis(self) -> gpui::Axis {
match self {
Side::Left | Side::Right => gpui::Axis::Horizontal,
Side::Bottom => gpui::Axis::Vertical,
}
}
}
const DIALOG_RADIUS: f32 = 16.0;
pub fn sheet_panel(theme: &Theme, side: Side) -> gpui::Div {
let card = div()
.size_full()
.flex()
.flex_col()
.shadow_lg()
.text_color(theme.text);
let card = match side {
Side::Left => card
.rounded_r(px(DIALOG_RADIUS))
.border_r_1()
.border_color(hairline(0.10)),
Side::Right => card
.rounded_l(px(DIALOG_RADIUS))
.border_l_1()
.border_color(hairline(0.10)),
Side::Bottom => card
.rounded_t(px(DIALOG_RADIUS))
.border_t_1()
.border_color(hairline(0.10)),
};
if theme.glass {
card.bg(theme.glass_overlay())
} else {
card.bg(theme.surface_overlay)
}
}
pub fn sheet(
id: impl Into<SharedString>,
viewport: gpui::Size<Pixels>,
side: Side,
extent: Pixels,
content: AnyElement,
closing: Option<web_time::Instant>,
on_dismiss: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static,
) -> AnyElement {
let id = id.into();
let exit = closing.map(exit_progress);
let panel = div().absolute();
let panel = match side.axis() {
gpui::Axis::Horizontal => panel.top_0().bottom_0().w(extent),
gpui::Axis::Vertical => panel.left_0().right_0().h(extent),
};
let panel = panel.child(crate::surface::popover(DIALOG_RADIUS, content));
let seat = move |el: gpui::Div, t: f32| {
let inset = extent * (t - 1.0);
match side {
Side::Left => el.left(inset),
Side::Right => el.right(inset),
Side::Bottom => el.bottom(inset),
}
};
let panel = if let Some(t) = exit {
let panel = seat(panel, 1.0 - t).child(div().absolute().inset_0().occlude());
panel
.with_animation(
SharedString::from(format!("{id}-out")),
motion::MENU_OUT.animation(),
move |el, _| el,
)
.into_any_element()
} else {
panel
.with_animation(id.clone(), motion::DIALOG_IN.animation(), seat)
.into_any_element()
};
gpui::deferred(
gpui::anchored()
.position(gpui::point(px(0.0), px(0.0)))
.child(
div()
.id(SharedString::from(format!("{id}-scrim")))
.occlude()
.relative()
.w(viewport.width)
.h(viewport.height)
.bg(scrim_alpha(0.6 * (1.0 - exit.unwrap_or(0.0))))
.on_click(on_dismiss)
.child(panel),
),
)
.priority(2)
.into_any_element()
}
pub fn menu_row(theme: &Theme, active: bool, fade: Option<Fade>) -> gpui::Div {
let row = div()
.flex()
.flex_row()
.items_center()
.gap(px(10.0))
.px(px(8.0))
.py(px(6.0))
.rounded(px(Theme::inset_radius(Theme::surface_radius(), MENU_PAD)))
.text_style(TextStyle::Body)
.cursor_pointer();
match (active, fade) {
(true, _) => row.bg(theme.card_selected_bg()).text_color(theme.text),
(false, None) => row.text_color(theme.text.opacity(0.9)),
(false, Some(fade)) => {
let mut row = row
.text_color(motion::hover_blend(
&fade,
theme.text.opacity(0.9),
theme.text,
))
.bg(motion::hover_blend(
&fade,
theme::ink(0.0),
theme.element_hover,
));
row.interactivity().on_hover(motion::hover_listener(fade));
row
}
}
}
pub fn menu_heading(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
let label = label.into();
div()
.px(px(8.0))
.pb(px(4.0))
.pt(px(6.0))
.text_style(TextStyle::Caption2)
.text_color(theme.text_muted.opacity(0.6))
.child(tracked_upper(&label))
}
pub fn tracked_upper(label: &str) -> String {
let upper = label.to_uppercase();
let mut out = String::with_capacity(upper.len() * 2);
let mut first = true;
for ch in upper.chars() {
if !first {
out.push('\u{200A}'); }
out.push(ch);
first = false;
}
out
}
pub fn divider() -> gpui::Div {
div().h(px(1.0)).mx(px(-4.0)).my(px(4.0)).bg(hairline(0.07))
}
pub fn band() -> gpui::Hsla {
theme::band()
}
pub fn key_cap(_theme: &Theme) -> gpui::Div {
div()
.h(px(22.0))
.px(px(5.0))
.rounded(px(5.0))
.flex()
.flex_row()
.items_center()
.justify_center()
.gap(px(4.0))
.bg(ink(0.05))
}
fn key_hint_label(theme: &Theme, label: &'static str) -> gpui::Div {
div()
.text_style(TextStyle::Caption)
.text_color(theme.text_muted.opacity(0.45))
.child(SharedString::from(label))
}
pub fn key_hint(theme: &Theme, icon: impl Into<Icon>, label: &'static str) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(5.0))
.child(
key_cap(theme).child(
crate::icons::icon(icon)
.size(px(12.5))
.text_color(theme.text_muted.opacity(0.7)),
),
)
.child(key_hint_label(theme, label))
}
pub fn key_hint_text(
theme: &Theme,
cap: impl Into<SharedString>,
label: &'static str,
) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(5.0))
.child(
key_cap(theme)
.text_style(TextStyle::Subheadline)
.font_family(theme.font_mono.clone())
.text_color(theme.text_muted.opacity(0.7))
.child(cap.into()),
)
.child(key_hint_label(theme, label))
}
pub fn key_hint_pair(
theme: &Theme,
first: impl Into<Icon>,
second: impl Into<Icon>,
label: &'static str,
) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.gap(px(5.0))
.child(
key_cap(theme)
.child(
crate::icons::icon(first)
.size(px(12.5))
.text_color(theme.text_muted.opacity(0.7)),
)
.child(div().w(px(1.0)).h(px(11.0)).bg(hairline(0.10)))
.child(
crate::icons::icon(second)
.size(px(12.5))
.text_color(theme.text_muted.opacity(0.7)),
),
)
.child(key_hint_label(theme, label))
}
pub fn kbd_hint(theme: &Theme, label: impl Into<SharedString>) -> gpui::Div {
div()
.flex_none()
.px(px(5.0))
.py(px(1.0))
.rounded(px(5.0))
.bg(ink(0.05))
.text_style(TextStyle::Caption)
.font_family(theme.font_mono.clone())
.text_color(theme.text_muted.opacity(0.6))
.child(label.into())
}
pub fn search_line(theme: &Theme, input: AnyElement) -> gpui::Div {
stack::row()
.mx(px(-MENU_PAD))
.px(px(MENU_PAD + 8.0))
.py(px(7.0))
.mb(px(MENU_PAD))
.border_b_1()
.border_color(hairline(0.07))
.text_style(TextStyle::Body)
.child(
icons::icon(icons::glyph::Search)
.size(px(13.0))
.text_color(theme.text_faint),
)
.child(div().flex_1().child(input))
}
pub fn menu_section() -> gpui::Div {
div()
.mt(px(4.0))
.pt(px(4.0))
.border_t_1()
.border_color(hairline(0.06))
.flex()
.flex_col()
.gap(px(2.0))
}
pub fn dialog_card(theme: &Theme) -> gpui::Div {
div()
.w(px(360.0))
.p(px(20.0))
.rounded(px(DIALOG_RADIUS))
.bg(theme.surface_dialog)
.border_1()
.border_color(hairline(0.10))
.shadow_lg()
.flex()
.flex_col()
.text_color(theme.text)
}
pub fn dialog_title(theme: &Theme, title: impl Into<SharedString>) -> gpui::Div {
div()
.text_style(TextStyle::Headline)
.text_color(theme.text)
.child(title.into())
}
pub fn dialog_body(theme: &Theme, copy: impl Into<SharedString>) -> gpui::Div {
div()
.text_style(TextStyle::Body)
.line_height(px(19.0))
.text_color(theme.text_muted)
.child(copy.into())
}
pub fn dialog_field(input: AnyElement) -> gpui::Div {
div()
.w_full()
.px(px(12.0))
.py(px(8.0))
.rounded(px(Theme::button_radius()))
.border_1()
.border_color(hairline(0.08))
.bg(ink(0.04))
.text_style(TextStyle::Body)
.child(input)
}
pub fn redacted_rows(
_id: &'static str,
_theme: &Theme,
count: usize,
painter: Painter,
cx: &mut gpui::App,
) -> AnyElement {
let wash = ink(0.04);
let delta = motion::pulse_delta(&PULSE, painter, cx);
div()
.flex()
.flex_col()
.gap(px(6.0))
.py(px(4.0))
.children((0..count).map(move |i| {
let phase = motion::staggered_phase(delta, i, 0.08);
div()
.h(px(28.0))
.rounded(px(Theme::control_radius()))
.bg(wash)
.opacity(0.35 + 0.4 * motion::pulse_wave(phase))
}))
.into_any_element()
}
pub fn error_row(theme: &Theme, message: impl Into<SharedString>) -> gpui::Div {
div()
.flex()
.flex_col()
.gap(px(6.0))
.p(px(Theme::SPACE))
.text_style(TextStyle::Callout)
.text_color(theme.danger)
.child(message.into())
}