markdown: Use list for Markdown render and add scrollbar. (#1357)

This commit is contained in:
Jason Lee 2025-10-13 22:06:07 +08:00 committed by GitHub
parent 2574e7f812
commit 5b2fed0ccb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 594 additions and 425 deletions

View file

@ -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(),
),
)
}

View file

@ -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(),
),
),
)

View file

@ -459,6 +459,7 @@ pub struct StoryContainer {
story_klass: Option<SharedString>,
closable: bool,
zoomable: Option<PanelControl>,
paddings: Pixels,
on_active: Option<fn(AnyView, bool, &mut Window, &mut App)>,
}
@ -473,18 +474,27 @@ pub trait Story: Render + Sized {
}
fn title() -> &'static str;
fn description() -> &'static str {
""
}
fn closable() -> bool {
true
}
fn zoomable() -> Option<PanelControl> {
Some(PanelControl::default())
}
fn title_bg() -> Option<Hsla> {
None
}
fn paddings() -> Pixels {
px(16.)
}
fn new_view(window: &mut Window, cx: &mut App) -> Entity<impl Render>;
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),
)
})

View file

@ -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<PanelControl> {
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<Self>,
) -> 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()
}
}

View file

@ -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)),
)

View file

@ -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<Pixels>;
fn set_offset(&self, offset: Point<Pixels>);
fn is_uniform_list(&self) -> bool {
false
}
/// The full size of the content, including padding.
fn content_size(&self) -> Size<Pixels>;
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<Pixels> {
let base_handle = &self.0.borrow().base_handle;
base_handle.max_offset() + base_handle.bounds().size
}
}
impl ScrollHandleOffsetable for ListState {
fn offset(&self) -> Point<Pixels> {
self.scroll_px_offset_for_scrollbar()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.set_offset_from_scrollbar(offset);
}
fn content_size(&self) -> Size<Pixels> {
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<Cell<ScrollbarStateInner>>);
@ -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);
}

View file

@ -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();
}))

View file

@ -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<Span>,
pub(super) children: Vec<InlineNode>,
@ -205,7 +205,7 @@ impl Paragraph {
}
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct Table {
pub children: Vec<TableRow>,
pub column_aligns: Vec<ColumnumnAlign>,
@ -236,12 +236,12 @@ impl From<mdast::AlignKind> for ColumnumnAlign {
}
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct TableRow {
pub children: Vec<TableCell>,
}
#[derive(Debug, Default, PartialEq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct TableCell {
pub children: Paragraph,
pub width: Option<DefiniteLength>,
@ -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<Node>,
@ -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<Div> = 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<ListState>,
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<Div> = 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<ListState>,
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()
}
}
}
}

View file

@ -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<ListState>,
state: Entity<TextViewState>,
}
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<InitState>,
state: Entity<TextViewState>,
style: StyleRefinement,
selectable: bool,
scrollable: bool,
}
#[derive(PartialEq)]
@ -205,7 +210,6 @@ pub(crate) struct TextViewState {
tx: Option<smol::channel::Sender<Update>>,
parsed_result: Option<Result<ParsedContent, SharedString>>,
focus_handle: Option<FocusHandle>,
/// The bounds of the text view
bounds: Bounds<Pixels>,
/// 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<TextViewState>, 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();
});