refactor avatar
This commit is contained in:
+464
-29
@@ -1,12 +1,25 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
||||
Window, div, img, px,
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity,
|
||||
IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{Selectable, Sizable, Size};
|
||||
use crate::{Selectable, Sizable, Size, StyledExt};
|
||||
|
||||
/// Number of rows and columns in the generated pixel grid.
|
||||
const PIXEL_GRID: usize = 8;
|
||||
/// Probability that a cell in the left half of the grid is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
|
||||
const MIN_FILLED: usize = 5;
|
||||
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
|
||||
const FALLBACK_SEED: &str = "coop";
|
||||
/// Number of segments used to approximate the avatar circle.
|
||||
const CIRCLE_SEGMENTS: usize = 32;
|
||||
|
||||
/// Returns the size of the avatar based on the given [`Size`].
|
||||
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
@@ -19,19 +32,350 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
}
|
||||
}
|
||||
|
||||
/// An element that renders a user avatar with customizable appearance options.
|
||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
||||
///
|
||||
/// Use it for entities that have no profile picture: the same seed always
|
||||
/// renders the same pattern, so identities stay recognizable without a
|
||||
/// network round trip. The pattern is painted as geometry and cropped to a
|
||||
/// circle, at the same sizes as [`Avatar`].
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::{Avatar};
|
||||
/// use ui::avatar::PixelAvatar;
|
||||
///
|
||||
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
|
||||
/// PixelAvatar::new("alice");
|
||||
/// ```
|
||||
#[derive(IntoElement)]
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Creates a pixel avatar from `seed`.
|
||||
pub fn new(seed: impl AsRef<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)]
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
image: Img,
|
||||
picture: Option<SharedString>,
|
||||
grayscale: bool,
|
||||
seed: Option<SharedString>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
border_color: Option<Hsla>,
|
||||
@@ -39,11 +383,16 @@ pub struct Avatar {
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Creates a new avatar element with the specified image source.
|
||||
pub fn new(src: impl Into<ImageSource>) -> Self {
|
||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
||||
///
|
||||
/// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when
|
||||
/// `picture` is `None`.
|
||||
pub fn new(picture: Option<SharedString>) -> Self {
|
||||
Avatar {
|
||||
base: div(),
|
||||
image: img(src),
|
||||
picture,
|
||||
grayscale: false,
|
||||
seed: None,
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
border_color: None,
|
||||
@@ -51,17 +400,26 @@ impl Avatar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the seed for the generated pixel avatar.
|
||||
///
|
||||
/// The seed should be a stable identifier of the entity the avatar
|
||||
/// represents, such as a public key.
|
||||
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
|
||||
self.seed = Some(seed.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Applies a grayscale filter to the avatar image.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use ui::{Avatar, AvatarShape};
|
||||
/// use ui::avatar::Avatar;
|
||||
///
|
||||
/// let avatar = Avatar::new("path/to/image.png").grayscale(true);
|
||||
/// Avatar::new(None).grayscale(true);
|
||||
/// ```
|
||||
pub fn grayscale(mut self, grayscale: bool) -> Self {
|
||||
self.image = self.image.grayscale(grayscale);
|
||||
self.grayscale = grayscale;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -113,8 +471,24 @@ impl RenderOnce for Avatar {
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
let image_size = avatar_size(self.size);
|
||||
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
|
||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
let container_size = image_size + border_width * 2.;
|
||||
|
||||
let content = match self.picture {
|
||||
Some(picture) => {
|
||||
let seed = self.seed;
|
||||
let grayscale = self.grayscale;
|
||||
img(picture)
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.grayscale(grayscale)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
|
||||
.into_any_element()
|
||||
}
|
||||
None => generated_avatar(self.seed.as_deref(), image_size),
|
||||
};
|
||||
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
@@ -124,18 +498,79 @@ impl RenderOnce for Avatar {
|
||||
.when_some(self.border_color, |this, color| {
|
||||
this.border(border_width).border_color(color)
|
||||
})
|
||||
.child(
|
||||
self.image
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || {
|
||||
img("brand/avatar.png")
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
.child(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pixel_patterns_are_symmetric_and_stable() {
|
||||
for seed in 0..50 {
|
||||
let pattern = pixel_pattern(seed);
|
||||
let filled = pattern.iter().filter(|&&cell| cell != 0).count();
|
||||
|
||||
assert!(
|
||||
filled >= MIN_FILLED * 2,
|
||||
"pattern too sparse for seed {seed}"
|
||||
);
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID {
|
||||
assert_eq!(
|
||||
pattern[row * PIXEL_GRID + col],
|
||||
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)],
|
||||
"asymmetric pattern for seed {seed} at ({row}, {col})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for seed in [0, 1, 42, u64::MAX] {
|
||||
assert_eq!(pixel_pattern(seed), pixel_pattern(seed));
|
||||
}
|
||||
|
||||
assert_ne!(pixel_pattern(42), pixel_pattern(43));
|
||||
}
|
||||
|
||||
fn area(polygon: &[Point<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 menu;
|
||||
pub mod modal;
|
||||
pub mod nav_item;
|
||||
pub mod notification;
|
||||
pub mod popover;
|
||||
pub mod resizable;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
|
||||
IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::Selectable;
|
||||
use crate::avatar::Avatar;
|
||||
use crate::button::Button;
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::popover::Popover;
|
||||
use crate::popover::{Popover, PopoverState};
|
||||
|
||||
/// Builds the items of a popup menu on each render.
|
||||
type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
|
||||
|
||||
/// A dropdown menu trait for buttons and other interactive elements
|
||||
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
|
||||
@@ -44,8 +47,7 @@ pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
trigger: T,
|
||||
#[allow(clippy::type_complexity)]
|
||||
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||
builder: Rc<MenuBuilder>,
|
||||
}
|
||||
|
||||
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)]
|
||||
struct DropdownMenuState {
|
||||
struct MenuState {
|
||||
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>
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state =
|
||||
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
|
||||
|
||||
Popover::new(SharedString::from(format!("popover:{}", self.id)))
|
||||
.appearance(false)
|
||||
@@ -100,46 +178,21 @@ where
|
||||
.trigger(self.trigger)
|
||||
.trigger_style(self.style)
|
||||
.anchor(self.anchor)
|
||||
.content(move |_, window, cx| {
|
||||
// Here is special logic to only create the PopupMenu once and reuse it.
|
||||
// Because this `content` will called in every time render, so we need to store the menu
|
||||
// in state to avoid recreating at every render.
|
||||
//
|
||||
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
|
||||
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
|
||||
let menu = match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
None => {
|
||||
let builder = builder.clone();
|
||||
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
|
||||
builder(menu, window, cx)
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = Some(menu.clone());
|
||||
});
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for dismiss events from the PopupMenu to close the popover.
|
||||
let popover_state = cx.entity();
|
||||
window
|
||||
.subscribe(&menu, cx, {
|
||||
let menu_state = menu_state.clone();
|
||||
move |_, _: &DismissEvent, window, cx| {
|
||||
popover_state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = None;
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
impl RenderOnce for ContextMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
};
|
||||
|
||||
menu.clone()
|
||||
})
|
||||
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
|
||||
.appearance(false)
|
||||
.overlay_closable(false)
|
||||
.anchor(self.anchor)
|
||||
.mouse_button(self.mouse_button)
|
||||
.trigger_with(move |_open, _window, _cx| self.child)
|
||||
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod dropdown_menu;
|
||||
mod menu_item;
|
||||
mod popup_menu;
|
||||
|
||||
pub use dropdown_menu::DropdownMenu;
|
||||
pub use dropdown_menu::{ContextMenu, DropdownMenu};
|
||||
pub use popup_menu::{PopupMenu, PopupMenuItem};
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
/// This is only used to initialize the open state of the popover.
|
||||
|
||||
Reference in New Issue
Block a user