diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 8bcb3af9..cea715e7 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -299,9 +299,9 @@ impl Room { } /// A stable seed for the room's generated avatar - pub fn display_image_seed(&self, cx: &App) -> SharedString { + pub fn display_image_seed(&self, cx: &App) -> String { if self.is_group() { - SharedString::from(self.id.to_string()) + self.id.to_string() } else { self.display_member(cx).avatar_seed() } diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs index 859d0f0c..a2d63602 100644 --- a/crates/community_ui/src/lib.rs +++ b/crates/community_ui/src/lib.rs @@ -641,10 +641,7 @@ impl CommunityPanel { .community .read_with(cx, |community, _cx| { let seed = community.id().to_hex(); - let avatar = match community.icon() { - Some(path) => Avatar::from_source(path).seed(seed).large(), - None => Avatar::new(None).seed(seed).large(), - }; + let avatar = Avatar::from_source(community.icon()).seed(seed).large(); (community.name(), avatar) }) @@ -762,10 +759,7 @@ impl Panel for CommunityPanel { self.community .read_with(cx, |community, _cx| { let seed = community.id().to_hex(); - let avatar = match community.icon() { - Some(path) => Avatar::from_source(path).seed(seed).xsmall(), - None => Avatar::new(None).seed(seed).xsmall(), - }; + let avatar = Avatar::from_source(community.icon()).seed(seed).xsmall(); h_flex() .gap_1() diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index da74ea79..243268b5 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -113,8 +113,8 @@ impl Person { } /// A stable seed for this profile's generated avatar - pub fn avatar_seed(&self) -> SharedString { - SharedString::from(self.public_key().to_hex()) + pub fn avatar_seed(&self) -> String { + self.public_key().to_hex() } /// Get profile name diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 1e7c83ee..57909c81 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -10,7 +10,10 @@ use crate::{Selectable, Sizable, Size, StyledExt}; /// Number of rows and columns in the generated pixel grid. const PIXEL_GRID: usize = 8; -/// Probability that a cell in the left half of the grid is filled. +/// Empty cells kept between the pattern and the avatar edge, so the art +/// gathers in the center instead of filling the whole avatar. +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; @@ -33,19 +36,6 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength { } /// A deterministic, offline pixel-art avatar derived from a seed. -/// -/// Use it for entities that have no profile picture: the same seed always -/// renders the same pattern, so identities stay recognizable without a -/// network round trip. The pattern is painted as geometry and cropped to a -/// circle, at the same sizes as [`Avatar`]. -/// -/// # Examples -/// -/// ``` -/// use ui::avatar::PixelAvatar; -/// -/// PixelAvatar::new("alice"); -/// ``` #[derive(IntoElement)] pub struct PixelAvatar { seed: u64, @@ -87,7 +77,7 @@ impl RenderOnce for PixelAvatar { move |bounds, seed, window, cx| { let theme = cx.theme(); let main = Hsla { - h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1., + h: seed as f32 / u64::MAX as f32, s: 0.6, l: if theme.is_dark() { 0.6 } else { 0.45 }, a: 1., @@ -142,8 +132,11 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID]; let mut filled = 0usize; - for row in 0..PIXEL_GRID { - for col in 0..PIXEL_GRID / 2 { + 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 }); @@ -153,17 +146,17 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { } if filled < MIN_FILLED { - let half = PIXEL_GRID * PIXEL_GRID / 2; - let start = (rng.next() % half as u64) as usize; + let total = art_rows * art_columns; + let start = (rng.next() % total as u64) as usize; - for offset in 0..half { + for offset in 0..total { if filled >= MIN_FILLED { break; } - let ix = (start + offset) % half; - let row = ix / (PIXEL_GRID / 2); - let col = ix % (PIXEL_GRID / 2); + 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); @@ -358,24 +351,12 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement { } /// An element that renders a user avatar with customizable appearance options. -/// -/// Entities without a picture still get a stable identity: the avatar falls -/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there -/// is no picture and when the picture fails to load. -/// -/// # Examples -/// -/// ``` -/// use ui::avatar::Avatar; -/// -/// Avatar::new(None).seed("alice"); -/// ``` #[derive(IntoElement)] pub struct Avatar { base: Div, picture: Option, grayscale: bool, - seed: Option, + seed: Option, style: StyleRefinement, size: Size, border_color: Option, @@ -384,16 +365,13 @@ pub struct Avatar { impl Avatar { /// Creates an avatar for an entity whose profile picture may be missing. - /// - /// Use [`Avatar::seed`] to choose the generated - /// pixel avatar rendered when `picture` is `None`. pub fn new(picture: Option) -> Self { - Self::from_picture(picture.map(ImageSource::from)) + Self::from_source(picture) } - /// Creates an avatar from an already-resolved source. - pub fn from_source(picture: impl Into) -> Self { - Self::from_picture(Some(picture.into())) + /// Creates an avatar from an already-resolved source, which may be missing. + pub fn from_source(picture: Option>) -> Self { + Self::from_picture(picture.map(Into::into)) } fn from_picture(picture: Option) -> Self { @@ -410,33 +388,18 @@ impl Avatar { } /// Sets the seed for the generated pixel avatar. - /// - /// The seed should be a stable identifier of the entity the avatar - /// represents, such as a public key. - pub fn seed(mut self, seed: impl Into) -> Self { + pub fn seed(mut self, seed: impl Into) -> Self { self.seed = Some(seed.into()); self } /// Applies a grayscale filter to the avatar image. - /// - /// # Examples - /// - /// ``` - /// use ui::avatar::Avatar; - /// - /// Avatar::new(None).grayscale(true); - /// ``` pub fn grayscale(mut self, grayscale: bool) -> Self { self.grayscale = grayscale; self } /// Sets the border color of the avatar. - /// - /// This might be used to match the border to the background color of - /// the parent element to create the illusion of cropping another - /// shape underneath (for example in face piles.) pub fn border_color(mut self, color: impl Into) -> Self { self.border_color = Some(color.into()); self @@ -480,6 +443,7 @@ impl RenderOnce for Avatar { } else { px(0.) }; + let image_size = avatar_size(self.size).to_pixels(window.rem_size()); let container_size = image_size + border_width * 2.; @@ -487,6 +451,7 @@ impl RenderOnce for Avatar { Some(picture) => { let seed = self.seed; let grayscale = self.grayscale; + img(picture) .size(image_size) .rounded_full() @@ -510,76 +475,3 @@ impl RenderOnce for Avatar { .child(content) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pixel_patterns_are_symmetric_and_stable() { - for seed in 0..50 { - let pattern = pixel_pattern(seed); - let filled = pattern.iter().filter(|&&cell| cell != 0).count(); - - assert!( - filled >= MIN_FILLED * 2, - "pattern too sparse for seed {seed}" - ); - - for row in 0..PIXEL_GRID { - for col in 0..PIXEL_GRID { - assert_eq!( - pattern[row * PIXEL_GRID + col], - pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)], - "asymmetric pattern for seed {seed} at ({row}, {col})" - ); - } - } - } - - for seed in [0, 1, 42, u64::MAX] { - assert_eq!(pixel_pattern(seed), pixel_pattern(seed)); - } - - assert_ne!(pixel_pattern(42), pixel_pattern(43)); - } - - fn area(polygon: &[Point]) -> f32 { - let mut sum: f32 = 0.; - for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) { - sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32(); - } - (sum / 2.).abs() - } - - #[test] - fn clipping_keeps_only_the_part_inside_the_circle() { - let circle = circle_polygon(point(px(10.), px(10.)), 10.); - let square = |left: f32, top: f32| { - [ - point(px(left), px(top)), - point(px(left + 4.), px(top)), - point(px(left + 4.), px(top + 4.)), - point(px(left), px(top + 4.)), - ] - }; - - let inside = clip_polygon(&square(8., 8.), &circle); - assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside)); - - assert!(clip_polygon(&square(20., 20.), &circle).is_empty()); - - let straddling = clip_polygon(&square(0., 0.), &circle); - for vertex in &straddling { - let delta_x = vertex.x.as_f32() - 10.; - let delta_y = vertex.y.as_f32() - 10.; - assert!( - delta_x.hypot(delta_y) <= 10. + 0.1, - "clipped vertex outside the circle" - ); - } - - let area = area(&straddling); - assert!(area > 0. && area < 16., "area {area}"); - } -} diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs index 1a7fca76..8d1a9877 100644 --- a/crates/workspace/src/dialogs/new_chat.rs +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -11,6 +11,7 @@ use gpui::{ }; use nostr_sdk::prelude::*; use person::PersonRegistry; +use settings::AppSettings; use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; @@ -21,8 +22,6 @@ use ui::input::{Input, InputEvent, InputState}; use ui::nav::Nav; use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; -use crate::sidebar::nav_avatar; - /// Maximum number of members a chat can have. const MAX_MEMBERS: usize = 3; @@ -349,10 +348,14 @@ impl NewChat { Nav::new(SharedString::from(format!("contact-{}", start + offset))) .label(profile.name()) .text_sm() - .when_some( - nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) + .when(!AppSettings::get_hide_avatar(cx), |this| { + this.prefix( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_member(&public_key, cx); })) @@ -380,9 +383,12 @@ impl NewChat { .bg(cx.theme().ghost_element_background_alt) .text_size(px(10.)) .hover(|this| this.bg(cx.theme().ghost_element_hover)) - .when_some(profile.avatar(), |this, avatar| { - this.child(Avatar::from_source(avatar).xsmall().flex_shrink_0()) - }) + .child( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .xsmall() + .flex_shrink_0(), + ) .child(div().max_w_32().truncate().child(profile.name())) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_member(&public_key, cx); diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 10b6db2e..e204c9cb 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -13,9 +13,11 @@ use gpui::{ use instant::Duration; use nostr_sdk::prelude::*; use person::PersonRegistry; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry}; use theme::ActiveTheme; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::input::{Input, InputEvent, InputState}; @@ -23,8 +25,6 @@ use ui::nav::Nav; use ui::notification::Notification; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; -use crate::sidebar::nav_avatar; - const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; pub fn init(dock: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -337,6 +337,7 @@ impl SearchPanel { range: Range, cx: &Context, ) -> Vec> { + let hide_avatar = AppSettings::get_hide_avatar(cx); let persons = PersonRegistry::global(cx); // Get the results @@ -365,10 +366,14 @@ impl SearchPanel { .label(profile.name()) .text_sm() .font_medium() - .when_some( - nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) .on_click(handler) .selected(selected) .into_any_element() @@ -382,6 +387,7 @@ impl SearchPanel { range: Range, cx: &Context, ) -> Vec> { + let hide_avatar = AppSettings::get_hide_avatar(cx); let persons = PersonRegistry::global(cx); // Get the contact list @@ -410,10 +416,14 @@ impl SearchPanel { .label(profile.name().trim()) .text_sm() .font_medium() - .when_some( - nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(profile.avatar()) + .seed(profile.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) .on_click(handler) .selected(selected) .into_any_element() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index aa79b110..88066c15 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -44,7 +44,7 @@ mod tab; mod utils; use tab::SidebarTab; -pub(crate) use utils::{nav_avatar, nav_icon, pick_banner}; +pub(crate) use utils::{nav_icon, pick_banner}; pub enum SidebarRow { Room { room: Entity }, @@ -833,6 +833,8 @@ impl Sidebar { rows: &[SidebarRow], cx: &Context, ) -> Vec { + let hide_avatar = AppSettings::get_hide_avatar(cx); + rows.get(range.clone()) .into_iter() .flatten() @@ -855,8 +857,13 @@ impl Sidebar { .label(name) .text_sm() .font_medium() - .when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| { - this.prefix(avatar) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) }) .suffix( div() @@ -904,10 +911,14 @@ impl Sidebar { .label(person.name()) .text_sm() .font_medium() - .when_some( - nav_avatar(Some(person.avatar_seed()), person.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) .selected(selected) .on_click(move |_event, _window, cx| { if let Err(error) = sidebar.update(cx, |this, cx| { @@ -929,8 +940,13 @@ impl Sidebar { Nav::new(SharedString::from(format!("com-{index}"))) .label(name) .text_sm() - .when_some(nav_avatar(Some(seed), picture, cx), |this, avatar| { - this.prefix(avatar) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) }) .on_click(move |_event, window, cx| { ui::dock::add_panel_to( @@ -1021,6 +1037,7 @@ impl Sidebar { } fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement { + let hide_avatar = AppSettings::get_hide_avatar(cx); let persons = PersonRegistry::global(cx); let person = persons.read(cx).get(public_key, cx); @@ -1028,10 +1045,14 @@ impl Sidebar { .label(person.name()) .text_sm() .font_medium() - .when_some( - nav_avatar(Some(person.avatar_seed()), person.avatar(), cx), - |this, avatar| this.prefix(avatar), - ) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) .into_any_element() } } diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs index b01f3520..97386c0d 100644 --- a/crates/workspace/src/sidebar/utils.rs +++ b/crates/workspace/src/sidebar/utils.rs @@ -1,39 +1,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use gpui::prelude::FluentBuilder as _; -use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px}; -use settings::AppSettings; +use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px}; use theme::ActiveTheme; -use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Sizable, h_flex}; -pub(crate) fn nav_avatar( - seed: Option>, - picture: Option>, - cx: &App, -) -> Option { - if AppSettings::get_hide_avatar(cx) { - return None; - } - - match (seed.map(Into::into), picture.map(Into::into)) { - (None, None) => None, - (seed, Some(picture)) => Some( - Avatar::from_source(picture) - .when_some(seed, |avatar, seed| avatar.seed(seed)) - .small() - .flex_shrink_0() - .into_any_element(), - ), - (Some(seed), None) => Some( - PixelAvatar::new(seed) - .small() - .flex_shrink_0() - .into_any_element(), - ), - } -} - pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement { h_flex() .flex_shrink_0()