feat: revamp the chat panel ui (#7)
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m40s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-02-19 07:25:07 +00:00
parent e327178161
commit f6ce53ef9c
53 changed files with 4613 additions and 3738 deletions

221
crates/ui/src/list/cache.rs Normal file
View File

@@ -0,0 +1,221 @@
use std::rc::Rc;
use gpui::{App, Pixels, Size};
use crate::IndexPath;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RowEntry {
Entry(IndexPath),
SectionHeader(usize),
SectionFooter(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct MeasuredEntrySize {
pub(crate) item_size: Size<Pixels>,
pub(crate) section_header_size: Size<Pixels>,
pub(crate) section_footer_size: Size<Pixels>,
}
impl RowEntry {
#[inline]
#[allow(unused)]
pub(crate) fn is_section_header(&self) -> bool {
matches!(self, RowEntry::SectionHeader(_))
}
pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool {
match self {
RowEntry::Entry(index_path) => index_path == path,
RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false,
}
}
#[allow(unused)]
pub(crate) fn index(&self) -> IndexPath {
match self {
RowEntry::Entry(index_path) => *index_path,
RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix),
RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix),
}
}
#[inline]
#[allow(unused)]
pub(crate) fn is_section_footer(&self) -> bool {
matches!(self, RowEntry::SectionFooter(_))
}
#[inline]
pub(crate) fn is_entry(&self) -> bool {
matches!(self, RowEntry::Entry(_))
}
#[inline]
#[allow(unused)]
pub(crate) fn section_ix(&self) -> Option<usize> {
match self {
RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix),
_ => None,
}
}
}
#[derive(Default, Clone)]
pub(crate) struct RowsCache {
/// Only have section's that have rows.
pub(crate) entities: Rc<Vec<RowEntry>>,
pub(crate) items_count: usize,
/// The sections, the item is number of rows in each section.
pub(crate) sections: Rc<Vec<usize>>,
pub(crate) entries_sizes: Rc<Vec<Size<Pixels>>>,
measured_size: MeasuredEntrySize,
}
impl RowsCache {
pub(crate) fn get(&self, flatten_ix: usize) -> Option<RowEntry> {
self.entities.get(flatten_ix).cloned()
}
/// Returns the number of flattened rows (Includes header, item, footer).
pub(crate) fn len(&self) -> usize {
self.entities.len()
}
/// Return the number of items in the cache.
pub(crate) fn items_count(&self) -> usize {
self.items_count
}
/// Returns the index of the Entry with given path in the flattened rows.
pub(crate) fn position_of(&self, path: &IndexPath) -> Option<usize> {
self.entities
.iter()
.position(|p| p.is_entry() && p.eq_index_path(path))
}
/// Return prev row, if the row is the first in the first section, goes to the last row.
///
/// Empty rows section are skipped.
pub(crate) fn prev(&self, path: Option<IndexPath>) -> IndexPath {
let path = path.unwrap_or_default();
let Some(pos) = self.position_of(&path) else {
return self
.entities
.iter()
.rfind(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default();
};
if let Some(path) = self
.entities
.iter()
.take(pos)
.rev()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
{
path
} else {
self.entities
.iter()
.rfind(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default()
}
}
/// Returns the next row, if the row is the last in the last section, goes to the first row.
///
/// Empty rows section are skipped.
pub(crate) fn next(&self, path: Option<IndexPath>) -> IndexPath {
let Some(mut path) = path else {
return IndexPath::default();
};
let Some(pos) = self.position_of(&path) else {
return self
.entities
.iter()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default();
};
if let Some(next_path) = self
.entities
.iter()
.skip(pos + 1)
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
{
path = next_path;
} else {
path = self
.entities
.iter()
.find(|entry| entry.is_entry())
.map(|entry| entry.index())
.unwrap_or_default()
}
path
}
pub(crate) fn prepare_if_needed<F>(
&mut self,
sections_count: usize,
measured_size: MeasuredEntrySize,
cx: &App,
rows_count_f: F,
) where
F: Fn(usize, &App) -> usize,
{
let mut new_sections = vec![];
for section_ix in 0..sections_count {
new_sections.push(rows_count_f(section_ix, cx));
}
let need_update = new_sections != *self.sections || self.measured_size != measured_size;
if !need_update {
return;
}
let mut entries_sizes = vec![];
let mut total_items_count = 0;
self.measured_size = measured_size;
self.sections = Rc::new(new_sections);
self.entities = Rc::new(
self.sections
.iter()
.enumerate()
.flat_map(|(section, items_count)| {
total_items_count += items_count;
let mut children = vec![];
if *items_count == 0 {
return children;
}
children.push(RowEntry::SectionHeader(section));
entries_sizes.push(measured_size.section_header_size);
for row in 0..*items_count {
children.push(RowEntry::Entry(IndexPath {
section,
row,
..Default::default()
}));
entries_sizes.push(measured_size.item_size);
}
children.push(RowEntry::SectionFooter(section));
entries_sizes.push(measured_size.section_footer_size);
children
})
.collect(),
);
self.entries_sizes = Rc::new(entries_sizes);
self.items_count = total_items_count;
}
}

View File

@@ -0,0 +1,171 @@
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window};
use theme::ActiveTheme;
use crate::list::loading::Loading;
use crate::list::ListState;
use crate::{h_flex, Icon, IconName, IndexPath, Selectable};
/// A delegate for the List.
#[allow(unused)]
pub trait ListDelegate: Sized + 'static {
type Item: Selectable + IntoElement;
/// When Query Input change, this method will be called.
/// You can perform search here.
fn perform_search(
&mut self,
query: &str,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Task<()> {
Task::ready(())
}
/// Return the number of sections in the list, default is 1.
///
/// Min value is 1.
fn sections_count(&self, cx: &App) -> usize {
1
}
/// Return the number of items in the section at the given index.
///
/// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items,
/// the section header and footer will also be skipped.
fn items_count(&self, section: usize, cx: &App) -> usize;
/// Render the item at the given index.
///
/// Return None will skip the item.
///
/// NOTE: Every item should have same height.
fn render_item(
&mut self,
ix: IndexPath,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<Self::Item>;
/// Render the section header at the given index, default is None.
///
/// NOTE: Every header should have same height.
fn render_section_header(
&mut self,
section: usize,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// Render the section footer at the given index, default is None.
///
/// NOTE: Every footer should have same height.
fn render_section_footer(
&mut self,
section: usize,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// Return a Element to show when list is empty.
fn render_empty(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_color(cx.theme().text_muted.opacity(0.6))
.child(Icon::new(IconName::Inbox).size_12())
.into_any_element()
}
/// Returns Some(AnyElement) to render the initial state of the list.
///
/// This can be used to show a view for the list before the user has
/// interacted with it.
///
/// For example: The last search results, or the last selected item.
///
/// Default is None, that means no initial state.
fn render_initial(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<AnyElement> {
None
}
/// Returns the loading state to show the loading view.
fn loading(&self, cx: &App) -> bool {
false
}
/// Returns a Element to show when loading, default is built-in Skeleton
/// loading view.
fn render_loading(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
Loading
}
/// Set the selected index, just store the ix, don't confirm.
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
);
/// Set the index of the item that has been right clicked.
fn set_right_clicked_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
}
/// Set the confirm and give the selected index,
/// this is means user have clicked the item or pressed Enter.
///
/// This will always to `set_selected_index` before confirm.
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
}
/// Cancel the selection, e.g.: Pressed ESC.
fn cancel(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
/// Return true to enable load more data when scrolling to the bottom.
///
/// Default: false
fn has_more(&self, cx: &App) -> bool {
false
}
/// Returns a threshold value (n entities), of course,
/// when scrolling to the bottom, the remaining number of rows
/// triggers `load_more`.
///
/// This should smaller than the total number of first load rows.
///
/// Default: 20 entities (section header, footer and row)
fn load_more_threshold(&self) -> usize {
20
}
/// Load more data when the table is scrolled to the bottom.
///
/// This will performed in a background task.
///
/// This is always called when the table is near the bottom,
/// so you must check if there is more data to load or lock
/// the loading state.
fn load_more(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,57 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton,
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled,
Window,
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable as _};
use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt};
type OnClick = Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>;
type OnMouseEnter = Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>;
type Suffix = Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum ListItemMode {
#[default]
Entry,
Separator,
}
impl ListItemMode {
#[inline]
fn is_separator(&self) -> bool {
matches!(self, ListItemMode::Separator)
}
}
#[derive(IntoElement)]
pub struct ListItem {
base: Stateful<Div>,
mode: ListItemMode,
style: StyleRefinement,
disabled: bool,
selected: bool,
secondary_selected: bool,
confirmed: bool,
check_icon: Option<Icon>,
on_click: OnClick,
on_mouse_enter: OnMouseEnter,
suffix: Suffix,
#[allow(clippy::type_complexity)]
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
#[allow(clippy::type_complexity)]
suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(),
mode: ListItemMode::Entry,
base: h_flex().id(id),
style: StyleRefinement::default(),
disabled: false,
selected: false,
secondary_selected: false,
confirmed: false,
on_click: None,
on_mouse_enter: None,
@@ -43,9 +61,15 @@ impl ListItem {
}
}
/// Set this list item to as a separator, it not able to be selected.
pub fn separator(mut self) -> Self {
self.mode = ListItemMode::Separator;
self
}
/// Set to show check icon, default is None.
pub fn check_icon(mut self, icon: IconName) -> Self {
self.check_icon = Some(Icon::new(icon));
pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
self.check_icon = Some(icon.into());
self
}
@@ -111,11 +135,16 @@ impl Selectable for ListItem {
fn is_selected(&self) -> bool {
self.selected
}
fn secondary_selected(mut self, selected: bool) -> Self {
self.secondary_selected = selected;
self
}
}
impl Styled for ListItem {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
&mut self.style
}
}
@@ -127,35 +156,39 @@ impl ParentElement for ListItem {
impl RenderOnce for ListItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_active = self.selected || self.confirmed;
let is_active = self.confirmed || self.selected;
let corner_radii = self.style.corner_radii.clone();
let _selected_style = StyleRefinement {
corner_radii,
..Default::default()
};
let is_selectable = !(self.disabled || self.mode.is_separator());
self.base
.relative()
.gap_x_1()
.py_1()
.px_3()
.text_base()
.text_color(cx.theme().text)
.relative()
.items_center()
.justify_between()
.when_some(self.on_click, |this, on_click| {
if !self.disabled {
this.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, _window, cx| {
cx.stop_propagation();
})
.on_click(on_click)
} else {
this
}
.refine_style(&self.style)
.when(is_selectable, |this| {
this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
})
.when(!is_active, |this| {
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
})
.when(is_active, |this| this.bg(cx.theme().element_active))
.when(!is_active && !self.disabled, |this| {
this.hover(|this| this.bg(cx.theme().elevated_surface_background))
})
// Mouse enter
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
if !self.disabled {
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
} else {
this
}
.when(!is_selectable, |this| {
this.text_color(cx.theme().text_muted)
})
.child(
h_flex()
@@ -177,5 +210,17 @@ impl RenderOnce for ListItem {
}),
)
.when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
.map(|this| {
if is_selectable && (self.selected || self.secondary_selected) {
let bg = if self.selected {
cx.theme().ghost_element_active
} else {
cx.theme().ghost_element_background
};
this.bg(bg)
} else {
this
}
})
}
}

View File

@@ -17,7 +17,7 @@ impl RenderOnce for LoadingItem {
.gap_1p5()
.overflow_hidden()
.child(Skeleton::new().h_5().w_48().max_w_full())
.child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()),
.child(Skeleton::new().secondary().h_3().w_64().max_w_full()),
)
}
}

View File

@@ -1,7 +1,28 @@
pub(crate) mod cache;
mod delegate;
#[allow(clippy::module_inception)]
mod list;
mod list_item;
mod loading;
mod separator_item;
pub use delegate::*;
pub use list::*;
pub use list_item::*;
pub use separator_item::*;
use serde::{Deserialize, Serialize};
/// Settings for List.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListSettings {
/// Whether to use active highlight style on ListItem, default
pub active_highlight: bool,
}
impl Default for ListSettings {
fn default() -> Self {
Self {
active_highlight: true,
}
}
}

View File

@@ -0,0 +1,50 @@
use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement};
use smallvec::SmallVec;
use crate::list::ListItem;
use crate::{Selectable, StyledExt};
pub struct ListSeparatorItem {
style: StyleRefinement,
children: SmallVec<[AnyElement; 2]>,
}
impl ListSeparatorItem {
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
children: SmallVec::new(),
}
}
}
impl Default for ListSeparatorItem {
fn default() -> Self {
Self::new()
}
}
impl ParentElement for ListSeparatorItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Selectable for ListSeparatorItem {
fn selected(self, _: bool) -> Self {
self
}
fn is_selected(&self) -> bool {
false
}
}
impl RenderOnce for ListSeparatorItem {
fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement {
ListItem::new("separator")
.refine_style(&self.style)
.children(self.children)
.disabled(true)
}
}