diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 475b9198..7f33a63a 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -1,64 +1,124 @@ use gpui::{ - AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement, - Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, - SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, + App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, + SharedString, StyleRefinement, Styled, Window, div, px, }; +use gpui_base::AvatarImage; use gpui_kit::base::StyledExt as _; 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. -const PIXEL_GRID: usize = 8; +/// Number of rows and columns in the pixel grid. +const GRID_SIZE: usize = 8; /// Empty cells kept between the pattern and the avatar edge. const MARGIN: usize = 1; /// Probability that a cell in the left half of the pattern area is filled. const FILL_PROBABILITY: f32 = 0.42; /// Probability that a filled cell uses the accent shade instead of the main color. const ACCENT_PROBABILITY: f32 = 0.25; -/// Minimum number of filled left-half cells, so a pattern never reads as empty. +/// Minimum number of filled left-half cells. 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"; -/// 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]. -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. +/// An avatar for a user or other entity. #[derive(IntoElement)] -struct PixelAvatar { - seed: u64, +pub struct Avatar { + base: Div, + picture: Option, + seed: Option, size: Size, + selected: bool, style: StyleRefinement, } -impl PixelAvatar { - /// Creates a pixel avatar from the given seed. - fn new(seed: impl AsRef) -> Self { +impl Avatar { + /// Create an avatar showing `picture`, falling back to pixel art seeded by [`Avatar::seed`]. + pub fn new(picture: Option) -> Self { Self { - seed: fnv1a(seed.as_ref().as_bytes()), + base: div(), + picture, + seed: None, size: Size::Medium, + selected: false, style: StyleRefinement::default(), } } + + /// The seed for the generated pixel avatar. + pub fn seed(mut self, seed: impl Into) -> 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) -> Self { self.size = size.into(); 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 { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -66,76 +126,84 @@ impl Styled for PixelAvatar { } impl RenderOnce for PixelAvatar { - fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { - let side = avatar_size(self.size).to_pixels(window.rem_size()); - let seed = self.seed; + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + 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() - .refine_style(&self.style) - .size(side) + .grid() + .grid_cols(GRID_SIZE as u16) + .grid_rows(GRID_SIZE as u16) + .size(self.side) .flex_shrink_0() - .child(canvas( - move |_bounds, _window, _cx| seed, - move |bounds, seed, window, cx| { - let theme = cx.theme(); - let main = Hsla { - 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); - } - }, - )) + .rounded_full() + .overflow_hidden() + .bg(main.opacity(0.16)) + .children(cells) + .refine_style(&self.style) } } -/// Builds the mirrored fill pattern for the given seed. -fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { +/// The rendered side length of an avatar at `size`. +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 pattern = [0u8; PIXEL_GRID * PIXEL_GRID]; + let mut pattern = [0u8; GRID_SIZE * GRID_SIZE]; let mut filled = 0usize; - let art_rows = PIXEL_GRID - 2 * MARGIN; - let art_columns = PIXEL_GRID / 2 - MARGIN; + // Only the inner rows and the inner left half are candidates; mirroring + // 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 col in MARGIN..PIXEL_GRID / 2 { + for row in MARGIN..GRID_SIZE - MARGIN { + for col in MARGIN..GRID_SIZE / 2 { if rng.chance(FILL_PROBABILITY) { let accent = rng.chance(ACCENT_PROBABILITY); 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 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); filled += 1; } @@ -167,146 +235,10 @@ fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { pattern } -/// Paints `polygons as a single anti-aliased filled path in `color. -fn paint_polygons<'a>( - window: &mut Window, - polygons: impl IntoIterator>>, - 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, radius: f32) -> Vec> { - 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, row: usize, col: usize) -> [Point; 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], clip: &[Point]) -> Vec> { - 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, end: Point, vertex: Point) -> 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, - end: Point, - from: Point, - to: Point, -) -> Option> { - 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; +/// Fill `cell (row, col)` and its horizontal mirror. +fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) { + pattern[row * GRID_SIZE + col] = value; + pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value; } /// 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 } } - -/// 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, - seed: Option, - 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) -> Self { - Self::from_source(picture) - } - - /// Creates an avatar from an already-resolved source, which may be missing. - pub fn from_source(picture: Option>) -> Self { - Self::from_picture(picture.map(Into::into)) - } - - fn from_picture(picture: Option) -> 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) -> Self { - self.seed = Some(seed.into()); - self - } -} - -impl Sizable for Avatar { - fn with_size(mut self, size: impl Into) -> 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) - } -} diff --git a/crates/workspace/src/nav.rs b/crates/workspace/src/nav.rs index 7600ab78..b01c89b6 100644 --- a/crates/workspace/src/nav.rs +++ b/crates/workspace/src/nav.rs @@ -6,7 +6,7 @@ use gpui::{ ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, 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. #[derive(IntoElement)] @@ -101,41 +101,27 @@ impl Styled for Nav { impl RenderOnce for Nav { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { 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() - .id(self.id) - .h_9() - .w_full() - .px_1p5() - .gap_1p5() - .rounded(cx.theme().radius) - .text_color(text_color) - .when_some(self.prefix, |this, prefix| this.child(prefix)) - .child( - h_flex() - .gap_1() - .flex_1() - .min_w_0() - .child(div().flex_1().min_w_0().truncate().child(self.label)) - .when_some(self.suffix, |this, suffix| { - this.child(div().flex_shrink_0().child(suffix)) - }), - ) - .when(clickable, |this| { - this.cursor_pointer() - .hover(|this| this.bg(cx.theme().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), - ) + h_flex() + .id(self.id) + .h_8() + .w_full() + .px_2() + .gap_2() + .rounded(cx.theme().radius) + .when_some(self.prefix, |this, prefix| this.child(prefix)) + .child(div().flex_1().min_w_0().truncate().child(self.label)) + .when_some(self.suffix, |this, suffix| { + this.child(div().flex_shrink_0().child(suffix)) + }) + .when(clickable, |this| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().list_hover)) + .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) } } diff --git a/crates/workspace/src/panels/greeter.rs b/crates/workspace/src/panels/greeter.rs index 91a81e0d..853359d9 100644 --- a/crates/workspace/src/panels/greeter.rs +++ b/crates/workspace/src/panels/greeter.rs @@ -13,7 +13,6 @@ use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; 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::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use nostr_sdk::prelude::*; @@ -302,11 +301,23 @@ impl GreeterPanel { .collect() }; - GroupBox::new() - .id("requests") - .title("Requests") - .fill() - .child(v_flex().gap_0().w_full().children(rows)) + v_flex() + .gap_2() + .child( + div() + .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() } @@ -408,11 +419,23 @@ impl GreeterPanel { .collect(); Some( - GroupBox::new() - .id("follow-ups") - .title("Follow ups") - .fill() - .child(v_flex().gap_0().w_full().children(rows)) + v_flex() + .gap_2() + .child( + div() + .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(), ) } diff --git a/crates/workspace/src/sidebar/group_view.rs b/crates/workspace/src/sidebar/group_view.rs index d8640028..f879fd88 100644 --- a/crates/workspace/src/sidebar/group_view.rs +++ b/crates/workspace/src/sidebar/group_view.rs @@ -8,8 +8,8 @@ use gpui::{ }; use gpui_kit::assets::IconName; 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::scroll::Scrollbar; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use nip29::Group; use nostr_sdk::prelude::*; @@ -155,6 +155,16 @@ impl Sidebar { .pl_0p5() .gap_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( v_flex() @@ -184,28 +194,17 @@ impl Sidebar { ), ) .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - "group-rows", - 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(), + div().min_h_0().flex_1().child( + uniform_list( + "group-rows", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_group_rows(range, rows.as_slice(), &member_group, admin, cx) + }), ) - .child(Scrollbar::vertical(&self.group_scroll)), + .h_full() + .px_2(), + ), ) .into_any_element() } @@ -292,7 +291,7 @@ impl Sidebar { this.prefix( Avatar::new(person.avatar()) .seed(person.avatar_seed()) - .small(), + .xsmall(), ) }) .when(!roles.is_empty(), |this| { diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index 7cc606a7..e21f5859 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -2,18 +2,24 @@ use auto_update::AutoUpdater; use gpui::prelude::FluentBuilder; use gpui::{ 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::component::button::{Button, ButtonVariants}; -use gpui_kit::component::menu::DropdownMenu; -use gpui_kit::component::{Disableable, Selectable, Sizable, h_flex}; +use gpui_kit::base::StyledExt as _; +use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; +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 state::NostrRegistry; use ui::Avatar; use super::Sidebar; use crate::Command; use crate::dock::TAB_BAR_HEIGHT; +use crate::nav::Nav; impl Sidebar { pub(super) fn render_user( @@ -24,99 +30,114 @@ impl Sidebar { let updater = AutoUpdater::try_global(cx); let profile = self.directory.read(cx).person(current_user, cx); let avatar = profile.avatar(); + let name = profile.name(); h_flex() .id("sidebar-user") .w_full() .h(TAB_BAR_HEIGHT) .flex_shrink_0() - .items_center() - .gap_2() + .justify_between() .px_2() .when(cfg!(target_os = "macos"), |this| this.pl(px(80.))) .child( Button::new("current-user") - .child( - Avatar::new(avatar.clone()) - .seed(profile.avatar_seed()) - .small(), - ) - .small() - .dropdown_caret(true) - .compact() .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| { - this.menu_with_icon( - "Profile", - 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), - ) + this.menu("Profile", Box::new(Command::ShowProfile)) + .menu("Relays", Box::new(Command::ShowRelayList)) .separator() - .menu_with_icon( - "New Chat", - IconName::MessageSquare, - Box::new(Command::NewChat), + .item( + PopupMenuItem::element(|_, cx| { + div().text_color(cx.theme().danger).child("Log out") + }) + .on_click(|_, window, cx| { + open_logout_dialog(window, cx); + }), ) }), ) - .when(self.group_open, |this| { - this.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( + h_flex() + .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() + .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) -> 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, cx: &mut App) -> AnyElement { @@ -153,3 +174,25 @@ impl Sidebar { .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 + }) + }); +} diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index 1b386b28..72b41d42 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -7,8 +7,7 @@ use gpui::{ }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; -use gpui_kit::component::scroll::Scrollbar; -use gpui_kit::component::{ActiveTheme, Icon, h_flex, v_flex}; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use nip29::GroupsRegistry; use settings::AppSettings; @@ -44,12 +43,15 @@ impl Sidebar { // Whether to show the create button for the contact picker let create_button = contacts_open && !self.selected_contacts.is_empty(); - let (list_id, scroll_handle, list_title) = if contacts_open { - ("sidebar-contacts", &self.contacts_scroll, Some("Contacts")) + let (list_id, title) = if contacts_open { + ("sidebar-contacts", Some(("Contacts", IconName::Book))) } else if requests_open { - ("sidebar-requests", &self.requests_scroll, Some("Requests")) + ( + "sidebar-requests", + Some(("Requests", IconName::UserRoundPlus)), + ) } else { - ("sidebar-list", &self.list_scroll, None) + ("sidebar-list", None) }; let request_count = ChatRegistry::global(cx) @@ -61,13 +63,15 @@ impl Sidebar { .flex_1() .min_h_0() .relative() - .gap_2() .child( v_flex() .px_2() + .gap_1() + .items_start() + .justify_start() .child( Nav::new("browse") - .prefix(Icon::new(IconName::Compass)) + .prefix(Icon::new(IconName::Compass).small()) .label("Browse") .text_sm() .on_click(|_, window, cx| { @@ -76,7 +80,7 @@ impl Sidebar { ) .child( Nav::new("reqs") - .prefix(Icon::new(IconName::UserRoundPlus)) + .prefix(Icon::new(IconName::UserRoundPlus).small()) .label("Requests") .text_sm() .when(request_count > 0, |this| { @@ -117,7 +121,7 @@ impl Sidebar { ) .child( Nav::new("contacts") - .prefix(Icon::new(IconName::Book)) + .prefix(Icon::new(IconName::Book).small()) .label("Contacts") .text_sm() .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( - div() - .px_4() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(list_title), + h_flex() + .h_10() + .w_full() + .flex_shrink_0() + .items_center() + .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( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - list_id, - rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_rows(range, rows.as_slice(), cx) - }), - ) - .track_scroll(scroll_handle) - .h_full() - .px_2(), - ) - .child(Scrollbar::vertical(scroll_handle)), + uniform_list( + list_id, + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), + ) + .min_h_0() + .flex_1() + .px_2(), ) .when(create_button, |this| { this.child(self.render_create_overlay(cx)) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 0a986c59..2efff0ea 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, 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::component::dock::{DockArea, Panel, PanelEvent}; @@ -38,7 +38,6 @@ use search::SearchResult; pub struct Sidebar { focus_handle: FocusHandle, - list_scroll: UniformListScrollHandle, dock: WeakEntity, /// Random background shown behind the signed-out screen. banner: SharedString, @@ -47,8 +46,6 @@ pub struct Sidebar { group: Option>, /// Whether the sidebar is showing the group's information instead of its list. group_open: bool, - /// Scroll state of the group's member list. - group_scroll: UniformListScrollHandle, /// Whether the group view's admins section is expanded. admins_open: bool, /// Whether the group view's members section is expanded. @@ -69,13 +66,10 @@ pub struct Sidebar { directory: Entity, /// Contacts selected in the contact picker. selected_contacts: HashSet, - /// Scroll state of the contact picker's list. - contacts_scroll: UniformListScrollHandle, /// Whether the main list is showing chat requests instead of chats and groups. requests_open: bool, new_requests: bool, - requests_scroll: UniformListScrollHandle, /// Whether the search bar is shown below the header. search_open: bool, @@ -93,8 +87,6 @@ pub struct Sidebar { global_results: Option>, /// The async global profile search. global_task: Option>, - /// Scroll state of the search results list. - search_scroll: UniformListScrollHandle, tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, @@ -208,12 +200,10 @@ impl Sidebar { Self { focus_handle: cx.focus_handle(), - list_scroll: UniformListScrollHandle::new(), dock, banner: onboarding::pick_banner(), group: None, group_open: false, - group_scroll: UniformListScrollHandle::new(), admins_open: true, members_open: true, pins_open: true, @@ -223,9 +213,7 @@ impl Sidebar { contacts: None, directory: Directory::new(cx), selected_contacts: HashSet::new(), - contacts_scroll: UniformListScrollHandle::new(), requests_open: false, - requests_scroll: UniformListScrollHandle::new(), new_requests: false, search_open: false, global_open: false, @@ -235,7 +223,6 @@ impl Sidebar { global_finding: false, global_results: None, global_task: None, - search_scroll: UniformListScrollHandle::new(), tasks: smallvec![], _subscriptions: subscriptions, } @@ -310,45 +297,51 @@ impl Render for Sidebar { .image_cache(retain_all("sidebar")) .size_full() .relative() - .gap_2() + .justify_between() .bg(cx.theme().sidebar) - .when_some(current_user.as_ref(), |this, current_user| { - this.child(title_bar_drag_handlers( - self.render_user(current_user, cx), - window, - cx, - )) - }) - .when(self.search_open, |this| this.child(self.render_find(cx))) - .when(!logged_in, |this| { - this.relative() - .child(title_bar_drag_handlers( - div() - .id("onboarding-drag") - .absolute() - .top_0() - .left_0() - .h(TAB_BAR_HEIGHT) - .w_full(), - window, - cx, - )) - .child( - div().absolute().inset_0().child( - img(self.banner.clone()) - .size_full() - .object_fit(ObjectFit::Cover), - ), - ) - .child(onboarding::render_signed_out(window, cx)) - }) - .map(|this| match group { - 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)), - }) + .text_color(cx.theme().sidebar_foreground) + .child( + v_flex() + .flex_1() + .min_h_0() + .when_some(current_user.as_ref(), |this, current_user| { + this.child(title_bar_drag_handlers( + self.render_user(current_user, cx), + window, + cx, + )) + }) + .when(self.search_open, |this| this.child(self.render_find(cx))) + .when(!logged_in, |this| { + this.child(title_bar_drag_handlers( + div() + .id("onboarding-drag") + .absolute() + .top_0() + .left_0() + .h(TAB_BAR_HEIGHT) + .w_full(), + window, + cx, + )) + .child( + div().absolute().inset_0().child( + img(self.banner.clone()) + .size_full() + .object_fit(ObjectFit::Cover), + ), + ) + .child(onboarding::render_signed_out(window, cx)) + }) + .map(|this| match group { + 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| { this.child( div() diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs index c67ae761..1d6056be 100644 --- a/crates/workspace/src/sidebar/rows.rs +++ b/crates/workspace/src/sidebar/rows.rs @@ -12,7 +12,7 @@ use gpui_kit::base::StyledExt as _; use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle}; 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 nostr_sdk::prelude::*; use settings::AppSettings; @@ -151,24 +151,13 @@ impl Sidebar { .text_color(cx.theme().muted_foreground) .into_any_element() } - SidebarRow::Empty(text) => v_flex() - .h_10() + SidebarRow::Empty(text) => h_flex() + .h_8() .w_full() - .px_1p5() - .items_center() - .child( - h_flex() - .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), - ) + .px_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(*text) .into_any_element(), SidebarRow::Room { room } => { let id = room.read(cx).id; diff --git a/crates/workspace/src/sidebar/search.rs b/crates/workspace/src/sidebar/search.rs index 1f6769da..4f2e89be 100644 --- a/crates/workspace/src/sidebar/search.rs +++ b/crates/workspace/src/sidebar/search.rs @@ -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::rc::Rc; @@ -11,6 +6,10 @@ use gpui::{ AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task, 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 nostr_sdk::prelude::*; use state::{FIND_LIMIT, NostrRegistry}; @@ -245,22 +244,17 @@ impl Sidebar { .min_h_0() .relative() .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - "sidebar-search", - 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(), + div().min_h_0().flex_1().child( + uniform_list( + "sidebar-search", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), ) - .child(Scrollbar::vertical(&self.search_scroll)), + .h_full() + .px_2(), + ), ) .into_any_element() }