Files
Ely-GPUI-Components/src/messaging/channels.rs
T
ZacharyZhang-NYandClaude Opus 5.5 5779daef82 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:22 -04:00

283 lines
8.3 KiB
Rust

use std::rc::Rc;
use gpui::{
App, ElementId, ImageSource, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use crate::{
data_display::{Avatar, CountBadge, Presence},
forms::OnValue,
lists::{ListItem, Sections},
primitives::{Icon, IconName},
theme::{ActiveTheme, AvatarSize, IconSize},
};
/// What unread messages add at a row's end: a count, and a pin before it when pinned.
fn trail(id: &ElementId, unread: u32, pinned: bool, cx: &App) -> gpui::Div {
let colors = &cx.theme().colors;
div()
.flex()
.items_center()
.gap_2()
.when(pinned, |trail| {
trail.child(
Icon::new(IconName::Pin)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
})
.when(unread > 0, |trail| {
trail.child(CountBadge::new((id.clone(), "unread"), unread as usize).max(99))
})
}
/// A channel as a row: a hash, or a lock when it is private, its name, and how many messages wait unread. Unread, its name stands out; muted, it goes quiet and counts nothing; pinned, it keeps a pin. As a `ListItem` it goes into a `ChannelList`.
#[derive(IntoElement)]
pub struct ChannelItem {
id: ElementId,
name: SharedString,
private: bool,
unread: u32,
muted: bool,
pinned: bool,
}
impl ChannelItem {
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
name: name.into(),
private: false,
unread: 0,
muted: false,
pinned: false,
}
}
pub fn private(mut self) -> Self {
self.private = true;
self
}
/// Messages waiting unread.
pub fn unread(mut self, count: u32) -> Self {
self.unread = count;
self
}
pub fn muted(mut self, muted: bool) -> Self {
self.muted = muted;
self
}
pub fn pinned(mut self, pinned: bool) -> Self {
self.pinned = pinned;
self
}
fn item(self, cx: &App) -> ListItem {
let colors = &cx.theme().colors;
let unread = if self.muted { 0 } else { self.unread };
let mark = if self.private {
IconName::Lock
} else {
IconName::Hash
};
let trail = trail(&self.id, unread, self.pinned, cx);
ListItem::new(self.id, self.name)
.leading(Icon::new(mark).size(IconSize::Sm).color(colors.fg_muted))
.strong(unread > 0)
.quiet(self.muted)
.trailing(trail)
}
}
impl RenderOnce for ChannelItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.item(cx)
}
}
/// A chat with one person as a row: their picture or initials with their presence, their name, and how many messages wait unread; unread, the name stands out. As a `ListItem` it goes into a `ChannelList`.
#[derive(IntoElement)]
pub struct DirectMessageItem {
id: ElementId,
name: SharedString,
picture: Option<ImageSource>,
presence: Presence,
unread: u32,
}
impl DirectMessageItem {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
presence: Presence,
) -> Self {
Self {
id: id.into(),
name: name.into(),
picture: None,
presence,
unread: 0,
}
}
pub fn picture(mut self, source: impl Into<ImageSource>) -> Self {
self.picture = Some(source.into());
self
}
/// Messages waiting unread.
pub fn unread(mut self, count: u32) -> Self {
self.unread = count;
self
}
fn item(self, cx: &App) -> ListItem {
let avatar = Avatar::new((self.id.clone(), "avatar"), self.name.clone())
.size(AvatarSize::Sm)
.presence(self.presence);
let avatar = match self.picture {
Some(picture) => avatar.image(picture),
None => avatar,
};
let trail = trail(&self.id, self.unread, false, cx);
ListItem::new(self.id, self.name)
.leading(avatar)
.strong(self.unread > 0)
.quiet(self.presence == Presence::Offline && self.unread == 0)
.trailing(trail)
}
}
impl RenderOnce for DirectMessageItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.item(cx)
}
}
/// Rows a channel list takes: a channel or a direct message.
pub enum ChatRow {
Channel(ChannelItem),
Direct(DirectMessageItem),
}
impl From<ChannelItem> for ChatRow {
fn from(item: ChannelItem) -> Self {
Self::Channel(item)
}
}
impl From<DirectMessageItem> for ChatRow {
fn from(item: DirectMessageItem) -> Self {
Self::Direct(item)
}
}
/// Channels and chats in sections down a column, such as Starred, Channels and Direct messages, each section a list: a press, an arrow or Enter opens a chat, and the one open is lit. Tab moves between sections.
#[derive(IntoElement)]
pub struct ChannelList {
id: ElementId,
sections: Vec<(SharedString, Vec<(SharedString, ChatRow)>)>,
open: Option<SharedString>,
on_open: Option<OnValue>,
}
impl ChannelList {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
sections: Vec::new(),
open: None,
on_open: None,
}
}
/// A section under `title`, its chats by key; a title names one section, and a section holds a chat.
pub fn section(
mut self,
title: impl Into<SharedString>,
rows: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<ChatRow>)>,
) -> Self {
let title = title.into();
let rows: Vec<(SharedString, ChatRow)> = rows
.into_iter()
.map(|(key, row)| (key.into(), row.into()))
.collect();
assert!(!rows.is_empty(), "section {title} holds no chats");
let named = self.sections.iter().any(|(other, _)| *other == title);
assert!(!named, "section {title} twice");
for (key, _) in &rows {
let seen = self
.sections
.iter()
.flat_map(|(_, rows)| rows)
.any(|(other, _)| other == key);
assert!(!seen, "chat {key} twice");
}
self.sections.push((title, rows));
self
}
/// The chat open now, by key.
pub fn open(mut self, key: impl Into<SharedString>) -> Self {
self.open = Some(key.into());
self
}
/// Gets the key of the chat to open.
pub fn on_open(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ChannelList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if let Some(open) = &self.open {
let listed = self
.sections
.iter()
.flat_map(|(_, rows)| rows)
.any(|(key, _)| key == open);
assert!(listed, "no chat {open}");
}
let open: OnValue = {
let (id, on_open) = (self.id.clone(), self.on_open.clone());
Rc::new(move |key, window, cx| {
log::info!("channel list {id:?}: open {key}");
if let Some(on_open) = &on_open {
on_open(key, window, cx);
}
})
};
let sections = self.sections.into_iter().fold(
Sections::new(self.id.clone()),
|sections, (title, rows)| {
let rows = rows
.into_iter()
.map(|(key, row)| {
let item = match row {
ChatRow::Channel(item) => item.item(cx),
ChatRow::Direct(item) => item.item(cx),
};
(key, item)
})
.collect();
sections.section(title, rows)
},
);
div().debug_selector(|| "channel-list".into()).child(
sections
.selected(self.open)
.on_select(open.clone())
.on_activate(open),
)
}
}