feat: redesign the sidebar and workspace (#53)

Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
2026-09-25 02:26:31 +00:00
parent 1032ac3751
commit e4e13b0889
38 changed files with 2378 additions and 2484 deletions
+4
View File
@@ -19,5 +19,9 @@ log.workspace = true
uuid = "1.10"
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+1 -1
View File
@@ -27,7 +27,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
Size::Large => px(64.).into(),
Size::Medium => px(32.).into(),
Size::Small => px(24.).into(),
Size::XSmall => px(20.).into(),
Size::XSmall => px(18.).into(),
Size::Size(size) => size.into(),
}
}
+38 -200
View File
@@ -5,22 +5,21 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle,
SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _,
WeakEntity, Window, actions, div, px, rems,
WeakEntity, Window, actions, div, px,
};
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
use gpui_base::dock::{
DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode,
PaneRef, PanelId, TabGroupContext, TabGroupRenderer, TileContext, TilesRenderer,
PaneRef, PanelId, TabGroupContext, TabGroupRenderer,
};
use gpui_base::{Placement, ResizeHandleContext, Side};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use crate::button::{Button, ButtonVariants as _};
use crate::menu::DropdownMenu as _;
use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab;
use crate::tab::tab_bar::TabBar;
@@ -95,6 +94,19 @@ pub fn add_panel(
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
}
/// Add a panel to a dock reached through a weak handle.
pub fn add_panel_to(
dock: &WeakEntity<DockArea>,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut App,
) {
let _ = dock.update(cx, |area, cx| {
add_panel(area, panel, placement, window, cx);
});
}
/// The panel in any region of `area` whose logical id is `key`.
fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId, NodeId, usize)> {
let placements = [
@@ -167,7 +179,6 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
PaneRef::Tiles { .. } => None,
}
}
@@ -182,7 +193,6 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
};
child.and_then(right_top_group)
}
PaneRef::Tiles { .. } => None,
}
}
@@ -263,18 +273,6 @@ impl DockAreaRenderer for DockSkin {
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(TabGroupSkin::new(self.shared.clone()))
}
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
Rc::new(NoTiles)
}
}
struct NoTiles;
impl TilesRenderer for NoTiles {
fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
Empty.into_any_element()
}
}
/// The payload a dock's resize handle drags; the handle is the affordance.
@@ -430,9 +428,6 @@ impl TabGroupSkin {
}
/// Whether this group is the left dock's root with a single panel.
///
/// Such a group draws no tab bar, so its panel owns the window's top-left
/// corner — including the space the macOS traffic lights overlay.
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
@@ -445,9 +440,6 @@ impl TabGroupSkin {
&& group.panels().len() == 1
}
/// Whether this group is the topmost-left group on screen, which sits under
/// the native macOS traffic lights. The left dock's group is leftmost while
/// it is open and holds a panel; the center's is leftmost otherwise.
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
@@ -465,172 +457,6 @@ impl TabGroupSkin {
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
}
fn render_toolbar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let zoomed = group.is_zoomed();
let closable = group.is_closable();
let zoomable = group.active_panel().is_some_and(|panel| panel.zoomable(cx));
let zoom_label = if zoomed { "Zoom Out" } else { "Zoom In" };
let buttons = group
.active_panel()
.and_then(PanelHandle::of)
.map(|handle| handle.panel().toolbar_buttons(window, cx))
.unwrap_or_default();
let menu_panel = group
.active_panel()
.and_then(PanelHandle::of)
.map(|handle| handle.panel().clone());
h_flex()
.p_0p5()
.gap_1p5()
.occlude()
.rounded_full()
.children(buttons.into_iter().map(|button| button.small().ghost()))
.when(zoomed, |this| {
this.child(
Button::new("zoom")
.icon(IconName::Zoom)
.small()
.ghost()
.tooltip("Zoom Out")
.on_click({
let group = TabGroupContext::clone(group);
move |_, window, cx| group.toggle_zoom(window, cx)
}),
)
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.small()
.ghost()
.dropdown_menu({
move |menu, _, cx| {
let menu = match menu_panel.clone() {
Some(panel) => panel.popup_menu(menu, cx),
None => menu,
};
menu.when(zoomable, |this| {
this.separator().menu(zoom_label, Box::new(ToggleZoom))
})
.when(closable, |this| {
this.separator().menu("Close", Box::new(ClosePanel))
})
}
})
.anchor(Anchor::TopRight),
)
}
fn render_title(
&self,
group: &TabGroupContext,
ix: usize,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let panel = group.panels()[ix].clone();
let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some();
let drag = tab_drag(group, ix, cx);
let is_title_bar = self.is_title_bar_group(group, cx);
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let bar = h_flex()
.id("tab-title-bar")
.justify_between()
.items_center()
.line_height(rems(1.0))
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.when(left_button.is_some(), |this| this.pl_2())
.when(right_button.is_some(), |this| this.pr_2())
.when(has_leading, |this| {
this.child(
h_flex()
.flex_shrink_0()
.mr_1()
.gap_1()
.children(left_button)
.children(bottom_button),
)
})
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
.child(
div()
.id("tab")
.flex_initial()
.min_w_0()
.px_2()
.overflow_hidden()
.whitespace_nowrap()
.child(
div()
.w_full()
.text_ellipsis()
.text_sm()
.child(panel_title(&panel, cx)),
)
.when_some(drag, |this, drag| {
this.on_drag(drag, {
let panel = panel.clone();
move |drag, offset, _, cx| {
cx.stop_propagation();
drag.set_drag_offset(offset);
cx.new(|_| DragPreview {
panel: panel.clone(),
})
}
})
}),
)
.child({
let space = div().id("tab-title-space").flex_1().h_full();
if is_title_bar {
title_bar_drag_handlers(space, window, cx).into_any_element()
} else {
space.into_any_element()
}
})
.child(
h_flex()
.flex_shrink_0()
.ml_1()
.gap_1()
.child(self.render_toolbar(group, window, cx))
.children(right_button),
)
.when_some(trailing_chrome, |this, chrome| this.child(chrome));
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
}
fn render_tabs(
&self,
group: &TabGroupContext,
@@ -642,9 +468,12 @@ impl TabGroupSkin {
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some();
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let tabs_count = group.panels().len();
let is_title_bar = self.is_title_bar_group(group, cx);
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let displayed_ix = displayed.and_then(|displayed| {
group
@@ -652,12 +481,14 @@ impl TabGroupSkin {
.iter()
.position(|panel| panel.panel_id(cx) == displayed)
});
let is_title_bar = self.is_title_bar_group(group, cx);
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let empty_space = div()
.id("tab-bar-empty-space")
.h_full()
@@ -673,6 +504,7 @@ impl TabGroupSkin {
}
})
});
let empty_space = if is_title_bar {
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
} else {
@@ -689,7 +521,9 @@ impl TabGroupSkin {
.when(is_title_bar || has_leading, |this| {
this.prefix(
h_flex()
.items_center()
.size(TABBAR_HEIGHT)
.flex_shrink_0()
.justify_center()
.top_0()
.right(-px(1.))
.pl_0p5()
@@ -767,13 +601,13 @@ impl TabGroupSkin {
.when(!collapsed, |this| {
this.suffix(
h_flex()
.flex_shrink_0()
.items_center()
.top_0()
.right_0()
.h_full()
.px_0p5()
.gap_1()
.child(self.render_toolbar(group, window, cx))
.children(right_button)
.children(trailing_chrome),
)
@@ -846,7 +680,13 @@ impl TabGroupSkin {
.small()
.ghost()
.tab_stop(false)
.tooltip(if is_open { "Collapse" } else { "Expand" })
.map(|this| {
if is_open {
this.tooltip("Collapse")
} else {
this.tooltip("Expand")
}
})
.on_click(move |_, window, cx| {
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
}),
@@ -901,13 +741,11 @@ impl TabGroupRenderer for TabGroupSkin {
self.scroll_handle.scroll_to_item(visible_ix);
}
match visible.as_slice() {
[] => Empty.into_any_element(),
// One panel in a group that is not asking for tabs gets the title
// instead of a tab bar.
[ix] => self.render_title(group, *ix, window, cx),
_ => self.render_tabs(group, visible.as_slice(), window, cx),
if visible.is_empty() {
return Empty.into_any_element();
}
self.render_tabs(group, visible.as_slice(), window, cx)
}
fn render_active_panel(
-3
View File
@@ -19,9 +19,6 @@ pub enum PanelEvent {
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
///
/// This is used to identify the panel when deserializing the panel.
/// Once you have defined a panel id, this must not be changed.
fn panel_id(&self) -> SharedString;
/// The title of the panel
+4
View File
@@ -22,6 +22,7 @@ impl<T: IconNamed> From<T> for Icon {
pub enum IconName {
ArrowLeft,
ArrowRight,
ArrowDownCircle,
Boom,
Book,
ChevronDown,
@@ -66,6 +67,7 @@ pub enum IconName {
Ship,
Shield,
Group,
Hashtag,
History,
UserKey,
Upload,
@@ -99,6 +101,7 @@ impl IconNamed for IconName {
match self {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
Self::Boom => "icons/boom.svg",
Self::Book => "icons/book.svg",
Self::ChevronDown => "icons/chevron-down.svg",
@@ -146,6 +149,7 @@ impl IconNamed for IconName {
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::Hashtag => "icons/hashtag.svg",
Self::History => "icons/history.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
+3
View File
@@ -16,8 +16,11 @@ pub mod dock;
pub mod group_box;
pub mod indicator;
pub mod input;
pub mod markdown;
pub mod menu;
pub mod message;
pub mod modal;
pub mod nav;
pub mod nav_item;
pub mod notification;
pub mod popover;
+491
View File
@@ -0,0 +1,491 @@
use std::ops::Range;
use std::sync::{Arc, LazyLock};
use gpui::{
AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
};
use regex::Regex;
use theme::ActiveTheme;
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
/// A span of the source content, by byte range, that renders as `text` instead.
///
/// Replacements are resolved before rendering and styled as an accent-colored
/// link. Mentions are one use: callers resolve the public key to a display name
/// and hand the renderer the span to substitute. They must be sorted by
/// `range.start` and must not overlap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineReplacement {
pub range: Range<usize>,
pub text: SharedString,
}
impl InlineReplacement {
pub fn new(range: Range<usize>, text: impl Into<SharedString>) -> Self {
Self {
range,
text: text.into(),
}
}
}
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Code,
InlineCode(bool),
Replacement,
Style(HighlightStyle),
}
impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Style(style)
}
}
/// Message content flattened into a single styled string.
///
/// Markdown structure (headings, lists, code blocks, emphasis, links) is turned
/// into plain text plus highlight ranges, ready to hand to an `InteractiveText`.
#[derive(Default)]
pub struct RenderedText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>,
pub link_urls: Arc<[String]>,
}
impl RenderedText {
/// Parse `content`, optionally as markdown, replacing `replacements` inline.
pub fn new(content: &str, replacements: &[InlineReplacement], markdown: bool) -> Self {
let mut text = String::new();
let mut highlights = Vec::new();
let mut link_ranges = Vec::new();
let mut link_urls = Vec::new();
render_text_mut(
content,
replacements,
&mut text,
&mut highlights,
&mut link_ranges,
&mut link_urls,
markdown,
);
// Trim trailing whitespace and adjust highlight and link ranges.
let trimmed_len = text.trim_end().len();
// Retain highlights and link ranges that are within the trimmed text.
if trimmed_len < text.len() {
highlights.retain_mut(|(range, _)| {
range.end = range.end.min(trimmed_len);
range.start < range.end
});
let mut ix = 0;
while ix < link_ranges.len() {
let range = &mut link_ranges[ix];
range.end = range.end.min(trimmed_len);
if range.start < range.end {
ix += 1;
} else {
link_ranges.remove(ix);
link_urls.remove(ix);
}
}
text.truncate(trimmed_len);
}
RenderedText {
text: SharedString::from(text),
link_urls: link_urls.into(),
link_ranges,
highlights,
}
}
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
let code_background = cx.theme().elevated_surface_background;
let color = cx.theme().text_accent;
let code_font = if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"monospace"
};
InteractiveText::new(
id,
StyledText::new(self.text.clone())
.with_default_highlights(
&window.text_style(),
self.highlights.iter().map(|(range, highlight)| {
(
range.clone(),
match highlight {
Highlight::Code => HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
Highlight::InlineCode(link) => {
if *link {
HighlightStyle {
background_color: Some(code_background),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}
} else {
HighlightStyle {
background_color: Some(code_background),
..Default::default()
}
}
}
Highlight::Replacement => HighlightStyle {
color: Some(color),
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
},
Highlight::Style(highlight) => *highlight,
},
)
}),
)
.with_font_family_overrides(self.highlights.iter().filter_map(
|(range, highlight)| match highlight {
Highlight::Code | Highlight::InlineCode(_) => {
Some((range.clone(), code_font.into()))
}
_ => None,
},
)),
)
.on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone();
move |ix, _, cx| {
let url = &link_urls[ix];
if WEB_URL.is_match(url) {
cx.open_url(url);
}
}
})
.into_any_element()
}
}
#[allow(clippy::too_many_arguments)]
fn render_text_mut(
block: &str,
replacements: &[InlineReplacement],
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
markdown: bool,
) {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let mut bold_depth = 0;
let mut italic_depth = 0;
let mut strikethrough_depth = 0;
let mut link_url = None;
let mut list_stack = Vec::new();
let mut code_block = false;
// Only enable the extensions that make sense for chat messages. Notably this leaves
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
} else {
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
};
for (event, source_range) in events {
let prev_len = text.len();
match event {
Event::Text(t) => {
if code_block {
text.push_str(t.as_ref());
highlights.push((prev_len..text.len(), Highlight::Code));
continue;
}
let t_str = t.as_ref();
let mut last_processed = 0;
for replacement in replacements {
if replacement.range.start >= source_range.end {
break;
}
if replacement.range.start < source_range.start
|| replacement.range.end > source_range.end
{
continue;
}
let Some(token) = block.get(replacement.range.clone()) else {
continue;
};
let Some(offset) = t_str[last_processed..].find(token) else {
continue;
};
let replacement_start_in_text = last_processed + offset;
let replacement_end_in_text = replacement_start_in_text + token.len();
// Add text before this replacement
if replacement_start_in_text > last_processed {
let before_replacement = &t_str[last_processed..replacement_start_in_text];
process_text_segment(
before_replacement,
bold_depth,
italic_depth,
strikethrough_depth,
link_url.clone(),
text,
highlights,
link_ranges,
link_urls,
);
}
// Process the replacement
let replacement_start = text.len();
text.push_str(&replacement.text);
let replacement_end = text.len();
highlights.push((replacement_start..replacement_end, Highlight::Replacement));
last_processed = replacement_end_in_text;
}
// Add any remaining text after the last replacement
if last_processed < t_str.len() {
let remaining_text = &t_str[last_processed..];
process_text_segment(
remaining_text,
bold_depth,
italic_depth,
strikethrough_depth,
link_url.clone(),
text,
highlights,
link_ranges,
link_urls,
);
}
}
Event::Code(t) => {
text.push_str(t.as_ref());
let is_link = link_url.is_some();
if let Some(link_url) = link_url.clone() {
link_ranges.push(prev_len..text.len());
link_urls.push(link_url);
}
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
}
Event::Start(tag) => match tag {
Tag::Paragraph => new_paragraph(text, &mut list_stack),
Tag::Heading { .. } => {
new_paragraph(text, &mut list_stack);
bold_depth += 1;
}
Tag::CodeBlock(_kind) => {
new_paragraph(text, &mut list_stack);
code_block = true;
}
Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1,
Tag::Strikethrough => strikethrough_depth += 1,
Tag::Link { dest_url, .. } => {
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
}
Tag::List(number) => {
list_stack.push((number, false));
}
Tag::Item => {
let len = list_stack.len();
if let Some((list_number, has_content)) = list_stack.last_mut() {
*has_content = false;
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
for _ in 0..len - 1 {
text.push_str(" ");
}
if let Some(number) = list_number {
text.push_str(&format!("{}. ", number));
*number += 1;
*has_content = false;
} else {
text.push_str("- ");
}
}
}
_ => {}
},
Event::End(tag) => match tag {
TagEnd::CodeBlock => code_block = false,
TagEnd::Heading(_) => bold_depth -= 1,
TagEnd::Emphasis => italic_depth -= 1,
TagEnd::Strong => bold_depth -= 1,
TagEnd::Strikethrough => strikethrough_depth -= 1,
TagEnd::Link => link_url = None,
TagEnd::List(_) => drop(list_stack.pop()),
_ => {}
},
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
Event::Rule => {
new_paragraph(text, &mut list_stack);
text.push_str("────────\n");
}
Event::HardBreak => text.push('\n'),
Event::SoftBreak => text.push('\n'),
_ => {}
}
}
}
#[allow(clippy::too_many_arguments)]
fn process_text_segment(
segment: &str,
bold_depth: i32,
italic_depth: i32,
strikethrough_depth: i32,
link_url: Option<String>,
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
// Build the style for this segment
let mut style = HighlightStyle::default();
if bold_depth > 0 {
style.font_weight = Some(FontWeight::BOLD);
}
if italic_depth > 0 {
style.font_style = Some(FontStyle::Italic);
}
if strikethrough_depth > 0 {
style.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
});
}
// Ranges always refer to the rendered text, including replaced spans.
let segment_start = text.len();
text.push_str(segment);
let text_end = text.len();
if let Some(link_url) = link_url {
// Handle as a markdown link
link_ranges.push(segment_start..text_end);
link_urls.push(link_url);
style.underline = Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
});
// Add highlight for the entire linked segment
if style != HighlightStyle::default() {
highlights.push((segment_start..text_end, Highlight::Style(style)));
}
} else {
// Handle link detection within the segment
let mut finder = linkify::LinkFinder::new();
finder.kinds(&[linkify::LinkKind::Url]);
let mut last_link_pos = 0;
for link in finder
.links(segment)
.filter(|link| WEB_URL.is_match(link.as_str()))
{
let start = link.start();
let end = link.end();
// Add non-link text before this link
if start > last_link_pos {
let non_link_start = segment_start + last_link_pos;
let non_link_end = segment_start + start;
if style != HighlightStyle::default() {
highlights.push((non_link_start..non_link_end, Highlight::Style(style)));
}
}
// Add the link
let range = (segment_start + start)..(segment_start + end);
link_ranges.push(range.clone());
link_urls.push(link.as_str().to_string());
// Apply link styling (underline + existing style)
let mut link_style = style;
link_style.underline = Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
});
highlights.push((range, Highlight::Style(link_style)));
last_link_pos = end;
}
// Add any remaining text after the last link
if last_link_pos < segment.len() {
let remaining_start = segment_start + last_link_pos;
let remaining_end = segment_start + segment.len();
if style != HighlightStyle::default() {
highlights.push((remaining_start..remaining_end, Highlight::Style(style)));
}
}
}
}
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content {
is_subsequent_paragraph_of_list = true;
} else {
*has_content = true;
return;
}
}
if !text.is_empty() {
if !text.ends_with('\n') {
text.push('\n');
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
}
+325
View File
@@ -0,0 +1,325 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
MouseDownEvent, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window,
div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
type DoubleClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
/// A single message row in a chat-like list.
#[derive(IntoElement)]
pub struct MessageRow {
id: ElementId,
style: StyleRefinement,
show_author: bool,
hide_avatar: bool,
avatar: Option<AnyElement>,
author: Option<SharedString>,
timestamp: Option<SharedString>,
header_extras: SmallVec<[AnyElement; 1]>,
body: SmallVec<[AnyElement; 4]>,
overlays: SmallVec<[AnyElement; 2]>,
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
on_double_click: Option<DoubleClickListener>,
}
impl MessageRow {
/// Create a message row with the given element id.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
show_author: false,
hide_avatar: false,
avatar: None,
author: None,
timestamp: None,
header_extras: SmallVec::new(),
body: SmallVec::new(),
overlays: SmallVec::new(),
on_mouse_down: None,
on_double_click: None,
}
}
/// Whether this row opens a run of messages from one author.
#[must_use]
pub fn show_author(mut self, show_author: bool) -> Self {
self.show_author = show_author;
self
}
/// Hide the avatar column entirely.
#[must_use]
pub fn hide_avatar(mut self, hide_avatar: bool) -> Self {
self.hide_avatar = hide_avatar;
self
}
/// The avatar shown when this row opens a run of messages.
#[must_use]
pub fn avatar(mut self, avatar: impl IntoElement) -> Self {
self.avatar = Some(avatar.into_any_element());
self
}
/// The author's display name, shown next to the timestamp.
#[must_use]
pub fn author(mut self, author: impl Into<SharedString>) -> Self {
self.author = Some(author.into());
self
}
/// The time the message was sent.
#[must_use]
pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
self.timestamp = Some(timestamp.into());
self
}
/// Append an element to the header row.
#[must_use]
pub fn header_extra(mut self, extra: impl IntoElement) -> Self {
self.header_extras.push(extra.into_any_element());
self
}
/// Append an element to the message body, in order.
#[must_use]
pub fn child(mut self, child: impl IntoElement) -> Self {
self.body.push(child.into_any_element());
self
}
/// Append several elements to the message body, in order.
#[must_use]
pub fn children(mut self, children: impl IntoIterator<Item = impl IntoElement>) -> Self {
self.body
.extend(children.into_iter().map(|child| child.into_any_element()));
self
}
/// Append an element positioned over the row.
#[must_use]
pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
self.overlays.push(overlay.into_any_element());
self
}
/// Handle a mouse button press anywhere on the row.
#[must_use]
pub fn on_mouse_down(
mut self,
button: MouseButton,
listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_mouse_down = Some((button, Box::new(listener)));
self
}
/// Handle a double click anywhere on the row.
#[must_use]
pub fn on_double_click(
mut self,
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_double_click = Some(Box::new(listener));
self
}
}
impl Styled for MessageRow {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for MessageRow {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
id,
style,
show_author,
hide_avatar,
avatar,
author,
timestamp,
header_extras,
body,
overlays,
on_mouse_down,
on_double_click,
} = self;
div()
.id(id)
.group("")
.relative()
.w_full()
.py_1()
.when(show_author, |this| this.pt_2())
.px_3()
.refine_style(&style)
.child(
h_flex()
.items_start()
.gap_3()
.when(!hide_avatar, |this| {
if show_author {
match avatar {
Some(avatar) => this.child(avatar),
None => this.child(div().flex_shrink_0().w(px(32.))),
}
} else {
this.child(div().flex_shrink_0().w(px(32.)))
}
})
.child(
v_flex()
.flex_1()
.w_full()
.min_w_0()
.flex_initial()
.overflow_hidden()
.when(show_author, |this| {
this.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.when_some(author, |this, author| {
this.child(div().font_semibold().child(author))
})
.when_some(timestamp, |this, timestamp| {
this.child(timestamp)
})
.children(header_extras),
)
})
.children(body),
),
)
.children(overlays)
.when_some(on_mouse_down, |this, (button, listener)| {
this.on_mouse_down(button, listener)
})
.when_some(on_double_click, |this, listener| {
this.on_double_click(listener)
})
.hover(|this| this.bg(cx.theme().surface_background))
.into_any_element()
}
}
/// A welcome message shown at the top of a message list.
#[derive(IntoElement)]
pub struct WelcomeMessage {
id: ElementId,
style: StyleRefinement,
icon: Option<AnyElement>,
title: Option<SharedString>,
message: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>,
}
impl WelcomeMessage {
/// Create a welcome message with the given element id.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
icon: None,
title: None,
message: None,
children: SmallVec::new(),
}
}
/// The icon shown above the title.
#[must_use]
pub fn icon(mut self, icon: impl IntoElement) -> Self {
self.icon = Some(icon.into_any_element());
self
}
/// The welcome title.
#[must_use]
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// The welcome body text.
#[must_use]
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into());
self
}
/// Append an element below the body text.
#[must_use]
pub fn child(mut self, child: impl IntoElement) -> Self {
self.children.push(child.into_any_element());
self
}
}
impl Styled for WelcomeMessage {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for WelcomeMessage {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
id,
style,
icon,
title,
message,
children,
} = self;
v_flex()
.id(id)
.w_full()
.gap_2()
.p_3()
.items_center()
.justify_center()
.text_center()
.refine_style(&style)
.when_some(icon, |this, icon| this.child(icon))
.child(
v_flex()
.items_center()
.justify_center()
.text_center()
.when_some(title, |this, title| {
this.child(
div()
.text_sm()
.font_semibold()
.text_color(cx.theme().text)
.child(title),
)
})
.when_some(message, |this, message| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(message),
)
}),
)
.children(children)
}
}
+123
View File
@@ -0,0 +1,123 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
div,
};
use theme::ActiveTheme;
use crate::{Selectable, StyledExt, h_flex, v_flex};
/// A single navigation row, such as an entry in a sidebar list.
#[derive(IntoElement)]
pub struct Nav {
id: ElementId,
style: StyleRefinement,
prefix: Option<AnyElement>,
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl Nav {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
prefix: None,
label: SharedString::default(),
suffix: None,
selected: false,
on_click: None,
}
}
/// Sets the element shown before the label, such as an avatar or icon.
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Sets the row's label.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
/// Sets the element shown after the label, such as a timestamp or badge.
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Selectable for Nav {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl Styled for Nav {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some();
v_flex()
.id(format!("nav-{}", self.id))
.w_full()
.h_10()
.child(
h_flex()
.id(self.id)
.h_9()
.w_full()
.px_1p5()
.gap_1p5()
.rounded(cx.theme().radius_lg)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.gap_1()
.flex_1()
.child(div().truncate().min_w_0().child(self.label))
.child(div().flex_1())
.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().ghost_element_hover))
.when(self.selected, |this| {
this.bg(cx.theme().ghost_element_active)
})
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
.refine_style(&self.style),
)
}
}
+91 -54
View File
@@ -3,9 +3,9 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, relative,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use theme::ActiveTheme;
use crate::{Icon, IconName, Selectable, h_flex};
@@ -25,6 +25,7 @@ pub struct Tab {
children: Vec<AnyElement>,
pub(super) disabled: bool,
pub(super) selected: bool,
pub(super) segmented: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
@@ -69,6 +70,7 @@ impl Default for Tab {
children: Vec::new(),
disabled: false,
selected: false,
segmented: false,
prefix: None,
suffix: None,
on_click: None,
@@ -132,6 +134,12 @@ impl Tab {
self.tab_bar_prefix = Some(tab_bar_prefix);
self
}
/// Render the tab as a segment inside a segmented control.
pub(crate) fn segmented(mut self, segmented: bool) -> Self {
self.segmented = segmented;
self
}
}
impl ParentElement for Tab {
@@ -167,74 +175,103 @@ impl Styled for Tab {
impl RenderOnce for Tab {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let fg = if self.disabled {
let Self {
ix,
base,
label,
icon,
prefix,
suffix,
children,
disabled,
selected,
segmented,
on_click,
..
} = self;
let foreground = if disabled {
cx.theme().text_muted
} else if self.selected {
} else if selected {
cx.theme().tab_active_foreground
} else {
cx.theme().tab_foreground
};
self.base
.id(self.ix)
.flex()
.flex_wrap()
.gap_1()
let content = h_flex()
.flex_1()
.h_6()
.whitespace_nowrap()
.items_center()
.flex_shrink_0()
.h(TABBAR_HEIGHT)
.relative()
.overflow_hidden()
.text_color(fg)
.text_sm()
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.when(segmented, |this| this.justify_center().px_1())
.when(!segmented, |this| this.justify_start())
.map(|this| match icon {
Some(icon) => this.w(px(38.)).child(icon.size_4()),
None => this
.map(|this| match label {
Some(label) => this.child(label),
None => this,
})
.children(children),
});
base.id(ix)
.group("tab")
.flex()
.items_center()
.text_color(foreground)
.when(segmented, |this| {
this.text_xs()
.flex_1()
.h(px(30.))
.line_height(relative(1.))
.whitespace_nowrap()
.items_center()
.justify_center()
.h_6()
.rounded(cx.theme().radius)
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_active_background)
.when(cx.theme().shadow, |this| this.shadow_sm())
})
.when(!selected && !disabled, |this| {
this.hover(|this| this.bg(cx.theme().tab_hover_background))
})
})
.when(!segmented, |this| {
this.flex_shrink_0()
.min_w_32()
.h_7()
.gap_1()
.px_1p5()
.text_sm()
.rounded(cx.theme().radius)
.overflow_hidden()
.flex_shrink_0()
.px_3()
.map(|this| match self.icon {
Some(icon) => this.w(px(38.)).child(icon.size_4()),
None => this
.map(|this| match self.label {
Some(label) => this.child(label),
None => this,
})
.children(self.children),
}),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().pr_2().child(suffix))
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_background)
})
.when(!selected && !disabled, |this| {
this.hover(|this| {
this.bg(cx.theme().tab_hover_background)
.text_color(cx.theme().tab_active_foreground)
})
})
})
.when_some(prefix, |this, prefix| this.child(prefix))
.child(content)
.when_some(suffix, |this, suffix| {
this.child(
div()
.flex_shrink_0()
.when(!selected, |this| {
this.invisible().group_hover("tab", |this| this.visible())
})
.child(suffix),
)
})
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
cx.stop_propagation();
})
.when(!self.disabled, |this| {
this.when_some(self.on_click.clone(), |this, on_click| {
.when(!disabled, |this| {
this.when_some(on_click, |this, on_click| {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
.child(
div()
.absolute()
.bottom_0()
.left_0()
.right_0()
.h_0p5()
.when(self.selected && !self.disabled, |this| {
this.bg(cx.theme().element_active)
})
.when(!self.selected && !self.disabled, |this| {
this.invisible().group_hover("", |this| {
this.visible().bg(cx.theme().secondary_background)
})
}),
)
}
}
+24 -3
View File
@@ -7,6 +7,7 @@ use gpui::{
Window, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use super::Tab;
use crate::button::{Button, ButtonVariants as _};
@@ -25,6 +26,7 @@ pub struct TabBar {
last_empty_space: AnyElement,
selected_index: Option<usize>,
menu: bool,
segmented: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
}
@@ -43,9 +45,16 @@ impl TabBar {
selected_index: None,
on_click: None,
menu: false,
segmented: false,
}
}
/// Render the tabs as a segmented control inside a pill-shaped track.
pub fn segmented(mut self, segmented: bool) -> Self {
self.segmented = segmented;
self
}
/// Set whether to show the menu button when tabs overflow, default is false.
pub fn menu(mut self, menu: bool) -> Self {
self.menu = menu;
@@ -113,33 +122,45 @@ impl Styled for TabBar {
}
impl RenderOnce for TabBar {
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let mut item_labels = Vec::new();
let selected_index = self.selected_index;
let on_click = self.on_click.clone();
let segmented = self.segmented;
let has_prefix = self.prefix.is_some();
self.base
.group("tab-bar")
.relative()
.flex()
.items_center()
.min_w_0()
.refine_style(&self.style)
.when(segmented, |this| {
this.bg(cx.theme().tab_background)
.p_0p5()
.rounded(cx.theme().radius)
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.id("tabs")
.flex_1()
.overflow_x_scroll()
.when(!segmented, |this| {
this.overflow_x_scroll()
.when(!has_prefix, |this| this.pl_2())
})
.when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle)
})
.gap(px(0.))
.gap_1()
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
item_labels.push((child.label.clone(), child.disabled));
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
child
.ix(ix)
.tab_bar_prefix(tab_bar_prefix)
.segmented(segmented)
.when_some(self.selected_index, |this, selected_ix| {
this.selected(selected_ix == ix)
})