From 5b2fed0ccbd686cb29f772e1006417e10e456f81 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 13 Oct 2025 22:06:07 +0800 Subject: [PATCH] markdown: Use `list` for Markdown render and add scrollbar. (#1357) --- crates/story/examples/html.rs | 25 +- crates/story/examples/markdown.rs | 24 +- crates/story/src/lib.rs | 15 +- crates/story/src/welcome_story.rs | 16 +- crates/ui/src/input/popovers/hover_popover.rs | 7 +- crates/ui/src/scroll/scrollbar.rs | 49 +- crates/ui/src/table/mod.rs | 6 +- crates/ui/src/text/node.rs | 762 ++++++++++-------- crates/ui/src/text/text_view.rs | 115 ++- 9 files changed, 594 insertions(+), 425 deletions(-) diff --git a/crates/story/examples/html.rs b/crates/story/examples/html.rs index 56c0cbd5..748c733c 100644 --- a/crates/story/examples/html.rs +++ b/crates/story/examples/html.rs @@ -2,7 +2,7 @@ use gpui::*; use gpui_component::{ highlighter::Language, input::{InputState, TabSize, TextInput}, - resizable::{h_resizable, resizable_panel, ResizableState}, + resizable::{ResizableState, h_resizable, resizable_panel}, text::TextView, }; use story::Assets; @@ -69,20 +69,15 @@ impl Render for Example { ) .child( resizable_panel().child( - div() - .id("preview") - .size_full() - .p_5() - .overflow_y_scroll() - .child( - TextView::html( - "preview", - self.input_state.read(cx).value().clone(), - window, - cx, - ) - .selectable(), - ), + TextView::html( + "preview", + self.input_state.read(cx).value().clone(), + window, + cx, + ) + .p_5() + .scrollable() + .selectable(), ), ) } diff --git a/crates/story/examples/markdown.rs b/crates/story/examples/markdown.rs index 8b2cb3eb..160f6fc4 100644 --- a/crates/story/examples/markdown.rs +++ b/crates/story/examples/markdown.rs @@ -98,20 +98,16 @@ impl Render for Example { ) .child( resizable_panel().child( - div() - .id("preview") - .size_full() - .p_5() - .overflow_y_scroll() - .child( - TextView::markdown( - "preview", - self.input_state.read(cx).value().clone(), - window, - cx, - ) - .selectable(), - ), + TextView::markdown( + "preview", + self.input_state.read(cx).value().clone(), + window, + cx, + ) + .flex_none() + .p_5() + .scrollable() + .selectable(), ), ), ) diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 886c11ba..e9deccc6 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -459,6 +459,7 @@ pub struct StoryContainer { story_klass: Option, closable: bool, zoomable: Option, + paddings: Pixels, on_active: Option, } @@ -473,18 +474,27 @@ pub trait Story: Render + Sized { } fn title() -> &'static str; + fn description() -> &'static str { "" } + fn closable() -> bool { true } + fn zoomable() -> Option { Some(PanelControl::default()) } + fn title_bg() -> Option { None } + + fn paddings() -> Pixels { + px(16.) + } + fn new_view(window: &mut Window, cx: &mut App) -> Entity; fn on_active(&mut self, active: bool, window: &mut Window, cx: &mut App) { @@ -492,6 +502,7 @@ pub trait Story: Render + Sized { let _ = window; let _ = cx; } + fn on_active_any(view: AnyView, active: bool, window: &mut Window, cx: &mut App) where Self: 'static, @@ -521,6 +532,7 @@ impl StoryContainer { story_klass: None, closable: true, zoomable: Some(PanelControl::default()), + paddings: px(16.), on_active: None, } } @@ -541,6 +553,7 @@ impl StoryContainer { story.name = name.into(); story.description = description.into(); story.title_bg = S::title_bg(); + story.paddings = S::paddings(); story }); @@ -768,7 +781,7 @@ impl Render for StoryContainer { .id("story-children") .w_full() .flex_1() - .p_4() + .p(self.paddings) .child(story), ) }) diff --git a/crates/story/src/welcome_story.rs b/crates/story/src/welcome_story.rs index a8eaef62..807d98d9 100644 --- a/crates/story/src/welcome_story.rs +++ b/crates/story/src/welcome_story.rs @@ -1,8 +1,8 @@ use gpui::{ - App, AppContext, Context, Entity, FocusHandle, Focusable, ParentElement, Render, Styled, Window, + App, AppContext, Context, Entity, FocusHandle, Focusable, Render, Styled as _, Window, px, }; -use gpui_component::{dock::PanelControl, text::TextView, v_flex}; +use gpui_component::{dock::PanelControl, text::TextView}; use crate::Story; @@ -38,6 +38,10 @@ impl Story for WelcomeStory { fn zoomable() -> Option { None } + + fn paddings() -> gpui::Pixels { + px(0.) + } } impl Focusable for WelcomeStory { @@ -52,9 +56,9 @@ impl Render for WelcomeStory { window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - v_flex().p_4().gap_5().child( - TextView::markdown("intro", include_str!("../../../README.md"), window, cx) - .selectable(), - ) + TextView::markdown("intro", include_str!("../../../README.md"), window, cx) + .p_4() + .scrollable() + .selectable() } } diff --git a/crates/ui/src/input/popovers/hover_popover.rs b/crates/ui/src/input/popovers/hover_popover.rs index 5cf9c872..4a768160 100644 --- a/crates/ui/src/input/popovers/hover_popover.rs +++ b/crates/ui/src/input/popovers/hover_popover.rs @@ -3,7 +3,8 @@ use std::{ops::Range, rc::Rc}; use gpui::{ deferred, div, point, prelude::FluentBuilder as _, px, AnyElement, App, AppContext as _, AvailableSpace, Bounds, Element, ElementId, Entity, InteractiveElement, IntoElement, - MouseDownEvent, ParentElement as _, Pixels, Render, StyleRefinement, Styled, Window, + MouseDownEvent, ParentElement as _, Pixels, Render, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, }; use crate::{ @@ -184,11 +185,13 @@ impl Element for Popover { .end .min(window.bounds().size.width - SNAP_TO_EDGE * 2) .max(px(200.)); + let max_height = (window.bounds().size.height - SNAP_TO_EDGE * 2).min(px(320.)); let is_open = *open_state.read(cx); let mut popover = deferred( div() + .id("hover-popover-content") .when(!is_open, |s| s.invisible()) .flex_none() .occlude() @@ -197,6 +200,8 @@ impl Element for Popover { .popover_style(cx) .shadow_md() .max_w(max_width) + .max_h(max_height) + .overflow_y_scroll() .refine_style(&self.style) .child((self.content_builder)(window, cx)), ) diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index 1459d2be..fb2b6954 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -9,8 +9,9 @@ use crate::{ActiveTheme, AxisExt}; use gpui::{ fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner, CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, - IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Size, Style, Timer, UniformListScrollHandle, Window, + IntoElement, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, + Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Timer, + UniformListScrollHandle, Window, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -35,7 +36,7 @@ impl ScrollbarShow { } /// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) -pub(crate) const WIDTH: Pixels = px(2. * 2. + 8.); +const WIDTH: Pixels = px(2. * 2. + 8.); const MIN_THUMB_SIZE: f32 = 48.; const THUMB_WIDTH: Pixels = px(6.); @@ -52,11 +53,10 @@ const FADE_OUT_DELAY: f32 = 2.0; pub trait ScrollHandleOffsetable { fn offset(&self) -> Point; fn set_offset(&self, offset: Point); - fn is_uniform_list(&self) -> bool { - false - } /// The full size of the content, including padding. fn content_size(&self) -> Size; + fn start_drag(&self) {} + fn end_drag(&self) {} } impl ScrollHandleOffsetable for ScrollHandle { @@ -82,16 +82,34 @@ impl ScrollHandleOffsetable for UniformListScrollHandle { self.0.borrow_mut().base_handle.set_offset(offset) } - fn is_uniform_list(&self) -> bool { - true - } - fn content_size(&self) -> Size { let base_handle = &self.0.borrow().base_handle; base_handle.max_offset() + base_handle.bounds().size } } +impl ScrollHandleOffsetable for ListState { + fn offset(&self) -> Point { + self.scroll_px_offset_for_scrollbar() + } + + fn set_offset(&self, offset: Point) { + self.set_offset_from_scrollbar(offset); + } + + fn content_size(&self) -> Size { + self.viewport_bounds().size + self.max_offset_for_scrollbar() + } + + fn start_drag(&self) { + self.scrollbar_drag_started(); + } + + fn end_drag(&self) { + self.scrollbar_drag_ended(); + } +} + #[derive(Debug, Clone)] pub struct ScrollbarState(Rc>); @@ -296,6 +314,11 @@ impl Scrollbar { } } + // Get the width of the scrollbar. + pub(crate) const fn width() -> Pixels { + WIDTH + } + /// Create with vertical and horizontal scrollbar. pub fn both( state: &ScrollbarState, @@ -778,6 +801,7 @@ impl Element for Scrollbar { // click on the thumb bar, set the drag position let pos = event.position - thumb_bounds.origin; + scroll_handle.start_drag(); state.set(state.get().with_drag_pos(axis, pos)); cx.notify(view_id); @@ -854,6 +878,9 @@ impl Element for Scrollbar { // Move thumb position on dragging if state.get().dragged_axis == Some(axis) && event.dragging() { + // Stop the event propagation to avoid selecting text or other side effects. + cx.stop_propagation(); + // drag_pos is the position of the mouse down event // We need to keep the thumb bar still at the origin down position let drag_pos = state.get().drag_pos; @@ -900,10 +927,12 @@ impl Element for Scrollbar { }); window.on_mouse_event({ + let scroll_handle = self.scroll_handle.clone(); let state = self.state.clone(); move |_event: &MouseUpEvent, phase, _, cx| { if phase.bubble() { + scroll_handle.end_drag(); state.set(state.get().with_unset_drag_pos()); cx.notify(view_id); } diff --git a/crates/ui/src/table/mod.rs b/crates/ui/src/table/mod.rs index 86340c74..f9cf75ab 100644 --- a/crates/ui/src/table/mod.rs +++ b/crates/ui/src/table/mod.rs @@ -5,7 +5,7 @@ use crate::{ context_menu::ContextMenuExt, h_flex, popup_menu::PopupMenu, - scroll::{self, ScrollableMask, Scrollbar, ScrollbarState}, + scroll::{ScrollableMask, Scrollbar, ScrollbarState}, v_flex, ActiveTheme, Icon, IconName, Sizable, Size, StyleSized as _, StyledExt, VirtualListScrollHandle, }; @@ -688,7 +688,7 @@ where .top(self.size.table_row_height()) .right_0() .bottom_0() - .w(scroll::WIDTH) + .w(Scrollbar::width()) .on_scroll_wheel(cx.listener(|_, _: &ScrollWheelEvent, _, cx| { cx.notify(); })) @@ -709,7 +709,7 @@ where .left(self.fixed_head_cols_bounds.size.width) .right_0() .bottom_0() - .h(scroll::WIDTH) + .h(Scrollbar::width()) .on_scroll_wheel(cx.listener(|_, _: &ScrollWheelEvent, _, cx| { cx.notify(); })) diff --git a/crates/ui/src/text/node.rs b/crates/ui/src/text/node.rs index 5b41b677..b37d750c 100644 --- a/crates/ui/src/text/node.rs +++ b/crates/ui/src/text/node.rs @@ -6,8 +6,8 @@ use std::{ use gpui::{ div, img, prelude::FluentBuilder as _, px, relative, rems, AnyElement, App, DefiniteLength, - Div, ElementId, FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _, - IntoElement, Length, ObjectFit, ParentElement, SharedString, SharedUri, + Div, Element, ElementId, FontStyle, FontWeight, Half, HighlightStyle, InteractiveElement as _, + IntoElement, Length, ListState, ObjectFit, ParentElement, SharedString, SharedUri, StatefulInteractiveElement, Styled, StyledImage as _, Window, }; use markdown::mdast; @@ -111,7 +111,7 @@ impl PartialEq for ImageNode { } } -#[derive(Default, Debug)] +#[derive(Default, Clone, Debug)] pub(crate) struct InlineNode { /// The text content. pub(crate) text: SharedString, @@ -154,7 +154,7 @@ impl InlineNode { /// /// Unlike other Element, this is cloneable, because it is used in the Node AST. /// We are keep the selection state inside this AST Nodes. -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub(crate) struct Paragraph { pub(super) span: Option, pub(super) children: Vec, @@ -205,7 +205,7 @@ impl Paragraph { } } -#[derive(Debug, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct Table { pub children: Vec, pub column_aligns: Vec, @@ -236,12 +236,12 @@ impl From for ColumnumnAlign { } } -#[derive(Debug, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct TableRow { pub children: Vec, } -#[derive(Debug, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct TableCell { pub children: Paragraph, pub width: Option, @@ -354,25 +354,34 @@ impl CodeBlock { text } - fn render(&self, node_cx: &NodeContext, _: &mut Window, cx: &mut App) -> AnyElement { + fn render( + &self, + options: &NodeRenderOptions, + node_cx: &NodeContext, + _: &mut Window, + cx: &mut App, + ) -> AnyElement { let style = &node_cx.style; div() - .id("codeblock") - .mb(style.paragraph_gap) - .p_3() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary.opacity(0.85)) - .font_family("Menlo, Monaco, Consolas, monospace") - .text_size(rems(0.875)) - .relative() - .refine_style(&style.code_block) - .child(Inline::new( - "code", - self.state.clone(), - vec![], - self.styles.clone(), - )) + .when(!options.is_last, |this| this.pb(style.paragraph_gap)) + .child( + div() + .id("codeblock") + .p_3() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary.opacity(0.85)) + .font_family("Menlo, Monaco, Consolas, monospace") + .text_size(rems(0.875)) + .relative() + .refine_style(&style.code_block) + .child(Inline::new( + "code", + self.state.clone(), + vec![], + self.styles.clone(), + )), + ) .into_any_element() } } @@ -391,7 +400,7 @@ impl NodeContext { } /// The AST Node of the rich text. -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum Node { Root { children: Vec, @@ -651,337 +660,19 @@ impl Paragraph { } } -#[derive(Default)] -pub(crate) struct ListState { +#[derive(Default, Clone, Copy)] +struct NodeRenderOptions { + in_list: bool, todo: bool, ordered: bool, depth: usize, + is_last: bool, } -impl Node { - fn render_list_item( - item: &Node, - ix: usize, - state: ListState, - node_cx: &NodeContext, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - match item { - Node::ListItem { - children, - spread, - checked, - } => v_flex() - .id("li") - .when(*spread, |this| this.child(div())) - .children({ - let mut items: Vec
= Vec::with_capacity(children.len()); - - for (child_ix, child) in children.iter().enumerate() { - match child { - Node::Paragraph(_) => { - let last_not_list = child_ix > 0 - && !matches!(children[child_ix - 1], Node::List { .. }); - - let text = child.render( - Some(ListState { - depth: state.depth + 1, - ordered: state.ordered, - todo: checked.is_some(), - }), - false, - true, - node_cx, - window, - cx, - ); - - // merge content into last item. - if last_not_list { - if let Some(item_item) = items.last_mut() { - item_item.extend(vec![div() - .overflow_hidden() - .child(text) - .into_any_element()]); - continue; - } - } - - items.push( - h_flex() - .flex_1() - .relative() - .items_start() - .content_start() - .when(!state.todo && checked.is_none(), |this| { - this.child(list_item_prefix( - ix, - state.ordered, - state.depth, - )) - }) - .when_some(*checked, |this, checked| { - // Todo list checkbox - this.child( - div() - .flex() - .mt(rems(0.4)) - .mr_1p5() - .size(rems(0.875)) - .items_center() - .justify_center() - .rounded(cx.theme().radius.half()) - .border_1() - .border_color(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - .when(checked, |this| { - this.bg(cx.theme().primary).child( - Icon::new(IconName::Check) - .size_2() - .text_xs(), - ) - }), - ) - }) - .child(div().overflow_hidden().child(text)), - ); - } - Node::List { .. } => { - items.push(div().ml(rems(1.)).child(child.render( - Some(ListState { - depth: state.depth + 1, - ordered: state.ordered, - todo: checked.is_some(), - }), - true, - true, - node_cx, - window, - cx, - ))); - } - _ => {} - } - } - items - }) - .into_any_element(), - _ => div().into_any_element(), - } - } - - fn render_table( - item: &Node, - node_cx: &NodeContext, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - const DEFAULT_LENGTH: usize = 5; - const MAX_LENGTH: usize = 150; - let col_lens = match item { - Node::Table(table) => { - let mut col_lens = vec![]; - for row in table.children.iter() { - for (ix, cell) in row.children.iter().enumerate() { - if col_lens.len() <= ix { - col_lens.push(DEFAULT_LENGTH); - } - - let len = cell.children.text_len(); - if len > col_lens[ix] { - col_lens[ix] = len; - } - } - } - col_lens - } - _ => vec![], - }; - - match item { - Node::Table(table) => div() - .id("table") - .mb(rems(1.)) - .w_full() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .children({ - let mut rows = Vec::with_capacity(table.children.len()); - for (row_ix, row) in table.children.iter().enumerate() { - rows.push( - div() - .id("row") - .w_full() - .when(row_ix < table.children.len() - 1, |this| this.border_b_1()) - .border_color(cx.theme().border) - .flex() - .flex_row() - .children({ - let mut cells = Vec::with_capacity(row.children.len()); - for (ix, cell) in row.children.iter().enumerate() { - let align = table.column_align(ix); - let is_last_col = ix == row.children.len() - 1; - let len = col_lens - .get(ix) - .copied() - .unwrap_or(MAX_LENGTH) - .min(MAX_LENGTH); - - cells.push( - div() - .id("cell") - .flex() - .when(align == ColumnumnAlign::Center, |this| { - this.justify_center() - }) - .when(align == ColumnumnAlign::Right, |this| { - this.justify_end() - }) - .w(Length::Definite(relative(len as f32))) - .px_2() - .py_1() - .when(!is_last_col, |this| { - this.border_r_1() - .border_color(cx.theme().border) - }) - .truncate() - .child(cell.children.render(node_cx, window, cx)), - ) - } - cells - }), - ) - } - rows - }) - .into_any_element(), - _ => div().into_any_element(), - } - } - - pub(crate) fn render( - &self, - list_state: Option, - is_root: bool, - is_last_child: bool, - node_cx: &NodeContext, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - let in_list = list_state.is_some(); - let mb = if in_list || is_last_child { - rems(0.) - } else { - node_cx.style.paragraph_gap - }; - - match self { - Node::Root { children } => div() - .id("div") - .children({ - let children_len = children.len(); - children.into_iter().enumerate().map(move |(index, c)| { - let is_last_child = is_root && index == children_len - 1; - c.render(None, false, is_last_child, node_cx, window, cx) - }) - }) - .into_any_element(), - Node::Paragraph(paragraph) => div() - .id("p") - .mb(mb) - .child(paragraph.render(node_cx, window, cx)) - .into_any_element(), - Node::Heading { level, children } => { - let (text_size, font_weight) = match level { - 1 => (rems(2.), FontWeight::BOLD), - 2 => (rems(1.5), FontWeight::SEMIBOLD), - 3 => (rems(1.25), FontWeight::SEMIBOLD), - 4 => (rems(1.125), FontWeight::SEMIBOLD), - 5 => (rems(1.), FontWeight::SEMIBOLD), - 6 => (rems(1.), FontWeight::MEDIUM), - _ => (rems(1.), FontWeight::NORMAL), - }; - - let mut text_size = text_size.to_pixels(node_cx.style.heading_base_font_size); - if let Some(f) = node_cx.style.heading_font_size.as_ref() { - text_size = (f)(*level, node_cx.style.heading_base_font_size); - } - - h_flex() - .id(("h", *level as usize)) - .mb(rems(0.3)) - .whitespace_normal() - .text_size(text_size) - .font_weight(font_weight) - .child(children.render(node_cx, window, cx)) - .into_any_element() - } - Node::Blockquote { children } => div() - .id("blockquote") - .w_full() - .mb(mb) - .text_color(cx.theme().muted_foreground) - .border_l_3() - .border_color(cx.theme().secondary_active) - .px_4() - .children({ - let children_len = children.len(); - children.into_iter().enumerate().map(move |(index, c)| { - let is_last_child = is_root && index == children_len - 1; - c.render(None, false, is_last_child, node_cx, window, cx) - }) - }) - .into_any_element(), - Node::List { children, ordered } => v_flex() - .id(if *ordered { "ol" } else { "ul" }) - .mb(mb) - .children({ - let mut items = Vec::with_capacity(children.len()); - let list_state = list_state.unwrap_or_default(); - let mut ix = 0; - for item in children.into_iter() { - let is_item = item.is_list_item(); - - items.push(Self::render_list_item( - item, - ix, - ListState { - ordered: *ordered, - todo: list_state.todo, - depth: list_state.depth, - }, - node_cx, - window, - cx, - )); - - if is_item { - ix += 1; - } - } - items - }) - .into_any_element(), - Node::CodeBlock(code_block) => code_block.render(node_cx, window, cx), - Node::Table { .. } => Self::render_table(self, node_cx, window, cx).into_any_element(), - Node::Divider => div() - .id("divider") - .bg(cx.theme().border) - .h(px(2.)) - .mb(mb) - .into_any_element(), - Node::Break { .. } => div().id("break").into_any_element(), - Node::Unknown | Node::Definition { .. } => div().into_any_element(), - _ => { - if cfg!(debug_assertions) { - tracing::warn!("unknown implementation: {:?}", self); - } - - div().into_any_element() - } - } +impl NodeRenderOptions { + fn is_last(mut self, is_last: bool) -> Self { + self.is_last = is_last; + self } } @@ -1166,3 +857,374 @@ impl Node { .to_string() } } + +impl Node { + fn render_list_item( + item: &Node, + ix: usize, + options: NodeRenderOptions, + node_cx: &NodeContext, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + match item { + Node::ListItem { + children, + spread, + checked, + } => v_flex() + .id("li") + .when(*spread, |this| this.child(div())) + .children({ + let mut items: Vec
= Vec::with_capacity(children.len()); + + for (child_ix, child) in children.iter().enumerate() { + match child { + Node::Paragraph(_) => { + let last_not_list = child_ix > 0 + && !matches!(children[child_ix - 1], Node::List { .. }); + + let text = child.render_block( + NodeRenderOptions { + depth: options.depth + 1, + todo: checked.is_some(), + is_last: true, + ..options + }, + node_cx, + window, + cx, + ); + + // merge content into last item. + if last_not_list { + if let Some(item_item) = items.last_mut() { + item_item.extend(vec![div() + .overflow_hidden() + .child(text) + .into_any_element()]); + continue; + } + } + + items.push( + h_flex() + .flex_1() + .relative() + .items_start() + .content_start() + .when(!options.todo && checked.is_none(), |this| { + this.child(list_item_prefix( + ix, + options.ordered, + options.depth, + )) + }) + .when_some(*checked, |this, checked| { + // Todo list checkbox + this.child( + div() + .flex() + .mt(rems(0.4)) + .mr_1p5() + .size(rems(0.875)) + .items_center() + .justify_center() + .rounded(cx.theme().radius.half()) + .border_1() + .border_color(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .when(checked, |this| { + this.bg(cx.theme().primary).child( + Icon::new(IconName::Check) + .size_2() + .text_xs(), + ) + }), + ) + }) + .child(div().overflow_hidden().child(text)), + ); + } + Node::List { .. } => { + items.push(div().ml(rems(1.)).child(child.render_block( + NodeRenderOptions { + depth: options.depth + 1, + todo: checked.is_some(), + is_last: true, + ..options + }, + node_cx, + window, + cx, + ))); + } + _ => {} + } + } + items + }) + .into_any_element(), + _ => div().into_any_element(), + } + } + + fn render_table( + item: &Node, + node_cx: &NodeContext, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + const DEFAULT_LENGTH: usize = 5; + const MAX_LENGTH: usize = 150; + let col_lens = match item { + Node::Table(table) => { + let mut col_lens = vec![]; + for row in table.children.iter() { + for (ix, cell) in row.children.iter().enumerate() { + if col_lens.len() <= ix { + col_lens.push(DEFAULT_LENGTH); + } + + let len = cell.children.text_len(); + if len > col_lens[ix] { + col_lens[ix] = len; + } + } + } + col_lens + } + _ => vec![], + }; + + match item { + Node::Table(table) => div() + .pb(rems(1.)) + .w_full() + .child( + div() + .id("table") + .w_full() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .children({ + let mut rows = Vec::with_capacity(table.children.len()); + for (row_ix, row) in table.children.iter().enumerate() { + rows.push( + div() + .id("row") + .w_full() + .when(row_ix < table.children.len() - 1, |this| { + this.border_b_1() + }) + .border_color(cx.theme().border) + .flex() + .flex_row() + .children({ + let mut cells = Vec::with_capacity(row.children.len()); + for (ix, cell) in row.children.iter().enumerate() { + let align = table.column_align(ix); + let is_last_col = ix == row.children.len() - 1; + let len = col_lens + .get(ix) + .copied() + .unwrap_or(MAX_LENGTH) + .min(MAX_LENGTH); + + cells.push( + div() + .id("cell") + .flex() + .when( + align == ColumnumnAlign::Center, + |this| this.justify_center(), + ) + .when( + align == ColumnumnAlign::Right, + |this| this.justify_end(), + ) + .w(Length::Definite(relative(len as f32))) + .px_2() + .py_1() + .when(!is_last_col, |this| { + this.border_r_1() + .border_color(cx.theme().border) + }) + .truncate() + .child( + cell.children + .render(node_cx, window, cx), + ), + ) + } + cells + }), + ) + } + rows + }), + ) + .into_any_element(), + _ => div().into_any_element(), + } + } + + pub(super) fn render_root( + &self, + list_state: Option, + node_cx: &NodeContext, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + let options = NodeRenderOptions { + is_last: true, + ..Default::default() + }; + + let Some(list_state) = list_state else { + return self + .render_block(options, node_cx, window, cx) + .into_any_element(); + }; + + let children = match self { + Node::Root { children } => children, + _ => return div().into_any_element(), + }; + + let children = children.clone(); + let node_cx = node_cx.clone(); + + if list_state.item_count() != children.len() { + list_state.reset(children.len()); + } + + gpui::list(list_state, move |ix, window, cx| { + let is_last = ix + 1 == children.len(); + children[ix] + .render_block(options.is_last(is_last), &node_cx, window, cx) + .into_any_element() + }) + .size_full() + .into_any() + } + + fn render_block( + &self, + options: NodeRenderOptions, + node_cx: &NodeContext, + window: &mut Window, + cx: &mut App, + ) -> impl IntoElement { + let mb = if options.in_list || options.is_last { + rems(0.) + } else { + node_cx.style.paragraph_gap + }; + + match self { + Node::Root { children } => div() + .id("div") + .children( + children + .into_iter() + .map(move |node| node.render_block(options, node_cx, window, cx)), + ) + .into_any_element(), + Node::Paragraph(paragraph) => div() + .id("p") + .pb(mb) + .child(paragraph.render(node_cx, window, cx)) + .into_any_element(), + Node::Heading { level, children } => { + let (text_size, font_weight) = match level { + 1 => (rems(2.), FontWeight::BOLD), + 2 => (rems(1.5), FontWeight::SEMIBOLD), + 3 => (rems(1.25), FontWeight::SEMIBOLD), + 4 => (rems(1.125), FontWeight::SEMIBOLD), + 5 => (rems(1.), FontWeight::SEMIBOLD), + 6 => (rems(1.), FontWeight::MEDIUM), + _ => (rems(1.), FontWeight::NORMAL), + }; + + let mut text_size = text_size.to_pixels(node_cx.style.heading_base_font_size); + if let Some(f) = node_cx.style.heading_font_size.as_ref() { + text_size = (f)(*level, node_cx.style.heading_base_font_size); + } + + h_flex() + .id(("h", *level as usize)) + .pb(rems(0.3)) + .whitespace_normal() + .text_size(text_size) + .font_weight(font_weight) + .child(children.render(node_cx, window, cx)) + .into_any_element() + } + Node::Blockquote { children } => div() + .w_full() + .pb(mb) + .child( + div() + .id("blockquote") + .w_full() + .text_color(cx.theme().muted_foreground) + .border_l_3() + .border_color(cx.theme().secondary_active) + .px_4() + .children({ + let children_len = children.len(); + children.into_iter().enumerate().map(move |(index, c)| { + let is_last = index == children_len - 1; + c.render_block(options.is_last(is_last), node_cx, window, cx) + }) + }), + ) + .into_any_element(), + Node::List { children, ordered } => v_flex() + .id(if *ordered { "ol" } else { "ul" }) + .pb(mb) + .children({ + let mut items = Vec::with_capacity(children.len()); + let mut ix = 0; + for item in children.into_iter() { + let is_item = item.is_list_item(); + + items.push(Self::render_list_item( + item, + ix, + NodeRenderOptions { + ordered: *ordered, + ..options + }, + node_cx, + window, + cx, + )); + + if is_item { + ix += 1; + } + } + items + }) + .into_any_element(), + Node::CodeBlock(code_block) => code_block.render(&options, node_cx, window, cx), + Node::Table { .. } => Self::render_table(self, node_cx, window, cx).into_any_element(), + Node::Divider => div() + .pb(mb) + .child(div().id("divider").bg(cx.theme().border).h(px(2.))) + .into_any_element(), + Node::Break { .. } => div().id("break").into_any_element(), + Node::Unknown | Node::Definition { .. } => div().into_any_element(), + _ => { + if cfg!(debug_assertions) { + tracing::warn!("unknown implementation: {:?}", self); + } + + div().into_any_element() + } + } + } +} diff --git a/crates/ui/src/text/text_view.rs b/crates/ui/src/text/text_view.rs index aad99c1b..98efdbc0 100644 --- a/crates/ui/src/text/text_view.rs +++ b/crates/ui/src/text/text_view.rs @@ -6,14 +6,16 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - div, AnyElement, App, AppContext, Bounds, ClipboardItem, Context, Element, ElementId, Entity, - EntityId, FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, - KeyBinding, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, - Point, RenderOnce, SharedString, Size, Styled, Timer, Window, + div, px, AnyElement, App, AppContext, Bounds, ClipboardItem, Context, Element, ElementId, + Entity, EntityId, FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement, + IntoElement, KeyBinding, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement, Pixels, Point, RenderOnce, SharedString, Size, StyleRefinement, Styled, Timer, + Window, }; use smol::stream::StreamExt; use crate::highlighter::HighlightTheme; +use crate::scroll::{Scrollbar, ScrollbarState}; use crate::{ global_state::GlobalState, input::{self}, @@ -22,7 +24,7 @@ use crate::{ TextViewStyle, }, }; -use crate::{v_flex, ActiveTheme}; +use crate::{v_flex, ActiveTheme, StyledExt}; const CONTEXT: &'static str = "TextView"; @@ -37,29 +39,30 @@ pub(crate) fn init(cx: &mut App) { #[derive(IntoElement, Clone)] struct TextViewElement { + list_state: Option, state: Entity, } impl RenderOnce for TextViewElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { self.state.update(cx, |state, cx| { - div().map(|this| match &mut state.parsed_result { - Some(Ok(content)) => this.child(content.root_node.render( - None, - true, - true, - &content.node_cx, - window, - cx, - )), - Some(Err(err)) => this.child( - v_flex() - .gap_1() - .child("Failed to parse content") - .child(err.to_string()), - ), - None => this, - }) + v_flex() + .size_full() + .map(|this| match &mut state.parsed_result { + Some(Ok(content)) => this.child(content.root_node.render_root( + self.list_state.clone(), + &content.node_cx, + window, + cx, + )), + Some(Err(err)) => this.child( + v_flex() + .gap_1() + .child("Failed to parse content") + .child(err.to_string()), + ), + None => this, + }) }) } } @@ -85,7 +88,9 @@ pub struct TextView { id: ElementId, init_state: Option, state: Entity, + style: StyleRefinement, selectable: bool, + scrollable: bool, } #[derive(PartialEq)] @@ -205,7 +210,6 @@ pub(crate) struct TextViewState { tx: Option>, parsed_result: Option>, focus_handle: Option, - /// The bounds of the text view bounds: Bounds, /// The local (in TextView) position of the selection. @@ -213,6 +217,8 @@ pub(crate) struct TextViewState { /// Is current in selection. is_selecting: bool, is_selectable: bool, + scrollbar_state: ScrollbarState, + list_state: ListState, } impl TextViewState { @@ -227,6 +233,8 @@ impl TextViewState { selection_positions: (None, None), is_selecting: false, is_selectable: false, + scrollbar_state: ScrollbarState::default(), + list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), } } } @@ -346,6 +354,12 @@ impl RenderOnce for Text { } } +impl Styled for TextView { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + impl TextView { fn create_init_state( type_: TextViewType, @@ -394,8 +408,10 @@ impl TextView { Self { id, init_state: Some(init_state), + style: StyleRefinement::default(), state, selectable: false, + scrollable: false, } } @@ -421,8 +437,10 @@ impl TextView { Self { id, init_state: Some(init_state), + style: StyleRefinement::default(), state, selectable: false, + scrollable: false, } } @@ -459,6 +477,23 @@ impl TextView { self } + /// Set the text view to be scrollable, default is false. + /// + /// ## If true for `scrollable` + /// + /// The `scrollable` mode used for large content, + /// will show scrollbar, but requires the parent to have a fixed height, + /// and use [`gpui::list`] to render the content in a virtualized way. + /// + /// ## If false to fit content + /// + /// The TextView will expand to fit all content, no scrollbar. + /// This mode is suitable for small content, such as a few lines of text, a label, etc. + pub fn scrollable(mut self) -> Self { + self.scrollable = true; + self + } + fn on_action_copy(state: &Entity, cx: &mut App) { let Some(selected_text) = state.read(cx).selection_text() else { return; @@ -553,6 +588,9 @@ impl Element for TextView { self.init_state = Some(InitState::Initialized { tx }); } + let scrollbar_state = &self.state.read(cx).scrollbar_state; + let list_state = &self.state.read(cx).list_state; + let focus_handle = self .state .read(cx) @@ -563,6 +601,8 @@ impl Element for TextView { let mut el = div() .key_context(CONTEXT) .track_focus(focus_handle) + .size_full() + .relative() .on_action({ let state = self.state.clone(); move |_: &input::Copy, _, cx| { @@ -570,8 +610,25 @@ impl Element for TextView { } }) .child(TextViewElement { + list_state: if self.scrollable { + Some(list_state.clone()) + } else { + None + }, state: self.state.clone(), }) + .refine_style(&self.style) + .when(self.scrollable, |this| { + this.child( + div() + .absolute() + .w(Scrollbar::width()) + .top_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(scrollbar_state, list_state)), + ) + }) .into_any_element(); let layout_id = el.request_layout(window, cx); (layout_id, el) @@ -636,7 +693,11 @@ impl Element for TextView { // move to update end position. window.on_mouse_event({ let state = self.state.clone(); - move |event: &MouseMoveEvent, _, _, cx| { + move |event: &MouseMoveEvent, phase, _, cx| { + if !phase.bubble() { + return; + } + state.update(cx, |state, _| { state.update_selection(event.position); }); @@ -647,7 +708,11 @@ impl Element for TextView { // up to end selection window.on_mouse_event({ let state = self.state.clone(); - move |_: &MouseUpEvent, _, _, cx| { + move |_: &MouseUpEvent, phase, _, cx| { + if !phase.bubble() { + return; + } + state.update(cx, |state, _| { state.end_selection(); });