MarkdownDocument: a Markdown file in one CodeEditor, shown visual, source, split or preview
Visual draws the top-level blocks in a gpui list; a pressed block opens as its source and its typing is the text at once, written back as one undo step. CodeEditor takes the owner's line colors, resets with a fresh history and keeps at most 64 MiB of undo text. MarkdownMode gains Preview. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9036c92442
commit
2b6f5513aa
19 files changed
+1267
-86
No files matched your search
@@ -0,0 +1,301 @@
|
||||
mod parse;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod visual;
|
||||
|
||||
pub(crate) use visual::bind_keys;
|
||||
|
||||
use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||
ListState, Subscription, Task, Window,
|
||||
};
|
||||
|
||||
pub use parse::DocumentHeading;
|
||||
use parse::{Parsed, alike, parsed};
|
||||
|
||||
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink};
|
||||
use crate::{
|
||||
editor::{CodeEditor, EditorEvent, LineNumbers},
|
||||
forms::TextInput,
|
||||
};
|
||||
|
||||
/// Texts this long parse at once; longer ones elsewhere.
|
||||
const AT_ONCE: usize = 128 << 10;
|
||||
|
||||
/// What the document tells its owner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DocumentEvent {
|
||||
/// The text changed, typed in any mode or replaced.
|
||||
Changed,
|
||||
/// A new read of the text: blocks and headings.
|
||||
Parsed,
|
||||
}
|
||||
|
||||
/// The block open as its source, in its own field.
|
||||
struct Open {
|
||||
range: Range<usize>,
|
||||
input: Entity<TextInput>,
|
||||
/// Opened past the end, to add a block.
|
||||
append: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// A Markdown file: one text, one history, three views.
|
||||
pub struct MarkdownDocument {
|
||||
editor: Entity<CodeEditor>,
|
||||
mode: MarkdownMode,
|
||||
base: Option<Arc<Path>>,
|
||||
parsed: Arc<Parsed>,
|
||||
/// Text changes counted; `read` is the one parsed.
|
||||
edits: u64,
|
||||
read: u64,
|
||||
_parsing: Option<Task<()>>,
|
||||
list: ListState,
|
||||
open: Option<Open>,
|
||||
/// Where the caret rests while no block is open.
|
||||
rest: usize,
|
||||
focus: FocusHandle,
|
||||
on_link: Option<OnLink>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<DocumentEvent> for MarkdownDocument {}
|
||||
|
||||
impl Focusable for MarkdownDocument {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => self.editor.focus_handle(cx),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => self.focus.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
pub fn new(
|
||||
text: impl Into<String>,
|
||||
mode: MarkdownMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
CodeEditor::new(text, window, cx)
|
||||
.language("Markdown")
|
||||
.line_numbers(LineNumbers::Hidden)
|
||||
.hide_indent_guides()
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
let subscriptions = vec![cx.subscribe(&editor, |document, _, event, cx| {
|
||||
if *event == EditorEvent::Changed {
|
||||
document.changed(cx);
|
||||
}
|
||||
})];
|
||||
let overdraw = window.rem_size() * 40.0;
|
||||
let mut document = Self {
|
||||
editor,
|
||||
mode,
|
||||
base: None,
|
||||
parsed: Arc::default(),
|
||||
edits: 0,
|
||||
read: u64::MAX,
|
||||
_parsing: None,
|
||||
list: ListState::new(1, ListAlignment::Top, overdraw),
|
||||
open: None,
|
||||
rest: 0,
|
||||
focus: cx.focus_handle(),
|
||||
on_link: None,
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
document.reparse(cx);
|
||||
document
|
||||
}
|
||||
|
||||
/// The folder relative pictures load from.
|
||||
pub fn set_base(&mut self, folder: impl Into<Arc<Path>>, cx: &mut Context<Self>) {
|
||||
self.base = Some(folder.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Takes link presses; without it the system opens them.
|
||||
pub fn on_link(&mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) {
|
||||
self.on_link = Some(Rc::new(handler));
|
||||
}
|
||||
|
||||
pub fn editor(&self) -> &Entity<CodeEditor> {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> MarkdownMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Shows the text as `mode`; an open block writes first.
|
||||
pub fn set_mode(&mut self, mode: MarkdownMode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
log::info!("markdown document: {:?} to {mode:?}", self.mode);
|
||||
self.mode = mode;
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The text now, an open block's typing included.
|
||||
pub fn text(&self, cx: &App) -> String {
|
||||
let text = self.editor.read(cx).text();
|
||||
let Some(open) = &self.open else {
|
||||
return text.to_string();
|
||||
};
|
||||
let typed = open.input.read(cx).text();
|
||||
let written = match open.append {
|
||||
true if typed.is_empty() => String::new(),
|
||||
true => format!("{}{typed}", joint(text)),
|
||||
false => typed.to_string(),
|
||||
};
|
||||
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
|
||||
}
|
||||
|
||||
/// Replaces the text in one step; open typing drops.
|
||||
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
self.drop_open(cx);
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.set_text(text, cx));
|
||||
}
|
||||
|
||||
/// Replaces the text, undo afresh; open typing drops.
|
||||
pub fn reset(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
self.drop_open(cx);
|
||||
self.rest = 0;
|
||||
self.editor.update(cx, |editor, cx| editor.reset(text, cx));
|
||||
}
|
||||
|
||||
/// The headings as last read, with lines from zero.
|
||||
pub fn outline(&self, cx: &App) -> Vec<(DocumentHeading, usize)> {
|
||||
let editor = self.editor.read(cx);
|
||||
self.parsed
|
||||
.headings
|
||||
.iter()
|
||||
.filter(|heading| heading.at <= editor.text().len())
|
||||
.map(|heading| (heading.clone(), editor.buffer.line_of(heading.at)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the blocks shown read the text now.
|
||||
pub fn read_now(&self) -> bool {
|
||||
self.read == self.edits
|
||||
}
|
||||
|
||||
/// The caret's line and column, from zero.
|
||||
pub fn caret(&self, cx: &App) -> (usize, usize) {
|
||||
let editor = self.editor.read(cx);
|
||||
let at = match (&self.open, self.mode) {
|
||||
(_, MarkdownMode::Source | MarkdownMode::Split) => editor.primary().head,
|
||||
(Some(open), _) => {
|
||||
let input = open.input.read(cx);
|
||||
let line = editor
|
||||
.buffer
|
||||
.line_of(open.range.start.min(editor.text().len()));
|
||||
let typed = &input.text()[..input.cursor()];
|
||||
let column = match typed.rfind('\n') {
|
||||
Some(newline) => typed[newline + 1..].chars().count(),
|
||||
None => {
|
||||
let start = editor.buffer.line_range(line).start;
|
||||
editor.text()[start..open.range.start].chars().count()
|
||||
+ typed.chars().count()
|
||||
}
|
||||
};
|
||||
return (line + typed.matches('\n').count(), column);
|
||||
}
|
||||
(None, _) => self.rest,
|
||||
};
|
||||
editor.buffer.point(at.min(editor.text().len()))
|
||||
}
|
||||
|
||||
/// The first line in view.
|
||||
pub fn top_line(&self, cx: &App) -> usize {
|
||||
let editor = self.editor.read(cx);
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => editor.top_line().unwrap_or(0),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => {
|
||||
let ix = self.list.logical_scroll_top().item_ix;
|
||||
self.parsed.blocks.get(ix).map_or(0, |block| {
|
||||
editor
|
||||
.buffer
|
||||
.line_of(block.range.start.min(editor.text().len()))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings line `line` into view and focus, caret at start.
|
||||
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
let lines = self.editor.read(cx).buffer.lines();
|
||||
let at = self
|
||||
.editor
|
||||
.read(cx)
|
||||
.buffer
|
||||
.line_range(line.min(lines - 1))
|
||||
.start;
|
||||
self.rest = at;
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx)),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => {
|
||||
let blocks = &self.parsed.blocks;
|
||||
let ix = blocks
|
||||
.partition_point(|block| block.range.start <= at)
|
||||
.saturating_sub(1);
|
||||
self.list.scroll_to_reveal_item(ix);
|
||||
}
|
||||
}
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn changed(&mut self, cx: &mut Context<Self>) {
|
||||
self.edits += 1;
|
||||
cx.emit(DocumentEvent::Changed);
|
||||
self.reparse(cx);
|
||||
}
|
||||
|
||||
/// Reads the text again, at once when short.
|
||||
fn reparse(&mut self, cx: &mut Context<Self>) {
|
||||
let (text, edits) = (self.editor.read(cx).text().to_string(), self.edits);
|
||||
if text.len() <= AT_ONCE {
|
||||
self._parsing = None;
|
||||
return self.adopt(parsed(&text), edits, cx);
|
||||
}
|
||||
self._parsing = Some(cx.spawn(async move |document, cx| {
|
||||
let read = cx.background_spawn(async move { parsed(&text) }).await;
|
||||
document
|
||||
.update(cx, |document, cx| document.adopt(read, edits, cx))
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Takes the read of edit `edits`; older reads drop.
|
||||
fn adopt(&mut self, read: Parsed, edits: u64, cx: &mut Context<Self>) {
|
||||
if edits != self.edits {
|
||||
return log::debug!("markdown document: read {edits} lapsed");
|
||||
}
|
||||
let (head, tail) = alike(&self.parsed, &read);
|
||||
let old = self.parsed.blocks.len();
|
||||
self.list
|
||||
.splice(head..old - tail, read.blocks.len() - head - tail);
|
||||
self.parsed = Arc::new(read);
|
||||
self.read = edits;
|
||||
cx.emit(DocumentEvent::Parsed);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Closes the open block without writing it.
|
||||
fn drop_open(&mut self, cx: &mut Context<Self>) {
|
||||
if self.open.take().is_some() {
|
||||
log::info!("markdown document: the open block closed unwritten");
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user