diff --git a/AGENTS.md b/AGENTS.md index 382840d..acd1874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. -- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. Texts up to 128 KiB are read at once, longer ones on the background executor, and a new read keeps the list's blocks alike at both ends. `CodeEditor` keeps at most 64 MiB of undo text and drops the oldest steps past it. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. Texts up to 128 KiB are read at once, longer ones on the background executor, and a new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart, and a mode switch keeps the top line. A pressed block keeps its identity across the write-back of the one before it. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. - Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. diff --git a/src/documents/document/edit.rs b/src/documents/document/edit.rs new file mode 100644 index 0000000..090febf --- /dev/null +++ b/src/documents/document/edit.rs @@ -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)>) -> Option { + 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, + at_start: bool, + window: &mut Window, + cx: &mut Context, + ) { + 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) -> Option<(Range, 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) -> 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)); + } +} diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 7d76c10..8e0096d 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -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>, list: ListState, open: Option, - /// 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, _subscriptions: Vec, @@ -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.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, cx: &mut Context) { 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.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) { + // 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); diff --git a/src/documents/document/place.rs b/src/documents/document/place.rs new file mode 100644 index 0000000..79a5e45 --- /dev/null +++ b/src/documents/document/place.rs @@ -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.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) { + 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.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)); + } +} diff --git a/src/documents/document/tests.rs b/src/documents/document/tests.rs index 2a9a0dd..b796b0c 100644 --- a/src/documents/document/tests.rs +++ b/src/documents/document/tests.rs @@ -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, 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, + ix: Option, + 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, 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::(); + 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}")); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 61e16a2..c7a1c2e 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -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, - at_start: bool, - window: &mut Window, - cx: &mut Context, - ) { - 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) { - 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) -> 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) -> 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, + ix: Option, + cx: &mut Context, + ) -> gpui::Stateful { + 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) -> 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(), } } diff --git a/src/editor/edit.rs b/src/editor/edit.rs index adfd9e8..50ac7d4 100644 --- a/src/editor/edit.rs +++ b/src/editor/edit.rs @@ -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(); } } diff --git a/src/editor/ime.rs b/src/editor/ime.rs index 59d1acc..e5effea 100644 --- a/src/editor/ime.rs +++ b/src/editor/ime.rs @@ -67,6 +67,7 @@ impl CodeEditor { && start.text != self.buffer.text() { self.history.record(start, false); + self.cap_history(); } } diff --git a/src/editor/prose.rs b/src/editor/prose.rs index 9bc8670..feaaf8b 100644 --- a/src/editor/prose.rs +++ b/src/editor/prose.rs @@ -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, HighlightStyle)> { let Some(LineHighlighter(highlight)) = &self.options.highlighter else { diff --git a/src/editor/state.rs b/src/editor/state.rs index 8f7e5f7..1265c81 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -103,6 +103,8 @@ pub struct CodeEditor { pub(crate) buffer: Buffer, pub(crate) selections: Vec, pub(crate) history: History, + /// 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, @@ -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()); diff --git a/src/editor/tests/history.rs b/src/editor/tests/history.rs new file mode 100644 index 0000000..4c0fd39 --- /dev/null +++ b/src/editor/tests/history.rs @@ -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"); + }); +} diff --git a/src/editor/tests/mod.rs b/src/editor/tests/mod.rs index 67f3879..9d0dabe 100644 --- a/src/editor/tests/mod.rs +++ b/src/editor/tests/mod.rs @@ -1,3 +1,4 @@ +mod history; mod stale; use std::{cell::Cell, rc::Rc, time::Duration}; diff --git a/src/forms/text/edit.rs b/src/forms/text/edit.rs index 5563808..abff350 100644 --- a/src/forms/text/edit.rs +++ b/src/forms/text/edit.rs @@ -144,16 +144,26 @@ impl History { 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";