wip
This commit is contained in:
+162
-336
@@ -1,64 +1,124 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
|
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||||
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
SharedString, StyleRefinement, Styled, Window, div, px,
|
||||||
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
|
||||||
};
|
};
|
||||||
|
use gpui_base::AvatarImage;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::menu::DropdownMenu;
|
use gpui_kit::component::menu::DropdownMenu;
|
||||||
use gpui_kit::component::{ActiveTheme as _, Selectable, Sizable, Size};
|
use gpui_kit::component::{ActiveTheme, Selectable, Sizable, Size};
|
||||||
|
|
||||||
/// Number of rows and columns in the generated pixel grid.
|
/// Number of rows and columns in the pixel grid.
|
||||||
const PIXEL_GRID: usize = 8;
|
const GRID_SIZE: usize = 8;
|
||||||
/// Empty cells kept between the pattern and the avatar edge.
|
/// Empty cells kept between the pattern and the avatar edge.
|
||||||
const MARGIN: usize = 1;
|
const MARGIN: usize = 1;
|
||||||
/// Probability that a cell in the left half of the pattern area is filled.
|
/// Probability that a cell in the left half of the pattern area is filled.
|
||||||
const FILL_PROBABILITY: f32 = 0.42;
|
const FILL_PROBABILITY: f32 = 0.42;
|
||||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||||
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
|
/// Minimum number of filled left-half cells.
|
||||||
const MIN_FILLED: usize = 5;
|
const MIN_FILLED: usize = 5;
|
||||||
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
|
/// Seed for an avatar that has neither a picture nor a seed of its own.
|
||||||
const FALLBACK_SEED: &str = "coop";
|
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].
|
/// An avatar for a user or other entity.
|
||||||
fn avatar_size(size: Size) -> AbsoluteLength {
|
|
||||||
match size {
|
|
||||||
Size::Large => px(64.).into(),
|
|
||||||
Size::Medium => px(32.).into(),
|
|
||||||
Size::Small => px(24.).into(),
|
|
||||||
Size::XSmall => px(18.).into(),
|
|
||||||
Size::Size(size) => size.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
struct PixelAvatar {
|
pub struct Avatar {
|
||||||
seed: u64,
|
base: Div,
|
||||||
|
picture: Option<SharedString>,
|
||||||
|
seed: Option<String>,
|
||||||
size: Size,
|
size: Size,
|
||||||
|
selected: bool,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PixelAvatar {
|
impl Avatar {
|
||||||
/// Creates a pixel avatar from the given seed.
|
/// Create an avatar showing `picture`, falling back to pixel art seeded by [`Avatar::seed`].
|
||||||
fn new(seed: impl AsRef<str>) -> Self {
|
pub fn new(picture: Option<SharedString>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
base: div(),
|
||||||
|
picture,
|
||||||
|
seed: None,
|
||||||
size: Size::Medium,
|
size: Size::Medium,
|
||||||
|
selected: false,
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The seed for the generated pixel avatar.
|
||||||
|
pub fn seed(mut self, seed: impl Into<String>) -> Self {
|
||||||
|
self.seed = Some(seed.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sizable for PixelAvatar {
|
impl Selectable for Avatar {
|
||||||
|
fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_selected(&self) -> bool {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sizable for Avatar {
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||||
self.size = size.into();
|
self.size = size.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Styled for Avatar {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InteractiveElement for Avatar {
|
||||||
|
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||||
|
self.base.interactivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DropdownMenu for Avatar {}
|
||||||
|
|
||||||
|
impl RenderOnce for Avatar {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let side = side_length(self.size);
|
||||||
|
|
||||||
|
let content = match self.picture {
|
||||||
|
Some(url) => div()
|
||||||
|
.size_full()
|
||||||
|
.child(AvatarImage::new(url).size_full().rounded_full())
|
||||||
|
.into_any_element(),
|
||||||
|
None => PixelAvatar {
|
||||||
|
seed: fnv1a(self.seed.unwrap_or_else(|| FALLBACK_SEED.into()).as_bytes()),
|
||||||
|
side,
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
}
|
||||||
|
.into_any_element(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.base
|
||||||
|
.size(side)
|
||||||
|
.flex_shrink_0()
|
||||||
|
.rounded_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.bg(cx.theme().secondary)
|
||||||
|
.child(content)
|
||||||
|
.refine_style(&self.style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic, offline pixel-art avatar.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
struct PixelAvatar {
|
||||||
|
seed: u64,
|
||||||
|
side: Pixels,
|
||||||
|
style: StyleRefinement,
|
||||||
|
}
|
||||||
|
|
||||||
impl Styled for PixelAvatar {
|
impl Styled for PixelAvatar {
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
&mut self.style
|
&mut self.style
|
||||||
@@ -66,76 +126,84 @@ impl Styled for PixelAvatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for PixelAvatar {
|
impl RenderOnce for PixelAvatar {
|
||||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let side = avatar_size(self.size).to_pixels(window.rem_size());
|
let theme = cx.theme();
|
||||||
let seed = self.seed;
|
let pattern = pattern(self.seed);
|
||||||
|
let mut cells = Vec::new();
|
||||||
|
|
||||||
|
let main = Hsla {
|
||||||
|
h: self.seed as f32 / u64::MAX as f32,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for row in 0..GRID_SIZE {
|
||||||
|
for col in 0..GRID_SIZE {
|
||||||
|
let value = pattern[row * GRID_SIZE + col];
|
||||||
|
if value != 0 {
|
||||||
|
let color = if value == 2 { shade } else { main };
|
||||||
|
cells.push(
|
||||||
|
div()
|
||||||
|
.row_start(row as i16 + 1)
|
||||||
|
.row_end(row as i16 + 2)
|
||||||
|
.col_start(col as i16 + 1)
|
||||||
|
.col_end(col as i16 + 2)
|
||||||
|
.bg(color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.refine_style(&self.style)
|
.grid()
|
||||||
.size(side)
|
.grid_cols(GRID_SIZE as u16)
|
||||||
|
.grid_rows(GRID_SIZE as u16)
|
||||||
|
.size(self.side)
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.child(canvas(
|
.rounded_full()
|
||||||
move |_bounds, _window, _cx| seed,
|
.overflow_hidden()
|
||||||
move |bounds, seed, window, cx| {
|
.bg(main.opacity(0.16))
|
||||||
let theme = cx.theme();
|
.children(cells)
|
||||||
let main = Hsla {
|
.refine_style(&self.style)
|
||||||
h: seed as f32 / u64::MAX as f32,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the mirrored fill pattern for the given seed.
|
/// The rendered side length of an avatar at `size`.
|
||||||
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
fn side_length(size: Size) -> Pixels {
|
||||||
|
match size {
|
||||||
|
Size::XSmall => px(16.),
|
||||||
|
Size::Small => px(24.),
|
||||||
|
Size::Medium => px(48.),
|
||||||
|
Size::Large => px(80.),
|
||||||
|
Size::Size(size) => size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||||
let mut rng = PixelRng::new(seed);
|
let mut rng = PixelRng::new(seed);
|
||||||
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
|
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||||
let mut filled = 0usize;
|
let mut filled = 0usize;
|
||||||
|
|
||||||
let art_rows = PIXEL_GRID - 2 * MARGIN;
|
// Only the inner rows and the inner left half are candidates; mirroring
|
||||||
let art_columns = PIXEL_GRID / 2 - MARGIN;
|
// then keeps the art within the same inset, leaving the outer ring empty.
|
||||||
|
let art_rows = GRID_SIZE - 2 * MARGIN;
|
||||||
|
let art_columns = GRID_SIZE / 2 - MARGIN;
|
||||||
|
|
||||||
for row in MARGIN..PIXEL_GRID - MARGIN {
|
for row in MARGIN..GRID_SIZE - MARGIN {
|
||||||
for col in MARGIN..PIXEL_GRID / 2 {
|
for col in MARGIN..GRID_SIZE / 2 {
|
||||||
if rng.chance(FILL_PROBABILITY) {
|
if rng.chance(FILL_PROBABILITY) {
|
||||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||||
@@ -157,7 +225,7 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
|||||||
let row = MARGIN + ix / art_columns;
|
let row = MARGIN + ix / art_columns;
|
||||||
let col = MARGIN + ix % art_columns;
|
let col = MARGIN + ix % art_columns;
|
||||||
|
|
||||||
if pattern[row * PIXEL_GRID + col] == 0 {
|
if pattern[row * GRID_SIZE + col] == 0 {
|
||||||
set_cell(&mut pattern, row, col, 1);
|
set_cell(&mut pattern, row, col, 1);
|
||||||
filled += 1;
|
filled += 1;
|
||||||
}
|
}
|
||||||
@@ -167,146 +235,10 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
|||||||
pattern
|
pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paints `polygons as a single anti-aliased filled path in `color.
|
/// Fill `cell (row, col)` and its horizontal mirror.
|
||||||
fn paint_polygons<'a>(
|
fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) {
|
||||||
window: &mut Window,
|
pattern[row * GRID_SIZE + col] = value;
|
||||||
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
|
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||||
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 left-interior.
|
|
||||||
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.
|
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||||
@@ -340,109 +272,3 @@ impl PixelRng {
|
|||||||
self.next() as f32 / (u64::MAX as f32) < probability
|
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.
|
|
||||||
#[derive(IntoElement)]
|
|
||||||
pub struct Avatar {
|
|
||||||
base: Div,
|
|
||||||
picture: Option<ImageSource>,
|
|
||||||
seed: Option<String>,
|
|
||||||
style: StyleRefinement,
|
|
||||||
size: Size,
|
|
||||||
selected: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Avatar {
|
|
||||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
|
||||||
pub fn new(picture: Option<SharedString>) -> Self {
|
|
||||||
Self::from_source(picture)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an avatar from an already-resolved source, which may be missing.
|
|
||||||
pub fn from_source(picture: Option<impl Into<ImageSource>>) -> Self {
|
|
||||||
Self::from_picture(picture.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_picture(picture: Option<ImageSource>) -> Self {
|
|
||||||
Avatar {
|
|
||||||
base: div(),
|
|
||||||
picture,
|
|
||||||
seed: None,
|
|
||||||
style: StyleRefinement::default(),
|
|
||||||
size: Size::Medium,
|
|
||||||
selected: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the seed for the generated pixel avatar.
|
|
||||||
pub fn seed(mut self, seed: impl Into<String>) -> Self {
|
|
||||||
self.seed = Some(seed.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sizable for Avatar {
|
|
||||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
|
||||||
self.size = size.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Styled for Avatar {
|
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
|
||||||
&mut self.style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Selectable for Avatar {
|
|
||||||
fn is_selected(&self) -> bool {
|
|
||||||
self.selected
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selected(mut self, selected: bool) -> Self {
|
|
||||||
self.selected = selected;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InteractiveElement for Avatar {
|
|
||||||
fn interactivity(&mut self) -> &mut Interactivity {
|
|
||||||
self.base.interactivity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DropdownMenu for Avatar {}
|
|
||||||
|
|
||||||
impl RenderOnce for Avatar {
|
|
||||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
|
||||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
|
||||||
|
|
||||||
let content = match self.picture {
|
|
||||||
Some(picture) => {
|
|
||||||
let seed = self.seed;
|
|
||||||
|
|
||||||
img(picture)
|
|
||||||
.size(image_size)
|
|
||||||
.rounded_full()
|
|
||||||
.object_fit(ObjectFit::Cover)
|
|
||||||
.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()
|
|
||||||
.size(image_size)
|
|
||||||
.rounded_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+22
-36
@@ -6,7 +6,7 @@ use gpui::{
|
|||||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||||
Styled, Window, div,
|
Styled, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex};
|
||||||
|
|
||||||
/// A single navigation row, such as an entry in a sidebar list.
|
/// A single navigation row, such as an entry in a sidebar list.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
@@ -101,41 +101,27 @@ impl Styled for Nav {
|
|||||||
impl RenderOnce for Nav {
|
impl RenderOnce for Nav {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let clickable = self.on_click.is_some() || self.clickable;
|
let clickable = self.on_click.is_some() || self.clickable;
|
||||||
let text_color = if self.selected {
|
|
||||||
cx.theme().foreground
|
|
||||||
} else {
|
|
||||||
cx.theme().muted_foreground
|
|
||||||
};
|
|
||||||
|
|
||||||
v_flex().h_10().w_full().justify_center().child(
|
h_flex()
|
||||||
h_flex()
|
.id(self.id)
|
||||||
.id(self.id)
|
.h_8()
|
||||||
.h_9()
|
.w_full()
|
||||||
.w_full()
|
.px_2()
|
||||||
.px_1p5()
|
.gap_2()
|
||||||
.gap_1p5()
|
.rounded(cx.theme().radius)
|
||||||
.rounded(cx.theme().radius)
|
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||||
.text_color(text_color)
|
.child(div().flex_1().min_w_0().truncate().child(self.label))
|
||||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
.when_some(self.suffix, |this, suffix| {
|
||||||
.child(
|
this.child(div().flex_shrink_0().child(suffix))
|
||||||
h_flex()
|
})
|
||||||
.gap_1()
|
.when(clickable, |this| {
|
||||||
.flex_1()
|
this.cursor_pointer()
|
||||||
.min_w_0()
|
.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
.child(div().flex_1().min_w_0().truncate().child(self.label))
|
.when(self.selected, |this| this.bg(cx.theme().list_active))
|
||||||
.when_some(self.suffix, |this, suffix| {
|
})
|
||||||
this.child(div().flex_shrink_0().child(suffix))
|
.when_some(self.on_click, |this, handler| {
|
||||||
}),
|
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||||
)
|
})
|
||||||
.when(clickable, |this| {
|
.refine_style(&self.style)
|
||||||
this.cursor_pointer()
|
|
||||||
.hover(|this| this.bg(cx.theme().accent))
|
|
||||||
.when(self.selected, |this| this.bg(cx.theme().list_active))
|
|
||||||
})
|
|
||||||
.when_some(self.on_click, |this, handler| {
|
|
||||||
this.on_click(move |event, window, cx| handler(event, window, cx))
|
|
||||||
})
|
|
||||||
.refine_style(&self.style),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use gpui_kit::assets::IconName;
|
|||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||||
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
|
|
||||||
use gpui_kit::component::scroll::ScrollableElement;
|
use gpui_kit::component::scroll::ScrollableElement;
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -302,11 +301,23 @@ impl GreeterPanel {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupBox::new()
|
v_flex()
|
||||||
.id("requests")
|
.gap_2()
|
||||||
.title("Requests")
|
.child(
|
||||||
.fill()
|
div()
|
||||||
.child(v_flex().gap_0().w_full().children(rows))
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child("Requests"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.p_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().group_box)
|
||||||
|
.text_color(cx.theme().group_box_foreground)
|
||||||
|
.children(rows),
|
||||||
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,11 +419,23 @@ impl GreeterPanel {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
GroupBox::new()
|
v_flex()
|
||||||
.id("follow-ups")
|
.gap_2()
|
||||||
.title("Follow ups")
|
.child(
|
||||||
.fill()
|
div()
|
||||||
.child(v_flex().gap_0().w_full().children(rows))
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child("Follow ups"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.p_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().group_box)
|
||||||
|
.text_color(cx.theme().group_box_foreground)
|
||||||
|
.children(rows),
|
||||||
|
)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||||
use nip29::Group;
|
use nip29::Group;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -155,6 +155,16 @@ impl Sidebar {
|
|||||||
.pl_0p5()
|
.pl_0p5()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.flex_shrink_1()
|
.flex_shrink_1()
|
||||||
|
.child(
|
||||||
|
Button::new("sidebar-back")
|
||||||
|
.icon(IconName::ArrowLeft)
|
||||||
|
.tooltip("Back")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
|
this.hide_group(cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
.child(Avatar::new(picture).seed(seed.clone()).small())
|
.child(Avatar::new(picture).seed(seed.clone()).small())
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -184,28 +194,17 @@ impl Sidebar {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div().min_h_0().flex_1().child(
|
||||||
.min_h_0()
|
uniform_list(
|
||||||
.flex_1()
|
"group-rows",
|
||||||
.child(
|
rows.len(),
|
||||||
uniform_list(
|
cx.processor(move |this, range, _window, cx| {
|
||||||
"group-rows",
|
this.render_group_rows(range, rows.as_slice(), &member_group, admin, cx)
|
||||||
rows.len(),
|
}),
|
||||||
cx.processor(move |this, range, _window, cx| {
|
|
||||||
this.render_group_rows(
|
|
||||||
range,
|
|
||||||
rows.as_slice(),
|
|
||||||
&member_group,
|
|
||||||
admin,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.track_scroll(&self.group_scroll)
|
|
||||||
.h_full()
|
|
||||||
.px_2(),
|
|
||||||
)
|
)
|
||||||
.child(Scrollbar::vertical(&self.group_scroll)),
|
.h_full()
|
||||||
|
.px_2(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -292,7 +291,7 @@ impl Sidebar {
|
|||||||
this.prefix(
|
this.prefix(
|
||||||
Avatar::new(person.avatar())
|
Avatar::new(person.avatar())
|
||||||
.seed(person.avatar_seed())
|
.seed(person.avatar_seed())
|
||||||
.small(),
|
.xsmall(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!roles.is_empty(), |this| {
|
.when(!roles.is_empty(), |this| {
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ use auto_update::AutoUpdater;
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
|
AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
Stateful, Styled, div, px,
|
Stateful, Styled, Window, div, px,
|
||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::menu::DropdownMenu;
|
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
|
||||||
use gpui_kit::component::{Disableable, Selectable, Sizable, h_flex};
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
|
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
|
||||||
|
use gpui_kit::component::{
|
||||||
|
ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex,
|
||||||
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use state::NostrRegistry;
|
||||||
use ui::Avatar;
|
use ui::Avatar;
|
||||||
|
|
||||||
use super::Sidebar;
|
use super::Sidebar;
|
||||||
use crate::Command;
|
use crate::Command;
|
||||||
use crate::dock::TAB_BAR_HEIGHT;
|
use crate::dock::TAB_BAR_HEIGHT;
|
||||||
|
use crate::nav::Nav;
|
||||||
|
|
||||||
impl Sidebar {
|
impl Sidebar {
|
||||||
pub(super) fn render_user(
|
pub(super) fn render_user(
|
||||||
@@ -24,99 +30,114 @@ impl Sidebar {
|
|||||||
let updater = AutoUpdater::try_global(cx);
|
let updater = AutoUpdater::try_global(cx);
|
||||||
let profile = self.directory.read(cx).person(current_user, cx);
|
let profile = self.directory.read(cx).person(current_user, cx);
|
||||||
let avatar = profile.avatar();
|
let avatar = profile.avatar();
|
||||||
|
let name = profile.name();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id("sidebar-user")
|
.id("sidebar-user")
|
||||||
.w_full()
|
.w_full()
|
||||||
.h(TAB_BAR_HEIGHT)
|
.h(TAB_BAR_HEIGHT)
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.items_center()
|
.justify_between()
|
||||||
.gap_2()
|
|
||||||
.px_2()
|
.px_2()
|
||||||
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
||||||
.child(
|
.child(
|
||||||
Button::new("current-user")
|
Button::new("current-user")
|
||||||
.child(
|
|
||||||
Avatar::new(avatar.clone())
|
|
||||||
.seed(profile.avatar_seed())
|
|
||||||
.small(),
|
|
||||||
)
|
|
||||||
.small()
|
|
||||||
.dropdown_caret(true)
|
|
||||||
.compact()
|
|
||||||
.text()
|
.text()
|
||||||
|
.dropdown_caret(true)
|
||||||
|
.min_w_0()
|
||||||
|
.flex_shrink_1()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.child(name),
|
||||||
|
),
|
||||||
|
)
|
||||||
.dropdown_menu(move |this, _window, _cx| {
|
.dropdown_menu(move |this, _window, _cx| {
|
||||||
this.menu_with_icon(
|
this.menu("Profile", Box::new(Command::ShowProfile))
|
||||||
"Profile",
|
.menu("Relays", Box::new(Command::ShowRelayList))
|
||||||
IconName::UserRound,
|
|
||||||
Box::new(Command::ShowProfile),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Relays",
|
|
||||||
IconName::RadioTower,
|
|
||||||
Box::new(Command::ShowRelayList),
|
|
||||||
)
|
|
||||||
.separator()
|
|
||||||
.menu_with_icon(
|
|
||||||
"Settings",
|
|
||||||
IconName::Settings,
|
|
||||||
Box::new(Command::ShowSettings),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(div().flex_1())
|
|
||||||
.when_some(updater, |this, updater| {
|
|
||||||
this.child(self.render_updater(updater, cx))
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
Button::new("sidebar-search")
|
|
||||||
.icon(IconName::Search)
|
|
||||||
.tooltip("Search")
|
|
||||||
.selected(self.search_open)
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
|
||||||
this.toggle_search(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("sidebar-new")
|
|
||||||
.icon(IconName::MessageSquarePlus)
|
|
||||||
.tooltip("New")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.dropdown_menu(|menu, _window, _cx| {
|
|
||||||
menu.label("Public Group")
|
|
||||||
.menu_with_icon(
|
|
||||||
"New Group",
|
|
||||||
IconName::Users,
|
|
||||||
Box::new(Command::NewGroup),
|
|
||||||
)
|
|
||||||
.menu_with_icon(
|
|
||||||
"Join Group",
|
|
||||||
IconName::DoorOpen,
|
|
||||||
Box::new(Command::JoinGroup),
|
|
||||||
)
|
|
||||||
.separator()
|
.separator()
|
||||||
.menu_with_icon(
|
.item(
|
||||||
"New Chat",
|
PopupMenuItem::element(|_, cx| {
|
||||||
IconName::MessageSquare,
|
div().text_color(cx.theme().danger).child("Log out")
|
||||||
Box::new(Command::NewChat),
|
})
|
||||||
|
.on_click(|_, window, cx| {
|
||||||
|
open_logout_dialog(window, cx);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.when(self.group_open, |this| {
|
.child(
|
||||||
this.child(
|
h_flex()
|
||||||
Button::new("sidebar-back")
|
.when_some(updater, |this, updater| {
|
||||||
.icon(IconName::ArrowLeft)
|
this.child(self.render_updater(updater, cx))
|
||||||
.tooltip("Back")
|
})
|
||||||
.ghost()
|
.child(
|
||||||
.small()
|
Button::new("sidebar-search")
|
||||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
.icon(IconName::Search)
|
||||||
this.hide_group(cx);
|
.tooltip("Search")
|
||||||
})),
|
.selected(self.search_open)
|
||||||
)
|
.ghost()
|
||||||
})
|
.small()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.toggle_search(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("sidebar-new")
|
||||||
|
.icon(IconName::MessageSquarePlus)
|
||||||
|
.tooltip("New")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.dropdown_menu(|menu, _window, _cx| {
|
||||||
|
menu.label("Public Group")
|
||||||
|
.menu_with_icon(
|
||||||
|
"New Group",
|
||||||
|
IconName::Users,
|
||||||
|
Box::new(Command::NewGroup),
|
||||||
|
)
|
||||||
|
.menu_with_icon(
|
||||||
|
"Join Group",
|
||||||
|
IconName::DoorOpen,
|
||||||
|
Box::new(Command::JoinGroup),
|
||||||
|
)
|
||||||
|
.separator()
|
||||||
|
.menu_with_icon(
|
||||||
|
"New Chat",
|
||||||
|
IconName::MessageSquare,
|
||||||
|
Box::new(Command::NewChat),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bottom navigation, pinned below the list like signed's sidebar.
|
||||||
|
pub(super) fn render_footer(&self, _cx: &mut Context<Self>) -> Div {
|
||||||
|
v_flex()
|
||||||
|
.p_2()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.items_start()
|
||||||
|
.justify_start()
|
||||||
|
.child(
|
||||||
|
Nav::new("sidebar-settings")
|
||||||
|
.prefix(Icon::new(IconName::Settings).small())
|
||||||
|
.label("Settings")
|
||||||
|
.text_sm()
|
||||||
|
.on_click(|_, window, cx| {
|
||||||
|
window.dispatch_action(Box::new(Command::ShowSettings), cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
||||||
@@ -153,3 +174,25 @@ impl Sidebar {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirm signing out, then drop the stored credential like signed's exit dialog.
|
||||||
|
fn open_logout_dialog(window: &mut Window, cx: &mut App) {
|
||||||
|
const DESCRIPTION: &str = "Are you sure you want to log out? You will need to re-enter your credentials to sign back in.";
|
||||||
|
|
||||||
|
window.open_alert_dialog(cx, |alert, _, _| {
|
||||||
|
alert
|
||||||
|
.title("Log Out?")
|
||||||
|
.description(DESCRIPTION)
|
||||||
|
.button_props(
|
||||||
|
DialogButtonProps::default()
|
||||||
|
.ok_text("Log out")
|
||||||
|
.ok_variant(ButtonVariant::Danger)
|
||||||
|
.show_cancel(true),
|
||||||
|
)
|
||||||
|
.on_ok(|_, _, cx| {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.update(cx, |nostr, cx| nostr.sign_out(cx));
|
||||||
|
true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_kit::assets::IconName;
|
use gpui_kit::assets::IconName;
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, h_flex, v_flex};
|
|
||||||
use nip29::GroupsRegistry;
|
use nip29::GroupsRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
|
|
||||||
@@ -44,12 +43,15 @@ impl Sidebar {
|
|||||||
// Whether to show the create button for the contact picker
|
// Whether to show the create button for the contact picker
|
||||||
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
||||||
|
|
||||||
let (list_id, scroll_handle, list_title) = if contacts_open {
|
let (list_id, title) = if contacts_open {
|
||||||
("sidebar-contacts", &self.contacts_scroll, Some("Contacts"))
|
("sidebar-contacts", Some(("Contacts", IconName::Book)))
|
||||||
} else if requests_open {
|
} else if requests_open {
|
||||||
("sidebar-requests", &self.requests_scroll, Some("Requests"))
|
(
|
||||||
|
"sidebar-requests",
|
||||||
|
Some(("Requests", IconName::UserRoundPlus)),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
("sidebar-list", &self.list_scroll, None)
|
("sidebar-list", None)
|
||||||
};
|
};
|
||||||
|
|
||||||
let request_count = ChatRegistry::global(cx)
|
let request_count = ChatRegistry::global(cx)
|
||||||
@@ -61,13 +63,15 @@ impl Sidebar {
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.relative()
|
.relative()
|
||||||
.gap_2()
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.px_2()
|
.px_2()
|
||||||
|
.gap_1()
|
||||||
|
.items_start()
|
||||||
|
.justify_start()
|
||||||
.child(
|
.child(
|
||||||
Nav::new("browse")
|
Nav::new("browse")
|
||||||
.prefix(Icon::new(IconName::Compass))
|
.prefix(Icon::new(IconName::Compass).small())
|
||||||
.label("Browse")
|
.label("Browse")
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.on_click(|_, window, cx| {
|
.on_click(|_, window, cx| {
|
||||||
@@ -76,7 +80,7 @@ impl Sidebar {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Nav::new("reqs")
|
Nav::new("reqs")
|
||||||
.prefix(Icon::new(IconName::UserRoundPlus))
|
.prefix(Icon::new(IconName::UserRoundPlus).small())
|
||||||
.label("Requests")
|
.label("Requests")
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.when(request_count > 0, |this| {
|
.when(request_count > 0, |this| {
|
||||||
@@ -117,7 +121,7 @@ impl Sidebar {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Nav::new("contacts")
|
Nav::new("contacts")
|
||||||
.prefix(Icon::new(IconName::Book))
|
.prefix(Icon::new(IconName::Book).small())
|
||||||
.label("Contacts")
|
.label("Contacts")
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.when(contacts_open, |this| this.bg(cx.theme().list_active))
|
.when(contacts_open, |this| this.bg(cx.theme().list_active))
|
||||||
@@ -126,33 +130,34 @@ impl Sidebar {
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.when_some(list_title, |this, list_title| {
|
.when_some(title, |this, (title, title_icon)| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
h_flex()
|
||||||
.px_4()
|
.h_10()
|
||||||
.text_xs()
|
.w_full()
|
||||||
.font_semibold()
|
.flex_shrink_0()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.items_center()
|
||||||
.child(list_title),
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.px_2()
|
||||||
|
.gap_2()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(Icon::new(title_icon).small())
|
||||||
|
.child(div().text_xs().font_semibold().child(title)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
uniform_list(
|
||||||
.min_h_0()
|
list_id,
|
||||||
.flex_1()
|
rows.len(),
|
||||||
.child(
|
cx.processor(move |this, range, _window, cx| {
|
||||||
uniform_list(
|
this.render_rows(range, rows.as_slice(), cx)
|
||||||
list_id,
|
}),
|
||||||
rows.len(),
|
)
|
||||||
cx.processor(move |this, range, _window, cx| {
|
.min_h_0()
|
||||||
this.render_rows(range, rows.as_slice(), cx)
|
.flex_1()
|
||||||
}),
|
.px_2(),
|
||||||
)
|
|
||||||
.track_scroll(scroll_handle)
|
|
||||||
.h_full()
|
|
||||||
.px_2(),
|
|
||||||
)
|
|
||||||
.child(Scrollbar::vertical(scroll_handle)),
|
|
||||||
)
|
)
|
||||||
.when(create_button, |this| {
|
.when(create_button, |this| {
|
||||||
this.child(self.render_create_overlay(cx))
|
this.child(self.render_create_overlay(cx))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||||
IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription,
|
IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription,
|
||||||
Task, UniformListScrollHandle, WeakEntity, Window, div, img, retain_all,
|
Task, WeakEntity, Window, div, img, retain_all,
|
||||||
};
|
};
|
||||||
use gpui_kit::base::StyledExt as _;
|
use gpui_kit::base::StyledExt as _;
|
||||||
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
|
use gpui_kit::component::dock::{DockArea, Panel, PanelEvent};
|
||||||
@@ -38,7 +38,6 @@ use search::SearchResult;
|
|||||||
|
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
list_scroll: UniformListScrollHandle,
|
|
||||||
dock: WeakEntity<DockArea>,
|
dock: WeakEntity<DockArea>,
|
||||||
/// Random background shown behind the signed-out screen.
|
/// Random background shown behind the signed-out screen.
|
||||||
banner: SharedString,
|
banner: SharedString,
|
||||||
@@ -47,8 +46,6 @@ pub struct Sidebar {
|
|||||||
group: Option<WeakEntity<Group>>,
|
group: Option<WeakEntity<Group>>,
|
||||||
/// Whether the sidebar is showing the group's information instead of its list.
|
/// Whether the sidebar is showing the group's information instead of its list.
|
||||||
group_open: bool,
|
group_open: bool,
|
||||||
/// Scroll state of the group's member list.
|
|
||||||
group_scroll: UniformListScrollHandle,
|
|
||||||
/// Whether the group view's admins section is expanded.
|
/// Whether the group view's admins section is expanded.
|
||||||
admins_open: bool,
|
admins_open: bool,
|
||||||
/// Whether the group view's members section is expanded.
|
/// Whether the group view's members section is expanded.
|
||||||
@@ -69,13 +66,10 @@ pub struct Sidebar {
|
|||||||
directory: Entity<Directory>,
|
directory: Entity<Directory>,
|
||||||
/// Contacts selected in the contact picker.
|
/// Contacts selected in the contact picker.
|
||||||
selected_contacts: HashSet<PublicKey>,
|
selected_contacts: HashSet<PublicKey>,
|
||||||
/// Scroll state of the contact picker's list.
|
|
||||||
contacts_scroll: UniformListScrollHandle,
|
|
||||||
|
|
||||||
/// Whether the main list is showing chat requests instead of chats and groups.
|
/// Whether the main list is showing chat requests instead of chats and groups.
|
||||||
requests_open: bool,
|
requests_open: bool,
|
||||||
new_requests: bool,
|
new_requests: bool,
|
||||||
requests_scroll: UniformListScrollHandle,
|
|
||||||
|
|
||||||
/// Whether the search bar is shown below the header.
|
/// Whether the search bar is shown below the header.
|
||||||
search_open: bool,
|
search_open: bool,
|
||||||
@@ -93,8 +87,6 @@ pub struct Sidebar {
|
|||||||
global_results: Option<Vec<PublicKey>>,
|
global_results: Option<Vec<PublicKey>>,
|
||||||
/// The async global profile search.
|
/// The async global profile search.
|
||||||
global_task: Option<Task<()>>,
|
global_task: Option<Task<()>>,
|
||||||
/// Scroll state of the search results list.
|
|
||||||
search_scroll: UniformListScrollHandle,
|
|
||||||
|
|
||||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
@@ -208,12 +200,10 @@ impl Sidebar {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
list_scroll: UniformListScrollHandle::new(),
|
|
||||||
dock,
|
dock,
|
||||||
banner: onboarding::pick_banner(),
|
banner: onboarding::pick_banner(),
|
||||||
group: None,
|
group: None,
|
||||||
group_open: false,
|
group_open: false,
|
||||||
group_scroll: UniformListScrollHandle::new(),
|
|
||||||
admins_open: true,
|
admins_open: true,
|
||||||
members_open: true,
|
members_open: true,
|
||||||
pins_open: true,
|
pins_open: true,
|
||||||
@@ -223,9 +213,7 @@ impl Sidebar {
|
|||||||
contacts: None,
|
contacts: None,
|
||||||
directory: Directory::new(cx),
|
directory: Directory::new(cx),
|
||||||
selected_contacts: HashSet::new(),
|
selected_contacts: HashSet::new(),
|
||||||
contacts_scroll: UniformListScrollHandle::new(),
|
|
||||||
requests_open: false,
|
requests_open: false,
|
||||||
requests_scroll: UniformListScrollHandle::new(),
|
|
||||||
new_requests: false,
|
new_requests: false,
|
||||||
search_open: false,
|
search_open: false,
|
||||||
global_open: false,
|
global_open: false,
|
||||||
@@ -235,7 +223,6 @@ impl Sidebar {
|
|||||||
global_finding: false,
|
global_finding: false,
|
||||||
global_results: None,
|
global_results: None,
|
||||||
global_task: None,
|
global_task: None,
|
||||||
search_scroll: UniformListScrollHandle::new(),
|
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -310,45 +297,51 @@ impl Render for Sidebar {
|
|||||||
.image_cache(retain_all("sidebar"))
|
.image_cache(retain_all("sidebar"))
|
||||||
.size_full()
|
.size_full()
|
||||||
.relative()
|
.relative()
|
||||||
.gap_2()
|
.justify_between()
|
||||||
.bg(cx.theme().sidebar)
|
.bg(cx.theme().sidebar)
|
||||||
.when_some(current_user.as_ref(), |this, current_user| {
|
.text_color(cx.theme().sidebar_foreground)
|
||||||
this.child(title_bar_drag_handlers(
|
.child(
|
||||||
self.render_user(current_user, cx),
|
v_flex()
|
||||||
window,
|
.flex_1()
|
||||||
cx,
|
.min_h_0()
|
||||||
))
|
.when_some(current_user.as_ref(), |this, current_user| {
|
||||||
})
|
this.child(title_bar_drag_handlers(
|
||||||
.when(self.search_open, |this| this.child(self.render_find(cx)))
|
self.render_user(current_user, cx),
|
||||||
.when(!logged_in, |this| {
|
window,
|
||||||
this.relative()
|
cx,
|
||||||
.child(title_bar_drag_handlers(
|
))
|
||||||
div()
|
})
|
||||||
.id("onboarding-drag")
|
.when(self.search_open, |this| this.child(self.render_find(cx)))
|
||||||
.absolute()
|
.when(!logged_in, |this| {
|
||||||
.top_0()
|
this.child(title_bar_drag_handlers(
|
||||||
.left_0()
|
div()
|
||||||
.h(TAB_BAR_HEIGHT)
|
.id("onboarding-drag")
|
||||||
.w_full(),
|
.absolute()
|
||||||
window,
|
.top_0()
|
||||||
cx,
|
.left_0()
|
||||||
))
|
.h(TAB_BAR_HEIGHT)
|
||||||
.child(
|
.w_full(),
|
||||||
div().absolute().inset_0().child(
|
window,
|
||||||
img(self.banner.clone())
|
cx,
|
||||||
.size_full()
|
))
|
||||||
.object_fit(ObjectFit::Cover),
|
.child(
|
||||||
),
|
div().absolute().inset_0().child(
|
||||||
)
|
img(self.banner.clone())
|
||||||
.child(onboarding::render_signed_out(window, cx))
|
.size_full()
|
||||||
})
|
.object_fit(ObjectFit::Cover),
|
||||||
.map(|this| match group {
|
),
|
||||||
Some(group) if self.group_open && !searching => {
|
)
|
||||||
this.child(self.render_group(group, cx))
|
.child(onboarding::render_signed_out(window, cx))
|
||||||
}
|
})
|
||||||
_ if searching => this.child(self.render_results(cx)),
|
.map(|this| match group {
|
||||||
_ => this.child(self.render_list(cx)),
|
Some(group) if self.group_open && !searching => {
|
||||||
})
|
this.child(self.render_group(group, cx))
|
||||||
|
}
|
||||||
|
_ if searching => this.child(self.render_results(cx)),
|
||||||
|
_ => this.child(self.render_list(cx)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when(logged_in, |this| this.child(self.render_footer(cx)))
|
||||||
.when(loading && logged_in, |this| {
|
.when(loading && logged_in, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use gpui_kit::base::StyledExt as _;
|
|||||||
use gpui_kit::component::dialog::DialogButtonProps;
|
use gpui_kit::component::dialog::DialogButtonProps;
|
||||||
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
||||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||||
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex};
|
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
|
||||||
use nip29::Group;
|
use nip29::Group;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -151,24 +151,13 @@ impl Sidebar {
|
|||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
SidebarRow::Empty(text) => v_flex()
|
SidebarRow::Empty(text) => h_flex()
|
||||||
.h_10()
|
.h_8()
|
||||||
.w_full()
|
.w_full()
|
||||||
.px_1p5()
|
.px_2()
|
||||||
.items_center()
|
.text_xs()
|
||||||
.child(
|
.text_color(cx.theme().muted_foreground)
|
||||||
h_flex()
|
.child(*text)
|
||||||
.h_9()
|
|
||||||
.w_full()
|
|
||||||
.justify_center()
|
|
||||||
.border_1()
|
|
||||||
.border_dashed()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().muted_foreground)
|
|
||||||
.child(*text),
|
|
||||||
)
|
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
SidebarRow::Room { room } => {
|
SidebarRow::Room { room } => {
|
||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
use gpui_kit::assets::IconName;
|
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
|
||||||
use gpui_kit::component::input::Input;
|
|
||||||
use gpui_kit::component::scroll::Scrollbar;
|
|
||||||
use gpui_kit::component::{ActiveTheme, Selectable, Sizable, h_flex, v_flex};
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -11,6 +6,10 @@ use gpui::{
|
|||||||
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
|
AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task,
|
||||||
Window, div, uniform_list,
|
Window, div, uniform_list,
|
||||||
};
|
};
|
||||||
|
use gpui_kit::assets::IconName;
|
||||||
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_kit::component::input::Input;
|
||||||
|
use gpui_kit::component::{ActiveTheme, Selectable, Sizable, h_flex, v_flex};
|
||||||
use nip29::{Group, GroupsRegistry};
|
use nip29::{Group, GroupsRegistry};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::{FIND_LIMIT, NostrRegistry};
|
use state::{FIND_LIMIT, NostrRegistry};
|
||||||
@@ -245,22 +244,17 @@ impl Sidebar {
|
|||||||
.min_h_0()
|
.min_h_0()
|
||||||
.relative()
|
.relative()
|
||||||
.child(
|
.child(
|
||||||
div()
|
div().min_h_0().flex_1().child(
|
||||||
.min_h_0()
|
uniform_list(
|
||||||
.flex_1()
|
"sidebar-search",
|
||||||
.child(
|
rows.len(),
|
||||||
uniform_list(
|
cx.processor(move |this, range, _window, cx| {
|
||||||
"sidebar-search",
|
this.render_rows(range, rows.as_slice(), cx)
|
||||||
rows.len(),
|
}),
|
||||||
cx.processor(move |this, range, _window, cx| {
|
|
||||||
this.render_rows(range, rows.as_slice(), cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.track_scroll(&self.search_scroll)
|
|
||||||
.h_full()
|
|
||||||
.px_2(),
|
|
||||||
)
|
)
|
||||||
.child(Scrollbar::vertical(&self.search_scroll)),
|
.h_full()
|
||||||
|
.px_2(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user