refactor avatar

This commit is contained in:
2026-09-18 17:03:17 +07:00
parent 88005fbc41
commit 1320a2c361
18 changed files with 919 additions and 263 deletions
+14 -5
View File
@@ -289,12 +289,21 @@ impl Room {
} }
} }
/// Gets the display image for the room /// Gets the display picture for the room, if it has one
pub fn display_image(&self, cx: &App) -> SharedString { pub fn display_image(&self, cx: &App) -> Option<SharedString> {
if !self.is_group() { if self.is_group() {
self.display_member(cx).avatar() None
} else { } 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()
} }
} }
+5 -3
View File
@@ -1203,6 +1203,7 @@ impl ChatPanel {
if show_author { if show_author {
this.child( this.child(
Avatar::new(author.avatar()) Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0() .flex_shrink_0()
.relative() .relative()
.dropdown_menu(move |this, _window, _cx| { .dropdown_menu(move |this, _window, _cx| {
@@ -1470,7 +1471,7 @@ impl ChatPanel {
h_flex() h_flex()
.gap_1() .gap_1()
.font_semibold() .font_semibold()
.child(Avatar::new(avatar).small()) .child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
.child(name.clone()), .child(name.clone()),
), ),
) )
@@ -1978,11 +1979,12 @@ impl Panel for ChatPanel {
self.room self.room
.read_with(cx, |this, cx| { .read_with(cx, |this, cx| {
let label = this.display_name(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() h_flex()
.gap_1p5() .gap_1p5()
.child(Avatar::new(url).xsmall()) .child(Avatar::new(picture).seed(seed).xsmall())
.child(label) .child(label)
.into_any_element() .into_any_element()
}) })
+5 -1
View File
@@ -655,7 +655,11 @@ impl DeviceRegistry {
.child( .child(
h_flex() h_flex()
.gap_2() .gap_2()
.child(Avatar::new(profile.avatar()).xsmall()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(profile.name()), .child(profile.name()),
), ),
), ),
+8 -4
View File
@@ -103,14 +103,18 @@ impl Person {
self.messaging_relays.first().cloned() self.messaging_relays.first().cloned()
} }
/// Get profile avatar /// Get profile picture, if the profile has one
pub fn avatar(&self) -> SharedString { pub fn avatar(&self) -> Option<SharedString> {
self.metadata() self.metadata()
.picture .picture
.as_ref() .as_ref()
.filter(|picture| !picture.is_empty()) .filter(|picture| !picture.is_empty())
.map(|picture| picture.into()) .map(SharedString::from)
.unwrap_or_else(|| "brand/avatar.png".into()) }
/// 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 /// Get profile name
+464 -29
View File
@@ -1,12 +1,25 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity, AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString,
Window, div, img, px, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
}; };
use theme::ActiveTheme; 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`]. /// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength { 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 /// # 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<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;
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<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 so that its interior is on the left of every directed edge.
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.
///
/// 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)] #[derive(IntoElement)]
pub struct Avatar { pub struct Avatar {
base: Div, base: Div,
image: Img, picture: Option<SharedString>,
grayscale: bool,
seed: Option<SharedString>,
style: StyleRefinement, style: StyleRefinement,
size: Size, size: Size,
border_color: Option<Hsla>, border_color: Option<Hsla>,
@@ -39,11 +383,16 @@ pub struct Avatar {
} }
impl Avatar { impl Avatar {
/// Creates a new avatar element with the specified image source. /// Creates an avatar for an entity whose profile picture may be missing.
pub fn new(src: impl Into<ImageSource>) -> Self { ///
/// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when
/// `picture` is `None`.
pub fn new(picture: Option<SharedString>) -> Self {
Avatar { Avatar {
base: div(), base: div(),
image: img(src), picture,
grayscale: false,
seed: None,
style: StyleRefinement::default(), style: StyleRefinement::default(),
size: Size::Medium, size: Size::Medium,
border_color: None, 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<SharedString>) -> Self {
self.seed = Some(seed.into());
self
}
/// Applies a grayscale filter to the avatar image. /// Applies a grayscale filter to the avatar image.
/// ///
/// # Examples /// # 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 { pub fn grayscale(mut self, grayscale: bool) -> Self {
self.image = self.image.grayscale(grayscale); self.grayscale = grayscale;
self self
} }
@@ -113,8 +471,24 @@ impl RenderOnce for Avatar {
} else { } else {
px(0.) px(0.)
}; };
let image_size = avatar_size(self.size); let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.; 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() div()
.flex_shrink_0() .flex_shrink_0()
@@ -124,18 +498,79 @@ impl RenderOnce for Avatar {
.when_some(self.border_color, |this, color| { .when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color) this.border(border_width).border_color(color)
}) })
.child( .child(content)
self.image }
.size(image_size) }
.rounded_full()
.object_fit(ObjectFit::Cover) #[cfg(test)]
.bg(cx.theme().ghost_element_background) mod tests {
.with_fallback(move || { use super::*;
img("brand/avatar.png")
.size(image_size) #[test]
.rounded_full() fn pixel_patterns_are_symmetric_and_stable() {
.into_any_element() 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<Pixels>]) -> 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}");
} }
} }
+1
View File
@@ -18,6 +18,7 @@ pub mod indicator;
pub mod input; pub mod input;
pub mod menu; pub mod menu;
pub mod modal; pub mod modal;
pub mod nav_item;
pub mod notification; pub mod notification;
pub mod popover; pub mod popover;
pub mod resizable; pub mod resizable;
+100 -47
View File
@@ -1,15 +1,18 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
RenderOnce, SharedString, StyleRefinement, Styled, Window, IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
}; };
use crate::Selectable; use crate::Selectable;
use crate::avatar::Avatar; use crate::avatar::Avatar;
use crate::button::Button; use crate::button::Button;
use crate::menu::PopupMenu; 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>) -> PopupMenu;
/// A dropdown menu trait for buttons and other interactive elements /// A dropdown menu trait for buttons and other interactive elements
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
@@ -44,8 +47,7 @@ pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
style: StyleRefinement, style: StyleRefinement,
anchor: Anchor, anchor: Anchor,
trigger: T, trigger: T,
#[allow(clippy::type_complexity)] builder: Rc<MenuBuilder>,
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
} }
impl<T> DropdownMenuPopover<T> impl<T> DropdownMenuPopover<T>
@@ -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<MenuBuilder>,
}
impl ContextMenu {
pub fn new(
id: impl Into<ElementId>,
child: impl IntoElement,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> 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<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
}
#[derive(Default)] #[derive(Default)]
struct DropdownMenuState { struct MenuState {
menu: Option<Entity<PopupMenu>>, menu: Option<Entity<PopupMenu>>,
} }
/// Builds the menu once and reuses it until it is dismissed.
///
/// The popover content closure runs on every render, so rebuilding the menu
/// entity each time would drop its focus and selection state.
fn cached_menu(
menu_state: &Entity<MenuState>,
builder: Rc<MenuBuilder>,
window: &mut Window,
cx: &mut Context<PopoverState>,
) -> Entity<PopupMenu> {
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<T> RenderOnce for DropdownMenuPopover<T> impl<T> RenderOnce for DropdownMenuPopover<T>
where where
T: Selectable + IntoElement + 'static, T: Selectable + IntoElement + 'static,
{ {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone(); let builder = self.builder.clone();
let menu_state = let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id))) Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false) .appearance(false)
@@ -100,46 +178,21 @@ where
.trigger(self.trigger) .trigger(self.trigger)
.trigger_style(self.style) .trigger_style(self.style)
.anchor(self.anchor) .anchor(self.anchor)
.content(move |_, window, cx| { .content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), 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);
// Listen for dismiss events from the PopupMenu to close the popover. impl RenderOnce for ContextMenu {
let popover_state = cx.entity(); fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
window let builder = self.builder.clone();
.subscribe(&menu, cx, { let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
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.clone() Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
} .appearance(false)
}; .overlay_closable(false)
.anchor(self.anchor)
menu.clone() .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))
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ mod dropdown_menu;
mod menu_item; mod menu_item;
mod popup_menu; mod popup_menu;
pub use dropdown_menu::DropdownMenu; pub use dropdown_menu::{ContextMenu, DropdownMenu};
pub use popup_menu::{PopupMenu, PopupMenuItem}; pub use popup_menu::{PopupMenu, PopupMenuItem};
pub(crate) fn init(cx: &mut App) { pub(crate) fn init(cx: &mut App) {
+100
View File
@@ -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<AnyElement>,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl NavItem {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
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))
})
}
}
+13
View File
@@ -87,6 +87,19 @@ impl Popover {
self self
} }
/// Set the trigger from a builder, for elements that have no selected state.
///
/// [`Self::trigger`] marks the trigger as selected while the popover is
/// open, so it cannot be used with elements whose selection carries a
/// different meaning, such as a row that indicates the current room.
pub fn trigger_with<F>(mut self, trigger: F) -> Self
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`. /// Set the default open state of the popover, default is `false`.
/// ///
/// This is only used to initialize the open state of the popover. /// This is only used to initialize the open state of the popover.
+10 -2
View File
@@ -295,7 +295,11 @@ impl Screening {
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_sm() .text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .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()), .child(profile.name()),
); );
} }
@@ -335,7 +339,11 @@ impl Render for Screening {
.items_center() .items_center()
.justify_center() .justify_center()
.text_center() .text_center()
.child(Avatar::new(profile.avatar()).large()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
)
.child( .child(
div() div()
.font_semibold() .font_semibold()
+5 -1
View File
@@ -239,7 +239,11 @@ impl ContactListPanel {
h_flex() h_flex()
.gap_2() .gap_2()
.text_sm() .text_sm()
.child(Avatar::new(profile.avatar()).small()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()), .child(profile.name()),
) )
.child( .child(
+2 -7
View File
@@ -309,12 +309,7 @@ impl Render for ProfilePanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar_input = self.avatar_input.read(cx).value(); let avatar_input = self.avatar_input.read(cx).value();
// Get the avatar let picture = (!avatar_input.is_empty()).then_some(avatar_input);
let avatar = if avatar_input.is_empty() {
"brand/avatar.png"
} else {
avatar_input.as_str()
};
// Get the public key as short string // Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
@@ -331,7 +326,7 @@ impl Render for ProfilePanel {
.items_center() .items_center()
.justify_center() .justify_center()
.gap_4() .gap_4()
.child(Avatar::new(avatar).large()) .child(Avatar::new(picture).seed(self.public_key.to_hex()).large())
.child( .child(
Button::new("upload") Button::new("upload")
.icon(IconName::PlusCircle) .icon(IconName::PlusCircle)
+2
View File
@@ -344,6 +344,7 @@ impl SearchPanel {
RoomEntry::new(range.start + ix) RoomEntry::new(range.start + ix)
.name(profile.name()) .name(profile.name())
.avatar(profile.avatar()) .avatar(profile.avatar())
.seed(profile.avatar_seed())
.on_click(handler) .on_click(handler)
.selected(selected) .selected(selected)
.into_any_element() .into_any_element()
@@ -381,6 +382,7 @@ impl SearchPanel {
RoomEntry::new(range.start + ix) RoomEntry::new(range.start + ix)
.name(profile.name().trim()) .name(profile.name().trim())
.avatar(profile.avatar()) .avatar(profile.avatar())
.seed(profile.avatar_seed())
.on_click(handler) .on_click(handler)
.selected(selected) .selected(selected)
.into_any_element() .into_any_element()
+22 -22
View File
@@ -3,8 +3,8 @@ use std::rc::Rc;
use chat::RoomKind; use chat::RoomKind;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString,
SharedString, StatefulInteractiveElement, Styled, Window, div, px, StatefulInteractiveElement, Styled, Window, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
@@ -16,22 +16,19 @@ use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex
use crate::dialogs::screening; 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)] #[derive(IntoElement)]
pub struct RoomEntry { pub struct RoomEntry {
ix: usize, ix: usize,
public_key: Option<PublicKey>, public_key: Option<PublicKey>,
name: Option<SharedString>, name: Option<SharedString>,
avatar: Option<SharedString>, avatar: Option<SharedString>,
seed: Option<SharedString>,
created_at: Option<SharedString>, created_at: Option<SharedString>,
kind: Option<RoomKind>, kind: Option<RoomKind>,
depth: u8, depth: u8,
selected: bool, selected: bool,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
trailing: Option<AnyElement>,
} }
impl RoomEntry { impl RoomEntry {
@@ -41,12 +38,12 @@ impl RoomEntry {
public_key: None, public_key: None,
name: None, name: None,
avatar: None, avatar: None,
seed: None,
created_at: None, created_at: None,
kind: None, kind: None,
depth: 0, depth: 0,
handler: None, handler: None,
selected: false, selected: false,
trailing: None,
} }
} }
@@ -60,8 +57,13 @@ impl RoomEntry {
self self
} }
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self { pub fn avatar(mut self, picture: Option<SharedString>) -> Self {
self.avatar = Some(avatar.into()); self.avatar = picture;
self
}
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
self.seed = Some(seed.into());
self self
} }
@@ -80,11 +82,6 @@ impl RoomEntry {
self self
} }
pub fn trailing(mut self, trailing: impl IntoElement) -> Self {
self.trailing = Some(trailing.into_any_element());
self
}
pub fn on_click( pub fn on_click(
mut self, mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -112,22 +109,26 @@ impl RenderOnce for RoomEntry {
let public_key = self.public_key; let public_key = self.public_key;
let is_selected = self.is_selected(); 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() h_flex()
.id(self.ix) .id(self.ix)
.group(ROOM_ENTRY_GROUP)
.h_8() .h_8()
.w_full() .w_full()
.pl(px(6. + self.depth as f32 * 14.)) .pl(px(6. + self.depth as f32 * 10.))
.pr_1p5() .pr_1p5()
.gap_2() .gap_2()
.text_sm() .text_sm()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| this.children(avatar))
this.when_some(self.avatar, |this, avatar| {
this.child(Avatar::new(avatar).small().flex_shrink_0())
})
})
.child( .child(
div() div()
.flex_1() .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.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)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.when_some(self.handler, |this, handler| { .when_some(self.handler, |this, handler| {
this.on_click(move |event, window, cx| { this.on_click(move |event, window, cx| {
+72 -81
View File
@@ -20,10 +20,12 @@ use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent}; use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator; 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::scroll::Scrollbar;
use ui::{ 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; use crate::Command;
@@ -31,7 +33,6 @@ use crate::Command;
mod entry; mod entry;
mod tree; mod tree;
use entry::ROOM_ENTRY_GROUP;
pub(crate) use entry::RoomEntry; pub(crate) use entry::RoomEntry;
use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities};
@@ -291,7 +292,8 @@ impl Sidebar {
let room_id = room.read(cx).id; let room_id = room.read(cx).id;
let public_key = room.read(cx).display_member(cx).public_key(); let public_key = room.read(cx).display_member(cx).public_key();
let name = room.read(cx).display_name(cx); 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 kind = room.read(cx).kind;
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let room_clone = room.clone(); let room_clone = room.clone();
@@ -301,55 +303,47 @@ impl Sidebar {
}); });
}); });
let sidebar = cx.entity().downgrade(); let entry = RoomEntry::new(index)
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)
.name(name) .name(name)
.avatar(avatar) .avatar(picture)
.seed(seed)
.public_key(public_key) .public_key(public_key)
.kind(kind) .kind(kind)
.created_at(created_at) .created_at(created_at)
.depth(*depth) .depth(*depth)
.trailing(trailing) .on_click(handler);
.on_click(handler)
.into_any_element() 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( SidebarRow::Community { entry, depth } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64), ElementId::NamedInteger("tree-row".into(), index as u64),
@@ -399,17 +393,23 @@ impl Sidebar {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx); let profile = persons.read(cx).get(public_key, cx);
let avatar = profile.avatar(); let avatar = profile.avatar();
let avatar_seed = profile.avatar_seed();
let name = profile.name(); let name = profile.name();
this.child( this.child(
Button::new("current-user") Button::new("current-user")
.child(Avatar::new(avatar.clone()).xsmall()) .child(
Avatar::new(avatar.clone())
.seed(avatar_seed.clone())
.xsmall(),
)
.small() .small()
.caret() .caret()
.compact() .compact()
.transparent() .transparent()
.dropdown_menu(move |this, _window, cx| { .dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone(); let avatar = avatar.clone();
let avatar_seed = avatar_seed.clone();
let name = name.clone(); let name = name.clone();
this.min_w(px(256.)) this.min_w(px(256.))
@@ -418,7 +418,11 @@ impl Sidebar {
.gap_1p5() .gap_1p5()
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .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()) .child(name.clone())
})) }))
.separator() .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<TreeSection> { fn load_expanded(cx: &App) -> BTreeSet<TreeSection> {
let Some(keys) = AppSettings::get_expanded_sections(cx) else { let Some(keys) = AppSettings::get_expanded_sections(cx) else {
return BTreeSet::from([TreeSection::Community, TreeSection::Messages]); return BTreeSet::from([TreeSection::Community, TreeSection::Messages]);
@@ -520,24 +511,24 @@ impl Render for Sidebar {
.px_2() .px_2()
.py_1() .py_1()
.gap_1() .gap_1()
.child(nav_item( .child(
"nav-inbox", NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small())
IconName::Inbox, .on_click(|_event, _window, cx| {
"Inbox", cx.dispatch_action(&Command::ShowInbox)
Command::ShowInbox, }),
)) )
.child(nav_item( .child(
"nav-browse", NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small())
IconName::Compass, .on_click(|_event, _window, cx| {
"Browse", cx.dispatch_action(&Command::ShowBrowse)
Command::ShowBrowse, }),
)) )
.child(nav_item( .child(
"nav-search", NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small())
IconName::Search, .on_click(|_event, _window, cx| {
"Search", cx.dispatch_action(&Command::ShowSearch)
Command::ShowSearch, }),
)), ),
) )
.child( .child(
v_flex() v_flex()
+26 -36
View File
@@ -7,6 +7,7 @@ use gpui::{
SharedString, StatefulInteractiveElement, Styled, Window, div, px, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::PixelAvatar;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -147,8 +148,9 @@ impl TreeRow {
self self
} }
pub fn avatar(mut self, name: impl Into<SharedString>) -> Self { /// Sets the seed for the row's generated avatar.
self.avatar = Some(name.into()); pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
self.avatar = Some(seed.into());
self self
} }
@@ -174,11 +176,7 @@ impl TreeRow {
impl RenderOnce for TreeRow { impl RenderOnce for TreeRow {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let indent = px(6. + self.depth as f32 * 14.); let indent = px(6. + self.depth as f32 * 14.);
let avatar_initial = self let avatar_seed = self.avatar;
.avatar
.as_ref()
.and_then(|name| name.chars().next())
.map(|letter| SharedString::from(letter.to_uppercase().to_string()));
let is_section = self.kind == TreeRowKind::Section; let is_section = self.kind == TreeRowKind::Section;
let is_community = self.kind == TreeRowKind::Community; let is_community = self.kind == TreeRowKind::Community;
let is_hint = self.kind == TreeRowKind::Hint; let is_hint = self.kind == TreeRowKind::Hint;
@@ -192,9 +190,7 @@ impl RenderOnce for TreeRow {
.gap_2() .gap_2()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.when(is_section, |this| { .when(is_section, |this| {
this.text_xs() this.text_xs().text_color(cx.theme().text_muted)
.font_semibold()
.text_color(cx.theme().text_muted)
}) })
.when(is_community, |this| this.text_sm()) .when(is_community, |this| this.text_sm())
.when(is_hint, |this| { .when(is_hint, |this| {
@@ -202,36 +198,30 @@ impl RenderOnce for TreeRow {
.font_normal() .font_normal()
.text_color(cx.theme().text_placeholder) .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| { .when_some(self.icon, |this, icon| {
this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted))
}) })
.when_some(avatar_initial, |this, initial| { .when_some(avatar_seed, |this, seed| {
this.child( this.child(PixelAvatar::new(seed).xsmall())
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),
)
}) })
.child(div().flex_1().truncate().child(self.label)) .child(
.when_some(self.count, |this, count| { h_flex()
this.child( .gap_1()
div() .flex_1()
.flex_shrink_0() .child(div().truncate().min_w_0().child(self.label))
.text_xs() .when_some(self.count, |this, count| {
.text_color(cx.theme().text_placeholder) this.child(
.child(count.to_string()), 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| { .when(self.dot, |this| {
this.child( this.child(
+69 -24
View File
@@ -1,7 +1,7 @@
# Sidebar tree redesign # Sidebar tree redesign
Status: steps 1-9 implemented. Search lives in `panels/search.rs`; the sidebar Status: steps 1-10 implemented. Search lives in `panels/search.rs`; the sidebar
renders the nav rail, the flattened tree, per-row pin/unpin menus, and the 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 Community section from placeholder data (`TODO(concord)`). Pins and expanded
sections persist through `settings::Settings`. `cargo check`, `cargo clippy sections persist through `settings::Settings`. `cargo check`, `cargo clippy
--workspace --all-targets` and `rustfmt --check` on the changed files are clean. --workspace --all-targets` and `rustfmt --check` on the changed files are clean.
@@ -247,9 +247,9 @@ API exposes it.
### 6.3 File rows ### 6.3 File rows
- Rooms reuse `RoomEntry` with two additions: `.depth(u8)` (left padding - Rooms reuse `RoomEntry` with `.depth(u8)` (left padding
`px(6. + depth * 14.)`) and an optional `.trailing(AnyElement)` slot for the `px(6. + depth * 14.)`), wrapped in a `ContextMenu` that opens the pin/unpin
hover ellipsis; height becomes `h_8`. menu; height becomes `h_8`.
- Community rows use `TreeRow` with a 20px `element_background` circle and the - Community rows use `TreeRow` with a 20px `element_background` circle and the
first letter, `text_sm` label. first letter, `text_sm` label.
- Indent guide (optional polish): 1px `border_variant` vertical line at the - 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 ## 8. Pin folder
- Pin state: `pinned_rooms: Vec<u64>` in `Sidebar`, order = pin order. - Pin state: `pinned_rooms: Vec<u64>` in `Sidebar`, order = pin order.
- UI: hover ellipsis (`IconName::Ellipsis`, `ghost_alt`, `xsmall`, `compact`) - UI: right-clicking a room row opens a `ContextMenu`
on each room row, opening a `DropdownMenu` with `Pin` / `Unpin` (`crates/ui/src/menu/dropdown_menu.rs`) with `Pin` / `Unpin`
(`PopupMenuItem::new(...).on_click(...)`). The ellipsis is a `RoomEntry` (`PopupMenuItem::new(...).on_click(...)`). The menu is a `PopupMenu` anchored to
trailing element, hidden by default and revealed with `group_hover` against the the row and opened with `MouseButton::Right`, reusing the cached-menu machinery
row's `ROOM_ENTRY_GROUP` group. (There is no right-click menu pattern in the shared with `DropdownMenuPopover`.
codebase yet; a context menu is a follow-up.) - 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; - `Pinned` folder is hidden when no pinned room resolves to a live room;
otherwise expanded by default, showing pinned rooms in pin order. otherwise expanded by default, showing pinned rooms in pin order.
- A pinned room remains listed under `Messages`. - 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` `uniform_list("sidebar-tree")`. `has_search`, `find_focused`, `set_input_focus`
were dropped because they only existed to switch the sidebar between the room were dropped because they only existed to switch the sidebar between the room
list and the search view. list and the search view.
- [x] **Step 6 — pin UI.** Per-row ellipsis (`IconName::Ellipsis`, `ghost_alt`, - [x] **Step 6 — pin UI.** Each room row is wrapped in a `ContextMenu`
`xsmall`, `compact`) passed to `RoomEntry::trailing`, revealed on row hover (`ui::menu::ContextMenu`, added in this step) that opens a `PopupMenu` with
through the `ROOM_ENTRY_GROUP` group name, opening a `DropdownMenu` with `Pin` / `Unpin` on right-click; the handlers call `pin_room`/`unpin_room` through
Pin/Unpin; the handlers call `pin_room`/`unpin_room` through a a `WeakEntity<Sidebar>`. `ContextMenu` reuses the cached-menu logic extracted
`WeakEntity<Sidebar>`. Click propagation: `gpui_base::Popover` registers the from `DropdownMenuPopover` and opens through `Popover::trigger_with`, so the
trigger's `on_mouse_down` with `cx.stop_propagation()`, and GPUI only fires an trigger keeps its own click handler and no `Selectable` state is forced onto the
element's `on_click` when that element recorded the matching mouse-down, so the row. Left-click still opens the room, because GPUI fires `on_click` only for the
row's `emit_room` click does not fire when the menu trigger is clicked. No extra left button while the popover handles the right one. (The first cut used a
handling was needed. 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 - [x] **Step 7 — community section.** Dummy entries and the empty-state hint are
rendered; the `TODO(concord)` marker sits on `dummy_communities()`. The rendered; the `TODO(concord)` marker sits on `dummy_communities()`. The
flattening and rendering landed with step 5 (`SidebarRow::Community` -> 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** checked per file with `rustfmt +nightly --check`; `cargo fmt --all` is **not**
run, because the repo's committed formatting does not match the installed run, because the repo's committed formatting does not match the installed
nightly rustfmt (many pre-existing diffs in unrelated files). 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<dyn Fn>` 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<SharedString>` (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 ## 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/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/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/{inbox,browse,search}.rs` | New panel modules |
| `crates/workspace/src/panels/mod.rs` | Module registration | | `crates/workspace/src/panels/mod.rs` | Module registration |
| `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms | | `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms |
| `crates/ui/src/icon.rs` | New icon variants | | `crates/ui/src/icon.rs` | New icon variants |
| `assets/icons/{folder,compass,message}.svg` | New assets | | `assets/icons/{folder,compass,message}.svg` | New assets |
| `crates/settings/src/lib.rs` | Step 8: `pinned_rooms`, `expanded_sections`, accessors, `entity()` | | `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 ## 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; - each folder toggles and keeps its state across re-renders and room updates;
- Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears - Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears
when expanded; when expanded;
- pin/unpin from the row menu updates the Pinned folder without opening the - pin/unpin from the row context menu (right-click) updates the Pinned folder
room; clicking a pinned row opens it; without opening the room; clicking a pinned row opens it;
- Messages lists ongoing rooms and still opens the screening modal for - Messages lists ongoing rooms and still opens the screening modal for
non-ongoing rooms; non-ongoing rooms;
- pins and expanded/collapsed folders survive an app restart (collapsing every - pins and expanded/collapsed folders survive an app restart (collapsing every
folder also survives, rather than reverting to the default sections); 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]` - There is no GPUI test infrastructure in the repo (no `#[gpui::test]`
anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin
ordering) if they are extracted as free functions; `cargo check` plus the ordering) if they are extracted as free functions; `cargo check` plus the