MarkdownDocument: one caret for every mode, pressed blocks kept across write-backs, preview read only, keyboard reach, history capped on every path
CI / site (push) Successful in 25s
CI / platforms (windows-latest) (push) Successful in 4m37s
CI / crate (push) Failing after 26m50s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 01:14:06 -04:00
1 parent 2b6f5513aa
commit fc181b44c2
13 files changed
+590 -242

No files matched your search

+160
View File
@@ -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));
}
}
+15 -75
View File
@@ -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);
+116
View File
@@ -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));
}
}
+151
View File
@@ -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}"));
}
+61 -152
View File
@@ -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(),
}
}
+2
View File
@@ -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();
}
}
+1
View File
@@ -67,6 +67,7 @@ impl CodeEditor {
&& start.text != self.buffer.text()
{
self.history.record(start, false);
self.cap_history();
}
}
+10
View File
@@ -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
View File
@@ -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());
+34
View File
@@ -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
View File
@@ -1,3 +1,4 @@
mod history;
mod stale;
use std::{cell::Cell, rc::Rc, time::Duration};
+34 -8
View File
@@ -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";