diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 6d25e6e0..ef4c5ce4 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -16,7 +16,6 @@ use gpui::{ retain_all, svg, white, }; use gpui_kit::assets::IconName; -use gpui_kit::component::avatar::Avatar; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; @@ -34,11 +33,11 @@ use smallvec::{SmallVec, smallvec}; use state::{ FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted, }; -use ui::{TextStyle, Typeset as _}; use crate::file::*; use crate::message::{ - MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions, opens_run, + Avatar, MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions, + opens_run, }; use crate::text::RenderedText; @@ -931,9 +930,9 @@ impl ChatPanel { h_flex() .h_10() .justify_center() - .text_style(TextStyle::Callout) + .text_xs() .bg(cx.theme().secondary) - .rounded(px(ui::panel_radius())) + .rounded(cx.theme().radius_lg) .child("Message isn't traced yet"), ) }) @@ -946,10 +945,10 @@ impl ChatPanel { h_flex() .h_7() .px_2() - .gap(px(ui::SPACE)) + .gap_2() .bg(cx.theme().secondary) - .rounded(px(ui::control_radius())) - .text_style(TextStyle::Callout) + .rounded(cx.theme().radius) + .text_xs() .child(div().size_1p5().rounded_full().bg(gpui::green())) .child(SharedString::from(url.to_string())), ); @@ -978,10 +977,10 @@ impl ChatPanel { h_flex() .h_7() .px_2() - .gap(px(ui::SPACE)) + .gap_2() .bg(cx.theme().secondary) - .rounded(px(ui::control_radius())) - .text_style(TextStyle::Callout) + .rounded(cx.theme().radius) + .text_xs() .child(div().size_1p5().rounded_full().bg(gpui::green())) .child(SharedString::from(url.to_string())), ); @@ -1018,7 +1017,7 @@ impl ChatPanel { .items_center() .justify_center() .text_center() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .line_height(relative(1.3)) .child( @@ -1041,7 +1040,7 @@ impl ChatPanel { h_flex() .w_full() .gap_3() - .text_style(TextStyle::Callout) + .text_xs() .child( h_flex() .flex_shrink_0() @@ -1157,10 +1156,11 @@ impl ChatPanel { .ghost() .relative() .flex_shrink_0() - .child(match author.avatar() { - Some(picture) => Avatar::new().src(picture).into_any_element(), - None => Avatar::new().into_any_element(), - }) + .child( + Avatar::new(author.avatar()) + .seed(author.avatar_seed()) + .into_any_element(), + ) .dropdown_menu(move |this, _window, _cx| { this.menu("Public Key", Box::new(Command::Copy(pk))) .menu("View Relays", Box::new(Command::Relays(pk))) @@ -1290,23 +1290,18 @@ impl ChatPanel { let avatar = profile.avatar(); v_flex() - .gap(px(ui::SPACE)) + .gap_2() .w_full() .child( h_flex() - .gap(px(ui::SPACE)) - .text_style(TextStyle::Callout) + .gap_2() + .text_xs() .child(SharedString::from("Sent to:")) .child( h_flex() .gap_1() .font_weight(gpui::FontWeight::SEMIBOLD) - .child(match avatar { - Some(picture) => { - Avatar::new().src(picture).small().into_any_element() - } - None => Avatar::new().small().into_any_element(), - }) + .child(Avatar::new(avatar).seed(profile.avatar_seed()).small()) .child(name.clone()), ), ) @@ -1318,8 +1313,8 @@ impl ChatPanel { .p_1() .h_16() .w_full() - .text_style(TextStyle::Callout) - .rounded(px(ui::surface_radius())) + .text_xs() + .rounded(cx.theme().radius_lg) .bg(cx.theme().warning.opacity(0.15)) .text_color(cx.theme().warning) .child(div().flex_1().w_full().text_center().child(error)), @@ -1328,7 +1323,7 @@ impl ChatPanel { .when_some(report.output().cloned(), |this, output| { this.child( v_flex() - .gap(px(ui::SPACE)) + .gap_2() .w_full() .children({ let mut items = Vec::with_capacity(output.failed.len()); @@ -1339,18 +1334,18 @@ impl ChatPanel { .gap_0p5() .p_1() .w_full() - .rounded(px(ui::surface_radius())) + .rounded(cx.theme().radius_lg) .bg(cx.theme().danger.opacity(0.06)) .child( div() - .text_style(TextStyle::Caption) + .text_xs() .font_weight(gpui::FontWeight::SEMIBOLD) .line_height(relative(1.25)) .child(SharedString::from(url.to_string())), ) .child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().danger.opacity(0.9)) .line_height(relative(1.25)) .child(SharedString::from(msg.to_string())), @@ -1369,18 +1364,18 @@ impl ChatPanel { .gap_0p5() .p_1() .w_full() - .rounded(px(ui::surface_radius())) + .rounded(cx.theme().radius_lg) .bg(cx.theme().muted) .child( div() - .text_style(TextStyle::Caption) + .text_xs() .font_weight(gpui::FontWeight::SEMIBOLD) .line_height(relative(1.25)) .child(SharedString::from(url.0.to_string())), ) .child( div() - .text_style(TextStyle::Caption) + .text_xs() .line_height(relative(1.25)) .child(SharedString::from("Successfully")), ), @@ -1408,7 +1403,7 @@ impl ChatPanel { .right_4() .top_neg_2() .when(cx.theme().shadow, |this| this.shadow_sm()) - .rounded(px(ui::surface_radius())) + .rounded(cx.theme().radius_lg) .border_1() .border_color(cx.theme().border) .bg(cx.theme().background) @@ -1488,7 +1483,7 @@ impl ChatPanel { img(url.as_str()) .size_16() .when(cx.theme().shadow, |this| this.shadow_lg()) - .rounded(px(ui::control_radius())) + .rounded(cx.theme().radius) .object_fit(ObjectFit::Cover), ) .child( @@ -1549,7 +1544,7 @@ impl ChatPanel { .border_color(cx.theme().border) .h(px(250.)) .object_fit(ObjectFit::Cover) - .rounded(px(ui::bubble_radius())), + .rounded(cx.theme().radius_lg), ) .into_any_element(); } @@ -1577,21 +1572,21 @@ impl ChatPanel { .self_start() .items_start() .min_w_0() - .gap(px(ui::SPACE)) + .gap_2() .p_2() .border_1() .border_color(cx.theme().border) - .rounded(px(ui::control_radius())) + .rounded(cx.theme().radius) .child(Icon::new(IconName::Lock).text_color(cx.theme().primary)) .child( v_flex() .min_w_0() .overflow_hidden() - .text_style(TextStyle::Callout) + .text_xs() .child(div().line_height(relative(1.2)).child(file.display_name())) .child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(label), ), @@ -1623,7 +1618,7 @@ impl ChatPanel { img(pending.path.clone()) .size_16() .when(cx.theme().shadow, |this| this.shadow_sm()) - .rounded(px(ui::control_radius())) + .rounded(cx.theme().radius) .object_fit(ObjectFit::Cover), ) } else { @@ -1633,11 +1628,11 @@ impl ChatPanel { .flex() .items_center() .justify_center() - .rounded(px(ui::control_radius())) + .rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border) .bg(cx.theme().sidebar) - .text_style(TextStyle::Caption) + .text_xs() .text_center() .child("Preview not available"), ) @@ -1696,7 +1691,7 @@ impl ChatPanel { .flex() .items_baseline() .gap_1() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child("Replying to:") .child(div().text_color(cx.theme().primary).child(profile.name())), @@ -1717,7 +1712,7 @@ impl ChatPanel { .child( div() .w_full() - .text_style(TextStyle::Callout) + .text_xs() .text_ellipsis() .line_clamp(1) .child(text.preview()), @@ -1827,24 +1822,16 @@ impl Panel for ChatPanel { self.room .read_with(cx, |this, cx| { let label = this.display_name(cx); - let picture = this.display_image(cx); + let seed = this.display_image_seed(cx); h_flex() .gap_1() - .text_style(TextStyle::Caption) - .child(match picture { - Some(picture) => Avatar::new().src(picture).xsmall().into_any_element(), - None => Avatar::new().xsmall().into_any_element(), - }) + .text_xs() + .child(Avatar::new(this.display_image(cx)).seed(seed).xsmall()) .child(label) .into_any_element() }) - .unwrap_or( - div() - .text_style(TextStyle::Caption) - .child("Unknown") - .into_any_element(), - ) + .unwrap_or(div().text_xs().child("Unknown").into_any_element()) } fn toolbar_buttons( @@ -1895,7 +1882,7 @@ impl Render for ChatPanel { .h_12() .w_full() .px_2() - .gap(px(ui::SPACE)) + .gap_2() .border_b_1() .border_color(cx.theme().border) .child(Input::new(&self.subject_input).small()) @@ -1951,7 +1938,7 @@ impl Render for ChatPanel { this.child( div() .px_1() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().warning) .child(WARNING), ) diff --git a/crates/chat_ui/src/message.rs b/crates/chat_ui/src/message.rs index 0d67ebf2..83533bbe 100644 --- a/crates/chat_ui/src/message.rs +++ b/crates/chat_ui/src/message.rs @@ -10,7 +10,8 @@ use gpui_kit::component::{ ActiveTheme, InteractiveElementExt as _, StyledExt as _, h_flex, v_flex, }; use smallvec::SmallVec; -use ui::{TextStyle, Typeset as _, bubble_radius, control_radius}; + +pub use ui::Avatar; type MouseDownListener = Box; type ClickListener = Box; @@ -206,8 +207,8 @@ impl RenderOnce for MessageRow { .when(show_author, |this| { this.child( h_flex() - .gap(px(ui::SPACE)) - .text_style(TextStyle::Callout) + .gap_2() + .text_xs() .text_color(cx.theme().muted_foreground) .when_some(author, |this, author| { this.child( @@ -296,7 +297,7 @@ impl RenderOnce for ReplyPreview { .px_2() .border_l_2() .border_color(cx.theme().list_active) - .text_style(TextStyle::Callout) + .text_xs() .refine_style(&style) .child(div().font_weight(gpui::FontWeight::SEMIBOLD).child(author)) .child(div().w_full().text_ellipsis().line_clamp(1).child(preview)) @@ -346,7 +347,7 @@ impl RenderOnce for MessageMedia { .border_color(cx.theme().border) .h(px(250.)) .object_fit(ObjectFit::Cover) - .rounded(px(bubble_radius())), + .rounded(cx.theme().radius_lg), ) .into_any_element(); } @@ -357,7 +358,7 @@ impl RenderOnce for MessageMedia { .flex() .flex_row() .flex_wrap() - .gap(px(ui::SPACE)) + .gap_2() .refine_style(&style) .children(media.into_iter().enumerate().map(|(ix, item)| { div().id(format!("media-{ix}")).flex_shrink_0().child( @@ -365,7 +366,7 @@ impl RenderOnce for MessageMedia { .h_32() .border_1() .border_color(cx.theme().border) - .rounded(px(bubble_radius())), + .rounded(cx.theme().radius_lg), ) })) .into_any_element() @@ -441,11 +442,11 @@ impl RenderOnce for ReactionBar { .refine_style(&style) .children(groups.into_iter().map(|group| { h_flex() - .gap(px(ui::SPACE)) + .gap_2() .py_0p5() .px_1() - .rounded(px(control_radius())) - .text_style(TextStyle::Caption) + .rounded(cx.theme().radius) + .text_xs() .border_1() .when(group.mine, |this| { this.text_color(cx.theme().foreground) @@ -525,7 +526,7 @@ impl RenderOnce for WelcomeMessage { v_flex() .id(id) .w_full() - .gap(px(ui::SPACE)) + .gap_2() .p_3() .items_center() .justify_center() @@ -540,7 +541,8 @@ impl RenderOnce for WelcomeMessage { .when_some(title, |this, title| { this.child( div() - .text_style(TextStyle::Headline) + .text_sm() + .font_semibold() .text_color(cx.theme().foreground) .child(title), ) @@ -548,7 +550,7 @@ impl RenderOnce for WelcomeMessage { .when_some(message, |this, message| { this.child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(message), ) diff --git a/crates/nip29_ui/src/details.rs b/crates/nip29_ui/src/details.rs index e06682e8..d2bab785 100644 --- a/crates/nip29_ui/src/details.rs +++ b/crates/nip29_ui/src/details.rs @@ -2,10 +2,9 @@ use gpui::{ AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px, relative, }; -use gpui_kit::component::avatar::Avatar; use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; use nip29::{Group, GroupMetadata}; -use ui::{TextStyle, Typeset as _, control_radius}; +use ui::Avatar; pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |this, _window, cx| { @@ -22,10 +21,7 @@ fn body(group: &WeakEntity, cx: &App) -> AnyElement { }; let group = entity.read(cx); - let mut body = v_flex() - .gap_3() - .text_style(TextStyle::Callout) - .child(header(group, cx)); + let mut body = v_flex().gap_3().text_xs().child(header(group, cx)); if let Some(metadata) = group.metadata() { if let Some(about) = metadata.about.as_ref() { @@ -130,23 +126,24 @@ fn header(group: &Group, cx: &App) -> AnyElement { h_flex() .gap_3() .items_center() - .child(match group.display_image() { - Some(picture) => Avatar::new().src(picture).large().into_any_element(), - None => Avatar::new().large().into_any_element(), - }) + .child( + Avatar::new(group.display_image()) + .seed(key.cache_tag()) + .large(), + ) .child( v_flex() .min_w_0() .child( div() - .text_style(TextStyle::Body) + .text_sm() .font_weight(gpui::FontWeight::SEMIBOLD) .truncate() .child(group.display_name()), ) .child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .truncate() .child(SharedString::from(format!( @@ -164,7 +161,7 @@ fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement { .gap_1() .child( div() - .text_style(TextStyle::Caption) + .text_xs() .font_weight(gpui::FontWeight::MEDIUM) .text_color(cx.theme().muted_foreground) .child(label), @@ -185,10 +182,10 @@ fn chip(label: impl Into, cx: &App) -> AnyElement { div() .px_2() .py_1() - .rounded(px(control_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().secondary) .text_color(cx.theme().foreground) - .text_style(TextStyle::Caption) + .text_xs() .line_height(relative(1.)) .child(label.into()) .into_any_element() diff --git a/crates/nip29_ui/src/invite.rs b/crates/nip29_ui/src/invite.rs index 35bfdc5b..d0e44121 100644 --- a/crates/nip29_ui/src/invite.rs +++ b/crates/nip29_ui/src/invite.rs @@ -11,7 +11,6 @@ use gpui_kit::component::notification::Notification; use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; use nip29::{Group, Invite}; use nostr_sdk::prelude::SecretKey; -use ui::{TextStyle, Typeset as _}; use crate::util::{field, report}; @@ -48,7 +47,7 @@ fn body(group: WeakEntity, input: Entity, cx: &App) -> impl I v_flex() .gap_3() - .text_style(TextStyle::Callout) + .text_xs() .child( div() .text_color(cx.theme().muted_foreground) @@ -68,7 +67,7 @@ fn body(group: WeakEntity, input: Entity, cx: &App) -> impl I fn invite_row(group: WeakEntity, invite: &Invite, cx: &App) -> impl IntoElement { let created = div() .flex_shrink_0() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(invite.created_at.to_ago()); diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 3ffd3b91..95e70010 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -11,7 +11,6 @@ use gpui::{ list, px, }; use gpui_kit::assets::IconName; -use gpui_kit::component::avatar::Avatar; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dock::{Panel, PanelEvent}; @@ -26,7 +25,7 @@ use serde::Deserialize; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, upload}; -use ui::{TextStyle, Typeset as _, control_radius}; +use ui::Avatar; use util::{attachment_name, display_name, opens_run, pin_label, report}; mod details; @@ -209,7 +208,7 @@ pub fn confirm_leave(group: WeakEntity, window: &mut Window, cx: &mut App ) .child( div() - .text_style(TextStyle::Callout) + .text_xs() .child("You will stop receiving messages from this group."), ) .on_ok(move |_event, _window, cx| { @@ -245,7 +244,7 @@ pub fn confirm_delete(group: WeakEntity, window: &mut Window, cx: &mut Ap ) .child( div() - .text_style(TextStyle::Callout) + .text_xs() .child("The relay will remove this group for everyone."), ) .on_ok(move |_event, window, cx| { @@ -714,10 +713,10 @@ impl GroupPanel { h_flex() .flex_shrink_0() .w_full() - .gap(px(ui::SPACE)) + .gap_2() .px_3() .py_2() - .text_style(TextStyle::Callout) + .text_xs() .border_b_1() .border_color(cx.theme().border) .child( @@ -752,10 +751,10 @@ impl GroupPanel { h_flex() .flex_shrink_0() .w_full() - .gap(px(ui::SPACE)) + .gap_2() .px_3() .py_2() - .text_style(TextStyle::Callout) + .text_xs() .bg(cx.theme().secondary) .child( Icon::new(IconName::Info) @@ -819,9 +818,9 @@ impl GroupPanel { div() .px_1p5() .py_0p5() - .rounded(px(control_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().primary) - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(pin_label(group, pin)) })) @@ -838,16 +837,12 @@ impl GroupPanel { ( display_name(group), - match picture { - Some(picture) => Avatar::new().src(picture).large().into_any_element(), - None => Avatar::new().large().into_any_element(), - }, + Avatar::new(picture.map(Into::into)) + .seed(group.key().cache_tag()) + .large(), ) } - None => ( - SharedString::from("this group"), - Avatar::new().large().into_any_element(), - ), + None => (SharedString::from("this group"), Avatar::new(None).large()), }; WelcomeMessage::new("welcome") @@ -904,10 +899,10 @@ impl GroupPanel { .w_full() .justify_center() .items_center() - .gap(px(ui::SPACE)) + .gap_2() .px_3() .py_2() - .text_style(TextStyle::Callout) + .text_xs() .text_color(cx.theme().muted_foreground); match notice { @@ -1117,9 +1112,9 @@ impl GroupPanel { .gap_1() .px_2() .py_1() - .rounded(px(control_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().secondary) - .text_style(TextStyle::Caption) + .text_xs() .child( div() .flex_shrink_0() @@ -1170,8 +1165,8 @@ impl GroupPanel { .px_2() .border_1() .border_color(cx.theme().border) - .rounded(px(control_radius())) - .text_style(TextStyle::Caption) + .rounded(cx.theme().radius) + .text_xs() .text_color(cx.theme().muted_foreground) .child(Icon::new(IconName::Link).xsmall()) .child( @@ -1233,26 +1228,24 @@ impl Panel for GroupPanel { .upgrade() .map(|group| { let group = group.read(cx); - let picture = group - .metadata() - .and_then(|metadata| metadata.picture.clone()); h_flex() .gap_1() - .text_style(TextStyle::Caption) - .child(match picture { - Some(picture) => Avatar::new().src(picture).xsmall().into_any_element(), - None => Avatar::new().xsmall().into_any_element(), - }) + .text_xs() + .child( + Avatar::new( + group + .metadata() + .and_then(|metadata| metadata.picture.clone()) + .map(Into::into), + ) + .seed(group.key().cache_tag()) + .xsmall(), + ) .child(display_name(group)) .into_any_element() }) - .unwrap_or_else(|| { - div() - .text_style(TextStyle::Caption) - .child("Unknown") - .into_any_element() - }) + .unwrap_or_else(|| div().text_xs().child("Unknown").into_any_element()) } fn toolbar_buttons( @@ -1296,7 +1289,7 @@ impl Render for GroupPanel { None => h_flex() .size_full() .justify_center() - .text_style(TextStyle::Callout) + .text_xs() .text_color(cx.theme().muted_foreground) .child("No messages yet") .into_any_element(), diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 2bd87e33..022c0186 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -4,10 +4,9 @@ use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, - WeakEntity, div, px, + WeakEntity, div, }; use gpui_kit::assets::IconName; -use gpui_kit::component::avatar::Avatar; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex}; use nip29::{Activity, Group, Reaction}; @@ -15,7 +14,7 @@ use nostr_sdk::prelude::{EventId, Kind}; use person::Directory; use settings::AppSettings; use state::NostrRegistry; -use ui::{TextStyle, Typeset as _}; +use ui::Avatar; use crate::GroupPanel; @@ -58,13 +57,11 @@ pub(crate) fn render( let row = MessageRow::new(ix) .show_author(show_author) .hide_avatar(hide_avatar) - .avatar(match author.avatar() { - Some(picture) => Avatar::new() - .src(picture) - .flex_shrink_0() - .into_any_element(), - None => Avatar::new().flex_shrink_0().into_any_element(), - }) + .avatar( + Avatar::new(author.avatar()) + .seed(author.avatar_seed()) + .flex_shrink_0(), + ) .author(author.name()) .timestamp(message.created_at.to_human_time()) .when(!message.replies_to.is_empty(), |this| { @@ -217,26 +214,24 @@ pub(crate) fn render_activity( h_flex() .id(ix) .w_full() - .gap(px(ui::SPACE)) + .gap_2() .py_1() .px_3() .hover(|this| this.bg(cx.theme().primary)) .child(badge(activity, cx)) .when(!hide_avatar, |this| { - this.child(match subject.avatar() { - Some(picture) => Avatar::new() - .src(picture) + this.child( + Avatar::new(subject.avatar()) + .seed(subject.avatar_seed()) .small() - .flex_shrink_0() - .into_any_element(), - None => Avatar::new().small().flex_shrink_0().into_any_element(), - }) + .flex_shrink_0(), + ) }) .child( div() .min_w_0() .truncate() - .text_style(TextStyle::Callout) + .text_xs() .font_weight(gpui::FontWeight::SEMIBOLD) .child(subject.name()), ) @@ -244,7 +239,7 @@ pub(crate) fn render_activity( div() .min_w_0() .truncate() - .text_style(TextStyle::Callout) + .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(action(activity))), ) @@ -252,7 +247,7 @@ pub(crate) fn render_activity( div() .flex_shrink_0() .ml_1() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(activity.created_at.to_ago()), ) diff --git a/crates/nip29_ui/src/metadata.rs b/crates/nip29_ui/src/metadata.rs index e47f0702..d6f42f47 100644 --- a/crates/nip29_ui/src/metadata.rs +++ b/crates/nip29_ui/src/metadata.rs @@ -4,7 +4,7 @@ use gpui::http_client::{AsyncBody, HttpClient}; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled, - WeakEntity, Window, div, px, + WeakEntity, Window, div, }; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dialog::DialogButtonProps; @@ -15,7 +15,6 @@ use gpui_kit::component::switch::Switch; use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; use nip29::{Group, GroupId, GroupMetadata}; use nostr_sdk::prelude::RelayUrl; -use ui::{TextStyle, Typeset as _}; use crate::util::field; @@ -126,7 +125,7 @@ pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) .child(children_section(&children, cx)) .child( v_flex() - .gap(px(ui::SPACE)) + .gap_2() .child( form_field() .label("Private") @@ -287,7 +286,7 @@ fn children_section(children: &Entity>, cx: &App) -> AnyElement { let mut section = v_flex() .gap_1() - .text_style(TextStyle::Callout) + .text_xs() .text_color(cx.theme().muted_foreground) .child("Children"); diff --git a/crates/nip29_ui/src/util.rs b/crates/nip29_ui/src/util.rs index 8267df04..252ca8d2 100644 --- a/crates/nip29_ui/src/util.rs +++ b/crates/nip29_ui/src/util.rs @@ -5,7 +5,6 @@ use gpui_kit::component::notification::Notification; use gpui_kit::component::{ActiveTheme, WindowExt, v_flex}; use nip29::{Group, Pin, Row}; use nostr_sdk::prelude::Url; -use ui::{TextStyle, Typeset as _}; pub(crate) fn display_name(group: &Group) -> SharedString { group @@ -47,7 +46,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool { pub(crate) fn field(label: &'static str, input: &Entity, cx: &App) -> Div { v_flex() .gap_1() - .text_style(TextStyle::Callout) + .text_xs() .text_color(cx.theme().muted_foreground) .child(label) .child(Input::new(input)) diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index 9efec2b8..ed7142e4 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -1,4 +1,3 @@ -use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use std::cell::Cell; use std::collections::HashSet; use std::path::PathBuf; @@ -9,17 +8,17 @@ 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, px, relative, + SharedString, Styled, Subscription, Task, Window, div, relative, }; -use gpui_kit::component::avatar::Avatar; use gpui_kit::component::button::Button; use gpui_kit::component::notification::{Notification, NotificationType}; +use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use nostr_sdk::prelude::*; use person::Directory; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner}; -use ui::{TextStyle, Typeset as _, control_radius}; +use ui::Avatar; const IDENTIFIER: &str = "coop:device"; @@ -727,25 +726,25 @@ impl DeviceRegistry { let profile = directory.read(cx).person(&requester, cx); v_flex() - .gap(px(ui::SPACE)) - .text_style(TextStyle::Callout) + .gap_2() + .text_xs() .child( div() - .text_style(TextStyle::Callout) + .text_xs() .line_height(relative(1.25)) .child(SharedString::from(MSG)), ) .child( v_flex() - .gap(px(ui::SPACE)) + .gap_2() .child( v_flex() .gap_1() - .text_style(TextStyle::Callout) + .text_xs() .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from("From:")), ) @@ -754,20 +753,16 @@ impl DeviceRegistry { .h_8() .w_full() .px_2() - .rounded(px(control_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().muted) .child( h_flex() - .gap(px(ui::SPACE)) - .child(match profile.avatar() { - Some(picture) => Avatar::new() - .src(picture) - .xsmall() - .into_any_element(), - None => Avatar::new() - .xsmall() - .into_any_element(), - }) + .gap_2() + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .xsmall(), + ) .child(profile.name()), ), ), @@ -775,11 +770,11 @@ impl DeviceRegistry { .child( v_flex() .gap_1() - .text_style(TextStyle::Callout) + .text_xs() .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from("Code:")), ) @@ -788,7 +783,7 @@ impl DeviceRegistry { .h_8() .w_full() .px_2() - .rounded(px(control_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().muted) .child(code_label.clone()), ), diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs new file mode 100644 index 00000000..dcc6c374 --- /dev/null +++ b/crates/ui/src/avatar.rs @@ -0,0 +1,445 @@ +use gpui::{ + AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement, + Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, + SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, +}; +use gpui_kit::base::StyledExt as _; +use gpui_kit::component::{ActiveTheme as _, Selectable, Sizable, Size}; + +/// Number of rows and columns in the generated pixel grid. +const PIXEL_GRID: usize = 8; +/// Empty cells kept between the pattern and the avatar edge. +const MARGIN: usize = 1; +/// Probability that a cell in the left half of the pattern area is filled. +const FILL_PROBABILITY: f32 = 0.42; +/// Probability that a filled cell uses the accent shade instead of the main color. +const ACCENT_PROBABILITY: f32 = 0.25; +/// Minimum number of filled left-half cells, so a pattern never reads as empty. +const MIN_FILLED: usize = 5; +/// Fallback seed for an avatar that has neither a picture nor a seed of its own. +const FALLBACK_SEED: &str = "coop"; +/// Number of segments used to approximate the avatar circle. +const CIRCLE_SEGMENTS: usize = 32; + +/// Returns the size of the avatar based on the given [`Size]. +fn avatar_size(size: Size) -> AbsoluteLength { + match size { + Size::Large => px(64.).into(), + Size::Medium => px(32.).into(), + Size::Small => px(24.).into(), + Size::XSmall => px(18.).into(), + Size::Size(size) => size.into(), + } +} + +/// A deterministic, offline pixel-art avatar derived from a seed. +#[derive(IntoElement)] +struct PixelAvatar { + seed: u64, + size: Size, + style: StyleRefinement, +} + +impl PixelAvatar { + /// Creates a pixel avatar from `seed. + fn new(seed: impl AsRef) -> Self { + Self { + seed: fnv1a(seed.as_ref().as_bytes()), + size: Size::Medium, + style: StyleRefinement::default(), + } + } +} + +impl Sizable for PixelAvatar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for PixelAvatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for PixelAvatar { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let side = avatar_size(self.size).to_pixels(window.rem_size()); + let seed = self.seed; + + div() + .refine_style(&self.style) + .size(side) + .flex_shrink_0() + .child(canvas( + move |_bounds, _window, _cx| seed, + move |bounds, seed, window, cx| { + let theme = cx.theme(); + let main = Hsla { + h: seed as f32 / u64::MAX as f32, + s: 0.6, + l: if theme.is_dark() { 0.6 } else { 0.45 }, + a: 1., + }; + let shade = if theme.is_dark() { + Hsla { + l: (main.l * 1.6).min(0.95), + ..main + } + } else { + Hsla { + l: (main.l * 0.45).max(0.18), + ..main + } + }; + + let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.); + paint_polygons(window, std::iter::once(&circle), main.opacity(0.16)); + + let pattern = pixel_pattern(seed); + let mut cells = Vec::new(); + + for (value, color) in [(1u8, main), (2u8, shade)] { + cells.clear(); + + for row in 0..PIXEL_GRID { + for col in 0..PIXEL_GRID { + if pattern[row * PIXEL_GRID + col] != value { + continue; + } + + let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle); + if cell.len() >= 3 { + cells.push(cell); + } + } + } + + paint_polygons(window, cells.iter(), color); + } + }, + )) + } +} + +/// Builds the mirrored fill pattern for `seed. +fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { + let mut rng = PixelRng::new(seed); + let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID]; + let mut filled = 0usize; + + let art_rows = PIXEL_GRID - 2 * MARGIN; + let art_columns = PIXEL_GRID / 2 - MARGIN; + + for row in MARGIN..PIXEL_GRID - MARGIN { + for col in MARGIN..PIXEL_GRID / 2 { + if rng.chance(FILL_PROBABILITY) { + let accent = rng.chance(ACCENT_PROBABILITY); + set_cell(&mut pattern, row, col, if accent { 2 } else { 1 }); + filled += 1; + } + } + } + + if filled < MIN_FILLED { + let total = art_rows * art_columns; + let start = (rng.next() % total as u64) as usize; + + for offset in 0..total { + if filled >= MIN_FILLED { + break; + } + + let ix = (start + offset) % total; + let row = MARGIN + ix / art_columns; + let col = MARGIN + ix % art_columns; + + if pattern[row * PIXEL_GRID + col] == 0 { + set_cell(&mut pattern, row, col, 1); + filled += 1; + } + } + } + + pattern +} + +/// Paints `polygons as a single anti-aliased filled path in `color. +fn paint_polygons<'a>( + window: &mut Window, + polygons: impl IntoIterator>>, + color: Hsla, +) { + let mut builder = PathBuilder::fill(); + let mut painted = false; + + for polygon in polygons { + if polygon.len() >= 3 { + builder.add_polygon(polygon, true); + painted = true; + } + } + + if painted && let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +/// Approximates the circle of `radius around `center as a convex polygon wound left-interior. +fn circle_polygon(center: Point, radius: f32) -> Vec> { + let center_x = center.x.as_f32(); + let center_y = center.y.as_f32(); + + (0..CIRCLE_SEGMENTS) + .map(|index| { + let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32; + point( + px(center_x + radius * angle.cos()), + px(center_y + radius * angle.sin()), + ) + }) + .collect() +} + +/// The four corners of cell `(row, col) of the grid laid out in `bounds. +fn cell_polygon(bounds: &Bounds, row: usize, col: usize) -> [Point; 4] { + let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32; + let left = bounds.origin.x.as_f32() + col as f32 * cell; + let top = bounds.origin.y.as_f32() + row as f32 * cell; + + [ + point(px(left), px(top)), + point(px(left + cell), px(top)), + point(px(left + cell), px(top + cell)), + point(px(left), px(top + cell)), + ] +} + +/// Clips `subject to the convex `clip polygon, keeping the part inside it. +fn clip_polygon(subject: &[Point], clip: &[Point]) -> Vec> { + let mut current = subject.to_vec(); + let mut next = Vec::with_capacity(subject.len() + 4); + + for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) { + if current.is_empty() { + break; + } + + next.clear(); + let mut previous = match current.last() { + Some(&vertex) => vertex, + None => break, + }; + + for &vertex in current.iter() { + let previous_inside = is_inside(start, end, previous); + let vertex_inside = is_inside(start, end, vertex); + + if vertex_inside { + if !previous_inside + && let Some(crossing) = line_intersection(start, end, previous, vertex) + { + next.push(crossing); + } + next.push(vertex); + } else if previous_inside + && let Some(crossing) = line_intersection(start, end, previous, vertex) + { + next.push(crossing); + } + + previous = vertex; + } + + std::mem::swap(&mut current, &mut next); + } + + current +} + +/// Whether `vertex lies on the interior side of the directed edge `start -> end. +fn is_inside(start: Point, end: Point, vertex: Point) -> bool { + let start_x = start.x.as_f32(); + let start_y = start.y.as_f32(); + let edge_x = end.x.as_f32() - start_x; + let edge_y = end.y.as_f32() - start_y; + let to_vertex_x = vertex.x.as_f32() - start_x; + let to_vertex_y = vertex.y.as_f32() - start_y; + + edge_x * to_vertex_y - edge_y * to_vertex_x >= 0. +} + +/// The intersection of segment `from -> to with the infinite line `start -> end. +fn line_intersection( + start: Point, + end: Point, + from: Point, + to: Point, +) -> Option> { + let start_x = start.x.as_f32(); + let start_y = start.y.as_f32(); + let edge_x = end.x.as_f32() - start_x; + let edge_y = end.y.as_f32() - start_y; + let from_x = from.x.as_f32(); + let from_y = from.y.as_f32(); + let segment_x = to.x.as_f32() - from_x; + let segment_y = to.y.as_f32() - from_y; + let denominator = edge_x * segment_y - edge_y * segment_x; + + if denominator.abs() < f32::EPSILON { + return None; + } + + let offset_x = from_x - start_x; + let offset_y = from_y - start_y; + let t = (edge_y * offset_x - edge_x * offset_y) / denominator; + + Some(point( + px(from_x + segment_x * t), + px(from_y + segment_y * t), + )) +} + +/// Fills `cell (row, col) and its horizontal mirror. +fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) { + pattern[row * PIXEL_GRID + col] = value; + pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value; +} + +/// FNV-1a 64-bit hash, stable across platforms and runs. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for &byte in bytes { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Tiny xorshift64* PRNG for deriving the pattern from the seed. +struct PixelRng(u64); + +impl PixelRng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn chance(&mut self, probability: f32) -> bool { + self.next() as f32 / (u64::MAX as f32) < probability + } +} + +/// Renders the generated pixel avatar shown in place of a missing picture. +fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement { + PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED)) + .with_size(size) + .into_any_element() +} + +/// An element that renders a user avatar with customizable appearance options. +#[derive(IntoElement)] +pub struct Avatar { + base: Div, + picture: Option, + seed: Option, + style: StyleRefinement, + size: Size, + selected: bool, +} + +impl Avatar { + /// Creates an avatar for an entity whose profile picture may be missing. + pub fn new(picture: Option) -> Self { + Self::from_source(picture) + } + + /// Creates an avatar from an already-resolved source, which may be missing. + pub fn from_source(picture: Option>) -> Self { + Self::from_picture(picture.map(Into::into)) + } + + fn from_picture(picture: Option) -> Self { + Avatar { + base: div(), + picture, + seed: None, + style: StyleRefinement::default(), + size: Size::Medium, + selected: false, + } + } + + /// Sets the seed for the generated pixel avatar. + pub fn seed(mut self, seed: impl Into) -> Self { + self.seed = Some(seed.into()); + self + } +} + +impl Sizable for Avatar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for Avatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Selectable for Avatar { + fn is_selected(&self) -> bool { + self.selected + } + + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } +} + +impl InteractiveElement for Avatar { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl RenderOnce for Avatar { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let image_size = avatar_size(self.size).to_pixels(window.rem_size()); + + let content = match self.picture { + Some(picture) => { + let seed = self.seed; + + img(picture) + .size(image_size) + .rounded_full() + .object_fit(ObjectFit::Cover) + .with_fallback(move || generated_avatar(seed.as_deref(), image_size)) + .into_any_element() + } + None => generated_avatar(self.seed.as_deref(), image_size), + }; + + div() + .flex_shrink_0() + .size(image_size) + .rounded_full() + .overflow_hidden() + .child(content) + } +} diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs deleted file mode 100644 index 17ee3931..00000000 --- a/crates/ui/src/layout.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Layout constants, ported from bezel. 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}; - -use gpui::{Pixels, px}; - -/// The base radius behind every radius accessor, as raw `f32` bits. -static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits()); - -/// 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 } -} - -/// Window border radius for platforms with client-side decorations. -pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0); - -/// Window shadow size for platforms with client-side decorations. -pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0); - -/// Window border size for platforms with client-side decorations. -pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0); - -/// Space left of the sidebar header for the macOS traffic lights. -pub const TRAFFIC_LIGHT_PADDING: f32 = 80.; - -/// Workspace tabbar height. -pub const TABBAR_HEIGHT: Pixels = px(36.0); - -/// Default sidebar width. -pub const SIDEBAR_WIDTH: Pixels = px(240.); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 2804bba2..a3ab13b8 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,15 +1,5 @@ -pub use layout::{ - BASE_RADIUS, BUTTON_HEIGHT, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING, - CLIENT_SIDE_DECORATION_SHADOW, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SIDEBAR_WIDTH, - SPACE, TABBAR_HEIGHT, TRAFFIC_LIGHT_PADDING, bubble_radius, button_radius, control_radius, - inset_radius, panel_radius, surface_radius, -}; -pub use styled::{background, divider}; -pub use typography::{DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size}; +pub use avatar::Avatar; pub use window_drag::title_bar_drag_handlers; -pub mod layout; -pub mod motion; -pub mod styled; -pub mod typography; +pub mod avatar; pub mod window_drag; diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs deleted file mode 100644 index ffc0cb31..00000000 --- a/crates/ui/src/motion.rs +++ /dev/null @@ -1,668 +0,0 @@ -//! 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 web_time::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, - leases: HashMap, - running: bool, -} - -impl Global for PulseClock {} - -impl PulseClock { - fn next_wake(&self) -> Option { - 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(cx: &Context) -> 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::() - .and_then(|clock| clock.leases.get(&self.0)) - .is_some_and(|lease| lease.in_flight) - } -} - -impl From for Painter { - fn from(entity_id: EntityId) -> Self { - Self(entity_id) - } -} - -impl From 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::(); - 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::(); - 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::(); - 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::(); - 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(id: impl Into, element: E) -> AnimationElement -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(id: impl Into, element: E) -> AnimationElement -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(id: impl Into, element: E) -> AnimationElement -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(id: impl Into, t: f32, element: E) -> AnimationElement -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(id: impl Into, element: E) -> AnimationElement -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(id: impl Into, element: E) -> AnimationElement -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) -> Self { - Self { - painter, - key: key.into(), - } - } -} - -/// Per-fade progress store. -#[derive(Default)] -pub struct HoverFades { - pub entries: HashMap, - 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 = 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::().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); - } -} diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs deleted file mode 100644 index 707e0dbf..00000000 --- a/crates/ui/src/styled.rs +++ /dev/null @@ -1,27 +0,0 @@ -use gpui::{ - App, Div, ImageSource, ObjectFit, ParentElement as _, Styled as _, StyledImage as _, div, img, -}; -use gpui_kit::component::ActiveTheme as _; - -/// Returns a `Div` as divider. -pub fn divider(cx: &App) -> Div { - div().w_full().h_px().bg(cx.theme().border) -} - -/// Returns a full-bleed image with a wash for readability, to show behind panel content. -pub fn background(image: I, cx: &App) -> Div -where - I: Into, -{ - let wash = if cx.theme().mode.is_dark() { 0.5 } else { 0.2 }; - div() - .absolute() - .inset_0() - .child(img(image).size_full().object_fit(ObjectFit::Cover)) - .child( - div() - .absolute() - .inset_0() - .bg(cx.theme().background.opacity(wash)), - ) -} diff --git a/crates/ui/src/typography.rs b/crates/ui/src/typography.rs deleted file mode 100644 index 584d5be2..00000000 --- a/crates/ui/src/typography.rs +++ /dev/null @@ -1,131 +0,0 @@ -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 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 Typeset for E {} diff --git a/crates/ui/src/window_drag.rs b/crates/ui/src/window_drag.rs index 4efc0c0f..2befc4f6 100644 --- a/crates/ui/src/window_drag.rs +++ b/crates/ui/src/window_drag.rs @@ -3,8 +3,11 @@ use gpui::{ Window, WindowControlArea, }; -/// Window-drag and double-click handlers for the app's integrated title bar -/// row. Attach to the header element that acts as the drag region. +struct DragState { + should_move: bool, +} + +/// Window-drag and double-click handlers for the app's integrated title bar. pub fn title_bar_drag_handlers( this: Stateful
, window: &mut Window, @@ -49,7 +52,3 @@ pub fn title_bar_drag_handlers( } }) } - -struct DragState { - should_move: bool, -} diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index 50c90d95..f9a7f45b 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -10,7 +10,6 @@ use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex}; use instant::Duration; use nostr_connect::prelude::*; use state::NostrRegistry; -use ui::{TextStyle, Typeset as _, surface_radius}; pub fn open(window: &mut Window, cx: &mut App) { let import = cx.new(|cx| ImportIdentity::new(window, cx)); @@ -239,10 +238,10 @@ impl Render for ImportIdentity { v_flex() .size_full() .gap_4() - .text_style(TextStyle::Callout) + .text_xs() .child( v_flex() - .gap(px(ui::SPACE)) + .gap_2() .when(!is_unlock, |this| { this.child( v_flex() @@ -285,9 +284,9 @@ impl Render for ImportIdentity { this.child( div() .p_2() - .rounded(px(surface_radius())) + .rounded(cx.theme().radius_lg) .bg(cx.theme().warning.opacity(0.15)) - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().warning) .line_height(relative(1.2)) .child(div().child(BUNKER_WARN)), @@ -297,9 +296,9 @@ impl Render for ImportIdentity { this.child( div() .p_2() - .rounded(px(surface_radius())) + .rounded(cx.theme().radius_lg) .bg(cx.theme().warning.opacity(0.15)) - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().warning) .line_height(relative(1.2)) .child(div().child(KEY_WARN)), @@ -330,7 +329,7 @@ impl Render for ImportIdentity { .when_some(self.error.read(cx).as_ref(), |this, error| { this.child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_center() .text_color(cx.theme().danger) .child(error.clone()), diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs index aea12a5d..75cf2b39 100644 --- a/crates/workspace/src/dialogs/new_chat.rs +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -1,10 +1,3 @@ -use crate::nav::Nav; -use gpui_kit::assets::IconName; -use gpui_kit::component::avatar::Avatar; -use gpui_kit::component::button::{Button, ButtonVariants}; -use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; -use gpui_kit::component::input::{Input, InputEvent, InputState}; -use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use std::ops::Range; use std::rc::Rc; @@ -16,11 +9,19 @@ use gpui::{ Render, SharedString, StatefulInteractiveElement as _, Styled, Subscription, Task, WeakEntity, Window, div, px, uniform_list, }; +use gpui_kit::assets::IconName; +use gpui_kit::base::StyledExt as _; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use nostr_sdk::prelude::*; use person::Directory; use settings::AppSettings; use state::NostrRegistry; -use ui::{TextStyle, Typeset as _}; +use ui::Avatar; + +use crate::nav::Nav; /// Maximum number of members a chat can have. const MAX_MEMBERS: usize = 3; @@ -337,12 +338,13 @@ impl NewChat { Nav::new(start + offset) .label(profile.name()) - .text_style(TextStyle::Callout) + .text_xs() .when(!AppSettings::get_hide_avatar(cx), |this| { - this.prefix(match profile.avatar() { - Some(picture) => Avatar::new().src(picture).small().into_any_element(), - None => Avatar::new().small().flex_shrink_0().into_any_element(), - }) + this.prefix( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ) }) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_member(&public_key, cx); @@ -367,12 +369,14 @@ impl NewChat { .p_0p5() .rounded_full() .bg(cx.theme().secondary) - .text_style(TextStyle::Caption2) + .text_xs() + .font_medium() .hover(|this| this.bg(cx.theme().primary)) - .child(match profile.avatar() { - Some(picture) => Avatar::new().src(picture).xsmall().into_any_element(), - None => Avatar::new().xsmall().flex_shrink_0().into_any_element(), - }) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .xsmall(), + ) .child(div().max_w_32().truncate().child(profile.name())) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_member(&public_key, cx); @@ -411,16 +415,11 @@ impl Render for NewChat { }) .map(|this| { if let Some(error) = self.error.clone() { - this.child( - div() - .text_style(TextStyle::Caption) - .text_color(cx.theme().danger) - .child(error), - ) + this.child(div().text_xs().text_color(cx.theme().danger).child(error)) } else { this.child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(notice), ) diff --git a/crates/workspace/src/dialogs/new_group.rs b/crates/workspace/src/dialogs/new_group.rs index 02230c54..85506346 100644 --- a/crates/workspace/src/dialogs/new_group.rs +++ b/crates/workspace/src/dialogs/new_group.rs @@ -7,20 +7,18 @@ use gpui::{ }; use gpui_base::{Popup, Select}; use gpui_kit::assets::IconName; -use gpui_kit::component::ActiveTheme; -use gpui_kit::component::avatar::Avatar; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::form::field; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_kit::component::separator::Separator; use gpui_kit::component::switch::Switch; -use gpui_kit::component::{Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; +use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use settings::AppSettings; use state::upload; -use ui::divider; -use ui::{TextStyle, Typeset as _, button_radius, control_radius}; +use ui::Avatar; const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"]; const PRIVATE_DESC: &str = "Only members can read group messages"; @@ -349,7 +347,7 @@ impl NewGroup { if let Some(parent) = &self.parent { return v_flex().gap_1p5().child(field_label("Relay", cx)).child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(format!( "Created on {} (same relay as the parent group).", @@ -367,8 +365,8 @@ impl NewGroup { .w_full() .h_8() .px_3() - .rounded(px(button_radius())) - .text_style(TextStyle::Callout) + .rounded(cx.theme().radius) + .text_xs() .cursor_default() .bg(if open { cx.theme().list_active @@ -428,8 +426,8 @@ impl NewGroup { .id(SharedString::from(format!("option-{relay}"))) .px_1p5() .py_1() - .rounded(px(button_radius())) - .text_style(TextStyle::Callout) + .rounded(cx.theme().radius) + .text_xs() .cursor_default() .hover(|this| this.bg(cx.theme().primary)) .child(relay_label(relay)) @@ -437,14 +435,14 @@ impl NewGroup { this.activate_relay_option(index, cx); })) })) - .child(divider(cx)) + .child(Separator::horizontal()) .child( h_flex() .id("relay-add") .px_1p5() .py_1() - .rounded(px(button_radius())) - .text_style(TextStyle::Callout) + .rounded(cx.theme().radius) + .text_xs() .cursor_default() .child("Custom relay") .hover(|this| this.bg(cx.theme().primary)) @@ -542,7 +540,7 @@ impl NewGroup { .child( div() .flex_1() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().danger) .child(error.to_owned()), ) @@ -553,7 +551,7 @@ impl NewGroup { .cursor_pointer() .flex_shrink_0() .underline() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().danger) .child("Open relay website") .on_click({ @@ -597,12 +595,11 @@ impl Render for NewGroup { h_flex() .items_center() .gap_3() - .child(match self.picture.clone() { - Some(picture) => { - Avatar::new().src(picture).large().into_any_element() - } - None => Avatar::new().large().into_any_element(), - }) + .child( + Avatar::new(self.picture.clone()) + .seed(self.name_input.read(cx).value().trim().to_owned()) + .large(), + ) .child( Button::new("upload-picture") .label(if self.picture.is_some() { @@ -659,7 +656,7 @@ fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div { fn field_label(text: &'static str, cx: &App) -> Div { div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .font_weight(gpui::FontWeight::SEMIBOLD) .child(text) @@ -669,7 +666,7 @@ fn switch_row(switch: impl IntoElement, cx: &App) -> Div { div() .w_full() .p_2() - .rounded(px(control_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().secondary) .child(switch) } diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index 6ae2eac5..f7f7384a 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -1,10 +1,3 @@ -use gpui_kit::assets::IconName; -use gpui_kit::component::avatar::Avatar; -use gpui_kit::component::button::{Button, ButtonVariants}; -use gpui_kit::component::dock::DockPlacement; -use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState}; -use gpui_kit::component::notification::Notification; -use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use std::str::FromStr; use anyhow::Error; @@ -12,14 +5,20 @@ 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, px, retain_all, + PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all, }; +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::dock::DockPlacement; +use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState}; +use gpui_kit::component::notification::Notification; +use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use instant::Duration; use nostr_sdk::prelude::*; use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use settings::AppSettings; use state::{NostrRegistry, upload}; -use ui::{TextStyle, Typeset as _, button_radius}; +use ui::Avatar; use crate::Workspace; @@ -374,10 +373,7 @@ impl Render for ProfileDialog { .items_center() .justify_center() .bg(cx.theme().sidebar) - .child(match avatar { - Some(picture) => Avatar::new().src(picture).large().into_any_element(), - None => Avatar::new().large().into_any_element(), - }) + .child(Avatar::new(avatar).seed(profile.avatar_seed()).large()) .when(self.editing, |this| { this.child( Button::new("upload") @@ -400,9 +396,9 @@ impl Render for ProfileDialog { .w_full() .justify_center() .gap_3() - .rounded(px(button_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().sidebar) - .text_style(TextStyle::Callout) + .text_xs() .child(shorten_pkey) .child( Button::new("copy") @@ -537,7 +533,7 @@ impl Render for ProfileDialog { fn label(label: &'static str, cx: &App) -> Div { div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .font_weight(gpui::FontWeight::SEMIBOLD) .child(label) @@ -554,10 +550,8 @@ fn field( None => (SharedString::from(placeholder), cx.theme().muted_foreground), }; - v_flex().gap_1p5().child(label(text, cx)).child( - div() - .text_style(TextStyle::Callout) - .text_color(color) - .child(content), - ) + v_flex() + .gap_1p5() + .child(label(text, cx)) + .child(div().text_xs().text_color(color).child(content)) } diff --git a/crates/workspace/src/dialogs/relays.rs b/crates/workspace/src/dialogs/relays.rs index 78b112e0..c91aed1a 100644 --- a/crates/workspace/src/dialogs/relays.rs +++ b/crates/workspace/src/dialogs/relays.rs @@ -1,4 +1,5 @@ use gpui_kit::assets::IconName; +use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; @@ -12,13 +13,12 @@ use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, ClickEvent, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign, - WeakEntity, Window, div, px, rems, + WeakEntity, Window, div, rems, }; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; -use ui::{TextStyle, Typeset as _, button_radius, panel_radius}; const GOSSIP_MSG: &str = "Gossip Relays are the relays where you publish all your events and discover events from others."; const INBOX_MSG: &str = "Messaging Relays host your private messages. Others find these relays and send you messages there."; @@ -108,10 +108,10 @@ impl RelayManager { let manager = cx.entity().downgrade(); v_flex() - .gap(px(ui::SPACE)) + .gap_2() .child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(GOSSIP_MSG)), ) @@ -132,7 +132,7 @@ impl RelayManager { .tooltip("Relay metadata") .ghost() .h(rems(2.)) - .text_style(TextStyle::Caption) + .text_xs() .dropdown_menu(move |menu, _window, _cx| { menu.item(metadata_item(&manager, RelayMetadata::Read)) .item(metadata_item(&manager, RelayMetadata::Write)) @@ -153,7 +153,7 @@ impl RelayManager { this.child( div() .italic() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().danger) .child(error.clone()), ) @@ -197,10 +197,10 @@ impl RelayManager { fn render_inbox(&mut self, cx: &mut Context) -> impl IntoElement { v_flex() - .gap(px(ui::SPACE)) + .gap_2() .child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(INBOX_MSG)), ) @@ -224,7 +224,7 @@ impl RelayManager { this.child( div() .italic() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().danger) .child(error.clone()), ) @@ -607,7 +607,7 @@ impl Render for RelayManager { v_flex() .gap_4() - .text_style(TextStyle::Callout) + .text_xs() .child(self.render_tab_bar(cx)) .child(content) .child(self.render_actions(cx)) @@ -646,19 +646,20 @@ fn relay_row( .h_8() .px_2() .justify_between() - .rounded(px(button_radius())) + .rounded(cx.theme().radius) .bg(cx.theme().sidebar) .child( h_flex() .gap_1() - .text_style(TextStyle::Callout) + .text_xs() .child(label) .when_some(badge, |this, badge| { this.child( div() .p_0p5() .rounded_xs() - .text_style(TextStyle::Caption2) + .text_xs() + .font_medium() .child(badge), ) }), @@ -681,8 +682,8 @@ fn empty_relays(cx: &App) -> impl IntoElement { .border_2() .border_dashed() .border_color(cx.theme().border) - .rounded(px(panel_radius())) - .text_style(TextStyle::Callout) + .rounded(cx.theme().radius_lg) + .text_xs() .text_align(TextAlign::Center) .child(SharedString::from("Please add some relays.")) } diff --git a/crates/workspace/src/dialogs/restore.rs b/crates/workspace/src/dialogs/restore.rs index 18535158..9cfe049a 100644 --- a/crates/workspace/src/dialogs/restore.rs +++ b/crates/workspace/src/dialogs/restore.rs @@ -11,7 +11,6 @@ use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex}; use instant::Duration; use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; -use ui::{TextStyle, Typeset as _}; #[derive(Debug)] pub struct RestoreEncryption { @@ -101,11 +100,11 @@ impl Render for RestoreEncryption { v_flex() .size_full() .gap_3() - .text_style(TextStyle::Callout) + .text_xs() .child( v_flex() .gap_1() - .text_style(TextStyle::Callout) + .text_xs() .text_color(cx.theme().muted_foreground) .child("Secret Key") .child(Input::new(&self.key_input)), @@ -125,7 +124,7 @@ impl Render for RestoreEncryption { .when_some(self.error.read(cx).as_ref(), |this, error| { this.child( div() - .text_style(TextStyle::Caption) + .text_xs() .text_center() .text_color(cx.theme().danger) .child(error.clone()), diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 7788f367..0029aa5b 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -1,8 +1,3 @@ -use gpui_kit::assets::IconName; -use gpui_kit::component::avatar::Avatar; -use gpui_kit::component::button::{Button, ButtonVariants}; -use gpui_kit::component::spinner::Spinner; -use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use std::collections::HashMap; use anyhow::Error; @@ -12,12 +7,16 @@ use gpui::{ App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list, }; +use gpui_kit::assets::IconName; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::spinner::Spinner; +use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use instant::Duration; use nostr_sdk::prelude::*; use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use smallvec::{SmallVec, smallvec}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; -use ui::{TextStyle, Typeset as _, button_radius}; +use ui::Avatar; pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Screening::new(public_key, window, cx)) @@ -311,15 +310,14 @@ impl Screening { .w_full() .px_2() .gap_1p5() - .rounded(px(button_radius())) - .text_style(TextStyle::Callout) + .rounded(cx.theme().radius) + .text_xs() .hover(|this| this.bg(cx.theme().primary)) - .child(match profile.avatar() { - Some(picture) => { - Avatar::new().src(picture).small().into_any_element() - } - None => Avatar::new().small().into_any_element(), - }) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ) .child(profile.name()), ); } @@ -371,10 +369,11 @@ impl Render for Screening { .items_center() .justify_center() .text_center() - .child(match profile.avatar() { - Some(picture) => Avatar::new().src(picture).large().into_any_element(), - None => Avatar::new().large().into_any_element(), - }) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .large(), + ) .child( div() .font_weight(gpui::FontWeight::SEMIBOLD) @@ -393,7 +392,7 @@ impl Render for Screening { .justify_center() .rounded_full() .bg(cx.theme().secondary) - .text_style(TextStyle::Callout) + .text_xs() .truncate() .text_ellipsis() .text_center() @@ -430,36 +429,33 @@ impl Render for Screening { .child( h_flex() .items_start() - .gap(px(ui::SPACE)) - .text_style(TextStyle::Callout) + .gap_2() + .text_xs() .child(status_badge(Some(self.followed), cx)) .child( - v_flex() - .text_style(TextStyle::Callout) - .child("Contact") - .child( - div() - .line_clamp(1) - .text_color(cx.theme().muted_foreground) - .child({ - if self.followed { - SharedString::from(CONTACT) - } else { - SharedString::from(NOT_CONTACT) - } - }), - ), + v_flex().text_xs().child("Contact").child( + div() + .line_clamp(1) + .text_color(cx.theme().muted_foreground) + .child({ + if self.followed { + SharedString::from(CONTACT) + } else { + SharedString::from(NOT_CONTACT) + } + }), + ), ), ) .child( h_flex() .items_start() - .gap(px(ui::SPACE)) - .text_style(TextStyle::Callout) + .gap_2() + .text_xs() .child(status_badge(last_active, cx)) .child( v_flex() - .text_style(TextStyle::Callout) + .text_xs() .child( h_flex() .gap_0p5() @@ -493,11 +489,11 @@ impl Render for Screening { .child( h_flex() .items_start() - .gap(px(ui::SPACE)) + .gap_2() .child(status_badge(Some(self.verified), cx)) .child( v_flex() - .text_style(TextStyle::Callout) + .text_xs() .child({ if let Some(addr) = self.address(cx) { SharedString::from(format!("{} validation", addr)) @@ -528,11 +524,11 @@ impl Render for Screening { .child( h_flex() .items_start() - .gap(px(ui::SPACE)) + .gap_2() .child(status_badge(Some(mutuals > 0), cx)) .child( h_flex() - .text_style(TextStyle::Callout) + .text_xs() .child( div() .line_clamp(1) diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index 83fc0087..a3bc831c 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -4,6 +4,7 @@ use gpui::{ SharedString, Styled, Window, div, }; use gpui_kit::assets::IconName; +use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::form::field; use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants}; @@ -14,7 +15,6 @@ use gpui_kit::component::switch::Switch; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use settings::AppSettings; use settings::AppearanceMode; -use ui::{TextStyle, Typeset as _}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Preferences::new(window, cx)) @@ -149,14 +149,10 @@ impl Render for Preferences { .justify_between() .child( v_flex() + .child(div().text_xs().child(SharedString::from("Mode"))) .child( div() - .text_style(TextStyle::Callout) - .child(SharedString::from("Mode")), - ) - .child( - div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(MODE)), ), @@ -204,14 +200,10 @@ impl Render for Preferences { v_flex() .flex_1() .min_w_0() + .child(div().text_xs().child(SharedString::from("Image"))) .child( div() - .text_style(TextStyle::Callout) - .child(SharedString::from("Image")), - ) - .child( - div() - .text_style(TextStyle::Caption) + .text_xs() .text_color(cx.theme().muted_foreground) .truncate() .child(SharedString::from(background_label)), @@ -288,7 +280,8 @@ impl Render for Preferences { ) .child( div() - .text_style(TextStyle::Caption2) + .text_xs() + .font_medium() .italic() .text_color(cx.theme().muted_foreground) .child(SharedString::from("Only support blossom service")), diff --git a/crates/workspace/src/dock/invalid_panel.rs b/crates/workspace/src/dock/invalid_panel.rs new file mode 100644 index 00000000..267527d2 --- /dev/null +++ b/crates/workspace/src/dock/invalid_panel.rs @@ -0,0 +1,66 @@ +use gpui::{ + App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement as _, Render, + SharedString, Styled as _, Window, div, +}; +use gpui_base::dock::PanelState; +use gpui_kit::component::ActiveTheme as _; +use gpui_kit::component::dock::{BasePanel, Panel, PanelEvent}; + +/// Stands in for a panel this build cannot construct. +/// It returns the state it was handed, so the layout survives a load and save. +pub(crate) struct InvalidPanel { + name: SharedString, + focus_handle: FocusHandle, + old_state: PanelState, +} + +impl InvalidPanel { + pub(crate) fn new( + name: impl Into, + state: PanelState, + cx: &mut Context, + ) -> Self { + Self { + focus_handle: cx.focus_handle(), + name: name.into(), + old_state: state, + } + } +} + +impl BasePanel for InvalidPanel { + fn panel_name(&self) -> &'static str { + "InvalidPanel" + } + + fn dump(&self, _: &App) -> PanelState { + self.old_state.clone() + } +} + +impl Panel for InvalidPanel {} + +impl EventEmitter for InvalidPanel {} + +impl Focusable for InvalidPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InvalidPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .my_6() + .flex() + .flex_col() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child(format!( + "The `{}` panel type is not registered in PanelRegistry.", + self.name.clone() + )) + } +} diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock/mod.rs similarity index 82% rename from crates/workspace/src/dock.rs rename to crates/workspace/src/dock/mod.rs index ee0043b9..7dd33d90 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock/mod.rs @@ -1,11 +1,21 @@ use std::sync::Arc; -use gpui::{App, WeakEntity, Window}; +use gpui::{App, Pixels, WeakEntity, Window, px}; use gpui_kit::component::dock::{ DockArea, DockPlacement, InsertTarget, NodeId, PaneRef, PanelHandle, PanelId, }; -/// The panel in any region of `area` whose id is `key`. +mod invalid_panel; +mod skin; +mod tab_panel; +mod window_controls; + +pub use skin::CoopDockSkin; + +/// The fixed height of the tab bar, which doubles as the window title bar. +pub const TAB_BAR_HEIGHT: Pixels = px(44.); + +/// The panel in any region of `area whose id is `key. fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> { let placements = [ DockPlacement::Center, @@ -36,7 +46,7 @@ fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, Node None } -/// Add `panel` to the area, or move it to the end of its existing tab group. +/// Add `panel to the area, or move it to the end of its existing tab group. pub fn add_panel( area: &mut DockArea, panel: PanelHandle, diff --git a/crates/workspace/src/dock/skin.rs b/crates/workspace/src/dock/skin.rs new file mode 100644 index 00000000..67fc5a9d --- /dev/null +++ b/crates/workspace/src/dock/skin.rs @@ -0,0 +1,269 @@ +use std::cell::Cell; +use std::ops::Deref as _; +use std::rc::Rc; +use std::sync::Arc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _, + IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style, + Styled as _, WeakEntity, Window, div, px, +}; +use gpui_base::dock::{ + DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState, + PanelView, TabGroupRenderer, +}; +use gpui_base::{HandleEdge, resize_handle}; +use gpui_kit::component::ActiveTheme as _; +use gpui_kit::component::dock::panel_handle; + +use super::invalid_panel::InvalidPanel; +use super::tab_panel::CoopTabGroupSkin; + +/// State the skin shares with its per-container renderers. +pub(crate) struct SkinShared { + area: WeakEntity, + toggle_button_visible: Cell, + /// The dock whose resize handle is being dragged, if any. Only one can be. + resizing_dock: Cell>, +} + +impl SkinShared { + pub(crate) fn area(&self) -> &WeakEntity { + &self.area + } + + pub(crate) fn is_toggle_button_visible(&self) -> bool { + self.toggle_button_visible.get() + } + + pub(crate) fn resizing_dock(&self) -> &Cell> { + &self.resizing_dock + } +} + +/// The coop appearance for a [`DockArea]. +/// Install it in the constructor, the only place the area's weak handle is available. +pub struct CoopDockSkin { + shared: Rc, +} + +impl CoopDockSkin { + pub fn new(cx: &mut Context) -> Rc { + Rc::new(Self { + shared: Rc::new(SkinShared { + area: cx.weak_entity(), + toggle_button_visible: Cell::new(true), + resizing_dock: Cell::new(None), + }), + }) + } + + pub(crate) fn shared(&self) -> &Rc { + &self.shared + } +} + +/// Payload a dock's resize handle drags. +/// +/// It draws nothing, the handle element is the visible affordance. +#[derive(Clone)] +struct ResizePanel; + +impl Render for ResizePanel { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} + +impl DockAreaRenderer for CoopDockSkin { + fn frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ + div() + .id("dock-area") + .relative() + .size_full() + .overflow_hidden() + .flex() + .flex_row() + } + + fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful
{ + div() + .id("dock-area-center") + .flex() + .flex_1() + .flex_col() + .overflow_hidden() + } + + fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful
{ + // `size_full and `flex_1 stop the frame collapsing in an unsizing parent. + div() + .id(("dock-split-frame", node.as_u64())) + .size_full() + .flex_1() + .min_h(px(0.)) + .overflow_hidden() + .bg(cx.theme().tokens.tab_bar) + } + + fn render_dock( + &self, + dock: &DockContext, + content: AnyElement, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + div() + .flex() + .size_full() + .relative() + .child(content) + .child(self.render_resize_handle(dock, window, cx)) + .child(DockResizeTracker { + dock: dock.clone(), + shared: self.shared().clone(), + }) + .into_any_element() + } + + /// Placeholder for a panel this build cannot construct. + /// It dumps the state it was handed, so the layout survives a load and save. + fn build_placeholder( + &self, + state: &PanelState, + _: &mut Window, + cx: &mut App, + ) -> Option> { + let state = state.clone(); + Some(panel_handle(cx.new(|cx| { + InvalidPanel::new(state.panel_name.clone(), state, cx) + }))) + } + + fn tab_group_renderer(&self) -> Rc { + Rc::new(CoopTabGroupSkin::new(self.shared().clone())) + } +} + +impl CoopDockSkin { + fn render_resize_handle( + &self, + dock: &DockContext, + _: &mut Window, + _: &mut App, + ) -> impl IntoElement { + let placement = dock.placement(); + let shared = self.shared().clone(); + + resize_handle("resize-handle", placement.axis()) + .when(placement.is_left(), |this| { + this.inside(HandleEdge::Trailing) + }) + .when(placement.is_right() || placement.is_bottom(), |this| { + this.inside(HandleEdge::Leading) + }) + .on_drag(ResizePanel, move |info, _, _, cx| { + cx.stop_propagation(); + shared.resizing_dock().set(Some(placement)); + cx.new(|_| info.deref().clone()) + }) + } +} + +/// Turns the window's mouse stream into dock resizing. +/// It draws nothing, the `paint hook is the only window listener registration point. +struct DockResizeTracker { + dock: DockContext, + shared: Rc, +} + +impl IntoElement for DockResizeTracker { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for DockResizeTracker { + type PrepaintState = (); + type RequestLayoutState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Window, + _: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: gpui::Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + _: &mut App, + ) { + let placement = self.dock.placement(); + + window.on_mouse_event({ + let dock = self.dock.clone(); + let shared = self.shared.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { + return; + } + // Dragging a closed dock's handle reopens it. + // Read the live state, the snapshot in `dock would toggle it shut again. + let open = shared + .area() + .upgrade() + .is_some_and(|area| area.read(cx).is_dock_open(placement)); + if !open { + dock.toggle(window, cx); + } + dock.resize_to(event.position, window, cx); + } + }); + + window.on_mouse_event({ + let shared = self.shared.clone(); + move |_: &MouseUpEvent, phase, _, cx| { + if !phase.bubble() || shared.resizing_dock().get() != Some(placement) { + return; + } + shared.resizing_dock().set(None); + // The size lives on the dock, not the layout tree. + // Nothing else tells a subscriber to persist it. + _ = shared + .area() + .update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged)); + } + }); + } +} diff --git a/crates/workspace/src/dock/tab_panel.rs b/crates/workspace/src/dock/tab_panel.rs new file mode 100644 index 00000000..a086c4dd --- /dev/null +++ b/crates/workspace/src/dock/tab_panel.rs @@ -0,0 +1,779 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, + Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render, + ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, + Styled as _, Window, div, px, size, +}; +use gpui_base::dock::{ + AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef, + PanelView as BasePanelView, TabGroupContext, TabGroupRenderer, +}; +use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs}; +use gpui_kit::assets::IconName; +use gpui_kit::component::animation::{Lerp as _, ease_out_cubic}; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom}; +use gpui_kit::component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; +use ui::title_bar_drag_handlers; + +use super::skin::SkinShared; +use super::{TAB_BAR_HEIGHT, window_controls}; + +const DRAG_PREVIEW_SIZE: gpui::Size = size(px(96.), px(30.)); +const ACTIVE_TAB_SELECTOR: &str = "coop-dock-active-tab"; +const ACTIVE_TAB_CLOSE_SELECTOR: &str = "coop-dock-active-tab-close"; +const TAB_HOVER_GROUP: &str = "coop-dock-tab"; + +/// A panel's title, or its registered name when the panel has no handle. +pub(crate) fn panel_title( + panel: &Arc, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + match PanelHandle::of(panel) { + Some(handle) => handle.title(window, cx), + None => SharedString::from(panel.panel_name(cx)).into_any_element(), + } +} + +/// The preview that follows the cursor while a panel is dragged. +struct DragPanelPreview { + panel: Arc, +} + +impl Render for DragPanelPreview { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .id("drag-panel") + .cursor_grab() + .p_1() + .whitespace_nowrap() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius_lg) + .text_color(cx.theme().tab_foreground) + .bg(cx.theme().tokens.tab_active) + .opacity(0.75) + .child(panel_title(&self.panel, window, cx)) + } +} + +/// The zoom affordance `panel offers, if any. +/// +/// The panel must offer a control and be zoomable, base refuses a zoom otherwise. +fn panel_zoom_control(panel: &Arc, cx: &App) -> Option { + panel + .zoomable(cx) + .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx))) + .flatten() +} + +/// The left-most, top-most tab group in a container. +/// +/// A left dock's collapse button lives in this group. +fn left_top_group(node: &PaneNode) -> Option { + match node.kind() { + PaneRef::Tabs { .. } => Some(node.id()), + PaneRef::Split { children, .. } => children.first().and_then(left_top_group), + } +} + +/// The right-most, top-most tab group. +/// +/// A vertical split picks its first child, a horizontal split picks its last. +fn right_top_group(node: &PaneNode) -> Option { + match node.kind() { + PaneRef::Tabs { .. } => Some(node.id()), + PaneRef::Split { axis, children, .. } => match axis { + gpui::Axis::Vertical => children.first(), + gpui::Axis::Horizontal => children.last(), + } + .and_then(right_top_group), + } +} + +/// The bottom or right dock whose root tab group is this one, if any. +/// +/// Base keeps a dock's last group, so the skin removes these docks as a whole. +fn dock_root_group( + shared: &SkinShared, + group: &TabGroupContext, + cx: &App, +) -> Option { + let area = shared.area().upgrade()?; + let area = area.read(cx); + + [DockPlacement::Bottom, DockPlacement::Right] + .into_iter() + .find(|placement| { + area.layout(*placement) + .is_some_and(|tree| tree.root().id() == group.node()) + }) +} + +/// Close `panel, removing its dock instead when base would refuse an empty one. +/// +/// A bottom or right dock's only panel is that case, so the whole dock goes. +fn close_panel( + shared: &SkinShared, + group: &TabGroupContext, + panel: &Arc, + window: &mut Window, + cx: &mut App, +) { + if !panel.closable(cx) { + return; + } + + let dock = (group.panels().len() <= 1) + .then(|| dock_root_group(shared, group, cx)) + .flatten(); + + match dock { + Some(placement) => { + _ = shared.area().update(cx, |area, cx| { + area.remove_dock(placement, window, cx); + }); + } + None => group.close(panel.panel_id(cx), window, cx), + } +} + +/// One tab group's appearance, built once per container so its geometry is its own. +pub(crate) struct CoopTabGroupSkin { + shared: Rc, + scroll_handle: ScrollHandle, + /// The tab shown last frame, so a change scrolls the new one into view. + last_active_ix: Cell>, + /// Bounds of the title bar row, measured to place the title-bar drag overlay. + title_bar_bounds: Rc>>>, + /// Bounds of the empty strip after the last tab, where the drag region starts. + title_bar_strip_bounds: Rc>>>, + /// Bounds of the suffix area, where the drag region ends. + title_bar_suffix_bounds: Rc>>>, +} + +impl CoopTabGroupSkin { + pub(crate) fn new(shared: Rc) -> Self { + Self { + shared, + scroll_handle: ScrollHandle::default(), + last_active_ix: Cell::new(None), + title_bar_bounds: Rc::new(Cell::new(None)), + title_bar_strip_bounds: Rc::new(Cell::new(None)), + title_bar_suffix_bounds: Rc::new(Cell::new(None)), + } + } + + /// A group that is the left dock's only group, with one panel, draws no chrome. + /// The sidebar is one. + fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool { + let Some(area) = self.shared.area().upgrade() else { + return false; + }; + let area = area.read(cx); + let Some(left) = area + .layout(DockPlacement::Left) + .map(|tree| tree.root().id()) + else { + return false; + }; + left == group.node() && group.panels().len() == 1 + } + + /// The tab's drag payload, or `None when the group must not be rearranged. + /// + /// A locked group never is, a bottom or right dock root always is. + fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option { + if group.is_locked() { + return None; + } + if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() { + return None; + } + group.drag_panel(ix, cx) + } + + /// A dock's collapse button for this group's bar, or `None when it does not belong. + /// The icon direction depends on whether the dock is open. + fn dock_toggle_button( + &self, + placement: DockPlacement, + group: &TabGroupContext, + cx: &mut App, + ) -> Option