MarkdownDocument: one caret for every mode, pressed blocks kept across write-backs, preview read only, keyboard reach, history capped on every path
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2b6f5513aa
commit
fc181b44c2
13 files changed
+590
-242
No files matched your search
@@ -0,0 +1,160 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{AppContext as _, Context, Focusable as _, Window};
|
||||
|
||||
use super::{DocumentEvent, MarkdownDocument, Open};
|
||||
use crate::{
|
||||
documents::{
|
||||
markdown::markdown_highlights,
|
||||
modes::{at_edge, joint},
|
||||
},
|
||||
forms::{InputEvent, TextInput},
|
||||
};
|
||||
|
||||
/// Where byte `at` lies after `edit`, if it survives.
|
||||
fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
|
||||
match edit {
|
||||
None => Some(at),
|
||||
Some((replaced, _)) if at < replaced.start => Some(at),
|
||||
Some((replaced, written)) if at >= replaced.end => Some(at - replaced.len() + written),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// Opens block `ix` as shown, or one past the end.
|
||||
pub(super) fn open(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
at_start: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let pressed = match ix.map(|ix| self.parsed.blocks.get(ix)) {
|
||||
Some(None) => return log::warn!("markdown document: a press on a block read away"),
|
||||
pressed => pressed.flatten().map(|block| block.range.start),
|
||||
};
|
||||
let written = self.commit(cx);
|
||||
if written.is_some() {
|
||||
self.reparse(cx);
|
||||
}
|
||||
if !self.read_now() {
|
||||
return log::warn!("markdown document: pressed while the text is read again");
|
||||
}
|
||||
let text = self.editor.read(cx).text();
|
||||
let (range, append) = match pressed {
|
||||
None => (text.len()..text.len(), true),
|
||||
Some(start) => {
|
||||
let found = moved(start, &written).and_then(|start| {
|
||||
self.parsed
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|block| block.range.start == start)
|
||||
});
|
||||
let Some(block) = found else {
|
||||
return log::warn!("markdown document: the pressed block changed as one wrote");
|
||||
};
|
||||
(block.range.clone(), false)
|
||||
}
|
||||
};
|
||||
let source = text[range.clone()].to_string();
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(1, usize::MAX)
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(source, cx);
|
||||
if at_start {
|
||||
input.select(0..0, cx);
|
||||
}
|
||||
});
|
||||
window.focus(&input.focus_handle(cx), cx);
|
||||
let subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&input, |document, _, event: &InputEvent, cx| match event {
|
||||
InputEvent::Blur => {
|
||||
document.commit(cx);
|
||||
}
|
||||
InputEvent::Changed => cx.emit(DocumentEvent::Changed),
|
||||
_ => {}
|
||||
}),
|
||||
];
|
||||
log::info!("markdown document: block {range:?} opens");
|
||||
self.open = Some(Open {
|
||||
range,
|
||||
input,
|
||||
append,
|
||||
_subscriptions: subscriptions,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Writes the open block as one step; the edit made.
|
||||
pub(super) fn commit(&mut self, cx: &mut Context<Self>) -> Option<(Range<usize>, usize)> {
|
||||
let open = self.open.take()?;
|
||||
cx.notify();
|
||||
let typed = open.input.read(cx).text().to_string();
|
||||
let caret = open.input.read(cx).cursor();
|
||||
let text = self.editor.read(cx).text();
|
||||
let (lead, written) = match open.append {
|
||||
true if typed.is_empty() => return None,
|
||||
true => (joint(text).len(), format!("{}{typed}", joint(text))),
|
||||
false => (0, typed),
|
||||
};
|
||||
let at = open.range.start + lead + caret;
|
||||
let edit = (text[open.range.clone()] != written).then(|| {
|
||||
log::info!("markdown document: block {:?} written back", open.range);
|
||||
(open.range.clone(), written.len())
|
||||
});
|
||||
if edit.is_some() {
|
||||
self.writes += 1;
|
||||
let start = open.range.start;
|
||||
let landed = self.editor.update(cx, |editor, cx| {
|
||||
editor.edit([(open.range, written.clone())], cx);
|
||||
editor.text().get(start..start + written.len()) == Some(written.as_str())
|
||||
});
|
||||
if !landed {
|
||||
self.writes -= 1;
|
||||
log::error!("markdown document: the editor refused a write-back");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// The one caret follows the block's.
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
edit
|
||||
}
|
||||
|
||||
/// Up past the open block's top, or down past it.
|
||||
pub(super) fn step(&mut self, down: bool, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let Some(open) = &self.open else {
|
||||
return false;
|
||||
};
|
||||
if open.append || !at_edge(open.input.read(cx), down) {
|
||||
return false;
|
||||
}
|
||||
let here = self.block_at(open.range.start);
|
||||
let next = match down {
|
||||
true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()),
|
||||
false => here.checked_sub(1),
|
||||
};
|
||||
let Some(next) = next else {
|
||||
return false;
|
||||
};
|
||||
self.open(Some(next), down, window, cx);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_place_moves_with_an_edit_before_it() {
|
||||
let edit = Some((2..6, 9));
|
||||
assert_eq!(super::moved(10, &edit), Some(15));
|
||||
assert_eq!(super::moved(1, &edit), Some(1), "before stays");
|
||||
assert_eq!(super::moved(3, &edit), None, "inside is gone");
|
||||
assert_eq!(super::moved(7, &None), Some(7));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod edit;
|
||||
mod parse;
|
||||
mod place;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod visual;
|
||||
@@ -54,8 +56,8 @@ pub struct MarkdownDocument {
|
||||
_parsing: Option<Task<()>>,
|
||||
list: ListState,
|
||||
open: Option<Open>,
|
||||
/// Where the caret rests while no block is open.
|
||||
rest: usize,
|
||||
/// Its own write-backs whose changes are still to come.
|
||||
writes: usize,
|
||||
focus: FocusHandle,
|
||||
on_link: Option<OnLink>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
@@ -102,7 +104,7 @@ impl MarkdownDocument {
|
||||
_parsing: None,
|
||||
list: ListState::new(1, ListAlignment::Top, overdraw),
|
||||
open: None,
|
||||
rest: 0,
|
||||
writes: 0,
|
||||
focus: cx.focus_handle(),
|
||||
on_link: None,
|
||||
_subscriptions: subscriptions,
|
||||
@@ -130,11 +132,13 @@ impl MarkdownDocument {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Shows the text as `mode`; an open block writes first.
|
||||
/// Shows `mode` at the same line; an open block writes.
|
||||
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);
|
||||
let top = self.top_line(cx);
|
||||
self.mode = mode;
|
||||
self.set_top_line(top, cx);
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
cx.notify();
|
||||
@@ -165,7 +169,6 @@ impl MarkdownDocument {
|
||||
/// 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));
|
||||
}
|
||||
|
||||
@@ -185,77 +188,14 @@ impl MarkdownDocument {
|
||||
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>) {
|
||||
// Only the document writes while a block is open.
|
||||
if self.writes > 0 {
|
||||
self.writes -= 1;
|
||||
} else if self.open.is_some() {
|
||||
log::error!("markdown document: the text changed under the open block");
|
||||
self.drop_open(cx);
|
||||
}
|
||||
self.edits += 1;
|
||||
cx.emit(DocumentEvent::Changed);
|
||||
self.reparse(cx);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use gpui::{App, Context, Focusable as _, ListOffset, ScrollStrategy, Window, px};
|
||||
|
||||
use super::MarkdownDocument;
|
||||
use crate::documents::{MarkdownMode, modes::joint};
|
||||
|
||||
/// Line and column, from zero, of byte `at` in `text`.
|
||||
fn point(text: &str, at: usize) -> (usize, usize) {
|
||||
let before = &text[..at];
|
||||
let line_start = before.rfind('\n').map_or(0, |newline| newline + 1);
|
||||
(
|
||||
before.matches('\n').count(),
|
||||
before[line_start..].chars().count(),
|
||||
)
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// The caret's line and column from zero, every mode.
|
||||
pub fn caret(&self, cx: &App) -> (usize, usize) {
|
||||
let editor = self.editor.read(cx);
|
||||
let Some(open) = &self.open else {
|
||||
return point(editor.text(), editor.primary().head);
|
||||
};
|
||||
let input = open.input.read(cx);
|
||||
let joined = match open.append && !input.text().is_empty() {
|
||||
true => joint(editor.text()).len(),
|
||||
false => 0,
|
||||
};
|
||||
point(&self.text(cx), open.range.start + joined + input.cursor())
|
||||
}
|
||||
|
||||
/// The caret to `line` and `column`, clamped to text.
|
||||
pub fn set_caret(&mut self, line: usize, column: usize, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
let editor = self.editor.read(cx);
|
||||
let range = editor
|
||||
.buffer
|
||||
.line_range(line.min(editor.buffer.lines() - 1));
|
||||
let text = &editor.text()[range.clone()];
|
||||
let within = text
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map_or(text.len(), |(at, _)| at);
|
||||
let at = range.start + within;
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let at = self
|
||||
.parsed
|
||||
.blocks
|
||||
.get(ix)
|
||||
.map_or(editor.text().len(), |block| block.range.start);
|
||||
editor.buffer.line_of(at.min(editor.text().len()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls `line` to the top; the caret stays.
|
||||
pub fn set_top_line(&mut self, line: usize, cx: &mut Context<Self>) {
|
||||
let editor = self.editor.read(cx);
|
||||
let line = line.min(editor.buffer.lines() - 1);
|
||||
let (scroll, at) = (editor.scroll.clone(), editor.buffer.line_range(line).start);
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => {
|
||||
scroll.scroll_to_item_strict(line, ScrollStrategy::Top)
|
||||
}
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => {
|
||||
let item_ix = self.block_at(at);
|
||||
self.list.scroll_to(ListOffset {
|
||||
item_ix,
|
||||
offset_in_item: px(0.),
|
||||
});
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The block holding byte `at`, or the one before it.
|
||||
pub(super) fn block_at(&self, at: usize) -> usize {
|
||||
self.parsed
|
||||
.blocks
|
||||
.partition_point(|block| block.range.start <= at)
|
||||
.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Line `line` in view and focus, caret at start.
|
||||
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_caret(line, 0, cx);
|
||||
let at = self.editor.read(cx).primary().head;
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => {}
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => {
|
||||
self.list.scroll_to_reveal_item(self.block_at(at))
|
||||
}
|
||||
}
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn points_count_lines_and_characters() {
|
||||
assert_eq!(super::point("ab\nçd\n", 6), (1, 2));
|
||||
assert_eq!(super::point("", 0), (0, 0));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,15 @@ fn press(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// A few frames, so deferred scrolls land.
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn text(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> String {
|
||||
document.read_with(cx, |document, cx| document.text(cx))
|
||||
}
|
||||
@@ -242,3 +251,145 @@ fn down_past_the_last_block_keeps_it_open(cx: &mut TestAppContext) {
|
||||
});
|
||||
assert_eq!(opened, "Last.");
|
||||
}
|
||||
|
||||
/// Opens block `ix` as shown, its field set to `typed`.
|
||||
fn type_in(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
ix: Option<usize>,
|
||||
typed: Option<&str>,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.open(ix, false, window, cx);
|
||||
if let (Some(typed), Some(open)) = (typed, &document.open) {
|
||||
open.input.update(cx, |input, cx| input.set_text(typed, cx));
|
||||
}
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn opened(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> String {
|
||||
document.read_with(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("a block is open");
|
||||
open.input.read(cx).text().to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_after_deleting_a_block_opens_the_one_pressed(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(
|
||||
"A
|
||||
|
||||
B
|
||||
|
||||
C",
|
||||
MarkdownMode::Visual,
|
||||
cx,
|
||||
);
|
||||
type_in(&document, Some(0), Some(""), cx);
|
||||
type_in(&document, Some(2), None, cx);
|
||||
assert_eq!(opened(&document, cx), "C");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_after_splitting_a_block_opens_the_one_pressed(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(
|
||||
"A
|
||||
|
||||
B
|
||||
|
||||
C",
|
||||
MarkdownMode::Visual,
|
||||
cx,
|
||||
);
|
||||
type_in(
|
||||
&document,
|
||||
Some(0),
|
||||
Some(
|
||||
"A
|
||||
|
||||
New",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
type_in(&document, Some(1), None, cx);
|
||||
assert_eq!(opened(&document, cx), "B");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_caret_survives_an_undo_inside_a_character(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("éabcd", MarkdownMode::Visual, cx);
|
||||
type_in(&document, Some(0), Some("x"), cx);
|
||||
cx.simulate_keystrokes("escape");
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), "éabcd");
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (0, 0), "the restored caret");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_takes_no_undo_or_redo(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| document.set_text("Changed", cx));
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Preview, window, cx)
|
||||
});
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), "Changed");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_added_block_reports_its_caret_after_the_joint(cx: &mut TestAppContext) {
|
||||
for start in ["A", "A\n", "A\n\n"] {
|
||||
let (document, cx) = open(start, MarkdownMode::Visual, cx);
|
||||
type_in(&document, None, Some("B"), cx);
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (2, 1), "after {start:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn caret_and_view_set_apart_and_carry_across_modes(cx: &mut TestAppContext) {
|
||||
let long = (0..60).map(|n| format!("Line {n}\n\n")).collect::<String>();
|
||||
let (document, cx) = open(&long, MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(4, 3, cx));
|
||||
document.update(cx, |document, cx| document.set_top_line(40, cx));
|
||||
frames(cx);
|
||||
let source = document.read_with(cx, |document, cx| document.top_line(cx));
|
||||
assert_eq!(source, 40, "the source scrolled");
|
||||
let caret = document.read_with(cx, |document, cx| document.caret(cx));
|
||||
assert_eq!(caret, (4, 3), "the view moved alone");
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
frames(cx);
|
||||
let (caret, top) = document.read_with(cx, |document, cx| {
|
||||
(document.caret(cx), document.top_line(cx))
|
||||
});
|
||||
assert_eq!(caret, (4, 3), "one caret for every mode");
|
||||
assert_eq!(top, 40, "the view stays at its line");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_keyboard_reaches_and_opens_a_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
cx.update(|window, cx| window.focus_next(cx));
|
||||
cx.simulate_keystrokes("enter");
|
||||
frames(cx);
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse("enter").expect("a key"),
|
||||
});
|
||||
frames(cx);
|
||||
assert_eq!(opened(&document, cx), "# Lift");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_change_under_an_open_block_closes_it(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
type_in(&document, Some(2), Some("- three"), cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.edit([(0..0, "x".to_string())], cx));
|
||||
cx.run_until_parked();
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
assert_eq!(text(&document, cx), format!("x{TEXT}"));
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Focusable as _, FontWeight, InteractiveElement,
|
||||
IntoElement, KeyBinding, MouseButton, ParentElement, Render, Styled, Window, actions, div,
|
||||
list,
|
||||
AnyElement, App, ClickEvent, Context, FontWeight, InteractiveElement, IntoElement, KeyBinding,
|
||||
MouseButton, MouseDownEvent, ParentElement, Render, StatefulInteractiveElement, Styled, Window,
|
||||
actions, div, list,
|
||||
};
|
||||
|
||||
use super::{DocumentEvent, MarkdownDocument, Open};
|
||||
use super::MarkdownDocument;
|
||||
use crate::{
|
||||
documents::{
|
||||
MarkdownMode,
|
||||
markdown::markdown_highlights,
|
||||
modes::{at_edge, joint},
|
||||
render::draw_nodes,
|
||||
},
|
||||
forms::{Down, InputEvent, TextInput, Up},
|
||||
documents::{MarkdownMode, render::draw_nodes},
|
||||
forms::{Down, Up},
|
||||
primitives::FocusRing as _,
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
|
||||
@@ -34,104 +30,6 @@ pub(crate) fn bind_keys(cx: &mut App) {
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// Opens block `ix`, or a new one past the end.
|
||||
pub(super) fn open(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
at_start: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.commit(cx);
|
||||
if !self.read_now() {
|
||||
return log::warn!("markdown document: pressed while the text is read again");
|
||||
}
|
||||
let text = self.editor.read(cx).text();
|
||||
let (range, append) = match ix {
|
||||
Some(ix) => (self.parsed.blocks[ix].range.clone(), false),
|
||||
None => (text.len()..text.len(), true),
|
||||
};
|
||||
let source = text[range.clone()].to_string();
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(1, usize::MAX)
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(source, cx);
|
||||
if at_start {
|
||||
input.select(0..0, cx);
|
||||
}
|
||||
});
|
||||
window.focus(&input.focus_handle(cx), cx);
|
||||
let subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&input, |document, _, event: &InputEvent, cx| match event {
|
||||
InputEvent::Blur => document.commit(cx),
|
||||
InputEvent::Changed => cx.emit(DocumentEvent::Changed),
|
||||
_ => {}
|
||||
}),
|
||||
];
|
||||
log::info!("markdown document: block {range:?} opens");
|
||||
self.open = Some(Open {
|
||||
range,
|
||||
input,
|
||||
append,
|
||||
_subscriptions: subscriptions,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Writes the open block back as one undo step.
|
||||
pub(super) fn commit(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(open) = self.open.take() else {
|
||||
return;
|
||||
};
|
||||
cx.notify();
|
||||
let typed = open.input.read(cx).text().to_string();
|
||||
let text = self.editor.read(cx).text();
|
||||
let written = match open.append {
|
||||
true if typed.is_empty() => return,
|
||||
true => format!("{}{typed}", joint(text)),
|
||||
false => typed,
|
||||
};
|
||||
self.rest = open.range.start + written.len();
|
||||
if text[open.range.clone()] == written {
|
||||
return;
|
||||
}
|
||||
log::info!("markdown document: block {:?} written back", open.range);
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.edit([(open.range, written)], cx));
|
||||
}
|
||||
|
||||
/// Up past the open block's top, or down past it.
|
||||
fn step(&mut self, down: bool, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let Some(open) = &self.open else {
|
||||
return false;
|
||||
};
|
||||
if open.append || !at_edge(open.input.read(cx), down) {
|
||||
return false;
|
||||
}
|
||||
let start = open.range.start;
|
||||
let here = |blocks: &[super::parse::Block]| {
|
||||
blocks.partition_point(|block| block.range.start <= start)
|
||||
};
|
||||
// The block next door, before writing back.
|
||||
let next = |blocks: &[super::parse::Block]| match down {
|
||||
true => (here(blocks) < blocks.len()).then(|| here(blocks)),
|
||||
false => here(blocks).checked_sub(2),
|
||||
};
|
||||
if next(&self.parsed.blocks).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.commit(cx);
|
||||
let Some(target) = next(&self.parsed.blocks) else {
|
||||
return false;
|
||||
};
|
||||
self.open(Some(target), down, window, cx);
|
||||
true
|
||||
}
|
||||
|
||||
/// List item `ix`: a block, or the end.
|
||||
fn item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
@@ -142,38 +40,27 @@ impl MarkdownDocument {
|
||||
theme.mono_family.clone(),
|
||||
);
|
||||
let editing = self.mode == MarkdownMode::Visual;
|
||||
let Some(block) = self.parsed.blocks.get(ix) else {
|
||||
return match (&self.open, editing) {
|
||||
(Some(open), _) if open.append => div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.child(open.input.clone())
|
||||
.into_any_element(),
|
||||
(_, true) => div()
|
||||
.id("more")
|
||||
.min_h_8()
|
||||
.cursor_text()
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|document, _, window, cx| {
|
||||
window.prevent_default();
|
||||
document.open(None, false, window, cx);
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
(_, false) => div().into_any_element(),
|
||||
};
|
||||
};
|
||||
if let Some(open) = self.open.as_ref().filter(|open| open.range == block.range) {
|
||||
return div()
|
||||
let open = self.open.as_ref();
|
||||
let field = |open: &super::Open| {
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.child(open.input.clone())
|
||||
.into_any_element();
|
||||
.into_any_element()
|
||||
};
|
||||
let Some(block) = self.parsed.blocks.get(ix) else {
|
||||
return match (open, editing) {
|
||||
(Some(open), _) if open.append => field(open),
|
||||
(_, true) => self
|
||||
.pressable(div().id("more").min_h_8(), None, cx)
|
||||
.into_any_element(),
|
||||
(_, false) => div().into_any_element(),
|
||||
};
|
||||
};
|
||||
if let Some(open) = open.filter(|open| open.range == block.range) {
|
||||
return field(open);
|
||||
}
|
||||
let shown = match &block.nodes {
|
||||
Some(nodes) => div()
|
||||
@@ -202,31 +89,46 @@ impl MarkdownDocument {
|
||||
.py_1()
|
||||
.rounded(radius)
|
||||
.child(shown);
|
||||
if !editing {
|
||||
return block.into_any_element();
|
||||
match editing {
|
||||
true => self.pressable(block, Some(ix), cx).into_any_element(),
|
||||
false => block.into_any_element(),
|
||||
}
|
||||
block
|
||||
}
|
||||
|
||||
/// `element` opens block `ix` on a press, Enter or Space.
|
||||
fn pressable(
|
||||
&self,
|
||||
element: gpui::Stateful<gpui::Div>,
|
||||
ix: Option<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> gpui::Stateful<gpui::Div> {
|
||||
let hover = cx.theme().colors.hover.opacity(0.5);
|
||||
element
|
||||
.tab_index(0)
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.focus_ring(cx)
|
||||
.cursor_text()
|
||||
.hover(|block| block.bg(colors.hover.opacity(0.5)))
|
||||
.hover(move |element| element.bg(hover))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |document, event: &gpui::MouseDownEvent, window, cx| {
|
||||
cx.listener(move |document, event: &MouseDownEvent, window, cx| {
|
||||
// Cmd or Ctrl leaves the press to a link.
|
||||
if event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.open(Some(ix), false, window, cx);
|
||||
document.open(ix, false, window, cx);
|
||||
}),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener(move |document, event: &ClickEvent, window, cx| {
|
||||
if event.is_keyboard() {
|
||||
document.open(ix, false, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn blocks(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
list(self.list.clone(), cx.processor(Self::item))
|
||||
.size_full()
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,14 +139,21 @@ impl Render for MarkdownDocument {
|
||||
.size_full()
|
||||
.bg(colors.surface)
|
||||
.font_weight(FontWeight::NORMAL);
|
||||
let blocks = list(self.list.clone(), cx.processor(Self::item)).size_full();
|
||||
match self.mode {
|
||||
MarkdownMode::Source => root.child(self.editor.clone()).into_any_element(),
|
||||
MarkdownMode::Split => root
|
||||
.flex()
|
||||
.child(div().flex_1().min_w_0().h_full().child(self.editor.clone()))
|
||||
.child(div().flex_1().min_w_0().h_full().child(self.blocks(cx)))
|
||||
.child(div().flex_1().min_w_0().h_full().child(blocks))
|
||||
.into_any_element(),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => root
|
||||
MarkdownMode::Preview => root
|
||||
.track_focus(&self.focus)
|
||||
.px_2()
|
||||
.py_3()
|
||||
.child(blocks)
|
||||
.into_any_element(),
|
||||
MarkdownMode::Visual => root
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(cx.listener(|document, _: &Undo, _, cx| {
|
||||
@@ -276,7 +185,7 @@ impl Render for MarkdownDocument {
|
||||
)
|
||||
.px_2()
|
||||
.py_3()
|
||||
.child(self.blocks(cx))
|
||||
.child(blocks)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@ impl CodeEditor {
|
||||
}
|
||||
if let Some(previous) = self.history.undo(self.snapshot()) {
|
||||
self.restore(previous, cx);
|
||||
self.cap_history();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +346,7 @@ impl CodeEditor {
|
||||
}
|
||||
if let Some(next) = self.history.redo(self.snapshot()) {
|
||||
self.restore(next, cx);
|
||||
self.cap_history();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ impl CodeEditor {
|
||||
&& start.text != self.buffer.text()
|
||||
{
|
||||
self.history.record(start, false);
|
||||
self.cap_history();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ impl CodeEditor {
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// Keeps undo within its byte budget, logging what went.
|
||||
pub(crate) fn cap_history(&mut self) {
|
||||
let dropped = self
|
||||
.history
|
||||
.trim(self.history_bytes, |step| step.text.len());
|
||||
if dropped > 0 {
|
||||
log::info!("code editor: {dropped} far undo steps dropped");
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's colors, as the owner or as code.
|
||||
pub(crate) fn line_colors(&self, text: &str, cx: &App) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
let Some(LineHighlighter(highlight)) = &self.options.highlighter else {
|
||||
|
||||
+4
-6
@@ -103,6 +103,8 @@ pub struct CodeEditor {
|
||||
pub(crate) buffer: Buffer,
|
||||
pub(crate) selections: Vec<Selection>,
|
||||
pub(crate) history: History<Snapshot>,
|
||||
/// Undo keeps at most this many bytes of text.
|
||||
pub(crate) history_bytes: usize,
|
||||
pub(crate) focus: FocusHandle,
|
||||
pub(crate) scroll: UniformListScrollHandle,
|
||||
pub(crate) folded: BTreeSet<usize>,
|
||||
@@ -172,6 +174,7 @@ impl CodeEditor {
|
||||
buffer: Buffer::new(text),
|
||||
selections: vec![Selection::caret(0)],
|
||||
history: History::default(),
|
||||
history_bytes: super::prose::HISTORY_BYTES,
|
||||
focus,
|
||||
scroll: UniformListScrollHandle::new(),
|
||||
folded: BTreeSet::new(),
|
||||
@@ -432,12 +435,7 @@ impl CodeEditor {
|
||||
if before.text != self.buffer.text() {
|
||||
if self.composing.is_none() {
|
||||
self.history.record(before, typing);
|
||||
let dropped = self
|
||||
.history
|
||||
.trim(super::prose::HISTORY_BYTES, |step| step.text.len());
|
||||
if dropped > 0 {
|
||||
log::info!("code editor: {dropped} oldest undo steps dropped");
|
||||
}
|
||||
self.cap_history();
|
||||
}
|
||||
self.marks.ghost = None;
|
||||
self.folded.retain(|line| *line < self.buffer.lines());
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use gpui::{EntityInputHandler, TestAppContext};
|
||||
|
||||
use super::editor;
|
||||
|
||||
/// Bytes of text all undo and redo steps hold.
|
||||
fn held(editor: &super::CodeEditor) -> usize {
|
||||
editor.history.held(|step| step.text.len())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn compositions_keep_undo_within_its_budget(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor(&"x".repeat(100), cx);
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.history_bytes = 250;
|
||||
for word in ["a", "b", "c", "d"] {
|
||||
editor.replace_and_mark_text_in_range(None, word, None, window, cx);
|
||||
editor.unmark_text(window, cx);
|
||||
}
|
||||
assert!(held(editor) <= 250, "{} bytes held", held(editor));
|
||||
assert!(editor.history.can_undo(), "the nearest steps stay");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn undo_keeps_redo_within_its_budget(cx: &mut TestAppContext) {
|
||||
let (editor, cx) = editor("small", cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.history_bytes = 50;
|
||||
editor.edit([(0..5, "y".repeat(80))], cx);
|
||||
editor.undo(cx);
|
||||
assert_eq!(editor.text(), "small");
|
||||
assert!(!editor.history.can_redo(), "a redo past the budget went");
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod history;
|
||||
mod stale;
|
||||
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
+34
-8
@@ -144,16 +144,26 @@ impl<S> History<S> {
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// Drops oldest undo steps past `budget`; returns how many.
|
||||
/// The size all steps hold, as `size` measures one.
|
||||
pub fn held(&self, size: impl Fn(&S) -> usize) -> usize {
|
||||
self.undo.iter().chain(&self.redo).map(size).sum()
|
||||
}
|
||||
|
||||
/// Drops oldest undo, then farthest redo, past `budget`; how many.
|
||||
pub fn trim(&mut self, budget: usize, size: impl Fn(&S) -> usize) -> usize {
|
||||
let mut held: usize = self.undo.iter().chain(&self.redo).map(&size).sum();
|
||||
let mut dropped = 0;
|
||||
while held > budget && dropped < self.undo.len() {
|
||||
held -= size(&self.undo[dropped]);
|
||||
dropped += 1;
|
||||
let mut held = self.held(&size);
|
||||
let (mut undone, mut redone) = (0, 0);
|
||||
while held > budget && undone < self.undo.len() {
|
||||
held -= size(&self.undo[undone]);
|
||||
undone += 1;
|
||||
}
|
||||
self.undo.drain(..dropped);
|
||||
dropped
|
||||
while held > budget && redone < self.redo.len() {
|
||||
held -= size(&self.redo[redone]);
|
||||
redone += 1;
|
||||
}
|
||||
self.undo.drain(..undone);
|
||||
self.redo.drain(..redone);
|
||||
undone + redone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +184,22 @@ mod tests {
|
||||
assert_eq!(history.trim(100, String::len), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_drops_the_farthest_redo_when_undo_is_spent() {
|
||||
let mut history = History::default();
|
||||
history.record("small".to_string(), false);
|
||||
// Undo leaves the large text to redo.
|
||||
assert_eq!(history.undo("x".repeat(20)), Some("small".to_string()));
|
||||
history.redo.insert(0, "y".repeat(20));
|
||||
assert_eq!(history.trim(25, String::len), 1);
|
||||
assert_eq!(
|
||||
history.redo("small".into()),
|
||||
Some("x".repeat(20)),
|
||||
"the nearest stays"
|
||||
);
|
||||
assert_eq!(history.redo("x".repeat(20)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphemes_keep_clusters_whole() {
|
||||
let text = "ae\u{301}🇯🇵z";
|
||||
|
||||
Reference in new issue
Block a user