diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 094209b0..8bcb3af9 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -289,12 +289,21 @@ impl Room { } } - /// Gets the display image for the room - pub fn display_image(&self, cx: &App) -> SharedString { - if !self.is_group() { - self.display_member(cx).avatar() + /// Gets the display picture for the room, if it has one + pub fn display_image(&self, cx: &App) -> Option { + if self.is_group() { + None } else { - SharedString::from("brand/group.png") + self.display_member(cx).avatar() + } + } + + /// A stable seed for the room's generated avatar + pub fn display_image_seed(&self, cx: &App) -> SharedString { + if self.is_group() { + SharedString::from(self.id.to_string()) + } else { + self.display_member(cx).avatar_seed() } } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 0b54972d..c6a23955 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1203,6 +1203,7 @@ impl ChatPanel { if show_author { this.child( Avatar::new(author.avatar()) + .seed(author.avatar_seed()) .flex_shrink_0() .relative() .dropdown_menu(move |this, _window, _cx| { @@ -1470,7 +1471,7 @@ impl ChatPanel { h_flex() .gap_1() .font_semibold() - .child(Avatar::new(avatar).small()) + .child(Avatar::new(avatar).seed(profile.avatar_seed()).small()) .child(name.clone()), ), ) @@ -1978,11 +1979,12 @@ impl Panel for ChatPanel { self.room .read_with(cx, |this, cx| { let label = this.display_name(cx); - let url = this.display_image(cx); + let picture = this.display_image(cx); + let seed = this.display_image_seed(cx); h_flex() .gap_1p5() - .child(Avatar::new(url).xsmall()) + .child(Avatar::new(picture).seed(seed).xsmall()) .child(label) .into_any_element() }) diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 84f9b079..ddd2f34c 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -655,7 +655,11 @@ impl DeviceRegistry { .child( h_flex() .gap_2() - .child(Avatar::new(profile.avatar()).xsmall()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .xsmall(), + ) .child(profile.name()), ), ), diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index f597a197..da74ea79 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -103,14 +103,18 @@ impl Person { self.messaging_relays.first().cloned() } - /// Get profile avatar - pub fn avatar(&self) -> SharedString { + /// Get profile picture, if the profile has one + pub fn avatar(&self) -> Option { self.metadata() .picture .as_ref() .filter(|picture| !picture.is_empty()) - .map(|picture| picture.into()) - .unwrap_or_else(|| "brand/avatar.png".into()) + .map(SharedString::from) + } + + /// A stable seed for this profile's generated avatar + pub fn avatar_seed(&self) -> SharedString { + SharedString::from(self.public_key().to_hex()) } /// Get profile name diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 1586548c..2276e085 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -1,12 +1,25 @@ use gpui::prelude::FluentBuilder; use gpui::{ - AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity, - IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, - Window, div, img, px, + AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity, + IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString, + StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, }; use theme::ActiveTheme; -use crate::{Selectable, Sizable, Size}; +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. +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`]. pub(super) fn avatar_size(size: Size) -> AbsoluteLength { @@ -19,19 +32,350 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength { } } -/// An element that renders a user avatar with customizable appearance options. +/// 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}; +/// use ui::avatar::PixelAvatar; /// -/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red()); +/// PixelAvatar::new("alice"); +/// ``` +#[derive(IntoElement)] +pub struct PixelAvatar { + seed: u64, + size: Size, + style: StyleRefinement, +} + +impl PixelAvatar { + /// Creates a pixel avatar from `seed`. + pub fn new(seed: impl AsRef) -> Self { + Self { + seed: fnv1a(seed.as_ref().as_bytes()), + size: Size::Medium, + style: StyleRefinement::default(), + } + } +} + +impl Sizable for PixelAvatar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for PixelAvatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for PixelAvatar { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let side = avatar_size(self.size).to_pixels(window.rem_size()); + let seed = self.seed; + + canvas( + move |_bounds, _window, _cx| seed, + 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., + 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); + } + }, + ) + .refine_style(&self.style) + .size(side) + .flex_shrink_0() + } +} + +/// 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; + + for row in 0..PIXEL_GRID { + for col in 0..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 half = PIXEL_GRID * PIXEL_GRID / 2; + let start = (rng.next() % half as u64) as usize; + + for offset in 0..half { + if filled >= MIN_FILLED { + break; + } + + let ix = (start + offset) % half; + let row = ix / (PIXEL_GRID / 2); + let col = ix % (PIXEL_GRID / 2); + + if pattern[row * PIXEL_GRID + col] == 0 { + set_cell(&mut pattern, row, col, 1); + filled += 1; + } + } + } + + pattern +} + +/// Paints `polygons` as a single anti-aliased filled path in `color`. +fn paint_polygons<'a>( + window: &mut Window, + polygons: impl IntoIterator>>, + color: Hsla, +) { + let mut builder = PathBuilder::fill(); + let mut painted = false; + + for polygon in polygons { + if polygon.len() >= 3 { + builder.add_polygon(polygon, true); + painted = true; + } + } + + if painted && let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +/// Approximates the circle of `radius` around `center` as a convex polygon, +/// wound so that its interior is on the left of every directed edge. +fn circle_polygon(center: Point, radius: f32) -> Vec> { + let center_x = center.x.as_f32(); + let center_y = center.y.as_f32(); + + (0..CIRCLE_SEGMENTS) + .map(|index| { + let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32; + point( + px(center_x + radius * angle.cos()), + px(center_y + radius * angle.sin()), + ) + }) + .collect() +} + +/// The four corners of cell `(row, col)` of the grid laid out in `bounds`. +fn cell_polygon(bounds: &Bounds, row: usize, col: usize) -> [Point; 4] { + let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32; + let left = bounds.origin.x.as_f32() + col as f32 * cell; + let top = bounds.origin.y.as_f32() + row as f32 * cell; + + [ + point(px(left), px(top)), + point(px(left + cell), px(top)), + point(px(left + cell), px(top + cell)), + point(px(left), px(top + cell)), + ] +} + +/// Clips `subject` to the convex `clip` polygon, keeping the part inside it. +fn clip_polygon(subject: &[Point], clip: &[Point]) -> Vec> { + let mut current = subject.to_vec(); + let mut next = Vec::with_capacity(subject.len() + 4); + + for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) { + if current.is_empty() { + break; + } + + next.clear(); + let mut previous = match current.last() { + Some(&vertex) => vertex, + None => break, + }; + + for &vertex in current.iter() { + let previous_inside = is_inside(start, end, previous); + let vertex_inside = is_inside(start, end, vertex); + + if vertex_inside { + if !previous_inside + && let Some(crossing) = line_intersection(start, end, previous, vertex) + { + next.push(crossing); + } + next.push(vertex); + } else if previous_inside + && let Some(crossing) = line_intersection(start, end, previous, vertex) + { + next.push(crossing); + } + + previous = vertex; + } + + std::mem::swap(&mut current, &mut next); + } + + current +} + +/// Whether `vertex` lies on the interior side of the directed edge `start -> end`. +fn is_inside(start: Point, end: Point, vertex: Point) -> bool { + let start_x = start.x.as_f32(); + let start_y = start.y.as_f32(); + let edge_x = end.x.as_f32() - start_x; + let edge_y = end.y.as_f32() - start_y; + let to_vertex_x = vertex.x.as_f32() - start_x; + let to_vertex_y = vertex.y.as_f32() - start_y; + + edge_x * to_vertex_y - edge_y * to_vertex_x >= 0. +} + +/// The intersection of segment `from -> to` with the infinite line `start -> end`. +fn line_intersection( + start: Point, + end: Point, + from: Point, + to: Point, +) -> Option> { + let start_x = start.x.as_f32(); + let start_y = start.y.as_f32(); + let edge_x = end.x.as_f32() - start_x; + let edge_y = end.y.as_f32() - start_y; + let from_x = from.x.as_f32(); + let from_y = from.y.as_f32(); + let segment_x = to.x.as_f32() - from_x; + let segment_y = to.y.as_f32() - from_y; + let denominator = edge_x * segment_y - edge_y * segment_x; + + if denominator.abs() < f32::EPSILON { + return None; + } + + let offset_x = from_x - start_x; + let offset_y = from_y - start_y; + let t = (edge_y * offset_x - edge_x * offset_y) / denominator; + + Some(point( + px(from_x + segment_x * t), + px(from_y + segment_y * t), + )) +} + +/// Fills `cell (row, col)` and its horizontal mirror. +fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) { + pattern[row * PIXEL_GRID + col] = value; + pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value; +} + +/// FNV-1a 64-bit hash, stable across platforms and runs. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for &byte in bytes { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Tiny xorshift64* PRNG for deriving the pattern from the seed. +struct PixelRng(u64); + +impl PixelRng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn chance(&mut self, probability: f32) -> bool { + self.next() as f32 / (u64::MAX as f32) < probability + } +} + +/// Renders the generated pixel avatar shown in place of a missing picture. +fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement { + PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED)) + .with_size(size) + .into_any_element() +} + +/// An element that renders a user avatar with customizable appearance options. +/// +/// 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, - image: Img, + picture: Option, + grayscale: bool, + seed: Option, style: StyleRefinement, size: Size, border_color: Option, @@ -39,11 +383,16 @@ pub struct Avatar { } impl Avatar { - /// Creates a new avatar element with the specified image source. - pub fn new(src: impl Into) -> Self { + /// 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 { Avatar { base: div(), - image: img(src), + picture, + grayscale: false, + seed: None, style: StyleRefinement::default(), size: Size::Medium, border_color: None, @@ -51,17 +400,26 @@ 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 { + self.seed = Some(seed.into()); + self + } + /// Applies a grayscale filter to the avatar image. /// /// # Examples /// /// ``` - /// use ui::{Avatar, AvatarShape}; + /// use ui::avatar::Avatar; /// - /// let avatar = Avatar::new("path/to/image.png").grayscale(true); + /// Avatar::new(None).grayscale(true); /// ``` pub fn grayscale(mut self, grayscale: bool) -> Self { - self.image = self.image.grayscale(grayscale); + self.grayscale = grayscale; self } @@ -113,8 +471,24 @@ impl RenderOnce for Avatar { } else { px(0.) }; - let image_size = avatar_size(self.size); - let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.; + let image_size = avatar_size(self.size).to_pixels(window.rem_size()); + let container_size = image_size + border_width * 2.; + + let content = match self.picture { + Some(picture) => { + let seed = self.seed; + let grayscale = self.grayscale; + img(picture) + .size(image_size) + .rounded_full() + .object_fit(ObjectFit::Cover) + .grayscale(grayscale) + .bg(cx.theme().ghost_element_background) + .with_fallback(move || generated_avatar(seed.as_deref(), image_size)) + .into_any_element() + } + None => generated_avatar(self.seed.as_deref(), image_size), + }; div() .flex_shrink_0() @@ -124,18 +498,79 @@ impl RenderOnce for Avatar { .when_some(self.border_color, |this, color| { this.border(border_width).border_color(color) }) - .child( - self.image - .size(image_size) - .rounded_full() - .object_fit(ObjectFit::Cover) - .bg(cx.theme().ghost_element_background) - .with_fallback(move || { - img("brand/avatar.png") - .size(image_size) - .rounded_full() - .into_any_element() - }), - ) + .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/ui/src/lib.rs b/crates/ui/src/lib.rs index 20d5ef46..d4b5ab75 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -18,6 +18,7 @@ pub mod indicator; pub mod input; pub mod menu; pub mod modal; +pub mod nav_item; pub mod notification; pub mod popover; pub mod resizable; diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs index b7f0d324..f1a25534 100644 --- a/crates/ui/src/menu/dropdown_menu.rs +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -1,15 +1,18 @@ use std::rc::Rc; use gpui::{ - Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, - RenderOnce, SharedString, StyleRefinement, Styled, Window, + Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, + IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window, }; use crate::Selectable; use crate::avatar::Avatar; use crate::button::Button; use crate::menu::PopupMenu; -use crate::popover::Popover; +use crate::popover::{Popover, PopoverState}; + +/// Builds the items of a popup menu on each render. +type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu; /// A dropdown menu trait for buttons and other interactive elements pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { @@ -44,8 +47,7 @@ pub struct DropdownMenuPopover { style: StyleRefinement, anchor: Anchor, trigger: T, - #[allow(clippy::type_complexity)] - builder: Rc) -> PopupMenu>, + builder: Rc, } impl DropdownMenuPopover @@ -80,19 +82,95 @@ where } } +/// Opens a [`PopupMenu`] when its child is clicked with a mouse button +/// (right by default), keeping the child's own click handler intact. +#[derive(IntoElement)] +pub struct ContextMenu { + id: ElementId, + anchor: Anchor, + mouse_button: MouseButton, + child: AnyElement, + builder: Rc, +} + +impl ContextMenu { + pub fn new( + id: impl Into, + child: impl IntoElement, + builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + Self { + id: id.into(), + anchor: Anchor::TopLeft, + mouse_button: MouseButton::Right, + child: child.into_any_element(), + builder: Rc::new(builder), + } + } + + /// Set the anchor corner of the menu, default is `Anchor::TopLeft`. + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + /// Set the mouse button that opens the menu, default is `MouseButton::Right`. + pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self { + self.mouse_button = mouse_button; + self + } +} + #[derive(Default)] -struct DropdownMenuState { +struct MenuState { menu: Option>, } +/// Builds the menu once and reuses it until it is dismissed. +/// +/// The popover content closure runs on every render, so rebuilding the menu +/// entity each time would drop its focus and selection state. +fn cached_menu( + menu_state: &Entity, + builder: Rc, + window: &mut Window, + cx: &mut Context, +) -> Entity { + if let Some(menu) = menu_state.read(cx).menu.clone() { + return menu; + } + + let menu = PopupMenu::build(window, cx, move |menu, window, cx| { + builder(menu, window, cx) + }); + menu_state.update(cx, |state, _| { + state.menu = Some(menu.clone()); + }); + menu.focus_handle(cx).focus(window, cx); + + let popover_state = cx.entity(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &DismissEvent, window, cx| { + popover_state.update(cx, |state, cx| state.dismiss(window, cx)); + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + + menu +} + impl RenderOnce for DropdownMenuPopover where T: Selectable + IntoElement + 'static, { fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { let builder = self.builder.clone(); - let menu_state = - window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default()); + let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default()); Popover::new(SharedString::from(format!("popover:{}", self.id))) .appearance(false) @@ -100,46 +178,21 @@ where .trigger(self.trigger) .trigger_style(self.style) .anchor(self.anchor) - .content(move |_, window, cx| { - // Here is special logic to only create the PopupMenu once and reuse it. - // Because this `content` will called in every time render, so we need to store the menu - // in state to avoid recreating at every render. - // - // And we also need to rebuild the menu when it is dismissed, to rebuild menu items - // dynamically for support `dropdown_menu` method, so we listen for DismissEvent below. - let menu = match menu_state.read(cx).menu.clone() { - Some(menu) => menu, - None => { - let builder = builder.clone(); - let menu = PopupMenu::build(window, cx, move |menu, window, cx| { - builder(menu, window, cx) - }); - menu_state.update(cx, |state, _| { - state.menu = Some(menu.clone()); - }); - menu.focus_handle(cx).focus(window, cx); + .content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx)) + } +} - // Listen for dismiss events from the PopupMenu to close the popover. - let popover_state = cx.entity(); - window - .subscribe(&menu, cx, { - let menu_state = menu_state.clone(); - move |_, _: &DismissEvent, window, cx| { - popover_state.update(cx, |state, cx| { - state.dismiss(window, cx); - }); - menu_state.update(cx, |state, _| { - state.menu = None; - }); - } - }) - .detach(); +impl RenderOnce for ContextMenu { + fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { + let builder = self.builder.clone(); + let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default()); - menu.clone() - } - }; - - menu.clone() - }) + Popover::new(SharedString::from(format!("context-menu:{}", self.id))) + .appearance(false) + .overlay_closable(false) + .anchor(self.anchor) + .mouse_button(self.mouse_button) + .trigger_with(move |_open, _window, _cx| self.child) + .content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx)) } } diff --git a/crates/ui/src/menu/mod.rs b/crates/ui/src/menu/mod.rs index ae872f5a..b65aecab 100644 --- a/crates/ui/src/menu/mod.rs +++ b/crates/ui/src/menu/mod.rs @@ -4,7 +4,7 @@ mod dropdown_menu; mod menu_item; mod popup_menu; -pub use dropdown_menu::DropdownMenu; +pub use dropdown_menu::{ContextMenu, DropdownMenu}; pub use popup_menu::{PopupMenu, PopupMenuItem}; pub(crate) fn init(cx: &mut App) { diff --git a/crates/ui/src/nav_item.rs b/crates/ui/src/nav_item.rs new file mode 100644 index 00000000..5ab0cae9 --- /dev/null +++ b/crates/ui/src/nav_item.rs @@ -0,0 +1,100 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, + div, +}; +use theme::ActiveTheme; + +use crate::{StyledExt, h_flex}; + +/// A single navigation entry in a sidebar. +/// +/// It has an arbitrary leading element, such as an icon or avatar, and a text +/// label. It can carry an optional trailing suffix, such as a status icon, and +/// an optional click handler. Rows with a click handler are highlighted on +/// hover and show a pointer cursor. +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +pub struct NavItem { + id: ElementId, + style: StyleRefinement, + icon: AnyElement, + label: SharedString, + /// Trailing element at the right edge of the row, after the ellipsized label. + suffix: Option, + on_click: Option>, +} + +impl NavItem { + pub fn new( + id: impl Into, + label: impl Into, + icon: impl IntoElement, + ) -> Self { + Self { + id: id.into(), + style: StyleRefinement::default(), + icon: icon.into_any_element(), + label: label.into(), + suffix: None, + on_click: None, + } + } + + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +impl Styled for NavItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for NavItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let clickable = self.on_click.is_some(); + + h_flex() + .id(self.id) + .refine_style(&self.style) + .px_2() + .py_1() + .w_full() + .gap_2() + .rounded(cx.theme().radius) + .text_color(cx.theme().text) + .child(self.icon) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .child(self.label), + ) + .when_some(self.suffix, |this, suffix| { + this.child(div().flex_shrink_0().child(suffix)) + }) + .when(clickable, |this| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + }) + .when_some(self.on_click, |this, handler| { + this.on_click(move |event, window, cx| handler(event, window, cx)) + }) + } +} diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index c77ab263..5ab53a02 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -87,6 +87,19 @@ impl Popover { self } + /// Set the trigger from a builder, for elements that have no selected state. + /// + /// [`Self::trigger`] marks the trigger as selected while the popover is + /// open, so it cannot be used with elements whose selection carries a + /// different meaning, such as a row that indicates the current room. + pub fn trigger_with(mut self, trigger: F) -> Self + where + F: FnOnce(bool, &Window, &App) -> AnyElement + 'static, + { + self.trigger = Some(Box::new(trigger)); + self + } + /// Set the default open state of the popover, default is `false`. /// /// This is only used to initialize the open state of the popover. diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 79738788..6ae2fdab 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -295,7 +295,11 @@ impl Screening { .rounded(cx.theme().radius) .text_sm() .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .child(Avatar::new(profile.avatar()).small()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ) .child(profile.name()), ); } @@ -335,7 +339,11 @@ impl Render for Screening { .items_center() .justify_center() .text_center() - .child(Avatar::new(profile.avatar()).large()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .large(), + ) .child( div() .font_semibold() diff --git a/crates/workspace/src/panels/contact_list.rs b/crates/workspace/src/panels/contact_list.rs index 8a0e6d18..d3f34f37 100644 --- a/crates/workspace/src/panels/contact_list.rs +++ b/crates/workspace/src/panels/contact_list.rs @@ -239,7 +239,11 @@ impl ContactListPanel { h_flex() .gap_2() .text_sm() - .child(Avatar::new(profile.avatar()).small()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ) .child(profile.name()), ) .child( diff --git a/crates/workspace/src/panels/profile.rs b/crates/workspace/src/panels/profile.rs index a6c7fd19..b7b19398 100644 --- a/crates/workspace/src/panels/profile.rs +++ b/crates/workspace/src/panels/profile.rs @@ -309,12 +309,7 @@ impl Render for ProfilePanel { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { let avatar_input = self.avatar_input.read(cx).value(); - // Get the avatar - let avatar = if avatar_input.is_empty() { - "brand/avatar.png" - } else { - avatar_input.as_str() - }; + let picture = (!avatar_input.is_empty()).then_some(avatar_input); // Get the public key as short string let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); @@ -331,7 +326,7 @@ impl Render for ProfilePanel { .items_center() .justify_center() .gap_4() - .child(Avatar::new(avatar).large()) + .child(Avatar::new(picture).seed(self.public_key.to_hex()).large()) .child( Button::new("upload") .icon(IconName::PlusCircle) diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 6243c209..628c7192 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -344,6 +344,7 @@ impl SearchPanel { RoomEntry::new(range.start + ix) .name(profile.name()) .avatar(profile.avatar()) + .seed(profile.avatar_seed()) .on_click(handler) .selected(selected) .into_any_element() @@ -381,6 +382,7 @@ impl SearchPanel { RoomEntry::new(range.start + ix) .name(profile.name().trim()) .avatar(profile.avatar()) + .seed(profile.avatar_seed()) .on_click(handler) .selected(selected) .into_any_element() diff --git a/crates/workspace/src/sidebar/entry.rs b/crates/workspace/src/sidebar/entry.rs index 05808e86..f7c91233 100644 --- a/crates/workspace/src/sidebar/entry.rs +++ b/crates/workspace/src/sidebar/entry.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use chat::RoomKind; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, px, + App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, }; use nostr_sdk::prelude::*; use settings::AppSettings; @@ -16,22 +16,19 @@ use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex use crate::dialogs::screening; -/// Group name callers can target from a `trailing` element to react to row hover. -pub const ROOM_ENTRY_GROUP: &str = "room-entry"; - #[derive(IntoElement)] pub struct RoomEntry { ix: usize, public_key: Option, name: Option, avatar: Option, + seed: Option, created_at: Option, kind: Option, depth: u8, selected: bool, #[allow(clippy::type_complexity)] handler: Option>, - trailing: Option, } impl RoomEntry { @@ -41,12 +38,12 @@ impl RoomEntry { public_key: None, name: None, avatar: None, + seed: None, created_at: None, kind: None, depth: 0, handler: None, selected: false, - trailing: None, } } @@ -60,8 +57,13 @@ impl RoomEntry { self } - pub fn avatar(mut self, avatar: impl Into) -> Self { - self.avatar = Some(avatar.into()); + pub fn avatar(mut self, picture: Option) -> Self { + self.avatar = picture; + self + } + + pub fn seed(mut self, seed: impl Into) -> Self { + self.seed = Some(seed.into()); self } @@ -80,11 +82,6 @@ impl RoomEntry { self } - pub fn trailing(mut self, trailing: impl IntoElement) -> Self { - self.trailing = Some(trailing.into_any_element()); - self - } - pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -112,22 +109,26 @@ impl RenderOnce for RoomEntry { let public_key = self.public_key; let is_selected = self.is_selected(); + let avatar = match (self.avatar, self.seed) { + (None, None) => None, + (picture, seed) => Some( + Avatar::new(picture) + .when_some(seed, |avatar, seed| avatar.seed(seed)) + .xsmall() + .flex_shrink_0(), + ), + }; h_flex() .id(self.ix) - .group(ROOM_ENTRY_GROUP) .h_8() .w_full() - .pl(px(6. + self.depth as f32 * 14.)) + .pl(px(6. + self.depth as f32 * 10.)) .pr_1p5() .gap_2() .text_sm() .rounded(cx.theme().radius) - .when(!hide_avatar, |this| { - this.when_some(self.avatar, |this, avatar| { - this.child(Avatar::new(avatar).small().flex_shrink_0()) - }) - }) + .when(!hide_avatar, |this| this.children(avatar)) .child( div() .flex_1() @@ -162,7 +163,6 @@ impl RenderOnce for RoomEntry { .when_some(self.created_at, |this, created_at| this.child(created_at)), ), ) - .when_some(self.trailing, |this, trailing| this.child(trailing)) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .when_some(self.handler, |this, handler| { this.on_click(move |event, window, cx| { diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index a3e12e2a..01069320 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -20,10 +20,12 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; +use ui::nav_item::NavItem; use ui::scroll::Scrollbar; use ui::{ - IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, v_flex, + Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, + v_flex, }; use crate::Command; @@ -31,7 +33,6 @@ use crate::Command; mod entry; mod tree; -use entry::ROOM_ENTRY_GROUP; pub(crate) use entry::RoomEntry; use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; @@ -291,7 +292,8 @@ impl Sidebar { let room_id = room.read(cx).id; let public_key = room.read(cx).display_member(cx).public_key(); let name = room.read(cx).display_name(cx); - let avatar = room.read(cx).display_image(cx); + let picture = room.read(cx).display_image(cx); + let seed = room.read(cx).display_image_seed(cx); let kind = room.read(cx).kind; let created_at = room.read(cx).created_at.to_ago(); let room_clone = room.clone(); @@ -301,55 +303,47 @@ impl Sidebar { }); }); - let sidebar = cx.entity().downgrade(); - let trailing = - Button::new(ElementId::NamedInteger("room-menu".into(), index as u64)) - .icon(IconName::Ellipsis) - .ghost_alt() - .xsmall() - .compact() - .invisible() - .group_hover(ROOM_ENTRY_GROUP, |style| style.visible()) - .dropdown_menu(move |this, _window, _cx| { - let sidebar = sidebar.clone(); - - if pinned { - this.item(PopupMenuItem::new("Unpin").on_click( - move |_event, _window, cx| { - if let Err(error) = - sidebar.update(cx, |sidebar, cx| { - sidebar.unpin_room(room_id, cx); - }) - { - log::error!("Failed to unpin room: {error}"); - } - }, - )) - } else { - this.item(PopupMenuItem::new("Pin").on_click( - move |_event, _window, cx| { - if let Err(error) = - sidebar.update(cx, |sidebar, cx| { - sidebar.pin_room(room_id, cx); - }) - { - log::error!("Failed to pin room: {error}"); - } - }, - )) - } - }); - - RoomEntry::new(index) + let entry = RoomEntry::new(index) .name(name) - .avatar(avatar) + .avatar(picture) + .seed(seed) .public_key(public_key) .kind(kind) .created_at(created_at) .depth(*depth) - .trailing(trailing) - .on_click(handler) - .into_any_element() + .on_click(handler); + + let sidebar = cx.entity().downgrade(); + ContextMenu::new( + ElementId::NamedInteger("room-context-menu".into(), index as u64), + entry, + move |this, _window, _cx| { + let sidebar = sidebar.clone(); + + if pinned { + this.item(PopupMenuItem::new("Unpin").on_click( + move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |sidebar, cx| { + sidebar.unpin_room(room_id, cx); + }) { + log::error!("Failed to unpin room: {error}"); + } + }, + )) + } else { + this.item(PopupMenuItem::new("Pin").on_click( + move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |sidebar, cx| { + sidebar.pin_room(room_id, cx); + }) { + log::error!("Failed to pin room: {error}"); + } + }, + )) + } + }, + ) + .into_any_element() } SidebarRow::Community { entry, depth } => TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), @@ -399,17 +393,23 @@ impl Sidebar { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(public_key, cx); let avatar = profile.avatar(); + let avatar_seed = profile.avatar_seed(); let name = profile.name(); this.child( Button::new("current-user") - .child(Avatar::new(avatar.clone()).xsmall()) + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), + ) .small() .caret() .compact() .transparent() .dropdown_menu(move |this, _window, cx| { let avatar = avatar.clone(); + let avatar_seed = avatar_seed.clone(); let name = name.clone(); this.min_w(px(256.)) @@ -418,7 +418,11 @@ impl Sidebar { .gap_1p5() .text_xs() .text_color(cx.theme().text_muted) - .child(Avatar::new(avatar.clone()).xsmall()) + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), + ) .child(name.clone()) })) .separator() @@ -463,19 +467,6 @@ impl Sidebar { } } -fn nav_item(id: &'static str, icon: IconName, label: &'static str, command: Command) -> Button { - Button::new(id) - .icon(icon) - .label(label) - .ghost_alt() - .small() - .w_full() - .justify_start() - .on_click(move |_event, _window, cx| { - cx.dispatch_action(&command); - }) -} - fn load_expanded(cx: &App) -> BTreeSet { let Some(keys) = AppSettings::get_expanded_sections(cx) else { return BTreeSet::from([TreeSection::Community, TreeSection::Messages]); @@ -520,24 +511,24 @@ impl Render for Sidebar { .px_2() .py_1() .gap_1() - .child(nav_item( - "nav-inbox", - IconName::Inbox, - "Inbox", - Command::ShowInbox, - )) - .child(nav_item( - "nav-browse", - IconName::Compass, - "Browse", - Command::ShowBrowse, - )) - .child(nav_item( - "nav-search", - IconName::Search, - "Search", - Command::ShowSearch, - )), + .child( + NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .on_click(|_event, _window, cx| { + cx.dispatch_action(&Command::ShowInbox) + }), + ) + .child( + NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small()) + .on_click(|_event, _window, cx| { + cx.dispatch_action(&Command::ShowBrowse) + }), + ) + .child( + NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small()) + .on_click(|_event, _window, cx| { + cx.dispatch_action(&Command::ShowSearch) + }), + ), ) .child( v_flex() diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 36a9db9e..764662a4 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -7,6 +7,7 @@ use gpui::{ SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; use theme::ActiveTheme; +use ui::avatar::PixelAvatar; use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -147,8 +148,9 @@ impl TreeRow { self } - pub fn avatar(mut self, name: impl Into) -> Self { - self.avatar = Some(name.into()); + /// Sets the seed for the row's generated avatar. + pub fn avatar(mut self, seed: impl Into) -> Self { + self.avatar = Some(seed.into()); self } @@ -174,11 +176,7 @@ impl TreeRow { impl RenderOnce for TreeRow { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let indent = px(6. + self.depth as f32 * 14.); - let avatar_initial = self - .avatar - .as_ref() - .and_then(|name| name.chars().next()) - .map(|letter| SharedString::from(letter.to_uppercase().to_string())); + let avatar_seed = self.avatar; let is_section = self.kind == TreeRowKind::Section; let is_community = self.kind == TreeRowKind::Community; let is_hint = self.kind == TreeRowKind::Hint; @@ -192,9 +190,7 @@ impl RenderOnce for TreeRow { .gap_2() .rounded(cx.theme().radius) .when(is_section, |this| { - this.text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) + this.text_xs().text_color(cx.theme().text_muted) }) .when(is_community, |this| this.text_sm()) .when(is_hint, |this| { @@ -202,36 +198,30 @@ impl RenderOnce for TreeRow { .font_normal() .text_color(cx.theme().text_placeholder) }) - .when_some(self.caret, |this, caret| { - this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) - }) .when_some(self.icon, |this, icon| { this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) }) - .when_some(avatar_initial, |this, initial| { - this.child( - div() - .flex_shrink_0() - .size_5() - .rounded_full() - .bg(cx.theme().element_background) - .flex() - .items_center() - .justify_center() - .text_xs() - .text_color(cx.theme().text) - .child(initial), - ) + .when_some(avatar_seed, |this, seed| { + this.child(PixelAvatar::new(seed).xsmall()) }) - .child(div().flex_1().truncate().child(self.label)) - .when_some(self.count, |this, count| { - this.child( - div() - .flex_shrink_0() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(count.to_string()), - ) + .child( + h_flex() + .gap_1() + .flex_1() + .child(div().truncate().min_w_0().child(self.label)) + .when_some(self.count, |this, count| { + this.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().text_placeholder) + .font_semibold() + .child(count.to_string()), + ) + }), + ) + .when_some(self.caret, |this, caret| { + this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) }) .when(self.dot, |this| { this.child( diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index 002995d5..93b03413 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,7 +1,7 @@ # Sidebar tree redesign -Status: steps 1-9 implemented. Search lives in `panels/search.rs`; the sidebar -renders the nav rail, the flattened tree, per-row pin/unpin menus, and the +Status: steps 1-10 implemented. Search lives in `panels/search.rs`; the sidebar +renders the nav rail, the flattened tree, per-row pin/unpin context menus, and the Community section from placeholder data (`TODO(concord)`). Pins and expanded sections persist through `settings::Settings`. `cargo check`, `cargo clippy --workspace --all-targets` and `rustfmt --check` on the changed files are clean. @@ -247,9 +247,9 @@ API exposes it. ### 6.3 File rows -- Rooms reuse `RoomEntry` with two additions: `.depth(u8)` (left padding - `px(6. + depth * 14.)`) and an optional `.trailing(AnyElement)` slot for the - hover ellipsis; height becomes `h_8`. +- Rooms reuse `RoomEntry` with `.depth(u8)` (left padding + `px(6. + depth * 14.)`), wrapped in a `ContextMenu` that opens the pin/unpin + menu; height becomes `h_8`. - Community rows use `TreeRow` with a 20px `element_background` circle and the first letter, `text_sm` label. - Indent guide (optional polish): 1px `border_variant` vertical line at the @@ -278,12 +278,15 @@ Search is now a panel, not a sidebar mode: ## 8. Pin folder - Pin state: `pinned_rooms: Vec` in `Sidebar`, order = pin order. -- UI: hover ellipsis (`IconName::Ellipsis`, `ghost_alt`, `xsmall`, `compact`) - on each room row, opening a `DropdownMenu` with `Pin` / `Unpin` - (`PopupMenuItem::new(...).on_click(...)`). The ellipsis is a `RoomEntry` - trailing element, hidden by default and revealed with `group_hover` against the - row's `ROOM_ENTRY_GROUP` group. (There is no right-click menu pattern in the - codebase yet; a context menu is a follow-up.) +- UI: right-clicking a room row opens a `ContextMenu` + (`crates/ui/src/menu/dropdown_menu.rs`) with `Pin` / `Unpin` + (`PopupMenuItem::new(...).on_click(...)`). The menu is a `PopupMenu` anchored to + the row and opened with `MouseButton::Right`, reusing the cached-menu machinery + shared with `DropdownMenuPopover`. +- The row keeps its own left-click handler: GPUI fires `on_click` only for the + left button, and the popover's right-button handler calls `cx.stop_propagation()`, + so pinning never opens the room. `RoomEntry` no longer carries a `trailing` slot + or a group name for hover-revealed chrome. - `Pinned` folder is hidden when no pinned room resolves to a live room; otherwise expanded by default, showing pinned rooms in pin order. - A pinned room remains listed under `Messages`. @@ -351,15 +354,16 @@ unused until step 5 consumes them. Run the checks in §15 after each step. `uniform_list("sidebar-tree")`. `has_search`, `find_focused`, `set_input_focus` were dropped because they only existed to switch the sidebar between the room list and the search view. -- [x] **Step 6 — pin UI.** Per-row ellipsis (`IconName::Ellipsis`, `ghost_alt`, - `xsmall`, `compact`) passed to `RoomEntry::trailing`, revealed on row hover - through the `ROOM_ENTRY_GROUP` group name, opening a `DropdownMenu` with - Pin/Unpin; the handlers call `pin_room`/`unpin_room` through a - `WeakEntity`. Click propagation: `gpui_base::Popover` registers the - trigger's `on_mouse_down` with `cx.stop_propagation()`, and GPUI only fires an - element's `on_click` when that element recorded the matching mouse-down, so the - row's `emit_room` click does not fire when the menu trigger is clicked. No extra - handling was needed. +- [x] **Step 6 — pin UI.** Each room row is wrapped in a `ContextMenu` + (`ui::menu::ContextMenu`, added in this step) that opens a `PopupMenu` with + `Pin` / `Unpin` on right-click; the handlers call `pin_room`/`unpin_room` through + a `WeakEntity`. `ContextMenu` reuses the cached-menu logic extracted + from `DropdownMenuPopover` and opens through `Popover::trigger_with`, so the + trigger keeps its own click handler and no `Selectable` state is forced onto the + row. Left-click still opens the room, because GPUI fires `on_click` only for the + left button while the popover handles the right one. (The first cut used a + hover ellipsis in a `RoomEntry::trailing` slot; that was removed once the context + menu existed.) - [x] **Step 7 — community section.** Dummy entries and the empty-state hint are rendered; the `TODO(concord)` marker sits on `dummy_communities()`. The flattening and rendering landed with step 5 (`SidebarRow::Community` -> @@ -393,6 +397,37 @@ unused until step 5 consumes them. Run the checks in §15 after each step. checked per file with `rustfmt +nightly --check`; `cargo fmt --all` is **not** run, because the repo's committed formatting does not match the installed nightly rustfmt (many pre-existing diffs in unrelated files). +- [x] **Step 10 — nav item element.** Extracted the rail rows into + `ui::nav_item::NavItem` (`crates/ui/src/nav_item.rs`), ported from the + `signed_ui` reference and adapted to this repo (`Rc` handlers, + `ghost_element_hover`, `StyledExt::refine_style`, no `gpui_component` + dependency). The sidebar builds the three rail rows directly with it and the + local `nav_item(...) -> Button` helper is gone. +- [x] **Step 11 — pixel avatars.** Entities without a picture used to fall back + to the generic `brand/avatar.png` (and `brand/group.png` for groups), and the + community rows drew a first-letter circle. Both are replaced by a deterministic + pixel avatar ported from the `signed_ui` `pixel_avatar.rs` reference and added + to `ui::avatar` (`crates/ui/src/avatar.rs`) as `PixelAvatar`: an 8x8 mirrored + grid seeded by an FNV-1a hash of a stable string, with the hue offset from + `theme().icon_accent` and fixed saturation/lightness per appearance so patterns + stay readable in both modes and distinguishable between seeds. The cells are + painted as path geometry in a `canvas` and cropped to a circle with + Sutherland-Hodgman clipping: GPUI clips an overflowing child to its bounding box + and never to a corner radius, so a rounded container cannot crop a grid into a + circle, while paths are rasterized with MSAA, so the crop is anti-aliased and the + avatar is a true circle rather than a stair-stepped disc. It sizes through the + shared `avatar_size`, so it matches `Avatar` at every size, including the + default, and is adapted to this repo like step 10 (no `gpui_component`, + `crate::Sizable`/`Size`, `StyledExt::refine_style`). `Avatar::new` now takes + `Option` (the + picture) plus `.seed(...)`, and renders the generated avatar both when the + picture is absent and when it fails to load; `Person::avatar()` and + `Room::display_image()` return `Option`, with the new `Person::avatar_seed()` + and `Room::display_image_seed()` supplying the seed (public key for a person or + DM, room id for a group). `RoomEntry` takes the picture plus a seed, and + `TreeRow`'s letter circle became a `PixelAvatar` seeded by the row's name. Every + avatar call site passes a seed: chat (`chat_ui`), device, screening, contact + list, profile, search, and the sidebar. ## 13. Files touched @@ -400,13 +435,20 @@ unused until step 5 consumes them. Run the checks in §15 after each step. | --- | --- | | `crates/workspace/src/sidebar/mod.rs` | State, flattening, render rewrite; search code moves out | | `crates/workspace/src/sidebar/tree.rs` | New: sections, rows, `TreeRow`, dummy data | -| `crates/workspace/src/sidebar/entry.rs` | `depth`, `trailing`, height | +| `crates/workspace/src/sidebar/entry.rs` | `depth`, height | | `crates/workspace/src/panels/{inbox,browse,search}.rs` | New panel modules | | `crates/workspace/src/panels/mod.rs` | Module registration | | `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms | | `crates/ui/src/icon.rs` | New icon variants | | `assets/icons/{folder,compass,message}.svg` | New assets | | `crates/settings/src/lib.rs` | Step 8: `pinned_rooms`, `expanded_sections`, accessors, `entity()` | +| `crates/ui/src/nav_item.rs` | Step 10: `NavItem` element, new | +| `crates/ui/src/avatar.rs` | Step 11: `PixelAvatar`; `Avatar` takes a picture plus a seed | +| `crates/person/src/person.rs` | Step 11: `avatar()` returns `Option`, new `avatar_seed()` | +| `crates/chat/src/room.rs` | Step 11: `display_image()` returns `Option`, new `display_image_seed()` | +| `crates/workspace/src/{sidebar,panels,dialogs}/**.rs` | Step 11: room rows, community rows, and person avatars pass seeds | +| `crates/ui/src/menu/dropdown_menu.rs` | Step 6: `ContextMenu` + cached-menu helper shared with `DropdownMenuPopover` | +| `crates/ui/src/popover.rs` | Step 6: `Popover::trigger_with` for triggers without a selected state | ## 14. Edge cases @@ -443,13 +485,16 @@ unused until step 5 consumes them. Run the checks in §15 after each step. - each folder toggles and keeps its state across re-renders and room updates; - Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears when expanded; - - pin/unpin from the row menu updates the Pinned folder without opening the - room; clicking a pinned row opens it; + - pin/unpin from the row context menu (right-click) updates the Pinned folder + without opening the room; clicking a pinned row opens it; - Messages lists ongoing rooms and still opens the screening modal for non-ongoing rooms; - pins and expanded/collapsed folders survive an app restart (collapsing every folder also survives, rather than reverting to the default sections); - - empty states at 0 ongoing and 0 requests. + - empty states at 0 ongoing and 0 requests; + - profiles, DMs, groups, and community rows without a picture show a generated + pixel avatar, which is stable across restarts and matches wherever the same + identity appears; a picture that fails to load falls back to it as well. - There is no GPUI test infrastructure in the repo (no `#[gpui::test]` anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin ordering) if they are extracted as free functions; `cargo check` plus the