This commit is contained in:
2026-10-22 15:20:11 +07:00
parent b560ea4116
commit 49f2d9edc1
9 changed files with 468 additions and 610 deletions
+162 -336
View File
@@ -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
View File
@@ -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),
)
} }
} }
+34 -11
View File
@@ -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(),
) )
} }
+22 -23
View File
@@ -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| {
+124 -81
View File
@@ -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
})
});
}
+38 -33
View File
@@ -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))
+45 -52
View File
@@ -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()
+7 -18
View File
@@ -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;
+14 -20
View File
@@ -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()
} }