feat: add markdown text view
This commit is contained in:
@@ -40,7 +40,9 @@ code_view = [
|
||||
]
|
||||
diff_view = ["code_view"]
|
||||
markdown_view = [
|
||||
"code_view",
|
||||
"dep:gpui-native-core",
|
||||
"gpui-native-core/button",
|
||||
"gpui-native-core/scrollbar",
|
||||
"dep:pulldown-cmark",
|
||||
"dep:gpui-native-icons",
|
||||
]
|
||||
|
||||
+1018
-103
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,24 @@
|
||||
//! Shared helpers for the editor's views: gutter cells, role colors and run
|
||||
//! styles, code line text, language mapping and change washes.
|
||||
|
||||
#[cfg(feature = "markdown_view")]
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
use std::ops::Range;
|
||||
|
||||
#[cfg(feature = "markdown_view")]
|
||||
use crate::highlight::{Language, Line};
|
||||
#[cfg(feature = "markdown_view")]
|
||||
use gpui::StyledText;
|
||||
#[cfg(feature = "code_view")]
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div};
|
||||
#[cfg(feature = "code_view")]
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps};
|
||||
#[cfg(feature = "diff_view")]
|
||||
use gpui_native_theme::{WASH_ALPHA, WASH_ALPHA_GUTTER, WASH_ALPHA_STRONG};
|
||||
|
||||
#[cfg(feature = "code_view")]
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
use crate::highlight::Role;
|
||||
|
||||
#[cfg(feature = "code_view")]
|
||||
#[cfg(any(feature = "code_view", feature = "markdown_view"))]
|
||||
impl Role {
|
||||
/// Maps the role to the theme's semantic palette.
|
||||
pub(crate) fn color(self, colors: ColorTokens, ramps: Ramps) -> Hsla {
|
||||
|
||||
+446
-23
@@ -2,15 +2,27 @@ use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent,
|
||||
ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
|
||||
px,
|
||||
App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _,
|
||||
TestAppContext, VisualTestContext, Window, div, point, px, rems,
|
||||
};
|
||||
use gpui_native_editor::highlight::Role;
|
||||
use gpui_native_editor::markdown::{
|
||||
InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState,
|
||||
InlineItem, InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading,
|
||||
MarkdownViewState, TextView, bind_text_view_keys,
|
||||
};
|
||||
|
||||
fn items_text(items: &[InlineItem]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
InlineItem::Text(span) => Some(span.text.as_ref()),
|
||||
InlineItem::Image { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn spans_text(spans: &[InlineSpan]) -> String {
|
||||
spans
|
||||
.iter()
|
||||
@@ -19,13 +31,13 @@ fn spans_text(spans: &[InlineSpan]) -> String {
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineSpan>, usize, bool) {
|
||||
fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec<InlineItem>, usize, bool) {
|
||||
match &blocks[index] {
|
||||
MarkdownBlock::Paragraph {
|
||||
spans,
|
||||
items,
|
||||
indent,
|
||||
quote,
|
||||
} => (spans, *indent, *quote),
|
||||
} => (items, *indent, *quote),
|
||||
other => panic!("block {index} is {other:?}, not a paragraph"),
|
||||
}
|
||||
}
|
||||
@@ -34,10 +46,10 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
|
||||
match &blocks[index] {
|
||||
MarkdownBlock::ListItem {
|
||||
marker,
|
||||
spans,
|
||||
items,
|
||||
indent,
|
||||
quote,
|
||||
} => (*marker, spans_text(spans), *indent, *quote),
|
||||
} => (*marker, items_text(items), *indent, *quote),
|
||||
other => panic!("block {index} is {other:?}, not a list item"),
|
||||
}
|
||||
}
|
||||
@@ -46,11 +58,14 @@ fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize
|
||||
fn inline_styles_survive_nesting_and_merge_runs() {
|
||||
let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let (spans, ..) = paragraph_of(&blocks, 0);
|
||||
let (items, ..) = paragraph_of(&blocks, 0);
|
||||
|
||||
let styled: Vec<(String, &InlineStyle)> = spans
|
||||
let styled: Vec<(String, &InlineStyle)> = items
|
||||
.iter()
|
||||
.map(|span| (span.text.to_string(), &span.style))
|
||||
.filter_map(|item| match item {
|
||||
InlineItem::Text(span) => Some((span.text.to_string(), &span.style)),
|
||||
InlineItem::Image { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong)));
|
||||
@@ -133,8 +148,8 @@ fn tight_items_stay_tight_but_block_content_flushes_before_them() {
|
||||
assert_eq!(text, "item text");
|
||||
assert_eq!(indent, 0);
|
||||
|
||||
let (spans, indent, _) = paragraph_of(&blocks, 1);
|
||||
assert_eq!(spans_text(spans), "second paragraph");
|
||||
let (items, indent, _) = paragraph_of(&blocks, 1);
|
||||
assert_eq!(items_text(items), "second paragraph");
|
||||
assert_eq!(
|
||||
indent, 1,
|
||||
"loose item continuations indent under the item's text"
|
||||
@@ -174,8 +189,8 @@ fn tight_item_content_after_a_nested_block_stays_in_the_item() {
|
||||
}
|
||||
));
|
||||
|
||||
let (spans, indent, _) = paragraph_of(&blocks, 2);
|
||||
assert_eq!(spans_text(spans), "after");
|
||||
let (items, indent, _) = paragraph_of(&blocks, 2);
|
||||
assert_eq!(items_text(items), "after");
|
||||
assert_eq!(indent, 1, "the continuation stays under the item's text");
|
||||
}
|
||||
|
||||
@@ -250,10 +265,11 @@ fn quotes_mark_their_paragraphs_and_lists() {
|
||||
assert!(!paragraph_of(&blocks, 2).2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "rust")]
|
||||
#[test]
|
||||
fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
||||
let blocks = parse_body("```rust\nfn main() {}\n```\n\n```text\nplain\n```");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
let blocks = parse_body("```rust\nfn main() {}\n```\n");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
|
||||
let MarkdownBlock::Code {
|
||||
language, lines, ..
|
||||
@@ -270,12 +286,18 @@ fn fenced_code_highlights_known_languages_and_keeps_the_info_string() {
|
||||
.any(|span| span.role == Role::Keyword),
|
||||
"rust fences highlight through the grammar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_code_keeps_the_info_string_and_renders_unknown_languages_plain() {
|
||||
let blocks = parse_body("```text\nplain\n```");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
|
||||
let MarkdownBlock::Code {
|
||||
language, lines, ..
|
||||
} = &blocks[1]
|
||||
} = &blocks[0]
|
||||
else {
|
||||
panic!("the second block is code");
|
||||
panic!("the block is code");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("text"));
|
||||
assert!(
|
||||
@@ -309,11 +331,59 @@ fn tables_parse_header_first_with_column_positions() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_render_their_alt_text_and_html_renders_nothing() {
|
||||
fn images_render_inline_and_standalone_and_html_renders_nothing() {
|
||||
// An image alone in its paragraph becomes a standalone image block.
|
||||
let blocks = parse_body("");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
let MarkdownBlock::Image { source, alt, .. } = &blocks[0] else {
|
||||
panic!("the block is an image");
|
||||
};
|
||||
assert_eq!(source.as_ref(), "logo.png");
|
||||
assert_eq!(alt.as_ref(), "the logo");
|
||||
|
||||
// Two consecutive images in one paragraph become two blocks.
|
||||
let blocks = parse_body("\n");
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(
|
||||
blocks
|
||||
.iter()
|
||||
.filter(|block| matches!(block, MarkdownBlock::Image { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
|
||||
// An image among text renders as an inline atom; the spaces around it
|
||||
// stay in their runs, and its alt text is dropped.
|
||||
let blocks = parse_body("see  now\n\n<div>hidden</div>\n");
|
||||
assert_eq!(blocks.len(), 1, "the html block renders no block at all");
|
||||
let (spans, ..) = paragraph_of(&blocks, 0);
|
||||
assert_eq!(spans_text(spans), "see the logo now");
|
||||
let (items, ..) = paragraph_of(&blocks, 0);
|
||||
assert_eq!(items_text(items), "see now");
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, InlineItem::Image { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
// Images inside headings and table cells fall back to their alt text.
|
||||
let blocks = parse_body("#  head\n\n|  |\n|---|\n| x |\n");
|
||||
let MarkdownBlock::Heading { spans, .. } = &blocks[0] else {
|
||||
panic!("the first block is a heading");
|
||||
};
|
||||
assert!(
|
||||
spans_text(spans).contains("alt h"),
|
||||
"the heading image renders as its alt text"
|
||||
);
|
||||
let MarkdownBlock::Table { table, .. } = &blocks[1] else {
|
||||
panic!("the second block is a table");
|
||||
};
|
||||
let cell_text: String = table.rows[0].cells[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.text.as_ref())
|
||||
.collect();
|
||||
assert_eq!(cell_text, "cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -500,3 +570,356 @@ fn a_large_document_swaps_and_renders_without_freezing() {
|
||||
"blocks past the viewport never render"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_ids_survive_prepending_and_edits_around_them() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let state = cx.new(|_| MarkdownViewState::new("feed", "first\n\nsecond\n"));
|
||||
|
||||
let (first, second) = cx.update(|cx| {
|
||||
let state = state.read(cx);
|
||||
(
|
||||
state.block_id(0).expect("the first block has an id"),
|
||||
state.block_id(1).expect("the second block has an id"),
|
||||
)
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_source("new\n\nfirst\n\nsecond\n", cx)
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
let state = state.read(cx);
|
||||
assert_eq!(
|
||||
state.block_id(1),
|
||||
Some(first.clone()),
|
||||
"prepending a block keeps the id of the old first block"
|
||||
);
|
||||
assert_eq!(
|
||||
state.block_id(2),
|
||||
Some(second.clone()),
|
||||
"prepending a block keeps the id of the old second block"
|
||||
);
|
||||
});
|
||||
|
||||
// Editing a block's text changes only that block's id.
|
||||
cx.update(|cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_source("new\n\nfirst edited\n\nsecond\n", cx)
|
||||
});
|
||||
});
|
||||
cx.update(|cx| {
|
||||
let state = state.read(cx);
|
||||
assert_eq!(
|
||||
state.block_id(2),
|
||||
Some(second),
|
||||
"the untouched block keeps its id"
|
||||
);
|
||||
assert_ne!(
|
||||
state.block_id(1),
|
||||
Some(first),
|
||||
"the edited block gets a new id"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_spans_pieces_and_images_contribute_no_text() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let state = cx.new(|_| {
|
||||
MarkdownViewState::new(
|
||||
"sel",
|
||||
"alpha\n\nbravo  charlie\n\n- delta\n- echo\n",
|
||||
)
|
||||
});
|
||||
|
||||
let selected = cx.update(|cx| {
|
||||
state.update(cx, |view, cx| {
|
||||
view.select_range(0, 30, cx);
|
||||
view.selected_text()
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
selected.as_deref(),
|
||||
Some("alpha\nbravo \n charlie\ndelta\nec"),
|
||||
"pieces join with newlines and images contribute no text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_empty_past_the_document_end_and_clears_on_reparse() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let state = cx.new(|_| MarkdownViewState::new("sel", "only\n"));
|
||||
|
||||
let (overshoot, after_reparse) = cx.update(|cx| {
|
||||
state.update(cx, |view, cx| {
|
||||
view.select_range(0, 10_000, cx);
|
||||
let overshoot = view.selected_text();
|
||||
view.set_source("other\n", cx);
|
||||
(overshoot, view.selected_text())
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
overshoot.as_deref(),
|
||||
Some("only"),
|
||||
"ranges clamp to the document"
|
||||
);
|
||||
assert_eq!(after_reparse, None, "reparsing clears the selection");
|
||||
}
|
||||
|
||||
struct TextViewHost {
|
||||
link_handler: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Render for TextViewHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let clicks = self.link_handler.clone();
|
||||
div()
|
||||
.debug_selector(|| "text-view".to_string())
|
||||
.size_full()
|
||||
.child(
|
||||
TextView::markdown(
|
||||
"text-view",
|
||||
"[a very long link label that fills the line](https://example.com/welcome)",
|
||||
)
|
||||
.on_link_click(move |url, _window, _cx| {
|
||||
clicks.borrow_mut().push(url.to_string());
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_view_clicks_route_through_the_link_handler() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let clicks: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let clicks_cell = clicks.clone();
|
||||
let window = cx.add_window(move |_, _| TextViewHost {
|
||||
link_handler: clicks_cell,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
let bounds = visual
|
||||
.debug_bounds("text-view")
|
||||
.expect("the text view renders");
|
||||
let position = point(bounds.left() + px(4.), bounds.top() + px(10.));
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
visual.simulate_event(MouseUpEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
clicks.borrow().as_slice(),
|
||||
["https://example.com/welcome".to_string()],
|
||||
"the click lands on the link and routes through the handler"
|
||||
);
|
||||
}
|
||||
|
||||
struct SelectionHost {
|
||||
state: Entity<MarkdownViewState>,
|
||||
}
|
||||
|
||||
impl Render for SelectionHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().child(div().w(rems(30.)).child(TextView::new(&self.state)))
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_window(
|
||||
source: &str,
|
||||
) -> (TestAppContext, VisualTestContext, Entity<MarkdownViewState>) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
cx.update(bind_text_view_keys);
|
||||
let state = MarkdownViewState::new("sel-view", source);
|
||||
let state_cell: Rc<RefCell<Option<Entity<MarkdownViewState>>>> = Rc::new(RefCell::new(None));
|
||||
let cell = state_cell.clone();
|
||||
let window = cx.add_window(move |_, cx| {
|
||||
let view = cx.new(|_| state);
|
||||
cell.borrow_mut().replace(view.clone());
|
||||
SelectionHost { state: view }
|
||||
});
|
||||
let state = state_cell.borrow().clone().expect("state captured");
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, visual, state)
|
||||
}
|
||||
|
||||
/// Leaks a block id so it can feed `debug_bounds`, which needs `&'static str`.
|
||||
fn leaked_block_id(
|
||||
state: &Entity<MarkdownViewState>,
|
||||
cx: &App,
|
||||
block_index: usize,
|
||||
) -> &'static str {
|
||||
Box::<str>::leak(
|
||||
state
|
||||
.read(cx)
|
||||
.block_id(block_index)
|
||||
.expect("the block has an id")
|
||||
.to_string()
|
||||
.into_boxed_str(),
|
||||
)
|
||||
}
|
||||
|
||||
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
visual.simulate_event(MouseUpEvent {
|
||||
position,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_across_paragraphs_selects_and_copies() {
|
||||
let (cx, mut visual, state) = selection_window("alpha bravo\n\ncharlie delta\n");
|
||||
|
||||
let first = cx.update(|cx| leaked_block_id(&state, cx, 0));
|
||||
let second = cx.update(|cx| leaked_block_id(&state, cx, 1));
|
||||
|
||||
let start = {
|
||||
let bounds = visual.debug_bounds(first).expect("the first block renders");
|
||||
gpui::point(bounds.left() + px(4.), bounds.top() + px(8.))
|
||||
};
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
position: start,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
let end = {
|
||||
let bounds = visual
|
||||
.debug_bounds(second)
|
||||
.expect("the second block renders");
|
||||
gpui::point(bounds.left() + px(40.), bounds.top() + px(8.))
|
||||
};
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: end,
|
||||
..Default::default()
|
||||
});
|
||||
visual.simulate_event(MouseUpEvent {
|
||||
position: end,
|
||||
button: MouseButton::Left,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let selected = cx.update(|cx| state.read(cx).selected_text());
|
||||
assert_eq!(
|
||||
selected.as_deref(),
|
||||
Some("alpha bravo\ncharli"),
|
||||
"the drag selects from the press through the pointer"
|
||||
);
|
||||
|
||||
visual.dispatch_action(gpui_native_editor::markdown::Copy);
|
||||
let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
||||
assert_eq!(
|
||||
clipboard.as_deref(),
|
||||
selected.as_deref(),
|
||||
"the copy action copies the selected text"
|
||||
);
|
||||
}
|
||||
|
||||
struct TaskHost {
|
||||
toggles: Rc<RefCell<Vec<(usize, bool)>>>,
|
||||
}
|
||||
|
||||
impl Render for TaskHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let toggles = self.toggles.clone();
|
||||
div().size_full().child(
|
||||
TextView::markdown("task-view", "- [ ] first\n- [x] second\n").on_task_toggle(
|
||||
move |block, checked, _window, _cx| {
|
||||
toggles.borrow_mut().push((block, checked));
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_task_checkbox_toggles_it_through_the_handler() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let toggles: Rc<RefCell<Vec<(usize, bool)>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let toggles_cell = toggles.clone();
|
||||
let window = cx.add_window(move |_, _| TaskHost {
|
||||
toggles: toggles_cell,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
let bounds = visual
|
||||
.debug_bounds("task-toggle-0")
|
||||
.expect("the task renders");
|
||||
click(
|
||||
&mut visual,
|
||||
gpui::point(bounds.center().x, bounds.center().y),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
toggles.borrow().as_slice(),
|
||||
[(0, true)],
|
||||
"checking the first task reports its block and the new state"
|
||||
);
|
||||
}
|
||||
|
||||
struct CodeHost;
|
||||
|
||||
impl Render for CodeHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(TextView::markdown(
|
||||
"code-host",
|
||||
"```text\nfn main() {}\n```\n",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_code_block_copy_button_copies_the_fenced_source() {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(gpui_native_theme::Theme::init);
|
||||
let window = cx.add_window(|_, _| CodeHost);
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
|
||||
let bounds = visual
|
||||
.debug_bounds("copy-code")
|
||||
.expect("the copy button renders");
|
||||
click(
|
||||
&mut visual,
|
||||
gpui::point(bounds.center().x, bounds.center().y),
|
||||
);
|
||||
|
||||
let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
||||
assert_eq!(
|
||||
clipboard.as_deref(),
|
||||
Some("fn main() {}"),
|
||||
"the copy button copies the fenced source"
|
||||
);
|
||||
}
|
||||
|
||||
+188
-129
@@ -46,7 +46,7 @@ use gpui_native_data::tree::{Tree, TreeItem};
|
||||
use gpui_native_editor::code_view::CodeViewState;
|
||||
use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState};
|
||||
use gpui_native_editor::highlight::Language;
|
||||
use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState};
|
||||
use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState, TextView};
|
||||
use gpui_native_icons::{IconAssets, IconRole, icon};
|
||||
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
||||
use gpui_native_layout::resizable::{Resizable, ResizableState};
|
||||
@@ -98,6 +98,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
||||
("code-view", "Code view"),
|
||||
("diff-view", "Diff view"),
|
||||
("markdown-view", "Markdown view"),
|
||||
("text-view", "Text view"),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -612,6 +613,9 @@ impl Gallery {
|
||||
"Markdown view",
|
||||
|_, cx| cx.new(MarkdownViewDemo::new).into(),
|
||||
));
|
||||
dock.register_panel(PanelDescriptor::new("text-view", "Text view", |_, cx| {
|
||||
cx.new(TextViewDemo::new).into()
|
||||
}));
|
||||
dock.register_panel(PanelDescriptor::new("sidebar", "Sidebar", |_, cx| {
|
||||
cx.new(|_| SidebarDemo { selected: None }).into()
|
||||
}));
|
||||
@@ -2191,30 +2195,28 @@ impl Render for TreeDemo {
|
||||
let mut paths = Vec::new();
|
||||
Self::flatten(&self.nodes, 0, &[], "", &mut items, &mut paths);
|
||||
|
||||
ScrollArea::new("tree-demo")
|
||||
.size_full()
|
||||
.child(demo_section(
|
||||
"outline",
|
||||
colors,
|
||||
Tree::new("tree-demo-tree")
|
||||
.selected(self.selected)
|
||||
.on_change(move |selected, _, cx| {
|
||||
view.update(cx, |demo, cx| {
|
||||
demo.selected = selected;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.on_disclosure(move |index, _, cx| {
|
||||
view_disclosure.update(cx, |demo, cx| {
|
||||
let paths = TreeDemo::paths(&demo.nodes);
|
||||
if let Some(path) = paths.get(index) {
|
||||
TreeDemo::toggle(&mut demo.nodes, path);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.items(move |_, _| items.clone()),
|
||||
))
|
||||
ScrollArea::new("tree-demo").size_full().child(demo_section(
|
||||
"outline",
|
||||
colors,
|
||||
Tree::new("tree-demo-tree")
|
||||
.selected(self.selected)
|
||||
.on_change(move |selected, _, cx| {
|
||||
view.update(cx, |demo, cx| {
|
||||
demo.selected = selected;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.on_disclosure(move |index, _, cx| {
|
||||
view_disclosure.update(cx, |demo, cx| {
|
||||
let paths = TreeDemo::paths(&demo.nodes);
|
||||
if let Some(path) = paths.get(index) {
|
||||
TreeDemo::toggle(&mut demo.nodes, path);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.items(move |_, _| items.clone()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2645,42 +2647,40 @@ impl Render for MenuDemo {
|
||||
let view = cx.entity();
|
||||
let colors = cx.theme().colors;
|
||||
|
||||
ScrollArea::new("menu-demo")
|
||||
.size_full()
|
||||
.child(demo_section(
|
||||
"trigger and items",
|
||||
colors,
|
||||
Menu::new("menu-demo-menu")
|
||||
.open(self.open)
|
||||
.on_change(move |open, _, cx| {
|
||||
view.update(cx, |demo, cx| {
|
||||
demo.open = open;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.trigger(|_, _| {
|
||||
Button::new("menu-demo-trigger")
|
||||
.label("Actions")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.into_any_element()
|
||||
})
|
||||
.items(|_, _| {
|
||||
vec![
|
||||
MenuItem::new("Cut").shortcut("cmd-x").handler(|_, cx| {
|
||||
eprintln!("menu: cut");
|
||||
let _ = cx;
|
||||
}),
|
||||
MenuItem::new("Copy").shortcut("cmd-c"),
|
||||
MenuItem::new("Paste").shortcut("cmd-v"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Use quiet mode").checked(true),
|
||||
MenuItem::new("Show hidden files"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Unavailable").disabled(true),
|
||||
MenuItem::new("Delete").destructive(true),
|
||||
]
|
||||
}),
|
||||
))
|
||||
ScrollArea::new("menu-demo").size_full().child(demo_section(
|
||||
"trigger and items",
|
||||
colors,
|
||||
Menu::new("menu-demo-menu")
|
||||
.open(self.open)
|
||||
.on_change(move |open, _, cx| {
|
||||
view.update(cx, |demo, cx| {
|
||||
demo.open = open;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.trigger(|_, _| {
|
||||
Button::new("menu-demo-trigger")
|
||||
.label("Actions")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.into_any_element()
|
||||
})
|
||||
.items(|_, _| {
|
||||
vec![
|
||||
MenuItem::new("Cut").shortcut("cmd-x").handler(|_, cx| {
|
||||
eprintln!("menu: cut");
|
||||
let _ = cx;
|
||||
}),
|
||||
MenuItem::new("Copy").shortcut("cmd-c"),
|
||||
MenuItem::new("Paste").shortcut("cmd-v"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Use quiet mode").checked(true),
|
||||
MenuItem::new("Show hidden files"),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Unavailable").disabled(true),
|
||||
MenuItem::new("Delete").destructive(true),
|
||||
]
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,30 +2835,28 @@ impl Render for DropdownMenuDemo {
|
||||
]
|
||||
});
|
||||
|
||||
ScrollArea::new("dropdown-menu-demo")
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(demo_section(
|
||||
"menu-only trigger and split button",
|
||||
colors,
|
||||
h_flex().gap_3().child(export).child(save),
|
||||
))
|
||||
.child(demo_section(
|
||||
"trigger variants",
|
||||
colors,
|
||||
h_flex().gap_3().child(duplicate).child(delete).child(more),
|
||||
))
|
||||
.child(demo_section(
|
||||
"status",
|
||||
colors,
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(colors.muted_foreground)
|
||||
.child(status),
|
||||
)),
|
||||
)
|
||||
ScrollArea::new("dropdown-menu-demo").size_full().child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(demo_section(
|
||||
"menu-only trigger and split button",
|
||||
colors,
|
||||
h_flex().gap_3().child(export).child(save),
|
||||
))
|
||||
.child(demo_section(
|
||||
"trigger variants",
|
||||
colors,
|
||||
h_flex().gap_3().child(duplicate).child(delete).child(more),
|
||||
))
|
||||
.child(demo_section(
|
||||
"status",
|
||||
colors,
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(colors.muted_foreground)
|
||||
.child(status),
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2871,51 +2869,49 @@ impl Render for ContextMenuDemo {
|
||||
let view = cx.entity();
|
||||
let colors = cx.theme().colors;
|
||||
|
||||
ScrollArea::new("context-menu-demo")
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(demo_section(
|
||||
"on any target",
|
||||
colors,
|
||||
ContextMenu::new("context-menu-demo-menu")
|
||||
.target(move |_, cx| {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(theme.radius.overlay)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.text_size(theme.sizes.medium.text_size)
|
||||
.child("Right-click this card")
|
||||
.into_any_element()
|
||||
})
|
||||
.items(move |_, _| {
|
||||
CONTEXT_ACTIONS
|
||||
.iter()
|
||||
.map(|action| {
|
||||
let view = view.clone();
|
||||
MenuItem::new(*action).handler(move |_, cx| {
|
||||
view.update(cx, |demo, cx| {
|
||||
demo.action = SharedString::from(*action);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
ScrollArea::new("context-menu-demo").size_full().child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(demo_section(
|
||||
"on any target",
|
||||
colors,
|
||||
ContextMenu::new("context-menu-demo-menu")
|
||||
.target(move |_, cx| {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(theme.radius.overlay)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.text_size(theme.sizes.medium.text_size)
|
||||
.child("Right-click this card")
|
||||
.into_any_element()
|
||||
})
|
||||
.items(move |_, _| {
|
||||
CONTEXT_ACTIONS
|
||||
.iter()
|
||||
.map(|action| {
|
||||
let view = view.clone();
|
||||
MenuItem::new(*action).handler(move |_, cx| {
|
||||
view.update(cx, |demo, cx| {
|
||||
demo.action = SharedString::from(*action);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.accessibility_label("Card actions"),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(colors.muted_foreground)
|
||||
.child(self.action.clone()),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.accessibility_label("Card actions"),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(colors.muted_foreground)
|
||||
.child(self.action.clone()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3734,6 +3730,68 @@ impl Render for MarkdownViewDemo {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text view demo's sample document.
|
||||
const TEXT_VIEW_SAMPLE: &str = r###"Try the [relay docs](https://example.com/relay) or jump to [issue #71](https://example.com/issues/71).
|
||||
|
||||
Links route through the handler below — nothing opens on its own.
|
||||
"###;
|
||||
|
||||
/// The text view demo: an embeddable markdown view whose links the app routes.
|
||||
struct TextViewDemo {
|
||||
clicked_links: Vec<String>,
|
||||
}
|
||||
|
||||
impl TextViewDemo {
|
||||
fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
clicked_links: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextViewDemo {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.theme().colors;
|
||||
let radius = cx.theme().radius;
|
||||
let clicks = cx.entity().downgrade();
|
||||
let last_clicked = self.clicked_links.last().cloned();
|
||||
|
||||
ScrollArea::new("text-view-demo").size_full().child(
|
||||
v_flex().gap_3().child(demo_section(
|
||||
"embedded, fit-content; links route through the app",
|
||||
colors,
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.rounded(radius.control)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.p_3()
|
||||
.child(
|
||||
TextView::markdown("text-view-demo", TEXT_VIEW_SAMPLE)
|
||||
.on_link_click(move |url, _window, cx| {
|
||||
clicks
|
||||
.update(cx, |demo, cx| {
|
||||
demo.clicked_links.push(url.to_string());
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().text_xs().text_color(colors.muted_foreground).child(
|
||||
match last_clicked {
|
||||
Some(url) => format!("last routed link: {url}"),
|
||||
None => "click a link to route it".to_string(),
|
||||
},
|
||||
)),
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarDemo {
|
||||
selected: Option<usize>,
|
||||
}
|
||||
@@ -4053,6 +4111,7 @@ fn main() {
|
||||
.run(|cx: &mut App| {
|
||||
gpui_native_core::init(cx);
|
||||
gpui_native_editor::code_view::bind_code_view_keys(cx);
|
||||
gpui_native_editor::markdown::bind_text_view_keys(cx);
|
||||
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("cmd-k", OpenCommandPalette, None),
|
||||
|
||||
Reference in New Issue
Block a user