refactor avatar
This commit is contained in:
+14
-5
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user