@@ -7,7 +7,6 @@ use rust_embed::RustEmbed;
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
|
||||
+83
-82
@@ -24,7 +24,7 @@ use smallvec::{SmallVec, smallvec};
|
||||
use state::{
|
||||
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
@@ -903,9 +903,9 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.h_10()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_style(TextStyle::Callout)
|
||||
.bg(cx.theme().surface_raised)
|
||||
.rounded(px(theme::panel_radius()))
|
||||
.child("Message isn't traced yet"),
|
||||
)
|
||||
})
|
||||
@@ -918,10 +918,10 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
);
|
||||
@@ -950,10 +950,10 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
);
|
||||
@@ -975,7 +975,7 @@ impl ChatPanel {
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().ghost_element_background_alt),
|
||||
.text_color(cx.theme().surface_raised),
|
||||
)
|
||||
.message(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
@@ -990,14 +990,14 @@ impl ChatPanel {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.line_height(relative(1.3))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().ghost_element_active),
|
||||
.text_color(cx.theme().element_active),
|
||||
)
|
||||
.child(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
@@ -1013,15 +1013,15 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.size_8()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.bg(cx.theme().warning_muted)
|
||||
.text_color(cx.theme().warning)
|
||||
.child(Icon::new(IconName::Warning).small()),
|
||||
)
|
||||
.child(
|
||||
@@ -1161,7 +1161,7 @@ impl ChatPanel {
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().border_transparent),
|
||||
.bg(gpui::transparent_black()),
|
||||
)
|
||||
.overlay(self.render_actions(&id, &pk, cx))
|
||||
.on_mouse_down(
|
||||
@@ -1187,10 +1187,10 @@ impl ChatPanel {
|
||||
return div().child(
|
||||
img(media[0].clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.border_color(cx.theme().border_faint)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
.rounded(px(theme::bubble_radius())),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1201,7 +1201,7 @@ impl ChatPanel {
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
@@ -1215,8 +1215,8 @@ impl ChatPanel {
|
||||
img(item.clone())
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius),
|
||||
.border_color(cx.theme().border_faint)
|
||||
.rounded(px(theme::bubble_radius())),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1245,7 +1245,7 @@ impl ChatPanel {
|
||||
.px_2()
|
||||
.border_l_2()
|
||||
.border_color(cx.theme().element_active)
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(div().font_semibold().child(author.name()))
|
||||
.child(
|
||||
div()
|
||||
@@ -1254,7 +1254,7 @@ impl ChatPanel {
|
||||
.line_clamp(1)
|
||||
.child(message.preview()),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.hover(|this| this.bg(cx.theme().surface_raised))
|
||||
.on_click({
|
||||
let id = *id;
|
||||
cx.listener(move |this, _event, _window, _cx| {
|
||||
@@ -1287,16 +1287,16 @@ impl ChatPanel {
|
||||
.unwrap_or(false);
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.rounded(px(theme::control_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.border_1()
|
||||
.when(has_reacted, |this| {
|
||||
this.text_color(cx.theme().secondary_foreground)
|
||||
.bg(cx.theme().secondary_background)
|
||||
.border_color(cx.theme().secondary_active)
|
||||
this.text_color(cx.theme().text)
|
||||
.bg(cx.theme().surface_raised)
|
||||
.border_color(cx.theme().element_active)
|
||||
})
|
||||
.when(!has_reacted, |this| this.border_color(cx.theme().border))
|
||||
.child(emoji)
|
||||
@@ -1330,7 +1330,7 @@ impl ChatPanel {
|
||||
div()
|
||||
.id(SharedString::from(id.to_hex()))
|
||||
.child(label)
|
||||
.when(failed, |this| this.text_color(cx.theme().text_danger))
|
||||
.when(failed, |this| this.text_color(cx.theme().danger_muted))
|
||||
.when_some(reports, |this, reports| {
|
||||
this.when(!pending, |this| {
|
||||
this.on_click(move |_e, window, cx| {
|
||||
@@ -1361,12 +1361,12 @@ impl ChatPanel {
|
||||
let avatar = profile.avatar();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(SharedString::from("Sent to:"))
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -1384,17 +1384,17 @@ impl ChatPanel {
|
||||
.p_1()
|
||||
.h_16()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.text_style(TextStyle::Callout)
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.bg(cx.theme().warning_muted)
|
||||
.text_color(cx.theme().warning)
|
||||
.child(div().flex_1().w_full().text_center().child(error)),
|
||||
)
|
||||
})
|
||||
.when_some(report.output().cloned(), |this, output| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.children({
|
||||
let mut items = Vec::with_capacity(output.failed.len());
|
||||
@@ -1405,19 +1405,19 @@ impl ChatPanel {
|
||||
.gap_0p5()
|
||||
.p_1()
|
||||
.w_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().danger_background)
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.bg(cx.theme().danger.opacity(0.06))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(url.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger_foreground)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().danger_muted.opacity(0.9))
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(msg.to_string())),
|
||||
),
|
||||
@@ -1435,18 +1435,18 @@ impl ChatPanel {
|
||||
.gap_0p5()
|
||||
.p_1()
|
||||
.w_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(url.0.to_string())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from("Successfully")),
|
||||
),
|
||||
@@ -1473,10 +1473,10 @@ impl ChatPanel {
|
||||
.right_4()
|
||||
.top_neg_2()
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().background)
|
||||
.bg(cx.theme().bg)
|
||||
.children({
|
||||
let mut items = vec![];
|
||||
|
||||
@@ -1553,7 +1553,7 @@ impl ChatPanel {
|
||||
img(url.as_str())
|
||||
.size_16()
|
||||
.when(cx.theme().shadow, |this| this.shadow_lg())
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
.child(
|
||||
@@ -1611,10 +1611,10 @@ impl ChatPanel {
|
||||
.child(
|
||||
img(path.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.border_color(cx.theme().border_faint)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
.rounded(px(theme::bubble_radius())),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
@@ -1642,22 +1642,22 @@ impl ChatPanel {
|
||||
.self_start()
|
||||
.items_start()
|
||||
.min_w_0()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.p_2()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
|
||||
.border_color(cx.theme().border_faint)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.child(Icon::new(IconName::Lock).text_color(cx.theme().accent))
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(div().line_height(relative(1.2)).child(file.display_name()))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(label),
|
||||
),
|
||||
)
|
||||
@@ -1688,7 +1688,7 @@ impl ChatPanel {
|
||||
img(pending.path.clone())
|
||||
.size_16()
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
} else {
|
||||
@@ -1698,11 +1698,11 @@ impl ChatPanel {
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(theme::control_radius()))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_xs()
|
||||
.border_color(cx.theme().border_faint)
|
||||
.bg(cx.theme().surface)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_center()
|
||||
.child("Preview not available"),
|
||||
)
|
||||
@@ -1718,7 +1718,7 @@ impl ChatPanel {
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.border_color(cx.theme().border_faint)
|
||||
.bg(gpui::green())
|
||||
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
||||
)
|
||||
@@ -1764,14 +1764,10 @@ impl ChatPanel {
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Replying to:")
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(profile.name()),
|
||||
),
|
||||
.child(div().text_color(cx.theme().accent).child(profile.name())),
|
||||
)
|
||||
.child(
|
||||
Button::new("remove-reply")
|
||||
@@ -1789,7 +1785,7 @@ impl ChatPanel {
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(text.preview()),
|
||||
@@ -1885,12 +1881,17 @@ impl Panel for ChatPanel {
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.child(Avatar::new(picture).seed(seed).xsmall())
|
||||
.child(label)
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or(div().text_xs().child("Unknown").into_any_element())
|
||||
.unwrap_or(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.child("Unknown")
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
@@ -1937,10 +1938,10 @@ impl Render for ChatPanel {
|
||||
.h_12()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(Input::new(&self.subject_input).text_sm().small())
|
||||
.child(Input::new(&self.subject_input).small())
|
||||
.child(
|
||||
Button::new("change")
|
||||
.icon(IconName::CheckCircle)
|
||||
@@ -1993,8 +1994,8 @@ impl Render for ChatPanel {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_warning)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().warning)
|
||||
.child(WARNING),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ use gpui::{
|
||||
relative,
|
||||
};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
@@ -22,7 +22,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
};
|
||||
|
||||
let group = entity.read(cx);
|
||||
let mut body = v_flex().gap_3().text_sm().child(header(group, cx));
|
||||
let mut body = v_flex().gap_3().text_style(TextStyle::Callout).child(header(group, cx));
|
||||
|
||||
if let Some(metadata) = group.metadata() {
|
||||
if let Some(about) = metadata.about.as_ref() {
|
||||
@@ -76,7 +76,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
"Supported kinds",
|
||||
if items.is_empty() {
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("None")
|
||||
.into_any_element()
|
||||
} else {
|
||||
@@ -137,15 +137,15 @@ fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_base()
|
||||
.text_style(TextStyle::Body)
|
||||
.font_semibold()
|
||||
.truncate()
|
||||
.child(group.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.truncate()
|
||||
.child(SharedString::from(format!(
|
||||
"{} · {}",
|
||||
@@ -162,7 +162,7 @@ fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_medium()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label),
|
||||
@@ -183,10 +183,10 @@ fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.text_xs()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.text_color(cx.theme().text)
|
||||
.text_style(TextStyle::Caption)
|
||||
.line_height(relative(1.))
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
|
||||
@@ -5,7 +5,7 @@ use gpui::{
|
||||
};
|
||||
use nip29::Group;
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
@@ -40,7 +40,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
.child(
|
||||
v_flex().gap_3().child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Share the code with whoever should be able to join. The relay must accept it."),
|
||||
)
|
||||
|
||||
+17
-17
@@ -16,7 +16,7 @@ use serde::Deserialize;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariant, ButtonVariants};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
@@ -168,7 +168,7 @@ pub fn confirm_leave(group: WeakEntity<Group>, window: &mut Window, cx: &mut App
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child("You will stop receiving messages from this group."),
|
||||
)
|
||||
.on_ok(move |_event, _window, cx| {
|
||||
@@ -567,16 +567,16 @@ impl GroupPanel {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
Icon::new(IconName::Info)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
.text_color(cx.theme().text_faint),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().truncate().child(message))
|
||||
.child(
|
||||
@@ -615,9 +615,9 @@ impl GroupPanel {
|
||||
div()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().element_background)
|
||||
.text_xs()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().solid)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(pin_label(group, pin))
|
||||
}))
|
||||
@@ -695,10 +695,10 @@ impl GroupPanel {
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted);
|
||||
|
||||
match notice {
|
||||
@@ -878,9 +878,9 @@ impl GroupPanel {
|
||||
.h_8()
|
||||
.px_2()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.border_color(cx.theme().border_faint)
|
||||
.rounded(px(control_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Icon::new(IconName::Link).xsmall())
|
||||
.child(
|
||||
@@ -922,12 +922,12 @@ impl Panel for GroupPanel {
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.child(Avatar::from_source(picture).seed(seed).xsmall())
|
||||
.child(display_name(group))
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or_else(|| div().text_xs().child("Unknown").into_any_element())
|
||||
.unwrap_or_else(|| div().text_style(TextStyle::Caption).child("Unknown").into_any_element())
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
@@ -987,8 +987,8 @@ impl Render for GroupPanel {
|
||||
None => h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child("No messages yet")
|
||||
.into_any_element(),
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,7 @@ use nip29::{Activity, Group};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||
use ui::message::MessageRow;
|
||||
@@ -138,10 +138,10 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.child(badge(activity, cx))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
@@ -155,7 +155,7 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_semibold()
|
||||
.child(subject.name()),
|
||||
)
|
||||
@@ -163,16 +163,16 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(SharedString::from(action(activity))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(activity.created_at.to_ago()),
|
||||
)
|
||||
.into_any_element()
|
||||
@@ -180,11 +180,11 @@ pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyEl
|
||||
|
||||
fn badge(activity: &Activity, cx: &App) -> AnyElement {
|
||||
let (icon, color) = match activity.kind {
|
||||
Kind::GroupJoinRequest => (IconName::Login, cx.theme().icon_accent),
|
||||
Kind::GroupPutUser => (IconName::Login, cx.theme().icon_accent),
|
||||
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().text_danger),
|
||||
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().text_danger),
|
||||
_ => (IconName::Info, cx.theme().icon_muted),
|
||||
Kind::GroupJoinRequest => (IconName::Login, cx.theme().accent),
|
||||
Kind::GroupPutUser => (IconName::Login, cx.theme().accent),
|
||||
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().danger),
|
||||
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().danger),
|
||||
_ => (IconName::Info, cx.theme().text_faint),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
@@ -211,10 +211,10 @@ fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||
.child(
|
||||
img(only.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.border_color(cx.theme().border_faint)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
.rounded(px(bubble_radius())),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
@@ -225,14 +225,14 @@ fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.children(media.iter().enumerate().map(|(ix, item)| {
|
||||
div().id(format!("media-{ix}")).child(
|
||||
img(item.clone())
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius),
|
||||
.border_color(cx.theme().border_faint)
|
||||
.rounded(px(bubble_radius())),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window};
|
||||
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
@@ -73,7 +73,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
.child(field("Picture", &picture, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
Switch::new("private")
|
||||
.label("Private")
|
||||
|
||||
@@ -2,7 +2,7 @@ use anyhow::Result;
|
||||
use gpui::{App, Div, Entity, ParentElement, SharedString, Styled, Task, Window};
|
||||
use nip29::{Group, Pin, Row};
|
||||
use nostr_sdk::prelude::Url;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
@@ -52,7 +52,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
|
||||
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
|
||||
+15
-15
@@ -8,14 +8,14 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use anyhow::{Context as AnyhowContext, Error, anyhow};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
|
||||
SharedString, Styled, Subscription, Task, Window, div, relative,
|
||||
SharedString, Styled, Subscription, Task, Window, div, px, relative,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::Button;
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
@@ -720,25 +720,25 @@ impl DeviceRegistry {
|
||||
.title("Encryption Key Request")
|
||||
.content(move |_this, _window, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.line_height(relative(1.25))
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("From:")),
|
||||
)
|
||||
@@ -747,11 +747,11 @@ impl DeviceRegistry {
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
@@ -764,11 +764,11 @@ impl DeviceRegistry {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Code:")),
|
||||
)
|
||||
@@ -777,8 +777,8 @@ impl DeviceRegistry {
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_card)
|
||||
.child(code_label.clone()),
|
||||
),
|
||||
),
|
||||
|
||||
+41
-51
@@ -1,12 +1,10 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
use theme::{AppExt as _, AppearanceMode};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
@@ -53,7 +51,7 @@ macro_rules! setting_getters {
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub theme_mode: ThemeMode,
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub nip4e: bool,
|
||||
@@ -119,11 +117,11 @@ impl RoomConfig {
|
||||
/// Settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// Theme
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Theme mode
|
||||
pub theme_mode: ThemeMode,
|
||||
/// Which appearance to paint: follow the OS, or pin light or dark.
|
||||
/// `alias` reads the pre-bezel `theme_mode` key so old config files keep
|
||||
/// their pinned choice.
|
||||
#[serde(default, alias = "theme_mode")]
|
||||
pub appearance_mode: AppearanceMode,
|
||||
|
||||
/// Hide user avatars
|
||||
pub hide_avatar: bool,
|
||||
@@ -152,8 +150,7 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: None,
|
||||
theme_mode: ThemeMode::default(),
|
||||
appearance_mode: AppearanceMode::default(),
|
||||
hide_avatar: false,
|
||||
screening: true,
|
||||
nip4e: false,
|
||||
@@ -243,7 +240,7 @@ impl AppSettings {
|
||||
// Update settings
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_settings(settings, cx);
|
||||
this.apply_theme(None, cx);
|
||||
this.apply_theme(cx);
|
||||
cx.refresh_windows();
|
||||
})
|
||||
.ok();
|
||||
@@ -264,44 +261,10 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set theme
|
||||
pub fn set_theme<T>(&mut self, theme: T, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
// Update settings
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.theme = Some(theme.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
pub fn reset_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.theme = None;
|
||||
cx.notify();
|
||||
});
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Apply theme
|
||||
pub fn apply_theme(&mut self, mut window: Option<&mut Window>, cx: &mut Context<Self>) {
|
||||
if let Some(name) = self.inner.read(cx).theme.as_ref() {
|
||||
let mode = self.inner.read(cx).theme_mode;
|
||||
|
||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||
Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
|
||||
Theme::change(mode, window, cx);
|
||||
} else {
|
||||
log::info!("Failed to load theme: {name}");
|
||||
}
|
||||
} else {
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
|
||||
}
|
||||
/// Apply the persisted appearance mode to the app
|
||||
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
|
||||
let mode = self.inner.read(cx).appearance_mode;
|
||||
cx.set_appearance_mode(mode);
|
||||
}
|
||||
|
||||
/// Check if decoupling encryption key is enabled
|
||||
@@ -347,3 +310,30 @@ impl AppSettings {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn json_with(partial: &str) -> Result<Settings, serde_json::Error> {
|
||||
// Every field the persisted format has always required; `partial`
|
||||
// contributes the fields under test.
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{ {} "hide_avatar": false, "screening": true, "nip4e": false,
|
||||
"trusted_relays": [], "file_server": "https://nostr.download/" }}"#,
|
||||
partial
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_theme_mode_key_keeps_its_pinned_choice() {
|
||||
let settings = json_with(r#""theme_mode": "Dark", "#).unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_appearance_mode_falls_back_to_dark() {
|
||||
let settings = json_with("").unwrap();
|
||||
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../LICENSE
|
||||
@@ -1 +0,0 @@
|
||||
CREDITS: [zed/theme](https://github.com/zed-industries/zed/tree/main/crates/theme)
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Light/dark switching, adapted from bezel
|
||||
//! (`crates/theme/src/appearance.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors): what the user asked for, what the OS reports, and the
|
||||
//! plumbing that turns a change in either into a repaint.
|
||||
|
||||
use gpui::{App, Global, Subscription, Window};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Theme, ThemeMode};
|
||||
|
||||
/// The user's appearance preference. Serde-serializable so callers can persist
|
||||
/// it wherever their settings live; this crate never touches disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the OS — matches every other native app on the machine,
|
||||
/// including when the OS is set to switch at sunset.
|
||||
System,
|
||||
Light,
|
||||
/// Paint dark regardless of the OS.
|
||||
#[default]
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl AppearanceMode {
|
||||
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
|
||||
|
||||
/// Menu/label text.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state behind the current theme: what the user chose, and what the OS
|
||||
/// last said. Kept separate from [`Theme`] itself so that flipping the OS
|
||||
/// appearance while the user has pinned Light still records the new system
|
||||
/// value (and takes effect the moment they switch back to `System`).
|
||||
pub struct AppearanceState {
|
||||
pub mode: AppearanceMode,
|
||||
pub system: ThemeMode,
|
||||
}
|
||||
|
||||
impl Global for AppearanceState {}
|
||||
|
||||
/// Combine the user's choice with the OS state.
|
||||
pub fn resolve(mode: AppearanceMode, system: ThemeMode) -> ThemeMode {
|
||||
match mode {
|
||||
AppearanceMode::System => system,
|
||||
AppearanceMode::Light => ThemeMode::Light,
|
||||
AppearanceMode::Dark => ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the appearance globals and the matching theme. Call once at boot,
|
||||
/// before any window opens, so the first frame is already the right palette
|
||||
/// (installing later produces a visible dark-to-light flash).
|
||||
pub fn init(mode: AppearanceMode, cx: &mut App) {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.set_global(AppearanceState { mode, system });
|
||||
Theme::change(resolve(mode, system), None, cx);
|
||||
}
|
||||
|
||||
/// The mode currently in effect (defaults to `Dark` before [`init`]).
|
||||
pub fn mode(cx: &App) -> AppearanceMode {
|
||||
cx.try_global::<AppearanceState>()
|
||||
.map(|state| state.mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the user's preference and repaint if that changed the palette.
|
||||
/// Persisting the choice is the caller's job.
|
||||
pub fn set_mode(mode: AppearanceMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode == mode {
|
||||
return;
|
||||
}
|
||||
state.mode = mode;
|
||||
// Coming back to `System`, re-read the OS: a pinned mode holds an
|
||||
// appearance override, so `system` may be stale from before the pin.
|
||||
if mode == AppearanceMode::System {
|
||||
let system = ThemeMode::from_window(cx.window_appearance());
|
||||
cx.global_mut::<AppearanceState>().system = system;
|
||||
}
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Subscribe a window to OS appearance changes. The returned [`Subscription`]
|
||||
/// must outlive the window — callers typically `.detach()` it.
|
||||
///
|
||||
/// The notification is *per window*, but the appearance it reports is a system
|
||||
/// setting, so any one window is enough to learn about the change; re-applying
|
||||
/// is idempotent when several fire.
|
||||
pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription {
|
||||
// Reconcile against the *window's* appearance before subscribing: `init`
|
||||
// runs before any window exists and can only ask the app, which on some
|
||||
// platforms is not reliably populated that early in launch.
|
||||
record_system(window.appearance().into(), cx);
|
||||
window.observe_window_appearance(move |window, cx| {
|
||||
record_system(window.appearance().into(), cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. `window`, when given,
|
||||
/// is the more authoritative source for what the OS currently reports.
|
||||
pub fn record_system_appearance(window: Option<&Window>, cx: &mut App) {
|
||||
let appearance = match window {
|
||||
Some(window) => window.appearance(),
|
||||
None => cx.window_appearance(),
|
||||
};
|
||||
record_system(ThemeMode::from_window(appearance), cx);
|
||||
}
|
||||
|
||||
/// Record the OS appearance and re-apply if it moved. Only recorded while the
|
||||
/// user's mode is `System` — a pinned mode ignores the OS anyway, and
|
||||
/// recording an override read back would overwrite what the OS said with what
|
||||
/// we asked for.
|
||||
fn record_system(system: ThemeMode, cx: &mut App) {
|
||||
if !cx.has_global::<AppearanceState>() {
|
||||
return;
|
||||
}
|
||||
let state = cx.global_mut::<AppearanceState>();
|
||||
if state.mode != AppearanceMode::System || state.system == system {
|
||||
return;
|
||||
}
|
||||
state.system = system;
|
||||
apply(cx);
|
||||
}
|
||||
|
||||
/// Re-resolve the palette and, if it moved, swap the theme and force a full
|
||||
/// repaint. A no-op when the resolved appearance is unchanged — the OS fires
|
||||
/// the appearance notification for other changes too, and repainting every
|
||||
/// window for those would be a visible hitch for nothing.
|
||||
pub fn apply(cx: &mut App) {
|
||||
let Some(state) = cx.try_global::<AppearanceState>() else {
|
||||
return;
|
||||
};
|
||||
let wanted = resolve(state.mode, state.system);
|
||||
let changed = !cx
|
||||
.try_global::<Theme>()
|
||||
.is_some_and(|theme| theme.mode == wanted);
|
||||
if changed {
|
||||
Theme::change(wanted, None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Brand, ported from bezel (`crates/theme/src/brand.rs`, MIT License,
|
||||
//! Copyright (c) 2026 bezel contributors): one hue for the greys, one for the
|
||||
//! accent, one radius, and the text ink.
|
||||
//!
|
||||
//! A brand moves hue alone: every lightness in `palette.rs` stays as the
|
||||
//! palette set it.
|
||||
|
||||
use gpui::{App, Global, Hsla};
|
||||
|
||||
use crate::{ThemeColors, ThemeMode, color, layout};
|
||||
|
||||
/// A hue and how much of it, in oklch terms. `chroma: 0.0` is the shipped
|
||||
/// neutral, so [`Brand::default`] reproduces the built-in palette exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Tint {
|
||||
/// oklch hue, in degrees.
|
||||
pub hue: f32,
|
||||
/// oklch chroma. Neutral ramps live near 0.01–0.05; an accent carries more.
|
||||
pub chroma: f32,
|
||||
}
|
||||
|
||||
impl Tint {
|
||||
pub const NONE: Self = Self {
|
||||
hue: 0.0,
|
||||
chroma: 0.0,
|
||||
};
|
||||
|
||||
pub const fn new(hue: f32, chroma: f32) -> Self {
|
||||
Self { hue, chroma }
|
||||
}
|
||||
}
|
||||
|
||||
/// The greys a UI is built on, as oklch hue and chroma.
|
||||
///
|
||||
/// Tailwind's five neutral families at their 500 step (tailwindcss.com/docs/colors)
|
||||
/// — the same list shadcn offers as its base colour, and the reason these are
|
||||
/// quoted rather than invented: a neutral that carries hue is a judgement
|
||||
/// someone else has already made five times.
|
||||
pub const BASE_COLORS: [(&str, Tint); 5] = [
|
||||
("Neutral", Tint::NONE),
|
||||
("Stone", Tint::new(58.071, 0.013)),
|
||||
("Zinc", Tint::new(285.938, 0.016)),
|
||||
("Gray", Tint::new(264.364, 0.027)),
|
||||
("Slate", Tint::new(257.417, 0.046)),
|
||||
];
|
||||
|
||||
/// The alpha of each text role over the appearance's ink — white in dark,
|
||||
/// black in light.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TextInk {
|
||||
/// [`ThemeColors::text`].
|
||||
pub text: f32,
|
||||
/// [`ThemeColors::text_muted`].
|
||||
pub muted: f32,
|
||||
/// [`ThemeColors::text_faint`].
|
||||
pub faint: f32,
|
||||
}
|
||||
|
||||
impl TextInk {
|
||||
/// The role at `alpha` over `appearance`'s ink.
|
||||
pub fn color(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => gpui::hsla(0.0, 0.0, 1.0, alpha),
|
||||
ThemeMode::Light => gpui::hsla(0.0, 0.0, 0.0, alpha),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes [`ThemeColors::text`], [`ThemeColors::text_muted`] and
|
||||
/// [`ThemeColors::text_faint`] for the appearance.
|
||||
pub fn paint(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
|
||||
colors.text = Self::color(appearance, self.text);
|
||||
colors.text_muted = Self::color(appearance, self.muted);
|
||||
colors.text_faint = Self::color(appearance, self.faint);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text ink for both appearances.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Ink {
|
||||
pub dark: TextInk,
|
||||
pub light: TextInk,
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
/// Text and muted at 4.5:1 or more and faint at 3:1 or more, on
|
||||
/// [`ThemeColors::bg`] and [`ThemeColors::surface`] in both appearances.
|
||||
pub const ACCESSIBLE: Self = Self {
|
||||
dark: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.52,
|
||||
faint: 0.40,
|
||||
},
|
||||
light: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.56,
|
||||
faint: 0.43,
|
||||
},
|
||||
};
|
||||
/// AppKit's label, secondary and tertiary label colours. Muted is under
|
||||
/// 4.5:1 on white, and faint is under 3:1 in both appearances.
|
||||
pub const APPKIT: Self = Self {
|
||||
dark: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.55,
|
||||
faint: 0.25,
|
||||
},
|
||||
light: TextInk {
|
||||
text: 0.85,
|
||||
muted: 0.50,
|
||||
faint: 0.26,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn for_appearance(&self, appearance: ThemeMode) -> TextInk {
|
||||
match appearance {
|
||||
ThemeMode::Dark => self.dark,
|
||||
ThemeMode::Light => self.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an app changes about the shipped palette without redesigning it.
|
||||
///
|
||||
/// Installed as a gpui [`Global`] via [`crate::AppExt::set_brand`]; every
|
||||
/// palette install applies it, so it survives a light/dark switch and composes
|
||||
/// with [`crate::AppExt::set_palette`] rather than competing with it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Brand {
|
||||
/// The hue every grey in the palette carries.
|
||||
pub tint: Tint,
|
||||
/// The emphasis hue. Left neutral, the accent follows [`Self::tint`] like
|
||||
/// any other grey — which is what the shipped palette already is.
|
||||
pub accent: Tint,
|
||||
/// The base corner radius; every other corner is a ratio of it. See
|
||||
/// [`crate::BASE_RADIUS`].
|
||||
pub radius: f32,
|
||||
/// The text roles' alphas. [`Ink::APPKIT`] is what the palettes ship.
|
||||
pub ink: Ink,
|
||||
}
|
||||
|
||||
impl Global for Brand {}
|
||||
|
||||
impl Default for Brand {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tint: Tint::NONE,
|
||||
accent: Tint::NONE,
|
||||
radius: layout::BASE_RADIUS,
|
||||
ink: Ink::APPKIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brand {
|
||||
/// Whether applying this brand would change nothing — the shipped palette
|
||||
/// already carries these values, so a JSON theme's own text colors must be
|
||||
/// left alone.
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// The shipped palette for an appearance, rotated onto this brand.
|
||||
pub fn branded(&self, appearance: ThemeMode) -> ThemeColors {
|
||||
let mut colors = ThemeColors::for_appearance(appearance);
|
||||
// apply() re-asserts this brand's ink, so the default brand — whose
|
||||
// ink is the shipped one — must skip it to stay a strict no-op.
|
||||
if !self.is_default() {
|
||||
self.apply(appearance, &mut colors);
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
/// Rotate a palette onto this brand's hues.
|
||||
pub fn apply(&self, appearance: ThemeMode, colors: &mut ThemeColors) {
|
||||
self.ink
|
||||
.for_appearance(appearance)
|
||||
.paint(appearance, colors);
|
||||
// Every colour token, with the rule doing the choosing: a token that is
|
||||
// already grey takes the tint, and one that already carries a hue —
|
||||
// danger, warning, success — is semantic and keeps it. Translucent ink
|
||||
// is skipped because it paints over whatever is beneath it, which is
|
||||
// tinted already.
|
||||
let tokens: [&mut Hsla; 41] = [
|
||||
&mut colors.bg,
|
||||
&mut colors.surface,
|
||||
&mut colors.surface_raised,
|
||||
&mut colors.surface_card,
|
||||
&mut colors.surface_dialog,
|
||||
&mut colors.surface_overlay,
|
||||
&mut colors.element_hover,
|
||||
&mut colors.element_active,
|
||||
&mut colors.border_faint,
|
||||
&mut colors.border,
|
||||
&mut colors.border_strong,
|
||||
&mut colors.text,
|
||||
&mut colors.text_muted,
|
||||
&mut colors.text_faint,
|
||||
&mut colors.text_dim,
|
||||
&mut colors.solid,
|
||||
&mut colors.on_solid,
|
||||
&mut colors.accent,
|
||||
&mut colors.accent_strong,
|
||||
&mut colors.on_accent,
|
||||
&mut colors.danger,
|
||||
&mut colors.danger_muted,
|
||||
&mut colors.warning,
|
||||
&mut colors.warning_muted,
|
||||
&mut colors.success,
|
||||
&mut colors.busy,
|
||||
&mut colors.success_muted,
|
||||
&mut colors.surface_raised_hover,
|
||||
&mut colors.band,
|
||||
&mut colors.input_bg,
|
||||
&mut colors.selection,
|
||||
&mut colors.cursor,
|
||||
&mut colors.caret,
|
||||
&mut colors.ring,
|
||||
&mut colors.drop_target,
|
||||
&mut colors.danger_strong,
|
||||
&mut colors.code_text,
|
||||
&mut colors.code_wash,
|
||||
&mut colors.diff_add,
|
||||
&mut colors.diff_del,
|
||||
&mut colors.diff_hunk_bg,
|
||||
];
|
||||
let syntax: [&mut Hsla; 24] = [
|
||||
&mut colors.syntax.comment,
|
||||
&mut colors.syntax.keyword,
|
||||
&mut colors.syntax.string,
|
||||
&mut colors.syntax.string_special,
|
||||
&mut colors.syntax.escape,
|
||||
&mut colors.syntax.number,
|
||||
&mut colors.syntax.boolean,
|
||||
&mut colors.syntax.type_name,
|
||||
&mut colors.syntax.type_builtin,
|
||||
&mut colors.syntax.constructor,
|
||||
&mut colors.syntax.function,
|
||||
&mut colors.syntax.function_builtin,
|
||||
&mut colors.syntax.macro_name,
|
||||
&mut colors.syntax.property,
|
||||
&mut colors.syntax.constant,
|
||||
&mut colors.syntax.variable,
|
||||
&mut colors.syntax.variable_special,
|
||||
&mut colors.syntax.parameter,
|
||||
&mut colors.syntax.operator,
|
||||
&mut colors.syntax.punctuation,
|
||||
&mut colors.syntax.tag,
|
||||
&mut colors.syntax.attribute,
|
||||
&mut colors.syntax.label,
|
||||
&mut colors.syntax.invalid,
|
||||
];
|
||||
for slot in tokens.into_iter().chain(syntax) {
|
||||
if slot.a == 1.0 && slot.s <= f32::EPSILON {
|
||||
*slot = color::tint(*slot, self.tint.hue, self.tint.chroma);
|
||||
}
|
||||
}
|
||||
|
||||
if self.accent.chroma > 0.0 {
|
||||
let (accent_l, plate_l) = if appearance.is_light() {
|
||||
(ACCENT_L.1, PLATE_L.1)
|
||||
} else {
|
||||
(ACCENT_L.0, PLATE_L.0)
|
||||
};
|
||||
colors.accent = color::oklch(accent_l, self.accent.chroma, self.accent.hue);
|
||||
colors.accent_strong = color::oklch(plate_l, self.accent.chroma, self.accent.hue);
|
||||
// Whichever label the plate can actually hold. The shipped accent is
|
||||
// the maximum-contrast neutral, where the answer is always the
|
||||
// inverse; a chromatic plate at a yellow hue is bright enough that
|
||||
// the inverse would be the unreadable one.
|
||||
colors.on_accent = label_on(colors.accent_strong, colors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent's lightness in each appearance — indigo-400's and indigo-600's,
|
||||
/// the two steps `palette.rs` picked so an accent clears WCAG AA on its own
|
||||
/// background rather than glowing on one and vanishing on the other.
|
||||
const ACCENT_L: (f32, f32) = (0.673, 0.511);
|
||||
|
||||
/// The lightness of a *plate* carrying [`ThemeColors::on_accent`], taken from
|
||||
/// `danger_strong` — the palette's existing chromatic plate, already tuned to
|
||||
/// hold a label in both appearances.
|
||||
const PLATE_L: (f32, f32) = (0.58, 0.51);
|
||||
|
||||
/// Whichever of the palette's two extremes the plate can actually hold.
|
||||
fn label_on(plate: Hsla, colors: &ThemeColors) -> Hsla {
|
||||
let (a, b) = (colors.solid, colors.on_solid);
|
||||
if color::contrast_ratio(plate, a) >= color::contrast_ratio(plate, b) {
|
||||
a
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the installed brand (the default before one is set).
|
||||
pub fn installed(cx: &App) -> Brand {
|
||||
cx.try_global::<Brand>().copied().unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Color math ported from bezel (`crates/theme/src/color.rs`, MIT License,
|
||||
//! Copyright (c) 2026 bezel contributors): the oklch → sRGB → HSL converters
|
||||
//! behind the token constructors, WCAG contrast for the palette tests, and
|
||||
//! small paint helpers.
|
||||
|
||||
use gpui::{Hsla, hsla};
|
||||
|
||||
/// A neutral (chroma 0) oklch tone as Hsla. Chroma 0 means r == g == b exactly,
|
||||
/// so this goes straight to an achromatic Hsla (skipping the hue math avoids
|
||||
/// float-noise saturation).
|
||||
pub fn neutral(lightness: f32) -> Hsla {
|
||||
let [v, _, _] = oklch_to_srgb(lightness, 0.0, 0.0);
|
||||
hsla(0.0, 0.0, v, 1.0)
|
||||
}
|
||||
|
||||
/// An exact achromatic tone from an 8-bit channel value (`grey(13)` ≡ `#0d0d0d`)
|
||||
/// — for surfaces matched against reference samples.
|
||||
pub fn grey(value: u8) -> Hsla {
|
||||
hsla(0.0, 0.0, value as f32 / 255.0, 1.0)
|
||||
}
|
||||
|
||||
/// Convert an oklch color (CSS notation: L 0..1, C, H in degrees) to gpui Hsla.
|
||||
pub fn oklch(l: f32, c: f32, h_deg: f32) -> Hsla {
|
||||
let [r, g, b] = oklch_to_srgb(l, c, h_deg);
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
hsla(h, s, l, 1.0)
|
||||
}
|
||||
|
||||
/// oklch → sRGB (each 0..1, clamped/gamut-clipped per channel).
|
||||
/// Reference: Björn Ottosson's OKLab definition (the same matrices CSS Color 4 uses).
|
||||
pub fn oklch_to_srgb(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
||||
let [r, g, b] = oklch_to_linear(l, c, h_deg);
|
||||
[gamma_encode(r), gamma_encode(g), gamma_encode(b)]
|
||||
}
|
||||
|
||||
/// oklch → *linear* sRGB, unclamped: a component outside 0..1 is a color the
|
||||
/// display cannot make, which is what [`fit_chroma`] tests for.
|
||||
fn oklch_to_linear(l: f32, c: f32, h_deg: f32) -> [f32; 3] {
|
||||
let h = h_deg.to_radians();
|
||||
let a = c * h.cos();
|
||||
let b = c * h.sin();
|
||||
|
||||
// OKLab → LMS (cube roots undone)
|
||||
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
||||
let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b;
|
||||
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
||||
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||
|
||||
// LMS → linear sRGB
|
||||
let r = 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_93 * s3;
|
||||
let g = -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_4 * s3;
|
||||
let b = -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3;
|
||||
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
fn gamma_encode(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x <= 0.003_130_8 {
|
||||
12.92 * x
|
||||
} else {
|
||||
1.055 * x.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
fn gamma_decode(x: f32) -> f32 {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
if x <= 0.040_449_936 {
|
||||
x / 12.92
|
||||
} else {
|
||||
((x + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// The oklch lightness of an achromatic tone: [`neutral`] inverted.
|
||||
///
|
||||
/// Only greys round-trip through this. A grey's three channels are equal, and
|
||||
/// the three LMS matrix rows each sum to 1, so the whole OKLab transform
|
||||
/// collapses to one cube root — no inverse matrix needed.
|
||||
pub fn lightness(color: Hsla) -> f32 {
|
||||
gamma_decode(color.l).cbrt()
|
||||
}
|
||||
|
||||
/// The most chroma sRGB can hold at this lightness and hue, up to `chroma`.
|
||||
///
|
||||
/// Near black and near white the gamut is a needle: asking for a mid-ramp
|
||||
/// chroma there produces an out-of-range component, and clamping it per channel
|
||||
/// shifts the hue instead of dropping the saturation.
|
||||
fn fit_chroma(l: f32, chroma: f32, hue: f32) -> f32 {
|
||||
let fits = |c: f32| {
|
||||
oklch_to_linear(l, c, hue)
|
||||
.iter()
|
||||
.all(|x| (-1e-4..=1.0 + 1e-4).contains(x))
|
||||
};
|
||||
if fits(chroma) {
|
||||
return chroma;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0, chroma);
|
||||
for _ in 0..24 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if fits(mid) { lo = mid } else { hi = mid }
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
/// Re-emit an achromatic tone at `hue`, carrying as much `chroma` as its
|
||||
/// lightness can hold. Alpha rides through untouched.
|
||||
pub fn tint(color: Hsla, hue: f32, chroma: f32) -> Hsla {
|
||||
if chroma <= 0.0 {
|
||||
return color;
|
||||
}
|
||||
let l = lightness(color);
|
||||
let mut out = oklch(l, fit_chroma(l, chroma, hue), hue);
|
||||
out.a = color.a;
|
||||
out
|
||||
}
|
||||
|
||||
/// sRGB (0..1 components) → HSL, all components 0..1 (gpui's Hsla convention).
|
||||
pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
let delta = max - min;
|
||||
if delta < f32::EPSILON {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let s = if l > 0.5 {
|
||||
delta / (2.0 - max - min)
|
||||
} else {
|
||||
delta / (max + min)
|
||||
};
|
||||
let h = if (max - r).abs() < f32::EPSILON {
|
||||
((g - b) / delta).rem_euclid(6.0)
|
||||
} else if (max - g).abs() < f32::EPSILON {
|
||||
(b - r) / delta + 2.0
|
||||
} else {
|
||||
(r - g) / delta + 4.0
|
||||
} / 6.0;
|
||||
(h, s, l)
|
||||
}
|
||||
|
||||
/// HSL (gpui convention, all 0..1) → sRGB components 0..1.
|
||||
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] {
|
||||
if s <= f32::EPSILON {
|
||||
return [l, l, l];
|
||||
}
|
||||
let q = if l < 0.5 {
|
||||
l * (1.0 + s)
|
||||
} else {
|
||||
l + s - l * s
|
||||
};
|
||||
let p = 2.0 * l - q;
|
||||
let hue = |mut t: f32| {
|
||||
t = t.rem_euclid(1.0);
|
||||
if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
}
|
||||
};
|
||||
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
|
||||
}
|
||||
|
||||
/// WCAG 2.1 relative luminance of an opaque color.
|
||||
pub fn relative_luminance(color: Hsla) -> f32 {
|
||||
let lin = |c: f32| {
|
||||
if c <= 0.040_45 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
let [r, g, b] = hsl_to_rgb(color.h, color.s, color.l);
|
||||
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||
}
|
||||
|
||||
/// WCAG 2.1 contrast ratio between two opaque colors (1.0 … 21.0).
|
||||
///
|
||||
/// Used by the palette tests to prove each light token reproduces the contrast
|
||||
/// its dark counterpart had, rather than merely looking plausible.
|
||||
pub fn contrast_ratio(a: Hsla, b: Hsla) -> f32 {
|
||||
let (la, lb) = (relative_luminance(a), relative_luminance(b));
|
||||
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||
(hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
/// Composite `fg` (which may be translucent) over an opaque `bg`, returning the
|
||||
/// opaque result — the color the eye actually receives.
|
||||
pub fn flatten(fg: Hsla, bg: Hsla) -> Hsla {
|
||||
let a = fg.a.clamp(0.0, 1.0);
|
||||
let [fr, fg_, fb] = hsl_to_rgb(fg.h, fg.s, fg.l);
|
||||
let [br, bg_, bb] = hsl_to_rgb(bg.h, bg.s, bg.l);
|
||||
let (h, s, l) = rgb_to_hsl(
|
||||
fr * a + br * (1.0 - a),
|
||||
fg_ * a + bg_ * (1.0 - a),
|
||||
fb * a + bb * (1.0 - a),
|
||||
);
|
||||
hsla(h, s, l, 1.0)
|
||||
}
|
||||
|
||||
/// Linear per-component mix of two colors.
|
||||
pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let lerp = |x: f32, y: f32| x + (y - x) * t;
|
||||
hsla(
|
||||
lerp(a.h, b.h),
|
||||
lerp(a.s, b.s),
|
||||
lerp(a.l, b.l),
|
||||
lerp(a.a, b.a),
|
||||
)
|
||||
}
|
||||
+99
-564
@@ -1,583 +1,118 @@
|
||||
use gpui::{Hsla, Rgba, hsla};
|
||||
use gpui::Hsla;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::scale::{ColorScale, ColorScaleSet};
|
||||
use crate::syntax::SyntaxPalette;
|
||||
use crate::{ThemeMode, palette};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct ThemeColors {
|
||||
// Surface colors
|
||||
pub background: Hsla,
|
||||
pub surface_background: Hsla,
|
||||
pub elevated_surface_background: Hsla,
|
||||
pub panel_background: Hsla,
|
||||
pub overlay: Hsla,
|
||||
pub title_bar: Hsla,
|
||||
pub title_bar_inactive: Hsla,
|
||||
pub window_border: Hsla,
|
||||
/// The main content plane.
|
||||
pub bg: Hsla,
|
||||
/// The shell / sidebar plane.
|
||||
pub surface: Hsla,
|
||||
/// Opaque pills and chips raised above the panel.
|
||||
pub surface_raised: Hsla,
|
||||
/// An inline card resting on the main plane.
|
||||
pub surface_card: Hsla,
|
||||
/// A modal dialog, over the scrim.
|
||||
pub surface_dialog: Hsla,
|
||||
/// Popovers and menus — the highest plane.
|
||||
pub surface_overlay: Hsla,
|
||||
/// Row hover — translucent ink over whatever is beneath.
|
||||
pub element_hover: Hsla,
|
||||
/// Pressed or selected, one rung past [`ThemeColors::element_hover`].
|
||||
pub element_active: Hsla,
|
||||
|
||||
// Border colors
|
||||
/// The standard hairline border.
|
||||
pub border: Hsla,
|
||||
pub border_variant: Hsla,
|
||||
pub border_focused: Hsla,
|
||||
pub border_selected: Hsla,
|
||||
pub border_transparent: Hsla,
|
||||
pub border_disabled: Hsla,
|
||||
/// Dividers inside cards, nested frames.
|
||||
pub border_faint: Hsla,
|
||||
/// Focused or raised edges.
|
||||
pub border_strong: Hsla,
|
||||
/// The focus ring.
|
||||
pub ring: Hsla,
|
||||
|
||||
// Text colors
|
||||
/// Primary text.
|
||||
pub text: Hsla,
|
||||
/// Secondary text.
|
||||
pub text_muted: Hsla,
|
||||
pub text_placeholder: Hsla,
|
||||
pub text_accent: Hsla,
|
||||
pub text_danger: Hsla,
|
||||
pub text_warning: Hsla,
|
||||
/// Placeholders and disabled text.
|
||||
pub text_faint: Hsla,
|
||||
/// Diff file paths — one notch below [`ThemeColors::text_muted`].
|
||||
pub text_dim: Hsla,
|
||||
|
||||
// Icon colors
|
||||
pub icon: Hsla,
|
||||
pub icon_muted: Hsla,
|
||||
pub icon_accent: Hsla,
|
||||
|
||||
// Element colors
|
||||
pub element_foreground: Hsla,
|
||||
pub element_background: Hsla,
|
||||
pub element_hover: Hsla,
|
||||
pub element_active: Hsla,
|
||||
pub element_selected: Hsla,
|
||||
pub element_disabled: Hsla,
|
||||
|
||||
// Secondary element colors
|
||||
pub secondary_foreground: Hsla,
|
||||
pub secondary_background: Hsla,
|
||||
pub secondary_hover: Hsla,
|
||||
pub secondary_active: Hsla,
|
||||
pub secondary_selected: Hsla,
|
||||
pub secondary_disabled: Hsla,
|
||||
|
||||
// Danger element colors
|
||||
pub danger_foreground: Hsla,
|
||||
pub danger_background: Hsla,
|
||||
pub danger_hover: Hsla,
|
||||
pub danger_active: Hsla,
|
||||
pub danger_selected: Hsla,
|
||||
pub danger_disabled: Hsla,
|
||||
|
||||
// Warning element colors
|
||||
pub warning_foreground: Hsla,
|
||||
pub warning_background: Hsla,
|
||||
pub warning_hover: Hsla,
|
||||
pub warning_active: Hsla,
|
||||
pub warning_selected: Hsla,
|
||||
pub warning_disabled: Hsla,
|
||||
|
||||
// Ghost element colors
|
||||
pub ghost_element_background: Hsla,
|
||||
pub ghost_element_background_alt: Hsla,
|
||||
pub ghost_element_hover: Hsla,
|
||||
pub ghost_element_active: Hsla,
|
||||
pub ghost_element_selected: Hsla,
|
||||
pub ghost_element_disabled: Hsla,
|
||||
|
||||
// Tab colors
|
||||
pub tab_background: Hsla,
|
||||
pub tab_foreground: Hsla,
|
||||
pub tab_hover_background: Hsla,
|
||||
pub tab_active_background: Hsla,
|
||||
pub tab_active_foreground: Hsla,
|
||||
|
||||
// Scrollbar colors
|
||||
pub scrollbar_thumb_background: Hsla,
|
||||
pub scrollbar_thumb_hover_background: Hsla,
|
||||
pub scrollbar_thumb_border: Hsla,
|
||||
pub scrollbar_track_background: Hsla,
|
||||
pub scrollbar_track_border: Hsla,
|
||||
|
||||
// Interactive colors
|
||||
pub drop_target_background: Hsla,
|
||||
/// The text cursor.
|
||||
pub cursor: Hsla,
|
||||
/// The selected-text wash.
|
||||
pub selection: Hsla,
|
||||
|
||||
/// The primary button plate — the maximum-contrast fill.
|
||||
pub solid: Hsla,
|
||||
/// Label ink on [`ThemeColors::solid`].
|
||||
pub on_solid: Hsla,
|
||||
|
||||
/// Emphasis for text and icons; neutral by default.
|
||||
pub accent: Hsla,
|
||||
/// Accent fills carrying [`ThemeColors::on_accent`].
|
||||
pub accent_strong: Hsla,
|
||||
/// Label ink on [`ThemeColors::accent_strong`].
|
||||
pub on_accent: Hsla,
|
||||
|
||||
/// Errors and destructive actions.
|
||||
pub danger: Hsla,
|
||||
/// Inline error copy.
|
||||
pub danger_muted: Hsla,
|
||||
/// The destructive plate — white ink in both appearances.
|
||||
pub danger_strong: Hsla,
|
||||
/// Warnings.
|
||||
pub warning: Hsla,
|
||||
/// The translucent warning wash behind chips and banners.
|
||||
pub warning_muted: Hsla,
|
||||
/// Success and online presence.
|
||||
pub success: Hsla,
|
||||
/// Text on success-tinted chips.
|
||||
pub success_muted: Hsla,
|
||||
/// Working or streaming states.
|
||||
pub busy: Hsla,
|
||||
|
||||
/// Hover over [`ThemeColors::surface_raised`].
|
||||
pub surface_raised_hover: Hsla,
|
||||
/// A recessed header / footer strip.
|
||||
pub band: Hsla,
|
||||
/// Input backgrounds.
|
||||
pub input_bg: Hsla,
|
||||
/// The text caret.
|
||||
pub caret: Hsla,
|
||||
/// The line marking where a drag will land.
|
||||
pub drop_line: Hsla,
|
||||
/// The fill of the region a dragged pane would dock into.
|
||||
pub drop_target: Hsla,
|
||||
|
||||
/// Inline-code text.
|
||||
pub code_text: Hsla,
|
||||
/// The wash behind [`ThemeColors::code_text`].
|
||||
pub code_wash: Hsla,
|
||||
/// The shared syntax palette, read at paint time.
|
||||
pub syntax: SyntaxPalette,
|
||||
/// Diff additions.
|
||||
pub diff_add: Hsla,
|
||||
/// Diff deletions.
|
||||
pub diff_del: Hsla,
|
||||
/// The diff hunk-header wash.
|
||||
pub diff_hunk_bg: Hsla,
|
||||
}
|
||||
|
||||
impl Default for ThemeColors {
|
||||
fn default() -> Self {
|
||||
palette::dark()
|
||||
}
|
||||
}
|
||||
|
||||
/// The default colors for the theme.
|
||||
///
|
||||
/// Themes that do not specify all colors are refined off of these defaults.
|
||||
impl ThemeColors {
|
||||
/// Returns the default colors for light themes.
|
||||
///
|
||||
/// Themes that do not specify all colors are refined off of these defaults.
|
||||
pub(crate) fn light() -> Self {
|
||||
Self {
|
||||
background: neutral().light().step_1(),
|
||||
surface_background: neutral().light().step_2(),
|
||||
elevated_surface_background: neutral().light().step_3(),
|
||||
panel_background: neutral().light().step_1(),
|
||||
overlay: neutral().light_alpha().step_3(),
|
||||
title_bar: neutral().light().step_2(),
|
||||
title_bar_inactive: neutral().light().step_1(),
|
||||
window_border: hsl(240.0, 5.9, 78.0),
|
||||
|
||||
border: neutral().light().step_6(),
|
||||
border_variant: neutral().light().step_4(),
|
||||
border_focused: brand().light().step_7(),
|
||||
border_selected: brand().light().step_7(),
|
||||
border_transparent: gpui::transparent_black(),
|
||||
border_disabled: neutral().light().step_3(),
|
||||
ring: brand().light().step_8(),
|
||||
|
||||
text: neutral().light().step_12(),
|
||||
text_muted: neutral().light().step_11(),
|
||||
text_placeholder: neutral().light().step_10(),
|
||||
text_accent: brand().light().step_11(),
|
||||
text_danger: danger().light().step_9(),
|
||||
text_warning: warning().light().step_9(),
|
||||
|
||||
icon: neutral().light().step_11(),
|
||||
icon_muted: neutral().light().step_10(),
|
||||
icon_accent: brand().light().step_11(),
|
||||
|
||||
element_foreground: brand().light().step_12(),
|
||||
element_background: brand().light().step_9(),
|
||||
element_hover: brand().light_alpha().step_10(),
|
||||
element_active: brand().light().step_10(),
|
||||
element_selected: brand().light().step_11(),
|
||||
element_disabled: brand().light_alpha().step_3(),
|
||||
|
||||
secondary_foreground: brand().light().step_11(),
|
||||
secondary_background: brand().light().step_3(),
|
||||
secondary_hover: brand().light_alpha().step_4(),
|
||||
secondary_active: brand().light().step_5(),
|
||||
secondary_selected: brand().light().step_5(),
|
||||
secondary_disabled: brand().light_alpha().step_3(),
|
||||
|
||||
danger_foreground: danger().light().step_12(),
|
||||
danger_background: danger().light().step_3(),
|
||||
danger_hover: danger().light_alpha().step_4(),
|
||||
danger_active: danger().light().step_5(),
|
||||
danger_selected: danger().light().step_5(),
|
||||
danger_disabled: danger().light_alpha().step_3(),
|
||||
|
||||
warning_foreground: warning().light().step_12(),
|
||||
warning_background: warning().light().step_3(),
|
||||
warning_hover: warning().light_alpha().step_4(),
|
||||
warning_active: warning().light().step_5(),
|
||||
warning_selected: warning().light().step_5(),
|
||||
warning_disabled: warning().light_alpha().step_3(),
|
||||
|
||||
ghost_element_background: gpui::transparent_black(),
|
||||
ghost_element_background_alt: neutral().light().step_3(),
|
||||
ghost_element_hover: neutral().light_alpha().step_4(),
|
||||
ghost_element_active: neutral().light().step_5(),
|
||||
ghost_element_selected: neutral().light().step_5(),
|
||||
ghost_element_disabled: neutral().light_alpha().step_2(),
|
||||
|
||||
tab_background: neutral().light().step_3(),
|
||||
tab_foreground: neutral().light().step_11(),
|
||||
tab_hover_background: neutral().light_alpha().step_4(),
|
||||
tab_active_background: neutral().light().step_1(),
|
||||
tab_active_foreground: neutral().light().step_12(),
|
||||
|
||||
scrollbar_thumb_background: neutral().light_alpha().step_3(),
|
||||
scrollbar_thumb_hover_background: neutral().light_alpha().step_4(),
|
||||
scrollbar_thumb_border: gpui::transparent_black(),
|
||||
scrollbar_track_background: gpui::transparent_black(),
|
||||
scrollbar_track_border: gpui::transparent_black(),
|
||||
|
||||
drop_target_background: brand().light_alpha().step_2(),
|
||||
cursor: hsl(200., 100., 50.),
|
||||
selection: hsl(200., 100., 50.).alpha(0.25),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default colors for dark themes.
|
||||
///
|
||||
/// Themes that do not specify all colors are refined off of these defaults.
|
||||
pub(crate) fn dark() -> Self {
|
||||
Self {
|
||||
background: neutral().dark().step_1(),
|
||||
surface_background: neutral().dark().step_2(),
|
||||
elevated_surface_background: neutral().dark().step_3(),
|
||||
panel_background: neutral().dark().step_1(),
|
||||
overlay: neutral().dark_alpha().step_3(),
|
||||
title_bar: neutral().dark().step_2(),
|
||||
title_bar_inactive: neutral().dark().step_1(),
|
||||
window_border: hsl(240.0, 3.7, 28.0),
|
||||
|
||||
border: neutral().dark().step_6(),
|
||||
border_variant: neutral().dark().step_4(),
|
||||
border_focused: brand().dark().step_7(),
|
||||
border_selected: brand().dark().step_7(),
|
||||
border_transparent: gpui::transparent_black(),
|
||||
border_disabled: neutral().dark().step_3(),
|
||||
ring: brand().dark().step_8(),
|
||||
|
||||
text: neutral().dark().step_12(),
|
||||
text_muted: neutral().dark().step_11(),
|
||||
text_placeholder: neutral().dark().step_10(),
|
||||
text_accent: brand().dark().step_11(),
|
||||
text_danger: danger().dark().step_9(),
|
||||
text_warning: warning().dark().step_9(),
|
||||
|
||||
icon: neutral().dark().step_11(),
|
||||
icon_muted: neutral().dark().step_10(),
|
||||
icon_accent: brand().dark().step_11(),
|
||||
|
||||
element_foreground: brand().dark().step_1(),
|
||||
element_background: brand().dark().step_9(),
|
||||
element_hover: brand().dark_alpha().step_10(),
|
||||
element_active: brand().dark().step_10(),
|
||||
element_selected: brand().dark().step_11(),
|
||||
element_disabled: brand().dark_alpha().step_3(),
|
||||
|
||||
secondary_foreground: brand().dark().step_12(),
|
||||
secondary_background: brand().dark().step_3(),
|
||||
secondary_hover: brand().dark_alpha().step_4(),
|
||||
secondary_active: brand().dark().step_5(),
|
||||
secondary_selected: brand().dark().step_5(),
|
||||
secondary_disabled: brand().dark_alpha().step_3(),
|
||||
|
||||
danger_foreground: danger().dark().step_12(),
|
||||
danger_background: danger().dark().step_3(),
|
||||
danger_hover: danger().dark_alpha().step_4(),
|
||||
danger_active: danger().dark().step_5(),
|
||||
danger_selected: danger().dark().step_5(),
|
||||
danger_disabled: danger().dark_alpha().step_3(),
|
||||
|
||||
warning_foreground: warning().dark().step_12(),
|
||||
warning_background: warning().dark().step_3(),
|
||||
warning_hover: warning().dark_alpha().step_4(),
|
||||
warning_active: warning().dark().step_5(),
|
||||
warning_selected: warning().dark().step_5(),
|
||||
warning_disabled: warning().dark_alpha().step_3(),
|
||||
|
||||
ghost_element_background: gpui::transparent_black(),
|
||||
ghost_element_background_alt: neutral().dark().step_3(),
|
||||
ghost_element_hover: neutral().dark_alpha().step_4(),
|
||||
ghost_element_active: neutral().dark().step_5(),
|
||||
ghost_element_selected: neutral().dark().step_5(),
|
||||
ghost_element_disabled: neutral().dark_alpha().step_2(),
|
||||
|
||||
tab_background: neutral().dark().step_3(),
|
||||
tab_foreground: neutral().dark().step_11(),
|
||||
tab_hover_background: neutral().dark_alpha().step_4(),
|
||||
tab_active_background: neutral().dark().step_1(),
|
||||
tab_active_foreground: neutral().dark().step_12(),
|
||||
|
||||
scrollbar_thumb_background: neutral().dark_alpha().step_3(),
|
||||
scrollbar_thumb_hover_background: neutral().dark_alpha().step_4(),
|
||||
scrollbar_thumb_border: gpui::transparent_black(),
|
||||
scrollbar_track_background: gpui::transparent_black(),
|
||||
scrollbar_track_border: gpui::transparent_black(),
|
||||
|
||||
drop_target_background: brand().dark_alpha().step_2(),
|
||||
cursor: hsl(200., 100., 50.),
|
||||
selection: hsl(200., 100., 50.).alpha(0.25),
|
||||
}
|
||||
/// Returns the default colors for an appearance.
|
||||
pub fn for_appearance(appearance: ThemeMode) -> Self {
|
||||
palette::for_appearance(appearance)
|
||||
}
|
||||
}
|
||||
|
||||
fn neutral() -> ColorScaleSet {
|
||||
gray()
|
||||
}
|
||||
|
||||
fn brand() -> ColorScaleSet {
|
||||
yellow()
|
||||
}
|
||||
|
||||
fn danger() -> ColorScaleSet {
|
||||
tomato()
|
||||
}
|
||||
|
||||
fn warning() -> ColorScaleSet {
|
||||
orange()
|
||||
}
|
||||
|
||||
/// Make a [gpui::Hsla] color.
|
||||
///
|
||||
/// - h: 0..360.0
|
||||
/// - s: 0.0..100.0
|
||||
/// - l: 0.0..100.0
|
||||
pub(crate) fn hsl(h: f32, s: f32, l: f32) -> Hsla {
|
||||
hsla(h / 360., s / 100.0, l / 100.0, 1.0)
|
||||
}
|
||||
|
||||
type StaticColorScale = [&'static str; 12];
|
||||
|
||||
struct StaticColorScaleSet {
|
||||
light: StaticColorScale,
|
||||
light_alpha: StaticColorScale,
|
||||
dark: StaticColorScale,
|
||||
dark_alpha: StaticColorScale,
|
||||
}
|
||||
|
||||
impl TryFrom<StaticColorScaleSet> for ColorScaleSet {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: StaticColorScaleSet) -> Result<Self, Self::Error> {
|
||||
fn to_color_scale(scale: StaticColorScale) -> Result<ColorScale, anyhow::Error> {
|
||||
scale
|
||||
.into_iter()
|
||||
.map(|color| Rgba::try_from(color).map(Hsla::from))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(ColorScale::from_iter)
|
||||
}
|
||||
|
||||
Ok(Self::new(
|
||||
to_color_scale(value.light)?,
|
||||
to_color_scale(value.light_alpha)?,
|
||||
to_color_scale(value.dark)?,
|
||||
to_color_scale(value.dark_alpha)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gray() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fcfcfcff",
|
||||
"#f9f9f9ff",
|
||||
"#f0f0f0ff",
|
||||
"#e8e8e8ff",
|
||||
"#e0e0e0ff",
|
||||
"#d9d9d9ff",
|
||||
"#cececeff",
|
||||
"#bbbbbbff",
|
||||
"#8d8d8dff",
|
||||
"#838383ff",
|
||||
"#646464ff",
|
||||
"#202020ff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#00000003",
|
||||
"#00000006",
|
||||
"#0000000f",
|
||||
"#00000017",
|
||||
"#0000001f",
|
||||
"#00000026",
|
||||
"#00000031",
|
||||
"#00000044",
|
||||
"#00000072",
|
||||
"#0000007c",
|
||||
"#0000009b",
|
||||
"#000000df",
|
||||
],
|
||||
dark: [
|
||||
"#111111ff",
|
||||
"#191919ff",
|
||||
"#222222ff",
|
||||
"#2a2a2aff",
|
||||
"#313131ff",
|
||||
"#3a3a3aff",
|
||||
"#484848ff",
|
||||
"#606060ff",
|
||||
"#6e6e6eff",
|
||||
"#7b7b7bff",
|
||||
"#b4b4b4ff",
|
||||
"#eeeeeeff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#00000000",
|
||||
"#ffffff09",
|
||||
"#ffffff12",
|
||||
"#ffffff1b",
|
||||
"#ffffff22",
|
||||
"#ffffff2c",
|
||||
"#ffffff3b",
|
||||
"#ffffff55",
|
||||
"#ffffff64",
|
||||
"#ffffff72",
|
||||
"#ffffffaf",
|
||||
"#ffffffed",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn yellow() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fdfdf9ff",
|
||||
"#fefce9ff",
|
||||
"#fffab8ff",
|
||||
"#fff394ff",
|
||||
"#ffe770ff",
|
||||
"#f3d768ff",
|
||||
"#e4c767ff",
|
||||
"#d5ae39ff",
|
||||
"#ffe629ff",
|
||||
"#ffdc00ff",
|
||||
"#9e6c00ff",
|
||||
"#473b1fff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#aaaa0006",
|
||||
"#f4dd0016",
|
||||
"#ffee0047",
|
||||
"#ffe3016b",
|
||||
"#ffd5008f",
|
||||
"#ebbc0097",
|
||||
"#d2a10098",
|
||||
"#c99700c6",
|
||||
"#ffe100d6",
|
||||
"#ffdc00ff",
|
||||
"#9e6c00ff",
|
||||
"#2e2000e0",
|
||||
],
|
||||
dark: [
|
||||
"#14120bff",
|
||||
"#1b180fff",
|
||||
"#2d2305ff",
|
||||
"#362b00ff",
|
||||
"#433500ff",
|
||||
"#524202ff",
|
||||
"#665417ff",
|
||||
"#836a21ff",
|
||||
"#ffe629ff",
|
||||
"#ffff57ff",
|
||||
"#f5e147ff",
|
||||
"#f6eeb4ff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#d1510004",
|
||||
"#f9b4000b",
|
||||
"#ffaa001e",
|
||||
"#fdb70028",
|
||||
"#febb0036",
|
||||
"#fec40046",
|
||||
"#fdcb225c",
|
||||
"#fdca327b",
|
||||
"#ffe629ff",
|
||||
"#ffff57ff",
|
||||
"#fee949f5",
|
||||
"#fef6baf6",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn orange() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fefcfbff",
|
||||
"#fff7edff",
|
||||
"#ffefd6ff",
|
||||
"#ffdfb5ff",
|
||||
"#ffd19aff",
|
||||
"#ffc182ff",
|
||||
"#f5ae73ff",
|
||||
"#ec9455ff",
|
||||
"#f76b15ff",
|
||||
"#ef5f00ff",
|
||||
"#cc4e00ff",
|
||||
"#582d1dff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#c0400004",
|
||||
"#ff8e0012",
|
||||
"#ff9c0029",
|
||||
"#ff91014a",
|
||||
"#ff8b0065",
|
||||
"#ff81007d",
|
||||
"#ed6c008c",
|
||||
"#e35f00aa",
|
||||
"#f65e00ea",
|
||||
"#ef5f00ff",
|
||||
"#cc4e00ff",
|
||||
"#431200e2",
|
||||
],
|
||||
dark: [
|
||||
"#17120eff",
|
||||
"#1e160fff",
|
||||
"#331e0bff",
|
||||
"#462100ff",
|
||||
"#562800ff",
|
||||
"#66350cff",
|
||||
"#7e451dff",
|
||||
"#a35829ff",
|
||||
"#f76b15ff",
|
||||
"#ff801fff",
|
||||
"#ffa057ff",
|
||||
"#ffe0c2ff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#ec360007",
|
||||
"#fe6d000e",
|
||||
"#fb6a0025",
|
||||
"#ff590039",
|
||||
"#ff61004a",
|
||||
"#fd75045c",
|
||||
"#ff832c75",
|
||||
"#fe84389d",
|
||||
"#fe6d15f7",
|
||||
"#ff801fff",
|
||||
"#ffa057ff",
|
||||
"#ffe0c2ff",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn tomato() -> ColorScaleSet {
|
||||
StaticColorScaleSet {
|
||||
light: [
|
||||
"#fffcfcff",
|
||||
"#fff8f7ff",
|
||||
"#feebe7ff",
|
||||
"#ffdcd3ff",
|
||||
"#ffcdc2ff",
|
||||
"#fdbdafff",
|
||||
"#f5a898ff",
|
||||
"#ec8e7bff",
|
||||
"#e54d2eff",
|
||||
"#dd4425ff",
|
||||
"#d13415ff",
|
||||
"#5c271fff",
|
||||
],
|
||||
light_alpha: [
|
||||
"#ff000003",
|
||||
"#ff200008",
|
||||
"#f52b0018",
|
||||
"#ff35002c",
|
||||
"#ff2e003d",
|
||||
"#f92d0050",
|
||||
"#e7280067",
|
||||
"#db250084",
|
||||
"#df2600d1",
|
||||
"#d72400da",
|
||||
"#cd2200ea",
|
||||
"#460900e0",
|
||||
],
|
||||
dark: [
|
||||
"#181111ff",
|
||||
"#1f1513ff",
|
||||
"#391714ff",
|
||||
"#4e1511ff",
|
||||
"#5e1c16ff",
|
||||
"#6e2920ff",
|
||||
"#853a2dff",
|
||||
"#ac4d39ff",
|
||||
"#e54d2eff",
|
||||
"#ec6142ff",
|
||||
"#ff977dff",
|
||||
"#fbd3cbff",
|
||||
],
|
||||
dark_alpha: [
|
||||
"#f1121208",
|
||||
"#ff55330f",
|
||||
"#ff35232b",
|
||||
"#fd201142",
|
||||
"#fe332153",
|
||||
"#ff4f3864",
|
||||
"#fd644a7d",
|
||||
"#fe6d4ea7",
|
||||
"#fe5431e4",
|
||||
"#ff6847eb",
|
||||
"#ff977dff",
|
||||
"#ffd6cefb",
|
||||
],
|
||||
}
|
||||
.try_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Layout constants, ported from bezel
|
||||
//! (`crates/theme/src/theme/layout.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors). Numbers drive layout, colors are paint: these live as plain
|
||||
//! numbers and never depend on which color is painted.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// The branded base radius behind every radius accessor, as raw `f32` bits.
|
||||
static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits());
|
||||
|
||||
/// Point the radius accessors at a base. Called when a palette is installed.
|
||||
pub(crate) fn set_base_radius(radius: f32) {
|
||||
BASE.store(radius.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The gap between siblings. Measured on macOS 26: `NSStackView().spacing`,
|
||||
/// visual format's `-`, and `constraint(equalToSystemSpacingAfter:multiplier: 1)`
|
||||
/// all report 8.
|
||||
pub const SPACE: f32 = 8.0;
|
||||
|
||||
/// The margin from content to its container's edge. Same measurement,
|
||||
/// visual format's `|-`.
|
||||
pub const CONTENT_MARGIN: f32 = 20.0;
|
||||
|
||||
/// From a column's edge to the glyph of the control at the end of a row,
|
||||
/// measured to the glyph rather than the button. Mirrored at the trailing end.
|
||||
pub const EDGE: f32 = 12.0;
|
||||
|
||||
/// Button, text field and select-trigger height. Measured:
|
||||
/// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at
|
||||
/// `.regular` — the body line box with 4 above and below.
|
||||
pub const BUTTON_HEIGHT: f32 = 24.0;
|
||||
|
||||
/// The same controls at `.small`.
|
||||
pub const CONTROL_HEIGHT_SMALL: f32 = 20.0;
|
||||
|
||||
/// Every radius below is a ratio of this, so a brand radius moves the whole
|
||||
/// set together and keeps the concentric relationships intact.
|
||||
pub const BASE_RADIUS: f32 = 8.0;
|
||||
|
||||
/// Message bubble corner radius (2x).
|
||||
pub fn bubble_radius() -> f32 {
|
||||
radius(2.0)
|
||||
}
|
||||
|
||||
/// Floating-surface corner radius — popovers, menus, dialogs (1.5x).
|
||||
pub fn surface_radius() -> f32 {
|
||||
radius(1.5)
|
||||
}
|
||||
|
||||
/// Panel / card corner radius (1.25x).
|
||||
pub fn panel_radius() -> f32 {
|
||||
radius(1.25)
|
||||
}
|
||||
|
||||
/// Button, text field and select-trigger radius (1x).
|
||||
pub fn button_radius() -> f32 {
|
||||
radius(1.0)
|
||||
}
|
||||
|
||||
/// Small control radius (chips, tags, steppers) — a size down from
|
||||
/// [`button_radius`], for things that sit inside a control rather than being one
|
||||
/// (0.75x).
|
||||
pub fn control_radius() -> f32 {
|
||||
radius(0.75)
|
||||
}
|
||||
|
||||
/// A corner as a multiple of the branded base radius.
|
||||
///
|
||||
/// Read from a process-wide mirror rather than the theme global for the reason
|
||||
/// [`crate::paint::current_appearance`] is: the element builders that round a
|
||||
/// corner are free functions with no `cx` in scope, and a radius is one number
|
||||
/// for the whole app.
|
||||
fn radius(ratio: f32) -> f32 {
|
||||
f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio
|
||||
}
|
||||
|
||||
/// The concentric child of a surface: a row inset by `inset` inside a
|
||||
/// container of radius `outer` keeps its corners parallel to the container's,
|
||||
/// rather than looking pasted onto it.
|
||||
///
|
||||
/// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui
|
||||
/// has no container shape to inherit at paint time, so the relationship is
|
||||
/// stated where the child is *defined* instead of resolved at runtime.
|
||||
pub const fn inset_radius(outer: f32, inset: f32) -> f32 {
|
||||
if outer > inset { outer - inset } else { 0.0 }
|
||||
}
|
||||
+135
-99
@@ -1,25 +1,51 @@
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Global, Pixels, SharedString, Window, px};
|
||||
|
||||
mod brand;
|
||||
mod color;
|
||||
mod colors;
|
||||
mod geometry;
|
||||
mod layout;
|
||||
mod notification;
|
||||
mod paint;
|
||||
mod palette;
|
||||
mod platform_kind;
|
||||
mod registry;
|
||||
mod scale;
|
||||
mod scrollbar_mode;
|
||||
mod syntax;
|
||||
mod theme;
|
||||
mod typography;
|
||||
|
||||
pub mod appearance;
|
||||
|
||||
pub use appearance::AppearanceMode;
|
||||
pub use brand::{BASE_COLORS, Brand, Ink, TextInk, Tint};
|
||||
pub use color::{
|
||||
contrast_ratio, flatten, grey, hsl_to_rgb, lightness, mix, neutral, oklch, oklch_to_srgb,
|
||||
relative_luminance, rgb_to_hsl, tint,
|
||||
};
|
||||
pub use colors::*;
|
||||
pub use geometry::*;
|
||||
pub use layout::{
|
||||
BASE_RADIUS, BUTTON_HEIGHT, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SPACE, bubble_radius,
|
||||
button_radius, control_radius, inset_radius, panel_radius, surface_radius,
|
||||
};
|
||||
pub use notification::*;
|
||||
pub use paint::{
|
||||
INK_FILL_SCALE, INK_HAIRLINE_SCALE, SCRIM_ALPHA_DARK, band, current_appearance, hairline, ink,
|
||||
plate_active, plate_hover, scrim, set_current_appearance, surface_shadows, wash,
|
||||
};
|
||||
pub use platform_kind::PlatformKind;
|
||||
pub use registry::*;
|
||||
pub use scale::*;
|
||||
pub use scrollbar_mode::*;
|
||||
pub use syntax::{HighlightKind, SyntaxPalette};
|
||||
pub use theme::*;
|
||||
pub use typography::{
|
||||
DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size, set_base_text_size,
|
||||
};
|
||||
|
||||
/// Which appearance the app is painting. Bezel names this enum `Appearance`;
|
||||
/// coop's `ThemeMode` predates the port and carries the same two variants.
|
||||
pub type Appearance = ThemeMode;
|
||||
|
||||
/// Defines window border radius for platforms that use client side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
|
||||
@@ -40,25 +66,57 @@ pub const TABBAR_HEIGHT: Pixels = px(36.0);
|
||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
registry::init(cx);
|
||||
|
||||
Theme::sync_system_appearance(None, cx);
|
||||
appearance::init(AppearanceMode::default(), cx);
|
||||
Theme::sync_scrollbar_appearance(cx);
|
||||
}
|
||||
|
||||
/// An alternative palette source.
|
||||
pub type PaletteBuilder = fn(ThemeMode) -> ThemeColors;
|
||||
|
||||
struct GlobalPaletteBuilder(Option<PaletteBuilder>);
|
||||
|
||||
impl Global for GlobalPaletteBuilder {}
|
||||
|
||||
/// Install a brand or an alternative palette on the app.
|
||||
pub trait AppExt {
|
||||
fn set_brand(&mut self, brand: Brand);
|
||||
fn set_palette(&mut self, build: PaletteBuilder);
|
||||
fn set_appearance_mode(&mut self, mode: AppearanceMode);
|
||||
fn set_base_text_size(&mut self, points: f32);
|
||||
}
|
||||
|
||||
impl AppExt for App {
|
||||
fn set_brand(&mut self, brand: Brand) {
|
||||
self.set_global(brand);
|
||||
reinstall(self);
|
||||
}
|
||||
|
||||
fn set_palette(&mut self, build: PaletteBuilder) {
|
||||
self.set_global(GlobalPaletteBuilder(Some(build)));
|
||||
reinstall(self);
|
||||
}
|
||||
|
||||
fn set_appearance_mode(&mut self, mode: AppearanceMode) {
|
||||
appearance::set_mode(mode, self);
|
||||
}
|
||||
|
||||
fn set_base_text_size(&mut self, points: f32) {
|
||||
typography::set_base_text_size(points);
|
||||
self.refresh_windows();
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-install the palette for the current appearance.
|
||||
fn reinstall(cx: &mut App) {
|
||||
let mode = cx
|
||||
.try_global::<Theme>()
|
||||
.map(|theme| theme.mode)
|
||||
.unwrap_or_default();
|
||||
Theme::change(mode, None, cx);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
|
||||
/// Mirror the active coop theme into the `gpui-base` global theme.
|
||||
///
|
||||
/// Base paints a few things from its own tokens -- the focus ring, the wash
|
||||
/// behind selected text, scrollbars, and overlay backdrops -- so the two
|
||||
/// globals have to agree or those details drift away from the palette.
|
||||
///
|
||||
/// Only roles base can act on are projected. Radius, spacing, typography sizes,
|
||||
/// shadows, and scrollbar geometry keep their base defaults: coop has a single
|
||||
/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any
|
||||
/// mapping would be invented rather than derived.
|
||||
///
|
||||
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
|
||||
/// authoritative hook that keeps the projection current.
|
||||
pub fn sync_base(cx: &mut App) {
|
||||
let Some(theme) = cx.try_global::<Theme>() else {
|
||||
return;
|
||||
@@ -75,28 +133,28 @@ pub fn sync_base(cx: &mut App) {
|
||||
ScrollbarMode::Always => gpui_base::ScrollbarMode::Always,
|
||||
};
|
||||
let colors = theme.colors;
|
||||
let font_family = theme.font_family.clone();
|
||||
let font_sans = theme.font_sans.clone();
|
||||
|
||||
let base = gpui_base::Theme::global_mut(cx);
|
||||
base.appearance = appearance;
|
||||
base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode);
|
||||
base.tokens.typography.sans = font_family;
|
||||
base.tokens.typography.sans = font_sans;
|
||||
|
||||
let tokens = &mut base.tokens.colors;
|
||||
tokens.background = colors.background;
|
||||
tokens.background = colors.bg;
|
||||
tokens.foreground = colors.text;
|
||||
tokens.surface = colors.surface_background;
|
||||
tokens.surface = colors.surface;
|
||||
tokens.surface_foreground = colors.text;
|
||||
tokens.primary = colors.element_background;
|
||||
tokens.primary_foreground = colors.element_foreground;
|
||||
tokens.secondary = colors.secondary_background;
|
||||
tokens.secondary_foreground = colors.secondary_foreground;
|
||||
tokens.muted = colors.ghost_element_background_alt;
|
||||
tokens.primary = colors.solid;
|
||||
tokens.primary_foreground = colors.on_solid;
|
||||
tokens.secondary = colors.surface_raised;
|
||||
tokens.secondary_foreground = colors.text;
|
||||
tokens.muted = colors.surface_card;
|
||||
tokens.muted_foreground = colors.text_muted;
|
||||
tokens.accent = colors.ghost_element_hover;
|
||||
tokens.accent = colors.element_hover;
|
||||
tokens.accent_foreground = colors.text;
|
||||
tokens.destructive = colors.danger_background;
|
||||
tokens.destructive_foreground = colors.danger_foreground;
|
||||
tokens.destructive = colors.danger_strong;
|
||||
tokens.destructive_foreground = gpui::white();
|
||||
tokens.border = colors.border;
|
||||
tokens.input = colors.border;
|
||||
tokens.ring = colors.ring;
|
||||
@@ -116,34 +174,12 @@ impl ActiveTheme for App {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
/// Theme colors
|
||||
pub colors: ThemeColors,
|
||||
|
||||
/// Theme family
|
||||
pub theme: Rc<ThemeFamily>,
|
||||
|
||||
/// The appearance of the theme (light or dark).
|
||||
pub mode: ThemeMode,
|
||||
|
||||
/// The font family for the application.
|
||||
pub font_family: SharedString,
|
||||
|
||||
/// The root font size for the application, default is 15px.
|
||||
pub font_size: Pixels,
|
||||
|
||||
/// Radius for the general elements.
|
||||
pub radius: Pixels,
|
||||
|
||||
/// Radius for the large elements, e.g.: dialog, notification.
|
||||
pub radius_lg: Pixels,
|
||||
|
||||
/// Enable shadow for the general elements. default is true
|
||||
pub font_sans: SharedString,
|
||||
pub font_mono: SharedString,
|
||||
pub shadow: bool,
|
||||
|
||||
/// Show the scrollbar mode, default: scrolling
|
||||
pub scrollbar_mode: ScrollbarMode,
|
||||
|
||||
/// Notification settings
|
||||
pub notification: NotificationSettings,
|
||||
}
|
||||
|
||||
@@ -181,12 +217,7 @@ impl Theme {
|
||||
|
||||
/// Sync the theme with the system appearance
|
||||
pub fn sync_system_appearance(window: Option<&mut Window>, cx: &mut App) {
|
||||
let appearance = window
|
||||
.as_ref()
|
||||
.map(|window| window.appearance())
|
||||
.unwrap_or_else(|| cx.window_appearance());
|
||||
|
||||
Self::change(appearance, window, cx);
|
||||
appearance::record_system_appearance(window.as_deref(), cx);
|
||||
}
|
||||
|
||||
/// Sync the Scrollbar showing behavior with the system
|
||||
@@ -198,79 +229,84 @@ impl Theme {
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply a new theme to the application.
|
||||
pub fn apply_theme(new_theme: Rc<ThemeFamily>, window: Option<&mut Window>, cx: &mut App) {
|
||||
let theme = cx.global_mut::<Theme>();
|
||||
let mode = theme.mode;
|
||||
// Update the theme
|
||||
theme.theme = new_theme;
|
||||
// Emit a theme change event
|
||||
Self::change(mode, window, cx);
|
||||
}
|
||||
|
||||
/// Change the app's appearance
|
||||
pub fn change<M>(mode: M, window: Option<&mut Window>, cx: &mut App)
|
||||
where
|
||||
M: Into<ThemeMode>,
|
||||
{
|
||||
if !cx.has_global::<Theme>() {
|
||||
let default_theme = ThemeFamily::default();
|
||||
let theme = Theme::from(default_theme);
|
||||
|
||||
cx.set_global(theme);
|
||||
cx.set_global(Theme::default());
|
||||
}
|
||||
|
||||
let mode = mode.into();
|
||||
let theme = cx.global_mut::<Theme>();
|
||||
let builder = cx
|
||||
.try_global::<GlobalPaletteBuilder>()
|
||||
.and_then(|builder| builder.0);
|
||||
let mut colors = match builder {
|
||||
Some(build) => build(mode),
|
||||
None => ThemeColors::for_appearance(mode),
|
||||
};
|
||||
|
||||
// A default brand changes nothing; anything else rotates the tokens.
|
||||
let installed_brand = brand::installed(cx);
|
||||
if !installed_brand.is_default() {
|
||||
installed_brand.apply(mode, &mut colors);
|
||||
}
|
||||
|
||||
let theme = Theme::global_mut(cx);
|
||||
|
||||
// Set the theme mode
|
||||
theme.mode = mode;
|
||||
|
||||
// Set the theme colors
|
||||
if mode.is_dark() {
|
||||
theme.colors = *theme.theme.dark();
|
||||
} else {
|
||||
theme.colors = *theme.theme.light();
|
||||
}
|
||||
theme.colors = colors;
|
||||
|
||||
// Refresh the window if available
|
||||
if let Some(window) = window {
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// Keep the context-free paint helpers and the radius accessors in step
|
||||
paint::set_current_appearance(mode);
|
||||
layout::set_base_radius(installed_brand.radius);
|
||||
|
||||
// Keep the base-layer projection in step with the coop palette
|
||||
sync_base(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ThemeFamily> for Theme {
|
||||
fn from(family: ThemeFamily) -> Self {
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
let platform = PlatformKind::platform();
|
||||
let mode = ThemeMode::default();
|
||||
|
||||
// Define the font family based on the platform.
|
||||
let font_family = match platform {
|
||||
// The system UI font, by platform.
|
||||
let font_sans = match platform {
|
||||
PlatformKind::Linux => "Inter",
|
||||
_ => ".SystemUIFont",
|
||||
};
|
||||
|
||||
// Define the theme colors based on the appearance
|
||||
let colors = match mode {
|
||||
ThemeMode::Light => family.light(),
|
||||
ThemeMode::Dark => family.dark(),
|
||||
};
|
||||
|
||||
Theme {
|
||||
font_size: px(15.),
|
||||
font_family: font_family.into(),
|
||||
radius: px(6.),
|
||||
radius_lg: px(10.),
|
||||
Self {
|
||||
font_sans: font_sans.into(),
|
||||
font_mono: system_mono().into(),
|
||||
shadow: true,
|
||||
scrollbar_mode: ScrollbarMode::default(),
|
||||
notification: NotificationSettings::default(),
|
||||
mode,
|
||||
colors: *colors,
|
||||
theme: Rc::new(family),
|
||||
colors: ThemeColors::for_appearance(mode),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mono face has no alias of its own, so each backend is named here.
|
||||
fn system_mono() -> &'static str {
|
||||
if cfg!(target_family = "wasm") {
|
||||
".ZedMono"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
".AppleSystemUIFontMonospaced"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Cascadia Mono"
|
||||
} else {
|
||||
"DejaVu Sans Mono"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Context-free paint helpers, ported from bezel
|
||||
//! (`crates/theme/src/paint.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors): the process-wide appearance mirror and the free functions
|
||||
//! ([`ink`], [`hairline`], [`wash`], …) that element builders call without a
|
||||
//! `cx` in scope.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use gpui::{BoxShadow, Hsla, hsla, point, px};
|
||||
|
||||
use crate::ThemeMode;
|
||||
|
||||
/// Process-wide mirror of the installed theme's appearance.
|
||||
///
|
||||
/// The paint helpers are free functions called from deep inside element
|
||||
/// builders that have no `cx` in scope, so they read the appearance from here
|
||||
/// instead of the gpui global. Appearance is genuinely process-wide — one
|
||||
/// setting for every window — so a single mirror is sound; [`Theme::change`]
|
||||
/// is the only writer outside tests.
|
||||
static CURRENT_APPEARANCE: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// The appearance the context-free paint helpers are painting for.
|
||||
pub fn current_appearance() -> ThemeMode {
|
||||
match CURRENT_APPEARANCE.load(Ordering::Relaxed) {
|
||||
1 => ThemeMode::Light,
|
||||
_ => ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the context-free paint helpers at an appearance. Called by
|
||||
/// [`Theme::change`]; exposed for tests that build a palette without an `App`.
|
||||
pub fn set_current_appearance(appearance: ThemeMode) {
|
||||
let encoded = match appearance {
|
||||
ThemeMode::Dark => 0,
|
||||
ThemeMode::Light => 1,
|
||||
};
|
||||
CURRENT_APPEARANCE.store(encoded, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Light-mode alpha multiplier for **fills** (hover/active washes, chip and pill
|
||||
/// backgrounds).
|
||||
///
|
||||
/// The established light-UI scales (Primer, Radix) land subtle ≈ 3–4%, hover ≈ 8%,
|
||||
/// selected ≈ 14% black — which is where the dark palette's white alphas already
|
||||
/// sit. So the honest multiplier is 1: the same number in both appearances, with
|
||||
/// only the *tone* flipping. Any per-state correction belongs in that state's
|
||||
/// token, not in a blanket multiplier.
|
||||
pub const INK_FILL_SCALE: f32 = 1.0;
|
||||
|
||||
/// Light-mode alpha multiplier for **hairlines** (borders, dividers, rings).
|
||||
/// Opposite of fills: a 1px edge has to hold its own against a bright surround,
|
||||
/// and the dark palette's white hairlines are deliberately faint. Scaling up
|
||||
/// keeps separators legible instead of dissolving into the panel.
|
||||
pub const INK_HAIRLINE_SCALE: f32 = 1.35;
|
||||
|
||||
/// Lightness of [`hairline`] ink, dark then light.
|
||||
const HAIRLINE_TONE: (f32, f32) = (0.75, 0.25);
|
||||
|
||||
/// Translucent **fill** ink for interactive states and chip plates: soft-white on
|
||||
/// dark, soft-black on light at [`INK_FILL_SCALE`] of the alpha.
|
||||
///
|
||||
/// Alphas are quoted in *dark-mode terms* at every call site — the dark theme is
|
||||
/// the tuned one — and the light value is derived. Callers keep one number and
|
||||
/// both appearances stay in the relationship the dark tuning established.
|
||||
pub fn ink(alpha: f32) -> Hsla {
|
||||
ink_for(current_appearance(), alpha)
|
||||
}
|
||||
|
||||
pub(crate) fn ink_for(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => hsla(0.0, 0.0, 1.0, alpha),
|
||||
ThemeMode::Light => hsla(0.0, 0.0, 0.0, alpha * INK_FILL_SCALE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Translucent **hairline** ink for borders, dividers and rings: a light grey on
|
||||
/// dark, a dark grey on light at [`INK_HAIRLINE_SCALE`] of the alpha. Never
|
||||
/// pure white or black.
|
||||
///
|
||||
/// Separate from [`ink`] because edges and fills scale in opposite directions
|
||||
/// when the field brightens — a 1px line needs *more* ink on white, a plate needs
|
||||
/// less.
|
||||
pub fn hairline(alpha: f32) -> Hsla {
|
||||
hairline_for(current_appearance(), alpha)
|
||||
}
|
||||
|
||||
pub(crate) fn hairline_for(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => hsla(0.0, 0.0, HAIRLINE_TONE.0, alpha),
|
||||
ThemeMode::Light => hsla(
|
||||
0.0,
|
||||
0.0,
|
||||
HAIRLINE_TONE.1,
|
||||
(alpha * INK_HAIRLINE_SCALE).min(0.5),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactive-state wash: a softened [`ink`] that stops short of pure black or
|
||||
/// white so hover plates read as tinted glass rather than paint.
|
||||
pub fn wash(alpha: f32) -> Hsla {
|
||||
wash_for(current_appearance(), alpha)
|
||||
}
|
||||
|
||||
pub(crate) fn wash_for(appearance: ThemeMode, alpha: f32) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => hsla(0.0, 0.0, 0.92, alpha),
|
||||
ThemeMode::Light => hsla(0.0, 0.0, 0.10, alpha * INK_FILL_SCALE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha of the standard modal backdrop in dark mode. Call sites that need a
|
||||
/// heavier or lighter scrim pass their own dark-mode alpha to [`scrim`].
|
||||
pub const SCRIM_ALPHA_DARK: f32 = 0.60;
|
||||
|
||||
/// Modal backdrop at `alpha_dark` (quoted, as everywhere, in dark-mode terms).
|
||||
///
|
||||
/// Black in both appearances — a scrim's job is to darken what is behind it, and
|
||||
/// a "light scrim" of white would wash the modal out rather than seat it. What
|
||||
/// changes is strength: on a bright field a dark-mode-weight scrim reads as a
|
||||
/// blackout, so light mode scales to roughly half.
|
||||
pub fn scrim(alpha_dark: f32) -> Hsla {
|
||||
scrim_for(current_appearance(), alpha_dark)
|
||||
}
|
||||
|
||||
pub(crate) fn scrim_for(appearance: ThemeMode, alpha_dark: f32) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, alpha_dark),
|
||||
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.32 * (alpha_dark / SCRIM_ALPHA_DARK)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recessed band behind a picker header or footer strip.
|
||||
///
|
||||
/// A free function as well as a token so the chrome that paints it can be built
|
||||
/// from context-free helpers; both resolve to the same value.
|
||||
pub fn band() -> Hsla {
|
||||
band_for(current_appearance())
|
||||
}
|
||||
|
||||
pub(crate) fn band_for(appearance: ThemeMode) -> Hsla {
|
||||
match appearance {
|
||||
ThemeMode::Dark => hsla(0.0, 0.0, 0.0, 0.16),
|
||||
// A recessed strip on white needs far less ink than on near-black; the
|
||||
// dark 16% would read as a bruise.
|
||||
ThemeMode::Light => hsla(0.0, 0.0, 0.0, 0.045),
|
||||
}
|
||||
}
|
||||
|
||||
/// The elevation shadow a floating surface casts. Painted cut to outside the
|
||||
/// shape, because the same drop shadow under a translucent fill reads as a grey
|
||||
/// plate.
|
||||
pub fn surface_shadows() -> Vec<BoxShadow> {
|
||||
vec![
|
||||
BoxShadow {
|
||||
color: hsla(0.0, 0.0, 0.0, 0.1),
|
||||
offset: point(px(0.0), px(10.0)),
|
||||
blur_radius: px(15.0),
|
||||
spread_radius: px(-3.0),
|
||||
inset: false,
|
||||
},
|
||||
BoxShadow {
|
||||
color: hsla(0.0, 0.0, 0.0, 0.1),
|
||||
offset: point(px(0.0), px(4.0)),
|
||||
blur_radius: px(6.0),
|
||||
spread_radius: px(-4.0),
|
||||
inset: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Hover over an opaque plate: brighten the plate in dark mode, darken it in
|
||||
/// light — never swap an opaque plate for a translucent wash.
|
||||
pub(crate) fn plate_hover_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
|
||||
crate::color::flatten(ink_for(appearance, 0.10), plate)
|
||||
}
|
||||
|
||||
/// Pressed over an opaque plate, one rung above [`plate_hover_for`].
|
||||
pub(crate) fn plate_active_for(appearance: ThemeMode, plate: gpui::Hsla) -> gpui::Hsla {
|
||||
crate::color::flatten(ink_for(appearance, 0.16), plate)
|
||||
}
|
||||
|
||||
/// Hover over an opaque plate, for call sites outside this crate.
|
||||
pub fn plate_hover(plate: gpui::Hsla) -> gpui::Hsla {
|
||||
plate_hover_for(current_appearance(), plate)
|
||||
}
|
||||
|
||||
/// Pressed over an opaque plate, for call sites outside this crate.
|
||||
pub fn plate_active(plate: gpui::Hsla) -> gpui::Hsla {
|
||||
plate_active_for(current_appearance(), plate)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use gpui::{hsla, rgb};
|
||||
|
||||
use crate::paint::{band_for, hairline_for};
|
||||
use crate::syntax::SyntaxPalette;
|
||||
use crate::{Ink, TextInk, ThemeColors, ThemeMode, color};
|
||||
|
||||
pub(crate) fn dark() -> ThemeColors {
|
||||
let warning = color::oklch(0.828, 0.189, 84.429);
|
||||
ThemeColors {
|
||||
bg: color::grey(6),
|
||||
surface: color::grey(13),
|
||||
surface_raised: color::neutral(0.235),
|
||||
surface_card: color::grey(0x0e),
|
||||
surface_dialog: color::grey(0x10),
|
||||
surface_overlay: color::grey(0x16),
|
||||
element_hover: hsla(0.0, 0.0, 1.0, 0.08),
|
||||
element_active: hsla(0.0, 0.0, 1.0, 0.12),
|
||||
|
||||
border_faint: hairline_for(ThemeMode::Dark, 0.06),
|
||||
border: hairline_for(ThemeMode::Dark, 0.08),
|
||||
border_strong: hairline_for(ThemeMode::Dark, 0.14),
|
||||
|
||||
text: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.text),
|
||||
text_muted: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.muted),
|
||||
text_faint: TextInk::color(ThemeMode::Dark, Ink::APPKIT.dark.faint),
|
||||
text_dim: color::grey(0x98),
|
||||
|
||||
// The restored Radix yellow brand: #ffe629 plate, #14120b label.
|
||||
solid: color::oklch(0.918, 0.184, 100.9),
|
||||
on_solid: color::grey(0x0e),
|
||||
|
||||
// The restored Radix yellow brand: #f5e147 ink, #ffe629 plate.
|
||||
accent: color::oklch(0.900, 0.166, 101.7),
|
||||
accent_strong: color::oklch(0.918, 0.184, 100.9),
|
||||
on_accent: color::grey(0x0e),
|
||||
|
||||
danger: color::oklch(0.704, 0.191, 22.216),
|
||||
danger_muted: color::oklch(0.808, 0.114, 19.571),
|
||||
danger_strong: color::oklch(0.58, 0.16, 25.0),
|
||||
success: color::oklch(0.765, 0.177, 163.223),
|
||||
success_muted: color::oklch(0.845, 0.143, 164.978),
|
||||
busy: color::oklch(0.718, 0.202, 349.761),
|
||||
|
||||
surface_raised_hover: color::neutral(0.29),
|
||||
band: band_for(ThemeMode::Dark),
|
||||
input_bg: hsla(0.0, 0.0, 1.0, 0.03),
|
||||
selection: rgb(0x3f638b).into(),
|
||||
cursor: color::neutral(0.94),
|
||||
caret: color::neutral(0.673),
|
||||
ring: hairline_for(ThemeMode::Dark, 0.35),
|
||||
drop_line: hairline_for(ThemeMode::Dark, 0.35),
|
||||
drop_target: hsla(0.0, 0.0, 1.0, 0.10),
|
||||
|
||||
code_text: color::neutral(0.94),
|
||||
code_wash: hsla(0.0, 0.0, 1.0, 0.08),
|
||||
syntax: SyntaxPalette::dark(
|
||||
color::neutral(0.922),
|
||||
color::neutral(0.60),
|
||||
color::oklch(0.704, 0.191, 22.216),
|
||||
),
|
||||
diff_add: color::oklch(0.765, 0.177, 163.223),
|
||||
diff_del: color::oklch(0.704, 0.191, 22.216),
|
||||
diff_hunk_bg: hsla(0.6, 0.35, 0.6, 0.05),
|
||||
|
||||
warning,
|
||||
warning_muted: warning.alpha(0.16),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn light() -> ThemeColors {
|
||||
let warning = color::oklch(0.555, 0.163, 48.998);
|
||||
ThemeColors {
|
||||
bg: color::grey(0xff),
|
||||
surface: color::neutral(0.968),
|
||||
surface_raised: color::neutral(0.940),
|
||||
surface_card: color::grey(0xff),
|
||||
surface_dialog: color::grey(0xff),
|
||||
surface_overlay: color::grey(0xff),
|
||||
element_hover: hsla(0.0, 0.0, 0.0, 0.04),
|
||||
element_active: hsla(0.0, 0.0, 0.0, 0.06),
|
||||
|
||||
border_faint: hairline_for(ThemeMode::Light, 0.06),
|
||||
border: hairline_for(ThemeMode::Light, 0.08),
|
||||
border_strong: hairline_for(ThemeMode::Light, 0.14),
|
||||
|
||||
text: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.text),
|
||||
text_muted: color::neutral(0.45),
|
||||
text_faint: TextInk::color(ThemeMode::Light, Ink::APPKIT.light.faint),
|
||||
text_dim: color::neutral(0.50),
|
||||
|
||||
// The restored Radix yellow brand: #ffe629 plate, #473b1f label.
|
||||
solid: color::oklch(0.918, 0.184, 100.9),
|
||||
on_solid: color::oklch(0.358, 0.046, 86.9),
|
||||
|
||||
// The restored Radix yellow brand: #9e6c00 ink, #ffe629 plate.
|
||||
accent: color::oklch(0.569, 0.119, 76.8),
|
||||
accent_strong: color::oklch(0.918, 0.184, 100.9),
|
||||
on_accent: color::neutral(0.205),
|
||||
|
||||
danger: color::oklch(0.577, 0.245, 27.325),
|
||||
danger_muted: color::oklch(0.505, 0.213, 27.518),
|
||||
danger_strong: color::oklch(0.51, 0.20, 25.0),
|
||||
warning,
|
||||
warning_muted: warning.alpha(0.10),
|
||||
success: color::oklch(0.596, 0.145, 163.225),
|
||||
success_muted: color::oklch(0.508, 0.118, 165.612),
|
||||
busy: color::oklch(0.592, 0.249, 0.584),
|
||||
|
||||
surface_raised_hover: color::neutral(0.900),
|
||||
band: band_for(ThemeMode::Light),
|
||||
input_bg: color::neutral(0.950),
|
||||
selection: rgb(0xb3d7ff).into(),
|
||||
cursor: color::neutral(0.205),
|
||||
caret: color::neutral(0.511),
|
||||
ring: hairline_for(ThemeMode::Light, 0.35),
|
||||
drop_line: hairline_for(ThemeMode::Light, 0.35),
|
||||
drop_target: hsla(0.0, 0.0, 0.0, 0.06),
|
||||
|
||||
code_text: color::neutral(0.18),
|
||||
code_wash: hsla(0.0, 0.0, 0.0, 0.06),
|
||||
syntax: SyntaxPalette::light(
|
||||
color::neutral(0.25),
|
||||
color::neutral(0.48),
|
||||
color::oklch(0.505, 0.213, 27.518),
|
||||
),
|
||||
diff_add: color::oklch(0.596, 0.145, 163.225),
|
||||
diff_del: color::oklch(0.577, 0.245, 27.325),
|
||||
diff_hunk_bg: hsla(0.6, 0.35, 0.35, 0.07),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_appearance(appearance: ThemeMode) -> ThemeColors {
|
||||
match appearance {
|
||||
ThemeMode::Dark => dark(),
|
||||
ThemeMode::Light => light(),
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use gpui::{App, AppContext, AssetSource, Context, Entity, Global, SharedString};
|
||||
|
||||
use crate::ThemeFamily;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ThemeRegistry::set_global(cx.new(ThemeRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalThemeRegistry(Entity<ThemeRegistry>);
|
||||
|
||||
impl Global for GlobalThemeRegistry {}
|
||||
|
||||
pub struct ThemeRegistry {
|
||||
/// Map of theme names to theme families
|
||||
themes: HashMap<SharedString, Rc<ThemeFamily>>,
|
||||
}
|
||||
|
||||
impl ThemeRegistry {
|
||||
/// Retrieve the global theme registry state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalThemeRegistry>().0.clone()
|
||||
}
|
||||
|
||||
/// Set the global theme registry instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalThemeRegistry(state));
|
||||
}
|
||||
|
||||
/// Create a new theme registry instance
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut themes = HashMap::new();
|
||||
let asset = cx.asset_source();
|
||||
|
||||
if let Ok(paths) = asset.list("themes") {
|
||||
for path in paths.into_iter() {
|
||||
match Self::load(&path, asset) {
|
||||
Ok(theme) => {
|
||||
themes.insert(path, Rc::new(theme));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to load theme: {path}. Error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self { themes }
|
||||
}
|
||||
|
||||
/// Load a theme from the asset source.
|
||||
fn load(path: &str, asset: &Arc<dyn AssetSource>) -> Result<ThemeFamily, Error> {
|
||||
// Load the theme file from the assets
|
||||
let content = asset.load(path)?.context("Theme not found")?;
|
||||
|
||||
// Parse the JSON content into a Theme Family struct
|
||||
let theme: ThemeFamily = serde_json::from_slice(&content)?;
|
||||
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
/// Returns a reference to the map of themes.
|
||||
pub fn themes(&self) -> &HashMap<SharedString, Rc<ThemeFamily>> {
|
||||
&self.themes
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
use gpui::Hsla;
|
||||
|
||||
/// A collection of colors that are used to style the UI.
|
||||
///
|
||||
/// Each step has a semantic meaning, and is used to style different parts of the UI.
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
pub struct ColorScaleStep(usize);
|
||||
|
||||
impl ColorScaleStep {
|
||||
pub const EIGHT: Self = Self(8);
|
||||
pub const ELEVEN: Self = Self(11);
|
||||
pub const FIVE: Self = Self(5);
|
||||
pub const FOUR: Self = Self(4);
|
||||
pub const NINE: Self = Self(9);
|
||||
pub const ONE: Self = Self(1);
|
||||
pub const SEVEN: Self = Self(7);
|
||||
pub const SIX: Self = Self(6);
|
||||
pub const TEN: Self = Self(10);
|
||||
pub const THREE: Self = Self(3);
|
||||
pub const TWELVE: Self = Self(12);
|
||||
pub const TWO: Self = Self(2);
|
||||
}
|
||||
|
||||
/// A scale of colors for a given [`ColorScaleSet`].
|
||||
///
|
||||
/// Each [`ColorScale`] contains exactly 12 colors. Refer to
|
||||
/// [`ColorScaleStep`] for a reference of what each step is used for.
|
||||
pub struct ColorScale(Vec<Hsla>);
|
||||
|
||||
impl FromIterator<Hsla> for ColorScale {
|
||||
fn from_iter<T: IntoIterator<Item = Hsla>>(iter: T) -> Self {
|
||||
Self(Vec::from_iter(iter))
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorScale {
|
||||
/// Returns the specified step in the [`ColorScale`].
|
||||
#[inline]
|
||||
pub fn step(&self, step: ColorScaleStep) -> Hsla {
|
||||
// Steps are one-based, so we need convert to the zero-based vec index.
|
||||
self.0[step.0 - 1]
|
||||
}
|
||||
|
||||
/// `Step 1` - Used for main application backgrounds.
|
||||
///
|
||||
/// This step provides a neutral base for any overlaying components, ideal for applications' main backdrop or empty spaces such as canvas areas.
|
||||
///
|
||||
#[inline]
|
||||
pub fn step_1(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::ONE)
|
||||
}
|
||||
|
||||
/// `Step 2` - Used for both main application backgrounds and subtle component backgrounds.
|
||||
///
|
||||
/// Like `Step 1`, this step allows variations in background styles, from striped tables, sidebar backgrounds, to card backgrounds.
|
||||
#[inline]
|
||||
pub fn step_2(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::TWO)
|
||||
}
|
||||
|
||||
/// `Step 3` - Used for UI component backgrounds in their normal states.
|
||||
///
|
||||
/// This step maintains accessibility by guaranteeing a contrast ratio of 4.5:1 with steps 11 and 12 for text. It could also suit hover states for transparent components.
|
||||
#[inline]
|
||||
pub fn step_3(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::THREE)
|
||||
}
|
||||
|
||||
/// `Step 4` - Used for UI component backgrounds in their hover states.
|
||||
///
|
||||
/// Also suited for pressed or selected states of components with a transparent background.
|
||||
#[inline]
|
||||
pub fn step_4(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::FOUR)
|
||||
}
|
||||
|
||||
/// `Step 5` - Used for UI component backgrounds in their pressed or selected states.
|
||||
#[inline]
|
||||
pub fn step_5(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::FIVE)
|
||||
}
|
||||
|
||||
/// `Step 6` - Used for subtle borders on non-interactive components.
|
||||
///
|
||||
/// Its usage spans from sidebars' borders, headers' dividers, cards' outlines, to alerts' edges and separators.
|
||||
#[inline]
|
||||
pub fn step_6(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::SIX)
|
||||
}
|
||||
|
||||
/// `Step 7` - Used for subtle borders on interactive components.
|
||||
///
|
||||
/// This step subtly delineates the boundary of elements users interact with.
|
||||
#[inline]
|
||||
pub fn step_7(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::SEVEN)
|
||||
}
|
||||
|
||||
/// `Step 8` - Used for stronger borders on interactive components and focus rings.
|
||||
///
|
||||
/// It strengthens the visibility and accessibility of active elements and their focus states.
|
||||
#[inline]
|
||||
pub fn step_8(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::EIGHT)
|
||||
}
|
||||
|
||||
/// `Step 9` - Used for solid backgrounds.
|
||||
///
|
||||
/// `Step 9` is the most saturated step, having the least mix of white or black.
|
||||
///
|
||||
/// Due to its high chroma, `Step 9` is versatile and particularly useful for semantic colors such as
|
||||
/// error, warning, and success indicators.
|
||||
#[inline]
|
||||
pub fn step_9(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::NINE)
|
||||
}
|
||||
|
||||
/// `Step 10` - Used for hovered or active solid backgrounds, particularly when `Step 9` is their normal state.
|
||||
///
|
||||
/// May also be used for extremely low contrast text. This should be used sparingly, as it may be difficult to read.
|
||||
#[inline]
|
||||
pub fn step_10(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::TEN)
|
||||
}
|
||||
|
||||
/// `Step 11` - Used for text and icons requiring low contrast or less emphasis.
|
||||
#[inline]
|
||||
pub fn step_11(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::ELEVEN)
|
||||
}
|
||||
|
||||
/// `Step 12` - Used for text and icons requiring high contrast or prominence.
|
||||
#[inline]
|
||||
pub fn step_12(&self) -> Hsla {
|
||||
self.step(ColorScaleStep::TWELVE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides groups of [`ColorScale`]s for light and dark themes, as well as transparent versions of each scale.
|
||||
pub struct ColorScaleSet {
|
||||
light: ColorScale,
|
||||
dark: ColorScale,
|
||||
light_alpha: ColorScale,
|
||||
dark_alpha: ColorScale,
|
||||
}
|
||||
|
||||
impl ColorScaleSet {
|
||||
pub(crate) fn new(
|
||||
light: ColorScale,
|
||||
light_alpha: ColorScale,
|
||||
dark: ColorScale,
|
||||
dark_alpha: ColorScale,
|
||||
) -> Self {
|
||||
Self {
|
||||
light,
|
||||
light_alpha,
|
||||
dark,
|
||||
dark_alpha,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn light(&self) -> &ColorScale {
|
||||
&self.light
|
||||
}
|
||||
|
||||
pub(crate) fn light_alpha(&self) -> &ColorScale {
|
||||
&self.light_alpha
|
||||
}
|
||||
|
||||
pub(crate) fn dark(&self) -> &ColorScale {
|
||||
&self.dark
|
||||
}
|
||||
|
||||
pub(crate) fn dark_alpha(&self) -> &ColorScale {
|
||||
&self.dark_alpha
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Paint-only syntax colors, ported from bezel
|
||||
//! (`crates/theme/src/theme/syntax.rs`, MIT License, Copyright (c) 2026 bezel
|
||||
//! contributors). The hues follow the Git history graph's lane palette
|
||||
//! (indigo, pink, emerald, amber, red, neutral), while light-mode variants are
|
||||
//! darkened enough to remain readable as text on white.
|
||||
|
||||
use gpui::Hsla;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::color;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SyntaxPalette {
|
||||
pub comment: Hsla,
|
||||
pub keyword: Hsla,
|
||||
pub string: Hsla,
|
||||
pub string_special: Hsla,
|
||||
pub escape: Hsla,
|
||||
pub number: Hsla,
|
||||
pub boolean: Hsla,
|
||||
pub type_name: Hsla,
|
||||
pub type_builtin: Hsla,
|
||||
pub constructor: Hsla,
|
||||
pub function: Hsla,
|
||||
pub function_builtin: Hsla,
|
||||
pub macro_name: Hsla,
|
||||
pub property: Hsla,
|
||||
pub constant: Hsla,
|
||||
pub variable: Hsla,
|
||||
pub variable_special: Hsla,
|
||||
pub parameter: Hsla,
|
||||
pub operator: Hsla,
|
||||
pub punctuation: Hsla,
|
||||
pub tag: Hsla,
|
||||
pub attribute: Hsla,
|
||||
pub label: Hsla,
|
||||
pub invalid: Hsla,
|
||||
}
|
||||
|
||||
/// The token category a highlighter paints with, mirroring [`SyntaxPalette`]
|
||||
/// field-for-field so classification can name a kind without knowing its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HighlightKind {
|
||||
Comment,
|
||||
Keyword,
|
||||
String,
|
||||
StringSpecial,
|
||||
Escape,
|
||||
Number,
|
||||
Boolean,
|
||||
TypeName,
|
||||
TypeBuiltin,
|
||||
Constructor,
|
||||
Function,
|
||||
FunctionBuiltin,
|
||||
MacroName,
|
||||
Property,
|
||||
Constant,
|
||||
Variable,
|
||||
VariableSpecial,
|
||||
Parameter,
|
||||
Operator,
|
||||
Punctuation,
|
||||
Tag,
|
||||
Attribute,
|
||||
Label,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl SyntaxPalette {
|
||||
/// The palette color for a token kind.
|
||||
pub fn color(&self, kind: HighlightKind) -> Hsla {
|
||||
match kind {
|
||||
HighlightKind::Comment => self.comment,
|
||||
HighlightKind::Keyword => self.keyword,
|
||||
HighlightKind::String => self.string,
|
||||
HighlightKind::StringSpecial => self.string_special,
|
||||
HighlightKind::Escape => self.escape,
|
||||
HighlightKind::Number => self.number,
|
||||
HighlightKind::Boolean => self.boolean,
|
||||
HighlightKind::TypeName => self.type_name,
|
||||
HighlightKind::TypeBuiltin => self.type_builtin,
|
||||
HighlightKind::Constructor => self.constructor,
|
||||
HighlightKind::Function => self.function,
|
||||
HighlightKind::FunctionBuiltin => self.function_builtin,
|
||||
HighlightKind::MacroName => self.macro_name,
|
||||
HighlightKind::Property => self.property,
|
||||
HighlightKind::Constant => self.constant,
|
||||
HighlightKind::Variable => self.variable,
|
||||
HighlightKind::VariableSpecial => self.variable_special,
|
||||
HighlightKind::Parameter => self.parameter,
|
||||
HighlightKind::Operator => self.operator,
|
||||
HighlightKind::Punctuation => self.punctuation,
|
||||
HighlightKind::Tag => self.tag,
|
||||
HighlightKind::Attribute => self.attribute,
|
||||
HighlightKind::Label => self.label,
|
||||
HighlightKind::Invalid => self.invalid,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dark(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
|
||||
let indigo = git_graph_tone(color::oklch(0.673, 0.182, 276.935));
|
||||
let pink = git_graph_tone(color::oklch(0.718, 0.202, 349.761));
|
||||
let emerald = git_graph_tone(color::oklch(0.765, 0.177, 163.223));
|
||||
let amber = git_graph_tone(color::oklch(0.828, 0.189, 84.429));
|
||||
let red = git_graph_tone(danger);
|
||||
Self {
|
||||
comment,
|
||||
keyword: indigo,
|
||||
string: emerald,
|
||||
string_special: pink,
|
||||
escape: pink,
|
||||
number: amber,
|
||||
boolean: amber,
|
||||
type_name: amber,
|
||||
type_builtin: emerald,
|
||||
constructor: amber,
|
||||
function: indigo,
|
||||
function_builtin: pink,
|
||||
macro_name: pink,
|
||||
property: amber,
|
||||
constant: emerald,
|
||||
variable: text,
|
||||
variable_special: pink,
|
||||
parameter: text,
|
||||
operator: text,
|
||||
punctuation: text,
|
||||
tag: pink,
|
||||
attribute: amber,
|
||||
label: amber,
|
||||
invalid: red,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn light(text: Hsla, comment: Hsla, danger: Hsla) -> Self {
|
||||
let indigo = git_graph_tone(color::oklch(0.47, 0.20, 276.966));
|
||||
let pink = git_graph_tone(color::oklch(0.47, 0.17, 0.584));
|
||||
let emerald = git_graph_tone(color::oklch(0.46, 0.11, 163.225));
|
||||
let amber = git_graph_tone(color::oklch(0.47, 0.12, 48.998));
|
||||
let red = git_graph_tone(danger);
|
||||
Self {
|
||||
comment,
|
||||
keyword: indigo,
|
||||
string: emerald,
|
||||
string_special: pink,
|
||||
escape: pink,
|
||||
number: amber,
|
||||
boolean: amber,
|
||||
type_name: amber,
|
||||
type_builtin: emerald,
|
||||
constructor: amber,
|
||||
function: indigo,
|
||||
function_builtin: pink,
|
||||
macro_name: pink,
|
||||
property: amber,
|
||||
constant: emerald,
|
||||
variable: text,
|
||||
variable_special: pink,
|
||||
parameter: text,
|
||||
operator: text,
|
||||
punctuation: text,
|
||||
tag: pink,
|
||||
attribute: amber,
|
||||
label: amber,
|
||||
invalid: red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Git history intentionally softens lane saturation so the graph remains
|
||||
/// colorful without competing with content. Syntax uses the same treatment.
|
||||
fn git_graph_tone(mut color: Hsla) -> Hsla {
|
||||
color.s *= 0.72;
|
||||
color
|
||||
}
|
||||
+12
-329
@@ -1,16 +1,12 @@
|
||||
use std::path::Path;
|
||||
|
||||
use gpui::{SharedString, WindowAppearance};
|
||||
use schemars::JsonSchema;
|
||||
use gpui::WindowAppearance;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ThemeColors;
|
||||
|
||||
/// Which appearance the palette is painted for.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq, Hash, Deserialize, Serialize)]
|
||||
pub enum ThemeMode {
|
||||
#[default]
|
||||
Light,
|
||||
Dark,
|
||||
Light,
|
||||
}
|
||||
|
||||
impl ThemeMode {
|
||||
@@ -18,6 +14,15 @@ impl ThemeMode {
|
||||
matches!(self, Self::Dark)
|
||||
}
|
||||
|
||||
pub fn is_light(&self) -> bool {
|
||||
matches!(self, Self::Light)
|
||||
}
|
||||
|
||||
/// Map a gpui window appearance onto ours.
|
||||
pub fn from_window(appearance: WindowAppearance) -> Self {
|
||||
Self::from(appearance)
|
||||
}
|
||||
|
||||
/// Return theme name: `light`, `dark`.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -35,325 +40,3 @@ impl From<WindowAppearance> for ThemeMode {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme family
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct ThemeFamily {
|
||||
/// The unique identifier for the theme.
|
||||
pub id: String,
|
||||
|
||||
/// The name of the theme.
|
||||
pub name: SharedString,
|
||||
|
||||
/// The author of the theme.
|
||||
pub author: SharedString,
|
||||
|
||||
/// The URL of the theme.
|
||||
pub url: String,
|
||||
|
||||
/// The light colors for the theme.
|
||||
pub light: ThemeColors,
|
||||
|
||||
/// The dark colors for the theme.
|
||||
pub dark: ThemeColors,
|
||||
}
|
||||
|
||||
impl Default for ThemeFamily {
|
||||
fn default() -> Self {
|
||||
ThemeFamily {
|
||||
id: "coop".into(),
|
||||
name: "Coop Default Theme".into(),
|
||||
author: "Coop".into(),
|
||||
url: "https://github.com/reyakov/coop".into(),
|
||||
light: ThemeColors::light(),
|
||||
dark: ThemeColors::dark(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ThemeFamily {
|
||||
/// Returns the light colors for the theme.
|
||||
#[inline(always)]
|
||||
pub fn light(&self) -> &ThemeColors {
|
||||
&self.light
|
||||
}
|
||||
|
||||
/// Returns the dark colors for the theme.
|
||||
#[inline(always)]
|
||||
pub fn dark(&self) -> &ThemeColors {
|
||||
&self.dark
|
||||
}
|
||||
|
||||
/// Load a theme family from a JSON file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the JSON file containing the theme family. This can be
|
||||
/// an absolute path or a path relative to the current working directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
|
||||
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function will return an error if:
|
||||
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
|
||||
/// - The file contains invalid JSON
|
||||
/// - The JSON structure doesn't match the `ThemeFamily` schema
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use theme::ThemeFamily;
|
||||
///
|
||||
/// # fn main() -> anyhow::Result<()> {
|
||||
/// // Load from a relative path
|
||||
/// let theme = ThemeFamily::from_file("assets/themes/my-theme.json")?;
|
||||
///
|
||||
/// // Load from an absolute path
|
||||
/// let theme = ThemeFamily::from_file("/path/to/themes/my-theme.json")?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub(crate) fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
|
||||
let json_data = std::fs::read(path)?;
|
||||
let theme_family = serde_json::from_slice(&json_data)?;
|
||||
|
||||
Ok(theme_family)
|
||||
}
|
||||
|
||||
/// Load a theme family from a JSON file in the assets/themes directory.
|
||||
///
|
||||
/// This function looks for the file at `assets/themes/{name}.json` relative
|
||||
/// to the current working directory. This is useful for loading themes
|
||||
/// from the standard theme directory in the project structure.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Name of the theme file (without the .json extension)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(ThemeFamily)` if the file was successfully loaded and parsed,
|
||||
/// or `Err(anyhow::Error)` if there was an error reading or parsing the file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function will return an error if:
|
||||
/// - The file cannot be read (permission issues, file doesn't exist, etc.)
|
||||
/// - The file contains invalid JSON
|
||||
/// - The JSON structure doesn't match the `ThemeFamily` schema
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use theme::ThemeFamily;
|
||||
///
|
||||
/// # fn main() -> anyhow::Result<()> {
|
||||
/// // Assuming the file exists at `assets/themes/my-theme.json`
|
||||
/// let theme = ThemeFamily::from_assets("themes/my-theme.json")?;
|
||||
///
|
||||
/// println!("Loaded theme: {}", theme.name);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn from_assets(target: &str) -> anyhow::Result<Self> {
|
||||
let path = format!("assets/{target}");
|
||||
Self::from_file(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_from_file() {
|
||||
// Create a temporary directory for our test
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test-theme.json");
|
||||
|
||||
// Create a minimal valid theme JSON with hex colors
|
||||
// Using simple hex colors that Hsla can parse
|
||||
// Note: We need to escape the # characters in the raw string
|
||||
let json_data = r##"{
|
||||
"id": "test-theme",
|
||||
"name": "Test Theme",
|
||||
"author": "Coop",
|
||||
"url": "https://github.com/reyakov/coop",
|
||||
"light": {
|
||||
"background": "#ffffff",
|
||||
"surface_background": "#fafafa",
|
||||
"elevated_surface_background": "#f5f5f5",
|
||||
"panel_background": "#ffffff",
|
||||
"overlay": "#0000001a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#ffffff",
|
||||
"window_border": "#c7c7cf",
|
||||
"border": "#dbdbdb",
|
||||
"border_variant": "#d1d1d1",
|
||||
"border_focused": "#3366cc",
|
||||
"border_selected": "#3366cc",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#e6e6e6",
|
||||
"ring": "#4d79d6",
|
||||
"text": "#1a1a1a",
|
||||
"text_muted": "#4d4d4d",
|
||||
"text_placeholder": "#808080",
|
||||
"text_accent": "#3366cc",
|
||||
"icon": "#4d4d4d",
|
||||
"icon_muted": "#808080",
|
||||
"icon_accent": "#3366cc",
|
||||
"element_foreground": "#ffffff",
|
||||
"element_background": "#3366cc",
|
||||
"element_hover": "#3366cce6",
|
||||
"element_active": "#2e5cb8",
|
||||
"element_selected": "#2952a3",
|
||||
"element_disabled": "#3366cc4d",
|
||||
"secondary_foreground": "#2952a3",
|
||||
"secondary_background": "#e6ecf5",
|
||||
"secondary_hover": "#3366cc1a",
|
||||
"secondary_active": "#d9e2f0",
|
||||
"secondary_selected": "#d9e2f0",
|
||||
"secondary_disabled": "#3366cc4d",
|
||||
"danger_foreground": "#ffffff",
|
||||
"danger_background": "#f5e6e6",
|
||||
"danger_hover": "#cc33331a",
|
||||
"danger_active": "#f0d9d9",
|
||||
"danger_selected": "#f0d9d9",
|
||||
"danger_disabled": "#cc33334d",
|
||||
"warning_foreground": "#1a1a1a",
|
||||
"warning_background": "#f5f0e6",
|
||||
"warning_hover": "#cc99331a",
|
||||
"warning_active": "#f0ead9",
|
||||
"warning_selected": "#f0ead9",
|
||||
"warning_disabled": "#cc99334d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#e6e6e6",
|
||||
"ghost_element_hover": "#0000001a",
|
||||
"ghost_element_active": "#d9d9d9",
|
||||
"ghost_element_selected": "#d9d9d9",
|
||||
"ghost_element_disabled": "#0000000d",
|
||||
"tab_inactive_background": "#e6e6e6",
|
||||
"tab_hover_background": "#e0e0e0",
|
||||
"tab_active_background": "#d9d9d9",
|
||||
"scrollbar_thumb_background": "#00000033",
|
||||
"scrollbar_thumb_hover_background": "#0000004d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#d9d9d9",
|
||||
"drop_target_background": "#3366cc1a",
|
||||
"cursor": "#3399ff",
|
||||
"selection": "#3399ff40"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#1a1a1a",
|
||||
"surface_background": "#1f1f1f",
|
||||
"elevated_surface_background": "#242424",
|
||||
"panel_background": "#262626",
|
||||
"overlay": "#ffffff1a",
|
||||
"title_bar": "#00000000",
|
||||
"title_bar_inactive": "#1a1a1a",
|
||||
"window_border": "#404046",
|
||||
"border": "#404040",
|
||||
"border_variant": "#383838",
|
||||
"border_focused": "#4d79d6",
|
||||
"border_selected": "#4d79d6",
|
||||
"border_transparent": "#00000000",
|
||||
"border_disabled": "#2e2e2e",
|
||||
"ring": "#668cdf",
|
||||
"text": "#f2f2f2",
|
||||
"text_muted": "#b3b3b3",
|
||||
"text_placeholder": "#808080",
|
||||
"text_accent": "#668cdf",
|
||||
"icon": "#b3b3b3",
|
||||
"icon_muted": "#808080",
|
||||
"icon_accent": "#668cdf",
|
||||
"element_foreground": "#ffffff",
|
||||
"element_background": "#4d79d6",
|
||||
"element_hover": "#4d79d6e6",
|
||||
"element_active": "#456dc1",
|
||||
"element_selected": "#3e62ac",
|
||||
"element_disabled": "#4d79d64d",
|
||||
"secondary_foreground": "#3e62ac",
|
||||
"secondary_background": "#2a3652",
|
||||
"secondary_hover": "#4d79d61a",
|
||||
"secondary_active": "#303d5c",
|
||||
"secondary_selected": "#303d5c",
|
||||
"secondary_disabled": "#4d79d64d",
|
||||
"danger_foreground": "#ffffff",
|
||||
"danger_background": "#522a2a",
|
||||
"danger_hover": "#d64d4d1a",
|
||||
"danger_active": "#5c3030",
|
||||
"danger_selected": "#5c3030",
|
||||
"danger_disabled": "#d64d4d4d",
|
||||
"warning_foreground": "#f2f2f2",
|
||||
"warning_background": "#52482a",
|
||||
"warning_hover": "#d6b34d1a",
|
||||
"warning_active": "#5c5430",
|
||||
"warning_selected": "#5c5430",
|
||||
"warning_disabled": "#d6b34d4d",
|
||||
"ghost_element_background": "#00000000",
|
||||
"ghost_element_background_alt": "#2e2e2e",
|
||||
"ghost_element_hover": "#ffffff1a",
|
||||
"ghost_element_active": "#383838",
|
||||
"ghost_element_selected": "#383838",
|
||||
"ghost_element_disabled": "#ffffff0d",
|
||||
"tab_inactive_background": "#2e2e2e",
|
||||
"tab_hover_background": "#333333",
|
||||
"tab_active_background": "#383838",
|
||||
"scrollbar_thumb_background": "#ffffff33",
|
||||
"scrollbar_thumb_hover_background": "#ffffff4d",
|
||||
"scrollbar_thumb_border": "#00000000",
|
||||
"scrollbar_track_background": "#00000000",
|
||||
"scrollbar_track_border": "#383838",
|
||||
"drop_target_background": "#4d79d61a",
|
||||
"cursor": "#4db3ff",
|
||||
"selection": "#4db3ff40"
|
||||
}
|
||||
}"##;
|
||||
|
||||
// Write the JSON to the file
|
||||
fs::write(&file_path, json_data).unwrap();
|
||||
|
||||
// Test loading the theme from file
|
||||
let theme = ThemeFamily::from_file(&file_path).unwrap();
|
||||
|
||||
// Verify the loaded theme
|
||||
assert_eq!(theme.id, "test-theme");
|
||||
assert_eq!(theme.name, "Test Theme");
|
||||
|
||||
// Clean up
|
||||
dir.close().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_file_nonexistent() {
|
||||
// Test that loading a non-existent file returns an error
|
||||
let result = ThemeFamily::from_file("non-existent-file.json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_file_invalid_json() {
|
||||
// Create a temporary directory for our test
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("invalid-theme.json");
|
||||
|
||||
// Write invalid JSON
|
||||
fs::write(&file_path, "invalid json").unwrap();
|
||||
|
||||
// Test that loading invalid JSON returns an error
|
||||
let result = ThemeFamily::from_file(&file_path);
|
||||
assert!(result.is_err());
|
||||
|
||||
// Clean up
|
||||
dir.close().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use gpui::{FontWeight, Styled, px};
|
||||
|
||||
/// The base the ladder starts at; bezel's measured body is 13, coop keeps 15.
|
||||
pub const DEFAULT_BASE_TEXT_SIZE: f32 = 15.0;
|
||||
|
||||
/// The body size every painted role is scaled against, as raw `f32` bits.
|
||||
static BASE: AtomicU32 = AtomicU32::new(DEFAULT_BASE_TEXT_SIZE.to_bits());
|
||||
|
||||
/// Point the ladder at a body size in points; the caller refreshes windows.
|
||||
pub fn set_base_text_size(points: f32) {
|
||||
BASE.store(points.max(1.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The body size in points every painted role is scaled against.
|
||||
pub fn base_text_size() -> f32 {
|
||||
f32::from_bits(BASE.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// A role in the type ladder — SwiftUI's `Font.TextStyle`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TextStyle {
|
||||
LargeTitle,
|
||||
Title,
|
||||
Title2,
|
||||
Title3,
|
||||
Headline,
|
||||
Subheadline,
|
||||
Body,
|
||||
Callout,
|
||||
Footnote,
|
||||
Caption,
|
||||
Caption2,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// The role's measured size in points.
|
||||
pub const fn size(self) -> f32 {
|
||||
match self {
|
||||
Self::LargeTitle => 26.0,
|
||||
Self::Title => 22.0,
|
||||
Self::Title2 => 17.0,
|
||||
Self::Title3 => 15.0,
|
||||
Self::Headline | Self::Body => 13.0,
|
||||
Self::Callout => 12.0,
|
||||
Self::Subheadline => 11.0,
|
||||
Self::Footnote | Self::Caption | Self::Caption2 => 10.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn painted(self) -> f32 {
|
||||
self.size() * base_text_size() / Self::Body.size()
|
||||
}
|
||||
|
||||
pub const fn line_height(self) -> f32 {
|
||||
match self {
|
||||
Self::LargeTitle => 32.0,
|
||||
Self::Title => 26.0,
|
||||
Self::Title2 => 22.0,
|
||||
Self::Title3 => 20.0,
|
||||
Self::Headline | Self::Body => 16.0,
|
||||
Self::Callout => 15.0,
|
||||
Self::Subheadline => 14.0,
|
||||
Self::Footnote | Self::Caption | Self::Caption2 => 13.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn painted_line_height(self) -> f32 {
|
||||
self.line_height() * base_text_size() / Self::Body.size()
|
||||
}
|
||||
|
||||
pub const fn weight(self) -> FontWeight {
|
||||
match self {
|
||||
Self::Headline => FontWeight::BOLD,
|
||||
Self::Caption2 => FontWeight::MEDIUM,
|
||||
_ => FontWeight::NORMAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One role as it is set: a rung on the ladder, its leading, weight and scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Metrics {
|
||||
pub role: TextStyle,
|
||||
pub leading: f32,
|
||||
pub weight: FontWeight,
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub const fn new(role: TextStyle, leading: f32, weight: FontWeight) -> Self {
|
||||
Self {
|
||||
role,
|
||||
leading,
|
||||
weight,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn scaled(self, scale: f32) -> Self {
|
||||
Self { scale, ..self }
|
||||
}
|
||||
|
||||
pub fn size(self) -> f32 {
|
||||
self.role.painted() * self.scale
|
||||
}
|
||||
|
||||
pub fn line_height(self) -> f32 {
|
||||
self.size() * self.leading
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TextStyle> for Metrics {
|
||||
/// The ladder's own setting for a role: its measured leading and weight.
|
||||
fn from(role: TextStyle) -> Self {
|
||||
Self::new(role, role.line_height() / role.size(), role.weight())
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder, on anything styled.
|
||||
pub trait Typeset: Styled + Sized {
|
||||
/// Size, line height and weight together, from [`TextStyle`].
|
||||
fn text_style(self, style: TextStyle) -> Self {
|
||||
self.text_size(px(style.painted()))
|
||||
.line_height(px(style.painted_line_height()))
|
||||
.font_weight(style.weight())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Styled> Typeset for E {}
|
||||
@@ -10,8 +10,8 @@ theme = { path = "../theme" }
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
instant.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
uuid = "1.10"
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/// A cubic bezier function like CSS `cubic-bezier`.
|
||||
///
|
||||
/// Builder:
|
||||
///
|
||||
/// https://cubic-bezier.com
|
||||
pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 {
|
||||
move |t: f32| {
|
||||
let one_t = 1.0 - t;
|
||||
let one_t2 = one_t * one_t;
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
|
||||
// The Bezier curve function for x and y, where x0 = 0, y0 = 0, x3 = 1, y3 = 1
|
||||
let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3;
|
||||
|
||||
3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3
|
||||
}
|
||||
}
|
||||
+6
-25
@@ -1,4 +1,3 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
|
||||
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
||||
@@ -24,7 +23,7 @@ const FALLBACK_SEED: &str = "coop";
|
||||
const CIRCLE_SEGMENTS: usize = 32;
|
||||
|
||||
/// Returns the size of the avatar based on the given [`Size`].
|
||||
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
match size {
|
||||
Size::Large => px(64.).into(),
|
||||
Size::Medium => px(32.).into(),
|
||||
@@ -36,7 +35,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
|
||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
||||
#[derive(IntoElement)]
|
||||
pub(crate) struct PixelAvatar {
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
@@ -44,7 +43,7 @@ pub(crate) struct PixelAvatar {
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Creates a pixel avatar from `seed`.
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: Size::Medium,
|
||||
@@ -188,8 +187,7 @@ fn paint_polygons<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximates the circle of `radius` around `center` as a convex polygon,
|
||||
/// wound so that its interior is on the left of every directed edge.
|
||||
/// Approximates the circle of `radius` around `center` as a convex polygon wound left-interior.
|
||||
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
|
||||
let center_x = center.x.as_f32();
|
||||
let center_y = center.y.as_f32();
|
||||
@@ -354,11 +352,9 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
picture: Option<ImageSource>,
|
||||
grayscale: bool,
|
||||
seed: Option<String>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
border_color: Option<Hsla>,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
@@ -377,11 +373,9 @@ impl Avatar {
|
||||
Avatar {
|
||||
base: div(),
|
||||
picture,
|
||||
grayscale: false,
|
||||
seed: None,
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
border_color: None,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
@@ -424,27 +418,17 @@ impl InteractiveElement for Avatar {
|
||||
}
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let border_width = if self.border_color.is_some() {
|
||||
px(2.)
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
let container_size = image_size + border_width * 2.;
|
||||
|
||||
let content = match self.picture {
|
||||
Some(picture) => {
|
||||
let seed = self.seed;
|
||||
let grayscale = self.grayscale;
|
||||
|
||||
img(picture)
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.grayscale(grayscale)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -453,12 +437,9 @@ impl RenderOnce for Avatar {
|
||||
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size(container_size)
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.when_some(self.border_color, |this, color| {
|
||||
this.border(border_width).border_color(color)
|
||||
})
|
||||
.child(content)
|
||||
}
|
||||
}
|
||||
|
||||
+109
-112
@@ -4,10 +4,13 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled, Window, div, relative,
|
||||
Styled, Window, div, px, relative,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{
|
||||
ActiveTheme, TextStyle, Typeset as _, bubble_radius, button_radius, plate_active, plate_hover,
|
||||
wash,
|
||||
};
|
||||
|
||||
use crate::indicator::Indicator;
|
||||
use crate::tooltip::Tooltip;
|
||||
@@ -24,10 +27,10 @@ pub struct ButtonCustomVariant {
|
||||
impl ButtonCustomVariant {
|
||||
pub fn new(_window: &Window, cx: &App) -> Self {
|
||||
Self {
|
||||
color: cx.theme().element_background,
|
||||
foreground: cx.theme().element_foreground,
|
||||
hover: cx.theme().element_hover,
|
||||
active: cx.theme().element_active,
|
||||
color: cx.theme().solid,
|
||||
foreground: cx.theme().on_solid,
|
||||
hover: plate_hover(cx.theme().solid),
|
||||
active: plate_active(cx.theme().solid),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,47 +73,49 @@ pub enum ButtonVariant {
|
||||
pub trait ButtonVariants: Sized {
|
||||
fn with_variant(self, variant: ButtonVariant) -> Self;
|
||||
|
||||
/// With the primary style for the Button.
|
||||
fn primary(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Primary)
|
||||
}
|
||||
|
||||
/// With the secondary style for the Button.
|
||||
fn secondary(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Secondary)
|
||||
}
|
||||
|
||||
/// With the danger style for the Button.
|
||||
fn danger(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Danger)
|
||||
}
|
||||
|
||||
/// With the warning style for the Button.
|
||||
fn warning(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Warning)
|
||||
}
|
||||
|
||||
/// With the ghost style for the Button.
|
||||
fn ghost(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Ghost { alt: false })
|
||||
}
|
||||
|
||||
/// With the ghost style for the Button.
|
||||
fn ghost_alt(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Ghost { alt: true })
|
||||
}
|
||||
|
||||
/// With the transparent style for the Button.
|
||||
fn transparent(self) -> Self {
|
||||
self.with_variant(ButtonVariant::Transparent)
|
||||
}
|
||||
|
||||
/// With the custom style for the Button.
|
||||
fn custom(self, style: ButtonCustomVariant) -> Self {
|
||||
self.with_variant(ButtonVariant::Custom(style))
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of the label the icon sits on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum IconPosition {
|
||||
/// Left of the label.
|
||||
#[default]
|
||||
Start,
|
||||
/// Right of the label.
|
||||
End,
|
||||
}
|
||||
|
||||
/// A Button element.
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -127,13 +132,10 @@ pub struct Button {
|
||||
rounded: bool,
|
||||
compact: bool,
|
||||
caret: bool,
|
||||
indicator: bool,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
tab_index: isize,
|
||||
tab_stop: bool,
|
||||
|
||||
pub(crate) selected: bool,
|
||||
selected: bool,
|
||||
icon_position: IconPosition,
|
||||
}
|
||||
|
||||
impl From<Button> for AnyElement {
|
||||
@@ -151,22 +153,19 @@ impl Button {
|
||||
variant: ButtonVariant::default(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
indicator: false,
|
||||
compact: false,
|
||||
caret: false,
|
||||
rounded: false,
|
||||
size: Size::Medium,
|
||||
tooltip: None,
|
||||
on_click: None,
|
||||
on_hover: None,
|
||||
loading: false,
|
||||
children: Vec::new(),
|
||||
tab_index: 0,
|
||||
tab_stop: true,
|
||||
icon_position: IconPosition::Start,
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the button rounded.
|
||||
pub fn rounded(mut self) -> Self {
|
||||
self.rounded = true;
|
||||
self
|
||||
@@ -178,19 +177,23 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the icon of the button, if the Button have no label, the button well in Icon Button mode.
|
||||
/// Set the icon of the button, if the Button has no label, the button will be in Icon Button mode.
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tooltip of the button.
|
||||
/// Set which side of the label the icon sits on (default [`IconPosition::Start`]).
|
||||
pub fn icon_position(mut self, position: IconPosition) -> Self {
|
||||
self.icon_position = position;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
|
||||
self.tooltip = Some(tooltip.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the loading indicator.
|
||||
pub fn loading(mut self, loading: bool) -> Self {
|
||||
self.loading = loading;
|
||||
self
|
||||
@@ -202,13 +205,11 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the caret indicator.
|
||||
pub fn caret(mut self) -> Self {
|
||||
self.caret = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add click handler.
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
@@ -217,9 +218,7 @@ impl Button {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tab stop of the button, if true, the button will be focusable by tab key.
|
||||
///
|
||||
/// Default is true.
|
||||
/// Set whether the button can be focused with the tab key (default true).
|
||||
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
|
||||
self.tab_stop = tab_stop;
|
||||
self
|
||||
@@ -229,11 +228,6 @@ impl Button {
|
||||
fn clickable(&self) -> bool {
|
||||
!(self.disabled || self.loading) && self.on_click.is_some()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hoverable(&self) -> bool {
|
||||
!(self.disabled || self.loading) && self.on_hover.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for Button {
|
||||
@@ -288,24 +282,23 @@ impl InteractiveElement for Button {
|
||||
|
||||
impl RenderOnce for Button {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let style: ButtonVariant = self.variant;
|
||||
let style = self.variant;
|
||||
let clickable = self.clickable();
|
||||
let hoverable = self.hoverable();
|
||||
let normal_style = style.normal(cx);
|
||||
let icon_size = match self.size {
|
||||
Size::Size(v) => Size::Size(v * 0.75),
|
||||
Size::Large => Size::Medium,
|
||||
_ => self.size,
|
||||
};
|
||||
let icon_start = self.icon_position == IconPosition::Start;
|
||||
|
||||
self.base
|
||||
.tab_index(self.tab_index)
|
||||
.tab_index(0)
|
||||
.tab_stop(self.tab_stop)
|
||||
.disabled(self.disabled)
|
||||
.when_some(self.on_click.clone(), |this, on_click| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
// Stop handle any click event when disabled.
|
||||
// To avoid handle dropdown menu open when button is disabled.
|
||||
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
|
||||
if !clickable {
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
@@ -322,9 +315,16 @@ impl RenderOnce for Button {
|
||||
.cursor_default()
|
||||
.overflow_hidden()
|
||||
.map(|this| match self.rounded {
|
||||
false => this.rounded(cx.theme().radius),
|
||||
true => this.rounded_full(),
|
||||
false => this.rounded(px(button_radius())),
|
||||
true => this.rounded(px(bubble_radius())),
|
||||
})
|
||||
.when_some(
|
||||
match style {
|
||||
ButtonVariant::Secondary => Some(cx.theme().border),
|
||||
_ => None,
|
||||
},
|
||||
|this, border_color| this.border_1().border_color(border_color),
|
||||
)
|
||||
.when(!self.compact, |this| {
|
||||
if self.label.is_none() && self.children.is_empty() {
|
||||
// Icon Button
|
||||
@@ -337,7 +337,7 @@ impl RenderOnce for Button {
|
||||
}
|
||||
} else {
|
||||
// Normal Button
|
||||
match self.size {
|
||||
let this = match self.size {
|
||||
Size::Size(size) => this.px(size * 0.2),
|
||||
Size::XSmall => {
|
||||
if self.icon.is_some() {
|
||||
@@ -367,12 +367,21 @@ impl RenderOnce for Button {
|
||||
this.h_10().px_3()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !icon_start && self.icon.is_some() {
|
||||
match self.size {
|
||||
Size::XSmall | Size::Small => this.pl_2p5().pr_2(),
|
||||
Size::Medium | Size::Large => this.pl_3p5().pr_3(),
|
||||
Size::Size(_) => this,
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
// Stop handle any click event when disabled.
|
||||
// To avoid handle dropdown menu open when button is disabled.
|
||||
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
|
||||
if self.disabled {
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
@@ -380,30 +389,35 @@ impl RenderOnce for Button {
|
||||
// Avoid focus on mouse down.
|
||||
window.prevent_default();
|
||||
})
|
||||
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
|
||||
this.on_hover(move |hovered, window, cx| {
|
||||
(on_hover)(hovered, window, cx);
|
||||
})
|
||||
})
|
||||
.child({
|
||||
h_flex()
|
||||
.id("label")
|
||||
.justify_center()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.text_xs().gap_1(),
|
||||
Size::Small => this.text_sm().gap_1p5(),
|
||||
_ => this.text_sm().gap_2(),
|
||||
Size::XSmall => this.text_style(TextStyle::Caption).gap_1(),
|
||||
Size::Small => this.text_style(TextStyle::Callout).gap_1p5(),
|
||||
_ => this.text_style(TextStyle::Callout).gap(px(theme::SPACE)),
|
||||
})
|
||||
.when(!self.loading, |this| {
|
||||
this.when_some(self.icon, |this, icon| {
|
||||
.when(!self.loading && icon_start, |this| {
|
||||
this.when_some(self.icon.clone(), |this, icon| {
|
||||
this.child(icon.with_size(icon_size))
|
||||
})
|
||||
})
|
||||
.when(self.loading, |this| this.child(Indicator::new()))
|
||||
.when(self.loading && icon_start, |this| {
|
||||
this.child(Indicator::new())
|
||||
})
|
||||
.when_some(self.label, |this, label| {
|
||||
this.child(div().flex_none().line_height(relative(1.)).child(label))
|
||||
})
|
||||
.children(self.children)
|
||||
.when(!self.loading && !icon_start, |this| {
|
||||
this.when_some(self.icon.clone(), |this, icon| {
|
||||
this.child(icon.with_size(icon_size))
|
||||
})
|
||||
})
|
||||
.when(self.loading && !icon_start, |this| {
|
||||
this.child(Indicator::new())
|
||||
})
|
||||
.when(self.caret, |this| {
|
||||
this.justify_between().gap_0p5().child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
@@ -413,17 +427,6 @@ impl RenderOnce for Button {
|
||||
})
|
||||
})
|
||||
.text_color(normal_style.fg)
|
||||
.when(self.indicator && !self.disabled, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_px()
|
||||
.right_px()
|
||||
.size_1()
|
||||
.rounded_full()
|
||||
.bg(gpui::green()),
|
||||
)
|
||||
})
|
||||
.when(!self.disabled && !self.selected, |this| {
|
||||
this.bg(normal_style.bg)
|
||||
.hover(|this| {
|
||||
@@ -470,15 +473,15 @@ impl ButtonVariant {
|
||||
|
||||
fn bg_color(&self, cx: &App) -> Hsla {
|
||||
match self {
|
||||
ButtonVariant::Primary => cx.theme().element_background,
|
||||
ButtonVariant::Secondary => cx.theme().secondary_background,
|
||||
ButtonVariant::Danger => cx.theme().danger_background,
|
||||
ButtonVariant::Warning => cx.theme().warning_background,
|
||||
ButtonVariant::Primary => cx.theme().solid,
|
||||
ButtonVariant::Secondary => cx.theme().surface_raised,
|
||||
ButtonVariant::Danger => cx.theme().danger_strong,
|
||||
ButtonVariant::Warning => cx.theme().warning_muted,
|
||||
ButtonVariant::Ghost { alt } => {
|
||||
if *alt {
|
||||
cx.theme().ghost_element_background_alt
|
||||
cx.theme().surface_raised
|
||||
} else {
|
||||
cx.theme().ghost_element_background
|
||||
gpui::transparent_black()
|
||||
}
|
||||
}
|
||||
ButtonVariant::Custom(colors) => colors.color,
|
||||
@@ -488,11 +491,15 @@ impl ButtonVariant {
|
||||
|
||||
fn text_color(&self, cx: &App) -> Hsla {
|
||||
match self {
|
||||
ButtonVariant::Primary => cx.theme().element_foreground,
|
||||
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||
ButtonVariant::Danger => cx.theme().danger_foreground,
|
||||
ButtonVariant::Warning => cx.theme().warning_foreground,
|
||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||
ButtonVariant::Primary => cx.theme().on_solid,
|
||||
ButtonVariant::Secondary => cx.theme().text,
|
||||
// Bezel's destructive pairing: the red plate carries white ink in
|
||||
// both appearances, not the appearance-flipping on_accent.
|
||||
ButtonVariant::Danger => gpui::white(),
|
||||
// warning_muted is a translucent amber wash, so the label is the
|
||||
// amber token itself — dark amber in light, bright in dark.
|
||||
ButtonVariant::Warning => cx.theme().warning,
|
||||
ButtonVariant::Transparent => cx.theme().text_faint,
|
||||
ButtonVariant::Ghost { alt } => {
|
||||
if *alt {
|
||||
cx.theme().text
|
||||
@@ -506,19 +513,19 @@ impl ButtonVariant {
|
||||
|
||||
fn hovered(&self, cx: &App) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Primary => cx.theme().element_hover,
|
||||
ButtonVariant::Secondary => cx.theme().secondary_hover,
|
||||
ButtonVariant::Danger => cx.theme().danger_hover,
|
||||
ButtonVariant::Warning => cx.theme().warning_hover,
|
||||
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_hover,
|
||||
ButtonVariant::Primary => plate_hover(cx.theme().solid),
|
||||
ButtonVariant::Secondary => cx.theme().surface_raised_hover,
|
||||
ButtonVariant::Danger => plate_hover(cx.theme().danger_strong),
|
||||
ButtonVariant::Warning => plate_hover(cx.theme().warning_muted),
|
||||
ButtonVariant::Ghost { .. } => cx.theme().element_hover,
|
||||
ButtonVariant::Transparent => gpui::transparent_black(),
|
||||
ButtonVariant::Custom(colors) => colors.hover,
|
||||
};
|
||||
|
||||
let fg = match self {
|
||||
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||
ButtonVariant::Secondary => cx.theme().text,
|
||||
ButtonVariant::Ghost { .. } => cx.theme().text,
|
||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||
ButtonVariant::Transparent => cx.theme().text_faint,
|
||||
_ => self.text_color(cx),
|
||||
};
|
||||
|
||||
@@ -527,18 +534,18 @@ impl ButtonVariant {
|
||||
|
||||
fn active(&self, cx: &App) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Primary => cx.theme().element_active,
|
||||
ButtonVariant::Secondary => cx.theme().secondary_active,
|
||||
ButtonVariant::Danger => cx.theme().danger_active,
|
||||
ButtonVariant::Warning => cx.theme().warning_active,
|
||||
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_active,
|
||||
ButtonVariant::Primary => plate_active(cx.theme().solid),
|
||||
ButtonVariant::Secondary => plate_active(cx.theme().surface_raised),
|
||||
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
|
||||
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
|
||||
ButtonVariant::Ghost { .. } => cx.theme().element_active,
|
||||
ButtonVariant::Transparent => gpui::transparent_black(),
|
||||
ButtonVariant::Custom(colors) => colors.active,
|
||||
};
|
||||
|
||||
let fg = match self {
|
||||
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||
ButtonVariant::Secondary => cx.theme().text,
|
||||
ButtonVariant::Transparent => cx.theme().text_faint,
|
||||
_ => self.text_color(cx),
|
||||
};
|
||||
|
||||
@@ -547,18 +554,18 @@ impl ButtonVariant {
|
||||
|
||||
fn selected(&self, cx: &App) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Primary => cx.theme().element_selected,
|
||||
ButtonVariant::Secondary => cx.theme().secondary_selected,
|
||||
ButtonVariant::Danger => cx.theme().danger_selected,
|
||||
ButtonVariant::Warning => cx.theme().warning_selected,
|
||||
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_selected,
|
||||
ButtonVariant::Primary => cx.theme().element_active,
|
||||
ButtonVariant::Secondary => cx.theme().element_active,
|
||||
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
|
||||
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
|
||||
ButtonVariant::Ghost { .. } => cx.theme().element_active,
|
||||
ButtonVariant::Transparent => gpui::transparent_black(),
|
||||
ButtonVariant::Custom(colors) => colors.active,
|
||||
};
|
||||
|
||||
let fg = match self {
|
||||
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||
ButtonVariant::Secondary => cx.theme().text,
|
||||
ButtonVariant::Transparent => cx.theme().text_faint,
|
||||
_ => self.text_color(cx),
|
||||
};
|
||||
|
||||
@@ -566,18 +573,8 @@ impl ButtonVariant {
|
||||
}
|
||||
|
||||
fn disabled(&self, cx: &App) -> ButtonVariantStyle {
|
||||
let bg = match self {
|
||||
ButtonVariant::Danger => cx.theme().danger_disabled,
|
||||
ButtonVariant::Warning => cx.theme().warning_disabled,
|
||||
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_disabled,
|
||||
ButtonVariant::Secondary => cx.theme().secondary_disabled,
|
||||
_ => cx.theme().element_disabled,
|
||||
};
|
||||
|
||||
let fg = match self {
|
||||
ButtonVariant::Primary => cx.theme().text_muted,
|
||||
_ => cx.theme().text_muted,
|
||||
};
|
||||
let bg = wash(0.08);
|
||||
let fg = cx.theme().text_muted;
|
||||
|
||||
ButtonVariantStyle { bg, fg }
|
||||
}
|
||||
|
||||
+34
-90
@@ -2,20 +2,17 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Div, FocusHandle,
|
||||
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
div, hsla, point, px,
|
||||
AnimationExt as _, AnyElement, App, ClickEvent, Div, FocusHandle, IntoElement, ParentElement,
|
||||
Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, px, transparent_black,
|
||||
};
|
||||
use gpui_base::{DialogBackdrop, DialogPopup, DialogTitle};
|
||||
use instant::Duration;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, SCRIM_ALPHA_DARK, scrim, surface_radius, surface_shadows};
|
||||
|
||||
use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
|
||||
use crate::motion::DIALOG_IN;
|
||||
use crate::scroll::ScrollableElement;
|
||||
use crate::{IconName, Root, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||
type OnOk = Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>;
|
||||
type OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
|
||||
type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
|
||||
@@ -70,7 +67,6 @@ pub struct Dialog {
|
||||
width: Pixels,
|
||||
max_width: Option<Pixels>,
|
||||
|
||||
on_close: OnClose,
|
||||
on_ok: OnOk,
|
||||
on_cancel: OnCancel,
|
||||
|
||||
@@ -80,10 +76,10 @@ pub struct Dialog {
|
||||
show_close: bool,
|
||||
button_props: DialogButtonProps,
|
||||
|
||||
/// This will be change when open the dialog, the focus handle is create when open the dialog.
|
||||
pub focus_handle: FocusHandle,
|
||||
pub layer_ix: usize,
|
||||
pub overlay_visible: bool,
|
||||
/// The focus handle created when the dialog is opened, owned by the `Root`.
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
pub(crate) layer_ix: usize,
|
||||
pub(crate) overlay_visible: bool,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
@@ -100,7 +96,6 @@ impl Dialog {
|
||||
keyboard: true,
|
||||
layer_ix: 0,
|
||||
overlay_visible: false,
|
||||
on_close: Rc::new(|_, _, _| {}),
|
||||
on_ok: None,
|
||||
on_cancel: Rc::new(|_, _, _| true),
|
||||
button_props: DialogButtonProps::default(),
|
||||
@@ -115,15 +110,8 @@ impl Dialog {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the footer of the dialog.
|
||||
///
|
||||
/// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`.
|
||||
///
|
||||
/// - First `RenderButtonFn` is the render function for the OK button.
|
||||
/// - Second `RenderButtonFn` is the render function for the CANCEL button.
|
||||
///
|
||||
/// When you set the footer, the footer will be placed default footer buttons.
|
||||
pub fn footer<E, F>(mut self, footer: F) -> Self
|
||||
/// Sets a custom footer, replacing the default footer buttons.
|
||||
fn footer<E, F>(mut self, footer: F) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
|
||||
@@ -150,20 +138,7 @@ impl Dialog {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the callback for when the dialog is closed.
|
||||
///
|
||||
/// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
|
||||
pub fn on_close(
|
||||
mut self,
|
||||
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_close = Rc::new(on_close);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the callback for when the dialog is has been confirmed.
|
||||
///
|
||||
/// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
|
||||
/// Sets the confirm callback, returning `false` keeps the dialog open.
|
||||
pub fn on_ok(
|
||||
mut self,
|
||||
on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
|
||||
@@ -172,9 +147,7 @@ impl Dialog {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the callback for when the dialog is has been canceled.
|
||||
///
|
||||
/// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
|
||||
/// Sets the cancel callback, returning `false` keeps the dialog open.
|
||||
pub fn on_cancel(
|
||||
mut self,
|
||||
on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
|
||||
@@ -189,15 +162,13 @@ impl Dialog {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the dialog, defaults to 480px.
|
||||
/// Sets the width of the dialog, defaults to 380px.
|
||||
pub fn width(mut self, width: Pixels) -> Self {
|
||||
self.width = width;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the overlay closable of the dialog, defaults to `true`.
|
||||
///
|
||||
/// When the overlay is clicked, the dialog will be closed.
|
||||
/// Sets whether clicking the overlay closes the dialog, defaults to `true`.
|
||||
pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
|
||||
self.overlay_closable = overlay_closable;
|
||||
self
|
||||
@@ -209,7 +180,7 @@ impl Dialog {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_overlay(&self) -> bool {
|
||||
pub(crate) fn has_overlay(&self) -> bool {
|
||||
self.overlay
|
||||
}
|
||||
}
|
||||
@@ -232,13 +203,11 @@ impl RenderOnce for Dialog {
|
||||
let is_topmost = layer_ix + 1 == Root::read(window, cx).active_dialogs.len();
|
||||
let has_footer = self.footer.is_some();
|
||||
|
||||
let on_close = self.on_close.clone();
|
||||
let on_ok = self.on_ok.clone();
|
||||
let on_cancel = self.on_cancel.clone();
|
||||
|
||||
let render_ok: RenderButtonFn = Box::new({
|
||||
let on_ok = on_ok.clone();
|
||||
let on_close = on_close.clone();
|
||||
let ok_variant = self.button_props.ok_variant;
|
||||
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
|
||||
|
||||
@@ -249,7 +218,6 @@ impl RenderOnce for Dialog {
|
||||
.font_semibold()
|
||||
.on_click({
|
||||
let on_ok = on_ok.clone();
|
||||
let on_close = on_close.clone();
|
||||
|
||||
move |_, window, cx| {
|
||||
if let Some(on_ok) = &on_ok
|
||||
@@ -258,7 +226,6 @@ impl RenderOnce for Dialog {
|
||||
return;
|
||||
}
|
||||
|
||||
on_close(&ClickEvent::default(), window, cx);
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
})
|
||||
@@ -268,7 +235,6 @@ impl RenderOnce for Dialog {
|
||||
|
||||
let render_cancel: RenderButtonFn = Box::new({
|
||||
let on_cancel = on_cancel.clone();
|
||||
let on_close = on_close.clone();
|
||||
let cancel_variant = self.button_props.cancel_variant;
|
||||
let cancel_text = self
|
||||
.button_props
|
||||
@@ -281,13 +247,11 @@ impl RenderOnce for Dialog {
|
||||
.with_variant(cancel_variant)
|
||||
.on_click({
|
||||
let on_cancel = on_cancel.clone();
|
||||
let on_close = on_close.clone();
|
||||
move |_, window, cx| {
|
||||
if !on_cancel(&ClickEvent::default(), window, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
on_close(&ClickEvent::default(), window, cx);
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
})
|
||||
@@ -306,25 +270,24 @@ impl RenderOnce for Dialog {
|
||||
padding_right = pr.to_pixels(self.width.into(), window.rem_size());
|
||||
}
|
||||
|
||||
let animation = Animation::new(Duration::from_secs_f64(0.25))
|
||||
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
|
||||
|
||||
let backdrop = DialogBackdrop::new()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.when(self.overlay_visible, |this| this.bg(cx.theme().overlay))
|
||||
.with_animation("fade-in", animation.clone(), move |this, delta| {
|
||||
.when(self.overlay_visible, |this| {
|
||||
this.bg(scrim(SCRIM_ALPHA_DARK))
|
||||
})
|
||||
.with_animation("fade-in", DIALOG_IN.animation(), move |this, delta| {
|
||||
this.opacity(delta)
|
||||
});
|
||||
|
||||
let card = DialogPopup::new()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.bg(cx.theme().background)
|
||||
.bg(cx.theme().surface_dialog)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border.alpha(0.4))
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_xl())
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(surface_radius()))
|
||||
.when(cx.theme().shadow, |this| this.shadow(surface_shadows()))
|
||||
.min_h_24()
|
||||
.refine_style(&self.style)
|
||||
.relative()
|
||||
@@ -349,7 +312,6 @@ impl RenderOnce for Dialog {
|
||||
})
|
||||
.when(self.show_close, |this| {
|
||||
let on_cancel = on_cancel.clone();
|
||||
let on_close = on_close.clone();
|
||||
|
||||
this.child(
|
||||
div()
|
||||
@@ -364,14 +326,13 @@ impl RenderOnce for Dialog {
|
||||
.icon(IconName::CloseCircleFill)
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.foreground(cx.theme().icon_muted)
|
||||
.color(cx.theme().ghost_element_background)
|
||||
.hover(cx.theme().ghost_element_background)
|
||||
.active(cx.theme().ghost_element_background),
|
||||
.foreground(cx.theme().text_faint)
|
||||
.color(transparent_black())
|
||||
.hover(cx.theme().element_hover)
|
||||
.active(cx.theme().element_active),
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
on_cancel(&ClickEvent::default(), window, cx);
|
||||
on_close(&ClickEvent::default(), window, cx);
|
||||
window.close_dialog(cx);
|
||||
}),
|
||||
),
|
||||
@@ -407,26 +368,12 @@ impl RenderOnce for Dialog {
|
||||
.children(footer(render_ok, render_cancel, window, cx)),
|
||||
)
|
||||
})
|
||||
.with_animation("slide-down", animation, move |this, delta| {
|
||||
// Settle at the target offset; only the entrance is animated.
|
||||
let y_offset = px(30.) * (delta - 1.);
|
||||
// This is equivalent to `shadow_xl` with an extra opacity.
|
||||
let shadow = vec![
|
||||
BoxShadow {
|
||||
color: hsla(0., 0., 0., 0.1 * delta),
|
||||
offset: point(px(0.), px(20.)),
|
||||
blur_radius: px(25.),
|
||||
spread_radius: px(-5.),
|
||||
inset: false,
|
||||
},
|
||||
BoxShadow {
|
||||
color: hsla(0., 0., 0., 0.1 * delta),
|
||||
offset: point(px(0.), px(8.)),
|
||||
blur_radius: px(10.),
|
||||
spread_radius: px(-6.),
|
||||
inset: false,
|
||||
},
|
||||
];
|
||||
.with_animation("slide-down", DIALOG_IN.animation(), move |this, delta| {
|
||||
let y_offset = px(2.) * (delta - 1.);
|
||||
let mut shadow = surface_shadows();
|
||||
for shadow in &mut shadow {
|
||||
shadow.color.a *= delta;
|
||||
}
|
||||
this.top(y_offset).shadow(shadow)
|
||||
});
|
||||
|
||||
@@ -442,9 +389,6 @@ impl RenderOnce for Dialog {
|
||||
None => has_footer,
|
||||
})
|
||||
.on_cancel(move |event, window, cx| on_cancel(event, window, cx))
|
||||
.on_close(move |event, window, cx| {
|
||||
on_close(event, window, cx);
|
||||
window.close_dialog(cx);
|
||||
})
|
||||
.on_close(move |_, window, cx| window.close_dialog(cx))
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
@@ -17,7 +17,7 @@ use gpui_base::dock::{
|
||||
PaneRef, PanelId, TabGroupContext, TabGroupRenderer,
|
||||
};
|
||||
use gpui_base::{HandleEdge, Placement, ResizeHandleContext};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _, button_radius, panel_radius};
|
||||
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::resizable::{resize_handle, resize_handle_appearance};
|
||||
@@ -83,9 +83,11 @@ pub fn add_panel_to(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let _ = dock.update(cx, |area, cx| {
|
||||
if let Err(error) = dock.update(cx, |area, cx| {
|
||||
add_panel(area, panel, placement, window, cx);
|
||||
});
|
||||
}) {
|
||||
log::warn!("Failed to add panel to dock: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel in any region of `area` whose logical id is `key`.
|
||||
@@ -346,12 +348,12 @@ impl Render for DragPreview {
|
||||
.justify_center()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.rounded(px(button_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text)
|
||||
.text_ellipsis()
|
||||
.when(cx.theme().shadow, |this| this.shadow_xs())
|
||||
.bg(cx.theme().background)
|
||||
.bg(cx.theme().bg)
|
||||
.child(panel_title(&self.panel, cx))
|
||||
}
|
||||
}
|
||||
@@ -446,7 +448,7 @@ impl TabGroupSkin {
|
||||
.flex_grow_1()
|
||||
.min_w_16()
|
||||
.when(droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
|
||||
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().drop_target))
|
||||
.on_drop({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
@@ -465,7 +467,7 @@ impl TabGroupSkin {
|
||||
let bar = TabBar::new("tab-bar")
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.h(TABBAR_HEIGHT)
|
||||
.bg(cx.theme().panel_background)
|
||||
.bg(cx.theme().bg)
|
||||
.when(needs_traffic_light_padding, |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
})
|
||||
@@ -537,7 +539,7 @@ impl TabGroupSkin {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().border)
|
||||
.border_color(cx.theme().drop_line)
|
||||
})
|
||||
.on_drop({
|
||||
let group = TabGroupContext::clone(group);
|
||||
@@ -567,7 +569,7 @@ impl TabGroupSkin {
|
||||
h_flex()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.w_full()
|
||||
.bg(cx.theme().panel_background)
|
||||
.bg(cx.theme().bg)
|
||||
.child(bar.flex_1())
|
||||
.child(window_controls())
|
||||
.into_any_element()
|
||||
@@ -670,8 +672,7 @@ impl TabGroupRenderer for TabGroupSkin {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
// The left dock's only panel draws bare, so its content can own the
|
||||
// window's top-left corner instead of a tab bar doing so.
|
||||
// The plain left group draws bare so its content can own the window's top-left corner.
|
||||
if self.is_plain_left_group(group, cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
@@ -717,7 +718,7 @@ impl TabGroupRenderer for TabGroupSkin {
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.bg(cx.theme().panel_background)
|
||||
.bg(cx.theme().bg)
|
||||
.overflow_hidden()
|
||||
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
|
||||
)
|
||||
@@ -758,10 +759,10 @@ impl TabGroupRenderer for TabGroupSkin {
|
||||
.top(top)
|
||||
.w(width)
|
||||
.h(height)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.rounded(px(panel_radius()))
|
||||
.border_1()
|
||||
.border_color(cx.theme().element_disabled)
|
||||
.bg(cx.theme().drop_target_background)
|
||||
.border_color(cx.theme().drop_line)
|
||||
.bg(cx.theme().drop_target)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,9 @@ use gpui::{
|
||||
use gpui_base::dock::{PanelId, PanelState};
|
||||
|
||||
use crate::button::Button;
|
||||
use crate::menu::PopupMenu;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PanelEvent {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
LayoutChanged,
|
||||
}
|
||||
pub enum PanelEvent {}
|
||||
|
||||
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||
/// The name of the panel used to serialize, deserialize and identify the panel.
|
||||
@@ -31,44 +26,29 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||
true
|
||||
}
|
||||
|
||||
/// Return true if the panel is zoomable, default is `false`.
|
||||
/// Whether the panel is zoomable, default is `true`.
|
||||
fn zoomable(&self, _cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Return false to hide panel, true to show panel, default is `true`.
|
||||
///
|
||||
/// This method called in Panel render, we should make sure it is fast.
|
||||
fn visible(&self, _cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Set active state of the panel.
|
||||
///
|
||||
/// This method will be called when the panel is active or inactive.
|
||||
///
|
||||
/// The last_active_panel and current_active_panel will be touched when the panel is active.
|
||||
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
|
||||
|
||||
/// Set zoomed state of the panel.
|
||||
///
|
||||
/// This method will be called when the panel is zoomed or unzoomed.
|
||||
///
|
||||
/// Only current Panel will touch this method.
|
||||
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
|
||||
|
||||
/// The addition popup menu of the panel, default is `None`.
|
||||
fn popup_menu(&self, this: PopupMenu, _cx: &App) -> PopupMenu {
|
||||
this
|
||||
}
|
||||
|
||||
/// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`.
|
||||
/// Toolbar buttons shown at the right of the title bar.
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
pub trait PanelView: 'static + Send + Sync {
|
||||
pub(crate) trait PanelView: 'static + Send + Sync {
|
||||
fn panel_id(&self, cx: &App) -> SharedString;
|
||||
fn title(&self, cx: &App) -> AnyElement;
|
||||
fn closable(&self, cx: &App) -> bool;
|
||||
@@ -76,8 +56,6 @@ pub trait PanelView: 'static + Send + Sync {
|
||||
fn visible(&self, cx: &App) -> bool;
|
||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
|
||||
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
|
||||
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu;
|
||||
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button>;
|
||||
fn view(&self) -> AnyView;
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle;
|
||||
}
|
||||
@@ -115,14 +93,6 @@ impl<T: Panel> PanelView for Entity<T> {
|
||||
})
|
||||
}
|
||||
|
||||
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
|
||||
self.read(cx).popup_menu(menu, cx)
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button> {
|
||||
self.read(cx).toolbar_buttons(window, cx)
|
||||
}
|
||||
|
||||
fn view(&self) -> AnyView {
|
||||
self.clone().into()
|
||||
}
|
||||
@@ -132,24 +102,6 @@ impl<T: Panel> PanelView for Entity<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&dyn PanelView> for AnyView {
|
||||
fn from(handle: &dyn PanelView) -> Self {
|
||||
handle.view()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Panel> From<&dyn PanelView> for Entity<T> {
|
||||
fn from(value: &dyn PanelView) -> Self {
|
||||
value.view().downcast::<T>().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for dyn PanelView {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.view() == other.view()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PanelHandle {
|
||||
id: PanelId,
|
||||
@@ -164,13 +116,12 @@ impl PanelHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the coop handle behind one of base's.
|
||||
pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
|
||||
/// Downcast a base handle back to the coop handle.
|
||||
pub(crate) fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
|
||||
panel.as_any().downcast_ref::<Self>()
|
||||
}
|
||||
|
||||
/// The coop panel behind this handle.
|
||||
pub fn panel(&self) -> &Arc<dyn PanelView> {
|
||||
pub(crate) fn panel(&self) -> &Arc<dyn PanelView> {
|
||||
&self.panel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
|
||||
StyleRefinement, Styled, Window, div, relative,
|
||||
StyleRefinement, Styled, Window, div, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
use crate::{StyledExt as _, v_flex};
|
||||
|
||||
@@ -22,12 +22,6 @@ pub trait GroupBoxVariants: Sized {
|
||||
#[must_use]
|
||||
fn with_variant(self, variant: GroupBoxVariant) -> Self;
|
||||
|
||||
/// Set to use [`GroupBoxVariant::Normal`] to GroupBox.
|
||||
#[must_use]
|
||||
fn normal(self) -> Self {
|
||||
self.with_variant(GroupBoxVariant::Normal)
|
||||
}
|
||||
|
||||
/// Set to use [`GroupBoxVariant::Fill`] to GroupBox.
|
||||
#[must_use]
|
||||
fn fill(self) -> Self {
|
||||
@@ -35,16 +29,13 @@ pub trait GroupBoxVariants: Sized {
|
||||
}
|
||||
}
|
||||
|
||||
/// GroupBox is a styled container element that with
|
||||
/// an optional title to groups related content together.
|
||||
/// A styled container with an optional title that groups related content together.
|
||||
#[derive(IntoElement)]
|
||||
pub struct GroupBox {
|
||||
id: Option<ElementId>,
|
||||
variant: GroupBoxVariant,
|
||||
style: StyleRefinement,
|
||||
title_style: StyleRefinement,
|
||||
title: Option<AnyElement>,
|
||||
content_style: StyleRefinement,
|
||||
children: SmallVec<[AnyElement; 1]>,
|
||||
}
|
||||
|
||||
@@ -55,8 +46,6 @@ impl GroupBox {
|
||||
id: None,
|
||||
variant: GroupBoxVariant::default(),
|
||||
style: StyleRefinement::default(),
|
||||
title_style: StyleRefinement::default(),
|
||||
content_style: StyleRefinement::default(),
|
||||
title: None,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
@@ -106,7 +95,7 @@ impl RenderOnce for GroupBox {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (bg, has_paddings) = match self.variant {
|
||||
GroupBoxVariant::Normal => (None, false),
|
||||
GroupBoxVariant::Fill => (Some(cx.theme().surface_background), true),
|
||||
GroupBoxVariant::Fill => (Some(cx.theme().surface), true),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
@@ -119,9 +108,7 @@ impl RenderOnce for GroupBox {
|
||||
this.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.line_height(relative(1.))
|
||||
.refine_style(&self.title_style)
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_semibold()
|
||||
.child(title),
|
||||
)
|
||||
@@ -132,8 +119,7 @@ impl RenderOnce for GroupBox {
|
||||
.text_color(cx.theme().text)
|
||||
.when(has_paddings, |this| this.p_2())
|
||||
.gap_4()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.refine_style(&self.content_style)
|
||||
.rounded(px(theme::panel_radius()))
|
||||
.children(self.children),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-46
@@ -1,7 +1,7 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Hsla, IntoElement, Radians, Render, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, Svg, Transformation, Window, svg,
|
||||
AnyElement, App, Context, Hsla, IntoElement, Render, RenderOnce, SharedString, StyleRefinement,
|
||||
Styled, Svg, Window, svg,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -18,13 +18,11 @@ pub enum IconName {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowDownCircle,
|
||||
Boom,
|
||||
Book,
|
||||
Ban,
|
||||
ChevronDown,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
CaretUp,
|
||||
Check,
|
||||
CheckCircle,
|
||||
Close,
|
||||
@@ -32,17 +30,14 @@ pub enum IconName {
|
||||
CloseCircleFill,
|
||||
Compass,
|
||||
Copy,
|
||||
Device,
|
||||
Door,
|
||||
Ellipsis,
|
||||
Emoji,
|
||||
Eye,
|
||||
Folder,
|
||||
Input,
|
||||
Info,
|
||||
Invite,
|
||||
Inbox,
|
||||
InboxFill,
|
||||
Link,
|
||||
Loader,
|
||||
Lock,
|
||||
@@ -54,23 +49,14 @@ pub enum IconName {
|
||||
PlusChat,
|
||||
PlusCircle,
|
||||
Profile,
|
||||
Reset,
|
||||
Relay,
|
||||
Reply,
|
||||
Refresh,
|
||||
Scan,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Ship,
|
||||
Shield,
|
||||
Group,
|
||||
Hashtag,
|
||||
History,
|
||||
UserKey,
|
||||
Upload,
|
||||
Usb,
|
||||
Edit,
|
||||
PanelLeft,
|
||||
PanelLeftOpen,
|
||||
@@ -84,9 +70,6 @@ pub enum IconName {
|
||||
WindowMaximize,
|
||||
WindowMinimize,
|
||||
WindowRestore,
|
||||
Fistbump,
|
||||
FistbumpFill,
|
||||
Zoom,
|
||||
}
|
||||
|
||||
impl IconName {
|
||||
@@ -95,13 +78,11 @@ impl IconName {
|
||||
Self::ArrowLeft => "icons/arrow-left.svg",
|
||||
Self::ArrowRight => "icons/arrow-right.svg",
|
||||
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
|
||||
Self::Boom => "icons/boom.svg",
|
||||
Self::Book => "icons/book.svg",
|
||||
Self::Ban => "icons/ban.svg",
|
||||
Self::ChevronDown => "icons/chevron-down.svg",
|
||||
Self::CaretDown => "icons/caret-down.svg",
|
||||
Self::CaretRight => "icons/caret-right.svg",
|
||||
Self::CaretUp => "icons/caret-up.svg",
|
||||
Self::Check => "icons/check.svg",
|
||||
Self::CheckCircle => "icons/check-circle.svg",
|
||||
Self::Close => "icons/close.svg",
|
||||
@@ -109,17 +90,14 @@ impl IconName {
|
||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||
Self::Compass => "icons/compass.svg",
|
||||
Self::Copy => "icons/copy.svg",
|
||||
Self::Device => "icons/device.svg",
|
||||
Self::Door => "icons/door.svg",
|
||||
Self::Ellipsis => "icons/ellipsis.svg",
|
||||
Self::Emoji => "icons/emoji.svg",
|
||||
Self::Eye => "icons/eye.svg",
|
||||
Self::Folder => "icons/folder.svg",
|
||||
Self::Input => "icons/input.svg",
|
||||
Self::Info => "icons/info.svg",
|
||||
Self::Invite => "icons/invite.svg",
|
||||
Self::Inbox => "icons/inbox.svg",
|
||||
Self::InboxFill => "icons/inbox-fill.svg",
|
||||
Self::Link => "icons/link.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::Lock => "icons/lock.svg",
|
||||
@@ -131,23 +109,14 @@ impl IconName {
|
||||
Self::PlusChat => "icons/plus-chat.svg",
|
||||
Self::PlusCircle => "icons/plus-circle.svg",
|
||||
Self::Profile => "icons/profile.svg",
|
||||
Self::Reset => "icons/reset.svg",
|
||||
Self::Relay => "icons/relay.svg",
|
||||
Self::Reply => "icons/reply.svg",
|
||||
Self::Refresh => "icons/refresh.svg",
|
||||
Self::Scan => "icons/scan.svg",
|
||||
Self::Search => "icons/search.svg",
|
||||
Self::Settings => "icons/settings.svg",
|
||||
Self::Settings2 => "icons/settings2.svg",
|
||||
Self::Sun => "icons/sun.svg",
|
||||
Self::Ship => "icons/ship.svg",
|
||||
Self::Shield => "icons/shield.svg",
|
||||
Self::UserKey => "icons/user-key.svg",
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::Usb => "icons/usb.svg",
|
||||
Self::Group => "icons/group.svg",
|
||||
Self::Hashtag => "icons/hashtag.svg",
|
||||
Self::History => "icons/history.svg",
|
||||
Self::PanelLeft => "icons/panel-left.svg",
|
||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
Self::PanelRight => "icons/panel-right.svg",
|
||||
@@ -161,9 +130,6 @@ impl IconName {
|
||||
Self::WindowMaximize => "icons/window-maximize.svg",
|
||||
Self::WindowMinimize => "icons/window-minimize.svg",
|
||||
Self::WindowRestore => "icons/window-restore.svg",
|
||||
Self::Fistbump => "icons/fistbump.svg",
|
||||
Self::FistbumpFill => "icons/fistbump-fill.svg",
|
||||
Self::Zoom => "icons/zoom.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
@@ -188,7 +154,6 @@ pub struct Icon {
|
||||
path: SharedString,
|
||||
text_color: Option<Hsla>,
|
||||
size: Option<Size>,
|
||||
rotation: Option<Radians>,
|
||||
}
|
||||
|
||||
impl Default for Icon {
|
||||
@@ -199,7 +164,6 @@ impl Default for Icon {
|
||||
path: "".into(),
|
||||
text_color: None,
|
||||
size: None,
|
||||
rotation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,7 +172,6 @@ impl Clone for Icon {
|
||||
fn clone(&self) -> Self {
|
||||
let mut this = Self::default().path(self.path.clone());
|
||||
this.style = self.style.clone();
|
||||
this.rotation = self.rotation;
|
||||
this.size = self.size;
|
||||
this.text_color = self.text_color;
|
||||
this
|
||||
@@ -224,9 +187,7 @@ impl Icon {
|
||||
Self::default().path(name.path())
|
||||
}
|
||||
|
||||
/// Set the icon path of the Assets bundle
|
||||
///
|
||||
/// For example: `icons/foo.svg`
|
||||
/// Sets the icon path, relative to the assets bundle (for example `icons/foo.svg`).
|
||||
pub fn path(mut self, path: impl Into<SharedString>) -> Self {
|
||||
self.path = path.into();
|
||||
self
|
||||
@@ -291,7 +252,7 @@ impl From<Icon> for AnyElement {
|
||||
|
||||
impl Render for Icon {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon);
|
||||
let text_color = self.text_color.unwrap_or_else(|| cx.theme().text_muted);
|
||||
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
|
||||
|
||||
@@ -309,8 +270,5 @@ impl Render for Icon {
|
||||
Size::Large => this.size_6(),
|
||||
})
|
||||
.path(self.path.clone())
|
||||
.when_some(self.rotation, |this, rotation| {
|
||||
this.with_transformation(Transformation::rotate(rotation))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+13
-14
@@ -1,10 +1,11 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, App, Hsla, IntoElement, ParentElement, RenderOnce, Styled as _,
|
||||
Transformation, Window, div, ease_in_out, percentage,
|
||||
App, Hsla, IntoElement, ParentElement, RenderOnce, Styled as _, Transformation, Window, div,
|
||||
percentage,
|
||||
};
|
||||
use instant::Duration;
|
||||
|
||||
use crate::motion::{EASE_IN_OUT, MotionSpec, Painter, pulse_delta};
|
||||
use crate::{Icon, IconName, Sizable, Size};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
@@ -15,6 +16,12 @@ pub struct Indicator {
|
||||
color: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl Default for Indicator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -31,12 +38,6 @@ impl Indicator {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Indicator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Indicator {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
@@ -45,16 +46,14 @@ impl Sizable for Indicator {
|
||||
}
|
||||
|
||||
impl RenderOnce for Indicator {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let spec = MotionSpec::new(self.speed.as_millis().max(1) as u64, EASE_IN_OUT);
|
||||
let phase = pulse_delta(&spec, Painter::from(window.current_view()), cx);
|
||||
div().child(
|
||||
self.icon
|
||||
.with_size(self.size)
|
||||
.when_some(self.color, |this, color| this.text_color(color))
|
||||
.with_animation(
|
||||
"circle",
|
||||
Animation::new(self.speed).repeat().with_easing(ease_in_out),
|
||||
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
|
||||
),
|
||||
.transform(Transformation::rotate(percentage(phase))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ pub(crate) fn clear_button(cx: &App) -> Button {
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.text_color(cx.theme().icon_muted)
|
||||
.text_color(cx.theme().text_faint)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement,
|
||||
MouseButton, ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign,
|
||||
Window, div, px,
|
||||
App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton,
|
||||
ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, Window, div,
|
||||
px,
|
||||
};
|
||||
use gpui_base::InputBase;
|
||||
use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, button_radius};
|
||||
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::indicator::Indicator;
|
||||
use crate::input::clear_button;
|
||||
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
|
||||
use crate::{Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn primary_paste_offset(
|
||||
@@ -45,35 +44,27 @@ fn primary_paste_offset(
|
||||
/// The background of an input frame, which reads muted while the input is disabled.
|
||||
fn input_background(disabled: bool, cx: &App) -> Hsla {
|
||||
if disabled {
|
||||
cx.theme().surface_background
|
||||
cx.theme().surface
|
||||
} else {
|
||||
cx.theme().elevated_surface_background
|
||||
cx.theme().input_bg
|
||||
}
|
||||
}
|
||||
|
||||
/// The colors base paints input text with, read from the coop theme.
|
||||
///
|
||||
/// Base fills in any color left transparent from its own palette, and that
|
||||
/// palette is only a projection of this one, so every color coop paints with is
|
||||
/// named here rather than left to resolve.
|
||||
fn input_editor_style(cx: &App) -> InputEditorStyle {
|
||||
let theme = cx.theme();
|
||||
InputEditorStyle {
|
||||
foreground: theme.text,
|
||||
muted_foreground: theme.text_muted,
|
||||
background: theme.elevated_surface_background,
|
||||
border: theme.border,
|
||||
muted_foreground: theme.text_faint,
|
||||
background: theme.input_bg,
|
||||
border: theme.border_faint,
|
||||
selection: theme.selection,
|
||||
caret: theme.cursor,
|
||||
caret: theme.caret,
|
||||
..InputEditorStyle::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The input's own padding, resolved to pixels.
|
||||
///
|
||||
/// Base applies the multi-line padding itself so that the text, the gutter, and
|
||||
/// the scrollbar share one inset, and the single-line frame carries its own.
|
||||
/// Both come from the same size table, resolved through the window's rem size.
|
||||
fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges<Pixels> {
|
||||
let mut probe = div().input_px(size).input_py(size).refine_style(style);
|
||||
let padding = probe.style().padding.clone();
|
||||
@@ -94,23 +85,14 @@ fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges
|
||||
}
|
||||
|
||||
/// A text input element bound to an [`InputState`] or a [`TextareaState`].
|
||||
///
|
||||
/// The editing kind lives on the state, so `Input::new` accepts either and
|
||||
/// infers which one is rendered.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Input<M: InputModeKind = InputMode> {
|
||||
state: Entity<InputBaseState<M>>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
height: Option<DefiniteLength>,
|
||||
appearance: bool,
|
||||
cleanable: bool,
|
||||
mask_toggle: bool,
|
||||
disabled: bool,
|
||||
tab_index: isize,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
/// A styled multi-line text input.
|
||||
@@ -123,17 +105,6 @@ impl<M: InputModeKind> Sizable for Input<M> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: InputModeKind> Selectable for Input<M> {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: InputModeKind> Input<M> {
|
||||
/// Create a new [`Input`] element bind to the given state.
|
||||
pub fn new(state: &Entity<InputBaseState<M>>) -> Self {
|
||||
@@ -141,28 +112,12 @@ impl<M: InputModeKind> Input<M> {
|
||||
state: state.clone(),
|
||||
size: Size::default(),
|
||||
style: StyleRefinement::default(),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
height: None,
|
||||
appearance: true,
|
||||
cleanable: false,
|
||||
mask_toggle: false,
|
||||
disabled: false,
|
||||
tab_index: 0,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the appearance of the input field, if false the input field will no border, background.
|
||||
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||
self.appearance = appearance;
|
||||
@@ -180,18 +135,6 @@ impl<M: InputModeKind> Input<M> {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
fn render_toggle_mask_button(state: &Entity<InputBaseState<M>>) -> impl IntoElement {
|
||||
Button::new("toggle-mask")
|
||||
.icon(IconName::Eye)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.on_click({
|
||||
let state = state.clone();
|
||||
move |_, window, cx| state.update(cx, |state, cx| state.toggle_masked(window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: InputModeKind> Styled for Input<M> {
|
||||
@@ -235,10 +178,8 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
|
||||
let background = input_background(disabled, cx);
|
||||
let show_clear_button =
|
||||
self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line;
|
||||
let has_suffix = self.suffix.is_some() || loading || self.mask_toggle || show_clear_button;
|
||||
let has_suffix = loading || show_clear_button;
|
||||
|
||||
let prefix = self.prefix;
|
||||
let suffix = self.suffix;
|
||||
let state_entity = self.state.clone();
|
||||
|
||||
InputBase::new(("input", self.state.entity_id()))
|
||||
@@ -294,19 +235,15 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
|
||||
},
|
||||
)
|
||||
.items_center()
|
||||
.when(multi_line, |this| {
|
||||
this.h_auto()
|
||||
.when_some(self.height, |this, height| this.h(height))
|
||||
})
|
||||
.when(multi_line, |this| this.h_auto())
|
||||
.when(self.appearance, |this| {
|
||||
this.bg(background)
|
||||
.when(self.disabled, |this| this.opacity(0.5))
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(button_radius()))
|
||||
})
|
||||
.tab_index(self.tab_index)
|
||||
.tab_index(0)
|
||||
.gap(gap_x)
|
||||
.refine_style(&self.style)
|
||||
.children(prefix)
|
||||
.when(!multi_line, |this| this.child(state_entity.clone()))
|
||||
.when(multi_line, |this| {
|
||||
this.child(
|
||||
@@ -322,9 +259,6 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
|
||||
.gap(gap_x)
|
||||
.items_center()
|
||||
.when(loading, |this| this.child(Indicator::new()))
|
||||
.when(self.mask_toggle, |this| {
|
||||
this.child(Self::render_toggle_mask_button(&state_entity))
|
||||
})
|
||||
.when(show_clear_button, |this| {
|
||||
this.child(clear_button(cx).on_click({
|
||||
let state = state_entity.clone();
|
||||
@@ -335,8 +269,7 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
.children(suffix),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
+11
-38
@@ -1,29 +1,20 @@
|
||||
use gpui::{
|
||||
Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke, ParentElement as _,
|
||||
RenderOnce, StyleRefinement, Styled, Window, div, relative,
|
||||
Action, AsKeystroke, IntoElement, KeyContext, Keystroke, ParentElement as _, RenderOnce,
|
||||
StyleRefinement, Styled, Window, div,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
use crate::StyledExt;
|
||||
|
||||
/// A key binding tag
|
||||
#[derive(IntoElement, Clone, Debug)]
|
||||
pub struct Kbd {
|
||||
pub(crate) struct Kbd {
|
||||
style: StyleRefinement,
|
||||
stroke: Keystroke,
|
||||
}
|
||||
|
||||
impl From<Keystroke> for Kbd {
|
||||
fn from(stroke: Keystroke) -> Self {
|
||||
Self {
|
||||
style: StyleRefinement::default(),
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Kbd {
|
||||
pub fn new(stroke: Keystroke) -> Self {
|
||||
fn new(stroke: Keystroke) -> Self {
|
||||
Self {
|
||||
style: StyleRefinement::default(),
|
||||
stroke,
|
||||
@@ -31,7 +22,7 @@ impl Kbd {
|
||||
}
|
||||
|
||||
/// Return the first keybinding for the given action and context.
|
||||
pub fn binding_for_action(
|
||||
pub(crate) fn binding_for_action(
|
||||
action: &dyn Action,
|
||||
context: Option<&str>,
|
||||
window: &Window,
|
||||
@@ -50,25 +41,8 @@ impl Kbd {
|
||||
.map(|key| Self::new(key.as_keystroke().clone()))
|
||||
}
|
||||
|
||||
/// Return the first keybinding for the given action and focus handle.
|
||||
pub fn binding_for_action_in(
|
||||
action: &dyn Action,
|
||||
focus_handle: &FocusHandle,
|
||||
window: &Window,
|
||||
) -> Option<Self> {
|
||||
let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
|
||||
|
||||
binding
|
||||
.keystrokes()
|
||||
.first()
|
||||
.map(|key| Self::new(key.as_keystroke().clone()))
|
||||
}
|
||||
|
||||
/// Return the Platform specific keybinding string by KeyStroke
|
||||
///
|
||||
/// - macOS: https://support.apple.com/en-us/HT201236
|
||||
/// - Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec
|
||||
pub fn format(key: &Keystroke) -> String {
|
||||
/// Return the platform specific keybinding string for a keystroke.
|
||||
fn format(key: &Keystroke) -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
const DIVIDER: &str = "";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -197,16 +171,15 @@ impl RenderOnce for Kbd {
|
||||
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
|
||||
div()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.border_color(cx.theme().border_faint)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.bg(cx.theme().surface_background)
|
||||
.bg(cx.theme().surface_raised)
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.min_w_5()
|
||||
.text_center()
|
||||
.rounded_sm()
|
||||
.line_height(relative(1.))
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.whitespace_normal()
|
||||
.flex_shrink_0()
|
||||
.refine_style(&self.style)
|
||||
|
||||
@@ -23,9 +23,9 @@ pub mod switch;
|
||||
pub mod tab;
|
||||
pub mod tooltip;
|
||||
|
||||
mod animation;
|
||||
mod icon;
|
||||
mod kbd;
|
||||
pub mod motion;
|
||||
mod popover;
|
||||
mod resizable;
|
||||
mod root;
|
||||
|
||||
+18
-14
@@ -1,7 +1,8 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, IntoElement, SharedString, StyleRefinement, Styled as _, Window,
|
||||
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, StyleRefinement,
|
||||
Styled as _, Window,
|
||||
};
|
||||
use gpui_base::{TextView, TextViewStyle};
|
||||
use theme::ActiveTheme as _;
|
||||
@@ -62,15 +63,28 @@ impl RenderedText {
|
||||
/// Render the message as a GPUI Kit text view.
|
||||
pub fn element(&self, id: ElementId, _window: &Window, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let code_block = StyleRefinement::default().font_family(code_font_family());
|
||||
let code_block = StyleRefinement::default().font_family(theme.font_mono.clone());
|
||||
let muted_heading = theme.text_muted;
|
||||
|
||||
TextView::markdown(id, self.text.clone())
|
||||
.style(
|
||||
TextViewStyle::default()
|
||||
.with_foreground(theme.text)
|
||||
.with_muted_foreground(theme.text_muted)
|
||||
.with_link(theme.text_accent)
|
||||
.with_code_background(theme.elevated_surface_background)
|
||||
// Deeply nested headings read as secondary text.
|
||||
.with_heading(move |level| {
|
||||
if level >= 4 {
|
||||
StyleRefinement::default().text_color(muted_heading)
|
||||
} else {
|
||||
StyleRefinement::default()
|
||||
}
|
||||
})
|
||||
.with_link(theme.accent)
|
||||
.with_code_background(theme.code_wash)
|
||||
.with_inline_code(HighlightStyle {
|
||||
color: Some(theme.code_text),
|
||||
..Default::default()
|
||||
})
|
||||
.with_border(theme.border)
|
||||
.with_code_block(code_block)
|
||||
.with_dark(theme.is_dark()),
|
||||
@@ -101,13 +115,3 @@ fn is_web_url(url: &str) -> bool {
|
||||
let url = url.to_ascii_lowercase();
|
||||
url.starts_with("http://") || url.starts_with("https://")
|
||||
}
|
||||
|
||||
fn code_font_family() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"Menlo"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Consolas"
|
||||
} else {
|
||||
"monospace"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +75,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the anchor corner for the dropdown menu popover.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the style refinement for the dropdown menu trigger.
|
||||
fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||
self.style = style;
|
||||
@@ -88,13 +82,10 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a [`PopupMenu`] when its child is clicked with a mouse button
|
||||
/// (right by default), keeping the child's own click handler intact.
|
||||
/// Opens a [`PopupMenu`] when its child is right-clicked, keeping the child's own click handler.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ContextMenu {
|
||||
id: ElementId,
|
||||
anchor: Anchor,
|
||||
mouse_button: MouseButton,
|
||||
child: AnyElement,
|
||||
builder: Rc<MenuBuilder>,
|
||||
}
|
||||
@@ -107,24 +98,10 @@ impl ContextMenu {
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
anchor: Anchor::TopLeft,
|
||||
mouse_button: MouseButton::Right,
|
||||
child: child.into_any_element(),
|
||||
builder: Rc::new(builder),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the anchor corner of the menu, default is `Anchor::TopLeft`.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
|
||||
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
|
||||
self.mouse_button = mouse_button;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -132,10 +109,7 @@ struct MenuState {
|
||||
menu: Option<Entity<PopupMenu>>,
|
||||
}
|
||||
|
||||
/// Builds the menu once and reuses it until it is dismissed.
|
||||
///
|
||||
/// The popover content closure runs on every render, so rebuilding the menu
|
||||
/// entity each time would drop its focus and selection state.
|
||||
/// Builds the menu once and reuses it until dismissed, preserving focus and selection state.
|
||||
fn cached_menu(
|
||||
menu_state: &Entity<MenuState>,
|
||||
builder: Rc<MenuBuilder>,
|
||||
@@ -196,8 +170,8 @@ impl RenderOnce for ContextMenu {
|
||||
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
|
||||
.appearance(false)
|
||||
.overlay_closable(false)
|
||||
.anchor(self.anchor)
|
||||
.mouse_button(self.mouse_button)
|
||||
.anchor(Anchor::TopLeft)
|
||||
.mouse_button(MouseButton::Right)
|
||||
.trigger_with(move |_open, _window, _cx| self.child)
|
||||
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use gpui::{
|
||||
Styled, Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
use crate::{Disableable, StyledExt, h_flex};
|
||||
|
||||
@@ -39,18 +39,12 @@ impl MenuItemElement {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set ListItem as the selected item style.
|
||||
/// Set the selected style of the item.
|
||||
pub(crate) fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the disabled state of the MenuItem.
|
||||
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a handler for when the MenuItem is clicked.
|
||||
pub(crate) fn on_click(
|
||||
mut self,
|
||||
@@ -61,7 +55,10 @@ impl MenuItemElement {
|
||||
}
|
||||
|
||||
/// Set a handler for when the mouse enters the MenuItem.
|
||||
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
pub(crate) fn on_hover(
|
||||
mut self,
|
||||
handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_hover = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
@@ -93,7 +90,7 @@ impl RenderOnce for MenuItemElement {
|
||||
.group(&self.group_name)
|
||||
.gap_x_1()
|
||||
.p_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text)
|
||||
.relative()
|
||||
.items_center()
|
||||
@@ -104,12 +101,12 @@ impl RenderOnce for MenuItemElement {
|
||||
})
|
||||
.when(!self.disabled, |this| {
|
||||
this.group_hover(self.group_name, |this| {
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
this.bg(cx.theme().element_hover)
|
||||
.text_color(cx.theme().text)
|
||||
})
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().secondary_background)
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
this.bg(cx.theme().element_active)
|
||||
.text_color(cx.theme().text)
|
||||
})
|
||||
.when_some(self.on_click, |this, on_click| {
|
||||
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
||||
@@ -118,7 +115,7 @@ impl RenderOnce for MenuItemElement {
|
||||
.on_click(on_click)
|
||||
})
|
||||
})
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_faint))
|
||||
.children(self.children)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,22 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
|
||||
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
|
||||
KeyBinding, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollHandle, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored, div, px, rems,
|
||||
Action, Anchor, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent, Edges,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding,
|
||||
MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Styled, WeakEntity, Window,
|
||||
anchored, div, px, rems,
|
||||
};
|
||||
use gpui_base::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
|
||||
use theme::{ActiveTheme, Side};
|
||||
use theme::{ActiveTheme, Side, TextStyle, Typeset as _};
|
||||
|
||||
use crate::kbd::Kbd;
|
||||
use crate::menu::menu_item::MenuItemElement;
|
||||
use crate::scroll::ScrollableElement;
|
||||
use crate::{ElementExt, Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
||||
use crate::{Disableable, ElementExt, Icon, IconName, Sizable as _, StyledExt, h_flex, v_flex};
|
||||
|
||||
const CONTEXT: &str = "PopupMenu";
|
||||
const MAX_WIDTH: Pixels = px(500.);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||
@@ -28,7 +28,7 @@ pub fn init(cx: &mut App) {
|
||||
]);
|
||||
}
|
||||
|
||||
/// An menu item in a popup menu.
|
||||
/// A menu item in a popup menu.
|
||||
pub enum PopupMenuItem {
|
||||
/// A menu separator item.
|
||||
Separator,
|
||||
@@ -40,26 +40,11 @@ pub enum PopupMenuItem {
|
||||
label: SharedString,
|
||||
disabled: bool,
|
||||
checked: bool,
|
||||
is_link: bool,
|
||||
action: Option<Box<dyn Action>>,
|
||||
// For link item
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
},
|
||||
/// A menu item with custom element render.
|
||||
ElementItem {
|
||||
icon: Option<Icon>,
|
||||
disabled: bool,
|
||||
checked: bool,
|
||||
action: Option<Box<dyn Action>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
},
|
||||
/// A submenu item that opens another popup menu.
|
||||
///
|
||||
/// NOTE: This is only supported when the parent menu is not `scrollable`.
|
||||
Submenu {
|
||||
icon: Option<Icon>,
|
||||
label: SharedString,
|
||||
@@ -80,14 +65,13 @@ impl PopupMenuItem {
|
||||
disabled: false,
|
||||
checked: false,
|
||||
action: None,
|
||||
is_link: false,
|
||||
handler: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new submenu item that opens another popup menu.
|
||||
#[inline]
|
||||
pub fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
|
||||
fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
|
||||
PopupMenuItem::Submenu {
|
||||
icon: None,
|
||||
label: label.into(),
|
||||
@@ -98,27 +82,22 @@ impl PopupMenuItem {
|
||||
|
||||
/// Create a separator menu item.
|
||||
#[inline]
|
||||
pub fn separator() -> Self {
|
||||
fn separator() -> Self {
|
||||
PopupMenuItem::Separator
|
||||
}
|
||||
|
||||
/// Creates a label menu item.
|
||||
/// Create a label menu item.
|
||||
#[inline]
|
||||
pub fn label(label: impl Into<SharedString>) -> Self {
|
||||
fn label(label: impl Into<SharedString>) -> Self {
|
||||
PopupMenuItem::Label(label.into())
|
||||
}
|
||||
|
||||
/// Set the icon for the menu item.
|
||||
///
|
||||
/// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
match &mut self {
|
||||
PopupMenuItem::Item { icon: i, .. } => {
|
||||
*i = Some(icon.into());
|
||||
}
|
||||
PopupMenuItem::ElementItem { icon: i, .. } => {
|
||||
*i = Some(icon.into());
|
||||
}
|
||||
PopupMenuItem::Submenu { icon: i, .. } => {
|
||||
*i = Some(icon.into());
|
||||
}
|
||||
@@ -128,32 +107,19 @@ impl PopupMenuItem {
|
||||
}
|
||||
|
||||
/// Set the action for the menu item.
|
||||
///
|
||||
/// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
|
||||
pub fn action(mut self, action: Box<dyn Action>) -> Self {
|
||||
match &mut self {
|
||||
PopupMenuItem::Item { action: a, .. } => {
|
||||
*a = Some(action);
|
||||
}
|
||||
PopupMenuItem::ElementItem { action: a, .. } => {
|
||||
*a = Some(action);
|
||||
}
|
||||
_ => {}
|
||||
fn action(mut self, action: Box<dyn Action>) -> Self {
|
||||
if let PopupMenuItem::Item { action: a, .. } = &mut self {
|
||||
*a = Some(action);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the disabled state for the menu item.
|
||||
///
|
||||
/// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
match &mut self {
|
||||
PopupMenuItem::Item { disabled: d, .. } => {
|
||||
*d = disabled;
|
||||
}
|
||||
PopupMenuItem::ElementItem { disabled: d, .. } => {
|
||||
*d = disabled;
|
||||
}
|
||||
PopupMenuItem::Submenu { disabled: d, .. } => {
|
||||
*d = disabled;
|
||||
}
|
||||
@@ -162,57 +128,37 @@ impl PopupMenuItem {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set checked state for the menu item.
|
||||
///
|
||||
/// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon.
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
match &mut self {
|
||||
PopupMenuItem::Item { checked: c, .. } => {
|
||||
*c = checked;
|
||||
}
|
||||
PopupMenuItem::ElementItem { checked: c, .. } => {
|
||||
*c = checked;
|
||||
}
|
||||
_ => {}
|
||||
/// Set the checked state for the menu item.
|
||||
fn checked(mut self, checked: bool) -> Self {
|
||||
if let PopupMenuItem::Item { checked: c, .. } = &mut self {
|
||||
*c = checked;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a click handler for the menu item.
|
||||
///
|
||||
/// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
|
||||
pub fn on_click<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
match &mut self {
|
||||
PopupMenuItem::Item { handler: h, .. } => {
|
||||
*h = Some(Rc::new(handler));
|
||||
}
|
||||
PopupMenuItem::ElementItem { handler: h, .. } => {
|
||||
*h = Some(Rc::new(handler));
|
||||
}
|
||||
_ => {}
|
||||
if let PopupMenuItem::Item { handler: h, .. } = &mut self {
|
||||
*h = Some(Rc::new(handler));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_clickable(&self) -> bool {
|
||||
!matches!(self, PopupMenuItem::Separator)
|
||||
&& matches!(
|
||||
self,
|
||||
PopupMenuItem::Item {
|
||||
disabled: false,
|
||||
..
|
||||
} | PopupMenuItem::ElementItem {
|
||||
disabled: false,
|
||||
..
|
||||
} | PopupMenuItem::Submenu {
|
||||
disabled: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
matches!(
|
||||
self,
|
||||
PopupMenuItem::Item {
|
||||
disabled: false,
|
||||
..
|
||||
} | PopupMenuItem::Submenu {
|
||||
disabled: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -220,14 +166,9 @@ impl PopupMenuItem {
|
||||
matches!(self, PopupMenuItem::Separator)
|
||||
}
|
||||
|
||||
fn has_left_icon(&self, check_side: Side) -> bool {
|
||||
fn has_left_icon(&self) -> bool {
|
||||
match self {
|
||||
PopupMenuItem::Item { icon, checked, .. } => {
|
||||
icon.is_some() || (check_side.is_left() && *checked)
|
||||
}
|
||||
PopupMenuItem::ElementItem { icon, checked, .. } => {
|
||||
icon.is_some() || (check_side.is_left() && *checked)
|
||||
}
|
||||
PopupMenuItem::Item { icon, checked, .. } => icon.is_some() || *checked,
|
||||
PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
|
||||
_ => false,
|
||||
}
|
||||
@@ -235,66 +176,38 @@ impl PopupMenuItem {
|
||||
|
||||
#[inline]
|
||||
fn is_checked(&self) -> bool {
|
||||
match self {
|
||||
PopupMenuItem::Item { checked, .. } => *checked,
|
||||
PopupMenuItem::ElementItem { checked, .. } => *checked,
|
||||
_ => false,
|
||||
}
|
||||
matches!(self, PopupMenuItem::Item { checked: true, .. })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PopupMenu {
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
pub(crate) menu_items: Vec<PopupMenuItem>,
|
||||
|
||||
/// The focus handle of Entity to handle actions.
|
||||
pub(crate) action_context: Option<FocusHandle>,
|
||||
|
||||
focus_handle: FocusHandle,
|
||||
menu_items: Vec<PopupMenuItem>,
|
||||
axis: Axis,
|
||||
selected_index: Option<usize>,
|
||||
min_width: Option<Pixels>,
|
||||
max_width: Option<Pixels>,
|
||||
max_height: Option<Pixels>,
|
||||
bounds: Bounds<Pixels>,
|
||||
size: Size,
|
||||
check_side: Side,
|
||||
|
||||
/// The parent menu of this menu, if this is a submenu
|
||||
parent_menu: Option<WeakEntity<Self>>,
|
||||
scrollable: bool,
|
||||
external_link_icon: bool,
|
||||
scroll_handle: ScrollHandle,
|
||||
|
||||
/// This will update on render
|
||||
submenu_anchor: (Anchor, Pixels),
|
||||
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl PopupMenu {
|
||||
pub(crate) fn new(cx: &mut App) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
action_context: None,
|
||||
parent_menu: None,
|
||||
menu_items: Vec::new(),
|
||||
selected_index: None,
|
||||
axis: Axis::Vertical,
|
||||
min_width: None,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
check_side: Side::Left,
|
||||
bounds: Bounds::default(),
|
||||
scrollable: false,
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
external_link_icon: true,
|
||||
size: Size::default(),
|
||||
submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
|
||||
_subscriptions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(
|
||||
pub(crate) fn build(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
|
||||
@@ -302,45 +215,15 @@ impl PopupMenu {
|
||||
cx.new(|cx| f(Self::new(cx), window, cx))
|
||||
}
|
||||
|
||||
/// Set min width of the popup menu, default is 120px
|
||||
pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
|
||||
self.min_width = Some(width.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set max height of the popup menu, default is half of the window height
|
||||
pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
|
||||
self.max_height = Some(height.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the axis of children to horizontal.
|
||||
pub fn horizontal(mut self) -> Self {
|
||||
self.axis = Axis::Horizontal;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the menu to be scrollable to show vertical scrollbar.
|
||||
///
|
||||
/// NOTE: If this is true, the sub-menus will cannot be support.
|
||||
pub fn scrollable(mut self, scrollable: bool) -> Self {
|
||||
self.scrollable = scrollable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add Menu Item
|
||||
pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
|
||||
self.menu_with_disabled(label, action, false)
|
||||
}
|
||||
|
||||
/// Add Menu Item with disabled state
|
||||
pub fn menu_with_disabled(
|
||||
mut self,
|
||||
label: impl Into<SharedString>,
|
||||
action: Box<dyn Action>,
|
||||
disabled: bool,
|
||||
) -> Self {
|
||||
self.add_menu_item(label, None, action, disabled, false);
|
||||
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
|
||||
self.add_menu_item(label, None, action, false, false);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -399,19 +282,7 @@ impl PopupMenu {
|
||||
|
||||
/// Add a Submenu
|
||||
pub fn submenu(
|
||||
self,
|
||||
label: impl Into<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.submenu_with_icon(None, label, window, cx, f)
|
||||
}
|
||||
|
||||
/// Add a Submenu item with icon
|
||||
pub fn submenu_with_icon(
|
||||
mut self,
|
||||
icon: Option<Icon>,
|
||||
label: impl Into<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -423,9 +294,7 @@ impl PopupMenu {
|
||||
view.parent_menu = Some(parent_menu);
|
||||
});
|
||||
|
||||
self.menu_items.push(
|
||||
PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
|
||||
);
|
||||
self.menu_items.push(PopupMenuItem::submenu(label, submenu));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -436,13 +305,6 @@ impl PopupMenu {
|
||||
self
|
||||
}
|
||||
|
||||
/// Use small size, the menu item will have smaller height.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn small(mut self) -> Self {
|
||||
self.size = Size::Small;
|
||||
self
|
||||
}
|
||||
|
||||
fn add_menu_item(
|
||||
&mut self,
|
||||
label: impl Into<SharedString>,
|
||||
@@ -461,7 +323,7 @@ impl PopupMenu {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
|
||||
fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
|
||||
if let Some(ix) = self.selected_index
|
||||
&& let Some(item) = self.menu_items.get(ix)
|
||||
{
|
||||
@@ -489,52 +351,24 @@ impl PopupMenu {
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(index) = self.selected_index {
|
||||
let item = self.menu_items.get(index);
|
||||
match item {
|
||||
Some(PopupMenuItem::Item {
|
||||
handler, action, ..
|
||||
}) => {
|
||||
if let Some(handler) = handler {
|
||||
handler(&ClickEvent::default(), window, cx);
|
||||
} else if let Some(action) = action.as_ref() {
|
||||
self.dispatch_confirm_action(action.as_ref(), window, cx);
|
||||
}
|
||||
|
||||
self.dismiss(&Cancel, window, cx)
|
||||
}
|
||||
Some(PopupMenuItem::ElementItem {
|
||||
handler, action, ..
|
||||
}) => {
|
||||
if let Some(handler) = handler {
|
||||
handler(&ClickEvent::default(), window, cx);
|
||||
} else if let Some(action) = action.as_ref() {
|
||||
self.dispatch_confirm_action(action.as_ref(), window, cx);
|
||||
}
|
||||
self.dismiss(&Cancel, window, cx)
|
||||
}
|
||||
_ => {}
|
||||
if let Some(index) = self.selected_index
|
||||
&& let Some(PopupMenuItem::Item {
|
||||
handler, action, ..
|
||||
}) = self.menu_items.get(index)
|
||||
{
|
||||
if let Some(handler) = handler {
|
||||
handler(&ClickEvent::default(), window, cx);
|
||||
} else if let Some(action) = action.as_ref() {
|
||||
window.dispatch_action(action.boxed_clone(), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_confirm_action(
|
||||
&self,
|
||||
action: &dyn Action,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(context) = self.action_context.as_ref() {
|
||||
context.focus(window, cx);
|
||||
self.dismiss(&Cancel, window, cx)
|
||||
}
|
||||
|
||||
window.dispatch_action(action.boxed_clone(), cx);
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
|
||||
if self.selected_index != Some(ix) {
|
||||
self.selected_index = Some(ix);
|
||||
self.scroll_handle.scroll_to_item(ix);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -675,6 +509,7 @@ impl PopupMenu {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::only_used_in_recursion)]
|
||||
fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.active_submenu().is_some() {
|
||||
return;
|
||||
@@ -682,20 +517,17 @@ impl PopupMenu {
|
||||
|
||||
cx.emit(DismissEvent);
|
||||
|
||||
// Focus back to the previous focused handle.
|
||||
if let Some(action_context) = self.action_context.as_ref() {
|
||||
window.focus(action_context, cx);
|
||||
}
|
||||
|
||||
let Some(parent_menu) = self.parent_menu.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Dismiss parent menu, when this menu is dismissed
|
||||
_ = parent_menu.update(cx, |view, cx| {
|
||||
if let Err(error) = parent_menu.update(cx, |view, cx| {
|
||||
view.selected_index = None;
|
||||
view.dismiss(&Cancel, window, cx);
|
||||
});
|
||||
}) {
|
||||
log::warn!("Failed to dismiss parent menu: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dismiss(
|
||||
@@ -732,16 +564,7 @@ impl PopupMenu {
|
||||
) -> Option<Kbd> {
|
||||
let action = action?;
|
||||
|
||||
match self
|
||||
.action_context
|
||||
.as_ref()
|
||||
.and_then(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
|
||||
{
|
||||
Some(kbd) => Some(kbd),
|
||||
// Fallback to App level key binding
|
||||
None => Kbd::binding_for_action(action.as_ref(), None, window),
|
||||
}
|
||||
.map(|this| {
|
||||
Kbd::binding_for_action(action.as_ref(), None, window).map(|this| {
|
||||
this.p_0()
|
||||
.flex_nowrap()
|
||||
.border_0()
|
||||
@@ -754,7 +577,7 @@ impl PopupMenu {
|
||||
checked: bool,
|
||||
icon: Option<Icon>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
if !has_icon {
|
||||
return None;
|
||||
@@ -763,7 +586,7 @@ impl PopupMenu {
|
||||
let icon = if let Some(icon) = icon {
|
||||
icon.clone()
|
||||
} else if checked {
|
||||
Icon::new(IconName::Check)
|
||||
Icon::new(IconName::Check).text_color(cx.theme().accent)
|
||||
} else {
|
||||
Icon::empty()
|
||||
};
|
||||
@@ -771,16 +594,10 @@ impl PopupMenu {
|
||||
Some(icon)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn max_width(&self) -> Pixels {
|
||||
self.max_width.unwrap_or(px(500.))
|
||||
}
|
||||
|
||||
/// Calculate the anchor corner and left offset for child submenu
|
||||
fn update_submenu_menu_anchor(&mut self, window: &Window) {
|
||||
let bounds = self.bounds;
|
||||
let max_width = self.max_width();
|
||||
let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
|
||||
let (anchor, left) = if MAX_WIDTH + bounds.origin.x > window.bounds().size.width {
|
||||
(Anchor::TopRight, -px(16.))
|
||||
} else {
|
||||
(Anchor::TopLeft, bounds.size.width - px(8.))
|
||||
@@ -803,12 +620,7 @@ impl PopupMenu {
|
||||
cx: &mut Context<Self>,
|
||||
) -> MenuItemElement {
|
||||
let has_left_icon = options.has_left_icon;
|
||||
let is_left_check = options.check_side.is_left() && item.is_checked();
|
||||
let right_check_icon = if options.check_side.is_right() && item.is_checked() {
|
||||
Some(Icon::new(IconName::Check).xsmall())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is_checked = item.is_checked();
|
||||
|
||||
let selected = self.selected_index == Some(ix);
|
||||
const EDGE_PADDING: Pixels = px(4.);
|
||||
@@ -817,17 +629,14 @@ impl PopupMenu {
|
||||
let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
|
||||
let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
|
||||
|
||||
let (item_height, radius) = match self.size {
|
||||
Size::Small => (px(20.), options.radius.half()),
|
||||
_ => (px(26.), options.radius),
|
||||
};
|
||||
let item_height = px(26.);
|
||||
|
||||
let this = MenuItemElement::new(ix, &group_name)
|
||||
.relative()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.py_0()
|
||||
.px(INNER_PADDING)
|
||||
.rounded(radius)
|
||||
.rounded(options.radius)
|
||||
.items_center()
|
||||
.selected(selected)
|
||||
.on_hover(cx.listener(move |this, hovered, _, cx| {
|
||||
@@ -848,45 +657,25 @@ impl PopupMenu {
|
||||
.my_0p5()
|
||||
.mx_neg_1()
|
||||
.border_b(px(2.))
|
||||
.border_color(cx.theme().border)
|
||||
.border_color(cx.theme().border_faint)
|
||||
.disabled(true),
|
||||
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
|
||||
h_flex().cursor_default().items_center().gap_x_1().child(
|
||||
div()
|
||||
.flex_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label.clone()),
|
||||
),
|
||||
),
|
||||
PopupMenuItem::ElementItem {
|
||||
render, disabled, ..
|
||||
} => this
|
||||
.when(!disabled, |this| {
|
||||
this.on_click(
|
||||
cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
|
||||
)
|
||||
})
|
||||
.disabled(*disabled)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_h(item_height)
|
||||
.items_center()
|
||||
.gap_x_2()
|
||||
.child((render)(window, cx))
|
||||
.children(right_check_icon.map(|icon| icon.ml_3())),
|
||||
),
|
||||
PopupMenuItem::Item {
|
||||
icon,
|
||||
label,
|
||||
action,
|
||||
disabled,
|
||||
is_link,
|
||||
..
|
||||
} => {
|
||||
let show_link_icon = *is_link && self.external_link_icon;
|
||||
let action = action.as_ref().map(|action| action.boxed_clone());
|
||||
let key = self.render_key_binding(action, window, cx);
|
||||
|
||||
@@ -897,10 +686,10 @@ impl PopupMenu {
|
||||
})
|
||||
.disabled(*disabled)
|
||||
.h(item_height)
|
||||
.gap_x_2()
|
||||
.gap_x(px(theme::SPACE))
|
||||
.children(Self::render_icon(
|
||||
has_left_icon,
|
||||
is_left_check,
|
||||
is_checked,
|
||||
icon.clone(),
|
||||
window,
|
||||
cx,
|
||||
@@ -911,22 +700,7 @@ impl PopupMenu {
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.when(!show_link_icon, |this| this.child(label.clone()))
|
||||
.children(right_check_icon)
|
||||
.when(show_link_icon, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(label.clone())
|
||||
.child(
|
||||
Icon::new(IconName::Link)
|
||||
.xsmall()
|
||||
.text_color(cx.theme().text_muted),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(label.clone())
|
||||
.children(key),
|
||||
)
|
||||
}
|
||||
@@ -944,7 +718,7 @@ impl PopupMenu {
|
||||
.min_h(item_height)
|
||||
.size_full()
|
||||
.items_center()
|
||||
.gap_x_2()
|
||||
.gap_x(px(theme::SPACE))
|
||||
.children(Self::render_icon(
|
||||
has_left_icon,
|
||||
false,
|
||||
@@ -955,7 +729,7 @@ impl PopupMenu {
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(label.clone())
|
||||
@@ -1002,7 +776,6 @@ impl Focusable for PopupMenu {
|
||||
#[derive(Clone, Copy)]
|
||||
struct RenderOptions {
|
||||
has_left_icon: bool,
|
||||
check_side: Side,
|
||||
radius: Pixels,
|
||||
}
|
||||
|
||||
@@ -1013,21 +786,12 @@ impl Render for PopupMenu {
|
||||
let view = cx.entity().clone();
|
||||
let items_count = self.menu_items.len();
|
||||
|
||||
let max_width = self.max_width();
|
||||
let max_height = self.max_height.unwrap_or_else(|| {
|
||||
let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
|
||||
window_half_height.min(px(450.))
|
||||
});
|
||||
|
||||
let has_left_icon = self
|
||||
.menu_items
|
||||
.iter()
|
||||
.any(|item| item.has_left_icon(self.check_side));
|
||||
let has_left_icon = self.menu_items.iter().any(|item| item.has_left_icon());
|
||||
|
||||
let options = RenderOptions {
|
||||
has_left_icon,
|
||||
check_side: self.check_side,
|
||||
radius: cx.theme().radius.min(px(8.)),
|
||||
// Items sit p_1 (4px) inside the surface plate; keep corners concentric.
|
||||
radius: px(theme::inset_radius(theme::surface_radius(), 4.0)),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
@@ -1051,17 +815,11 @@ impl Render for PopupMenu {
|
||||
.p_1()
|
||||
.gap_y_0p5()
|
||||
.min_w(rems(8.))
|
||||
.when_some(self.min_width, |this, min_width| this.min_w(min_width))
|
||||
.max_w(max_width)
|
||||
.max_w(MAX_WIDTH)
|
||||
.map(|this| match self.axis {
|
||||
Axis::Horizontal => this.flex().flex_row().items_center(),
|
||||
Axis::Vertical => this.flex().flex_col(),
|
||||
})
|
||||
.when(self.scrollable, |this| {
|
||||
this.max_h(max_height)
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
})
|
||||
.children(
|
||||
self.menu_items
|
||||
.iter()
|
||||
@@ -1072,9 +830,5 @@ impl Render for PopupMenu {
|
||||
)
|
||||
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)),
|
||||
)
|
||||
.when(self.scrollable, |this| {
|
||||
// TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
|
||||
this.vertical_scrollbar(&self.scroll_handle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use gpui::{
|
||||
div, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
|
||||
|
||||
@@ -190,9 +190,9 @@ impl RenderOnce for MessageRow {
|
||||
.when(show_author, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_dim)
|
||||
.when_some(author, |this, author| {
|
||||
this.child(div().font_semibold().child(author))
|
||||
})
|
||||
@@ -212,7 +212,7 @@ impl RenderOnce for MessageRow {
|
||||
.when_some(on_double_click, |this, listener| {
|
||||
this.on_double_click(listener)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,6 @@ pub struct WelcomeMessage {
|
||||
icon: Option<AnyElement>,
|
||||
title: Option<SharedString>,
|
||||
message: Option<SharedString>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl WelcomeMessage {
|
||||
@@ -237,7 +236,6 @@ impl WelcomeMessage {
|
||||
icon: None,
|
||||
title: None,
|
||||
message: None,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,13 +259,6 @@ impl WelcomeMessage {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element below the body text.
|
||||
#[must_use]
|
||||
pub fn child(mut self, child: impl IntoElement) -> Self {
|
||||
self.children.push(child.into_any_element());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for WelcomeMessage {
|
||||
@@ -284,13 +275,12 @@ impl RenderOnce for WelcomeMessage {
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
children,
|
||||
} = self;
|
||||
|
||||
v_flex()
|
||||
.id(id)
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.p_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
@@ -305,8 +295,7 @@ impl RenderOnce for WelcomeMessage {
|
||||
.when_some(title, |this, title| {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_style(TextStyle::Headline)
|
||||
.text_color(cx.theme().text)
|
||||
.child(title),
|
||||
)
|
||||
@@ -314,12 +303,11 @@ impl RenderOnce for WelcomeMessage {
|
||||
.when_some(message, |this, message| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(message),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.children(children)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! The motion kit, ported from bezel (`crates/motion/src/lib.rs` and its
|
||||
//! `app.rs`, MIT License, Copyright (c) 2026 bezel contributors). TranslateY
|
||||
//! rides a relative `top` inset, which taffy applies after layout, so
|
||||
//! siblings never move.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationElement, AnimationExt as _, App, Context, ElementId, EntityId, Global,
|
||||
Hsla, IntoElement, Rgba, SharedString, Styled, Window, px,
|
||||
};
|
||||
use instant::Instant;
|
||||
|
||||
/// Redraw rate for anything driven by [`pulse_delta`].
|
||||
const PULSE_FPS: f32 = 30.0;
|
||||
|
||||
/// How long a view stays on the tick list after its last pulse paint.
|
||||
const PULSE_LEASE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Redraw rate for hover fades; [`HOVER_FADE`] paints in nine steps at it.
|
||||
const HOVER_FPS: f32 = 60.0;
|
||||
|
||||
/// Floor on how long the clock sleeps between wake-ups.
|
||||
const MIN_SLEEP: Duration = Duration::from_millis(1);
|
||||
|
||||
/// One view's claim on the clock.
|
||||
struct Lease {
|
||||
/// The fastest rate anything on this view has claimed.
|
||||
period: Duration,
|
||||
/// When this view is next owed a redraw.
|
||||
due: Instant,
|
||||
/// A notify is out and the render it provoked has not renewed yet.
|
||||
in_flight: bool,
|
||||
/// When the claim lapses if nothing renews it.
|
||||
until: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PulseClock {
|
||||
epoch: Option<Instant>,
|
||||
leases: HashMap<EntityId, Lease>,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
impl Global for PulseClock {}
|
||||
|
||||
impl PulseClock {
|
||||
fn next_wake(&self) -> Option<Instant> {
|
||||
self.leases
|
||||
.values()
|
||||
.map(|lease| lease.due.min(lease.until))
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
/// A component's line back to the view that paints it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Painter(EntityId);
|
||||
|
||||
impl Painter {
|
||||
/// The view this render belongs to; take it once where the state is built.
|
||||
pub fn of<T: 'static>(cx: &Context<T>) -> Self {
|
||||
Self(cx.entity_id())
|
||||
}
|
||||
|
||||
/// Redraw this view once.
|
||||
pub fn notify(self, cx: &mut App) {
|
||||
cx.notify(self.0);
|
||||
}
|
||||
|
||||
/// Claim `fps` redraws a second; renew from `render` so an unmounted
|
||||
/// element drops off.
|
||||
pub fn lease(self, fps: f32, until: Duration, cx: &mut App) {
|
||||
lease(self.0, fps, until, cx);
|
||||
}
|
||||
|
||||
/// Whether the clock is what asked for the render running now.
|
||||
pub fn woken(self, cx: &App) -> bool {
|
||||
cx.try_global::<PulseClock>()
|
||||
.and_then(|clock| clock.leases.get(&self.0))
|
||||
.is_some_and(|lease| lease.in_flight)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EntityId> for Painter {
|
||||
fn from(entity_id: EntityId) -> Self {
|
||||
Self(entity_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Painter> for EntityId {
|
||||
fn from(painter: Painter) -> Self {
|
||||
painter.0
|
||||
}
|
||||
}
|
||||
|
||||
fn lease(view: EntityId, fps: f32, until: Duration, cx: &mut App) {
|
||||
if cx.pause_when_inactive() && cx.active_window().is_none() {
|
||||
return;
|
||||
}
|
||||
let now = cx.background_executor().now();
|
||||
let period = Duration::from_secs_f32(1.0 / fps.clamp(1.0, 240.0));
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock
|
||||
.leases
|
||||
.entry(view)
|
||||
.and_modify(|lease| {
|
||||
lease.period = lease.period.min(period);
|
||||
lease.due = lease.due.min(now + period);
|
||||
lease.in_flight = false;
|
||||
lease.until = lease.until.max(now + until);
|
||||
})
|
||||
.or_insert(Lease {
|
||||
period,
|
||||
due: now + period,
|
||||
in_flight: false,
|
||||
until: now + until,
|
||||
});
|
||||
if clock.running {
|
||||
return;
|
||||
}
|
||||
clock.running = true;
|
||||
cx.spawn(async move |cx| {
|
||||
loop {
|
||||
let sleep = cx.update(|cx| {
|
||||
let now = cx.background_executor().now();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock
|
||||
.next_wake()
|
||||
.map(|wake| wake.saturating_duration_since(now).max(MIN_SLEEP))
|
||||
});
|
||||
let Some(sleep) = sleep else { break };
|
||||
cx.background_executor().timer(sleep).await;
|
||||
let parked = cx.update(|cx| {
|
||||
let now = cx.background_executor().now();
|
||||
tick_hover_fades();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock.leases.retain(|_, lease| lease.until > now);
|
||||
if clock.leases.is_empty() {
|
||||
clock.running = false;
|
||||
return true;
|
||||
}
|
||||
let mut owed = Vec::new();
|
||||
for (view, lease) in clock.leases.iter_mut() {
|
||||
if lease.due > now {
|
||||
continue;
|
||||
}
|
||||
lease.due += lease.period;
|
||||
if lease.due <= now {
|
||||
lease.due = now + lease.period;
|
||||
}
|
||||
lease.in_flight = true;
|
||||
owed.push(*view);
|
||||
}
|
||||
for view in owed {
|
||||
cx.notify(view);
|
||||
}
|
||||
false
|
||||
});
|
||||
if parked {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Current phase `[0,1)` of a repeating spec; reduced motion returns a
|
||||
/// static 0 and schedules nothing.
|
||||
pub fn pulse_delta(spec: &MotionSpec, painter: Painter, cx: &mut App) -> f32 {
|
||||
if cx.reduced_motion() {
|
||||
return 0.0;
|
||||
}
|
||||
painter.lease(PULSE_FPS, PULSE_LEASE, cx);
|
||||
let now = cx.background_executor().now();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
let epoch = *clock.epoch.get_or_insert(now);
|
||||
let period = spec.total().as_secs_f32();
|
||||
((now - epoch).as_secs_f32() / period).fract()
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing function, evaluated exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CubicBezier {
|
||||
pub x1: f32,
|
||||
pub y1: f32,
|
||||
pub x2: f32,
|
||||
pub y2: f32,
|
||||
}
|
||||
|
||||
impl CubicBezier {
|
||||
pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
|
||||
Self { x1, y1, x2, y2 }
|
||||
}
|
||||
|
||||
fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
|
||||
let c = 3.0 * a;
|
||||
let bb = 3.0 * (b - a) - c;
|
||||
let aa = 1.0 - c - bb;
|
||||
(aa, bb, c)
|
||||
}
|
||||
|
||||
fn sample_x(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.x1, self.x2);
|
||||
((a * t + b) * t + c) * t
|
||||
}
|
||||
|
||||
fn sample_y(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.y1, self.y2);
|
||||
((a * t + b) * t + c) * t
|
||||
}
|
||||
|
||||
fn sample_x_derivative(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.x1, self.x2);
|
||||
(3.0 * a * t + 2.0 * b) * t + c
|
||||
}
|
||||
|
||||
fn solve_t_for_x(&self, x: f32) -> f32 {
|
||||
let mut t = x;
|
||||
for _ in 0..8 {
|
||||
let err = self.sample_x(t) - x;
|
||||
if err.abs() < 1e-6 {
|
||||
return t;
|
||||
}
|
||||
let d = self.sample_x_derivative(t);
|
||||
if d.abs() < 1e-6 {
|
||||
break;
|
||||
}
|
||||
t -= err / d;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
|
||||
for _ in 0..32 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if self.sample_x(mid) < x {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
(lo + hi) / 2.0
|
||||
}
|
||||
|
||||
/// Eased output for input progress `x ∈ [0,1]`, clamped hard — gpui's
|
||||
/// animation element asserts its delta is in `[0,1]` and aborts.
|
||||
pub fn eval(&self, x: f32) -> f32 {
|
||||
if x <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if x >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// This curve as a gpui easing closure.
|
||||
pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
|
||||
move |x| self.eval(x)
|
||||
}
|
||||
}
|
||||
|
||||
/// The signature entrance curve — CSS `cubic-bezier(0.16, 1, 0.3, 1)`.
|
||||
pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
|
||||
/// CSS `ease-out` — width/height transitions.
|
||||
pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
|
||||
/// CSS `ease` — quick fades, menu/dialog pops.
|
||||
pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
|
||||
/// Sidebar resort glide — CSS `cubic-bezier(0.22, 1, 0.36, 1)`.
|
||||
pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
|
||||
/// CSS `ease-in-out`.
|
||||
pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
|
||||
/// Tailwind's default transition curve — CSS `cubic-bezier(0.4, 0, 0.2, 1)`.
|
||||
pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
|
||||
|
||||
/// One catalog entry: duration + optional delay + curve; gpui has no native
|
||||
/// delay, so `delay_ms` holds progress at 0 until it elapses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MotionSpec {
|
||||
pub duration_ms: u64,
|
||||
pub delay_ms: u64,
|
||||
pub curve: CubicBezier,
|
||||
}
|
||||
|
||||
impl MotionSpec {
|
||||
pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
|
||||
Self {
|
||||
duration_ms,
|
||||
delay_ms: 0,
|
||||
curve,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_delay(mut self, delay_ms: u64) -> Self {
|
||||
self.delay_ms = delay_ms;
|
||||
self
|
||||
}
|
||||
|
||||
/// Wall-clock span of the whole timeline (delay + duration).
|
||||
pub fn total(&self) -> Duration {
|
||||
Duration::from_millis(self.delay_ms + self.duration_ms)
|
||||
}
|
||||
|
||||
/// Eased progress (0..1) for a raw timeline delta (0..1 across `total`).
|
||||
pub fn progress(&self, raw_delta: f32) -> f32 {
|
||||
let total = (self.delay_ms + self.duration_ms) as f32;
|
||||
if total <= 0.0 || self.duration_ms == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let t =
|
||||
(raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
|
||||
self.curve.eval(t.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A oneshot gpui [`Animation`] for this spec.
|
||||
pub fn animation(&self) -> Animation {
|
||||
let spec = *self;
|
||||
Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entrances: 0.5s expo-out fade + 4px rise.
|
||||
pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
|
||||
/// Quick fade: 0.15s.
|
||||
pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
|
||||
/// Popover-in: 0.14s.
|
||||
pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
|
||||
/// Popover-out: 0.1s — quicker than the entrance.
|
||||
pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
|
||||
/// Dialog-in: 0.18s.
|
||||
pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
|
||||
/// Boot-splash exit: 0.5s fade + 6px lift after a 0.15s hold.
|
||||
pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
|
||||
/// Sidebar / pane width+height transitions: 200ms ease-out.
|
||||
pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
|
||||
/// Tab drag-reorder sliding transforms: 150ms.
|
||||
pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
|
||||
/// Per-pane collapse: 180ms height.
|
||||
pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
|
||||
/// A gliding move the reader caused rather than one they are dragging.
|
||||
pub const LAYOUT: MotionSpec = MotionSpec::new(200, EASE_OUT);
|
||||
/// Chevron rotate: 200ms.
|
||||
pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
|
||||
/// Scroll-to-row glide: 500ms ease-in-out.
|
||||
pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
|
||||
/// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`].
|
||||
pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
|
||||
/// Toast slide/fade, coop's own: 250ms over [`EASE_TAILWIND`].
|
||||
pub const NOTIFICATION_IN: MotionSpec = MotionSpec::new(250, EASE_TAILWIND);
|
||||
/// Pulse loader period: 2.4s.
|
||||
pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
|
||||
/// Spinner wave period: 750ms.
|
||||
pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
|
||||
|
||||
/// Standard entrance: opacity 0→1 + translateY 4→0 over [`FADE_IN`].
|
||||
pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, FADE_IN.animation(), |el, t| {
|
||||
el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Quick opacity-only fade over [`FADE_QUICK`].
|
||||
pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
|
||||
}
|
||||
|
||||
/// Popover entrance: fade + translateY −2→0 over [`MENU_IN`].
|
||||
pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, MENU_IN.animation(), |el, t| {
|
||||
el.relative()
|
||||
.opacity(0.3 + 0.7 * t)
|
||||
.top(px(-2.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Popover exit over [`MENU_OUT`]; the caller supplies progress because
|
||||
/// `with_animation` replays from 0 on remount.
|
||||
pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, MENU_OUT.animation(), move |el, _| {
|
||||
el.relative().opacity(1.0 - t).top(px(-2.0 * t))
|
||||
})
|
||||
}
|
||||
|
||||
/// Dialog entrance over [`DIALOG_IN`] (fade + 2px rise).
|
||||
pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, DIALOG_IN.animation(), |el, t| {
|
||||
el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Boot-splash exit: hold 150ms, then fade out + lift 6px over 500ms.
|
||||
pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
|
||||
el.opacity(1.0 - t).top(px(-6.0 * t))
|
||||
})
|
||||
}
|
||||
|
||||
/// Linear interpolation.
|
||||
pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
|
||||
from + (to - from) * t
|
||||
}
|
||||
|
||||
/// One element's hover fade progress over [`HOVER_FADE`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FadeEntry {
|
||||
origin: f32,
|
||||
target: f32,
|
||||
started: Instant,
|
||||
seen: u64,
|
||||
}
|
||||
|
||||
impl FadeEntry {
|
||||
fn value(&self, now: Instant, duration: Duration) -> f32 {
|
||||
let elapsed = now.saturating_duration_since(self.started);
|
||||
if duration.is_zero() || elapsed >= duration {
|
||||
return self.target;
|
||||
}
|
||||
let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||
lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
|
||||
}
|
||||
|
||||
fn settled(&self, now: Instant, duration: Duration) -> bool {
|
||||
self.origin == self.target || now.saturating_duration_since(self.started) >= duration
|
||||
}
|
||||
}
|
||||
|
||||
/// Which fade: the view that paints it plus the element key — two views
|
||||
/// using the same string would trade washes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Fade {
|
||||
pub painter: Painter,
|
||||
pub key: SharedString,
|
||||
}
|
||||
|
||||
impl Fade {
|
||||
pub fn new(painter: Painter, key: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
painter,
|
||||
key: key.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-fade progress store.
|
||||
#[derive(Default)]
|
||||
pub struct HoverFades {
|
||||
pub entries: HashMap<Fade, FadeEntry>,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl HoverFades {
|
||||
pub fn duration() -> Duration {
|
||||
HOVER_FADE.total().mul_f32(speed_scale())
|
||||
}
|
||||
|
||||
/// Pointer entered (`hovered`) or left the element behind `fade`.
|
||||
pub fn set_at(&mut self, fade: &Fade, hovered: bool, reduced: bool, now: Instant) {
|
||||
let target = if hovered { 1.0 } else { 0.0 };
|
||||
let duration = Self::duration();
|
||||
let current = self
|
||||
.entries
|
||||
.get(fade)
|
||||
.map(|e| e.value(now, duration))
|
||||
.unwrap_or(0.0);
|
||||
if target == 0.0 && !self.entries.contains_key(fade) {
|
||||
return;
|
||||
}
|
||||
let origin = if reduced { target } else { current };
|
||||
let seen = self.frame;
|
||||
self.entries.insert(
|
||||
fade.clone(),
|
||||
FadeEntry {
|
||||
origin,
|
||||
target,
|
||||
started: now,
|
||||
seen,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Hover progress (0..1) for `fade` at `now`; stamps liveness.
|
||||
pub fn value_at(&mut self, fade: &Fade, now: Instant) -> f32 {
|
||||
let frame = self.frame;
|
||||
match self.entries.get_mut(fade) {
|
||||
Some(entry) => {
|
||||
entry.seen = frame;
|
||||
entry.value(now, Self::duration())
|
||||
}
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the frame counter and prune settled or unmounted entries.
|
||||
pub fn tick_at(&mut self, now: Instant) -> bool {
|
||||
self.frame += 1;
|
||||
let frame = self.frame;
|
||||
let duration = Self::duration();
|
||||
let mut active = false;
|
||||
self.entries.retain(|_, entry| {
|
||||
if entry.seen + 1 < frame {
|
||||
return false;
|
||||
}
|
||||
let settled = entry.settled(now, duration);
|
||||
if !settled {
|
||||
active = true;
|
||||
}
|
||||
!(settled && entry.target == 0.0)
|
||||
});
|
||||
active
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
|
||||
}
|
||||
|
||||
/// Hover progress (0..1) for `fade` this frame.
|
||||
pub fn hover_t(fade: &Fade) -> f32 {
|
||||
HOVER_FADES.with(|fades| fades.borrow_mut().value_at(fade, Instant::now()))
|
||||
}
|
||||
|
||||
/// Record a hover flip for `fade`; prefer [`hover_listener`].
|
||||
pub fn set_hover(fade: &Fade, hovered: bool, reduced: bool) {
|
||||
HOVER_FADES.with(|fades| {
|
||||
fades
|
||||
.borrow_mut()
|
||||
.set_at(fade, hovered, reduced, Instant::now())
|
||||
});
|
||||
}
|
||||
|
||||
/// An `.on_hover` listener driving the fade.
|
||||
pub fn hover_listener(fade: Fade) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||
move |hovered, _window, cx| {
|
||||
set_hover(&fade, *hovered, cx.reduced_motion());
|
||||
fade.painter.lease(HOVER_FPS, HoverFades::duration(), cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once-per-tick bookkeeping for the fade store, driven by the clock.
|
||||
fn tick_hover_fades() {
|
||||
HOVER_FADES.with(|fades| {
|
||||
fades.borrow_mut().tick_at(Instant::now());
|
||||
});
|
||||
}
|
||||
|
||||
/// Blend two colors by `t` the way the browser transitions them: sRGB with
|
||||
/// premultiplied alpha, so a wash fading in never passes through grey.
|
||||
pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t <= 0.0 {
|
||||
return from;
|
||||
}
|
||||
if t >= 1.0 {
|
||||
return to;
|
||||
}
|
||||
let (f, g) = (Rgba::from(from), Rgba::from(to));
|
||||
let a = lerp(f.a, g.a, t);
|
||||
if a <= f32::EPSILON {
|
||||
return Hsla::from(Rgba { a: 0.0, ..g });
|
||||
}
|
||||
Hsla::from(Rgba {
|
||||
r: lerp(f.r * f.a, g.r * g.a, t) / a,
|
||||
g: lerp(f.g * f.a, g.g * g.a, t) / a,
|
||||
b: lerp(f.b * f.a, g.b * g.a, t) / a,
|
||||
a,
|
||||
})
|
||||
}
|
||||
|
||||
/// The standard hover blend: rest → hover color at this fade's progress.
|
||||
pub fn hover_blend(fade: &Fade, rest: Hsla, hover: Hsla) -> Hsla {
|
||||
mix(rest, hover, hover_t(fade))
|
||||
}
|
||||
|
||||
/// Process-wide motion speed, as raw `f32` bits; an atomic mirror because the
|
||||
/// timelines are read from free functions with no `cx` in scope.
|
||||
static SPEED: AtomicU32 = AtomicU32::new(1.0f32.to_bits());
|
||||
|
||||
/// How far every timeline in the catalog is stretched. `1.0` is the designed
|
||||
/// speed.
|
||||
pub fn speed_scale() -> f32 {
|
||||
f32::from_bits(SPEED.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Stretch every catalog timeline by `scale`; clamped, and a non-finite
|
||||
/// `scale` resets to `1.0`.
|
||||
pub fn set_speed(scale: f32) {
|
||||
let scale = if scale.is_finite() {
|
||||
scale.clamp(0.01, 100.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
SPEED.store(scale.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Process-wide motion switches.
|
||||
#[derive(Clone, Copy)]
|
||||
struct MotionSettings {
|
||||
reduced_motion: bool,
|
||||
pause_when_inactive: bool,
|
||||
}
|
||||
|
||||
impl Global for MotionSettings {}
|
||||
|
||||
impl MotionSettings {
|
||||
/// Defaults: the OS flag rules reduce motion; the clock pauses when no
|
||||
/// window is active.
|
||||
fn get(cx: &App) -> Self {
|
||||
cx.try_global::<MotionSettings>().copied().unwrap_or(Self {
|
||||
reduced_motion: false,
|
||||
pause_when_inactive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// App-level motion switches.
|
||||
pub trait AppExt {
|
||||
/// The OS reduce-motion flag ORed with the manual override.
|
||||
fn reduced_motion(&self) -> bool;
|
||||
|
||||
/// Set the manual reduce-motion override.
|
||||
fn set_reduced_motion(&mut self, reduced: bool);
|
||||
|
||||
/// Whether the leased clock claims frames while no window is active.
|
||||
fn pause_when_inactive(&self) -> bool;
|
||||
|
||||
fn set_pause_when_inactive(&mut self, pause: bool);
|
||||
}
|
||||
|
||||
impl AppExt for App {
|
||||
fn reduced_motion(&self) -> bool {
|
||||
self.reduce_motion() || MotionSettings::get(self).reduced_motion
|
||||
}
|
||||
|
||||
fn set_reduced_motion(&mut self, reduced: bool) {
|
||||
let mut settings = MotionSettings::get(self);
|
||||
settings.reduced_motion = reduced;
|
||||
self.set_global(settings);
|
||||
}
|
||||
|
||||
fn pause_when_inactive(&self) -> bool {
|
||||
MotionSettings::get(self).pause_when_inactive
|
||||
}
|
||||
|
||||
fn set_pause_when_inactive(&mut self, pause: bool) {
|
||||
let mut settings = MotionSettings::get(self);
|
||||
settings.pause_when_inactive = pause;
|
||||
self.set_global(settings);
|
||||
}
|
||||
}
|
||||
+12
-9
@@ -4,7 +4,7 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled, Window, div,
|
||||
Styled, Window, div, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -44,8 +44,7 @@ impl Nav {
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the row as clickable even when it has no `on_click` handler, so it
|
||||
/// still shows the hover affordance. Used by rows whose click opens a menu.
|
||||
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
|
||||
pub fn clickable(mut self, clickable: bool) -> Self {
|
||||
self.clickable = clickable;
|
||||
self
|
||||
@@ -104,15 +103,21 @@ impl Styled for Nav {
|
||||
impl RenderOnce for Nav {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let clickable = self.on_click.is_some() || self.clickable;
|
||||
let text_color = if self.selected {
|
||||
cx.theme().text
|
||||
} else {
|
||||
cx.theme().text_muted
|
||||
};
|
||||
|
||||
v_flex().h_10().w_full().items_center().child(
|
||||
v_flex().h_10().w_full().justify_center().child(
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.rounded(px(theme::button_radius()))
|
||||
.text_color(text_color)
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -126,10 +131,8 @@ impl RenderOnce for Nav {
|
||||
)
|
||||
.when(clickable, |this| {
|
||||
this.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().ghost_element_active)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.when(self.selected, |this| this.bg(cx.theme().element_active))
|
||||
})
|
||||
.when_some(self.on_click, |this, handler| {
|
||||
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||
|
||||
+14
-20
@@ -4,9 +4,9 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled, Window, div,
|
||||
Styled, Window, div, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
use crate::{Selectable, StyledExt, h_flex};
|
||||
|
||||
@@ -21,7 +21,6 @@ pub struct NavItem {
|
||||
label: SharedString,
|
||||
suffix: Option<AnyElement>,
|
||||
selected: bool,
|
||||
clickable: bool,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
@@ -43,18 +42,10 @@ impl NavItem {
|
||||
label: label.into(),
|
||||
suffix: None,
|
||||
selected: false,
|
||||
clickable: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the item as clickable even when it has no `on_click` handler, so it
|
||||
/// still shows the hover affordance. Used by items whose click opens a menu.
|
||||
pub fn clickable(mut self, clickable: bool) -> Self {
|
||||
self.clickable = clickable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
@@ -94,7 +85,12 @@ impl Styled for NavItem {
|
||||
|
||||
impl RenderOnce for NavItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let clickable = self.on_click.is_some() || self.clickable;
|
||||
let clickable = self.on_click.is_some();
|
||||
let text_color = if self.selected {
|
||||
cx.theme().text
|
||||
} else {
|
||||
cx.theme().text_muted
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
@@ -102,16 +98,16 @@ impl RenderOnce for NavItem {
|
||||
.px_2()
|
||||
.py_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().text)
|
||||
.gap(px(theme::SPACE))
|
||||
.rounded(px(theme::button_radius()))
|
||||
.text_color(text_color)
|
||||
.child(self.icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(self.label),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
@@ -119,10 +115,8 @@ impl RenderOnce for NavItem {
|
||||
})
|
||||
.when(clickable, |this| {
|
||||
this.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().ghost_element_active)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.when(self.selected, |this| this.bg(cx.theme().element_active))
|
||||
})
|
||||
.when_some(self.on_click, |this, handler| {
|
||||
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||
|
||||
@@ -5,19 +5,19 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
|
||||
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||
Subscription, Window, div, px, relative,
|
||||
Anchor, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
|
||||
ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription,
|
||||
Window, div, px, relative,
|
||||
};
|
||||
use gpui_base::{
|
||||
Toast as BaseToast, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState,
|
||||
ToastTransitionStatus,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
use crate::animation::cubic_bezier;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::motion::NOTIFICATION_IN;
|
||||
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// How often the notification lifecycle clock is sampled.
|
||||
@@ -40,20 +40,13 @@ pub enum NotificationKind {
|
||||
|
||||
impl NotificationKind {
|
||||
fn icon(&self, cx: &App) -> Icon {
|
||||
match self {
|
||||
Self::Info => Icon::new(IconName::Info)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().icon),
|
||||
Self::Success => Icon::new(IconName::CheckCircle)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().icon_accent),
|
||||
Self::Warning => Icon::new(IconName::Warning)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().text_warning),
|
||||
Self::Error => Icon::new(IconName::CloseCircle)
|
||||
.with_size(Size::Medium)
|
||||
.text_color(cx.theme().danger_foreground),
|
||||
}
|
||||
let (name, color) = match self {
|
||||
Self::Info => (IconName::Info, cx.theme().text_muted),
|
||||
Self::Success => (IconName::CheckCircle, cx.theme().success),
|
||||
Self::Warning => (IconName::Warning, cx.theme().warning),
|
||||
Self::Error => (IconName::CloseCircle, gpui::white()),
|
||||
};
|
||||
Icon::new(name).with_size(Size::Medium).text_color(color)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +71,7 @@ impl From<(TypeId, ElementId)> for NotificationId {
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// A notification element.
|
||||
pub struct Notification {
|
||||
/// The id is used make the notification unique.
|
||||
/// Then you push a notification with the same id, the previous notification will be replaced.
|
||||
///
|
||||
/// None means the notification will be added to the end of the list.
|
||||
/// Pushing a notification with the same id replaces the previous one.
|
||||
id: NotificationId,
|
||||
style: StyleRefinement,
|
||||
kind: Option<NotificationKind>,
|
||||
@@ -128,9 +118,7 @@ impl From<(NotificationKind, SharedString)> for Notification {
|
||||
struct DefaultIdType;
|
||||
|
||||
impl Notification {
|
||||
/// Create a new notification.
|
||||
///
|
||||
/// The default id is a random UUID.
|
||||
/// Creates a notification with a random UUID id.
|
||||
pub fn new() -> Self {
|
||||
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
|
||||
let id = (TypeId::of::<DefaultIdType>(), id.into());
|
||||
@@ -177,12 +165,7 @@ impl Notification {
|
||||
.with_kind(NotificationKind::Error)
|
||||
}
|
||||
|
||||
/// Set the type for unique identification of the notification.
|
||||
///
|
||||
/// ```rs
|
||||
/// struct MyNotificationKind;
|
||||
/// let notification = Notification::new("Hello").id::<MyNotificationKind>();
|
||||
/// ```
|
||||
/// Sets the type used to uniquely identify the notification.
|
||||
pub fn id<T: Sized + 'static>(mut self) -> Self {
|
||||
self.id = TypeId::of::<T>().into();
|
||||
self
|
||||
@@ -194,15 +177,13 @@ impl Notification {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the title of the notification, default is None.
|
||||
///
|
||||
/// If title is None, the notification will not have a title.
|
||||
/// Sets the title of the notification, default is None.
|
||||
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the type of the notification, default is NotificationType::Info.
|
||||
/// Sets the kind of the notification, default is `NotificationKind::Info`.
|
||||
pub fn with_kind(mut self, kind: NotificationKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
@@ -214,9 +195,7 @@ impl Notification {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the action button of the notification.
|
||||
///
|
||||
/// When an action is set, the notification will not autohide.
|
||||
/// Sets an action button; the notification will not autohide when set.
|
||||
pub fn action<F>(mut self, action: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
|
||||
@@ -226,8 +205,8 @@ impl Notification {
|
||||
self
|
||||
}
|
||||
|
||||
/// Dismiss the notification.
|
||||
pub fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
/// Emits a request for the notification list to dismiss this notification.
|
||||
pub(crate) fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissRequest);
|
||||
}
|
||||
|
||||
@@ -300,12 +279,14 @@ impl Render for Notification {
|
||||
};
|
||||
|
||||
let background = match self.kind {
|
||||
Some(NotificationKind::Error) => cx.theme().danger_background,
|
||||
_ => cx.theme().surface_background,
|
||||
Some(NotificationKind::Error) => cx.theme().danger_strong,
|
||||
_ => cx.theme().surface_overlay,
|
||||
};
|
||||
|
||||
let text_color = match self.kind {
|
||||
Some(NotificationKind::Error) => cx.theme().danger_foreground,
|
||||
// White on the danger plate in both appearances, per bezel's
|
||||
// destructive pairing.
|
||||
Some(NotificationKind::Error) => gpui::white(),
|
||||
_ => cx.theme().text,
|
||||
};
|
||||
|
||||
@@ -326,10 +307,12 @@ impl Render for Notification {
|
||||
.border_color(cx.theme().border)
|
||||
.bg(background)
|
||||
.text_color(text_color)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.rounded(px(theme::surface_radius()))
|
||||
.when(cx.theme().shadow, |this| {
|
||||
this.shadow(theme::surface_shadows())
|
||||
})
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.justify_start()
|
||||
.items_start()
|
||||
.when(only_message, |this| this.items_center())
|
||||
@@ -343,12 +326,12 @@ impl Render for Notification {
|
||||
.gap_1()
|
||||
.overflow_hidden()
|
||||
.when_some(self.title.clone(), |this, title| {
|
||||
this.child(h_flex().h_5().text_sm().font_semibold().child(title))
|
||||
this.child(h_flex().h_5().text_style(TextStyle::Headline).child(title))
|
||||
})
|
||||
.when_some(self.message.clone(), |this, message| {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.when(has_title, |this| this.text_color(cx.theme().text_muted))
|
||||
.line_height(relative(1.3))
|
||||
.child(message),
|
||||
@@ -356,7 +339,7 @@ impl Render for Notification {
|
||||
})
|
||||
.when_some(content, |this, content| this.child(content))
|
||||
.when_some(action, |this, action| {
|
||||
this.gap_2().child(
|
||||
this.gap(px(theme::SPACE)).child(
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.w_full()
|
||||
@@ -396,15 +379,11 @@ impl Render for Notification {
|
||||
}))
|
||||
.with_animation(
|
||||
ElementId::NamedInteger("slide-down".into(), closing as u64),
|
||||
Animation::new(Duration::from_secs_f64(0.25))
|
||||
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
|
||||
NOTIFICATION_IN.animation(),
|
||||
move |this, delta| {
|
||||
if closing {
|
||||
let opacity = 1. - delta;
|
||||
let that = this
|
||||
.shadow_none()
|
||||
.opacity(opacity)
|
||||
.when(opacity < 0.85, |this| this.shadow_none());
|
||||
let that = this.shadow_none().opacity(opacity);
|
||||
match placement {
|
||||
Anchor::TopRight | Anchor::BottomRight => {
|
||||
let x_offset = px(0.) + delta * px(45.);
|
||||
@@ -446,8 +425,7 @@ impl Render for Notification {
|
||||
|
||||
/// A list of notifications.
|
||||
pub(crate) struct NotificationList {
|
||||
/// Notifications that will be auto hidden.
|
||||
pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
|
||||
notifications: ToastManager<NotificationId, Entity<Notification>>,
|
||||
|
||||
/// Measured geometry and interaction state of the visible stack.
|
||||
stack_state: ToastStackState,
|
||||
@@ -455,12 +433,11 @@ pub(crate) struct NotificationList {
|
||||
/// Whether the lifecycle clock is running. The loop clears it as it exits.
|
||||
is_advancing: bool,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: HashMap<NotificationId, Subscription>,
|
||||
}
|
||||
|
||||
impl NotificationList {
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
||||
pub(crate) fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
notifications: ToastManager::new(ToastMotion::default()),
|
||||
stack_state: ToastStackState::default(),
|
||||
@@ -469,10 +446,7 @@ impl NotificationList {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tick the toast lifecycle until the last notification is unmounted.
|
||||
///
|
||||
/// The stack expansion is sampled here because it reaches the list through
|
||||
/// no event, and an idle window should arm no timer.
|
||||
/// Ticks the toast lifecycle, including stack expansion which arrives through no event.
|
||||
fn start_advancing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.is_advancing {
|
||||
return;
|
||||
@@ -520,7 +494,7 @@ impl NotificationList {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
pub(crate) fn push(
|
||||
&mut self,
|
||||
notification: impl Into<Notification>,
|
||||
window: &mut Window,
|
||||
@@ -575,18 +549,6 @@ impl NotificationList {
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
for id in self
|
||||
.notifications
|
||||
.dismiss_all(cx.background_executor().now())
|
||||
{
|
||||
if let Some(note) = self.notifications.get(&id) {
|
||||
note.update(cx, |note, cx| note.begin_close(cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NotificationList {
|
||||
|
||||
+34
-52
@@ -6,13 +6,13 @@ use gpui::{
|
||||
MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
|
||||
};
|
||||
use gpui_base::Popover as BasePopover;
|
||||
pub use gpui_base::PopoverState;
|
||||
pub(crate) use gpui_base::PopoverState;
|
||||
|
||||
use crate::{Selectable, StyledExt as _, v_flex};
|
||||
|
||||
/// A popover element that can be triggered by a button or any other element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Popover {
|
||||
pub(crate) struct Popover {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
@@ -26,8 +26,7 @@ pub struct Popover {
|
||||
>,
|
||||
>,
|
||||
children: Vec<AnyElement>,
|
||||
/// Style for trigger element.
|
||||
/// This is used for hotfix the trigger element style to support w_full.
|
||||
/// Style refinement applied to the trigger element.
|
||||
trigger_style: Option<StyleRefinement>,
|
||||
mouse_button: MouseButton,
|
||||
appearance: bool,
|
||||
@@ -35,8 +34,8 @@ pub struct Popover {
|
||||
}
|
||||
|
||||
impl Popover {
|
||||
/// Create a new Popover with `view` mode.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
/// Creates a new Popover with the given id.
|
||||
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
@@ -51,23 +50,20 @@ impl Popover {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the anchor corner of the popover, default is `Corner::TopLeft`.
|
||||
///
|
||||
/// This method is kept for backward compatibility with `Corner` type.
|
||||
/// Internally, it converts `Corner` to `Anchor`.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
/// Sets the anchor corner of the popover, default is `Anchor::TopLeft`.
|
||||
pub(crate) fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
|
||||
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
|
||||
/// Sets the mouse button that triggers the popover, default is `MouseButton::Left`.
|
||||
pub(crate) fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
|
||||
self.mouse_button = mouse_button;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the trigger element of the popover.
|
||||
pub fn trigger<T>(mut self, trigger: T) -> Self
|
||||
/// Sets the trigger element, marked as selected while the popover is open.
|
||||
pub(crate) fn trigger<T>(mut self, trigger: T) -> Self
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
@@ -78,12 +74,8 @@ impl Popover {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the trigger from a builder, for elements that have no selected state.
|
||||
///
|
||||
/// [`Self::trigger`] marks the trigger as selected while the popover is
|
||||
/// open, so it cannot be used with elements whose selection carries a
|
||||
/// different meaning, such as a row that indicates the current room.
|
||||
pub fn trigger_with<F>(mut self, trigger: F) -> Self
|
||||
/// Sets the trigger from a builder, for elements that have no selected state.
|
||||
pub(crate) fn trigger_with<F>(mut self, trigger: F) -> Self
|
||||
where
|
||||
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
|
||||
{
|
||||
@@ -91,23 +83,20 @@ impl Popover {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the style for the trigger element.
|
||||
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||
/// Sets the style for the trigger element.
|
||||
pub(crate) fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||
self.trigger_style = Some(style);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether clicking outside the popover will dismiss it, default is `true`.
|
||||
pub fn overlay_closable(mut self, closable: bool) -> Self {
|
||||
/// Sets whether clicking outside the popover dismisses it, default is `true`.
|
||||
pub(crate) fn overlay_closable(mut self, closable: bool) -> Self {
|
||||
self.overlay_closable = closable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the content builder for content of the Popover.
|
||||
///
|
||||
/// This callback will called every time on render the popover.
|
||||
/// So, you should avoid creating new elements or entities in the content closure.
|
||||
pub fn content<F, E>(mut self, content: F) -> Self
|
||||
/// Sets the content builder, called on every render of the popover.
|
||||
pub(crate) fn content<F, E>(mut self, content: F) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
|
||||
@@ -118,32 +107,13 @@ impl Popover {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether the popover no style, default is `false`.
|
||||
///
|
||||
/// If no style:
|
||||
///
|
||||
/// - The popover will not have a bg, border, shadow, or padding.
|
||||
/// - The click out of the popover will not dismiss it.
|
||||
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||
/// Sets whether the popover renders with default styling, default is `true`.
|
||||
pub(crate) fn appearance(mut self, appearance: bool) -> Self {
|
||||
self.appearance = appearance;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Popover {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Popover {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Popover {
|
||||
pub(crate) fn render_popover_content(
|
||||
fn render_popover_content(
|
||||
anchor: Anchor,
|
||||
appearance: bool,
|
||||
_: &mut Window,
|
||||
@@ -162,6 +132,18 @@ impl Popover {
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Popover {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Popover {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Popover {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let anchor = self.anchor;
|
||||
|
||||
@@ -12,15 +12,15 @@ pub(crate) fn resize_handle_appearance() -> ResizeHandleRenderer {
|
||||
Rc::new(
|
||||
|context: &ResizeHandleContext, _: &mut Window, cx: &mut App| {
|
||||
let color = if context.is_active() {
|
||||
cx.theme().border_selected
|
||||
cx.theme().border_strong
|
||||
} else {
|
||||
cx.theme().border
|
||||
cx.theme().element_hover
|
||||
};
|
||||
let axis = context.axis();
|
||||
|
||||
Some(
|
||||
div()
|
||||
.bg(cx.theme().border_variant)
|
||||
.bg(cx.theme().border_faint)
|
||||
.group_hover("handle", move |this| this.bg(color))
|
||||
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
|
||||
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
|
||||
|
||||
+9
-27
@@ -4,14 +4,14 @@ use std::rc::Rc;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, ElementId, Entity,
|
||||
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
|
||||
Window, canvas, div, point, px, size,
|
||||
FocusHandle, HitboxBehavior, InteractiveElement, IntoElement, MouseButton, ParentElement as _,
|
||||
Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle, Window, canvas, div,
|
||||
point, px, size,
|
||||
};
|
||||
use gpui_base::TextSelectionLayer;
|
||||
use theme::{
|
||||
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
|
||||
CLIENT_SIDE_DECORATION_SHADOW,
|
||||
CLIENT_SIDE_DECORATION_SHADOW, base_text_size, surface_shadows,
|
||||
};
|
||||
|
||||
use crate::dialog::Dialog;
|
||||
@@ -230,19 +230,12 @@ impl Root {
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Clear all notifications from the notification layer.
|
||||
pub fn clear_notifications(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) {
|
||||
self.notification
|
||||
.update(cx, |view, cx| view.clear(window, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Root {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let rem_size = cx.theme().font_size;
|
||||
let font_family = cx.theme().font_family.clone();
|
||||
let rem_size = px(base_text_size());
|
||||
let font_family = cx.theme().font_sans.clone();
|
||||
let decorations = window.window_decorations();
|
||||
|
||||
// Set the base font size
|
||||
@@ -361,19 +354,8 @@ impl Render for Root {
|
||||
.when(!tiling.right, |div| {
|
||||
div.border_r(CLIENT_SIDE_DECORATION_BORDER)
|
||||
})
|
||||
.when(!tiling.is_tiled(), |div| {
|
||||
div.shadow(vec![gpui::BoxShadow {
|
||||
color: Hsla {
|
||||
h: 0.,
|
||||
s: 0.,
|
||||
l: 0.,
|
||||
a: 0.4,
|
||||
},
|
||||
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
|
||||
spread_radius: px(0.),
|
||||
offset: point(px(0.0), px(0.0)),
|
||||
inset: false,
|
||||
}])
|
||||
.when(!tiling.is_tiled() && cx.theme().shadow, |div| {
|
||||
div.shadow(surface_shadows())
|
||||
}),
|
||||
})
|
||||
.on_mouse_move(|_e, _, cx| {
|
||||
@@ -381,7 +363,7 @@ impl Render for Root {
|
||||
})
|
||||
.size_full()
|
||||
.font_family(font_family)
|
||||
.bg(cx.theme().surface_background)
|
||||
.bg(cx.theme().bg)
|
||||
.text_color(cx.theme().text)
|
||||
.child(TextSelectionLayer)
|
||||
.child(self.view.clone()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod scrollable;
|
||||
|
||||
pub use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
|
||||
pub use gpui_base::Scrollbar;
|
||||
pub use scrollable::*;
|
||||
|
||||
@@ -3,35 +3,14 @@ use std::panic::Location;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px,
|
||||
ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
|
||||
};
|
||||
use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
|
||||
use theme::ActiveTheme as _;
|
||||
use gpui_base::{Scrollbar, ScrollbarAxis};
|
||||
|
||||
use crate::StyledExt as _;
|
||||
|
||||
/// A trait for elements that can be made scrollable with scrollbars.
|
||||
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
|
||||
/// Adds a scrollbar to the element.
|
||||
#[track_caller]
|
||||
fn scrollbar<H: ScrollbarHandle + Clone>(
|
||||
self,
|
||||
scroll_handle: &H,
|
||||
axis: impl Into<ScrollbarAxis>,
|
||||
) -> Self {
|
||||
self.child(ScrollbarLayer {
|
||||
id: "scrollbar_layer".into(),
|
||||
axis: axis.into(),
|
||||
scroll_handle: scroll_handle.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a vertical scrollbar to the element.
|
||||
#[track_caller]
|
||||
fn vertical_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
|
||||
self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
|
||||
}
|
||||
|
||||
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
|
||||
#[track_caller]
|
||||
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
|
||||
@@ -39,72 +18,6 @@ pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Eleme
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrollbar the application's theme describes: a 10px rail holding a 6px
|
||||
/// rounded thumb that grows to 8px under the pointer.
|
||||
fn scrollbar<H: ScrollbarHandle + Clone>(
|
||||
scroll_handle: &H,
|
||||
axis: ScrollbarAxis,
|
||||
cx: &App,
|
||||
) -> Scrollbar {
|
||||
let theme = cx.theme();
|
||||
let (thumb_width, thumb_radius) = if theme.scrollbar_mode.is_scrolling() {
|
||||
(px(6.), px(3.))
|
||||
} else {
|
||||
(px(8.), px(4.))
|
||||
};
|
||||
|
||||
Scrollbar::new(scroll_handle).axis(axis).styles(|styles| {
|
||||
styles
|
||||
.track(|track| {
|
||||
track
|
||||
.bg(theme.scrollbar_track_background)
|
||||
.border_color(theme.scrollbar_thumb_border)
|
||||
.width(px(10.))
|
||||
})
|
||||
.track_hover(|track| track.bg(theme.scrollbar_thumb_background).width(px(10.)))
|
||||
.thumb(|thumb| {
|
||||
thumb
|
||||
.bg(theme.scrollbar_thumb_background)
|
||||
.width(thumb_width)
|
||||
.inset(px(1.))
|
||||
.radius(thumb_radius)
|
||||
.min_length(px(48.))
|
||||
})
|
||||
.thumb_hover(|thumb| {
|
||||
thumb
|
||||
.bg(theme.scrollbar_thumb_hover_background)
|
||||
.width(px(8.))
|
||||
.inset(px(1.))
|
||||
.radius(px(4.))
|
||||
})
|
||||
.thumb_active(|thumb| {
|
||||
thumb
|
||||
.bg(theme.scrollbar_thumb_hover_background)
|
||||
.width(px(8.))
|
||||
.inset(px(1.))
|
||||
.radius(px(4.))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A scrollbar child that resolves the theme while rendering, which the
|
||||
/// [`ScrollableElement`] helpers cannot do at the call site.
|
||||
#[derive(IntoElement)]
|
||||
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
|
||||
id: ElementId,
|
||||
axis: ScrollbarAxis,
|
||||
scroll_handle: H,
|
||||
}
|
||||
|
||||
impl<H> RenderOnce for ScrollbarLayer<H>
|
||||
where
|
||||
H: ScrollbarHandle + Clone + 'static,
|
||||
{
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
scrollbar(&self.scroll_handle, self.axis, cx).id(self.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A scrollable element wrapper that adds scrollbars to an interactive element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
|
||||
@@ -128,15 +41,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Styled for Scrollable<E>
|
||||
where
|
||||
E: InteractiveElement + Styled + ParentElement + Element,
|
||||
{
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.element.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> ParentElement for Scrollable<E>
|
||||
where
|
||||
E: InteractiveElement + Styled + ParentElement + Element,
|
||||
@@ -146,18 +50,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Scrollable<Div> {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.element.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Scrollable<Stateful<Div>> {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.element.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> RenderOnce for Scrollable<E>
|
||||
where
|
||||
E: InteractiveElement + Styled + ParentElement + Element + 'static,
|
||||
@@ -167,8 +59,6 @@ where
|
||||
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
|
||||
.read(cx)
|
||||
.clone();
|
||||
|
||||
// Inherit the size from the element style.
|
||||
let style = StyleRefinement {
|
||||
size: self.element.style().size.clone(),
|
||||
..Default::default()
|
||||
@@ -190,22 +80,14 @@ where
|
||||
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
|
||||
ScrollbarAxis::Both => this.overflow_scroll(),
|
||||
})
|
||||
.child(
|
||||
self.element
|
||||
// Refine element size to `flex_1`.
|
||||
.size_auto()
|
||||
.flex_1(),
|
||||
),
|
||||
.child(self.element.size_auto().flex_1()),
|
||||
)
|
||||
.child(
|
||||
Scrollbar::new(&scroll_handle)
|
||||
.axis(self.axis)
|
||||
.id("scrollbar"),
|
||||
)
|
||||
.child(scrollbar(&scroll_handle, self.axis, cx).id("scrollbar"))
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollableElement for Div {}
|
||||
|
||||
impl<E> ScrollableElement for Stateful<E>
|
||||
where
|
||||
E: ParentElement + Styled + Element,
|
||||
Self: InteractiveElement,
|
||||
{
|
||||
}
|
||||
|
||||
+15
-17
@@ -1,7 +1,6 @@
|
||||
use gpui::{App, Div, Pixels, Refineable, StyleRefinement, Styled, div, px};
|
||||
pub use gpui_base::component_traits::{Disableable, Selectable};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius, surface_shadows};
|
||||
|
||||
/// Returns a `Div` as horizontal flex layout.
|
||||
pub fn h_flex() -> Div {
|
||||
@@ -15,7 +14,7 @@ pub fn v_flex() -> Div {
|
||||
|
||||
/// Returns a `Div` as divider.
|
||||
pub fn divider(cx: &App) -> Div {
|
||||
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
|
||||
div().my_1().w_full().h_px().bg(cx.theme().border_faint)
|
||||
}
|
||||
|
||||
macro_rules! font_weight {
|
||||
@@ -54,18 +53,20 @@ pub trait StyledExt: Styled + Sized {
|
||||
/// Set as Popover style
|
||||
#[inline]
|
||||
fn popover_style(self, cx: &mut App) -> Self {
|
||||
self.bg(cx.theme().background)
|
||||
let shadows = cx.theme().shadow.then(surface_shadows).unwrap_or_default();
|
||||
|
||||
self.bg(cx.theme().surface_overlay)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.shadow_md()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.shadow(shadows)
|
||||
.rounded(px(surface_radius()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Styled> StyledExt for E {}
|
||||
|
||||
/// A size for elements.
|
||||
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Size {
|
||||
Size(Pixels),
|
||||
XSmall,
|
||||
@@ -83,10 +84,7 @@ impl From<Pixels> for Size {
|
||||
|
||||
/// A trait for setting the size of an element.
|
||||
pub trait Sizable: Sized {
|
||||
/// Set the ui::Size of this element.
|
||||
///
|
||||
/// Also can receive a `ButtonSize` to convert to `IconSize`,
|
||||
/// Or a `Pixels` to set a custom size: `px(30.)`
|
||||
/// Set the element's [`Size`], or a custom pixel size (`px(30.)`).
|
||||
fn with_size(self, size: impl Into<Size>) -> Self;
|
||||
|
||||
/// Set to Size::XSmall
|
||||
@@ -130,18 +128,18 @@ impl<T: Styled> StyleSized<T> for T {
|
||||
match size {
|
||||
Size::XSmall => self.py_0p5(),
|
||||
Size::Medium => self.py_2(),
|
||||
Size::Large => self.py_5(),
|
||||
Size::Large => self.py_1p5(),
|
||||
_ => self.py_1(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_h(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::XSmall => self.h_6().text_xs(),
|
||||
Size::Small => self.h_8().text_sm(),
|
||||
Size::Medium => self.h_9().text_base(),
|
||||
Size::Large => self.h_12().text_lg(),
|
||||
Size::Size(size) => self.h(px(24.)).text_size(size),
|
||||
Size::XSmall => self.h_6().text_style(TextStyle::Caption),
|
||||
Size::Small => self.h_7().text_style(TextStyle::Callout),
|
||||
Size::Medium => self.h_8().text_style(TextStyle::Body),
|
||||
Size::Large => self.h_10().text_style(TextStyle::Title3),
|
||||
Size::Size(size) => self.h(size).text_size(size * 0.5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-51
@@ -4,12 +4,10 @@ use std::time::Duration;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window,
|
||||
div, px, white,
|
||||
div, px, transparent_black,
|
||||
};
|
||||
use gpui_base::{Spring, Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
|
||||
use theme::{ActiveTheme, Side};
|
||||
|
||||
use crate::{Disableable, Sizable, Size};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
|
||||
|
||||
@@ -17,12 +15,9 @@ type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
|
||||
pub struct Switch {
|
||||
id: ElementId,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
label: Option<SharedString>,
|
||||
description: Option<SharedString>,
|
||||
label_side: Side,
|
||||
on_click: OnClick,
|
||||
size: Size,
|
||||
}
|
||||
|
||||
impl Switch {
|
||||
@@ -32,12 +27,9 @@ impl Switch {
|
||||
Self {
|
||||
id: id.clone(),
|
||||
checked: false,
|
||||
disabled: false,
|
||||
label: None,
|
||||
description: None,
|
||||
on_click: None,
|
||||
label_side: Side::Left,
|
||||
size: Size::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,45 +57,27 @@ impl Switch {
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Switch {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for Switch {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Switch {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let checked = self.checked;
|
||||
let on_click = self.on_click.clone();
|
||||
|
||||
let (bg, toggle_bg) = match checked {
|
||||
true => (cx.theme().element_background, white()),
|
||||
false => (cx.theme().elevated_surface_background, white()),
|
||||
let bg = if checked {
|
||||
cx.theme().accent_strong
|
||||
} else {
|
||||
cx.theme().input_bg
|
||||
};
|
||||
|
||||
let (bg, toggle_bg) = match self.disabled {
|
||||
true => (bg.opacity(0.3), toggle_bg.opacity(0.8)),
|
||||
false => (bg, toggle_bg),
|
||||
};
|
||||
|
||||
let (bg_width, bg_height) = match self.size {
|
||||
Size::XSmall | Size::Small => (px(28.), px(16.)),
|
||||
_ => (px(36.), px(20.)),
|
||||
};
|
||||
|
||||
let bar_width = match self.size {
|
||||
Size::XSmall | Size::Small => px(12.),
|
||||
_ => px(16.),
|
||||
let toggle_bg = cx.theme().on_solid;
|
||||
// The border is structural (it insets the thumb); it only paints when off.
|
||||
let track_border = if checked {
|
||||
transparent_black()
|
||||
} else {
|
||||
cx.theme().border
|
||||
};
|
||||
|
||||
let bg_width = px(36.);
|
||||
let bg_height = px(20.);
|
||||
let bar_width = px(16.);
|
||||
let inset = px(2.);
|
||||
|
||||
let thumb_left = spring(
|
||||
@@ -124,14 +98,13 @@ impl RenderOnce for Switch {
|
||||
div().child(
|
||||
BaseSwitch::new(self.id.clone())
|
||||
.checked(checked)
|
||||
.disabled(self.disabled)
|
||||
.when_some(accessibility_label, |this, label| {
|
||||
this.accessibility_label(label)
|
||||
})
|
||||
.when_some(on_click, |this, on_click| {
|
||||
this.on_change(move |next, _event, window, cx| on_click(&next, window, cx))
|
||||
})
|
||||
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
|
||||
.flex_row_reverse()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
@@ -140,14 +113,16 @@ impl RenderOnce for Switch {
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.when_some(label, |this, label| {
|
||||
// Label
|
||||
this.child(div().text_sm().text_color(cx.theme().text).child(label))
|
||||
this.child(
|
||||
div()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text)
|
||||
.child(label),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
// Switch Bar
|
||||
SwitchTrack::new((self.id.clone(), "track"))
|
||||
.checked(checked)
|
||||
.disabled(self.disabled)
|
||||
.flex_shrink_0()
|
||||
.w(bg_width)
|
||||
.h(bg_height)
|
||||
@@ -155,11 +130,10 @@ impl RenderOnce for Switch {
|
||||
.flex()
|
||||
.items_center()
|
||||
.border(inset)
|
||||
.border_color(cx.theme().border_transparent)
|
||||
.border_color(track_border)
|
||||
.bg(bg)
|
||||
.when(!self.disabled, |this| this.cursor_pointer())
|
||||
.cursor_pointer()
|
||||
.child(
|
||||
// Switch Toggle
|
||||
SwitchThumb::new(checked)
|
||||
.rounded_full()
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
@@ -173,7 +147,7 @@ impl RenderOnce for Switch {
|
||||
this.child(
|
||||
div()
|
||||
.pr_3()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(description),
|
||||
)
|
||||
|
||||
+27
-80
@@ -5,9 +5,9 @@ use gpui::{
|
||||
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
|
||||
use crate::{Icon, IconName, Selectable, h_flex};
|
||||
use crate::{Selectable, h_flex};
|
||||
|
||||
pub mod tab_bar;
|
||||
|
||||
@@ -17,61 +17,27 @@ pub mod tab_bar;
|
||||
pub struct Tab {
|
||||
ix: usize,
|
||||
base: Div,
|
||||
pub(super) label: Option<SharedString>,
|
||||
icon: Option<Icon>,
|
||||
prefix: Option<AnyElement>,
|
||||
pub(super) tab_bar_prefix: Option<bool>,
|
||||
suffix: Option<AnyElement>,
|
||||
label: Option<SharedString>,
|
||||
children: Vec<AnyElement>,
|
||||
pub(super) disabled: bool,
|
||||
pub(super) selected: bool,
|
||||
pub(super) segmented: bool,
|
||||
tab_bar_prefix: Option<bool>,
|
||||
suffix: Option<AnyElement>,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
segmented: bool,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl From<&'static str> for Tab {
|
||||
fn from(label: &'static str) -> Self {
|
||||
Self::new().label(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Tab {
|
||||
fn from(label: String) -> Self {
|
||||
Self::new().label(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedString> for Tab {
|
||||
fn from(label: SharedString) -> Self {
|
||||
Self::new().label(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Icon> for Tab {
|
||||
fn from(icon: Icon) -> Self {
|
||||
Self::default().icon(icon)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IconName> for Tab {
|
||||
fn from(icon_name: IconName) -> Self {
|
||||
Self::default().icon(Icon::new(icon_name))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Tab {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ix: 0,
|
||||
base: div(),
|
||||
label: None,
|
||||
icon: None,
|
||||
tab_bar_prefix: None,
|
||||
children: Vec::new(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
segmented: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
on_click: None,
|
||||
}
|
||||
@@ -90,32 +56,20 @@ impl Tab {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set icon for the tab.
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the left side of the tab
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right side of the tab
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set disabled state to the tab, default false.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the click handler for the tab.
|
||||
pub fn on_click(
|
||||
pub(crate) fn on_click(
|
||||
mut self,
|
||||
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
@@ -179,8 +133,6 @@ impl RenderOnce for Tab {
|
||||
ix,
|
||||
base,
|
||||
label,
|
||||
icon,
|
||||
prefix,
|
||||
suffix,
|
||||
children,
|
||||
disabled,
|
||||
@@ -193,9 +145,9 @@ impl RenderOnce for Tab {
|
||||
let foreground = if disabled {
|
||||
cx.theme().text_muted
|
||||
} else if selected {
|
||||
cx.theme().tab_active_foreground
|
||||
cx.theme().text
|
||||
} else {
|
||||
cx.theme().tab_foreground
|
||||
cx.theme().text_muted
|
||||
};
|
||||
|
||||
let content = h_flex()
|
||||
@@ -206,15 +158,11 @@ impl RenderOnce for Tab {
|
||||
.overflow_hidden()
|
||||
.when(segmented, |this| this.justify_center().px_1())
|
||||
.when(!segmented, |this| this.justify_start())
|
||||
.map(|this| match icon {
|
||||
Some(icon) => this.w(px(38.)).child(icon.size_4()),
|
||||
None => this
|
||||
.map(|this| match label {
|
||||
Some(label) => this.child(label),
|
||||
None => this,
|
||||
})
|
||||
.children(children),
|
||||
});
|
||||
.map(|this| match label {
|
||||
Some(label) => this.child(label),
|
||||
None => this,
|
||||
})
|
||||
.children(children);
|
||||
|
||||
base.id(ix)
|
||||
.group("tab")
|
||||
@@ -222,16 +170,16 @@ impl RenderOnce for Tab {
|
||||
.items_center()
|
||||
.text_color(foreground)
|
||||
.when(segmented, |this| {
|
||||
this.text_xs()
|
||||
this.text_style(TextStyle::Caption)
|
||||
.flex_1()
|
||||
.h_6()
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(theme::button_radius()))
|
||||
.when(selected && !disabled, |this| {
|
||||
this.bg(cx.theme().tab_active_background)
|
||||
this.bg(cx.theme().bg)
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
})
|
||||
.when(!selected && !disabled, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().tab_hover_background))
|
||||
this.hover(|this| this.bg(cx.theme().element_hover))
|
||||
})
|
||||
})
|
||||
.when(!segmented, |this| {
|
||||
@@ -240,20 +188,19 @@ impl RenderOnce for Tab {
|
||||
.h_7()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_style(TextStyle::Callout)
|
||||
.rounded(px(theme::button_radius()))
|
||||
.overflow_hidden()
|
||||
.when(selected && !disabled, |this| {
|
||||
this.bg(cx.theme().tab_background)
|
||||
this.bg(cx.theme().element_active)
|
||||
})
|
||||
.when(!selected && !disabled, |this| {
|
||||
this.hover(|this| {
|
||||
this.bg(cx.theme().tab_hover_background)
|
||||
.text_color(cx.theme().tab_active_foreground)
|
||||
this.bg(cx.theme().element_hover)
|
||||
.text_color(cx.theme().text)
|
||||
})
|
||||
})
|
||||
})
|
||||
.when_some(prefix, |this, prefix| this.child(prefix))
|
||||
.child(content)
|
||||
.when_some(suffix, |this, suffix| {
|
||||
this.child(
|
||||
|
||||
@@ -52,25 +52,25 @@ impl TabBar {
|
||||
}
|
||||
|
||||
/// Track the scroll of the TabBar.
|
||||
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
|
||||
pub(crate) fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
|
||||
self.scroll_handle = Some(scroll_handle.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the prefix element of the TabBar
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
pub(crate) fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the suffix element of the TabBar
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add children of the TabBar.
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
|
||||
pub(crate) fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
|
||||
self.children.extend(children.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
@@ -88,14 +88,12 @@ impl TabBar {
|
||||
}
|
||||
|
||||
/// Set the last empty space element of the TabBar.
|
||||
pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
|
||||
pub(crate) fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
|
||||
self.last_empty_space = last_empty_space.into_any_element();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
|
||||
///
|
||||
/// When this is set, the children's on_click will be ignored.
|
||||
/// Set the on_click callback of the TabBar; when set, the children's on_click is ignored.
|
||||
pub fn on_click<F>(mut self, on_click: F) -> Self
|
||||
where
|
||||
F: Fn(&usize, &mut Window, &mut App) + 'static,
|
||||
@@ -124,9 +122,9 @@ impl RenderOnce for TabBar {
|
||||
.min_w_0()
|
||||
.refine_style(&self.style)
|
||||
.when(segmented, |this| {
|
||||
this.bg(cx.theme().tab_background)
|
||||
this.bg(cx.theme().surface)
|
||||
.p_0p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(theme::button_radius()))
|
||||
})
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
|
||||
+24
-53
@@ -1,31 +1,23 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, ClickEvent, Context, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton,
|
||||
ParentElement as _, Pixels, Render, RenderOnce, Stateful, StatefulInteractiveElement as _,
|
||||
Styled, Window, WindowControlArea, div, px,
|
||||
App, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels,
|
||||
RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, div,
|
||||
px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, plate_active};
|
||||
|
||||
use crate::{Icon, IconName, Sizable as _, h_flex};
|
||||
|
||||
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
|
||||
const TITLE_BAR_HEIGHT: Pixels = px(34.);
|
||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
||||
|
||||
// The Windows control buttons have a fixed width of 35px.
|
||||
//
|
||||
// We don't need implementation the click event for the control buttons.
|
||||
// If user clicked in the bounds, the window event will be triggered.
|
||||
// Windows control buttons rely on the native window event, so no click handlers are needed.
|
||||
#[derive(IntoElement, Clone)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
enum ControlIcon {
|
||||
Minimize,
|
||||
Restore,
|
||||
Maximize,
|
||||
Close {
|
||||
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
impl ControlIcon {
|
||||
@@ -41,9 +33,8 @@ impl ControlIcon {
|
||||
Self::Maximize
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>) -> Self {
|
||||
Self::Close { on_close_window }
|
||||
fn close() -> Self {
|
||||
Self::Close
|
||||
}
|
||||
|
||||
fn id(&self) -> &'static str {
|
||||
@@ -51,7 +42,7 @@ impl ControlIcon {
|
||||
Self::Minimize => "minimize",
|
||||
Self::Restore => "restore",
|
||||
Self::Maximize => "maximize",
|
||||
Self::Close { .. } => "close",
|
||||
Self::Close => "close",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +51,7 @@ impl ControlIcon {
|
||||
Self::Minimize => IconName::WindowMinimize,
|
||||
Self::Restore => IconName::WindowRestore,
|
||||
Self::Maximize => IconName::WindowMaximize,
|
||||
Self::Close { .. } => IconName::WindowClose,
|
||||
Self::Close => IconName::WindowClose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,18 +59,20 @@ impl ControlIcon {
|
||||
match self {
|
||||
Self::Minimize => WindowControlArea::Min,
|
||||
Self::Restore | Self::Maximize => WindowControlArea::Max,
|
||||
Self::Close { .. } => WindowControlArea::Close,
|
||||
Self::Close => WindowControlArea::Close,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_close(&self) -> bool {
|
||||
matches!(self, Self::Close { .. })
|
||||
matches!(self, Self::Close)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hover_fg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_foreground
|
||||
// White on the danger plate in both appearances, per bezel's
|
||||
// destructive pairing.
|
||||
gpui::white()
|
||||
} else {
|
||||
cx.theme().text
|
||||
}
|
||||
@@ -88,18 +81,18 @@ impl ControlIcon {
|
||||
#[inline]
|
||||
fn hover_bg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_background
|
||||
cx.theme().danger_strong
|
||||
} else {
|
||||
cx.theme().ghost_element_hover
|
||||
cx.theme().element_hover
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn active_bg(&self, cx: &mut App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_active
|
||||
plate_active(cx.theme().danger_strong)
|
||||
} else {
|
||||
cx.theme().ghost_element_active
|
||||
cx.theme().element_active
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,11 +107,6 @@ impl RenderOnce for ControlIcon {
|
||||
let hover_bg = self.hover_bg(cx);
|
||||
let active_bg = self.active_bg(cx);
|
||||
|
||||
let on_close_window = match &self {
|
||||
ControlIcon::Close { on_close_window } => on_close_window.clone(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
div()
|
||||
.id(self.id())
|
||||
.flex()
|
||||
@@ -144,13 +132,7 @@ impl RenderOnce for ControlIcon {
|
||||
match icon {
|
||||
Self::Minimize => window.minimize_window(),
|
||||
Self::Restore | Self::Maximize => window.zoom_window(),
|
||||
Self::Close { .. } => {
|
||||
if let Some(f) = on_close_window.clone() {
|
||||
f(&ClickEvent::default(), window, cx);
|
||||
} else {
|
||||
window.remove_window();
|
||||
}
|
||||
}
|
||||
Self::Close => window.remove_window(),
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -159,15 +141,10 @@ impl RenderOnce for ControlIcon {
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) struct WindowControls {
|
||||
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
|
||||
}
|
||||
pub(crate) struct WindowControls;
|
||||
|
||||
pub(crate) fn window_controls() -> WindowControls {
|
||||
WindowControls {
|
||||
on_close_window: None,
|
||||
}
|
||||
WindowControls
|
||||
}
|
||||
|
||||
pub fn title_bar_drag_handlers(
|
||||
@@ -232,16 +209,10 @@ impl RenderOnce for WindowControls {
|
||||
} else {
|
||||
ControlIcon::maximize()
|
||||
})
|
||||
.child(ControlIcon::close(self.on_close_window))
|
||||
.child(ControlIcon::close())
|
||||
}
|
||||
}
|
||||
|
||||
struct TitleBarState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
impl Render for TitleBarState {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
+14
-17
@@ -1,10 +1,10 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Window, div, relative,
|
||||
Window, px,
|
||||
};
|
||||
use gpui_base::Tooltip as BaseTooltip;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
|
||||
pub struct Tooltip {
|
||||
text: SharedString,
|
||||
@@ -18,20 +18,17 @@ impl Tooltip {
|
||||
|
||||
impl Render for Tooltip {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(
|
||||
BaseTooltip::new("tooltip")
|
||||
.font_family(".SystemUIFont")
|
||||
.m_3()
|
||||
.p_1p5()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().surface_background)
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text)
|
||||
.line_height(relative(1.25))
|
||||
.child(self.text.clone()),
|
||||
)
|
||||
BaseTooltip::new("tooltip")
|
||||
.font_family(cx.theme().font_sans.clone())
|
||||
.m_3()
|
||||
.p_1p5()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().surface_overlay)
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
.rounded(px(control_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text)
|
||||
.child(self.text.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ pub trait WindowExtension: Sized {
|
||||
key: impl Into<ElementId>,
|
||||
cx: &mut App,
|
||||
);
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
}
|
||||
|
||||
impl WindowExtension for Window {
|
||||
@@ -90,11 +87,4 @@ impl WindowExtension for Window {
|
||||
root.clear_notification_by_id::<T>(key, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clear_notifications(&mut self, cx: &mut App) {
|
||||
Root::update(self, cx, move |root, window, cx| {
|
||||
root.clear_notifications(window, cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use gpui::{
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
|
||||
@@ -223,8 +223,8 @@ impl Render for ImportIdentity {
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
@@ -248,10 +248,10 @@ impl Render for ImportIdentity {
|
||||
this.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.rounded(px(surface_radius()))
|
||||
.bg(cx.theme().warning_muted)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().warning)
|
||||
.line_height(relative(1.2))
|
||||
.child(div().child(BUNKER_WARN)),
|
||||
)
|
||||
@@ -260,10 +260,10 @@ impl Render for ImportIdentity {
|
||||
this.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().warning_background)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning_foreground)
|
||||
.rounded(px(surface_radius()))
|
||||
.bg(cx.theme().warning_muted)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().warning)
|
||||
.line_height(relative(1.2))
|
||||
.child(div().child(KEY_WARN)),
|
||||
)
|
||||
@@ -295,9 +295,9 @@ impl Render for ImportIdentity {
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupReference, GroupsRegistry};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
@@ -43,7 +43,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(Input::new(&input).cleanable(true).text_sm())
|
||||
.child(Input::new(&input).cleanable(true))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -56,7 +56,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
)
|
||||
.child(
|
||||
Button::new("join")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon(IconName::ArrowRight)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Join")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
|
||||
@@ -13,9 +13,9 @@ use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
use ui::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::nav::Nav;
|
||||
@@ -332,7 +332,7 @@ impl NewChat {
|
||||
|
||||
Nav::new(start + offset)
|
||||
.label(profile.name())
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.when(!AppSettings::get_hide_avatar(cx), |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(profile.avatar())
|
||||
@@ -365,9 +365,9 @@ impl NewChat {
|
||||
.gap_1()
|
||||
.p_0p5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().ghost_element_background_alt)
|
||||
.text_size(px(10.))
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.text_style(TextStyle::Caption2)
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.child(
|
||||
Avatar::from_source(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
@@ -394,7 +394,7 @@ impl Render for NewChat {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Input::new(&self.input).flex_1().text_sm().cleanable(true))
|
||||
.child(Input::new(&self.input).flex_1().cleanable(true))
|
||||
.child(
|
||||
Button::new("add")
|
||||
.icon(IconName::Plus)
|
||||
@@ -414,15 +414,15 @@ impl Render for NewChat {
|
||||
if let Some(error) = self.error.clone() {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(notice),
|
||||
)
|
||||
}
|
||||
@@ -449,7 +449,8 @@ impl Render for NewChat {
|
||||
)
|
||||
.child(
|
||||
Button::new("start")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon(IconName::ArrowRight)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Start")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::rc::Rc;
|
||||
use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
@@ -47,7 +47,8 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
)
|
||||
.child(
|
||||
Button::new("create")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon(IconName::ArrowRight)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Create")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
@@ -67,7 +68,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
|
||||
@@ -5,16 +5,16 @@ use chat::{ChatRegistry, Room, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all,
|
||||
PathPromptOptions, Render, SharedString, Styled, Task, Window, div, px, retain_all,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||
use settings::AppSettings;
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
use ui::dock::DockPlacement;
|
||||
use ui::input::{Input, InputState, Textarea, TextareaState};
|
||||
use ui::notification::Notification;
|
||||
@@ -366,12 +366,13 @@ impl Render for ProfileDialog {
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(cx.theme().surface_background)
|
||||
.bg(cx.theme().surface)
|
||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).large())
|
||||
.when(self.editing, |this| {
|
||||
this.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::PlusCircle)
|
||||
.icon(IconName::ArrowRight)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Add an avatar")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
@@ -391,9 +392,9 @@ impl Render for ProfileDialog {
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().surface_background)
|
||||
.text_sm()
|
||||
.rounded(px(button_radius()))
|
||||
.bg(cx.theme().surface)
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(shorten_pkey)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
@@ -464,6 +465,7 @@ impl Render for ProfileDialog {
|
||||
this.child(
|
||||
Button::new("update")
|
||||
.icon(IconName::Edit)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Update")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
@@ -476,6 +478,7 @@ impl Render for ProfileDialog {
|
||||
this.child(
|
||||
Button::new("message")
|
||||
.icon(IconName::Message)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Message")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
@@ -497,6 +500,7 @@ impl Render for ProfileDialog {
|
||||
.child(
|
||||
Button::new("save")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Save")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
@@ -513,7 +517,7 @@ impl Render for ProfileDialog {
|
||||
|
||||
fn label(label: &'static str, cx: &App) -> Div {
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.font_semibold()
|
||||
.child(label)
|
||||
@@ -530,8 +534,10 @@ fn field(
|
||||
None => (SharedString::from(placeholder), cx.theme().text_muted),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(label(text, cx))
|
||||
.child(div().text_sm().text_color(color).child(content))
|
||||
v_flex().gap_1p5().child(label(text, cx)).child(
|
||||
div()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(color)
|
||||
.child(content),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::scroll::ScrollableElement;
|
||||
@@ -108,10 +108,10 @@ impl RelayManager {
|
||||
let manager = cx.entity().downgrade();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(GOSSIP_MSG)),
|
||||
)
|
||||
@@ -132,7 +132,7 @@ impl RelayManager {
|
||||
.tooltip("Relay metadata")
|
||||
.ghost()
|
||||
.h(rems(2.))
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.dropdown_menu(move |menu, _window, _cx| {
|
||||
menu.item(metadata_item(&manager, RelayMetadata::Read))
|
||||
.item(metadata_item(&manager, RelayMetadata::Write))
|
||||
@@ -153,8 +153,8 @@ impl RelayManager {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
@@ -197,10 +197,10 @@ impl RelayManager {
|
||||
|
||||
fn render_inbox(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(INBOX_MSG)),
|
||||
)
|
||||
@@ -224,8 +224,8 @@ impl RelayManager {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
@@ -283,7 +283,8 @@ impl RelayManager {
|
||||
|
||||
h_flex().gap_1().justify_end().child(
|
||||
Button::new("update")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon(IconName::ArrowRight)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Update")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
@@ -607,7 +608,7 @@ impl Render for RelayManager {
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(self.render_tab_bar(cx))
|
||||
.child(content)
|
||||
.child(self.render_actions(cx))
|
||||
@@ -646,20 +647,19 @@ fn relay_row(
|
||||
.h_8()
|
||||
.px_2()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(px(button_radius()))
|
||||
.bg(cx.theme().surface)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(label)
|
||||
.when_some(badge, |this, badge| {
|
||||
this.child(
|
||||
div()
|
||||
.p_0p5()
|
||||
.rounded_xs()
|
||||
.font_semibold()
|
||||
.text_size(px(8.))
|
||||
.text_style(TextStyle::Caption2)
|
||||
.child(badge),
|
||||
)
|
||||
}),
|
||||
@@ -682,8 +682,8 @@ fn empty_relays(cx: &App) -> impl IntoElement {
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_sm()
|
||||
.rounded(px(panel_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use gpui::{
|
||||
use instant::Duration;
|
||||
use nip4e::DeviceRegistry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
@@ -100,11 +100,11 @@ impl Render for RestoreEncryption {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.gap_3()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Secret Key")
|
||||
.child(Input::new(&self.key_input)),
|
||||
@@ -112,7 +112,8 @@ impl Render for RestoreEncryption {
|
||||
.child(
|
||||
h_flex().gap_1().justify_end().child(
|
||||
Button::new("restore")
|
||||
.icon(IconName::CheckCircle)
|
||||
.icon(IconName::ArrowRight)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Restore")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
@@ -124,9 +125,9 @@ impl Render for RestoreEncryption {
|
||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
@@ -283,9 +283,9 @@ impl Screening {
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.rounded(px(button_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.child(Avatar::new(profile.avatar()).small())
|
||||
.child(profile.name()),
|
||||
);
|
||||
@@ -344,8 +344,8 @@ impl Render for Screening {
|
||||
.h_7()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_sm()
|
||||
.bg(cx.theme().surface_raised)
|
||||
.text_style(TextStyle::Callout)
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_center()
|
||||
@@ -385,33 +385,36 @@ impl Render for Screening {
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(status_badge(Some(self.followed), cx))
|
||||
.child(
|
||||
v_flex().text_sm().child("Contact").child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.followed {
|
||||
SharedString::from(CONTACT)
|
||||
} else {
|
||||
SharedString::from(NOT_CONTACT)
|
||||
}
|
||||
}),
|
||||
),
|
||||
v_flex()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child("Contact")
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.followed {
|
||||
SharedString::from(CONTACT)
|
||||
} else {
|
||||
SharedString::from(NOT_CONTACT)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(status_badge(last_active, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
@@ -446,11 +449,11 @@ impl Render for Screening {
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child({
|
||||
if let Some(addr) = self.address(cx) {
|
||||
SharedString::from(format!("{} validation", addr))
|
||||
@@ -481,11 +484,11 @@ impl Render for Screening {
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(status_badge(Some(mutuals > 0), cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
@@ -524,9 +527,9 @@ fn status_badge(status: Option<bool>, cx: &App) -> Div {
|
||||
if let Some(status) = status {
|
||||
this.child(Icon::new(IconName::CheckCircle).small().text_color({
|
||||
if status {
|
||||
cx.theme().icon_accent
|
||||
cx.theme().accent
|
||||
} else {
|
||||
cx.theme().icon_muted
|
||||
cx.theme().text_faint
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use gpui::http_client::Url;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Window, div, px,
|
||||
Window, div,
|
||||
};
|
||||
use settings::AppSettings;
|
||||
use theme::{ActiveTheme, Theme, ThemeMode};
|
||||
use theme::{ActiveTheme, AppExt as _, AppearanceMode, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::group_box::{GroupBox, GroupBoxVariants};
|
||||
use ui::input::{Input, InputState};
|
||||
@@ -47,10 +47,10 @@ impl Preferences {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the theme mode (light or dark)
|
||||
fn set_theme_mode(mode: ThemeMode, window: &mut Window, cx: &mut App) {
|
||||
AppSettings::update_theme_mode(mode, cx);
|
||||
Theme::change(mode, Some(window), cx);
|
||||
/// Set the appearance mode (system, light or dark)
|
||||
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
|
||||
AppSettings::update_appearance_mode(mode, cx);
|
||||
cx.set_appearance_mode(mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,13 @@ impl Render for Preferences {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
|
||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||
const MODE: &str = "Use the selected light or dark theme, or to follow the OS.";
|
||||
const MODE: &str = "Follow the OS, or always use light or dark.";
|
||||
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
|
||||
const RESET: &str = "Reset the theme to the default one.";
|
||||
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let nip4e = AppSettings::get_nip4e(cx);
|
||||
let theme_mode = AppSettings::get_theme_mode(cx);
|
||||
let appearance_mode = AppSettings::get_appearance_mode(cx);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
@@ -104,55 +103,30 @@ impl Render for Preferences {
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.child(div().text_sm().child(SharedString::from("Mode")))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(SharedString::from("Mode")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(MODE)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("theme-mode")
|
||||
.label(theme_mode.name())
|
||||
Button::new("appearance-mode")
|
||||
.label(appearance_mode.label())
|
||||
.ghost_alt()
|
||||
.small()
|
||||
.dropdown_menu(|this, _window, _cx| {
|
||||
this.item(PopupMenuItem::new("Light").on_click(
|
||||
|_, window, cx| {
|
||||
Self::set_theme_mode(ThemeMode::Light, window, cx);
|
||||
},
|
||||
))
|
||||
.item(
|
||||
PopupMenuItem::new("Dark").on_click(|_, window, cx| {
|
||||
Self::set_theme_mode(ThemeMode::Dark, window, cx);
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.child(div().text_sm().child(SharedString::from("Reset theme")))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(RESET)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("reset")
|
||||
.label("Reset")
|
||||
.ghost_alt()
|
||||
.small()
|
||||
.on_click(move |_ev, window, cx| {
|
||||
AppSettings::global(cx).update(cx, |this, cx| {
|
||||
this.reset_theme(window, cx);
|
||||
AppearanceMode::ALL.iter().fold(this, |menu, mode| {
|
||||
menu.item(PopupMenuItem::new(mode.label()).on_click(
|
||||
move |_, _window, cx| {
|
||||
Self::set_appearance_mode(*mode, cx);
|
||||
},
|
||||
))
|
||||
})
|
||||
}),
|
||||
),
|
||||
@@ -184,7 +158,7 @@ impl Render for Preferences {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Input::new(&self.file_input).text_xs().small())
|
||||
.child(Input::new(&self.file_input).small())
|
||||
.child(
|
||||
Button::new("update-file-server")
|
||||
.icon(IconName::Check)
|
||||
@@ -197,9 +171,9 @@ impl Render for Preferences {
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_style(TextStyle::Caption2)
|
||||
.italic()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(SharedString::from("Only support blossom service")),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
@@ -15,11 +14,11 @@ use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, SIDEBAR_WIDTH, Theme};
|
||||
use ui::button::Button;
|
||||
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
use ui::{Root, WindowExtension, v_flex};
|
||||
|
||||
use crate::dialogs::restore::RestoreEncryption;
|
||||
use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings};
|
||||
@@ -40,7 +39,6 @@ struct MsgRelayNotification;
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = workspace, no_json)]
|
||||
enum Command {
|
||||
ToggleTheme,
|
||||
Update,
|
||||
RefreshMessagingRelays,
|
||||
BackupEncryption,
|
||||
@@ -327,9 +325,6 @@ impl Workspace {
|
||||
Command::ResetEncryption => {
|
||||
self.confirm_reset_encryption(window, cx);
|
||||
}
|
||||
Command::ToggleTheme => {
|
||||
self.theme_selector(window, cx);
|
||||
}
|
||||
Command::BackupEncryption => {
|
||||
let device = DeviceRegistry::global(cx).downgrade();
|
||||
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
|
||||
@@ -420,12 +415,12 @@ impl Workspace {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(SharedString::from(ENC_MSG))
|
||||
.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.text_color(cx.theme().danger)
|
||||
.child(SharedString::from(ENC_WARN)),
|
||||
),
|
||||
)
|
||||
@@ -448,86 +443,6 @@ impl Workspace {
|
||||
.child(restore.clone())
|
||||
});
|
||||
}
|
||||
|
||||
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
let themes = registry.read(cx).themes();
|
||||
|
||||
this.width(px(520.))
|
||||
.show_close(true)
|
||||
.title("Select theme")
|
||||
.child(v_flex().gap_2().w_full().children({
|
||||
let mut items = vec![];
|
||||
|
||||
for (ix, (path, theme)) in themes.iter().enumerate() {
|
||||
items.push(
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.group("")
|
||||
.px_2()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.flex_1()
|
||||
.text_sm()
|
||||
.child(theme.name.clone())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(theme.author.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.invisible()
|
||||
.group_hover("", |this| this.visible())
|
||||
.child(
|
||||
Button::new(format!("url-{ix}"))
|
||||
.icon(IconName::Link)
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click({
|
||||
let theme = theme.clone();
|
||||
move |_ev, _window, cx| {
|
||||
cx.open_url(&theme.url);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("set-{ix}"))
|
||||
.icon(IconName::Check)
|
||||
.primary()
|
||||
.small()
|
||||
.on_click({
|
||||
let path = path.clone();
|
||||
move |_ev, window, cx| {
|
||||
let settings = AppSettings::global(cx);
|
||||
let path = path.clone();
|
||||
|
||||
settings.update(cx, |this, cx| {
|
||||
this.set_theme(path, window, cx);
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use anyhow::Error;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, px,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::USER_KEYRING;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{Input, InputState};
|
||||
@@ -132,24 +132,24 @@ impl Render for BackupPanel {
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Public Key:")),
|
||||
@@ -162,7 +162,7 @@ impl Render for BackupPanel {
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Secret Key:")),
|
||||
|
||||
@@ -6,14 +6,14 @@ use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, ScrollStrategy, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, relative,
|
||||
size,
|
||||
size, transparent_black,
|
||||
};
|
||||
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
|
||||
use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
||||
use nostr_sdk::prelude::{RelayUrl, Timestamp};
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
@@ -268,7 +268,7 @@ impl BrowsePanel {
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Public Groups"),
|
||||
@@ -276,7 +276,7 @@ impl BrowsePanel {
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| match &self.relay {
|
||||
Some(relay) => {
|
||||
@@ -294,13 +294,12 @@ impl BrowsePanel {
|
||||
.w_full()
|
||||
.max_w_64()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().surface_background)
|
||||
.rounded(px(button_radius()))
|
||||
.bg(cx.theme().surface)
|
||||
.child(
|
||||
Input::new(&self.input)
|
||||
.appearance(false)
|
||||
.small()
|
||||
.text_sm()
|
||||
.flex_1(),
|
||||
)
|
||||
.child(
|
||||
@@ -375,26 +374,26 @@ impl BrowsePanel {
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.rounded(cx.theme().radius)
|
||||
.rounded(px(panel_radius()))
|
||||
.cursor_pointer()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_transparent)
|
||||
.bg(cx.theme().surface_background)
|
||||
.border_color(transparent_black())
|
||||
.bg(cx.theme().surface)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().elevated_surface_background)
|
||||
.border_color(cx.theme().border_focused)
|
||||
this.bg(cx.theme().surface_raised)
|
||||
.border_color(cx.theme().border_strong)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(Avatar::from_source(picture).seed(seed).flex_shrink_0())
|
||||
})
|
||||
@@ -404,7 +403,7 @@ impl BrowsePanel {
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.truncate()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
@@ -413,8 +412,8 @@ impl BrowsePanel {
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(relay),
|
||||
),
|
||||
),
|
||||
@@ -422,7 +421,7 @@ impl BrowsePanel {
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.text_ellipsis()
|
||||
.line_clamp(2)
|
||||
@@ -443,7 +442,7 @@ impl BrowsePanel {
|
||||
.child(Icon::new(IconName::Book))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(SharedString::from(format!("{members} members"))),
|
||||
),
|
||||
),
|
||||
@@ -463,11 +462,11 @@ impl Panel for BrowsePanel {
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.child(
|
||||
Icon::new(IconName::Compass)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
.text_color(cx.theme().text_faint),
|
||||
)
|
||||
.child(TITLE)
|
||||
.into_any_element()
|
||||
@@ -543,7 +542,7 @@ fn notice(text: &'static str, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(text)
|
||||
.into_any_element()
|
||||
|
||||
@@ -5,14 +5,14 @@ use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||
Task, TextAlign, Window, div, rems, retain_all,
|
||||
Task, TextAlign, Window, div, px, rems, retain_all,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
@@ -233,12 +233,12 @@ impl ContactListPanel {
|
||||
.h_8()
|
||||
.px_2()
|
||||
.justify_between()
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.rounded(px(button_radius()))
|
||||
.hover(|this| this.bg(cx.theme().element_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.gap(px(theme::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
@@ -273,8 +273,8 @@ impl ContactListPanel {
|
||||
.border_2()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_sm()
|
||||
.rounded(px(panel_radius()))
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_align(TextAlign::Center)
|
||||
.child(SharedString::from("Please add some relays."))
|
||||
}
|
||||
@@ -308,13 +308,13 @@ impl Render for ContactListPanel {
|
||||
.overflow_y_scrollbar()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("New contact:"),
|
||||
@@ -342,8 +342,8 @@ impl Render for ContactListPanel {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window, div, px, svg,
|
||||
};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
|
||||
use crate::Command;
|
||||
use crate::dialogs::profile;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||
@@ -45,7 +44,7 @@ impl Panel for GreeterPanel {
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
@@ -85,13 +84,13 @@ impl Render for GreeterPanel {
|
||||
.child(
|
||||
h_flex()
|
||||
.mb_4()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
.text_color(cx.theme().text_faint),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -103,7 +102,7 @@ impl Render for GreeterPanel {
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(DESCRIPTION)),
|
||||
),
|
||||
@@ -111,13 +110,13 @@ impl Render for GreeterPanel {
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from("Get Started"))
|
||||
@@ -125,7 +124,7 @@ impl Render for GreeterPanel {
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.justify_start()
|
||||
.items_start()
|
||||
@@ -138,19 +137,6 @@ impl Render for GreeterPanel {
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open_profile(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("theme")
|
||||
.icon(Icon::new(IconName::Moon))
|
||||
.label("Change theme")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(Command::ToggleTheme),
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@ use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Panel for InboxPanel {
|
||||
.child(
|
||||
Icon::new(IconName::Inbox)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
.text_color(cx.theme().text_faint),
|
||||
)
|
||||
.child(self.name.clone())
|
||||
.into_any_element()
|
||||
@@ -55,7 +55,7 @@ impl Render for InboxPanel {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::nav::Nav;
|
||||
@@ -131,7 +131,7 @@ impl Sidebar {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
@@ -149,7 +149,7 @@ impl Sidebar {
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.truncate()
|
||||
.line_height(relative(1.2))
|
||||
.font_semibold()
|
||||
@@ -157,7 +157,7 @@ impl Sidebar {
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_style(TextStyle::Caption2)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(total_members),
|
||||
),
|
||||
@@ -230,10 +230,10 @@ impl Sidebar {
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
|
||||
.text_xs()
|
||||
.suffix(Icon::new(icon).small().text_color(cx.theme().text_faint))
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
match section {
|
||||
GroupSection::Admins => this.admins_open = !this.admins_open,
|
||||
@@ -262,7 +262,7 @@ impl Sidebar {
|
||||
|
||||
Nav::new(public_key.to_hex())
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_medium()
|
||||
.clickable(true)
|
||||
.when(!hide_avatar, |this| {
|
||||
@@ -277,8 +277,8 @@ impl Sidebar {
|
||||
this.suffix(
|
||||
div()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(SharedString::from(roles.join(", "))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Sidebar {
|
||||
.h(TABBAR_HEIGHT)
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.px_2()
|
||||
.when(cfg!(target_os = "macos"), |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
@@ -57,7 +57,6 @@ impl Sidebar {
|
||||
)
|
||||
.menu_with_icon("Relays", IconName::Relay, Box::new(Command::ShowRelayList))
|
||||
.separator()
|
||||
.menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme))
|
||||
.menu_with_icon(
|
||||
"Settings",
|
||||
IconName::Settings,
|
||||
@@ -121,7 +120,7 @@ impl Sidebar {
|
||||
let staged = updater.read(cx).staged();
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.when(!up_to_date, |this| {
|
||||
this.child(
|
||||
Button::new("update")
|
||||
|
||||
@@ -6,7 +6,7 @@ use gpui::{
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{DockPlacement, PanelHandle};
|
||||
use ui::notification::Notification;
|
||||
@@ -192,7 +192,7 @@ impl Sidebar {
|
||||
.px_4()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().background)
|
||||
.bg(cx.theme().bg)
|
||||
.child(
|
||||
Button::new("create-chat")
|
||||
.label(if self.selected_contacts.len() > 1 {
|
||||
@@ -208,7 +208,7 @@ impl Sidebar {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Selected")
|
||||
.child(
|
||||
|
||||
@@ -8,7 +8,7 @@ use gpui::{
|
||||
};
|
||||
use nip29::GroupsRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::nav_item::NavItem;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
@@ -61,7 +61,7 @@ impl Sidebar {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.child(
|
||||
v_flex()
|
||||
.px_2()
|
||||
@@ -85,34 +85,30 @@ impl Sidebar {
|
||||
.px_1()
|
||||
.min_w_6()
|
||||
.justify_center()
|
||||
.text_size(px(10.))
|
||||
.text_style(TextStyle::Caption2)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.text_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.child(SharedString::from(
|
||||
request_count.to_string(),
|
||||
)),
|
||||
)
|
||||
.when(self.new_requests && !requests_open, |this| {
|
||||
this.child(
|
||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
||||
div().size_1().rounded_full().bg(cx.theme().accent),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(requests_open, |this| {
|
||||
this.bg(cx.theme().ghost_element_active)
|
||||
})
|
||||
.when(requests_open, |this| this.bg(cx.theme().element_active))
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.toggle_requests(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
|
||||
.when(contacts_open, |this| {
|
||||
this.bg(cx.theme().ghost_element_active)
|
||||
})
|
||||
.when(contacts_open, |this| this.bg(cx.theme().element_active))
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.toggle_contacts(window, cx);
|
||||
})),
|
||||
@@ -122,9 +118,9 @@ impl Sidebar {
|
||||
this.child(
|
||||
div()
|
||||
.px_4()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(list_title),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
|
||||
StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
||||
StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px,
|
||||
retain_all,
|
||||
};
|
||||
use instant::Duration;
|
||||
@@ -15,7 +15,7 @@ use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
|
||||
use ui::dock::{DockArea, Panel, PanelEvent};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::input::{InputEvent, InputState};
|
||||
@@ -276,8 +276,8 @@ impl Render for Sidebar {
|
||||
.image_cache(retain_all("sidebar"))
|
||||
.size_full()
|
||||
.relative()
|
||||
.gap_2()
|
||||
.bg(cx.theme().surface_background)
|
||||
.gap(px(theme::SPACE))
|
||||
.bg(cx.theme().surface)
|
||||
.when_some(current_user.as_ref(), |this, current_user| {
|
||||
this.child(title_bar_drag_handlers(
|
||||
self.render_user(current_user, cx),
|
||||
@@ -326,17 +326,17 @@ impl Render for Sidebar {
|
||||
.px_8()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.gap(px(theme::SPACE))
|
||||
.w_full()
|
||||
.h_9()
|
||||
.justify_center()
|
||||
.bg(cx.theme().background.opacity(0.85))
|
||||
.bg(cx.theme().bg.opacity(0.85))
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.rounded_full()
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(Indicator::new().small().color(cx.theme().icon_accent))
|
||||
.child(Indicator::new().small().color(cx.theme().accent))
|
||||
.child("Getting messages..."),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,13 +5,13 @@ use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity,
|
||||
Window, div,
|
||||
Window, div, px,
|
||||
};
|
||||
use nip29::Group;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
||||
@@ -89,10 +89,10 @@ impl Sidebar {
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
|
||||
.text_xs()
|
||||
.suffix(Icon::new(icon).small().text_color(cx.theme().text_faint))
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
match section {
|
||||
ListSection::Pins => this.pins_open = !this.pins_open,
|
||||
@@ -112,9 +112,9 @@ impl Sidebar {
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.text_xs()
|
||||
.text_style(TextStyle::Caption)
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::Empty(text) => v_flex()
|
||||
@@ -130,9 +130,9 @@ impl Sidebar {
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.rounded(px(button_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(*text),
|
||||
)
|
||||
.into_any_element(),
|
||||
@@ -149,7 +149,7 @@ impl Sidebar {
|
||||
|
||||
let nav = Nav::new(index)
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(picture)
|
||||
@@ -161,8 +161,8 @@ impl Sidebar {
|
||||
.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(created_at),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
@@ -204,7 +204,7 @@ impl Sidebar {
|
||||
|
||||
Nav::new(index)
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(person.avatar())
|
||||
@@ -230,7 +230,7 @@ impl Sidebar {
|
||||
|
||||
Nav::new(index)
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(person.avatar())
|
||||
@@ -265,7 +265,7 @@ impl Sidebar {
|
||||
|
||||
let nav = Nav::new(index)
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(picture)
|
||||
@@ -278,8 +278,8 @@ impl Sidebar {
|
||||
this.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(suffix),
|
||||
)
|
||||
})
|
||||
@@ -339,7 +339,7 @@ impl Sidebar {
|
||||
|
||||
Nav::new(index)
|
||||
.label(content)
|
||||
.text_sm()
|
||||
.text_style(TextStyle::Callout)
|
||||
.font_medium()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
@@ -352,8 +352,8 @@ impl Sidebar {
|
||||
.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_faint)
|
||||
.child(detail),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
|
||||
@@ -4,12 +4,12 @@ use std::rc::Rc;
|
||||
use chat::{ChatRegistry, Room};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
|
||||
Window, div, uniform_list,
|
||||
Window, div, px, uniform_list,
|
||||
};
|
||||
use nip29::{Group, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::{FIND_LIMIT, NostrRegistry};
|
||||
use theme::ActiveTheme;
|
||||
use theme::{ActiveTheme, button_radius};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::Input;
|
||||
use ui::scroll::Scrollbar;
|
||||
@@ -201,14 +201,9 @@ impl Sidebar {
|
||||
.px_1p5()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
Input::new(&self.find_input)
|
||||
.appearance(false)
|
||||
.small()
|
||||
.text_xs(),
|
||||
)
|
||||
.rounded(px(button_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.child(Input::new(&self.find_input).appearance(false).small())
|
||||
.child(
|
||||
Button::new("sidebar-global")
|
||||
.icon(IconName::Compass)
|
||||
|
||||
Reference in New Issue
Block a user