feat: adopt the native OS theme #66
+48
-61
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
||||
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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<Group>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
@@ -22,10 +21,7 @@ fn body(group: &WeakEntity<Group>, 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<SharedString>, 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()
|
||||
|
||||
@@ -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<Group>, input: Entity<InputState>, 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<Group>, input: Entity<InputState>, cx: &App) -> impl I
|
||||
fn invite_row(group: WeakEntity<Group>, 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());
|
||||
|
||||
|
||||
+32
-39
@@ -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<Group>, 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<Group>, 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(),
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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<Group>, 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<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||
|
||||
let mut section = v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Children");
|
||||
|
||||
|
||||
@@ -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<InputState>, 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))
|
||||
|
||||
+19
-24
@@ -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()),
|
||||
),
|
||||
|
||||
@@ -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<str>) -> 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<Size>) -> 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<Item = &'a Vec<Point<Pixels>>>,
|
||||
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<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
|
||||
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<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 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<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
|
||||
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<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> 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<Pixels>,
|
||||
end: Point<Pixels>,
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
) -> Option<Point<Pixels>> {
|
||||
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<ImageSource>,
|
||||
seed: Option<String>,
|
||||
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<SharedString>) -> Self {
|
||||
Self::from_source(picture)
|
||||
}
|
||||
|
||||
/// Creates an avatar from an already-resolved source, which may be missing.
|
||||
pub fn from_source(picture: Option<impl Into<ImageSource>>) -> Self {
|
||||
Self::from_picture(picture.map(Into::into))
|
||||
}
|
||||
|
||||
fn from_picture(picture: Option<ImageSource>) -> 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<String>) -> Self {
|
||||
self.seed = Some(seed.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Avatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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.);
|
||||
+2
-12
@@ -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;
|
||||
|
||||
@@ -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<Instant>,
|
||||
leases: HashMap<EntityId, Lease>,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
impl Global for PulseClock {}
|
||||
|
||||
impl PulseClock {
|
||||
fn next_wake(&self) -> Option<Instant> {
|
||||
self.leases
|
||||
.values()
|
||||
.map(|lease| lease.due.min(lease.until))
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
/// A component's line back to the view that paints it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Painter(EntityId);
|
||||
|
||||
impl Painter {
|
||||
/// The view this render belongs to; take it once where the state is built.
|
||||
pub fn of<T: 'static>(cx: &Context<T>) -> Self {
|
||||
Self(cx.entity_id())
|
||||
}
|
||||
|
||||
/// Redraw this view once.
|
||||
pub fn notify(self, cx: &mut App) {
|
||||
cx.notify(self.0);
|
||||
}
|
||||
|
||||
/// Claim `fps` redraws a second; renew from `render` so an unmounted
|
||||
/// element drops off.
|
||||
pub fn lease(self, fps: f32, until: Duration, cx: &mut App) {
|
||||
lease(self.0, fps, until, cx);
|
||||
}
|
||||
|
||||
/// Whether the clock is what asked for the render running now.
|
||||
pub fn woken(self, cx: &App) -> bool {
|
||||
cx.try_global::<PulseClock>()
|
||||
.and_then(|clock| clock.leases.get(&self.0))
|
||||
.is_some_and(|lease| lease.in_flight)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EntityId> for Painter {
|
||||
fn from(entity_id: EntityId) -> Self {
|
||||
Self(entity_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Painter> for EntityId {
|
||||
fn from(painter: Painter) -> Self {
|
||||
painter.0
|
||||
}
|
||||
}
|
||||
|
||||
fn lease(view: EntityId, fps: f32, until: Duration, cx: &mut App) {
|
||||
if cx.pause_when_inactive() && cx.active_window().is_none() {
|
||||
return;
|
||||
}
|
||||
let now = cx.background_executor().now();
|
||||
let period = Duration::from_secs_f32(1.0 / fps.clamp(1.0, 240.0));
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock
|
||||
.leases
|
||||
.entry(view)
|
||||
.and_modify(|lease| {
|
||||
lease.period = lease.period.min(period);
|
||||
lease.due = lease.due.min(now + period);
|
||||
lease.in_flight = false;
|
||||
lease.until = lease.until.max(now + until);
|
||||
})
|
||||
.or_insert(Lease {
|
||||
period,
|
||||
due: now + period,
|
||||
in_flight: false,
|
||||
until: now + until,
|
||||
});
|
||||
if clock.running {
|
||||
return;
|
||||
}
|
||||
clock.running = true;
|
||||
cx.spawn(async move |cx| {
|
||||
loop {
|
||||
let sleep = cx.update(|cx| {
|
||||
let now = cx.background_executor().now();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock
|
||||
.next_wake()
|
||||
.map(|wake| wake.saturating_duration_since(now).max(MIN_SLEEP))
|
||||
});
|
||||
let Some(sleep) = sleep else { break };
|
||||
cx.background_executor().timer(sleep).await;
|
||||
let parked = cx.update(|cx| {
|
||||
let now = cx.background_executor().now();
|
||||
tick_hover_fades();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock.leases.retain(|_, lease| lease.until > now);
|
||||
if clock.leases.is_empty() {
|
||||
clock.running = false;
|
||||
return true;
|
||||
}
|
||||
let mut owed = Vec::new();
|
||||
for (view, lease) in clock.leases.iter_mut() {
|
||||
if lease.due > now {
|
||||
continue;
|
||||
}
|
||||
lease.due += lease.period;
|
||||
if lease.due <= now {
|
||||
lease.due = now + lease.period;
|
||||
}
|
||||
lease.in_flight = true;
|
||||
owed.push(*view);
|
||||
}
|
||||
for view in owed {
|
||||
cx.notify(view);
|
||||
}
|
||||
false
|
||||
});
|
||||
if parked {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Current phase `[0,1)` of a repeating spec; reduced motion returns a
|
||||
/// static 0 and schedules nothing.
|
||||
pub fn pulse_delta(spec: &MotionSpec, painter: Painter, cx: &mut App) -> f32 {
|
||||
if cx.reduced_motion() {
|
||||
return 0.0;
|
||||
}
|
||||
painter.lease(PULSE_FPS, PULSE_LEASE, cx);
|
||||
let now = cx.background_executor().now();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
let epoch = *clock.epoch.get_or_insert(now);
|
||||
let period = spec.total().as_secs_f32();
|
||||
((now - epoch).as_secs_f32() / period).fract()
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing function, evaluated exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CubicBezier {
|
||||
pub x1: f32,
|
||||
pub y1: f32,
|
||||
pub x2: f32,
|
||||
pub y2: f32,
|
||||
}
|
||||
|
||||
impl CubicBezier {
|
||||
pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
|
||||
Self { x1, y1, x2, y2 }
|
||||
}
|
||||
|
||||
fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
|
||||
let c = 3.0 * a;
|
||||
let bb = 3.0 * (b - a) - c;
|
||||
let aa = 1.0 - c - bb;
|
||||
(aa, bb, c)
|
||||
}
|
||||
|
||||
fn sample_x(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.x1, self.x2);
|
||||
((a * t + b) * t + c) * t
|
||||
}
|
||||
|
||||
fn sample_y(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.y1, self.y2);
|
||||
((a * t + b) * t + c) * t
|
||||
}
|
||||
|
||||
fn sample_x_derivative(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.x1, self.x2);
|
||||
(3.0 * a * t + 2.0 * b) * t + c
|
||||
}
|
||||
|
||||
fn solve_t_for_x(&self, x: f32) -> f32 {
|
||||
let mut t = x;
|
||||
for _ in 0..8 {
|
||||
let err = self.sample_x(t) - x;
|
||||
if err.abs() < 1e-6 {
|
||||
return t;
|
||||
}
|
||||
let d = self.sample_x_derivative(t);
|
||||
if d.abs() < 1e-6 {
|
||||
break;
|
||||
}
|
||||
t -= err / d;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
|
||||
for _ in 0..32 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if self.sample_x(mid) < x {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
(lo + hi) / 2.0
|
||||
}
|
||||
|
||||
/// Eased output for input progress `x ∈ [0,1]`, clamped hard — gpui's
|
||||
/// animation element asserts its delta is in `[0,1]` and aborts.
|
||||
pub fn eval(&self, x: f32) -> f32 {
|
||||
if x <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if x >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// This curve as a gpui easing closure.
|
||||
pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
|
||||
move |x| self.eval(x)
|
||||
}
|
||||
}
|
||||
|
||||
/// The signature entrance curve — CSS `cubic-bezier(0.16, 1, 0.3, 1)`.
|
||||
pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
|
||||
/// CSS `ease-out` — width/height transitions.
|
||||
pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
|
||||
/// CSS `ease` — quick fades, menu/dialog pops.
|
||||
pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
|
||||
/// Sidebar resort glide — CSS `cubic-bezier(0.22, 1, 0.36, 1)`.
|
||||
pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
|
||||
/// CSS `ease-in-out`.
|
||||
pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
|
||||
/// Tailwind's default transition curve — CSS `cubic-bezier(0.4, 0, 0.2, 1)`.
|
||||
pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
|
||||
|
||||
/// One catalog entry: duration + optional delay + curve; gpui has no native
|
||||
/// delay, so `delay_ms` holds progress at 0 until it elapses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MotionSpec {
|
||||
pub duration_ms: u64,
|
||||
pub delay_ms: u64,
|
||||
pub curve: CubicBezier,
|
||||
}
|
||||
|
||||
impl MotionSpec {
|
||||
pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
|
||||
Self {
|
||||
duration_ms,
|
||||
delay_ms: 0,
|
||||
curve,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_delay(mut self, delay_ms: u64) -> Self {
|
||||
self.delay_ms = delay_ms;
|
||||
self
|
||||
}
|
||||
|
||||
/// Wall-clock span of the whole timeline (delay + duration).
|
||||
pub fn total(&self) -> Duration {
|
||||
Duration::from_millis(self.delay_ms + self.duration_ms)
|
||||
}
|
||||
|
||||
/// Eased progress (0..1) for a raw timeline delta (0..1 across `total`).
|
||||
pub fn progress(&self, raw_delta: f32) -> f32 {
|
||||
let total = (self.delay_ms + self.duration_ms) as f32;
|
||||
if total <= 0.0 || self.duration_ms == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let t =
|
||||
(raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
|
||||
self.curve.eval(t.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A oneshot gpui [`Animation`] for this spec.
|
||||
pub fn animation(&self) -> Animation {
|
||||
let spec = *self;
|
||||
Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entrances: 0.5s expo-out fade + 4px rise.
|
||||
pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
|
||||
/// Quick fade: 0.15s.
|
||||
pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
|
||||
/// Popover-in: 0.14s.
|
||||
pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
|
||||
/// Popover-out: 0.1s — quicker than the entrance.
|
||||
pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
|
||||
/// Dialog-in: 0.18s.
|
||||
pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
|
||||
/// Boot-splash exit: 0.5s fade + 6px lift after a 0.15s hold.
|
||||
pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
|
||||
/// Sidebar / pane width+height transitions: 200ms ease-out.
|
||||
pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
|
||||
/// Tab drag-reorder sliding transforms: 150ms.
|
||||
pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
|
||||
/// Per-pane collapse: 180ms height.
|
||||
pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
|
||||
/// A gliding move the reader caused rather than one they are dragging.
|
||||
pub const LAYOUT: MotionSpec = MotionSpec::new(200, EASE_OUT);
|
||||
/// Chevron rotate: 200ms.
|
||||
pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
|
||||
/// Scroll-to-row glide: 500ms ease-in-out.
|
||||
pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
|
||||
/// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`].
|
||||
pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
|
||||
/// Toast slide/fade, coop's own: 250ms over [`EASE_TAILWIND`].
|
||||
pub const NOTIFICATION_IN: MotionSpec = MotionSpec::new(250, EASE_TAILWIND);
|
||||
/// Pulse loader period: 2.4s.
|
||||
pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
|
||||
/// Spinner wave period: 750ms.
|
||||
pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
|
||||
|
||||
/// Standard entrance: opacity 0→1 + translateY 4→0 over [`FADE_IN`].
|
||||
pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, FADE_IN.animation(), |el, t| {
|
||||
el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Quick opacity-only fade over [`FADE_QUICK`].
|
||||
pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
|
||||
}
|
||||
|
||||
/// Popover entrance: fade + translateY −2→0 over [`MENU_IN`].
|
||||
pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, MENU_IN.animation(), |el, t| {
|
||||
el.relative()
|
||||
.opacity(0.3 + 0.7 * t)
|
||||
.top(px(-2.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Popover exit over [`MENU_OUT`]; the caller supplies progress because
|
||||
/// `with_animation` replays from 0 on remount.
|
||||
pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, MENU_OUT.animation(), move |el, _| {
|
||||
el.relative().opacity(1.0 - t).top(px(-2.0 * t))
|
||||
})
|
||||
}
|
||||
|
||||
/// Dialog entrance over [`DIALOG_IN`] (fade + 2px rise).
|
||||
pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, DIALOG_IN.animation(), |el, t| {
|
||||
el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Boot-splash exit: hold 150ms, then fade out + lift 6px over 500ms.
|
||||
pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
|
||||
el.opacity(1.0 - t).top(px(-6.0 * t))
|
||||
})
|
||||
}
|
||||
|
||||
/// Linear interpolation.
|
||||
pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
|
||||
from + (to - from) * t
|
||||
}
|
||||
|
||||
/// One element's hover fade progress over [`HOVER_FADE`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FadeEntry {
|
||||
origin: f32,
|
||||
target: f32,
|
||||
started: Instant,
|
||||
seen: u64,
|
||||
}
|
||||
|
||||
impl FadeEntry {
|
||||
fn value(&self, now: Instant, duration: Duration) -> f32 {
|
||||
let elapsed = now.saturating_duration_since(self.started);
|
||||
if duration.is_zero() || elapsed >= duration {
|
||||
return self.target;
|
||||
}
|
||||
let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||
lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
|
||||
}
|
||||
|
||||
fn settled(&self, now: Instant, duration: Duration) -> bool {
|
||||
self.origin == self.target || now.saturating_duration_since(self.started) >= duration
|
||||
}
|
||||
}
|
||||
|
||||
/// Which fade: the view that paints it plus the element key — two views
|
||||
/// using the same string would trade washes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Fade {
|
||||
pub painter: Painter,
|
||||
pub key: SharedString,
|
||||
}
|
||||
|
||||
impl Fade {
|
||||
pub fn new(painter: Painter, key: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
painter,
|
||||
key: key.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-fade progress store.
|
||||
#[derive(Default)]
|
||||
pub struct HoverFades {
|
||||
pub entries: HashMap<Fade, FadeEntry>,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl HoverFades {
|
||||
pub fn duration() -> Duration {
|
||||
HOVER_FADE.total().mul_f32(speed_scale())
|
||||
}
|
||||
|
||||
/// Pointer entered (`hovered`) or left the element behind `fade`.
|
||||
pub fn set_at(&mut self, fade: &Fade, hovered: bool, reduced: bool, now: Instant) {
|
||||
let target = if hovered { 1.0 } else { 0.0 };
|
||||
let duration = Self::duration();
|
||||
let current = self
|
||||
.entries
|
||||
.get(fade)
|
||||
.map(|e| e.value(now, duration))
|
||||
.unwrap_or(0.0);
|
||||
if target == 0.0 && !self.entries.contains_key(fade) {
|
||||
return;
|
||||
}
|
||||
let origin = if reduced { target } else { current };
|
||||
let seen = self.frame;
|
||||
self.entries.insert(
|
||||
fade.clone(),
|
||||
FadeEntry {
|
||||
origin,
|
||||
target,
|
||||
started: now,
|
||||
seen,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Hover progress (0..1) for `fade` at `now`; stamps liveness.
|
||||
pub fn value_at(&mut self, fade: &Fade, now: Instant) -> f32 {
|
||||
let frame = self.frame;
|
||||
match self.entries.get_mut(fade) {
|
||||
Some(entry) => {
|
||||
entry.seen = frame;
|
||||
entry.value(now, Self::duration())
|
||||
}
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the frame counter and prune settled or unmounted entries.
|
||||
pub fn tick_at(&mut self, now: Instant) -> bool {
|
||||
self.frame += 1;
|
||||
let frame = self.frame;
|
||||
let duration = Self::duration();
|
||||
let mut active = false;
|
||||
self.entries.retain(|_, entry| {
|
||||
if entry.seen + 1 < frame {
|
||||
return false;
|
||||
}
|
||||
let settled = entry.settled(now, duration);
|
||||
if !settled {
|
||||
active = true;
|
||||
}
|
||||
!(settled && entry.target == 0.0)
|
||||
});
|
||||
active
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
|
||||
}
|
||||
|
||||
/// Hover progress (0..1) for `fade` this frame.
|
||||
pub fn hover_t(fade: &Fade) -> f32 {
|
||||
HOVER_FADES.with(|fades| fades.borrow_mut().value_at(fade, Instant::now()))
|
||||
}
|
||||
|
||||
/// Record a hover flip for `fade`; prefer [`hover_listener`].
|
||||
pub fn set_hover(fade: &Fade, hovered: bool, reduced: bool) {
|
||||
HOVER_FADES.with(|fades| {
|
||||
fades
|
||||
.borrow_mut()
|
||||
.set_at(fade, hovered, reduced, Instant::now())
|
||||
});
|
||||
}
|
||||
|
||||
/// An `.on_hover` listener driving the fade.
|
||||
pub fn hover_listener(fade: Fade) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||
move |hovered, _window, cx| {
|
||||
set_hover(&fade, *hovered, cx.reduced_motion());
|
||||
fade.painter.lease(HOVER_FPS, HoverFades::duration(), cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once-per-tick bookkeeping for the fade store, driven by the clock.
|
||||
fn tick_hover_fades() {
|
||||
HOVER_FADES.with(|fades| {
|
||||
fades.borrow_mut().tick_at(Instant::now());
|
||||
});
|
||||
}
|
||||
|
||||
/// Blend two colors by `t` the way the browser transitions them: sRGB with
|
||||
/// premultiplied alpha, so a wash fading in never passes through grey.
|
||||
pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t <= 0.0 {
|
||||
return from;
|
||||
}
|
||||
if t >= 1.0 {
|
||||
return to;
|
||||
}
|
||||
let (f, g) = (Rgba::from(from), Rgba::from(to));
|
||||
let a = lerp(f.a, g.a, t);
|
||||
if a <= f32::EPSILON {
|
||||
return Hsla::from(Rgba { a: 0.0, ..g });
|
||||
}
|
||||
Hsla::from(Rgba {
|
||||
r: lerp(f.r * f.a, g.r * g.a, t) / a,
|
||||
g: lerp(f.g * f.a, g.g * g.a, t) / a,
|
||||
b: lerp(f.b * f.a, g.b * g.a, t) / a,
|
||||
a,
|
||||
})
|
||||
}
|
||||
|
||||
/// The standard hover blend: rest → hover color at this fade's progress.
|
||||
pub fn hover_blend(fade: &Fade, rest: Hsla, hover: Hsla) -> Hsla {
|
||||
mix(rest, hover, hover_t(fade))
|
||||
}
|
||||
|
||||
/// Process-wide motion speed, as raw `f32` bits; an atomic mirror because the
|
||||
/// timelines are read from free functions with no `cx` in scope.
|
||||
static SPEED: AtomicU32 = AtomicU32::new(1.0f32.to_bits());
|
||||
|
||||
/// How far every timeline in the catalog is stretched. `1.0` is the designed
|
||||
/// speed.
|
||||
pub fn speed_scale() -> f32 {
|
||||
f32::from_bits(SPEED.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Stretch every catalog timeline by `scale`; clamped, and a non-finite
|
||||
/// `scale` resets to `1.0`.
|
||||
pub fn set_speed(scale: f32) {
|
||||
let scale = if scale.is_finite() {
|
||||
scale.clamp(0.01, 100.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
SPEED.store(scale.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Process-wide motion switches.
|
||||
#[derive(Clone, Copy)]
|
||||
struct MotionSettings {
|
||||
reduced_motion: bool,
|
||||
pause_when_inactive: bool,
|
||||
}
|
||||
|
||||
impl Global for MotionSettings {}
|
||||
|
||||
impl MotionSettings {
|
||||
/// Defaults: the OS flag rules reduce motion; the clock pauses when no
|
||||
/// window is active.
|
||||
fn get(cx: &App) -> Self {
|
||||
cx.try_global::<MotionSettings>().copied().unwrap_or(Self {
|
||||
reduced_motion: false,
|
||||
pause_when_inactive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// App-level motion switches.
|
||||
pub trait AppExt {
|
||||
/// The OS reduce-motion flag ORed with the manual override.
|
||||
fn reduced_motion(&self) -> bool;
|
||||
|
||||
/// Set the manual reduce-motion override.
|
||||
fn set_reduced_motion(&mut self, reduced: bool);
|
||||
|
||||
/// Whether the leased clock claims frames while no window is active.
|
||||
fn pause_when_inactive(&self) -> bool;
|
||||
|
||||
fn set_pause_when_inactive(&mut self, pause: bool);
|
||||
}
|
||||
|
||||
impl AppExt for App {
|
||||
fn reduced_motion(&self) -> bool {
|
||||
self.reduce_motion() || MotionSettings::get(self).reduced_motion
|
||||
}
|
||||
|
||||
fn set_reduced_motion(&mut self, reduced: bool) {
|
||||
let mut settings = MotionSettings::get(self);
|
||||
settings.reduced_motion = reduced;
|
||||
self.set_global(settings);
|
||||
}
|
||||
|
||||
fn pause_when_inactive(&self) -> bool {
|
||||
MotionSettings::get(self).pause_when_inactive
|
||||
}
|
||||
|
||||
fn set_pause_when_inactive(&mut self, pause: bool) {
|
||||
let mut settings = MotionSettings::get(self);
|
||||
settings.pause_when_inactive = pause;
|
||||
self.set_global(settings);
|
||||
}
|
||||
}
|
||||
@@ -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<I>(image: I, cx: &App) -> Div
|
||||
where
|
||||
I: Into<ImageSource>,
|
||||
{
|
||||
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)),
|
||||
)
|
||||
}
|
||||
@@ -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<TextStyle> for Metrics {
|
||||
/// The ladder's own setting for a role: its measured leading and weight.
|
||||
fn from(role: TextStyle) -> Self {
|
||||
Self::new(role, role.line_height() / role.size(), role.weight())
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder, on anything styled.
|
||||
pub trait Typeset: Styled + Sized {
|
||||
/// Size, line height and weight together, from [`TextStyle`].
|
||||
fn text_style(self, style: TextStyle) -> Self {
|
||||
self.text_size(px(style.painted()))
|
||||
.line_height(px(style.painted_line_height()))
|
||||
.font_weight(style.weight())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Styled> Typeset for E {}
|
||||
@@ -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<Div>,
|
||||
window: &mut Window,
|
||||
@@ -49,7 +52,3 @@ pub fn title_bar_drag_handlers(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
struct DragState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<Self>) -> 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."))
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<Screening> {
|
||||
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)
|
||||
|
||||
@@ -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<Preferences> {
|
||||
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")),
|
||||
|
||||
@@ -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<SharedString>,
|
||||
state: PanelState,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<PanelEvent> 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<Self>) -> 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()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||
resizing_dock: Cell<Option<DockPlacement>>,
|
||||
}
|
||||
|
||||
impl SkinShared {
|
||||
pub(crate) fn area(&self) -> &WeakEntity<DockArea> {
|
||||
&self.area
|
||||
}
|
||||
|
||||
pub(crate) fn is_toggle_button_visible(&self) -> bool {
|
||||
self.toggle_button_visible.get()
|
||||
}
|
||||
|
||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||
&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<SkinShared>,
|
||||
}
|
||||
|
||||
impl CoopDockSkin {
|
||||
pub fn new(cx: &mut Context<DockArea>) -> Rc<Self> {
|
||||
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<SkinShared> {
|
||||
&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<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
impl DockAreaRenderer for CoopDockSkin {
|
||||
fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("dock-area")
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.flex()
|
||||
.flex_row()
|
||||
}
|
||||
|
||||
fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
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<Div> {
|
||||
// `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<Arc<dyn PanelView>> {
|
||||
let state = state.clone();
|
||||
Some(panel_handle(cx.new(|cx| {
|
||||
InvalidPanel::new(state.panel_name.clone(), state, cx)
|
||||
})))
|
||||
}
|
||||
|
||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||
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<SkinShared>,
|
||||
}
|
||||
|
||||
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<gpui::ElementId> {
|
||||
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<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<gpui::Pixels> = 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<dyn BasePanelView>,
|
||||
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<dyn BasePanelView>,
|
||||
}
|
||||
|
||||
impl Render for DragPanelPreview {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<DockPlacement> {
|
||||
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<dyn BasePanelView>,
|
||||
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<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
/// The tab shown last frame, so a change scrolls the new one into view.
|
||||
last_active_ix: Cell<Option<usize>>,
|
||||
/// Bounds of the title bar row, measured to place the title-bar drag overlay.
|
||||
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the empty strip after the last tab, where the drag region starts.
|
||||
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the suffix area, where the drag region ends.
|
||||
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
}
|
||||
|
||||
impl CoopTabGroupSkin {
|
||||
pub(crate) fn new(shared: Rc<SkinShared>) -> 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<DragPanel> {
|
||||
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<Button> {
|
||||
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
// A missing dock is not collapsible, this also covers the old `left_dock.is_some() test.
|
||||
if !area.is_dock_collapsible(placement) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let designated = match placement {
|
||||
DockPlacement::Left => area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Right => area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| right_top_group(tree.root())),
|
||||
DockPlacement::Bottom => area
|
||||
.layout(DockPlacement::Bottom)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Center => return None,
|
||||
};
|
||||
if designated != Some(group.node()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_open = area.is_dock_open(placement);
|
||||
let icon = match (placement, is_open) {
|
||||
(DockPlacement::Left, true) => IconName::PanelLeft,
|
||||
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
|
||||
(DockPlacement::Right, true) => IconName::PanelRight,
|
||||
(DockPlacement::Right, false) => IconName::PanelRightOpen,
|
||||
(DockPlacement::Bottom, true) => IconName::PanelBottom,
|
||||
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
|
||||
(DockPlacement::Center, _) => return None,
|
||||
};
|
||||
|
||||
let area = self.shared.area().clone();
|
||||
Some(
|
||||
Button::new(SharedString::from(format!("toggle-dock:{placement:?}")))
|
||||
.icon(icon)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip(match is_open {
|
||||
true => "Collapse",
|
||||
false => "Expand",
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
_ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// One tab of the pill strip.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
ix: usize,
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
active: bool,
|
||||
is_bottom_dock: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Tab {
|
||||
let collapsed = group.is_collapsed();
|
||||
let droppable = group.is_droppable();
|
||||
let drag = self.tab_drag(group, ix, cx);
|
||||
let handle = PanelHandle::of(&panel);
|
||||
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
|
||||
|
||||
let closable = !collapsed
|
||||
&& (group.is_closable()
|
||||
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
|
||||
|
||||
// Whether a double-click zooms this tab in.
|
||||
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
|
||||
|
||||
Tab::new(ix)
|
||||
.group(TAB_HOVER_GROUP)
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_7()
|
||||
.min_w_40()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.text_sm()
|
||||
.overflow_hidden()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.h_6()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
}),
|
||||
)
|
||||
.when(closable, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
|
||||
})
|
||||
.when(!active, |this| {
|
||||
this.invisible()
|
||||
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
|
||||
})
|
||||
.child(
|
||||
Button::new(id)
|
||||
.icon(IconName::Close)
|
||||
.tooltip("Close")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let shared = self.shared.clone();
|
||||
let panel = panel.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
close_panel(&shared, &group, &panel, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.styles(|styles| {
|
||||
styles.selected(|style| {
|
||||
style
|
||||
.text_color(cx.theme().tab_active_foreground)
|
||||
.bg(cx.theme().tab_active)
|
||||
})
|
||||
})
|
||||
.hover(|this| {
|
||||
if active {
|
||||
this
|
||||
} else {
|
||||
this.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tab)
|
||||
}
|
||||
})
|
||||
.selected(active)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let area = self.shared.area().clone();
|
||||
move |event, window, cx| {
|
||||
group.select_tab(ix, window, cx);
|
||||
|
||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||
if is_bottom_dock && collapsed {
|
||||
_ = area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A double-click zooms the tab's panel
|
||||
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
|
||||
return;
|
||||
}
|
||||
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.when(!collapsed, |this| {
|
||||
this.when_some(drag, |this, drag| {
|
||||
this.on_drag(drag, {
|
||||
let panel = panel.clone();
|
||||
move |drag, offset, _, cx| {
|
||||
cx.stop_propagation();
|
||||
drag.set_drag_offset(offset);
|
||||
drag.set_preview_size(DRAG_PREVIEW_SIZE);
|
||||
cx.new(|_| DragPanelPreview {
|
||||
panel: panel.clone(),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().drag_border)
|
||||
})
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
|
||||
}
|
||||
})
|
||||
.drag_over::<AnyDrag>(|this, _, _, cx| {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().drag_border)
|
||||
})
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |item: &AnyDrag, window, cx| {
|
||||
group.drop_item(item.clone(), None, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The strip after the last tab, a drop target for panels and other drag items.
|
||||
/// Its left edge marks where the title-bar drag overlay starts.
|
||||
fn render_empty_space(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
tabs_count: usize,
|
||||
_cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let strip_bounds = self.title_bar_strip_bounds.clone();
|
||||
let shared = self.shared.clone();
|
||||
let droppable = group.is_droppable();
|
||||
|
||||
let mut empty = div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
.flex_grow_1()
|
||||
.min_w_16()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if strip_bounds.get() != Some(bounds) {
|
||||
strip_bounds.set(Some(bounds));
|
||||
_ = shared.area().update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
});
|
||||
|
||||
if droppable {
|
||||
empty = empty
|
||||
.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
let node = group.node();
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
// A panel dropped past its own last tab lands in the final slot.
|
||||
// A panel from elsewhere is appended in the background.
|
||||
let ix = (drag.source() == node).then(|| tabs_count - 1);
|
||||
group.drop_panel(drag.clone(), ix, false, window, cx);
|
||||
}
|
||||
})
|
||||
.drag_over::<AnyDrag>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |item: &AnyDrag, window, cx| {
|
||||
group.drop_item(item.clone(), None, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
empty.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl TabGroupRenderer for CoopTabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let control = group
|
||||
.active_panel()
|
||||
.and_then(|panel| panel_zoom_control(panel, cx));
|
||||
// An emptied group draws nothing, so no bare tab bar is left behind.
|
||||
if group.panels().is_empty() {
|
||||
return div().id("tab-panel");
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
|
||||
// `v_flex, a plain `div ignores `flex_grow and the content would collapse.
|
||||
v_flex()
|
||||
.id("tab-panel")
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = group.clone();
|
||||
move |_: &ToggleZoom, window, cx| {
|
||||
if !group.is_zoomed() && control.is_none() {
|
||||
return;
|
||||
}
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.on_action({
|
||||
let group = group.clone();
|
||||
let shared = shared.clone();
|
||||
move |_: &ClosePanel, window, cx| {
|
||||
let Some(panel) = group.active_panel() else {
|
||||
return;
|
||||
};
|
||||
close_panel(&shared, &group, panel, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id("active-panel")
|
||||
// A collapsed group draws its tab strip only, so the content claims no space.
|
||||
.when(!group.is_collapsed(), |this| this.flex_1())
|
||||
}
|
||||
|
||||
fn render_tab_bar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
// An emptied group draws no tab bar, the app prunes the emptied dock later.
|
||||
if group.panels().is_empty() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
// The sidebar group draws no chrome, the sidebar carries its own header.
|
||||
if self.is_plain_sidebar_group(group, cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let collapsed = group.is_collapsed();
|
||||
let active_ix = group.active_ix();
|
||||
let tabs_count = group.panels().len();
|
||||
|
||||
let left_dock_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
|
||||
let bottom_dock_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
// On macOS the traffic lights overlay the window's top-left corner, so the
|
||||
// tab bar under them reserves the space. That is the center's top-left group
|
||||
// when the left dock is closed or absent, and the zoomed group otherwise:
|
||||
// a zoom fills the whole area, hiding the docks the frame would draw.
|
||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||
let area = area.read(cx);
|
||||
match area.zoomed_group() {
|
||||
Some(zoomed) => zoomed == group.node(),
|
||||
None => {
|
||||
!area.is_dock_open(DockPlacement::Left)
|
||||
&& area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bring a newly displayed tab into view.
|
||||
// The group owns selection, so the skin watches for the change itself.
|
||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||
let visible: Vec<usize> = group
|
||||
.panels()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, panel)| panel.visible(cx))
|
||||
.map(|(ix, _)| ix)
|
||||
.collect();
|
||||
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
|
||||
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
|
||||
{
|
||||
self.scroll_handle.scroll_to_item(visible_ix);
|
||||
}
|
||||
|
||||
// The tab strip ends at the last tab, the bar has no element after it.
|
||||
// Cover that dead zone with an overlay so it can drag the window.
|
||||
let drag_overlay = match (
|
||||
self.title_bar_bounds.get(),
|
||||
self.title_bar_strip_bounds.get(),
|
||||
self.title_bar_suffix_bounds.get(),
|
||||
) {
|
||||
(Some(title), Some(strip), Some(suffix)) => {
|
||||
let left = strip.left() - title.left();
|
||||
let right = title.right() - suffix.left();
|
||||
(left + right < title.size.width).then(|| {
|
||||
title_bar_drag_handlers(
|
||||
div()
|
||||
.id(("title-bar-drag", group.node().as_u64()))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.left(left)
|
||||
.right(right),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let tabs: Vec<_> = group
|
||||
.panels()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, panel)| {
|
||||
let mut active = displayed == Some(panel.panel_id(cx));
|
||||
if !panel.visible(cx) {
|
||||
return None;
|
||||
}
|
||||
// Collapsed tabs never show as active, the strip only reopens the dock.
|
||||
if collapsed {
|
||||
active = false;
|
||||
}
|
||||
Some(self.render_tab(group, ix, panel.clone(), active, is_bottom_dock, window, cx))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let empty_space = self.render_empty_space(group, tabs_count, cx);
|
||||
let title_bar_bounds = self.title_bar_bounds.clone();
|
||||
let title_bar_suffix_bounds = self.title_bar_suffix_bounds.clone();
|
||||
let shared = self.shared.clone();
|
||||
let suffix_shared = self.shared.clone();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if title_bar_bounds.get() != Some(bounds) {
|
||||
title_bar_bounds.set(Some(bounds));
|
||||
_ = shared.area().update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Tabs::new("tab-bar")
|
||||
.px(px(-1.))
|
||||
// A collapsed group stands in for a closed bottom dock,
|
||||
// which base gives only its reserved strip. The bar keeps
|
||||
// its own height there rather than the title-bar height,
|
||||
// so it fits instead of being clipped away.
|
||||
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
// -1 px so the border does not overlap the first tab.
|
||||
.right(-px(1.))
|
||||
.h_full()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button),
|
||||
)
|
||||
.child(
|
||||
h_flex().id("tabs").flex_1().overflow_x_hidden().child(
|
||||
h_flex()
|
||||
.id("tabs-inner")
|
||||
.relative()
|
||||
.gap(px(4.))
|
||||
.overflow_x_scroll()
|
||||
.lock_scroll_axis()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.children(tabs)
|
||||
.when(!collapsed, |this| this.child(empty_space)),
|
||||
),
|
||||
)
|
||||
.when(!collapsed, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.h_full()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if title_bar_suffix_bounds.get() != Some(bounds) {
|
||||
title_bar_suffix_bounds.set(Some(bounds));
|
||||
_ = suffix_shared
|
||||
.area()
|
||||
.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
})
|
||||
.children(
|
||||
group
|
||||
.active_panel()
|
||||
.and_then(PanelHandle::of)
|
||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||
)
|
||||
.children(right_dock_button),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
|
||||
)
|
||||
.child(window_controls::window_controls(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_active_panel(
|
||||
&self,
|
||||
panel: AnyView,
|
||||
group: &TabGroupContext,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> AnyElement {
|
||||
if group.is_collapsed() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.id("tab-content")
|
||||
.overflow_y_scroll()
|
||||
.overflow_x_hidden()
|
||||
.flex_1()
|
||||
.child(panel.cached(StyleRefinement::default().absolute().size_full()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_drop_indicator(
|
||||
&self,
|
||||
indicator: DropIndicator,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let (from, to) = (indicator.from(), indicator.to());
|
||||
// The element sits at the drop target, the animation walks back from the source.
|
||||
let offset = from.origin() - to.origin();
|
||||
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left(to.origin().x)
|
||||
.top(to.origin().y)
|
||||
.w(to.size().width)
|
||||
.h(to.size().height)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bg(cx.theme().tokens.drop_target)
|
||||
.with_animation(
|
||||
gpui::ElementId::NamedInteger(
|
||||
"drop-placeholder".into(),
|
||||
indicator.epoch(),
|
||||
),
|
||||
Animation::new(Duration::from_millis(150)).with_easing(ease_out_cubic),
|
||||
move |this, delta| {
|
||||
let origin = offset.lerp(&Point::default(), delta);
|
||||
let width = from.size().width.lerp(&to.size().width, delta);
|
||||
let height = from.size().height.lerp(&to.size().height, delta);
|
||||
this.left(origin.x).top(origin.y).w(width).h(height)
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||
StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex};
|
||||
|
||||
use super::TAB_BAR_HEIGHT;
|
||||
|
||||
const CONTROL_WIDTH: f32 = 34.;
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
enum ControlIcon {
|
||||
Minimize,
|
||||
Restore,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl ControlIcon {
|
||||
fn minimize() -> Self {
|
||||
Self::Minimize
|
||||
}
|
||||
|
||||
fn restore() -> Self {
|
||||
Self::Restore
|
||||
}
|
||||
|
||||
fn maximize() -> Self {
|
||||
Self::Maximize
|
||||
}
|
||||
|
||||
fn close() -> Self {
|
||||
Self::Close
|
||||
}
|
||||
|
||||
fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Minimize => "minimize",
|
||||
Self::Restore => "restore",
|
||||
Self::Maximize => "maximize",
|
||||
Self::Close => "close",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
match self {
|
||||
Self::Minimize => IconName::WindowMinimize,
|
||||
Self::Restore => IconName::WindowRestore,
|
||||
Self::Maximize => IconName::WindowMaximize,
|
||||
Self::Close => IconName::WindowClose,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_control_area(&self) -> gpui::WindowControlArea {
|
||||
match self {
|
||||
Self::Minimize => gpui::WindowControlArea::Min,
|
||||
Self::Restore | Self::Maximize => gpui::WindowControlArea::Max,
|
||||
Self::Close => gpui::WindowControlArea::Close,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_close(&self) -> bool {
|
||||
matches!(self, Self::Close)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hover_fg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_foreground
|
||||
} else {
|
||||
cx.theme().secondary_foreground
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hover_bg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger
|
||||
} else {
|
||||
cx.theme().secondary_hover
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn active_bg(&self, cx: &mut App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_active
|
||||
} else {
|
||||
cx.theme().secondary_active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ControlIcon {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
let hover_fg = self.hover_fg(cx);
|
||||
let hover_bg = self.hover_bg(cx);
|
||||
let active_bg = self.active_bg(cx);
|
||||
let icon = self.clone();
|
||||
|
||||
div()
|
||||
.id(self.id())
|
||||
.flex()
|
||||
.w(px(CONTROL_WIDTH))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.content_center()
|
||||
.items_center()
|
||||
.text_color(cx.theme().foreground)
|
||||
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
|
||||
.active(|style| style.bg(active_bg).text_color(hover_fg))
|
||||
.when(is_windows, |this| {
|
||||
this.window_control_area(self.window_control_area())
|
||||
})
|
||||
.when(is_linux, |this| {
|
||||
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
match icon {
|
||||
Self::Minimize => window.minimize_window(),
|
||||
Self::Restore | Self::Maximize => window.zoom_window(),
|
||||
Self::Close => window.remove_window(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(Icon::new(self.icon()).small())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
|
||||
return div().id("window-controls");
|
||||
}
|
||||
|
||||
let supported = window.window_controls();
|
||||
|
||||
h_flex()
|
||||
.id("window-controls")
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
// The controls span the title bar but never grow past the tab bar height.
|
||||
.when(cfg!(target_os = "windows"), |this| {
|
||||
this.max_h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
.border_l_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().tokens.tab_bar)
|
||||
.when(supported.minimize, |this| {
|
||||
this.child(ControlIcon::minimize())
|
||||
})
|
||||
.when(supported.maximize, |this| {
|
||||
this.child(if window.is_maximized() {
|
||||
ControlIcon::restore()
|
||||
} else {
|
||||
ControlIcon::maximize()
|
||||
})
|
||||
})
|
||||
.child(ControlIcon::close())
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use chat::{ChatEvent, ChatRegistry};
|
||||
use common::download_dir;
|
||||
use gpui::{
|
||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use gpui_kit::component::button::Button;
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
@@ -18,7 +18,6 @@ use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use ui::{SIDEBAR_WIDTH, TextStyle, Typeset as _};
|
||||
|
||||
use crate::dialogs::restore::RestoreEncryption;
|
||||
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
|
||||
@@ -31,6 +30,11 @@ mod nav;
|
||||
mod panels;
|
||||
mod sidebar;
|
||||
|
||||
pub use dock::TAB_BAR_HEIGHT;
|
||||
|
||||
/// Default sidebar width.
|
||||
const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
|
||||
cx.new(|cx| Workspace::new(window, cx))
|
||||
}
|
||||
@@ -74,8 +78,10 @@ impl Workspace {
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let (dock, _skin) =
|
||||
gpui_kit::component::dock::DockSkin::dock_area("coop", None, window, cx);
|
||||
let dock = cx.new(|cx| {
|
||||
let skin = dock::CoopDockSkin::new(cx);
|
||||
gpui_kit::component::dock::DockArea::new("coop", None, window, cx).with_renderer(skin)
|
||||
});
|
||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||
let left_sidebar = sidebar.clone();
|
||||
|
||||
@@ -438,7 +444,7 @@ impl Workspace {
|
||||
.description(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.child(SharedString::from(ENC_MSG))
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -4,10 +4,9 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled, Window, div, px,
|
||||
Styled, Window, div,
|
||||
};
|
||||
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex, v_flex};
|
||||
use ui::button_radius;
|
||||
|
||||
/// A single navigation row, such as an entry in a sidebar list.
|
||||
#[derive(IntoElement)]
|
||||
@@ -115,7 +114,7 @@ impl RenderOnce for Nav {
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_1p5()
|
||||
.rounded(px(button_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(text_color)
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use anyhow::Error;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, px,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::{ActiveTheme, Sizable, v_flex};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::USER_KEYRING;
|
||||
use ui::divider;
|
||||
use ui::{TextStyle, Typeset as _};
|
||||
|
||||
const MSG: &str = "Store your account keys in a safe location. \
|
||||
You can restore your account or move to another client anytime you want.";
|
||||
@@ -136,24 +135,24 @@ impl Render for BackupPanel {
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from("Public Key:")),
|
||||
@@ -166,7 +165,7 @@ impl Render for BackupPanel {
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from("Secret Key:")),
|
||||
|
||||
@@ -1,11 +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::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||
use std::ops::Range;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -17,11 +9,18 @@ use gpui::{
|
||||
size, transparent_black,
|
||||
};
|
||||
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||
use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
||||
use nostr_sdk::prelude::{RelayUrl, Timestamp};
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use ui::{TextStyle, Typeset as _, button_radius, panel_radius};
|
||||
use ui::Avatar;
|
||||
|
||||
const TITLE: &str = "Browse";
|
||||
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
|
||||
@@ -269,7 +268,7 @@ impl BrowsePanel {
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.line_height(relative(1.2))
|
||||
.child("Public Groups"),
|
||||
@@ -277,7 +276,7 @@ impl BrowsePanel {
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.map(|this| match &self.relay {
|
||||
Some(relay) => {
|
||||
@@ -295,7 +294,7 @@ impl BrowsePanel {
|
||||
.w_full()
|
||||
.max_w_64()
|
||||
.px_1()
|
||||
.rounded(px(button_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().sidebar)
|
||||
.child(Input::new(&self.input).appearance(false).small().flex_1())
|
||||
.child(
|
||||
@@ -372,7 +371,7 @@ impl BrowsePanel {
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.overflow_hidden()
|
||||
.rounded(px(panel_radius()))
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.cursor_pointer()
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
@@ -386,20 +385,14 @@ impl BrowsePanel {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.p_2()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(match picture {
|
||||
Some(picture) => Avatar::new()
|
||||
.src(picture)
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
None => Avatar::new().flex_shrink_0().into_any_element(),
|
||||
})
|
||||
this.child(Avatar::new(picture).seed(seed).flex_shrink_0())
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -407,7 +400,7 @@ impl BrowsePanel {
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.truncate()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.line_height(relative(1.2))
|
||||
@@ -416,7 +409,7 @@ impl BrowsePanel {
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(relay),
|
||||
),
|
||||
@@ -425,7 +418,7 @@ impl BrowsePanel {
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_ellipsis()
|
||||
.line_clamp(2)
|
||||
@@ -446,7 +439,7 @@ impl BrowsePanel {
|
||||
.child(Icon::new(IconName::Book))
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.child(SharedString::from(format!("{members} members"))),
|
||||
),
|
||||
),
|
||||
@@ -468,7 +461,7 @@ impl Panel for BrowsePanel {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.child(
|
||||
Icon::new(IconName::Compass)
|
||||
.small()
|
||||
@@ -548,7 +541,7 @@ fn notice(text: &'static str, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(text)
|
||||
.into_any_element()
|
||||
|
||||
@@ -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::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::scroll::ScrollableElement;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Error;
|
||||
@@ -12,14 +5,20 @@ use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign,
|
||||
Window, div, px, rems, retain_all,
|
||||
Window, div, rems, retain_all,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState};
|
||||
use gpui_kit::component::scroll::ScrollableElement;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::Directory;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
use ui::{TextStyle, Typeset as _, button_radius, panel_radius};
|
||||
use ui::Avatar;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
|
||||
cx.new(|cx| ContactListPanel::new(window, cx))
|
||||
@@ -242,18 +241,17 @@ impl ContactListPanel {
|
||||
.h_8()
|
||||
.px_2()
|
||||
.justify_between()
|
||||
.rounded(px(button_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap(px(ui::SPACE))
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(match profile.avatar() {
|
||||
Some(picture) => {
|
||||
Avatar::new().src(picture).small().into_any_element()
|
||||
}
|
||||
None => Avatar::new().small().into_any_element(),
|
||||
})
|
||||
.gap_2()
|
||||
.text_xs()
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
)
|
||||
.child(
|
||||
@@ -283,8 +281,8 @@ impl ContactListPanel {
|
||||
.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."))
|
||||
}
|
||||
@@ -320,13 +318,13 @@ impl Render for ContactListPanel {
|
||||
.overflow_y_scrollbar()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("New contact:"),
|
||||
@@ -354,7 +352,7 @@ impl Render for ContactListPanel {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
use crate::dock::add_panel_to;
|
||||
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, Panel, PanelEvent, PanelHandle};
|
||||
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
|
||||
use gpui_kit::component::scroll::ScrollableElement;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
@@ -16,15 +7,23 @@ use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, svg,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, svg,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
|
||||
use gpui_kit::component::scroll::ScrollableElement;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use ui::{TextStyle, Typeset as _, button_radius};
|
||||
use ui::Avatar;
|
||||
|
||||
use super::browse;
|
||||
use crate::dock::add_panel_to;
|
||||
use crate::nav::Nav;
|
||||
use crate::sidebar::open_room;
|
||||
|
||||
/// How many requests and follow ups to show per section.
|
||||
@@ -205,8 +204,8 @@ impl GreeterPanel {
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(button_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("No chat requests")
|
||||
.into_any_element(),
|
||||
@@ -219,9 +218,6 @@ impl GreeterPanel {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
#[allow(unused_variables)]
|
||||
#[allow(unused_variables)]
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let preview = preview.clone();
|
||||
@@ -233,7 +229,7 @@ impl GreeterPanel {
|
||||
.h_9()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.rounded(px(button_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.on_click({
|
||||
@@ -243,23 +239,18 @@ impl GreeterPanel {
|
||||
open_room(&room, &dock, window, cx);
|
||||
}
|
||||
})
|
||||
.child(match picture {
|
||||
Some(picture) => Avatar::new().src(picture).small().into_any_element(),
|
||||
None => Avatar::new().small().flex_shrink_0().into_any_element(),
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(name),
|
||||
Avatar::new(room.read(cx).display_image(cx))
|
||||
.seed(seed)
|
||||
.small(),
|
||||
)
|
||||
.child(div().min_w_0().truncate().text_xs().child(name))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.italic()
|
||||
.child(preview),
|
||||
@@ -283,7 +274,7 @@ impl GreeterPanel {
|
||||
.child(
|
||||
div()
|
||||
.text_align(gpui::TextAlign::Right)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(created_at),
|
||||
),
|
||||
@@ -333,8 +324,6 @@ impl GreeterPanel {
|
||||
let room = room.clone();
|
||||
let dock = dock.clone();
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
#[allow(unused_variables)]
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let preview = preview.clone();
|
||||
@@ -346,7 +335,7 @@ impl GreeterPanel {
|
||||
.h_9()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.rounded(px(button_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.hover(|this| this.bg(cx.theme().primary))
|
||||
.on_click({
|
||||
@@ -356,23 +345,18 @@ impl GreeterPanel {
|
||||
open_room(&room, &dock, window, cx);
|
||||
}
|
||||
})
|
||||
.child(match picture {
|
||||
Some(picture) => Avatar::new().src(picture).small().into_any_element(),
|
||||
None => Avatar::new().small().flex_shrink_0().into_any_element(),
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child(name),
|
||||
Avatar::new(room.read(cx).display_image(cx))
|
||||
.seed(seed)
|
||||
.small(),
|
||||
)
|
||||
.child(div().min_w_0().truncate().text_xs().child(name))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.italic()
|
||||
.child(preview),
|
||||
@@ -396,7 +380,7 @@ impl GreeterPanel {
|
||||
.child(
|
||||
div()
|
||||
.text_align(gpui::TextAlign::Right)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(created_at),
|
||||
),
|
||||
@@ -439,7 +423,7 @@ impl GreeterPanel {
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_style(TextStyle::Subheadline)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.child("Find new friends"),
|
||||
)
|
||||
@@ -454,7 +438,7 @@ impl GreeterPanel {
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.label("Browse Public Groups")
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
let panel = browse::init(this.dock.clone(), window, cx);
|
||||
add_panel_to(
|
||||
@@ -470,7 +454,7 @@ impl GreeterPanel {
|
||||
Nav::new("browse-posts")
|
||||
.prefix(Icon::new(IconName::Link).small())
|
||||
.label("Browse Nostr via Jumble")
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url(JUMBLE_URL);
|
||||
}),
|
||||
@@ -489,7 +473,7 @@ impl Panel for GreeterPanel {
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
|
||||
@@ -5,7 +5,6 @@ use gpui::{
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
|
||||
use ui::{TextStyle, Typeset as _};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<InboxPanel> {
|
||||
cx.new(|cx| InboxPanel::new(window, cx))
|
||||
@@ -58,7 +57,7 @@ impl Render for InboxPanel {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(self.name.clone())
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ use std::rc::Rc;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
|
||||
ParentElement, SharedString, Styled, WeakEntity, div, relative, uniform_list,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::avatar::Avatar;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
@@ -15,7 +15,7 @@ use nip29::Group;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use state::NostrRegistry;
|
||||
use ui::{TextStyle, Typeset as _};
|
||||
use ui::Avatar;
|
||||
|
||||
use super::Sidebar;
|
||||
use crate::dialogs::profile;
|
||||
@@ -87,7 +87,7 @@ impl Sidebar {
|
||||
.current_user()
|
||||
.is_some_and(|me| group.read(cx).admins().contains(&me));
|
||||
|
||||
let (name, _seed, picture, total_members, rows) = {
|
||||
let (name, seed, picture, total_members, rows) = {
|
||||
let group = group.read(cx);
|
||||
let admins = group.admins();
|
||||
|
||||
@@ -144,7 +144,7 @@ impl Sidebar {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
@@ -155,19 +155,14 @@ impl Sidebar {
|
||||
.pl_0p5()
|
||||
.gap_1()
|
||||
.flex_shrink_1()
|
||||
.child(match picture {
|
||||
Some(picture) => {
|
||||
Avatar::new().src(picture).small().into_any_element()
|
||||
}
|
||||
None => Avatar::new().small().into_any_element(),
|
||||
})
|
||||
.child(Avatar::new(picture).seed(seed.clone()).small())
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.truncate()
|
||||
.line_height(relative(1.2))
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
@@ -175,7 +170,8 @@ impl Sidebar {
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Caption2)
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(total_members),
|
||||
),
|
||||
@@ -253,7 +249,7 @@ impl Sidebar {
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
@@ -289,20 +285,21 @@ impl Sidebar {
|
||||
|
||||
let nav = Nav::new(public_key.to_hex())
|
||||
.label(person.name())
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.clickable(true)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(match person.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(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.when(!roles.is_empty(), |this| {
|
||||
this.suffix(
|
||||
div()
|
||||
.truncate()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(roles.join(", "))),
|
||||
)
|
||||
|
||||
@@ -5,16 +5,15 @@ use gpui::{
|
||||
Stateful, Styled, div, px,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::avatar::Avatar;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::menu::DropdownMenu;
|
||||
use gpui_kit::component::{Disableable, Selectable, Sizable, h_flex};
|
||||
use nostr_sdk::prelude::*;
|
||||
use ui::TABBAR_HEIGHT;
|
||||
use ui::TRAFFIC_LIGHT_PADDING;
|
||||
use ui::Avatar;
|
||||
|
||||
use super::Sidebar;
|
||||
use crate::Command;
|
||||
use crate::dock::TAB_BAR_HEIGHT;
|
||||
|
||||
impl Sidebar {
|
||||
pub(super) fn render_user(
|
||||
@@ -29,20 +28,19 @@ impl Sidebar {
|
||||
h_flex()
|
||||
.id("sidebar-user")
|
||||
.w_full()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.h(TAB_BAR_HEIGHT)
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.when(cfg!(target_os = "macos"), |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
})
|
||||
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
||||
.child(
|
||||
Button::new("current-user")
|
||||
.child(match avatar.clone() {
|
||||
Some(picture) => Avatar::new().src(picture).small().into_any_element(),
|
||||
None => Avatar::new().small().into_any_element(),
|
||||
})
|
||||
.child(
|
||||
Avatar::new(avatar.clone())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
.small()
|
||||
.dropdown_caret(true)
|
||||
.compact()
|
||||
@@ -127,7 +125,7 @@ impl Sidebar {
|
||||
let staged = updater.read(cx).staged();
|
||||
|
||||
h_flex()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.when(!up_to_date, |this| {
|
||||
this.child(
|
||||
Button::new("update")
|
||||
|
||||
@@ -10,7 +10,6 @@ use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::NostrRegistry;
|
||||
use ui::{TextStyle, Typeset as _};
|
||||
|
||||
use super::Sidebar;
|
||||
use super::rows::SidebarRow;
|
||||
@@ -211,7 +210,7 @@ impl Sidebar {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("Selected")
|
||||
.child(
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
use crate::nav::Nav;
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::{ChatRegistry, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, px,
|
||||
uniform_list,
|
||||
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::scroll::Scrollbar;
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||
use nip29::GroupsRegistry;
|
||||
use settings::AppSettings;
|
||||
use ui::{TextStyle, Typeset as _, control_radius};
|
||||
|
||||
use super::Sidebar;
|
||||
use super::rows::SidebarRow;
|
||||
use crate::Command;
|
||||
use crate::nav::Nav;
|
||||
|
||||
/// A collapsible section of the sidebar's main list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -62,7 +61,7 @@ impl Sidebar {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.px_2()
|
||||
@@ -90,10 +89,11 @@ impl Sidebar {
|
||||
.px_1()
|
||||
.min_w_6()
|
||||
.justify_center()
|
||||
.text_style(TextStyle::Caption2)
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_center()
|
||||
.rounded(px(control_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary)
|
||||
.child(SharedString::from(
|
||||
request_count.to_string(),
|
||||
@@ -128,7 +128,7 @@ impl Sidebar {
|
||||
this.child(
|
||||
div()
|
||||
.px_4()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(list_title),
|
||||
|
||||
@@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription,
|
||||
Task, UniformListScrollHandle, WeakEntity, Window, div, img, px, retain_all,
|
||||
Task, UniformListScrollHandle, WeakEntity, Window, div, img, retain_all,
|
||||
};
|
||||
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{InputEvent, InputState};
|
||||
@@ -20,7 +20,9 @@ use nostr_sdk::prelude::*;
|
||||
use person::{Directory, PersonRegistry};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||
use ui::{TABBAR_HEIGHT, TextStyle, Typeset as _, title_bar_drag_handlers};
|
||||
use ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock::TAB_BAR_HEIGHT;
|
||||
|
||||
mod group_view;
|
||||
mod header;
|
||||
@@ -307,7 +309,7 @@ impl Render for Sidebar {
|
||||
.image_cache(retain_all("sidebar"))
|
||||
.size_full()
|
||||
.relative()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.bg(cx.theme().sidebar)
|
||||
.when_some(current_user.as_ref(), |this, current_user| {
|
||||
this.child(title_bar_drag_handlers(
|
||||
@@ -325,7 +327,7 @@ impl Render for Sidebar {
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.h(TAB_BAR_HEIGHT)
|
||||
.w_full(),
|
||||
window,
|
||||
cx,
|
||||
@@ -357,14 +359,14 @@ impl Render for Sidebar {
|
||||
.px_8()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap(px(ui::SPACE))
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h_9()
|
||||
.justify_center()
|
||||
.bg(cx.theme().background.opacity(0.85))
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.rounded_full()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Spinner::new().small().color(cx.theme().primary))
|
||||
|
||||
@@ -8,7 +8,6 @@ use gpui::{
|
||||
SharedString, Styled, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::component::avatar::Avatar;
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
@@ -16,7 +15,7 @@ use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_f
|
||||
use nip29::Group;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use ui::{TextStyle, Typeset as _, button_radius};
|
||||
use ui::Avatar;
|
||||
|
||||
use super::Sidebar;
|
||||
use super::list::ListSection;
|
||||
@@ -100,7 +99,7 @@ impl Sidebar {
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
@@ -122,7 +121,7 @@ impl Sidebar {
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.into_any_element()
|
||||
@@ -140,8 +139,8 @@ impl Sidebar {
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(px(button_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.rounded(cx.theme().radius)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(*text),
|
||||
)
|
||||
@@ -149,8 +148,6 @@ impl Sidebar {
|
||||
SidebarRow::Room { room } => {
|
||||
let id = room.read(cx).id;
|
||||
let name = room.read(cx).display_name(cx);
|
||||
let picture = room.read(cx).display_image(cx);
|
||||
#[allow(unused_variables)]
|
||||
let seed = room.read(cx).display_image_seed(cx);
|
||||
let created_at = room.read(cx).created_at.to_ago();
|
||||
let member = room.read(cx).members().first().copied();
|
||||
@@ -163,16 +160,13 @@ impl Sidebar {
|
||||
|
||||
let nav = Nav::new(index)
|
||||
.label(name)
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(match picture {
|
||||
Some(picture) => {
|
||||
Avatar::new().src(picture).small().into_any_element()
|
||||
}
|
||||
None => {
|
||||
Avatar::new().small().flex_shrink_0().into_any_element()
|
||||
}
|
||||
})
|
||||
this.prefix(
|
||||
Avatar::new(room.read(cx).display_image(cx))
|
||||
.seed(seed)
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.suffix(
|
||||
h_flex()
|
||||
@@ -181,7 +175,7 @@ impl Sidebar {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
.rounded(px(button_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().danger.opacity(0.15))
|
||||
.text_size(px(8.0))
|
||||
.text_color(cx.theme().danger)
|
||||
@@ -192,7 +186,7 @@ impl Sidebar {
|
||||
this.child(
|
||||
div()
|
||||
.font_weight(gpui::FontWeight::NORMAL)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(created_at),
|
||||
)
|
||||
@@ -236,16 +230,13 @@ impl Sidebar {
|
||||
|
||||
Nav::new(index)
|
||||
.label(person.name())
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(match person.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(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.selected(selected)
|
||||
.on_click(move |_event, _window, cx| {
|
||||
@@ -263,16 +254,13 @@ impl Sidebar {
|
||||
|
||||
Nav::new(index)
|
||||
.label(person.name())
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(match person.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(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.on_click(move |_event, window, cx| {
|
||||
profile::open(public_key, window, cx);
|
||||
@@ -285,7 +273,6 @@ impl Sidebar {
|
||||
let group = group.clone();
|
||||
|
||||
let name = group.read(cx).display_name();
|
||||
let picture = group.read(cx).display_image();
|
||||
let seed = group.read(cx).key().cache_tag();
|
||||
let tag = seed.clone();
|
||||
let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx);
|
||||
@@ -300,22 +287,19 @@ impl Sidebar {
|
||||
|
||||
let nav = Nav::new(index)
|
||||
.label(name)
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(match picture {
|
||||
Some(picture) => {
|
||||
Avatar::new().src(picture).small().into_any_element()
|
||||
}
|
||||
None => {
|
||||
Avatar::new().small().flex_shrink_0().into_any_element()
|
||||
}
|
||||
})
|
||||
this.prefix(
|
||||
Avatar::new(group.read(cx).display_image())
|
||||
.seed(seed)
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.when_some(suffix, |this, suffix| {
|
||||
this.suffix(
|
||||
div()
|
||||
.font_weight(gpui::FontWeight::NORMAL)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(suffix),
|
||||
)
|
||||
@@ -376,22 +360,19 @@ impl Sidebar {
|
||||
|
||||
Nav::new(index)
|
||||
.label(content)
|
||||
.text_style(TextStyle::Callout)
|
||||
.text_xs()
|
||||
.font_weight(gpui::FontWeight::MEDIUM)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(match person.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(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.suffix(
|
||||
div()
|
||||
.font_weight(gpui::FontWeight::NORMAL)
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(detail),
|
||||
)
|
||||
|
||||
@@ -9,12 +9,11 @@ use std::rc::Rc;
|
||||
use chat::{ChatRegistry, Room};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
|
||||
Window, div, px, uniform_list,
|
||||
Window, div, uniform_list,
|
||||
};
|
||||
use nip29::{Group, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::{FIND_LIMIT, NostrRegistry};
|
||||
use ui::button_radius;
|
||||
|
||||
use super::Sidebar;
|
||||
use super::rows::SidebarRow;
|
||||
@@ -215,7 +214,7 @@ impl Sidebar {
|
||||
.px_1p5()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.rounded(px(button_radius()))
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary)
|
||||
.child(Input::new(&self.find_input).appearance(false).small())
|
||||
.child(
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use assets::Assets;
|
||||
use gpui::{
|
||||
App, Bounds, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions,
|
||||
WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions,
|
||||
@@ -9,7 +8,7 @@ use gpui::{
|
||||
use gpui_platform::application;
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use state::{APP_ID, CLIENT_NAME};
|
||||
use ui::TABBAR_HEIGHT;
|
||||
use workspace::TAB_BAR_HEIGHT;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
@@ -94,7 +93,7 @@ fn main() {
|
||||
title: Some(SharedString::new_static(CLIENT_NAME)),
|
||||
traffic_light_position: Some(point(
|
||||
px(9.0),
|
||||
px(TABBAR_HEIGHT / px(2.) - 14. / 2.),
|
||||
px(TAB_BAR_HEIGHT / px(2.) - 14. / 2.),
|
||||
)),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
|
||||
> **Status: Phase A is implemented** (on the working tree, uncommitted). The
|
||||
> app builds against `gpui-kit` (resolved 0.7.3 / gpui-pre 0.3.9), every UI
|
||||
> element comes from the kit, `crates/theme` is deleted, and `crates/ui` is a
|
||||
> utilities-only crate (`motion`, `typography`, `layout`, `styled` helpers,
|
||||
> `window_drag`). Two planned holdouts moved into app crates instead of being
|
||||
> reimplemented on kit parts: the chat message composition (→ `chat_ui::message`)
|
||||
> and the sidebar `Nav` row (→ `workspace::nav`). The panel-background-image
|
||||
> feature was dropped (per §8). Validation: `cargo check`, `cargo clippy` and
|
||||
> `cargo fmt` are clean workspace-wide, the desktop binary builds, and `ui`,
|
||||
> `workspace` compile for `wasm32-unknown-unknown` (the remaining web failures
|
||||
> are the pre-existing `auto_update` → `rustls` chain, present at HEAD).
|
||||
> element comes from the kit, and `crates/theme` is deleted.
|
||||
>
|
||||
> **Follow-up (this revision, also uncommitted):** the last custom modules in
|
||||
> `crates/ui` are gone — `motion`, `typography`, `layout` and `styled` were
|
||||
> deleted and every call site now reads gpui-kit tokens directly (`text_xs` /
|
||||
> `text_sm`, `cx.theme().radius` / `radius_lg`, `gap_2`, `Separator`). The
|
||||
> dock wears the previous skin again, ported from the `signed` reference
|
||||
> implementation onto the kit dock engine (`workspace::dock`: pill tabs, the
|
||||
> 44px bar that doubles as the title bar, window controls, collapse buttons,
|
||||
> chromeless sidebar group). The avatar with the pixel fallback is back
|
||||
> (`ui::Avatar`): user image when set, deterministic pixel art from the
|
||||
> profile/group seed otherwise, also as the image-load fallback. Desktop
|
||||
> traffic lights recenter for the 44px bar (`workspace::TAB_BAR_HEIGHT`).
|
||||
> Validation: `cargo check`, `cargo clippy` and `cargo fmt` are clean
|
||||
> workspace-wide and the desktop binary builds; the wasm32 check fails
|
||||
> identically before and after (pre-existing `state` → `gpui_tokio` and
|
||||
> `rustls` blockers, verified at HEAD).
|
||||
|
||||
Plan to move coop's custom UI elements onto [GPUI-Kit](https://gpui-kit.com)
|
||||
(`gpui-kit` 0.7.1), delete the custom theme implementation (`crates/theme`),
|
||||
@@ -219,14 +227,19 @@ site is mapped.
|
||||
`colors.rs`, `palette.rs`, `brand.rs`, `appearance.rs`, `paint.rs`,
|
||||
`color.rs`, `syntax.rs`, `notification.rs`, `scrollbar_mode.rs`,
|
||||
`platform_kind.rs`;
|
||||
move to `crates/ui` unchanged: `typography.rs` (`TextStyle`/`Typeset`,
|
||||
~200 call sites — retained deliberately as coop's type ladder),
|
||||
`layout.rs` (spacing constants, radius accessors), `geometry.rs`
|
||||
(`Edges`/`Side`/`AxisExt`), and the three `CLIENT_SIDE_DECORATION_*`
|
||||
constants;
|
||||
- end state: `crates/ui` is utilities-only — `motion.rs`, `typography.rs`,
|
||||
`layout.rs`, `geometry.rs`, the CSD constants and the combined asset
|
||||
source. Every element module is deleted; nothing custom is maintained;
|
||||
- the `typography`, `layout`, `styled` and `motion` modules were NOT kept:
|
||||
a follow-up revision removed them and adapted every call site to gpui-kit
|
||||
tokens — the `TextStyle` ladder collapsed to `text_xs`/`text_sm` (plus
|
||||
`font_semibold`/`font_medium`), the radius ladder to `cx.theme().radius`
|
||||
/ `cx.theme().radius_lg`, `SPACE` to `gap_2`, `divider` to the kit
|
||||
`Separator`, and motion was deleted unused. `geometry.rs` and the CSD
|
||||
constants never carried over (the framework Root draws the window
|
||||
border); `TAB_BAR_HEIGHT` (44px, the dock tab bar that doubles as the
|
||||
title bar) now lives in `workspace::dock` and `SIDEBAR_WIDTH` in
|
||||
`workspace`;
|
||||
- end state: `crates/ui` is `avatar.rs` + `window_drag.rs` only — see the
|
||||
status note at the top of this file for the restored dock skin and
|
||||
avatar (the two deliberate exceptions to "no custom elements");
|
||||
- `AppearanceMode` (System/Light/Dark, serde-persisted) moves into
|
||||
`crates/settings`.
|
||||
- The gpui-base projection coop did in `sync_base` is now the framework's job
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
'''One-shot sweep: replace custom ui helpers with gpui-kit tokens.'''
|
||||
|
||||
import pathlib
|
||||
|
||||
CRATES = pathlib.Path('crates')
|
||||
|
||||
REPLACEMENTS = [
|
||||
# Text ladder -> gpui text classes (Caption2 before Caption)
|
||||
('.text_style(TextStyle::Headline)', '.text_sm().font_semibold()'),
|
||||
('.text_style(TextStyle::Body)', '.text_sm()'),
|
||||
('.text_style(TextStyle::Caption2)', '.text_xs().font_medium()'),
|
||||
('.text_style(TextStyle::Caption)', '.text_xs()'),
|
||||
('.text_style(TextStyle::Callout)', '.text_xs()'),
|
||||
('.text_style(TextStyle::Subheadline)', '.text_xs()'),
|
||||
# Radius ladder -> theme tokens
|
||||
('px(ui::bubble_radius())', 'cx.theme().radius_lg'),
|
||||
('px(ui::surface_radius())', 'cx.theme().radius_lg'),
|
||||
('px(ui::panel_radius())', 'cx.theme().radius_lg'),
|
||||
('px(ui::control_radius())', 'cx.theme().radius'),
|
||||
('px(ui::button_radius())', 'cx.theme().radius'),
|
||||
('px(bubble_radius())', 'cx.theme().radius_lg'),
|
||||
('px(surface_radius())', 'cx.theme().radius_lg'),
|
||||
('px(panel_radius())', 'cx.theme().radius_lg'),
|
||||
('px(control_radius())', 'cx.theme().radius'),
|
||||
('px(button_radius())', 'cx.theme().radius'),
|
||||
# Spacing -> Tailwind gap
|
||||
('gap(px(ui::SPACE))', 'gap_2()'),
|
||||
# Custom divider -> kit Separator
|
||||
('divider(cx)', 'Separator::horizontal()'),
|
||||
# Avatar import swap
|
||||
('use gpui_kit::component::avatar::Avatar;', 'use ui::Avatar;'),
|
||||
# Import lines that are now unused (whole line, with newline)
|
||||
('use ui::{TextStyle, Typeset as _, bubble_radius, control_radius};\n', ''),
|
||||
('use ui::{TextStyle, Typeset as _, button_radius, control_radius};\n', ''),
|
||||
('use ui::{TextStyle, Typeset as _, button_radius, panel_radius};\n', ''),
|
||||
('use ui::{TextStyle, Typeset as _, control_radius};\n', ''),
|
||||
('use ui::{TextStyle, Typeset as _, button_radius};\n', ''),
|
||||
('use ui::{TextStyle, Typeset as _, surface_radius};\n', ''),
|
||||
('use ui::{TextStyle, Typeset as _};\n', ''),
|
||||
('use ui::divider;\n', ''),
|
||||
('use ui::button_radius;\n', ''),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
changed = []
|
||||
for path in CRATES.rglob('*.rs'):
|
||||
if str(path).startswith('crates/ui/'):
|
||||
continue
|
||||
text = path.read_text()
|
||||
original = text
|
||||
for old, new in REPLACEMENTS:
|
||||
text = text.replace(old, new)
|
||||
if text != original:
|
||||
path.write_text(text)
|
||||
changed.append(str(path))
|
||||
print('\n'.join(changed))
|
||||
print(f'{len(changed)} files changed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user