From 0a5d0921f2fea12c4ec523adf280826c85d58d0f Mon Sep 17 00:00:00 2001 From: ahonn Date: Sat, 3 Oct 2026 01:01:54 +1000 Subject: [PATCH 1/6] perf(a11y): reuse text readings and group distant lines Cache accessible readings, group distant lines, and keep row geometry stable during scrolling. Preserve document selection mappings and cover cache reuse and grouped text with tests. --- crates/markraft-gpui/src/accessibility.rs | 1014 ++++++++++++++++++--- crates/markraft-gpui/src/surface/mod.rs | 2 +- 2 files changed, 881 insertions(+), 135 deletions(-) diff --git a/crates/markraft-gpui/src/accessibility.rs b/crates/markraft-gpui/src/accessibility.rs index 9eda170c..bc634a58 100644 --- a/crates/markraft-gpui/src/accessibility.rs +++ b/crates/markraft-gpui/src/accessibility.rs @@ -1,10 +1,11 @@ //! AccessKit text coordinates are selectable units, not UTF-16 offsets. use crate::shown::{ShownPiece, line_pieces}; use crate::surface::LayoutLine; -use gpui::{A11ySubtreeBuilder, App, Bounds, Entity, Pixels, Role, Window, accesskit}; +use gpui::{A11ySubtreeBuilder, App, Bounds, Entity, Pixels, Role, Window, accesskit, px}; use markraft_core::kind::conceal::Reveal; use markraft_core::projection::{Line, Projection}; use markraft_core::{EditorState, Selection}; +use std::collections::HashMap; use std::ops::Range; use std::sync::Arc; use unicode_segmentation::UnicodeSegmentation; @@ -18,6 +19,16 @@ pub(crate) struct AccessibleText { /// line up with; see [`AccessibleText::push_unlaid`]. unlaid: Vec>, unlaid_of: Option>, + /// The id the next reading of an unlaid line takes. + next_reading: u64, + /// Far lines read together, by the index of the first; see + /// [`AccessibleText::push_far`]. + far: HashMap, + /// What each laid line read as, by its index, for the projection + /// `unlaid_of`; see [`AccessibleText::laid_reading`]. + laid: HashMap, + /// Where the last update placed the content; see [`Space`]. + space: Space, runs: Vec, selection: (usize, usize), controls: Vec, @@ -40,12 +51,39 @@ struct AccessibleControl { bounds: accesskit::Rect, } -fn accessible_bounds(bounds: Bounds, scale: f32) -> accesskit::Rect { - accesskit::Rect { - x0: f64::from(f32::from(bounds.left()) * scale), - y0: f64::from(f32::from(bounds.top()) * scale), - x1: f64::from(f32::from(bounds.right()) * scale), - y1: f64::from(f32::from(bounds.bottom()) * scale), +/// Where the content's rows are told to stand: from the top of the content, +/// not of the window, so a frame that only scrolls moves the editor's node +/// alone. Every frame hands the whole tree over, and a row whose bounds moved +/// is copied whole, text and all. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Space { + /// Where the top of the content stands in the window. + top: Pixels, + scale: f32, +} + +impl Space { + pub(crate) fn new(top: Pixels, scale: f32) -> Self { + Self { top, scale } + } + + fn rect(self, bounds: Bounds) -> accesskit::Rect { + // Rows stand where the window's top and their place in the content + // add up to, so taking the top away again leaves rounding that a + // scroll changes. An eighth of a pixel is finer than anything a + // reader is shown, and keeps a row that did not move as it was. + let snap = |at: Pixels| (f64::from(f32::from(at) * self.scale) * 8.).round() / 8.; + accesskit::Rect { + x0: snap(bounds.left()), + y0: snap(bounds.top() - self.top), + x1: snap(bounds.right()), + y1: snap(bounds.bottom() - self.top), + } + } + + /// Where the content's top stands, in the editor node's own units. + fn offset(self) -> f64 { + f64::from(f32::from(self.top) * self.scale) } } @@ -98,15 +136,60 @@ impl AccessibleControl { /// its text grapheme by grapheme. Positions are the line's own, from where /// it starts, so a line an edit only moved along keeps them. struct Unlaid { - text: String, - offsets: Vec, - positions: Vec, + /// Which reading this is, so lines read together can tell whether any of + /// them was read again. + id: u64, + text: Arc, + lengths: Arc<[u8]>, + positions: Arc<[usize]>, /// Whether the text ends in the break to the next line, which the last /// line has none of. newline: bool, cell: Option<(usize, usize)>, } +/// Unlaid lines far from the screen, read as one run: a reader still reaches +/// them, a line at a time and each table cell with where it stands once the +/// screen comes near, without each costing a node of its own on every frame. +struct Far { + /// The reading of each line, and where the line starts counted from where + /// the first does: while both stay, so does what was read. + lines: Vec<(u64, usize)>, + text: Arc, + lengths: Arc<[u8]>, + /// Counted from where the first line starts. + positions: Arc<[usize]>, +} + +/// A laid line's visual rows as a reader is told them, which only where the +/// rows stand changes while the line keeps its projection, its rows and what +/// the selection reveals of it. +struct Laid { + rows: Vec>, + /// The selection and marked text, when they reveal any of the line. + revealed: Option, + reads: Vec, +} + +/// The selection's range and the marked text's, which say what is revealed. +type Revealing = ((usize, usize), Option<(usize, usize)>); + +struct RowReading { + from: usize, + content_end: usize, + text: Arc, + lengths: Arc<[u8]>, + positions: Arc<[usize]>, +} + +/// How many unlaid lines either side of the laid ones are read a line at a +/// time. Past them, lines are read [`FAR_LINES`] at a time. +const NEAR_LINES: usize = 512; +/// How many far lines one run reads at most. Runs start at multiples of it, so +/// a scroll that moves the near lines along leaves the runs past them as they +/// were. +const FAR_LINES: usize = 128; + /// What an update read that changes what a reader is told. #[derive(PartialEq)] struct Built { @@ -121,14 +204,20 @@ struct Built { struct TextRun { node_id: Option, + /// The line the run reads, and which of its visual rows; a run of far + /// lines is keyed by the first with no row. + key: (usize, usize), /// Document position of the run's first character. from: usize, /// Document position just past the run's own content. content_end: usize, - text: String, - offsets: Vec, - /// Document positions for each selectable unit boundary in `offsets`. - positions: Vec, + text: Arc, + /// The UTF-8 length of each selectable unit. + lengths: Arc<[u8]>, + /// Document positions for each selectable unit boundary, counted from + /// `base`. + positions: Arc<[usize]>, + base: usize, bounds: accesskit::Rect, /// The run's row and column, when it sits in a table cell. /// @@ -148,27 +237,94 @@ struct TextRun { /// displays, every character of which stands before the whole run, so a /// selection can only take or leave it whole. fn shown_row(pieces: &[ShownPiece], inner: Range) -> (String, Vec) { - let mut value = String::new(); - let mut before = Vec::new(); - for piece in pieces { - if piece.own { + ShownRows::new(pieces).row(inner) +} + +/// [`shown_row`] for the visual rows of one line, taken in order: where one +/// row stops is where the next starts looking, so a line of many rows — a +/// long code block is one line — is walked once rather than once a row. +struct ShownRows<'a> { + pieces: &'a [ShownPiece], + /// The piece the last row stopped in. + piece: usize, + /// How far into that piece the last row read, in `char`s and in bytes. + chars: usize, + bytes: usize, + /// Where the last row ended. A row starting before it reads afresh. + end: usize, +} + +impl<'a> ShownRows<'a> { + fn new(pieces: &'a [ShownPiece]) -> Self { + Self { + pieces, + piece: 0, + chars: 0, + bytes: 0, + end: 0, + } + } + + fn row(&mut self, inner: Range) -> (String, Vec) { + if inner.start < self.end { + *self = Self::new(self.pieces); + } + self.end = inner.end; + let mut value = String::new(); + let mut before = Vec::new(); + // Pieces run in line order and do not overlap, so everything before + // the first piece reaching into the row is in none of it. + while let Some(piece) = self.pieces.get(self.piece) { + let past = if piece.own { + piece.source.end <= inner.start + } else { + piece.source.start < inner.start + }; + if !past { + break; + } + self.next_piece(); + } + while let Some(piece) = self.pieces.get(self.piece) { + if piece.source.start >= inner.end { + break; + } + if !piece.own { + for c in piece.text.chars() { + value.push(c); + before.push(piece.source.start); + } + self.next_piece(); + continue; + } let from = piece.source.start.max(inner.start); let to = piece.source.end.min(inner.end); - for (index, c) in piece.text.chars().enumerate() { - let at = piece.source.start + index; - if (from..to).contains(&at) { + let mut chars = piece.text[self.bytes..].chars(); + while self.chars < to - piece.source.start { + let Some(c) = chars.next() else { + break; + }; + let at = piece.source.start + self.chars; + if at >= from { value.push(c); before.push(at); } + self.chars += 1; + self.bytes += c.len_utf8(); } - } else if inner.contains(&piece.source.start) { - for c in piece.text.chars() { - value.push(c); - before.push(piece.source.start); + if to < piece.source.end { + break; } + self.next_piece(); } + (value, before) + } + + fn next_piece(&mut self) { + self.piece += 1; + self.chars = 0; + self.bytes = 0; } - (value, before) } // AccessKit uses u8 for each selectable unit's UTF-8 length. An unusually long @@ -190,6 +346,14 @@ fn character_offsets(text: &str) -> Vec { offsets } +/// The UTF-8 length of each unit between `offsets`. +fn unit_lengths(offsets: &[usize]) -> Vec { + offsets + .windows(2) + .map(|pair| (pair[1] - pair[0]) as u8) + .collect() +} + /// The document position before each unit boundary in `offsets`, of text /// `value` whose characters stand at the line offsets `before`: walked once, /// since a long line has many boundaries. Past the text is `end`. @@ -224,21 +388,30 @@ fn positions_of( } /// The run `read` stands for, with `line` starting where it starts now. -fn unlaid_run(read: &Unlaid, line: &Line, bounds: Bounds, scale: f32) -> TextRun { +fn unlaid_run( + read: &Unlaid, + index: usize, + line: &Line, + bounds: Bounds, + space: Space, +) -> TextRun { TextRun { node_id: None, + key: (index, 0), from: line.from(), content_end: line.to(), - offsets: read.offsets.clone(), - positions: read.positions.iter().map(|&at| line.abs(at)).collect(), + lengths: read.lengths.clone(), + positions: read.positions.clone(), + base: line.from(), text: read.text.clone(), - bounds: accessible_bounds(bounds, scale), + bounds: space.rect(bounds), cell: read.cell, } } /// Line `index`, laid out as one run, as a reader is told it. fn read_unlaid( + id: u64, projection: &Projection, types: &crate::DocTypes, reveal: &Reveal, @@ -258,8 +431,9 @@ fn read_unlaid( .map(|pos| pos - line.from()) .collect(); Unlaid { - text, - offsets, + id, + text: text.into(), + lengths: unit_lengths(&offsets).into(), positions, newline, cell: types @@ -268,6 +442,67 @@ fn read_unlaid( } } +/// The visual rows of laid line `row` as a reader is told them, kept from +/// the last update while the line reads the same. A frame that only +/// scrolls moves where every row stands, and a long code block is one +/// line of thousands of rows. +fn laid_reading( + held: &mut HashMap, + projection: &Projection, + types: &crate::DocTypes, + (reveal, revealing): (&Reveal, Revealing), + row: &LayoutLine, + last_line: usize, +) -> Laid { + let rows = row.accessible_rows(); + let line = projection + .line(row.index) + .expect("a laid line is in the projection"); + let revealed = reveal.touches(line.from(), line.to()).then_some(revealing); + if let Some(kept) = held.remove(&row.index) + && kept.rows == rows + && kept.revealed == revealed + { + return kept; + } + let pieces = line_pieces(projection, types, row.index, reveal); + let mut shown = ShownRows::new(&pieces); + let reads = rows + .iter() + .map(|inner| { + let (mut value, mut before) = shown.row(inner.clone()); + if inner.end == line.len() && row.index < last_line { + value.push('\n'); + before.push(inner.end); + } + let offsets = character_offsets(&value); + let positions = positions_of(line, &value, &before, &offsets, inner.end); + RowReading { + from: if inner.start == 0 { + line.from() + } else { + line.offset_to_pos(inner.start) + .expect("a row starts in its line") + }, + content_end: if inner.end == line.len() { + line.to() + } else { + line.offset_to_pos(inner.end) + .expect("a row ends in its line") + }, + lengths: unit_lengths(&offsets).into(), + positions: positions.into(), + text: value.into(), + } + }) + .collect(); + Laid { + rows, + revealed, + reads, + } +} + impl AccessibleText { pub(crate) fn set_messages(&mut self, messages: crate::EditorMessages) { self.messages = messages; @@ -279,6 +514,8 @@ impl AccessibleText { self.built = None; self.unlaid = Vec::new(); self.unlaid_of = None; + self.far = HashMap::new(); + self.laid = HashMap::new(); self.runs = Vec::new(); self.controls = Vec::new(); } @@ -286,7 +523,9 @@ impl AccessibleText { /// Rebuild what a reader is told from the document and the frame: every /// line, as the visual rows it was laid out in where the frame laid it /// out, and as one run standing where `place` says otherwise, so a reader - /// reaches the whole note however little of it is on screen. + /// reaches the whole note however little of it is on screen. Lines far + /// from the laid ones are read [`FAR_LINES`] to a run: every frame hands + /// the whole tree over, so its size is paid for on each one. pub(crate) fn update( &mut self, projection: &Arc, @@ -294,11 +533,13 @@ impl AccessibleText { types: &crate::DocTypes, rows: &[LayoutLine], place: &dyn Fn(usize) -> Bounds, - scale: f32, + space: Space, ) { let doc = state.doc(); + let Space { top, scale } = space; // Every frame asks, and most change nothing a reader is told: a caret - // blink, a line off screen measured. A line the frame did not lay out + // blink, a line off screen measured, a scroll that lays out the same + // lines. A line the frame did not lay out // keeps the place it was given until something here moves, which only // costs a reader how precisely an unseen line is outlined. let built = Built { @@ -307,25 +548,54 @@ impl AccessibleText { composition: markraft_core::composition::composition_range(state) .map(|range| (range.from, range.to)), rows: rows.len(), - first: rows.first().map(|row| (row.index, row.origin)), - last: rows.last().map(|row| (row.index, row.origin)), + first: rows + .first() + .map(|row| (row.index, row.origin - gpui::point(px(0.), top))), + last: rows + .last() + .map(|row| (row.index, row.origin - gpui::point(px(0.), top))), scale, }; + self.space = space; if self.built.as_ref() == Some(&built) { return; } self.built = Some(built); + // Only the lines laid this time are kept for the next. + let mut laid_held = std::mem::take(&mut self.laid); + if self + .unlaid_of + .as_ref() + .is_none_or(|held| !Arc::ptr_eq(held, projection)) + { + laid_held.clear(); + } self.line_up_unlaid(projection); self.selection = (state.selection().anchor(doc), state.selection().head(doc)); // What a reader hears is what the screen shows: a concealed span reads // as what it displays, and as its source while the caret reveals it. + let revealing = ( + (state.selection().from(doc), state.selection().to(doc)), + markraft_core::composition::composition_range(state) + .map(|range| (range.from, range.to)), + ); let reveal = Reveal::at( - state.selection().from(doc)..state.selection().to(doc), - markraft_core::composition::composition_range(state).map(|range| range.from..range.to), + revealing.0.0..revealing.0.1, + revealing.1.map(|(from, to)| from..to), ); self.runs.clear(); self.controls.clear(); let last_line = projection.line_count().saturating_sub(1); + let near = match (rows.first(), rows.last()) { + (Some(first), Some(last)) => { + first.index.saturating_sub(NEAR_LINES)..last.index + NEAR_LINES + 1 + } + _ => 0..NEAR_LINES, + }; + let mut far = std::mem::take(&mut self.far); + // Far lines not yet in a run, which all fall in one stretch of + // `FAR_LINES`. + let mut pending: Option> = None; let mut laid = rows.iter().peekable(); for index in 0..projection.line_count() { let Some(line) = projection.line(index) else { @@ -333,14 +603,30 @@ impl AccessibleText { }; while laid.next_if(|row| row.index < index).is_some() {} let Some(row) = laid.next_if(|row| row.index == index) else { - self.push_unlaid(projection, types, &reveal, index, place(index), scale); + // A line the selection reveals reads afresh, so it is not read + // with others. A far table cell is, without saying where in + // its table it stands: every cell is a line, and a long note + // can hold thousands. + let alone = near.contains(&index) + || reveal.touches(line.from(), line.to()) + || self.reading(projection, types, &reveal, index).is_none(); + if alone { + self.push_far(&mut far, pending.take(), projection, place, space); + self.push_unlaid(projection, types, &reveal, index, place(index), space); + continue; + } + match &mut pending { + Some(lines) if index / FAR_LINES == lines.start / FAR_LINES => { + lines.end = index + 1; + } + _ => { + self.push_far(&mut far, pending.take(), projection, place, space); + pending = Some(index..index + 1); + } + } continue; }; - let pieces = line_pieces(projection, types, row.index, &reveal); - let prose: String = line_pieces(projection, types, row.index, &Reveal::nothing()) - .iter() - .map(|piece| piece.text.as_str()) - .collect(); + self.push_far(&mut far, pending.take(), projection, place, space); for run in line.runs() { let markraft_core::projection::RunContent::Atom(node) = &run.content else { continue; @@ -370,7 +656,7 @@ impl AccessibleText { action, label, checked: None, - bounds: accessible_bounds(*bounds, scale), + bounds: space.rect(*bounds), }); } } @@ -385,59 +671,55 @@ impl AccessibleText { .messages .format(crate::EditorMessage::Callout, &[("label", label)]), checked: None, - bounds: accessible_bounds(bounds, scale), + bounds: space.rect(bounds), }); } if let Some((checked, bounds)) = row.task_marker() { + let prose: String = line_pieces(projection, types, row.index, &Reveal::nothing()) + .iter() + .map(|piece| piece.text.as_str()) + .collect(); self.controls.push(AccessibleControl { node_id: None, action: ControlAction::ToggleTask(row.from), label: if prose.is_empty() { self.messages.text(crate::EditorMessage::Task) } else { - prose.clone() + prose }, checked: Some(checked), - bounds: accessible_bounds(bounds, scale), + bounds: space.rect(bounds), }); } - for (visual, inner) in row.accessible_rows().into_iter().enumerate() { - let (mut value, mut before) = shown_row(&pieces, inner.clone()); - if inner.end == line.len() && row.index < last_line { - value.push('\n'); - before.push(inner.end); - } - let x = f32::from(row.origin.x) * scale; - let y = f32::from(row.origin.y + row.visual_top(visual)) * scale; - let offsets = character_offsets(&value); - let positions = positions_of(line, &value, &before, &offsets, inner.end); + let laid = laid_reading( + &mut laid_held, + projection, + types, + (&reveal, revealing), + row, + last_line, + ); + for (visual, read) in laid.reads.iter().enumerate() { + let shown = Bounds::new( + gpui::point(row.origin.x, row.origin.y + row.visual_top(visual)), + gpui::size(row.width, row.visual_height(visual)), + ); self.runs.push(TextRun { node_id: None, - from: if inner.start == 0 { - line.from() - } else { - line.offset_to_pos(inner.start) - .expect("a row starts in its line") - }, - content_end: if inner.end == line.len() { - line.to() - } else { - line.offset_to_pos(inner.end) - .expect("a row ends in its line") - }, - offsets, - positions, - text: value, - bounds: accesskit::Rect { - x0: f64::from(x), - y0: f64::from(y), - x1: f64::from(x + f32::from(row.width) * scale), - y1: f64::from(y + f32::from(row.visual_height(visual)) * scale), - }, + key: (index, visual), + from: read.from, + content_end: read.content_end, + lengths: read.lengths.clone(), + positions: read.positions.clone(), + base: 0, + text: read.text.clone(), + bounds: space.rect(shown), cell: row.table.map(|cell| (cell.row, cell.column)), }); } + self.laid.insert(index, laid); } + self.push_far(&mut far, pending, projection, place, space); } /// Line the kept readings of unlaid lines up with `projection`: a line the @@ -471,45 +753,141 @@ impl AccessibleText { reveal: &Reveal, index: usize, bounds: Bounds, - scale: f32, + space: Space, ) { let Some(line) = projection.line(index) else { return; }; - let newline = index + 1 < projection.line_count(); - let touched = reveal.touches(line.from(), line.to()); - if touched { - let read = read_unlaid(projection, types, reveal, index, line, newline); - self.runs.push(unlaid_run(&read, line, bounds, scale)); + if reveal.touches(line.from(), line.to()) { + let newline = index + 1 < projection.line_count(); + let read = read_unlaid(0, projection, types, reveal, index, line, newline); + self.runs + .push(unlaid_run(&read, index, line, bounds, space)); return; } - let Some(slot) = self.unlaid.get_mut(index) else { - return; - }; + if let Some(read) = self.reading(projection, types, reveal, index) { + let run = unlaid_run(read, index, line, bounds, space); + self.runs.push(run); + } + } + + /// The kept reading of line `index`, which the selection does not touch, + /// read now if it has none. + fn reading( + &mut self, + projection: &Projection, + types: &crate::DocTypes, + reveal: &Reveal, + index: usize, + ) -> Option<&Unlaid> { + let line = projection.line(index)?; + let newline = index + 1 < projection.line_count(); + let slot = self.unlaid.get_mut(index)?; if slot.as_ref().is_none_or(|kept| kept.newline != newline) { - *slot = Some(read_unlaid(projection, types, reveal, index, line, newline)); + self.next_reading += 1; + let id = self.next_reading; + *slot = Some(read_unlaid( + id, projection, types, reveal, index, line, newline, + )); } - if let Some(read) = slot.as_ref() { - self.runs.push(unlaid_run(read, line, bounds, scale)); + slot.as_ref() + } + + /// Far `lines`, every one with a kept reading, as one run from the top of + /// the first to the bottom of the last. What `held` read for the same + /// lines is used again while each line keeps its reading and its place + /// from the first. + fn push_far( + &mut self, + held: &mut HashMap, + lines: Option>, + projection: &Projection, + place: &dyn Fn(usize) -> Bounds, + space: Space, + ) { + let Some(lines) = lines else { + return; + }; + let (Some(first), Some(last)) = + (projection.line(lines.start), projection.line(lines.end - 1)) + else { + return; + }; + let readings: Vec<&Unlaid> = lines + .clone() + .filter_map(|index| self.unlaid.get(index)?.as_ref()) + .collect(); + if readings.len() != lines.len() { + return; } + let keys: Vec<(u64, usize)> = readings + .iter() + .zip(lines.clone()) + .map(|(read, index)| { + let from = projection.line(index).map_or(first.from(), Line::from); + (read.id, from - first.from()) + }) + .collect(); + let read = match held.remove(&lines.start) { + Some(kept) if kept.lines == keys => kept, + _ => { + let mut text = String::new(); + let mut lengths = Vec::new(); + let mut positions = Vec::new(); + for (read, &(_, from)) in readings.iter().zip(&keys) { + // Each line's last boundary is the next one's first, + // which starts where the next line does. + positions.pop(); + text.push_str(&read.text); + lengths.extend_from_slice(&read.lengths); + positions.extend(read.positions.iter().map(|at| at + from)); + } + Far { + lines: keys, + text: text.into(), + lengths: lengths.into(), + positions: positions.into(), + } + } + }; + let bounds = place(lines.start).union(&place(lines.end - 1)); + self.runs.push(TextRun { + node_id: None, + key: (lines.start, usize::MAX), + from: first.from(), + content_end: last.to(), + text: read.text.clone(), + lengths: read.lengths.clone(), + positions: read.positions.clone(), + base: first.from(), + bounds: space.rect(bounds), + cell: None, + }); + self.far.insert(lines.start, read); } pub(crate) fn write(&mut self, builder: &mut A11ySubtreeBuilder) { - for (index, run) in self.runs.iter_mut().enumerate() { - // A row that stands in for content with no position of its own — - // an empty line, a divider — shares its document position with the - // row beside it. Each displayed row still needs its own AccessKit - // ID, so the index is part of the key. - let id = builder.synthetic_node_id(("text", run.from, index)); + // The rows stand where they do in the content, which the editor's own + // node moves to where the content stands in the window. That moves + // the node's own bounds as well, so they are moved back. + let offset = self.space.offset(); + let editor = builder.parent_node(); + if let Some(mut bounds) = editor.bounds() { + bounds.y0 -= offset; + bounds.y1 -= offset; + editor.set_bounds(bounds); + } + editor.set_transform(accesskit::Affine::translate((0., offset))); + for run in &mut self.runs { + // Keyed by line and row rather than by document position: a row + // that stands in for content with no position of its own — an + // empty line, a divider — shares its position with the row beside + // it, and typing moves the position of every line after the caret. + let id = builder.synthetic_node_id(("text", run.key)); run.node_id = Some(id); let mut node = accesskit::Node::new(Role::TextRun); - node.set_value(run.text.clone()); - node.set_character_lengths( - run.offsets - .windows(2) - .map(|pair| (pair[1] - pair[0]) as u8) - .collect::>(), - ); + node.set_value(&*run.text); + node.set_character_lengths(&*run.lengths); node.set_bounds(run.bounds); if let Some((row, column)) = run.cell { node.set_row_index(row); @@ -561,8 +939,10 @@ impl AccessibleText { .iter() .rev() .find(|run| run.from <= pos && pos <= run.content_end)?; - let index = run.positions.partition_point(|&boundary| boundary < pos); - let character_index = if run.positions.get(index) == Some(&pos) { + let index = run + .positions + .partition_point(|&boundary| run.base + boundary < pos); + let character_index = if run.positions.get(index).map(|at| run.base + at) == Some(pos) { index } else { index.saturating_sub(1) @@ -578,7 +958,9 @@ impl AccessibleText { .runs .iter() .find(|run| run.node_id == Some(position.node))?; - run.positions.get(position.character_index).copied() + run.positions + .get(position.character_index) + .map(|at| run.base + at) } pub(crate) fn selection(&self, selection: &accesskit::TextSelection) -> Option { @@ -730,9 +1112,12 @@ mod tests { ( run.from, run.content_end, - run.text.clone(), - run.offsets.clone(), - run.positions.clone(), + run.text.to_string(), + run.lengths.to_vec(), + run.positions + .iter() + .map(|at| run.base + at) + .collect::>(), run.cell, ) }) @@ -740,7 +1125,14 @@ mod tests { }; let mut kept = AccessibleText::default(); let projection = markraft_core::projection::projection_of(&state); - kept.update(&projection, &state, &types, &[], &place, 1.); + kept.update( + &projection, + &state, + &types, + &[], + &place, + Space::new(px(0.), 1.), + ); // Typed at the start, so every line after the first moves along. let edited = state .update([TransactionSpec::new().selection(Selection::cursor(1))]) @@ -753,9 +1145,23 @@ mod tests { .state() .clone(); let projection = markraft_core::projection::projection_of(&edited); - kept.update(&projection, &edited, &types, &[], &place, 1.); + kept.update( + &projection, + &edited, + &types, + &[], + &place, + Space::new(px(0.), 1.), + ); let mut fresh = AccessibleText::default(); - fresh.update(&projection, &edited, &types, &[], &place, 1.); + fresh.update( + &projection, + &edited, + &types, + &[], + &place, + Space::new(px(0.), 1.), + ); assert_eq!(read(&kept), read(&fresh)); assert!(read(&kept)[0].2.starts_with("new first")); } @@ -803,8 +1209,15 @@ mod tests { ) }; let mut text = AccessibleText::default(); - text.update(&projection, &state, &types, &rows[..1], &place, 1.); - let read: Vec<&str> = text.runs.iter().map(|run| run.text.as_str()).collect(); + text.update( + &projection, + &state, + &types, + &rows[..1], + &place, + Space::new(px(0.), 1.), + ); + let read: Vec<&str> = text.runs.iter().map(|run| &*run.text).collect(); assert_eq!(read, ["first\n", "second\n", "third"]); assert_eq!(text.runs[2].bounds.y0, 200.); } @@ -851,7 +1264,7 @@ mod tests { &types, &rows, &|_| Bounds::default(), - 2., + Space::new(px(0.), 2.), ); // Inline HTML is edited as text in place, so only the task boxes are // controls. @@ -921,16 +1334,16 @@ mod tests { &types, &rows, &|_| Bounds::default(), - 1., + Space::new(px(0.), 1.), ); let run = text.runs.remove(0); let line = &projection.lines()[0]; let offsets: Vec = run .positions .iter() - .map(|pos| line.pos_to_offset(*pos).unwrap()) + .map(|pos| line.pos_to_offset(run.base + pos).unwrap()) .collect(); - (run.text, offsets) + (run.text.to_string(), offsets) }; let projection = markraft_core::projection::projection_of(&state); let at = |offset| projection.lines()[0].offset_to_pos(offset).unwrap(); @@ -975,22 +1388,20 @@ mod tests { .map(|&byte| line.offset_to_pos(value[..byte].chars().count()).unwrap()) .collect(); let text = AccessibleText { - messages: crate::EditorMessages::ENGLISH, - built: None, - unlaid: Vec::new(), - unlaid_of: None, runs: vec![TextRun { node_id: Some(accesskit::NodeId(1)), + key: (0, 0), from: line.from(), content_end: line.to(), text: value.into(), - offsets, - positions: positions.clone(), + lengths: unit_lengths(&offsets).into(), + positions: positions.clone().into(), + base: 0, bounds: accesskit::Rect::ZERO, cell: None, }], selection: (line.from(), line.to()), - controls: Vec::new(), + ..AccessibleText::default() }; for (index, pos) in positions.into_iter().enumerate() { let accessible = text.text_position(pos).unwrap(); @@ -1004,26 +1415,24 @@ mod tests { fn accessible_positions_roundtrip_wrapped_lines_and_block_boundaries() { let run = |id, from, end, text: &str| TextRun { node_id: Some(accesskit::NodeId(id)), + key: (id as usize, 0), from, content_end: end, text: text.into(), - offsets: character_offsets(text), + lengths: unit_lengths(&character_offsets(text)).into(), positions: character_offsets(text) .into_iter() - .map(|byte| (from + text[..byte].chars().count()).min(end)) + .map(|byte| (text[..byte].chars().count()).min(end - from)) .collect(), + base: from, bounds: accesskit::Rect::ZERO, cell: None, }; // Two visual rows of one line holding "你好", then the next block. let text = AccessibleText { - messages: crate::EditorMessages::ENGLISH, - built: None, - unlaid: Vec::new(), - unlaid_of: None, runs: vec![run(1, 1, 2, "你"), run(2, 2, 3, "好\n")], selection: (1, 1), - controls: Vec::new(), + ..AccessibleText::default() }; let boundary = 2; let accessible = text.text_position(boundary).unwrap(); @@ -1046,4 +1455,341 @@ mod tests { .is_none() ); } + + /// Reading a line's rows one after another gives what reading each row + /// on its own gives, whatever order the rows come in. + #[test] + fn rows_read_in_turn_read_as_rows_read_alone() { + fn alone(pieces: &[ShownPiece], inner: Range) -> (String, Vec) { + let mut value = String::new(); + let mut before = Vec::new(); + for piece in pieces { + if piece.own { + let from = piece.source.start.max(inner.start); + let to = piece.source.end.min(inner.end); + for (index, c) in piece.text.chars().enumerate() { + let at = piece.source.start + index; + if (from..to).contains(&at) { + value.push(c); + before.push(at); + } + } + } else if inner.contains(&piece.source.start) { + for c in piece.text.chars() { + value.push(c); + before.push(piece.source.start); + } + } + } + (value, before) + } + let piece = |source: Range, text: &str, own| ShownPiece { + source, + text: text.into(), + own, + }; + let pieces = [ + piece(0..4, "ab你é", true), + piece(4..9, "&", false), + piece(9..9, "🙂", false), + piece(9..15, "cd 好ef", true), + piece(15..20, "label", false), + piece(20..23, "xyz", true), + ]; + let rowings: [&[Range]; 5] = [ + &[0..3, 3..9, 9..12, 12..23], + &[0..2, 5..10, 11..11, 14..21, 22..23], + &[0..9, 9..9, 9..23], + &[3..12, 1..4, 10..23, 0..23], + &[0..23, 23..23], + ]; + for rows in rowings { + let mut shown = ShownRows::new(&pieces); + for row in rows { + assert_eq!( + shown.row(row.clone()), + alone(&pieces, row.clone()), + "{row:?} of {rows:?}" + ); + } + } + } + + /// Lines far from the screen read together, each stretch starting at a + /// multiple of `FAR_LINES`, and say what reading them one by one says. + /// An edit far above them leaves what they read to be used again. + #[test] + fn far_lines_read_together_and_are_read_once() { + use markraft_core::TransactionSpec; + let count = NEAR_LINES + 2 * FAR_LINES + 40; + let source = (0..count) + .map(|i| format!("line {i} **bold** 你好")) + .collect::>() + .join("\n\n"); + let state = crate::typeahead::tests::state_of(&source); + let types = crate::DocTypes::from_schema_names( + state.schema(), + &markraft_commonmark::commonmark_doc_type_names(), + ); + let place = |index: usize| { + Bounds::new( + gpui::point(gpui::px(0.), gpui::px(10. * index as f32)), + gpui::size(gpui::px(400.), gpui::px(10.)), + ) + }; + let read = |text: &mut AccessibleText, state: &EditorState| { + let projection = markraft_core::projection::projection_of(state); + text.update( + &projection, + state, + &types, + &[], + &place, + Space::new(px(0.), 1.), + ); + projection + }; + let mut text = AccessibleText::default(); + let projection = read(&mut text, &state); + assert_eq!(projection.line_count(), count); + let far: Vec<&TextRun> = text + .runs + .iter() + .filter(|run| run.key.1 == usize::MAX) + .collect(); + assert_eq!(text.runs.len(), NEAR_LINES + 3); + assert_eq!( + far.iter().map(|run| run.key.0).collect::>(), + [ + NEAR_LINES, + NEAR_LINES + FAR_LINES, + NEAR_LINES + 2 * FAR_LINES + ] + ); + assert_eq!(far[2].bounds.y1, 10. * count as f64); + // The whole note, as reading every line alone gives it. + let mut whole = String::new(); + for index in 0..count { + let line = projection.line(index).unwrap(); + let alone = read_unlaid( + 0, + &projection, + &types, + &Reveal::nothing(), + index, + line, + index + 1 < count, + ); + whole.push_str(&alone.text); + // Each line starts at its own unit of the run that reads it. + let run = text + .runs + .iter() + .find(|run| run.from <= line.from() && line.to() <= run.content_end) + .unwrap(); + let unit = run + .text + .split_inclusive('\n') + .take(index - run.key.0) + .map(|piece| character_offsets(piece).len() - 1) + .sum::(); + assert_eq!(run.base + run.positions[unit], line.from(), "line {index}"); + } + let all: String = text.runs.iter().map(|run| &*run.text).collect(); + assert_eq!(all, whole); + for run in &text.runs { + assert_eq!(run.lengths.len() + 1, run.positions.len()); + assert_eq!( + run.lengths + .iter() + .map(|&len| usize::from(len)) + .sum::(), + run.text.len() + ); + assert_eq!(run.base + run.positions.last().unwrap(), run.content_end); + } + // Typed in the first line: every far line moves along and reads as it + // did, from what was read before. + let before: Vec> = far.iter().map(|run| run.text.clone()).collect(); + let edited = state + .update([TransactionSpec::new().selection(Selection::cursor(1))]) + .unwrap() + .state() + .clone(); + let edited = edited + .update([markraft_core::commands::insert_text("new ")(&edited).unwrap()]) + .unwrap() + .state() + .clone(); + read(&mut text, &edited); + let mut fresh = AccessibleText::default(); + read(&mut fresh, &edited); + let runs = |text: &AccessibleText| { + text.runs + .iter() + .map(|run| { + let positions: Vec = + run.positions.iter().map(|at| run.base + at).collect(); + ( + run.key, + run.from, + run.content_end, + run.text.to_string(), + positions, + ) + }) + .collect::>() + }; + assert_eq!(runs(&text), runs(&fresh)); + let after: Vec<&Arc> = text + .runs + .iter() + .filter(|run| run.key.1 == usize::MAX) + .map(|run| &run.text) + .collect(); + assert!( + before.iter().zip(after).all(|(a, b)| Arc::ptr_eq(a, b)), + "far lines are read again" + ); + } + + /// A frame that only scrolls moves the rows a reader is told of without + /// reading them again; a selection that reveals a line's source does. + #[test] + fn laid_lines_read_again_only_when_what_they_show_changes() { + let state = crate::typeahead::tests::state_of("first\n\nsecond\n\nx **ab** y"); + let projection = markraft_core::projection::projection_of(&state); + let types = crate::DocTypes::from_schema_names( + state.schema(), + &markraft_commonmark::commonmark_doc_type_names(), + ); + let style = crate::EditorStyle::notes(); + let images = crate::images::Images::default(); + let text_system = gpui::WindowTextSystem::new(std::sync::Arc::new(gpui::TextSystem::new( + std::sync::Arc::new(gpui::NoopTextSystem::new()), + ))); + let mut rows = crate::surface::shape( + &crate::surface::ShapeInput { + maths: None, + equations: None, + scale_factor: 1.0, + doc: state.doc(), + types: &types, + projection: &projection, + style: &style, + single_line: false, + images: &images, + messages: &crate::EditorMessages::ENGLISH, + wiki: None, + selection: 0..0, + spelling: None, + composition: None, + }, + gpui::px(400.), + &text_system, + ); + let place = |_: usize| Bounds::default(); + let mut text = AccessibleText::default(); + text.update( + &projection, + &state, + &types, + &rows, + &place, + Space::new(px(0.), 1.), + ); + let before: Vec<(Arc, f64)> = text + .runs + .iter() + .map(|run| (run.text.clone(), run.bounds.y0)) + .collect(); + assert_eq!(&*before[2].0, "x ab y"); + for row in &mut rows { + row.origin.y += gpui::px(50.); + } + text.update( + &projection, + &state, + &types, + &rows, + &place, + Space::new(px(0.), 1.), + ); + for (run, (read, y)) in text.runs.iter().zip(&before) { + assert!(Arc::ptr_eq(&run.text, read), "{read}"); + assert_eq!(run.bounds.y0, y + 50.); + } + // Scrolled: the rows and the content's top move together, and what a + // reader is told of the rows stays as it was. + let moved: Vec = text.runs.iter().map(|run| run.bounds.y0).collect(); + for row in &mut rows { + row.origin.y += gpui::px(30.); + } + text.update( + &projection, + &state, + &types, + &rows, + &place, + Space::new(px(30.), 1.), + ); + assert_eq!( + text.runs + .iter() + .map(|run| run.bounds.y0) + .collect::>(), + moved + ); + assert_eq!(text.space.offset(), 30.); + let line = &projection.lines()[2]; + let inside = state + .update([markraft_core::TransactionSpec::new() + .selection(Selection::cursor(line.offset_to_pos(5).unwrap()))]) + .unwrap() + .state() + .clone(); + text.update( + &projection, + &inside, + &types, + &rows, + &place, + Space::new(px(30.), 1.), + ); + // The selection never touched the middle line. + assert!(Arc::ptr_eq(&text.runs[1].text, &before[1].0)); + assert_eq!(&*text.runs[2].text, "x **ab** y"); + } + + /// A far table's cells are read with the lines around them; near the + /// screen each says where in its table it stands. + #[test] + fn far_table_cells_read_with_far_lines() { + let mut source = (0..NEAR_LINES + 10) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n\n"); + source.push_str("\n\n| a | b |\n| - | - |\n| c | d |\n\nlast"); + let state = crate::typeahead::tests::state_of(&source); + let projection = markraft_core::projection::projection_of(&state); + let types = crate::DocTypes::from_schema_names( + state.schema(), + &markraft_commonmark::commonmark_doc_type_names(), + ); + let mut text = AccessibleText::default(); + let place = |_: usize| Bounds::default(); + text.update( + &projection, + &state, + &types, + &[], + &place, + Space::new(px(0.), 1.), + ); + let far = text.runs.last().unwrap(); + assert_eq!(far.key, (NEAR_LINES, usize::MAX)); + assert_eq!(far.cell, None); + assert!(far.text.ends_with("a\nb\nc\nd\nlast"), "{}", far.text); + assert!(text.runs[..NEAR_LINES].iter().all(|run| run.key.1 == 0)); + } } diff --git a/crates/markraft-gpui/src/surface/mod.rs b/crates/markraft-gpui/src/surface/mod.rs index b3641676..783e902b 100644 --- a/crates/markraft-gpui/src/surface/mod.rs +++ b/crates/markraft-gpui/src/surface/mod.rs @@ -421,7 +421,7 @@ impl Element for EditorSurface { &view.types, &rows, &|index| (place.borrow_mut())(index), - window.scale_factor(), + crate::accessibility::Space::new(bounds.top(), window.scale_factor()), ); } let revealed = self.editor.update(cx, |editor, cx| { From 56a468f09b495c18c1f9a9e6c3d2bf9000db1848 Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 4 Oct 2026 20:36:19 +1100 Subject: [PATCH 2/6] feat(editor): add native context menus and text services Add native menus for text, links, tables, code blocks, and Markdown insertion. Integrate spelling, substitutions, translation, Writing Tools, and system services with persisted preferences. Guard asynchronous results against document and selection changes, and reject writes to read-only content. Preserve Markdown structure and formatting during native replacements, with undo support and regression coverage. Spelling scans return ranges only; guesses are fetched for the one word a menu asks about. An edit moves the diagnostics beside it and removes those it touches. Typing links only text that spells its own scheme. Smart deletion and smart copy apply to a selection made by a word gesture. A word picked across concealed spelling is a ReadingSelection in the core's kind layer; the selection layer depends on neither commands nor projection. Selection kinds map through map_with_schema, and a replacement states its ReplacementStyle. Vim Normal mode keeps such a selection, and an opener typed over it wraps it. --- Cargo.lock | 25 + README.md | 4 +- crates/markraft-app/Cargo.toml | 5 +- crates/markraft-app/build.rs | 64 + crates/markraft-app/locales/app/de.json | 58 +- crates/markraft-app/locales/app/en.json | 58 +- crates/markraft-app/locales/app/es.json | 58 +- crates/markraft-app/locales/app/fr.json | 58 +- crates/markraft-app/locales/app/ja.json | 58 +- crates/markraft-app/locales/app/ko.json | 58 +- crates/markraft-app/locales/app/pt-BR.json | 58 +- crates/markraft-app/locales/app/zh-Hans.json | 58 +- crates/markraft-app/locales/app/zh-Hant.json | 58 +- crates/markraft-app/locales/notices/de.json | 4 +- crates/markraft-app/locales/notices/en.json | 4 +- crates/markraft-app/locales/notices/es.json | 4 +- crates/markraft-app/locales/notices/fr.json | 4 +- crates/markraft-app/locales/notices/ja.json | 4 +- crates/markraft-app/locales/notices/ko.json | 4 +- .../markraft-app/locales/notices/pt-BR.json | 4 +- .../markraft-app/locales/notices/zh-Hans.json | 4 +- .../markraft-app/locales/notices/zh-Hant.json | 4 +- crates/markraft-app/src/app.rs | 72 +- crates/markraft-app/src/app/interaction.rs | 2 + crates/markraft-app/src/app/preferences.rs | 17 + crates/markraft-app/src/app/sessions.rs | 32 +- crates/markraft-app/src/app/ui.rs | 28 +- .../markraft-app/src/app/ui/context_menu.rs | 1332 +++++++++++++++++ .../src/app/ui/context_menu/markdown.rs | 153 ++ .../src/app/ui/context_menu/markdown/tests.rs | 119 ++ .../src/app/ui/context_menu/tests.rs | 1039 +++++++++++++ crates/markraft-app/src/app/ui/editing.rs | 168 +++ crates/markraft-app/src/app/ui/formatting.rs | 11 +- .../markraft-app/src/app/ui/text_checking.rs | 1328 ++++++++++++++++ crates/markraft-app/src/doc.rs | 29 +- crates/markraft-app/src/platform.rs | 10 +- .../src/platform/checking_panel.rs | 675 +++++++++ .../markraft-app/src/platform/context_menu.rs | 470 ++++++ .../src/platform/context_menu/caption.rs | 67 + .../context_menu/fixtures/captions.json | 222 +++ .../context_menu/fixtures/captions.swift | 46 + .../src/platform/native_presentation.swift | 24 + .../src/platform/text_checking.rs | 575 +++++++ .../src/platform/text_requestor.rs | 427 ++++++ .../platform/text_requestor/writing_tools.rs | 434 ++++++ .../src/platform/text_services.rs | 257 ++++ .../markraft-app/src/platform/translation.rs | 145 ++ .../src/platform/translation.swift | 120 ++ crates/markraft-app/src/storage.rs | 87 ++ crates/markraft-commonmark/Cargo.toml | 2 + crates/markraft-commonmark/src/commands.rs | 23 + .../src/commands/reading_replacement.rs | 603 ++++++++ crates/markraft-commonmark/src/lib.rs | 2 +- crates/markraft-commonmark/src/pairs.rs | 45 +- crates/markraft-commonmark/tests/pairs.rs | 84 +- .../tests/reading_replacement.rs | 276 ++++ crates/markraft-core/src/commands/general.rs | 10 +- crates/markraft-core/src/commands/mod.rs | 2 +- crates/markraft-core/src/commands/text.rs | 67 +- crates/markraft-core/src/composition.rs | 58 +- crates/markraft-core/src/kind/chains.rs | 6 +- crates/markraft-core/src/kind/conceal.rs | 26 +- crates/markraft-core/src/kind/mod.rs | 31 + crates/markraft-core/src/kind/reading.rs | 14 +- .../src/kind/reading_selection.rs | 310 ++++ crates/markraft-core/src/lib.rs | 2 +- crates/markraft-core/src/selection/custom.rs | 37 + crates/markraft-core/src/selection/mod.rs | 7 +- crates/markraft-core/src/state/transaction.rs | 38 + .../src/tests/mark_command_tests.rs | 106 ++ crates/markraft-gpui/Cargo.toml | 5 +- crates/markraft-gpui/src/clipboard.rs | 166 +- crates/markraft-gpui/src/context.rs | 369 +++++ .../src/context/block_actions.rs | 257 ++++ .../markraft-gpui/src/context/copy_formats.rs | 248 +++ crates/markraft-gpui/src/context/pointer.rs | 349 +++++ .../src/context/pointer_targets.rs | 167 +++ .../markraft-gpui/src/context/reading_text.rs | 588 ++++++++ .../markraft-gpui/src/context/smart_edit.rs | 268 ++++ crates/markraft-gpui/src/context/tests.rs | 1096 ++++++++++++++ .../src/context/transformations.rs | 197 +++ crates/markraft-gpui/src/extension.rs | 4 +- crates/markraft-gpui/src/ime.rs | 62 +- crates/markraft-gpui/src/lib.rs | 306 +++- .../markraft-gpui/src/pointer_word_tests.rs | 313 ++++ .../src/reading_selection_tests.rs | 220 +++ crates/markraft-gpui/src/shown.rs | 15 + crates/markraft-gpui/src/smart_clipboard.rs | 131 ++ crates/markraft-gpui/src/surface/mod.rs | 21 + crates/markraft-gpui/src/word_boundary.rs | 136 ++ crates/markraft-vim/src/command.rs | 5 +- crates/markraft-vim/src/tests.rs | 19 + docs/architecture.md | 70 + 93 files changed, 15139 insertions(+), 167 deletions(-) create mode 100644 crates/markraft-app/src/app/ui/context_menu.rs create mode 100644 crates/markraft-app/src/app/ui/context_menu/markdown.rs create mode 100644 crates/markraft-app/src/app/ui/context_menu/markdown/tests.rs create mode 100644 crates/markraft-app/src/app/ui/context_menu/tests.rs create mode 100644 crates/markraft-app/src/app/ui/editing.rs create mode 100644 crates/markraft-app/src/app/ui/text_checking.rs create mode 100644 crates/markraft-app/src/platform/checking_panel.rs create mode 100644 crates/markraft-app/src/platform/context_menu.rs create mode 100644 crates/markraft-app/src/platform/context_menu/caption.rs create mode 100644 crates/markraft-app/src/platform/context_menu/fixtures/captions.json create mode 100644 crates/markraft-app/src/platform/context_menu/fixtures/captions.swift create mode 100644 crates/markraft-app/src/platform/native_presentation.swift create mode 100644 crates/markraft-app/src/platform/text_checking.rs create mode 100644 crates/markraft-app/src/platform/text_requestor.rs create mode 100644 crates/markraft-app/src/platform/text_requestor/writing_tools.rs create mode 100644 crates/markraft-app/src/platform/text_services.rs create mode 100644 crates/markraft-app/src/platform/translation.rs create mode 100644 crates/markraft-app/src/platform/translation.swift create mode 100644 crates/markraft-commonmark/src/commands/reading_replacement.rs create mode 100644 crates/markraft-commonmark/tests/reading_replacement.rs create mode 100644 crates/markraft-core/src/kind/reading_selection.rs create mode 100644 crates/markraft-gpui/src/context.rs create mode 100644 crates/markraft-gpui/src/context/block_actions.rs create mode 100644 crates/markraft-gpui/src/context/copy_formats.rs create mode 100644 crates/markraft-gpui/src/context/pointer.rs create mode 100644 crates/markraft-gpui/src/context/pointer_targets.rs create mode 100644 crates/markraft-gpui/src/context/reading_text.rs create mode 100644 crates/markraft-gpui/src/context/smart_edit.rs create mode 100644 crates/markraft-gpui/src/context/tests.rs create mode 100644 crates/markraft-gpui/src/context/transformations.rs create mode 100644 crates/markraft-gpui/src/pointer_word_tests.rs create mode 100644 crates/markraft-gpui/src/reading_selection_tests.rs create mode 100644 crates/markraft-gpui/src/smart_clipboard.rs create mode 100644 crates/markraft-gpui/src/word_boundary.rs diff --git a/Cargo.lock b/Cargo.lock index abcdfeb1..4f1f5a28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4140,6 +4140,7 @@ name = "markraft-app" version = "0.1.6" dependencies = [ "base64 0.22.1", + "block2 0.6.2", "chrono", "futures-channel", "futures-util", @@ -4160,6 +4161,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-natural-language", "objc2-web-kit", "png 0.17.16", "raw-window-handle", @@ -4187,6 +4189,8 @@ dependencies = [ "scraper", "serde", "serde_json", + "similar", + "unicode-segmentation", "unicode-width", ] @@ -4214,6 +4218,7 @@ dependencies = [ "markraft-math", "markraft-media", "markraft-syntax", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde_json", @@ -5021,6 +5026,17 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-natural-language" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f3395c4eda23486f1ca42da8256021a99cf94ebe3cf189bfafeb7e197f8a6d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -6852,6 +6868,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", +] + [[package]] name = "simplecss" version = "0.2.2" diff --git a/README.md b/README.md index a5adff67..86156f4e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Press ⌥N in any app to write, and again to put it away. Notes are p - **Math.** LaTeX formulas, `$…$` inline and `$$…$$` as blocks, rendered offline through [RaTeX](https://github.com/erweixin/RaTeX), with optional equation numbers and references. - **Plain files.** Each note is a `.md` file. A save rewrites only the lines you edited and leaves the rest of the file unchanged. - **Keyboard.** ⌘K lists every action with its shortcut, / inserts a block, and vim mode can be turned on in Settings. +- **Context menu.** Right-click or Control-click to edit the selection, paste plain text or Markdown, and work with links, code blocks and tables using a native macOS menu. - **Native.** Written in Rust and drawn with [GPUI](https://www.gpui.rs), without a web view.

@@ -89,7 +90,8 @@ Bug reports and feature requests are welcome as issues. Pull requests are open t **Report an Issue…** in ⌘K opens an issue with your Mac and Markraft versions filled in. Logs and crash reports are written to `~/Library/Logs/Markraft` and stay on your Mac; attach the report if there is one. -To build from source: +To build from source, install Rust and Xcode 16 or newer with its command-line tools. +The macOS translation bridge is compiled with the bundled Swift compiler: ```sh bash scripts/download-sparkle.sh # fetch the Sparkle framework into target/sparkle diff --git a/crates/markraft-app/Cargo.toml b/crates/markraft-app/Cargo.toml index 84ad00d0..bc7f0899 100644 --- a/crates/markraft-app/Cargo.toml +++ b/crates/markraft-app/Cargo.toml @@ -50,10 +50,13 @@ libc = "0.2" global-hotkey = "0.7" tray-icon = { version = "0.21", default-features = false } objc2 = "0.6" +block2 = "0.6" +objc2-natural-language = { version = "0.3.2", default-features = false, features = ["NLTagger", "NLTagScheme", "NLTokenizer"] } objc2-app-kit = { version = "0.3.2", default-features = false, features = [ - "NSBitmapImageRep", "NSButton", "NSColor", "NSControl", "NSFontDescriptor", + "NSApplication", "NSAttributedString", "NSPasteboard", "NSBitmapImageRep", "NSButton", "NSColor", "NSControl", "NSFontDescriptor", "NSGraphics", "NSGraphicsContext", "NSImage", "NSImageRep", "NSResponder", "NSStatusBarButton", "NSStatusItem", "NSView", "NSPrintInfo", "NSPrintOperation", "NSWindow", + "NSBezierPath", "NSWritingToolsCoordinator", "NSWritingToolsCoordinatorContext", "NSWritingToolsCoordinatorAnimationParameters", "block2", "objc2-core-foundation", ] } # Printing lays notes out with WebKit, which paginates HTML. diff --git a/crates/markraft-app/build.rs b/crates/markraft-app/build.rs index 8a2bc771..b3995795 100644 --- a/crates/markraft-app/build.rs +++ b/crates/markraft-app/build.rs @@ -1,5 +1,69 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks"); + build_translation(); } } + +fn build_translation() { + use std::path::PathBuf; + use std::process::Command; + + const SOURCE: &str = "src/platform/translation.swift"; + const PRESENTATION: &str = "src/platform/native_presentation.swift"; + println!("cargo:rerun-if-changed={SOURCE}"); + println!("cargo:rerun-if-changed={PRESENTATION}"); + println!("cargo:rerun-if-env-changed=DEVELOPER_DIR"); + let output = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo sets OUT_DIR")); + let architecture = match std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() { + Ok("aarch64") => "arm64", + Ok("x86_64") => "x86_64", + other => panic!("unsupported macOS translation target: {other:?}"), + }; + let status = Command::new("xcrun") + .args([ + "swiftc", + "-emit-library", + "-static", + "-parse-as-library", + "-module-name", + "MarkraftTranslation", + "-target", + ]) + .arg(format!("{architecture}-apple-macos13.0")) + .arg(SOURCE) + .arg(PRESENTATION) + .arg("-o") + .arg(output.join("libMarkraftTranslation.a")) + .status() + .expect("Xcode Swift compiler is required for macOS text translation"); + assert!( + status.success(), + "failed to compile the system translation bridge" + ); + let swiftc = Command::new("xcrun") + .args(["--find", "swiftc"]) + .output() + .expect("locate Xcode Swift compiler"); + assert!( + swiftc.status.success(), + "failed to locate the Swift compiler" + ); + let swiftc = PathBuf::from( + String::from_utf8(swiftc.stdout) + .expect("Swift compiler path is UTF-8") + .trim(), + ); + let runtime = swiftc + .parent() + .and_then(|path| path.parent()) + .expect("Swift compiler belongs to an Xcode toolchain") + .join("lib/swift/macosx"); + println!("cargo:rustc-link-search=native={}", output.display()); + println!("cargo:rustc-link-search=native={}", runtime.display()); + println!("cargo:rustc-link-lib=static=MarkraftTranslation"); + println!("cargo:rustc-link-lib=dylib=swiftCore"); + println!("cargo:rustc-link-lib=framework=SwiftUI"); + println!("cargo:rustc-link-arg=-Wl,-weak_framework,Translation"); + println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); +} diff --git a/crates/markraft-app/locales/app/de.json b/crates/markraft-app/locales/app/de.json index b91e6dc1..c8a828d2 100644 --- a/crates/markraft-app/locales/app/de.json +++ b/crates/markraft-app/locales/app/de.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "Als reinen Text kopieren", + "copy-as-html": "Als HTML-Code kopieren", + "copy-without-theme": "Ohne Designformatierung kopieren", + "clear-format": "Formatierung entfernen", + "highlight": "Hervorheben", + "insert-image": "Bild…", + "math-block": "Formelblock", + "insert-paragraph-before": "Absatz davor einfügen", + "insert-paragraph-after": "Absatz danach einfügen", + "copy-table": "Tabelle kopieren", + "traditional-chinese": "In traditionelles Chinesisch konvertieren", + "simplified-chinese": "In vereinfachtes Chinesisch konvertieren", "add-column-left": "Spalte links einfügen", "add-column-right": "Spalte rechts einfügen", "add-row-above": "Zeile oberhalb einfügen", @@ -71,7 +83,39 @@ "task-list": "Aufgabenliste", "toggle-task": "Aufgabe umschalten", "undo": "Widerrufen", - "unpin-note": "Notiz loslösen" + "unpin-note": "Notiz loslösen", + "search-web": "Im Web suchen", + "share-text": "Teilen …", + "uppercase": "Großbuchstaben", + "lowercase": "Kleinbuchstaben", + "capitalize": "Großschreibung", + "start-speaking": "Sprachausgabe starten", + "stop-speaking": "Sprachausgabe stoppen", + "check-spelling": "Dokument jetzt prüfen", + "check-continuously": "Rechtschreibung während der Texteingabe prüfen", + "check-grammar": "Grammatik und Rechtschreibung prüfen", + "correct-spelling": "Rechtschreibung automatisch korrigieren", + "ignore-spelling": "Schreibweise ignorieren", + "learn-spelling": "Schreibweise lernen", + "unlearn-spelling": "Schreibweise verlernen", + "smart-quotes": "Intelligente Anführungszeichen", + "smart-dashes": "Intelligente Bindestriche", + "text-replacement": "Textersetzung", + "smart-links": "Intelligente Links", + "data-detection": "Datenerkennung", + "paste-match-style": "Einsetzen und Stil anpassen", + "look-up-selection": "„%{text}“ nachschlagen", + "translate-selection": "„%{text}“ übersetzen", + "underline": "Unterstrichen", + "show-spelling": "Rechtschreibung und Grammatik einblenden", + "replace-quotes": "Anführungszeichen ersetzen", + "replace-dashes": "Bindestriche ersetzen", + "add-links": "Links hinzufügen", + "replace-text": "Text ersetzen", + "show-substitutions": "Ersetzungen einblenden", + "smart-copy-paste": "Intelligentes Kopieren/Einsetzen", + "hide-spelling": "Rechtschreibung und Grammatik ausblenden", + "hide-substitutions": "Ersetzungen ausblenden" }, "notes": { "title": "Notizen", @@ -91,6 +135,10 @@ "no-matches": "Keine passenden Aktionen" }, "menu": { + "copy-paste-as": "Kopieren / Einfügen als", + "paragraph": "Absatz", + "format": "Format", + "insert": "Einfügen", "show-hide": "Notizen ein-/ausblenden", "new-note": "Neue Notiz", "settings": "Einstellungen …", @@ -106,7 +154,13 @@ "select-all": "Alles auswählen", "find": "Suchen …", "find-next": "Weitersuchen", - "find-previous": "Vorheriges suchen" + "find-previous": "Vorheriges suchen", + "transformations": "Transformationen", + "speech": "Sprachausgabe", + "open-with": "Öffnen mit", + "spelling": "Rechtschreibung und Grammatik", + "substitutions": "Ersetzungen", + "font": "Schrift" }, "input": { "start-writing": "Losschreiben …", diff --git a/crates/markraft-app/locales/app/en.json b/crates/markraft-app/locales/app/en.json index 95f7eadb..37990f78 100644 --- a/crates/markraft-app/locales/app/en.json +++ b/crates/markraft-app/locales/app/en.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "Copy as Plain Text", + "copy-as-html": "Copy as HTML Code", + "copy-without-theme": "Copy without Theme Styling", + "clear-format": "Clear Format", + "highlight": "Highlight", + "insert-image": "Image…", + "math-block": "Math Block", + "insert-paragraph-before": "Insert Paragraph Before", + "insert-paragraph-after": "Insert Paragraph After", + "copy-table": "Copy Table", + "traditional-chinese": "Convert to Traditional Chinese", + "simplified-chinese": "Convert to Simplified Chinese", "add-column-left": "Add Column Left", "add-column-right": "Add Column Right", "add-row-above": "Add Row Above", @@ -71,7 +83,39 @@ "task-list": "Task List", "toggle-task": "Toggle Task", "undo": "Undo", - "unpin-note": "Unpin Note" + "unpin-note": "Unpin Note", + "search-web": "Search the Web", + "share-text": "Share…", + "uppercase": "Make Upper Case", + "lowercase": "Make Lower Case", + "capitalize": "Capitalize", + "start-speaking": "Start Speaking", + "stop-speaking": "Stop Speaking", + "check-spelling": "Check Document Now", + "check-continuously": "Check Spelling While Typing", + "check-grammar": "Check Grammar With Spelling", + "correct-spelling": "Correct Spelling Automatically", + "ignore-spelling": "Ignore Spelling", + "learn-spelling": "Learn Spelling", + "unlearn-spelling": "Unlearn Spelling", + "smart-quotes": "Smart Quotes", + "smart-dashes": "Smart Dashes", + "text-replacement": "Text Replacement", + "smart-links": "Smart Links", + "data-detection": "Data Detectors", + "paste-match-style": "Paste and Match Style", + "look-up-selection": "Look Up “%{text}”", + "translate-selection": "Translate “%{text}”", + "underline": "Underline", + "show-spelling": "Show Spelling and Grammar", + "replace-quotes": "Replace Quotes", + "replace-dashes": "Replace Dashes", + "add-links": "Add Links", + "replace-text": "Replace Text", + "show-substitutions": "Show Substitutions", + "smart-copy-paste": "Smart Copy/Paste", + "hide-spelling": "Hide Spelling and Grammar", + "hide-substitutions": "Hide Substitutions" }, "notes": { "title": "Notes", @@ -91,6 +135,10 @@ "no-matches": "No matching actions" }, "menu": { + "copy-paste-as": "Copy / Paste As", + "paragraph": "Paragraph", + "format": "Format", + "insert": "Insert", "show-hide": "Show / Hide Notes", "new-note": "New Note", "settings": "Settings…", @@ -106,7 +154,13 @@ "select-all": "Select All", "find": "Find…", "find-next": "Find Next", - "find-previous": "Find Previous" + "find-previous": "Find Previous", + "transformations": "Transformations", + "speech": "Speech", + "open-with": "Open With", + "spelling": "Spelling and Grammar", + "substitutions": "Substitutions", + "font": "Font" }, "input": { "start-writing": "Start writing…", diff --git a/crates/markraft-app/locales/app/es.json b/crates/markraft-app/locales/app/es.json index ed475448..bd4dafaf 100644 --- a/crates/markraft-app/locales/app/es.json +++ b/crates/markraft-app/locales/app/es.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "Copiar como texto sin formato", + "copy-as-html": "Copiar como código HTML", + "copy-without-theme": "Copiar sin el estilo del tema", + "clear-format": "Borrar formato", + "highlight": "Resaltar", + "insert-image": "Imagen…", + "math-block": "Bloque de fórmulas", + "insert-paragraph-before": "Insertar párrafo antes", + "insert-paragraph-after": "Insertar párrafo después", + "copy-table": "Copiar tabla", + "traditional-chinese": "Convertir a chino tradicional", + "simplified-chinese": "Convertir a chino simplificado", "add-column-left": "Insertar columna a la izquierda", "add-column-right": "Insertar columna a la derecha", "add-row-above": "Insertar fila encima", @@ -71,7 +83,39 @@ "task-list": "Lista de tareas", "toggle-task": "Alternar tarea", "undo": "Deshacer", - "unpin-note": "Desfijar nota" + "unpin-note": "Desfijar nota", + "search-web": "Buscar en la web", + "share-text": "Compartir…", + "uppercase": "Convertir en mayúsculas", + "lowercase": "Convertir en minúsculas", + "capitalize": "Iniciales en mayúsculas", + "start-speaking": "Iniciar locución", + "stop-speaking": "Detener locución", + "check-spelling": "Comprobar documento ahora", + "check-continuously": "Comprobar ortografía mientras se escribe", + "check-grammar": "Comprobar gramática y ortografía", + "correct-spelling": "Corregir ortografía automáticamente", + "ignore-spelling": "Ignorar ortografía", + "learn-spelling": "Aprender ortografía", + "unlearn-spelling": "Olvidar ortografía", + "smart-quotes": "Comillas tipográficas", + "smart-dashes": "Guiones inteligentes", + "text-replacement": "Reemplazo de texto", + "smart-links": "Enlaces inteligentes", + "data-detection": "Detectores de datos", + "paste-match-style": "Pegar con el mismo estilo", + "look-up-selection": "Consultar “%{text}”", + "translate-selection": "Traducir “%{text}”", + "underline": "Subrayado", + "show-spelling": "Mostrar ortografía y gramática", + "replace-quotes": "Reemplazar comillas", + "replace-dashes": "Reemplazar guiones", + "add-links": "Añadir enlaces", + "replace-text": "Reemplazar texto", + "show-substitutions": "Mostrar sustituciones", + "smart-copy-paste": "Copiar/pegar inteligente", + "hide-spelling": "Ocultar ortografía y gramática", + "hide-substitutions": "Ocultar sustituciones" }, "notes": { "title": "Notas", @@ -91,6 +135,10 @@ "no-matches": "Ninguna acción coincide" }, "menu": { + "copy-paste-as": "Copiar / Pegar como", + "paragraph": "Párrafo", + "format": "Formato", + "insert": "Insertar", "show-hide": "Mostrar/ocultar notas", "new-note": "Nueva nota", "settings": "Ajustes…", @@ -106,7 +154,13 @@ "select-all": "Seleccionar todo", "find": "Buscar…", "find-next": "Buscar siguiente", - "find-previous": "Buscar anterior" + "find-previous": "Buscar anterior", + "transformations": "Transformaciones", + "speech": "Habla", + "open-with": "Abrir con", + "spelling": "Ortografía y gramática", + "substitutions": "Sustituciones", + "font": "Tipo de letra" }, "input": { "start-writing": "Empieza a escribir…", diff --git a/crates/markraft-app/locales/app/fr.json b/crates/markraft-app/locales/app/fr.json index 2274cdc1..d082091d 100644 --- a/crates/markraft-app/locales/app/fr.json +++ b/crates/markraft-app/locales/app/fr.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "Copier en texte brut", + "copy-as-html": "Copier en code HTML", + "copy-without-theme": "Copier sans le style du thème", + "clear-format": "Effacer la mise en forme", + "highlight": "Surligner", + "insert-image": "Image…", + "math-block": "Bloc de formule", + "insert-paragraph-before": "Insérer un paragraphe avant", + "insert-paragraph-after": "Insérer un paragraphe après", + "copy-table": "Copier le tableau", + "traditional-chinese": "Convertir en chinois traditionnel", + "simplified-chinese": "Convertir en chinois simplifié", "add-column-left": "Ajouter une colonne à gauche", "add-column-right": "Ajouter une colonne à droite", "add-row-above": "Ajouter une ligne au-dessus", @@ -71,7 +83,39 @@ "task-list": "Liste de tâches", "toggle-task": "Cocher/décocher la tâche", "undo": "Annuler", - "unpin-note": "Désépingler la note" + "unpin-note": "Désépingler la note", + "search-web": "Rechercher sur le Web", + "share-text": "Partager…", + "uppercase": "Mettre en majuscules", + "lowercase": "Mettre en minuscules", + "capitalize": "Initiales en majuscules", + "start-speaking": "Commencer la lecture", + "stop-speaking": "Arrêter la lecture", + "check-spelling": "Vérifier le document maintenant", + "check-continuously": "Vérifier l’orthographe lors de la saisie", + "check-grammar": "Vérifier la grammaire et l’orthographe", + "correct-spelling": "Corriger l’orthographe automatiquement", + "ignore-spelling": "Ignorer l’orthographe", + "learn-spelling": "Mémoriser l’orthographe", + "unlearn-spelling": "Oublier l’orthographe", + "smart-quotes": "Guillemets courbes", + "smart-dashes": "Tirets intelligents", + "text-replacement": "Remplacement de texte", + "smart-links": "Liens intelligents", + "data-detection": "Détecteurs de données", + "paste-match-style": "Coller et adapter le style", + "look-up-selection": "Rechercher « %{text} »", + "translate-selection": "Traduire « %{text} »", + "underline": "Souligné", + "show-spelling": "Afficher l’orthographe et la grammaire", + "replace-quotes": "Remplacer les guillemets", + "replace-dashes": "Remplacer les tirets", + "add-links": "Ajouter des liens", + "replace-text": "Remplacer le texte", + "show-substitutions": "Afficher les substitutions", + "smart-copy-paste": "Copier/coller intelligent", + "hide-spelling": "Masquer l’orthographe et la grammaire", + "hide-substitutions": "Masquer les substitutions" }, "notes": { "title": "Notes", @@ -91,6 +135,10 @@ "no-matches": "Aucune action correspondante" }, "menu": { + "copy-paste-as": "Copier / Coller en tant que", + "paragraph": "Paragraphe", + "format": "Format", + "insert": "Insérer", "show-hide": "Afficher/masquer les notes", "new-note": "Nouvelle note", "settings": "Réglages…", @@ -106,7 +154,13 @@ "select-all": "Tout sélectionner", "find": "Rechercher…", "find-next": "Rechercher le suivant", - "find-previous": "Rechercher le précédent" + "find-previous": "Rechercher le précédent", + "transformations": "Transformations", + "speech": "Parole", + "open-with": "Ouvrir avec", + "spelling": "Orthographe et grammaire", + "substitutions": "Substitutions", + "font": "Police" }, "input": { "start-writing": "Commencez à écrire…", diff --git a/crates/markraft-app/locales/app/ja.json b/crates/markraft-app/locales/app/ja.json index 7d586e5c..bb6bb153 100644 --- a/crates/markraft-app/locales/app/ja.json +++ b/crates/markraft-app/locales/app/ja.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "プレーンテキストとしてコピー", + "copy-as-html": "HTMLコードとしてコピー", + "copy-without-theme": "テーマのスタイルを除いてコピー", + "clear-format": "書式をクリア", + "highlight": "ハイライト", + "insert-image": "画像…", + "math-block": "数式ブロック", + "insert-paragraph-before": "前に段落を挿入", + "insert-paragraph-after": "後に段落を挿入", + "copy-table": "表をコピー", + "traditional-chinese": "繁体字中国語に変換", + "simplified-chinese": "簡体字中国語に変換", "add-column-left": "左に列を追加", "add-column-right": "右に列を追加", "add-row-above": "上に行を追加", @@ -71,7 +83,39 @@ "task-list": "タスクリスト", "toggle-task": "タスクの完了を切り替える", "undo": "取り消す", - "unpin-note": "ノートのピン固定を解除" + "unpin-note": "ノートのピン固定を解除", + "search-web": "Webで検索", + "share-text": "共有…", + "uppercase": "大文字にする", + "lowercase": "小文字にする", + "capitalize": "語頭を大文字にする", + "start-speaking": "読み上げを開始", + "stop-speaking": "読み上げを停止", + "check-spelling": "書類を今すぐチェック", + "check-continuously": "入力中にスペルチェック", + "check-grammar": "スペルと文法をチェック", + "correct-spelling": "スペルを自動的に修正", + "ignore-spelling": "スペルを無視", + "learn-spelling": "スペルを学習", + "unlearn-spelling": "スペルを学習解除", + "smart-quotes": "スマート引用符", + "smart-dashes": "スマートダッシュ", + "text-replacement": "テキストの置換", + "smart-links": "スマートリンク", + "data-detection": "データ検出", + "paste-match-style": "ペーストしてスタイルを合わせる", + "look-up-selection": "“%{text}”を調べる", + "translate-selection": "“%{text}”を翻訳", + "underline": "下線", + "show-spelling": "スペルと文法を表示", + "replace-quotes": "引用符を置換", + "replace-dashes": "ダッシュを置換", + "add-links": "リンクを追加", + "replace-text": "テキストを置換", + "show-substitutions": "自動置換を表示", + "smart-copy-paste": "スマートコピー/ペースト", + "hide-spelling": "スペルと文法を非表示", + "hide-substitutions": "自動置換を非表示" }, "notes": { "title": "ノート", @@ -91,6 +135,10 @@ "no-matches": "一致するアクションがありません" }, "menu": { + "copy-paste-as": "形式を指定してコピー / ペースト", + "paragraph": "段落", + "format": "書式", + "insert": "挿入", "show-hide": "ノートを表示/非表示", "new-note": "新規ノート", "settings": "設定…", @@ -106,7 +154,13 @@ "select-all": "すべてを選択", "find": "検索…", "find-next": "次を検索", - "find-previous": "前を検索" + "find-previous": "前を検索", + "transformations": "変換", + "speech": "スピーチ", + "open-with": "このアプリケーションで開く", + "spelling": "スペルと文法", + "substitutions": "自動置換", + "font": "フォント" }, "input": { "start-writing": "書き始める…", diff --git a/crates/markraft-app/locales/app/ko.json b/crates/markraft-app/locales/app/ko.json index 554051b1..d78ef00a 100644 --- a/crates/markraft-app/locales/app/ko.json +++ b/crates/markraft-app/locales/app/ko.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "일반 텍스트로 복사", + "copy-as-html": "HTML 코드로 복사", + "copy-without-theme": "테마 스타일 없이 복사", + "clear-format": "서식 지우기", + "highlight": "강조 표시", + "insert-image": "이미지…", + "math-block": "수식 블록", + "insert-paragraph-before": "앞에 단락 삽입", + "insert-paragraph-after": "뒤에 단락 삽입", + "copy-table": "표 복사", + "traditional-chinese": "중국어 번체로 변환", + "simplified-chinese": "중국어 간체로 변환", "add-column-left": "왼쪽에 열 추가", "add-column-right": "오른쪽에 열 추가", "add-row-above": "위에 행 추가", @@ -71,7 +83,39 @@ "task-list": "할 일 목록", "toggle-task": "할 일 상태 전환", "undo": "실행 취소", - "unpin-note": "노트 고정 해제" + "unpin-note": "노트 고정 해제", + "search-web": "웹 검색", + "share-text": "공유…", + "uppercase": "대문자로 만들기", + "lowercase": "소문자로 만들기", + "capitalize": "첫 글자를 대문자로", + "start-speaking": "말하기 시작", + "stop-speaking": "말하기 중단", + "check-spelling": "지금 문서 검사", + "check-continuously": "입력하는 동안 맞춤법 검사", + "check-grammar": "맞춤법과 함께 문법 검사", + "correct-spelling": "자동으로 맞춤법 수정", + "ignore-spelling": "맞춤법 무시", + "learn-spelling": "맞춤법 학습", + "unlearn-spelling": "맞춤법 학습 취소", + "smart-quotes": "스마트 인용 부호", + "smart-dashes": "스마트 대시", + "text-replacement": "텍스트 대치", + "smart-links": "스마트 링크", + "data-detection": "데이터 탐색기", + "paste-match-style": "붙여넣고 스타일 일치시킴", + "look-up-selection": "‘%{text}’ 찾아보기", + "translate-selection": "‘%{text}’ 번역", + "underline": "밑줄", + "show-spelling": "맞춤법 및 문법 보기", + "replace-quotes": "인용 부호 대치", + "replace-dashes": "대시 대치", + "add-links": "링크 추가", + "replace-text": "텍스트 대치하기", + "show-substitutions": "대치 보기", + "smart-copy-paste": "스마트 복사/붙여넣기", + "hide-spelling": "맞춤법 및 문법 가리기", + "hide-substitutions": "대치 가리기" }, "notes": { "title": "노트", @@ -91,6 +135,10 @@ "no-matches": "일치하는 동작 없음" }, "menu": { + "copy-paste-as": "형식을 지정하여 복사 / 붙여넣기", + "paragraph": "단락", + "format": "서식", + "insert": "삽입", "show-hide": "노트 보기/가리기", "new-note": "새로운 노트", "settings": "설정…", @@ -106,7 +154,13 @@ "select-all": "모두 선택", "find": "찾기…", "find-next": "다음 찾기", - "find-previous": "이전 찾기" + "find-previous": "이전 찾기", + "transformations": "변환", + "speech": "말하기", + "open-with": "다음으로 열기", + "spelling": "맞춤법 및 문법", + "substitutions": "대치", + "font": "서체" }, "input": { "start-writing": "작성 시작…", diff --git a/crates/markraft-app/locales/app/pt-BR.json b/crates/markraft-app/locales/app/pt-BR.json index 19cb4a0c..5c583a23 100644 --- a/crates/markraft-app/locales/app/pt-BR.json +++ b/crates/markraft-app/locales/app/pt-BR.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "Copiar como texto simples", + "copy-as-html": "Copiar como código HTML", + "copy-without-theme": "Copiar sem o estilo do tema", + "clear-format": "Limpar formatação", + "highlight": "Realçar", + "insert-image": "Imagem…", + "math-block": "Bloco de fórmula", + "insert-paragraph-before": "Inserir parágrafo antes", + "insert-paragraph-after": "Inserir parágrafo depois", + "copy-table": "Copiar tabela", + "traditional-chinese": "Converter para chinês tradicional", + "simplified-chinese": "Converter para chinês simplificado", "add-column-left": "Adicionar Coluna à Esquerda", "add-column-right": "Adicionar Coluna à Direita", "add-row-above": "Adicionar Linha Acima", @@ -71,7 +83,39 @@ "task-list": "Lista de Tarefas", "toggle-task": "Alternar Tarefa", "undo": "Desfazer", - "unpin-note": "Desafixar Nota" + "unpin-note": "Desafixar Nota", + "search-web": "Buscar na Web", + "share-text": "Compartilhar…", + "uppercase": "Converter para Maiúsculas", + "lowercase": "Converter para Minúsculas", + "capitalize": "Iniciais Maiúsculas", + "start-speaking": "Iniciar Fala", + "stop-speaking": "Parar Fala", + "check-spelling": "Verificar Documento Agora", + "check-continuously": "Verificar Ortografia ao Digitar", + "check-grammar": "Verificar Gramática com Ortografia", + "correct-spelling": "Corrigir Ortografia Automaticamente", + "ignore-spelling": "Ignorar Ortografia", + "learn-spelling": "Aprender Ortografia", + "unlearn-spelling": "Esquecer Ortografia", + "smart-quotes": "Aspas Inteligentes", + "smart-dashes": "Travessões Inteligentes", + "text-replacement": "Substituição de Texto", + "smart-links": "Links Inteligentes", + "data-detection": "Detectores de Dados", + "paste-match-style": "Colar e Manter Estilo", + "look-up-selection": "Pesquisar “%{text}”", + "translate-selection": "Traduzir “%{text}”", + "underline": "Sublinhado", + "show-spelling": "Mostrar Ortografia e Gramática", + "replace-quotes": "Substituir Aspas", + "replace-dashes": "Substituir Travessões", + "add-links": "Adicionar Links", + "replace-text": "Substituir Texto", + "show-substitutions": "Mostrar Substituições", + "smart-copy-paste": "Copiar/Colar Inteligente", + "hide-spelling": "Ocultar Ortografia e Gramática", + "hide-substitutions": "Ocultar Substituições" }, "notes": { "title": "Notas", @@ -91,6 +135,10 @@ "no-matches": "Nenhuma ação encontrada" }, "menu": { + "copy-paste-as": "Copiar / Colar como", + "paragraph": "Parágrafo", + "format": "Formato", + "insert": "Inserir", "show-hide": "Mostrar / Ocultar Notas", "new-note": "Nova Nota", "settings": "Ajustes…", @@ -106,7 +154,13 @@ "select-all": "Selecionar Tudo", "find": "Buscar…", "find-next": "Buscar Próximo", - "find-previous": "Buscar Anterior" + "find-previous": "Buscar Anterior", + "transformations": "Transformações", + "speech": "Fala", + "open-with": "Abrir Com", + "spelling": "Ortografia e Gramática", + "substitutions": "Substituições", + "font": "Fonte" }, "input": { "start-writing": "Comece a escrever…", diff --git a/crates/markraft-app/locales/app/zh-Hans.json b/crates/markraft-app/locales/app/zh-Hans.json index caa09956..248a5926 100644 --- a/crates/markraft-app/locales/app/zh-Hans.json +++ b/crates/markraft-app/locales/app/zh-Hans.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "复制为纯文本", + "copy-as-html": "复制为 HTML 代码", + "copy-without-theme": "复制时不包含主题样式", + "clear-format": "清除格式", + "highlight": "高亮", + "insert-image": "图片…", + "math-block": "数学公式块", + "insert-paragraph-before": "在前面插入段落", + "insert-paragraph-after": "在后面插入段落", + "copy-table": "复制表格", + "traditional-chinese": "转换为繁体中文", + "simplified-chinese": "转换为简体中文", "add-column-left": "在左侧插入列", "add-column-right": "在右侧插入列", "add-row-above": "在上方插入行", @@ -71,7 +83,39 @@ "task-list": "任务列表", "toggle-task": "切换任务状态", "undo": "撤销", - "unpin-note": "取消置顶笔记" + "unpin-note": "取消置顶笔记", + "search-web": "搜索网页", + "share-text": "共享…", + "uppercase": "转成大写", + "lowercase": "转成小写", + "capitalize": "词首字母大写", + "start-speaking": "开始朗读", + "stop-speaking": "停止朗读", + "check-spelling": "立即检查文稿", + "check-continuously": "键入时检查拼写", + "check-grammar": "检查拼写时检查语法", + "correct-spelling": "自动纠正拼写", + "ignore-spelling": "忽略拼写", + "learn-spelling": "学习拼写", + "unlearn-spelling": "取消学习拼写", + "smart-quotes": "智能引号", + "smart-dashes": "智能破折号", + "text-replacement": "文本替换", + "smart-links": "智能链接", + "data-detection": "数据检测器", + "paste-match-style": "粘贴并匹配样式", + "look-up-selection": "查询“%{text}”", + "translate-selection": "翻译“%{text}”", + "underline": "下划线", + "show-spelling": "显示拼写和语法", + "replace-quotes": "替换引号", + "replace-dashes": "替换破折号", + "add-links": "添加链接", + "replace-text": "替换文本", + "show-substitutions": "显示替换", + "smart-copy-paste": "智能拷贝/粘贴", + "hide-spelling": "隐藏拼写和语法", + "hide-substitutions": "隐藏替换" }, "notes": { "title": "笔记", @@ -91,6 +135,10 @@ "no-matches": "没有匹配的操作" }, "menu": { + "copy-paste-as": "复制 / 粘贴为", + "paragraph": "段落", + "format": "格式", + "insert": "插入", "show-hide": "显示/隐藏笔记", "new-note": "新建笔记", "settings": "设置…", @@ -106,7 +154,13 @@ "select-all": "全选", "find": "查找…", "find-next": "查找下一个", - "find-previous": "查找上一个" + "find-previous": "查找上一个", + "transformations": "变换", + "speech": "语音", + "open-with": "打开方式", + "spelling": "拼写和语法", + "substitutions": "替换", + "font": "字体" }, "input": { "start-writing": "开始书写…", diff --git a/crates/markraft-app/locales/app/zh-Hant.json b/crates/markraft-app/locales/app/zh-Hant.json index f5d36a31..a854f7b1 100644 --- a/crates/markraft-app/locales/app/zh-Hant.json +++ b/crates/markraft-app/locales/app/zh-Hant.json @@ -1,6 +1,18 @@ { "_version": 1, "command": { + "copy-as-plain-text": "拷貝為純文字", + "copy-as-html": "拷貝為 HTML 程式碼", + "copy-without-theme": "拷貝時不包含主題樣式", + "clear-format": "清除格式", + "highlight": "醒目提示", + "insert-image": "圖片…", + "math-block": "數學公式區塊", + "insert-paragraph-before": "在前方插入段落", + "insert-paragraph-after": "在後方插入段落", + "copy-table": "拷貝表格", + "traditional-chinese": "轉換為繁體中文", + "simplified-chinese": "轉換為簡體中文", "add-column-left": "在左側加入欄", "add-column-right": "在右側加入欄", "add-row-above": "在上方加入列", @@ -71,7 +83,39 @@ "task-list": "待辦事項列表", "toggle-task": "切換完成狀態", "undo": "復原", - "unpin-note": "取消釘選筆記" + "unpin-note": "取消釘選筆記", + "search-web": "搜尋網頁", + "share-text": "分享…", + "uppercase": "轉成大寫", + "lowercase": "轉成小寫", + "capitalize": "字首大寫", + "start-speaking": "開始朗讀", + "stop-speaking": "停止朗讀", + "check-spelling": "立即檢查文件", + "check-continuously": "輸入時檢查拼字", + "check-grammar": "檢查拼字時檢查文法", + "correct-spelling": "自動修正拼字", + "ignore-spelling": "忽略拼字", + "learn-spelling": "學習拼字", + "unlearn-spelling": "取消學習拼字", + "smart-quotes": "智慧型引號", + "smart-dashes": "智慧型破折號", + "text-replacement": "文字替代", + "smart-links": "智慧型連結", + "data-detection": "資料偵測器", + "paste-match-style": "貼上並符合樣式", + "look-up-selection": "查詢「%{text}」", + "translate-selection": "翻譯「%{text}」", + "underline": "底線", + "show-spelling": "顯示拼字與文法", + "replace-quotes": "取代引號", + "replace-dashes": "取代破折號", + "add-links": "加入連結", + "replace-text": "取代文字", + "show-substitutions": "顯示替代項目", + "smart-copy-paste": "智慧型拷貝/貼上", + "hide-spelling": "隱藏拼字與文法", + "hide-substitutions": "隱藏替代項目" }, "notes": { "title": "筆記", @@ -91,6 +135,10 @@ "no-matches": "沒有符合的操作" }, "menu": { + "copy-paste-as": "拷貝 / 貼上為", + "paragraph": "段落", + "format": "格式", + "insert": "插入", "show-hide": "顯示/隱藏筆記", "new-note": "新增筆記", "settings": "設定…", @@ -106,7 +154,13 @@ "select-all": "全選", "find": "尋找…", "find-next": "尋找下一個", - "find-previous": "尋找上一個" + "find-previous": "尋找上一個", + "transformations": "變換", + "speech": "語音", + "open-with": "打開方式", + "spelling": "拼字與文法", + "substitutions": "替代", + "font": "字體" }, "input": { "start-writing": "開始撰寫…", diff --git a/crates/markraft-app/locales/notices/de.json b/crates/markraft-app/locales/notices/de.json index 11782c85..4932775d 100644 --- a/crates/markraft-app/locales/notices/de.json +++ b/crates/markraft-app/locales/notices/de.json @@ -73,5 +73,7 @@ "notice.folder-daily": "Öffne einen Ordner, um Tagesnotizen zu verwenden.", "notice.choose-markdown-inside": "Wähle eine Markdown-Datei innerhalb des Notizenordners.", "dialog.daily-folder": "Ordner für Tagesnotizen", - "dialog.daily-template": "Vorlage für Tagesnotizen" + "dialog.daily-template": "Vorlage für Tagesnotizen", + "notice.spelling-complete": "Rechtschreibprüfung abgeschlossen.", + "notice.text-service-rejected": "Das Ergebnis konnte diese Auswahl nicht sicher ersetzen. Kopiere das Ergebnis stattdessen aus dem Systemwerkzeug." } diff --git a/crates/markraft-app/locales/notices/en.json b/crates/markraft-app/locales/notices/en.json index 3a26cc73..a3448a30 100644 --- a/crates/markraft-app/locales/notices/en.json +++ b/crates/markraft-app/locales/notices/en.json @@ -73,5 +73,7 @@ "notice.folder-daily": "Open a folder to use daily notes.", "notice.choose-markdown-inside": "Choose a Markdown file inside the notes folder.", "dialog.daily-folder": "Daily Notes Folder", - "dialog.daily-template": "Daily Note Template" + "dialog.daily-template": "Daily Note Template", + "notice.spelling-complete": "Spelling check complete.", + "notice.text-service-rejected": "The result could not replace this selection safely. Copy the result from the system tool instead." } diff --git a/crates/markraft-app/locales/notices/es.json b/crates/markraft-app/locales/notices/es.json index 48a60f5d..082b1e2c 100644 --- a/crates/markraft-app/locales/notices/es.json +++ b/crates/markraft-app/locales/notices/es.json @@ -73,5 +73,7 @@ "notice.folder-daily": "Abre una carpeta para usar las notas diarias.", "notice.choose-markdown-inside": "Elige un archivo Markdown dentro de la carpeta de notas.", "dialog.daily-folder": "Carpeta de notas diarias", - "dialog.daily-template": "Plantilla de nota diaria" + "dialog.daily-template": "Plantilla de nota diaria", + "notice.spelling-complete": "Revisión ortográfica completada.", + "notice.text-service-rejected": "El resultado no pudo reemplazar esta selección de forma segura. Copia el resultado desde la herramienta del sistema." } diff --git a/crates/markraft-app/locales/notices/fr.json b/crates/markraft-app/locales/notices/fr.json index 623ef42b..44fe698b 100644 --- a/crates/markraft-app/locales/notices/fr.json +++ b/crates/markraft-app/locales/notices/fr.json @@ -73,5 +73,7 @@ "notice.folder-daily": "Ouvrez un dossier pour utiliser les notes quotidiennes.", "notice.choose-markdown-inside": "Choisissez un fichier Markdown situé dans le dossier de notes.", "dialog.daily-folder": "Dossier des notes quotidiennes", - "dialog.daily-template": "Modèle de note quotidienne" + "dialog.daily-template": "Modèle de note quotidienne", + "notice.spelling-complete": "Vérification orthographique terminée.", + "notice.text-service-rejected": "Le résultat n’a pas pu remplacer cette sélection en toute sécurité. Copiez plutôt le résultat depuis l’outil système." } diff --git a/crates/markraft-app/locales/notices/ja.json b/crates/markraft-app/locales/notices/ja.json index ced768c6..416cde53 100644 --- a/crates/markraft-app/locales/notices/ja.json +++ b/crates/markraft-app/locales/notices/ja.json @@ -73,5 +73,7 @@ "notice.folder-daily": "デイリーノートを使うにはフォルダを開いてください。", "notice.choose-markdown-inside": "ノートフォルダ内のMarkdownファイルを選択してください。", "dialog.daily-folder": "デイリーノートのフォルダ", - "dialog.daily-template": "デイリーノートのテンプレート" + "dialog.daily-template": "デイリーノートのテンプレート", + "notice.spelling-complete": "スペルチェックが完了しました。", + "notice.text-service-rejected": "結果でこの選択範囲を安全に置き換えられませんでした。システムツールから結果をコピーしてください。" } diff --git a/crates/markraft-app/locales/notices/ko.json b/crates/markraft-app/locales/notices/ko.json index 18ab52b0..6e926789 100644 --- a/crates/markraft-app/locales/notices/ko.json +++ b/crates/markraft-app/locales/notices/ko.json @@ -73,5 +73,7 @@ "notice.folder-daily": "일일 노트를 사용하려면 폴더를 여세요.", "notice.choose-markdown-inside": "노트 폴더 안에 있는 Markdown 파일을 선택하세요.", "dialog.daily-folder": "일일 노트 폴더", - "dialog.daily-template": "일일 노트 템플릿" + "dialog.daily-template": "일일 노트 템플릿", + "notice.spelling-complete": "맞춤법 검사를 완료했습니다.", + "notice.text-service-rejected": "결과로 이 선택 영역을 안전하게 대치할 수 없습니다. 대신 시스템 도구에서 결과를 복사하세요." } diff --git a/crates/markraft-app/locales/notices/pt-BR.json b/crates/markraft-app/locales/notices/pt-BR.json index de5f68cb..57037c37 100644 --- a/crates/markraft-app/locales/notices/pt-BR.json +++ b/crates/markraft-app/locales/notices/pt-BR.json @@ -73,5 +73,7 @@ "notice.folder-daily": "Abra uma pasta para usar notas diárias.", "notice.choose-markdown-inside": "Escolha um arquivo Markdown dentro da pasta de notas.", "dialog.daily-folder": "Pasta de Notas Diárias", - "dialog.daily-template": "Modelo de Nota Diária" + "dialog.daily-template": "Modelo de Nota Diária", + "notice.spelling-complete": "Verificação ortográfica concluída.", + "notice.text-service-rejected": "O resultado não pôde substituir esta seleção com segurança. Copie o resultado da ferramenta do sistema." } diff --git a/crates/markraft-app/locales/notices/zh-Hans.json b/crates/markraft-app/locales/notices/zh-Hans.json index c3f1d67d..6c0830f6 100644 --- a/crates/markraft-app/locales/notices/zh-Hans.json +++ b/crates/markraft-app/locales/notices/zh-Hans.json @@ -73,5 +73,7 @@ "notice.folder-daily": "请先打开文件夹再使用日记。", "notice.choose-markdown-inside": "请选择笔记文件夹内的 Markdown 文件。", "dialog.daily-folder": "日记文件夹", - "dialog.daily-template": "日记模板" + "dialog.daily-template": "日记模板", + "notice.spelling-complete": "拼写检查完成。", + "notice.text-service-rejected": "此结果无法安全替换当前选区,请改为从系统工具复制结果。" } diff --git a/crates/markraft-app/locales/notices/zh-Hant.json b/crates/markraft-app/locales/notices/zh-Hant.json index 533cb92b..c0c10514 100644 --- a/crates/markraft-app/locales/notices/zh-Hant.json +++ b/crates/markraft-app/locales/notices/zh-Hant.json @@ -73,5 +73,7 @@ "notice.folder-daily": "請先開啟資料夾再使用日記。", "notice.choose-markdown-inside": "請選擇筆記資料夾內的 Markdown 檔案。", "dialog.daily-folder": "日記資料夾", - "dialog.daily-template": "日記範本" + "dialog.daily-template": "日記範本", + "notice.spelling-complete": "拼字檢查完成。", + "notice.text-service-rejected": "此結果無法安全取代目前選取範圍,請改從系統工具複製結果。" } diff --git a/crates/markraft-app/src/app.rs b/crates/markraft-app/src/app.rs index 7a7718f3..58b2aa72 100644 --- a/crates/markraft-app/src/app.rs +++ b/crates/markraft-app/src/app.rs @@ -123,6 +123,7 @@ pub struct MarkraftApp { instance: Instance, sessions: Sessions, interaction: Interaction, + context_menus: ui::context_menu::ContextMenus, input: Option, /// Where Tab has walked the chrome, and the handle its controls share. ring: FocusRing, @@ -185,6 +186,7 @@ pub struct MarkraftApp { shortcuts: std::sync::Arc, /// Whether brackets and quotes pair as they are typed, shared the same way. pairs: std::sync::Arc, + quote_pairs: std::sync::Arc, /// Whether someone is at the window, which is what the chrome follows. presence: Presence, /// The window's own size, and the one resize the app asked for. @@ -311,6 +313,7 @@ impl MarkraftApp { let emoji = markraft_gpui::EmojiInsertion::new(preferences.emoji_characters); let shortcuts = std::sync::Arc::new(preferences.markdown_shortcuts.into()); let pairs = std::sync::Arc::new(preferences.auto_pair.into()); + let quote_pairs = std::sync::Arc::new((!preferences.text_checking.quotes).into()); let house = markraft_commonmark::HouseStyleHandle::default(); apply_markdown_style(&house, &preferences); let find_editor = cx.new(|cx| { @@ -344,6 +347,7 @@ impl MarkraftApp { instance, sessions: Sessions::default(), interaction: Interaction::default(), + context_menus: Default::default(), input: None, ring: FocusRing::new(cx.focus_handle()), picker: Picker::default(), @@ -365,6 +369,7 @@ impl MarkraftApp { emoji, shortcuts, pairs, + quote_pairs, toolbar: Toolbar::default(), find_open: false, find_vim: None, @@ -913,6 +918,8 @@ impl MarkraftApp { cx.notify(); } pub fn hide(&mut self, window: &mut Window, cx: &mut Context) { + self.context_menus.dismiss(); + self.cancel_checking_panel(); self.close_popover(cx); self.cancel_input(cx); self.editor() @@ -1096,6 +1103,8 @@ impl MarkraftApp { self.notes_changed(cx); } fn select_note(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + self.context_menus.dismiss(); + self.cancel_checking_panel(); self.close_popover(cx); self.cancel_input(cx); self.editor().update(cx, |e, cx| e.cancel_composition(cx)); @@ -2118,6 +2127,29 @@ impl MarkraftApp { assets: Vec, window: &mut Window, cx: &mut Context, + ) { + self.insert_assets_with_context(assets, None, window, cx); + } + + /// A context-menu import keeps its original editor and selection throughout + /// saving and copying. A stale request must never replace newly selected text. + fn insert_context_assets( + &mut self, + assets: Vec, + request: markraft_gpui::ContextRequest, + window: &mut Window, + cx: &mut Context, + ) { + let editor = self.editor(); + self.insert_assets_with_context(assets, Some((editor, request)), window, cx); + } + + fn insert_assets_with_context( + &mut self, + assets: Vec, + context: Option<(Entity, markraft_gpui::ContextRequest)>, + window: &mut Window, + cx: &mut Context, ) { if assets.is_empty() { return; @@ -2126,10 +2158,15 @@ impl MarkraftApp { self.feedback.queue(Message::new("notice.resolve-readonly")); return; } + if context.as_ref().is_some_and(|(editor, request)| { + *editor != self.editor() || !editor.read(cx).context_is_current(request) + }) { + return; + } let id = self.library.active_id.clone(); self.flush_then(window, cx, move |this, window, cx| { if this.library.active_id == id { - this.insert_saved_assets(assets, window, cx); + this.insert_saved_assets(assets, context, window, cx); } }); } @@ -2137,6 +2174,7 @@ impl MarkraftApp { fn insert_saved_assets( &mut self, assets: Vec, + context: Option<(Entity, markraft_gpui::ContextRequest)>, window: &mut Window, cx: &mut Context, ) { @@ -2164,10 +2202,15 @@ impl MarkraftApp { .unwrap_or(std::path::Path::new(".")) .join("image-imports"); cx.spawn_in(window, async move |this, cx| { - // The copy belongs to the note it was started in; where the caret is by - // the time it finishes is the user's business, not a reason to drop it. + // Ordinary imports follow the current caret in the originating note. + // A menu request additionally retains its exact editor and selection. if !this - .update(cx, |this, _| this.library.active_id == id) + .update(cx, |this, cx| { + this.library.active_id == id + && context.as_ref().is_none_or(|(editor, request)| { + *editor == this.editor() && editor.read(cx).context_is_current(request) + }) + }) .unwrap_or(false) { return; @@ -2199,7 +2242,12 @@ impl MarkraftApp { ) }; let note = this.library.active_note(); - if this.library.active_id != id || note.read_only.is_some() { + if this.library.active_id != id + || note.read_only.is_some() + || context + .as_ref() + .is_some_and(|(editor, _)| *editor != this.editor()) + { let (text, path) = kept("the note changed"); this.feedback.enqueue(text, path); return; @@ -2215,10 +2263,18 @@ impl MarkraftApp { return; } }; - // Typing during the copy only moves the caret; the images go where - // it is now. + // Validate the menu snapshot immediately before replacement, after + // every asynchronous boundary. Ordinary imports still follow the + // current caret. Both remain isolated from a Vim Insert undo group. let applied = this.editor().update(cx, |editor, cx| { - editor.run_command(&markraft_core::commands::replace_selection(slice), cx) + if context + .as_ref() + .is_some_and(|(_, request)| !editor.context_is_current(request)) + { + return false; + } + let command = markraft_core::commands::replace_selection(slice); + command(editor.state()).is_some_and(|spec| editor.dispatch_isolated([spec], cx)) }); if !applied { let (text, path) = kept("the note would not take them"); diff --git a/crates/markraft-app/src/app/interaction.rs b/crates/markraft-app/src/app/interaction.rs index 023d85c1..97d0a57d 100644 --- a/crates/markraft-app/src/app/interaction.rs +++ b/crates/markraft-app/src/app/interaction.rs @@ -213,6 +213,8 @@ impl MarkraftApp { } pub(super) fn set_panel(&mut self, panel: Panel, cx: &mut Context) { + self.context_menus.dismiss(); + self.cancel_checking_panel(); self.leave_input(cx); self.interaction.switch_panel(panel); } diff --git a/crates/markraft-app/src/app/preferences.rs b/crates/markraft-app/src/app/preferences.rs index 14307ebc..74fe9a76 100644 --- a/crates/markraft-app/src/app/preferences.rs +++ b/crates/markraft-app/src/app/preferences.rs @@ -6,6 +6,7 @@ //! the editors, the platform and the Markdown writer — the one place that knows //! which field is read where. +use super::ui::CheckTrigger; use super::{MarkraftApp, apply_markdown_style}; use crate::platform::Shortcut; use crate::storage::{Pref, Preferences}; @@ -109,6 +110,22 @@ impl MarkraftApp { self.shortcuts .store(now.markdown_shortcuts, std::sync::atomic::Ordering::Relaxed); } + if before.text_checking.smart_insert_delete != now.text_checking.smart_insert_delete { + for editor in self.editors() { + editor.update(cx, |editor, _| { + editor.set_smart_insert_delete(now.text_checking.smart_insert_delete) + }); + } + } + if before.text_checking != now.text_checking { + self.schedule_text_checking(CheckTrigger::Changed, cx); + } + if before.text_checking.quotes != now.text_checking.quotes { + self.quote_pairs.store( + !now.text_checking.quotes, + std::sync::atomic::Ordering::Relaxed, + ); + } if before.auto_pair != now.auto_pair { self.pairs .store(now.auto_pair, std::sync::atomic::Ordering::Relaxed); diff --git a/crates/markraft-app/src/app/sessions.rs b/crates/markraft-app/src/app/sessions.rs index 33ce80c2..f031cdd8 100644 --- a/crates/markraft-app/src/app/sessions.rs +++ b/crates/markraft-app/src/app/sessions.rs @@ -1,4 +1,5 @@ //! Editor session lifetimes and workspace replacement. +use super::ui::CheckTrigger; use super::*; use crate::locale::Message; use markraft_commonmark::SourceTrack; @@ -185,6 +186,7 @@ impl MarkraftApp { self.focus_editor(window, cx); } self.sync_find(cx); + self.schedule_text_checking(CheckTrigger::Changed, cx); return; } let restore_focus = self.close_popover(cx) || restore_focus; @@ -255,7 +257,11 @@ impl MarkraftApp { Setup::new(doc::schema().clone()) .types(doc::types().clone()) .kind(std::sync::Arc::new(kind)) - .extensions(doc::extensions(self.shortcuts.clone(), self.pairs.clone())) + .extensions(doc::extensions_with_quotes( + self.shortcuts.clone(), + self.pairs.clone(), + self.quote_pairs.clone(), + )) .doc(document), cx, ) @@ -264,6 +270,7 @@ impl MarkraftApp { .with_image_base(image_base) .with_image_root(image_root.clone().map_err(|error| error.render(&self.i18n))) .with_file_paste(true) + .with_smart_insert_delete(self.preferences.text_checking.smart_insert_delete) .with_transaction_guard(move |transactions| { let locale = guard_locale.read().expect("the app locale lock"); if reloading.load(std::sync::atomic::Ordering::Relaxed) { @@ -334,6 +341,12 @@ impl MarkraftApp { &editor, window, move |this, editor, event: &EditorEvent, window, cx| { + if let EditorEvent::ContextMenuRequested(request) = event { + if this.library.active_id == note_id { + this.request_context_menu(editor, request.clone(), window, cx); + } + return; + } if let EditorEvent::FilesPasted(item) = event { if this.library.active_id == note_id { this.insert_assets(assets::from_clipboard(item.clone()), window, cx); @@ -388,6 +401,20 @@ impl MarkraftApp { { this.close_popover(cx); } + if this.library.active_id == note_id { + let trigger = if matches!( + event, + EditorEvent::Changed { + text_input: true, + .. + } + ) { + CheckTrigger::Typed + } else { + CheckTrigger::Changed + }; + this.schedule_text_checking(trigger, cx); + } let document = editor.read(cx).committed_document().clone(); this.reconcile_vim_find(window, cx); let title = this.library.note(¬e_id).map(|note| note.title()); @@ -405,6 +432,8 @@ impl MarkraftApp { if this.library.active_id != state_note_id { return; } + this.invalidate_text_service(cx); + this.sync_checking_panel(cx); if this.toolbar.formats_changed(|| { let editor = editor.read(cx); ( @@ -434,6 +463,7 @@ impl MarkraftApp { self.focus_editor(window, cx); } self.sync_find(cx); + self.schedule_text_checking(CheckTrigger::Changed, cx); } /// Apply an application edit through the editor's source guard and history. /// Composition is left untouched; callers can report that note as skipped. diff --git a/crates/markraft-app/src/app/ui.rs b/crates/markraft-app/src/app/ui.rs index 270f2a82..b0422932 100644 --- a/crates/markraft-app/src/app/ui.rs +++ b/crates/markraft-app/src/app/ui.rs @@ -1,5 +1,7 @@ use crate::locale::Message; mod code; +pub(super) mod context_menu; +mod editing; mod focus; mod formatting; mod icons; @@ -8,6 +10,7 @@ mod rename; pub(in crate::app) mod settings; pub(in crate::app) mod slash; pub(super) mod table; +mod text_checking; mod tokens; mod vim; pub(in crate::app) mod wiki; @@ -18,6 +21,7 @@ use focus::Surface; use icons::{Icon, icon, sized_icon}; use slash::{Command, SlashEffect}; use table::TableEdit; +pub(in crate::app) use text_checking::CheckTrigger; pub(in crate::app) use tokens::playback; use tokens::{POPOVER_RADIUS, ROW_HEIGHT, ROW_RADIUS, keycaps, popover_shadow}; @@ -46,7 +50,8 @@ enum Intent { Pin, PinNote(String), TrashNote(String), - Copy, + CopyMarkdown, + Edit(editing::EditCommand), CopyRichText, PastePlain, PasteMarkdown, @@ -109,7 +114,7 @@ fn shortcut_label(intent: &Intent) -> &'static str { Intent::Browse => "⌘P", Intent::Save => "⌘S", Intent::Hide => "⌘W", - Intent::Copy => "⇧⌘C", + Intent::CopyMarkdown => "⇧⌘C", Intent::PastePlain => "⇧⌘V", Intent::PasteMarkdown => "⌥⇧⌘V", Intent::Export => "⇧⌘E", @@ -163,7 +168,8 @@ impl Intent { | Self::DailyNext | Self::Browse | Self::Pin => ActionGroup::Notes, - Self::Copy + Self::Edit(_) + | Self::CopyMarkdown | Self::CopyRichText | Self::PastePlain | Self::PasteMarkdown @@ -295,7 +301,7 @@ fn intent_icon(intent: &Intent) -> Icon { Intent::Browse => Icon::Notes, Intent::Pin => Icon::Pin, Intent::Delete | Intent::TrashNote(_) => Icon::Trash, - Intent::Copy | Intent::CopyRichText | Intent::CopyLink => Icon::Copy, + Intent::CopyMarkdown | Intent::CopyRichText | Intent::CopyLink => Icon::Copy, Intent::Export | Intent::ExportHtml | Intent::ExportPdf => Icon::Export, Intent::Print => Icon::Print, Intent::SendToObsidian(_) => Icon::Send, @@ -351,7 +357,11 @@ impl MarkraftApp { if self.is_reloading() { return; } + if !self.editing_enabled(&intent, cx) { + return; + } match intent { + Intent::Edit(command) => self.edit_selection(command, window, cx), Intent::New => self.new_note(window, cx), Intent::DailyToday => { self.open_daily_note(daily_notes::today(), window, cx); @@ -471,7 +481,7 @@ impl MarkraftApp { Intent::TrashNote(id) => self.confirm_trash(id, true, window, cx), Intent::Select(id) => self.select_note(&id, window, cx), Intent::CodeLanguage(language) => self.apply_code_language(language, window, cx), - Intent::Copy => { + Intent::CopyMarkdown => { self.copy_markdown(cx); self.intent(Intent::Back, window, cx); } @@ -1408,7 +1418,11 @@ impl MarkraftApp { Command::new("daily-today", "command.daily-today", Intent::DailyToday) .ex(&[("today", 5)]), Command::new("save-now", "command.save-now", Intent::Save).ex(&[("write", 1)]), - Command::new("copy-markdown", "command.copy-as-markdown", Intent::Copy), + Command::new( + "copy-markdown", + "command.copy-as-markdown", + Intent::CopyMarkdown, + ), Command::new( "copy-rich-text", "command.copy-as-rich-text", @@ -1852,6 +1866,7 @@ impl MarkraftApp { .. } = command; let Some(intent) = intent else { continue }; + let enabled = self.editing_enabled(&intent, cx); let group = intent.action_group(); let separator = previous_group.is_some_and(|previous| previous != group); previous_group = Some(group); @@ -1868,6 +1883,7 @@ impl MarkraftApp { }) .child( self.row(id, label, shortcut, ex, intent, cx) + .when(!enabled, |s| s.opacity(0.45)) .h(ACTION_ROW_HEIGHT) .text_size(px(14.)) .role(Role::Button) diff --git a/crates/markraft-app/src/app/ui/context_menu.rs b/crates/markraft-app/src/app/ui/context_menu.rs new file mode 100644 index 00000000..764916de --- /dev/null +++ b/crates/markraft-app/src/app/ui/context_menu.rs @@ -0,0 +1,1332 @@ +//! One native popup belongs to one editor snapshot. Native tracking runs outside +//! GPUI updates; the returned command is validated before touching that editor. + +use super::*; +use crate::platform::checking_panel::{CheckingPanel, SubstitutionKind}; +use crate::platform::context_menu::{PreparedMenu, Row, TitleStyle}; +use crate::platform::text_checking::{CheckKind, CheckOptions, SpellDocument}; +use crate::platform::{ + text_requestor::{TextReplacement, TextServiceSession, TextServiceSnapshot}, + text_services::{self, OpenWithApplication, TextAnchor}, +}; +use crate::storage::TextCheckingSetting; +use editing::EditCommand; +use markraft_gpui::CopyFormat; +use markraft_gpui::{ContextRequest, ContextTarget, TextTransformation}; + +mod markdown; +use markdown::MarkdownAction; + +#[derive(Default)] +pub(in crate::app) struct ContextMenus { + next: u64, + pending: Option, + native: Option, + pub(super) checking: super::text_checking::TextChecking, +} + +struct Session { + id: u64, + note: String, + editor: WeakEntity, + request: ContextRequest, + actions: Vec, +} + +struct NativeSession { + id: u64, + note: String, + editor: WeakEntity, + request: ContextRequest, + _requestor: Option, + _translation: Option, +} + +#[derive(Clone)] +enum TextService { + Lookup, + Search, + Share, + Speak, + StopSpeaking, +} + +#[derive(Clone)] +enum MenuAction { + Intent(Intent), + Text(TextService, String), + Transform(TextTransformation), + PasteMatchStyle, + Translate(String), + Replace(ContextRequest, String), + IgnoreSpelling(String), + LearnSpelling(String), + UnlearnSpelling(String), + CheckSpelling, + CheckingPanel(CheckingPanel), + Substitute(SubstitutionKind), + CheckSetting(TextCheckingSetting), + OpenWith(String, OpenWithApplication), + CopyUrl(String), + OpenUrl(String), + Wiki { target: String, embed: bool }, + CopyCode(usize), + CodeLanguage(usize), + CopyAs(CopyFormat), + CopyTable(usize), + Markdown(MarkdownAction), + InsertParagraph { before: bool }, +} + +impl ContextMenus { + fn dismiss_popup(&mut self) { + self.pending = None; + self.native = None; + } + + pub(in crate::app) fn dismiss(&mut self) { + self.dismiss_popup(); + } +} + +struct MenuBuilder<'a> { + app: &'a MarkraftApp, + cx: &'a App, + actions: Vec, +} + +impl MenuBuilder<'_> { + fn spelling_word(&mut self, word: &str, learned: bool) -> Vec { + if learned { + vec![self.item( + "command.unlearn-spelling", + MenuAction::UnlearnSpelling(word.to_owned()), + true, + None, + )] + } else { + vec![ + self.item( + "command.ignore-spelling", + MenuAction::IgnoreSpelling(word.to_owned()), + true, + None, + ), + self.item( + "command.learn-spelling", + MenuAction::LearnSpelling(word.to_owned()), + true, + None, + ), + ] + } + } + + fn item( + &mut self, + label: &str, + action: MenuAction, + enabled: bool, + checked: Option, + ) -> Row { + self.literal(self.app.i18n.text(label), action, enabled, checked) + } + + fn literal( + &mut self, + label: String, + action: MenuAction, + enabled: bool, + checked: Option, + ) -> Row { + let id = self.actions.len(); + self.actions.push(action); + Row::Item { + id, + label, + enabled, + checked, + symbol: None, + appearance: Default::default(), + } + } + + fn intent(&mut self, label: &str, intent: Intent) -> Row { + let state = self.app.editing_state(&intent, self.cx); + self.item( + label, + MenuAction::Intent(intent), + state.is_none_or(|s| s.enabled), + state.and_then(|s| s.checked), + ) + } + + fn submenu(&self, label: &str, children: Vec) -> Row { + Row::Submenu { + label: self.app.i18n.text(label), + enabled: true, + children, + symbol: None, + } + } +} + +/// The rows that exist only for a selection with text in it. +#[derive(Default)] +struct SelectionRows { + suggestions: Vec, + lookup: Vec, + share: Option, + transformations: Option, +} + +/// The groups of one context menu, each built in the order its actions are +/// numbered. +impl MenuBuilder<'_> { + fn clipboard_rows(&mut self, editor: &EditorView) -> Vec { + let mut rows: Vec = [EditCommand::Cut, EditCommand::Copy, EditCommand::Paste] + .into_iter() + .map(|command| self.intent(command.label(), Intent::Edit(command))) + .collect(); + let paste_enabled = self + .app + .editing_state(&Intent::Edit(EditCommand::PastePlain), self.cx) + .is_some_and(|state| state.enabled); + rows.insert( + 3, + self.item( + "command.paste-match-style", + MenuAction::PasteMatchStyle, + paste_enabled, + None, + ), + ); + let mut copies = [ + ("command.copy-as-plain-text", CopyFormat::PlainText), + ("command.copy-as-markdown", CopyFormat::Markdown), + ("command.copy-as-html", CopyFormat::HtmlCode), + ("command.copy-without-theme", CopyFormat::RichText), + ] + .into_iter() + .map(|(label, format)| { + self.item( + label, + MenuAction::CopyAs(format), + editor.can_copy_as(format), + None, + ) + }) + .collect::>(); + copies.push(Row::Separator); + copies.extend( + [EditCommand::PastePlain, EditCommand::PasteMarkdown] + .into_iter() + .map(|command| self.intent(command.label(), Intent::Edit(command))), + ); + rows.push(self.submenu("menu.copy-paste-as", copies)); + rows + } + + /// Only styles Markdown can spell: the document format is not widened to + /// carry fonts, colours or paragraph geometry. + fn format_menu(&mut self) -> Row { + let mut formats: Vec = [ + ("command.bold", doc::Inline::Bold, TitleStyle::Bold), + ("command.italic", doc::Inline::Italic, TitleStyle::Italic), + ( + "command.underline", + doc::Inline::Underline, + TitleStyle::Underline, + ), + ] + .into_iter() + .map(|(label, mark, style)| { + self.intent(label, Intent::Mark(mark)) + .with_title_style(style) + }) + .collect(); + formats.push(Row::Separator); + formats.extend( + [ + ("command.strikethrough", doc::Inline::Strikethrough), + ("command.inline-code", doc::Inline::Code), + ("command.highlight", doc::Inline::Highlight), + ] + .into_iter() + .map(|(label, mark)| self.intent(label, Intent::Mark(mark))), + ); + formats.push(self.intent("command.link", Intent::Link)); + formats.push(Row::Separator); + formats.push(self.markdown("command.clear-format", MarkdownAction::ClearFormatting)); + self.submenu("menu.format", formats) + .with_symbol("textformat") + } + + fn markdown(&mut self, label: &str, action: MarkdownAction) -> Row { + let enabled = self.app.markdown_action_enabled(action, self.cx); + self.item(label, MenuAction::Markdown(action), enabled, None) + } + + fn paragraph_menu(&mut self) -> Row { + let paragraph = [ + ("command.heading-1", doc::Block::Heading(1)), + ("command.heading-2", doc::Block::Heading(2)), + ("command.heading-3", doc::Block::Heading(3)), + ("command.heading-4", doc::Block::Heading(4)), + ("command.heading-5", doc::Block::Heading(5)), + ("command.heading-6", doc::Block::Heading(6)), + ("command.paragraph", doc::Block::Paragraph), + ("command.quote", doc::Block::Quote), + ("command.ordered-list", doc::Block::Ordered), + ("command.bullet-list", doc::Block::Bullet), + ("command.task-list", doc::Block::Task), + ] + .into_iter() + .map(|(label, block)| self.intent(label, Intent::Block(block))) + .collect(); + self.submenu("menu.paragraph", paragraph) + } + + fn insert_menu( + &mut self, + editor: &EditorView, + request: &ContextRequest, + writable: bool, + ) -> Row { + let mut inserts = vec![ + self.markdown("command.insert-image", MarkdownAction::InsertImage), + self.intent("command.code-block", Intent::Block(doc::Block::Code)), + self.markdown("command.math-block", MarkdownAction::InsertMath), + self.intent("command.table", Intent::InsertTable), + self.intent("command.divider", Intent::Block(doc::Block::Divider)), + Row::Separator, + ]; + for (label, before) in [ + ("command.insert-paragraph-before", true), + ("command.insert-paragraph-after", false), + ] { + inserts.push(self.item( + label, + MenuAction::InsertParagraph { before }, + writable && editor.can_insert_context_paragraph(request, before), + None, + )); + } + self.submenu("menu.insert", inserts) + } + + /// Commands for the object under the pointer. + fn target_rows( + &mut self, + editor: &EditorView, + request: &ContextRequest, + writable: bool, + ) -> Vec { + let mut contextual = Vec::new(); + match &request.target { + ContextTarget::Link { url, range } => { + contextual.push(self.item( + "command.open-link", + MenuAction::OpenUrl(url.clone()), + true, + None, + )); + contextual.push(self.item( + "command.copy-link", + MenuAction::CopyUrl(url.clone()), + true, + None, + )); + // A selection crossing other text must not be moved just to make + // an object command work. Only offer edits for its active link. + let selection = request.selection_range(); + if selection.start >= range.start + && selection.end <= range.end + && editor.active_link().as_ref() == Some(url) + { + contextual.push(self.intent("surfaces.link.edit", Intent::EditLink)); + contextual.push(self.intent("command.remove-link", Intent::Unlink)); + } + if self.app.platform.is_some() { + let children = text_services::applications_for_url(url) + .into_iter() + .map(|application| { + let label = application.name.clone(); + self.literal( + label, + MenuAction::OpenWith(url.clone(), application), + true, + None, + ) + }) + .collect::>(); + if !children.is_empty() { + contextual.push(self.submenu("menu.open-with", children)); + } + } + } + ContextTarget::WikiLink { target, embed, .. } => { + contextual.push(self.item( + "command.open-link", + MenuAction::Wiki { + target: target.clone(), + embed: *embed, + }, + true, + None, + )); + contextual.push(self.item( + "command.copy-link", + MenuAction::CopyUrl(target.clone()), + true, + None, + )); + } + ContextTarget::CodeBlock { pos } => { + contextual.push(self.item( + "command.copy-code-block", + MenuAction::CopyCode(*pos), + editor.code_text_at(*pos).is_some(), + None, + )); + contextual.push(self.item( + "command.choose-code-language", + MenuAction::CodeLanguage(*pos), + writable, + None, + )); + } + ContextTarget::Table { pos, cell } => { + contextual.push(self.table_menu(editor, *pos, *cell)); + } + ContextTarget::Text | ContextTarget::Image { .. } | ContextTarget::Math { .. } => {} + } + contextual + } + + fn table_menu(&mut self, editor: &EditorView, pos: usize, cell: usize) -> Row { + let state = editor.state(); + let selection = state.selection(); + let in_cell = |position| { + state.doc().resolve(position).ok().is_some_and(|resolved| { + (1..=resolved.depth()).any(|depth| resolved.before(depth) == cell) + }) + }; + let mut table = Vec::new(); + if in_cell(selection.from(state.doc())) && in_cell(selection.to(state.doc())) { + table = [ + ("command.add-row-above", TableEdit::RowBefore), + ("command.add-row-below", TableEdit::RowAfter), + ("command.add-column-left", TableEdit::ColumnBefore), + ("command.add-column-right", TableEdit::ColumnAfter), + ( + "command.align-column-left", + TableEdit::Align(ColumnAlignment::Left), + ), + ( + "command.align-column-center", + TableEdit::Align(ColumnAlignment::Center), + ), + ( + "command.align-column-right", + TableEdit::Align(ColumnAlignment::Right), + ), + ("command.delete-row", TableEdit::DeleteRow), + ("command.delete-column", TableEdit::DeleteColumn), + ("command.delete-table", TableEdit::DeleteTable), + ] + .into_iter() + .map(|(label, edit)| self.intent(label, Intent::Table(edit))) + .collect(); + table.push(Row::Separator); + } + table.push(self.item( + "command.copy-table", + MenuAction::CopyTable(pos), + editor.can_copy_table_at(pos), + None, + )); + self.submenu("command.table", table) + } + + fn setting(&mut self, label: &str, setting: TextCheckingSetting) -> Row { + let value = self.app.preferences.text_checking.get(setting); + self.item(label, MenuAction::CheckSetting(setting), true, Some(value)) + } + + fn panel(&mut self, panel: CheckingPanel) -> Row { + self.item( + self.app.checking_panel_label(panel), + MenuAction::CheckingPanel(panel), + true, + None, + ) + } + + fn spelling_menu(&mut self) -> Row { + let mut checking = vec![ + self.panel(CheckingPanel::Spelling), + self.item( + "command.check-spelling", + MenuAction::CheckSpelling, + true, + None, + ), + Row::Separator, + ]; + for (label, setting) in [ + ("command.check-continuously", TextCheckingSetting::Spelling), + ("command.check-grammar", TextCheckingSetting::Grammar), + ("command.correct-spelling", TextCheckingSetting::Correction), + ] { + checking.push(self.setting(label, setting)); + } + self.submenu("menu.spelling", checking) + } + + /// Substitutions validate each detected reading range separately. A + /// concealed delimiter between ranges only prevents whole-text Services + /// replacement; it must not disable edits inside those ranges. + fn substitutions_menu(&mut self, has_text: bool, writable: bool) -> Row { + let mut rows = Vec::new(); + if has_text { + rows.extend( + [ + ("command.replace-quotes", SubstitutionKind::Quotes), + ("command.replace-dashes", SubstitutionKind::Dashes), + ("command.add-links", SubstitutionKind::Links), + ("command.replace-text", SubstitutionKind::Text), + ] + .into_iter() + .map(|(label, kind)| { + self.item(label, MenuAction::Substitute(kind), writable, None) + }), + ); + rows.push(Row::Separator); + } + rows.extend([self.panel(CheckingPanel::Substitutions), Row::Separator]); + for (label, setting) in [ + ( + "command.smart-copy-paste", + TextCheckingSetting::SmartInsertDelete, + ), + ("command.smart-quotes", TextCheckingSetting::Quotes), + ("command.smart-dashes", TextCheckingSetting::Dashes), + ("command.smart-links", TextCheckingSetting::Links), + ("command.data-detection", TextCheckingSetting::DataDetectors), + ( + "command.text-replacement", + TextCheckingSetting::Replacements, + ), + ] { + rows.push(self.setting(label, setting)); + } + self.submenu("menu.substitutions", rows) + } + + fn selection_rows( + &mut self, + editor: &EditorView, + request: &ContextRequest, + text: &markraft_gpui::ContextText, + writable: bool, + ) -> SelectionRows { + let mut suggestions = Vec::new(); + if let Some(checker) = self.app.spell_document() + && text.text.chars().count() <= 2048 + && editor.context_is_prose(request) + { + suggestions = self.checked_rows(editor, request, &text.text, &checker, writable); + if self.app.preferences.text_checking.data_detectors { + suggestions.extend(self.detected_data_row(editor, request, &checker)); + } + } + let detected = suggestions + .iter() + .any(|row| matches!(row, Row::DetectedData { .. })); + SelectionRows { + suggestions, + lookup: self.lookup_rows(&text.text, detected), + share: Some( + self.item( + "command.share-text", + MenuAction::Text(TextService::Share, text.text.clone()), + true, + None, + ) + .with_symbol("square.and.arrow.up"), + ), + transformations: self.transformations_menu(text, writable), + } + } + + /// Spelling and grammar results for the selected text, with the guesses + /// and dictionary commands for each. + fn checked_rows( + &mut self, + editor: &EditorView, + request: &ContextRequest, + text: &str, + checker: &SpellDocument, + writable: bool, + ) -> Vec { + let mut rows = Vec::new(); + // Learned words produce no spelling result. Query the native + // dictionary separately so users can reverse Learn Spelling. + if checker.has_learned(text) { + rows.extend(self.spelling_word(text, true)); + } + let checks = checker.check( + text, + CheckOptions { + spelling: true, + grammar: self.app.preferences.text_checking.grammar, + links: true, + data_detectors: false, + ..Default::default() + }, + ); + let urls = checks + .iter() + .filter(|check| check.kind == CheckKind::Link) + .map(|check| check.range.clone()) + .collect::>(); + for check in checks { + let Some(narrowed) = editor.context_text_range(request, check.range.clone()) else { + continue; + }; + if matches!(check.kind, CheckKind::Spelling | CheckKind::Grammar) + && !urls + .iter() + .any(|range| range.start < check.range.end && check.range.start < range.end) + { + if let Some(detail) = check.detail { + rows.push(self.literal(detail, MenuAction::CheckSpelling, false, None)); + } + let guesses = if check.kind == CheckKind::Spelling { + checker.guesses(text, check.range.clone()) + } else { + check.suggestions + }; + for suggestion in guesses.into_iter().take(5) { + rows.push(self.literal( + suggestion.clone(), + MenuAction::Replace(narrowed.clone(), suggestion), + writable, + None, + )); + } + if check.kind == CheckKind::Spelling { + let word = text + .chars() + .skip(check.range.start) + .take(check.range.end - check.range.start) + .collect::(); + rows.extend(self.spelling_word(&word, false)); + } + } + if self.app.preferences.text_checking.data_detectors + && !matches!(request.target, ContextTarget::Link { .. }) + && let Some(url) = check.url + { + rows.push(self.item("command.open-link", MenuAction::OpenUrl(url), true, None)); + } + } + rows + } + + /// The first date, address or similar in the selection's paragraph that + /// the selection overlaps. + fn detected_data_row( + &self, + editor: &EditorView, + request: &ContextRequest, + checker: &SpellDocument, + ) -> Option { + let selection = request.selection_range(); + let projection = editor.projection(); + let (index, _) = projection.pos_to_line_offset(selection.start)?; + let line = projection.line(index).filter(|line| line.len() <= 2048)?; + let paragraph = editor.context_source_range_snapshot(line.from()..line.to())?; + let reading = editor.context_text(¶graph)?; + let options = CheckOptions { + data_detectors: true, + ..Default::default() + }; + checker + .check(&reading.text, options) + .into_iter() + .find_map(|check| { + let result = check.data?; + let detected = editor + .context_text_range(¶graph, check.range) + .filter(|detected| editor.context_is_prose(detected))?; + let range = detected.text_range(); + (range.start < selection.end && range.end > selection.start).then_some( + Row::DetectedData { + result, + position: request.position, + }, + ) + }) + } + + fn lookup_rows(&mut self, text: &str, detected: bool) -> Vec { + let mut lookup = Vec::new(); + if !detected { + let snippet = crate::platform::context_menu::selection_label(text); + let app = self.app; + lookup.push( + self.literal( + app.i18n + .text_with("command.look-up-selection", &[("text", &snippet)]), + MenuAction::Text(TextService::Lookup, text.to_owned()), + true, + None, + ) + .with_symbol("info.circle"), + ); + if app.platform.is_some() && crate::platform::translation::available() { + lookup.push( + self.literal( + app.i18n + .text_with("command.translate-selection", &[("text", &snippet)]), + MenuAction::Translate(text.to_owned()), + true, + None, + ) + .with_symbol("translate"), + ); + } + lookup.push(Row::Separator); + } + lookup.push( + self.item( + "command.search-web", + MenuAction::Text(TextService::Search, text.to_owned()), + true, + None, + ) + .with_symbol("magnifyingglass"), + ); + lookup + } + + fn transformations_menu( + &mut self, + text: &markraft_gpui::ContextText, + writable: bool, + ) -> Option { + let transforms: Vec<_> = TextTransformation::available_for(&text.text) + .into_iter() + .map(|transform| { + let label = match transform { + TextTransformation::Uppercase => "command.uppercase", + TextTransformation::Lowercase => "command.lowercase", + TextTransformation::Capitalize => "command.capitalize", + TextTransformation::TraditionalChinese => "command.traditional-chinese", + TextTransformation::SimplifiedChinese => "command.simplified-chinese", + }; + self.item( + label, + MenuAction::Transform(transform), + writable && text.transformable, + None, + ) + }) + .collect(); + (!transforms.is_empty()).then(|| self.submenu("menu.transformations", transforms)) + } + + fn speech_menu(&mut self, text: String) -> Row { + let speech = vec![ + self.item( + "command.start-speaking", + MenuAction::Text(TextService::Speak, text.clone()), + !text.trim().is_empty(), + None, + ), + self.item( + "command.stop-speaking", + MenuAction::Text(TextService::StopSpeaking, String::new()), + self.app.platform.is_some() && text_services::is_speaking(), + None, + ), + ]; + self.submenu("menu.speech", speech) + } +} + +impl MarkraftApp { + fn context_menu_rows(&self, request: &ContextRequest, cx: &App) -> (Vec, Vec) { + let mut menu = MenuBuilder { + app: self, + cx, + actions: Vec::new(), + }; + let editor = self.editor().read(cx); + let writable = !self.is_reloading() + && self.library.active_note().read_only.is_none() + && !editor.is_composing(); + // One reading of the request serves every group that asks about it. + let reading = editor.context_text(request); + let has_text = reading.as_ref().is_some_and(|text| !text.text.is_empty()); + let selected = reading.filter(|text| !text.text.trim().is_empty()); + + let clipboard = menu.clipboard_rows(editor); + let format = menu.format_menu(); + let paragraph = menu.paragraph_menu(); + let insert = menu.insert_menu(editor, request, writable); + let contextual = menu.target_rows(editor, request, writable); + let spelling = menu.spelling_menu(); + let substitutions = menu.substitutions_menu(has_text, writable); + let selection = selected + .as_ref() + .map(|text| menu.selection_rows(editor, request, text, writable)) + .unwrap_or_default(); + let speech_text = selected + .or_else(|| editor.context_text(&editor.context_document_snapshot())) + .map(|text| text.text) + .unwrap_or_default(); + let speech = menu.speech_menu(speech_text); + + let mut result = Vec::new(); + for group in [ + selection.suggestions, + contextual, + selection.lookup, + clipboard, + ] { + if !group.is_empty() { + if !result.is_empty() { + result.push(Row::Separator); + } + result.extend(group); + } + } + if let Some(share) = selection.share { + result.push(Row::Separator); + result.push(share); + } + result.extend([ + Row::Separator, + paragraph, + format, + insert, + Row::WritingTools, + Row::Separator, + spelling, + substitutions, + ]); + result.extend(selection.transformations); + result.push(speech); + (result, menu.actions) + } + + pub(in crate::app) fn request_context_menu( + &mut self, + editor: &Entity, + request: ContextRequest, + window: &mut Window, + cx: &mut Context, + ) { + if self.editor() != *editor + || self.interaction.panel() != Panel::Editor + || !editor.read(cx).context_is_current(&request) + { + return; + } + // A secondary click on our nonactivating NSPanel does not make the app + // key like a primary click does. Activate before native menu tracking so + // keyboard navigation and Escape belong to the popup immediately. + if self.platform.is_some() { + cx.activate(true); + window.activate_window(); + } + self.close_popover(cx); + self.context_menus.dismiss_popup(); + self.ensure_spell_document(); + let (rows, actions) = self.context_menu_rows(&request, cx); + self.context_menus.next = self.context_menus.next.wrapping_add(1); + let id = self.context_menus.next; + let position = request.position; + self.context_menus.pending = Some(Session { + id, + note: self.library.active_id.clone(), + editor: editor.downgrade(), + request, + actions, + }); + // Headless tests exercise the same pending session and completion path. + if self.platform.is_none() { + return; + } + self.attach_text_services(id, window, cx); + let writing_tools = self + .context_menus + .native + .as_ref() + .and_then(|session| session._requestor.as_ref()) + .and_then(TextServiceSession::writing_tools_items); + let prepared = match PreparedMenu::new(&rows, window, writing_tools) { + Ok(menu) => menu, + Err(error) => { + self.context_menus.dismiss_popup(); + self.feedback.set_error(error); + cx.notify(); + return; + } + }; + // AppKit tracking can suspend GPUI presentation. Let the pointer's new + // selection paint before handing event delivery to the native popup. + let (painted, ready) = futures_channel::oneshot::channel(); + window.on_next_frame(move |_, _| { + let _ = painted.send(()); + }); + cx.spawn_in(window, async move |this, cx| { + if ready.await.is_err() { + return; + } + let valid = this + .update(cx, |app, cx| app.context_menu_current(id, cx)) + .unwrap_or(false); + if !valid { + return; + } + // No App, Window or entity borrow crosses AppKit's nested event loop. + let selected = prepared.show(position); + let _ = cx.update(|window, cx| { + let _ = this.update(cx, |app, cx| { + app.finish_context_menu(id, selected, window, cx) + }); + }); + }) + .detach(); + } + + fn attach_text_services(&mut self, id: u64, window: &Window, cx: &mut Context) { + let session = self.context_menus.pending.as_ref().expect("a pending menu"); + let Some(text) = self.editor().read(cx).context_text(&session.request) else { + return; + }; + let editable = text.replaceable && self.library.active_note().read_only.is_none(); + let prose = self.editor().read(cx).context_is_prose(&session.request); + let editor_style = self.editor().read(cx).style(); + let line_height = editor_style.body_size * editor_style.line_height_ratio; + let Ok((requestor, returned)) = TextServiceSession::attach( + window, + TextServiceSnapshot { + text: text.text, + editable, + prose, + }, + session.request.position, + line_height, + ) else { + return; + }; + self.context_menus.native = Some(NativeSession { + id, + note: session.note.clone(), + editor: session.editor.clone(), + request: session.request.clone(), + _requestor: Some(requestor), + _translation: None, + }); + cx.spawn(async move |this, cx| { + let Ok(replacement) = returned.await else { + return; + }; + let _ = this.update(cx, |app, cx| { + if app + .context_menus + .native + .as_ref() + .is_none_or(|session| session.id != id) + { + return; + } + let session = app.context_menus.native.take().expect("the active service"); + if !app.apply_service_replacement( + &session.note, + &session.editor, + &session.request, + replacement, + cx, + ) { + app.inform(Message::new("notice.text-service-rejected"), cx); + } + }); + }) + .detach(); + } + + fn apply_service_text( + &mut self, + note: &str, + editor: &WeakEntity, + request: &ContextRequest, + text: &str, + cx: &mut Context, + ) -> bool { + self.apply_service_replacement( + note, + editor, + request, + TextReplacement::PreserveStyles(text.to_owned()), + cx, + ) + } + + fn apply_service_replacement( + &mut self, + note: &str, + editor: &WeakEntity, + request: &ContextRequest, + replacement: TextReplacement, + cx: &mut Context, + ) -> bool { + use markraft_core::kind::ReadingReplacementPolicy; + + if note != self.library.active_id + || self.interaction.panel() != Panel::Editor + || self.is_reloading() + || self.library.active_note().read_only.is_some() + { + return false; + } + let Some(editor) = editor.upgrade().filter(|editor| *editor == self.editor()) else { + return false; + }; + let (text, policy) = match replacement { + TextReplacement::PlainText(text) => { + (text, ReadingReplacementPolicy::InheritSelectionStart) + } + TextReplacement::PreserveStyles(text) => { + (text, ReadingReplacementPolicy::PreserveUnchanged) + } + }; + editor.update(cx, |editor, cx| { + // A plain service can return identical text but replace its styles. + // Only source-preserving replacements may skip that transaction. + (policy == ReadingReplacementPolicy::PreserveUnchanged + && editor + .context_text(request) + .is_some_and(|selected| selected.text == text)) + || editor.replace_context_text_with_policy(request, &text, policy, cx) + }) + } + + pub(in crate::app) fn invalidate_text_service(&mut self, cx: &App) { + if self.context_menus.native.as_ref().is_some_and(|session| { + session.note != self.library.active_id + || !session.editor.upgrade().is_some_and(|editor| { + editor == self.editor() && editor.read(cx).context_is_current(&session.request) + }) + }) { + self.context_menus.native = None; + } + } + + fn present_text_service( + &mut self, + service: TextService, + text: String, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(service, TextService::Search) { + let mut url = + url::Url::parse("https://www.google.com/search").expect("static search URL"); + url.query_pairs_mut().append_pair("q", &text); + cx.open_url(url.as_str()); + return; + } + if matches!(service, TextService::StopSpeaking) { + text_services::stop_speaking(); + return; + } + let anchor = match TextAnchor::new(window, position) { + Ok(anchor) => anchor, + Err(error) => { + self.feedback.set_error(error); + cx.notify(); + return; + } + }; + cx.spawn(async move |this, cx| { + let result = match service { + TextService::Lookup => anchor.show_definition(&text), + TextService::Share => anchor.share(&text), + TextService::Speak => text_services::start_speaking(&text), + _ => Ok(()), + }; + if let Err(error) = result { + let _ = this.update(cx, |app, cx| { + app.feedback.set_error(error); + cx.notify(); + }); + } + }) + .detach(); + } + + fn context_menu_current(&self, id: u64, cx: &App) -> bool { + self.context_menus.pending.as_ref().is_some_and(|session| { + session.id == id + && session.note == self.library.active_id + && self.interaction.panel() == Panel::Editor + && !self.is_reloading() + && session.editor.upgrade().is_some_and(|editor| { + editor == self.editor() && editor.read(cx).context_is_current(&session.request) + }) + }) + } + + /// Run a menu's intent through the app's own dispatch. An edit is the + /// menu's own undo step, apart from the typing around it. + fn run_menu_intent(&mut self, intent: Intent, window: &mut Window, cx: &mut Context) { + match intent { + // A native popup holds keyboard focus, so the clipboard command + // goes to this editor rather than through the focused responder. + Intent::Edit(command) => { + if self.editing_enabled(&Intent::Edit(command), cx) { + self.editor().update(cx, |editor, cx| { + editor.execute_context_action(command.into(), cx) + }); + self.focus_editor(window, cx); + } + } + Intent::Mark(_) + | Intent::Block(_) + | Intent::InsertTable + | Intent::Table(_) + | Intent::Unlink => { + let editor = self.editor(); + let previous = editor.update(cx, |editor, _| editor.set_context_edit(true)); + self.intent(intent, window, cx); + editor.update(cx, |editor, _| editor.set_context_edit(previous)); + } + other => self.intent(other, window, cx), + } + } + + fn update_dictionary(&mut self, update: impl FnOnce(&SpellDocument), cx: &mut Context) { + if let Some(checker) = self.spell_document() { + update(&checker); + } + self.schedule_text_checking(CheckTrigger::Changed, cx); + } + + /// Present the system translation of `text`. The session stays alive + /// until the popover returns, and a returned replacement is written only + /// if that session is still the active one. + fn show_translation( + &mut self, + id: u64, + session: Session, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let editable = self.library.active_note().read_only.is_none() + && self + .editor() + .read(cx) + .context_text(&session.request) + .is_some_and(|text| text.replaceable); + let shown = crate::platform::translation::Translation::show( + window, + session.request.position, + text, + editable, + ); + let (translation, returned) = match shown { + Ok(shown) => shown, + Err(error) => { + self.feedback.set_error(error); + cx.notify(); + return; + } + }; + self.context_menus.native = Some(NativeSession { + id, + note: session.note, + editor: session.editor, + request: session.request, + _requestor: None, + _translation: Some(translation), + }); + cx.spawn(async move |this, cx| { + let Ok(result) = returned.await else { + return; + }; + let _ = this.update(cx, |app, cx| { + if app + .context_menus + .native + .as_ref() + .is_none_or(|session| session.id != id) + { + return; + } + let session = app + .context_menus + .native + .take() + .expect("the active translation"); + if let Some(text) = result + && !app.apply_service_text( + &session.note, + &session.editor, + &session.request, + &text, + cx, + ) + { + app.inform(Message::new("notice.text-service-rejected"), cx); + } + }); + }) + .detach(); + } + + fn finish_context_menu( + &mut self, + id: u64, + selected: Option, + window: &mut Window, + cx: &mut Context, + ) { + if !self.context_menu_current(id, cx) { + if self + .context_menus + .pending + .as_ref() + .is_some_and(|session| session.id == id) + { + self.context_menus.dismiss_popup(); + } + return; + } + let session = self.context_menus.pending.take().expect("validated popup"); + let Some(action) = selected + .and_then(|index| session.actions.get(index)) + .cloned() + else { + self.focus_editor(window, cx); + return; + }; + self.context_menus.native = None; + match action { + MenuAction::CopyAs(format) => { + self.editor().update(cx, |editor, cx| { + editor.copy_as(format, cx); + }); + self.focus_editor(window, cx); + } + MenuAction::CopyTable(pos) => { + self.editor().update(cx, |editor, cx| { + editor.copy_table_at(pos, cx); + }); + self.focus_editor(window, cx); + } + MenuAction::Markdown(action) => { + if self.markdown_action_enabled(action, cx) { + self.run_markdown_action(action, &session.request, window, cx); + } + } + MenuAction::InsertParagraph { before } => { + if self.library.active_note().read_only.is_none() { + self.editor().update(cx, |editor, cx| { + editor.insert_context_paragraph(&session.request, before, cx); + }); + } + self.focus_editor(window, cx); + } + MenuAction::CheckingPanel(panel) => self.show_checking_panel(panel, window, cx), + MenuAction::Substitute(kind) => { + self.apply_selected_substitutions(&session.request, kind, cx); + self.focus_editor(window, cx); + } + MenuAction::PasteMatchStyle => { + if self.editing_enabled(&Intent::Edit(EditCommand::PastePlain), cx) { + self.editor().update(cx, |editor, cx| { + editor.execute_context_action( + markraft_gpui::ContextAction::PasteMatchStyle, + cx, + ) + }); + } + self.focus_editor(window, cx); + } + MenuAction::Translate(text) => self.show_translation(id, session, &text, window, cx), + MenuAction::Replace(request, text) => { + self.apply_service_text(&session.note, &session.editor, &request, &text, cx); + self.focus_editor(window, cx); + } + MenuAction::IgnoreSpelling(word) => { + self.update_dictionary(|checker| checker.ignore(&word), cx) + } + MenuAction::LearnSpelling(word) => { + self.update_dictionary(|checker| checker.learn(&word), cx) + } + MenuAction::UnlearnSpelling(word) => { + self.update_dictionary(|checker| checker.unlearn(&word), cx) + } + MenuAction::CheckSpelling => self.schedule_text_checking(CheckTrigger::Requested, cx), + MenuAction::CheckSetting(setting) => self.toggle_text_checking(setting, window, cx), + MenuAction::Transform(transform) => { + if self.library.active_note().read_only.is_none() { + self.editor().update(cx, |editor, cx| { + editor.transform_context_text(&session.request, transform, cx); + }); + } + self.focus_editor(window, cx); + } + MenuAction::Text(service, text) => { + self.present_text_service(service, text, session.request.position, window, cx) + } + MenuAction::OpenWith(url, application) => { + if let Err(error) = text_services::open_with(&url, &application) { + self.feedback.set_error(error); + cx.notify(); + } + } + MenuAction::Intent(intent) => self.run_menu_intent(intent, window, cx), + MenuAction::CopyUrl(url) => { + cx.write_to_clipboard(ClipboardItem::new_string(url)); + self.focus_editor(window, cx); + } + MenuAction::OpenUrl(url) => EditorView::open_link(&url, cx), + MenuAction::Wiki { target, embed } => self.follow_wiki_link(&target, embed, window, cx), + MenuAction::CopyCode(pos) => { + if let Some(text) = self.editor().read(cx).code_text_at(pos) { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_owned())); + } + self.focus_editor(window, cx); + } + MenuAction::CodeLanguage(pos) => { + if self.library.active_note().read_only.is_none() { + self.open_code_language(pos, cx); + } + } + } + } +} + +impl From for markraft_gpui::ContextAction { + fn from(command: EditCommand) -> Self { + match command { + EditCommand::Copy => Self::Copy, + EditCommand::Cut => Self::Cut, + EditCommand::Paste => Self::Paste, + EditCommand::PastePlain => Self::PastePlain, + EditCommand::PasteMarkdown => Self::PasteMarkdown, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/markraft-app/src/app/ui/context_menu/markdown.rs b/crates/markraft-app/src/app/ui/context_menu/markdown.rs new file mode 100644 index 00000000..e5f087fd --- /dev/null +++ b/crates/markraft-app/src/app/ui/context_menu/markdown.rs @@ -0,0 +1,153 @@ +//! Markdown commands that have no native text-system equivalent. + +use super::*; +use markraft_core::{EditorState, Fragment, Selection, Slice, TransactionSpec}; + +#[derive(Clone, Copy)] +pub(super) enum MarkdownAction { + ClearFormatting, + InsertMath, + InsertImage, +} + +fn math_insertion(state: &EditorState) -> Option { + let selection = state.selection(); + let doc = state.doc(); + let from = doc.resolve(selection.from(doc)).ok()?; + let to = doc.resolve(selection.to(doc)).ok()?; + if from.depth() == 0 + || !from.parent().is_textblock(state.schema()) + || !from.same_parent(&to) + || doc::types().in_verbatim_block_at(state) + || doc::types() + .table_types() + .and_then(|types| markraft_core::commands::cell_at(types, state)) + .is_some() + { + return None; + } + let schema = state.schema(); + let line_break = schema + .node(markraft_commonmark::schema::LINE_BREAK, []) + .ok()?; + let paragraph = schema + .node( + markraft_commonmark::schema::PARAGRAPH, + [ + schema.text("$$"), + line_break.clone(), + line_break, + schema.text("$$"), + ], + ) + .ok()?; + // A block insert goes after this paragraph and keeps selected prose intact. + let position = from.after(from.depth()); + markraft_core::commands::changes_spec( + state, + vec![markraft_core::Change::insert( + position, + Slice::from_fragment(Fragment::from_node(paragraph)), + )], + "input.block", + ) + .map(|spec| spec.selection(Selection::cursor(position + 4))) +} + +impl MarkraftApp { + pub(super) fn markdown_action_enabled(&self, action: MarkdownAction, cx: &App) -> bool { + let editor = self.editor().read(cx); + if self.is_reloading() + || self.library.active_note().read_only.is_some() + || editor.is_composing() + { + return false; + } + match action { + MarkdownAction::ClearFormatting => markraft_commonmark::Formatter::new( + self.house.clone(), + ) + .clear_formatting()(editor.state()) + .is_ok_and(|spec| spec.is_some()), + MarkdownAction::InsertMath => math_insertion(editor.state()).is_some(), + MarkdownAction::InsertImage => !doc::types().in_verbatim_block_at(editor.state()), + } + } + + pub(super) fn run_markdown_action( + &mut self, + action: MarkdownAction, + request: &ContextRequest, + window: &mut Window, + cx: &mut Context, + ) { + match action { + MarkdownAction::ClearFormatting => { + let command = + markraft_commonmark::Formatter::new(self.house.clone()).clear_formatting(); + let result = command(self.editor().read(cx).state()); + match result { + Ok(Some(spec)) => { + self.editor() + .update(cx, |editor, cx| editor.dispatch_isolated([spec], cx)); + } + Err(error) => { + self.feedback.set_error(refusal_message(&error)); + } + Ok(None) => {} + } + self.focus_editor(window, cx); + } + MarkdownAction::InsertMath => { + self.editor().update(cx, |editor, cx| { + if let Some(spec) = math_insertion(editor.state()) { + editor.dispatch_isolated([spec], cx); + } + }); + self.focus_editor(window, cx); + } + MarkdownAction::InsertImage => { + let note = self.library.active_id.clone(); + let editor = self.editor().downgrade(); + let request = request.clone(); + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: None, + }); + let prompt = self.file_panel(prompt, window, cx); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = prompt.await else { + return; + }; + let _ = cx.update(|window, cx| { + this.update(cx, |app, cx| { + if app.library.active_id != note + || app.is_reloading() + || !editor.upgrade().is_some_and(|editor| { + editor == app.editor() + && editor.read(cx).context_is_current(&request) + }) + || !app.markdown_action_enabled(MarkdownAction::InsertImage, cx) + { + return; + } + app.insert_context_assets( + paths.into_iter().map(assets::Asset::File).collect(), + request.clone(), + window, + cx, + ); + app.focus_editor(window, cx); + }) + }); + }) + .detach(); + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/markraft-app/src/app/ui/context_menu/markdown/tests.rs b/crates/markraft-app/src/app/ui/context_menu/markdown/tests.rs new file mode 100644 index 00000000..50577a27 --- /dev/null +++ b/crates/markraft-app/src/app/ui/context_menu/markdown/tests.rs @@ -0,0 +1,119 @@ +//! Image menu completion uses the selection that opened the native file panel. + +use super::{ContextTarget, MarkdownAction}; +use crate::e2e::harness::{Harness, open_with}; +use gpui::{Point, TestAppContext}; +use markraft_core::{Node, history}; +use std::path::PathBuf; + +fn choose_image(h: &mut Harness<'_>) { + let app = h.app.clone(); + h.cx.update(|window, cx| { + app.update(cx, |app, cx| { + let request = app + .editor() + .read(cx) + .context_snapshot(Point::default(), ContextTarget::Text); + assert!(app.markdown_action_enabled(MarkdownAction::InsertImage, cx)); + app.run_markdown_action(MarkdownAction::InsertImage, &request, window, cx); + }); + }); + h.cx.run_until_parked(); + assert!(h.cx.did_prompt_for_paths()); +} + +fn image_file(h: &Harness<'_>) -> PathBuf { + // Outside the notes folder, so success must copy the image before inserting. + let path = h.root().join("菜单图片.png"); + std::fs::write( + &path, + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/icon/markraft-menubar.png" + )), + ) + .unwrap(); + path +} + +fn document(h: &mut Harness<'_>) -> Node { + h.app.update(h.cx, |app, cx| app.active_document(cx)) +} + +fn undo_depth(h: &mut Harness<'_>) -> usize { + h.app.update(h.cx, |app, cx| { + history::undo_depth(app.editor().read(cx).state()) + }) +} + +#[gpui::test] +fn image_menu_import_replaces_original_selection_and_undo_restores_it(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "hello world\n")], |_| {}); + h.select(1, 6); + let original = document(&mut h); + let selection = h.selection(); + let image = image_file(&h); + choose_image(&mut h); + h.cx.simulate_path_prompt_response(|options| { + assert!(options.files); + assert!(!options.directories); + assert!(options.multiple); + Some(vec![image]) + }); + h.wait_until(|h| document(h) != original); + let markdown = h.markdown(); + assert!(markdown.contains("![image](assets/"), "{markdown}"); + assert!(markdown.ends_with(" world"), "{markdown}"); + assert!(!markdown.contains("hello"), "{markdown}"); + let copied: Vec<_> = std::fs::read_dir(h.notes.join("assets")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(copied.len(), 1); + assert_eq!( + std::fs::read(&copied[0]).unwrap(), + std::fs::read(h.root().join("菜单图片.png")).unwrap() + ); + assert_eq!(undo_depth(&mut h), 1); + assert!(h.edit(|editor, cx| { + let spec = history::undo(editor.state()).unwrap(); + editor.dispatch([spec], cx) + })); + assert_eq!(document(&mut h), original); + assert_eq!(h.selection(), selection); + assert_eq!(undo_depth(&mut h), 0); +} + +#[gpui::test] +fn image_menu_cancel_keeps_document_selection_and_history(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "hello world\n")], |_| {}); + h.select(1, 6); + let original = document(&mut h); + let selection = h.selection(); + choose_image(&mut h); + h.cx.simulate_path_prompt_response(|_| None); + h.cx.run_until_parked(); + assert!(!h.cx.did_prompt_for_paths()); + assert_eq!(document(&mut h), original); + assert_eq!(h.selection(), selection); + assert_eq!(undo_depth(&mut h), 0); + assert!(!h.notes.join("assets").exists()); +} + +#[gpui::test] +fn image_menu_changed_selection_during_dialog_refuses_import(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "hello world\n")], |_| {}); + h.select(1, 6); + let original = document(&mut h); + let image = image_file(&h); + choose_image(&mut h); + h.select(7, 12); + let moved = h.selection(); + h.cx.simulate_path_prompt_response(|_| Some(vec![image])); + h.cx.run_until_parked(); + assert!(!h.cx.did_prompt_for_paths()); + assert_eq!(document(&mut h), original); + assert_eq!(h.selection(), moved); + assert_eq!(undo_depth(&mut h), 0); + assert!(!h.notes.join("assets").exists()); +} diff --git a/crates/markraft-app/src/app/ui/context_menu/tests.rs b/crates/markraft-app/src/app/ui/context_menu/tests.rs new file mode 100644 index 00000000..2133861d --- /dev/null +++ b/crates/markraft-app/src/app/ui/context_menu/tests.rs @@ -0,0 +1,1039 @@ +//! Exercise the native menu's request/completion boundary over a real note and +//! persistence session. Pointer geometry and AppKit tracking are tested elsewhere. + +use super::{ContextTarget, EditCommand, Intent, MenuAction, Row, TableEdit, doc}; +use crate::e2e::harness::{Harness, open_with}; +use gpui::{ClipboardItem, TestAppContext, point, px}; + +fn open_menu(h: &mut Harness<'_>, target: ContextTarget) -> u64 { + let entity = h.app.clone(); + h.cx.update(|window, cx| { + entity.update(cx, |app, cx| { + let editor = app.editor().clone(); + let request = editor + .read(cx) + .context_snapshot(point(px(20.), px(30.)), target); + app.request_context_menu(&editor, request, window, cx); + app.context_menus + .pending + .as_ref() + .expect("a pending menu") + .id + }) + }) +} + +fn item(h: &mut Harness<'_>, matches: impl Fn(&MenuAction) -> bool) -> usize { + h.app.update(h.cx, |app, _| { + app.context_menus + .pending + .as_ref() + .expect("a pending menu") + .actions + .iter() + .position(matches) + .expect("the requested menu action") + }) +} + +fn command(h: &mut Harness<'_>, command: EditCommand) -> usize { + item( + h, + |action| matches!(action, MenuAction::Intent(Intent::Edit(found)) if *found == command), + ) +} + +fn finish(h: &mut Harness<'_>, id: u64, selected: Option) { + let entity = h.app.clone(); + h.cx.update(|window, cx| { + entity.update(cx, |app, cx| { + app.finish_context_menu(id, selected, window, cx) + }) + }); + h.cx.run_until_parked(); +} + +fn row_state(rows: &[Row], id: usize) -> Option<(bool, Option)> { + rows.iter().find_map(|row| match row { + Row::Item { + id: found, + enabled, + checked, + .. + } if *found == id => Some((*enabled, *checked)), + Row::Submenu { children, .. } => row_state(children, id), + _ => None, + }) +} + +fn state(h: &mut Harness<'_>, id: usize) -> (bool, Option) { + h.app.update(h.cx, |app, cx| { + let session = app.context_menus.pending.as_ref().expect("a pending menu"); + let (rows, _) = app.context_menu_rows(&session.request, cx); + row_state(&rows, id).expect("a row for the action") + }) +} + +fn clipboard(h: &mut Harness<'_>) -> String { + h.cx.read_from_clipboard() + .and_then(|item| item.text()) + .unwrap_or_default() +} + +fn rows(h: &mut Harness<'_>) -> Vec { + h.app.update(h.cx, |app, cx| { + let session = app.context_menus.pending.as_ref().expect("a pending menu"); + app.context_menu_rows(&session.request, cx).0 + }) +} + +fn assert_item(row: &Row, expected: usize) { + assert!(matches!(row, Row::Item { id, .. } if *id == expected)); +} + +#[gpui::test] +fn link_commands_lead_without_changing_generic_edit_order(cx: &mut TestAppContext) { + let mut h = open_with( + cx, + &[("note.md", "[sample link](https://example.com) tail\n")], + |_| {}, + ); + h.select(2, 13); + let id = open_menu( + &mut h, + ContextTarget::Link { + url: "https://example.com".into(), + range: 2..13, + }, + ); + let menu = rows(&mut h); + let open = item(&mut h, |a| matches!(a, MenuAction::OpenUrl(_))); + let copy = item(&mut h, |a| matches!(a, MenuAction::CopyUrl(_))); + let edit = item(&mut h, |a| { + matches!(a, MenuAction::Intent(Intent::EditLink)) + }); + let unlink = item(&mut h, |a| matches!(a, MenuAction::Intent(Intent::Unlink))); + for (row, action) in menu.iter().zip([open, copy, edit, unlink]) { + assert_item(row, action); + } + assert!(matches!(menu[4], Row::Separator)); + let edits = menu + .iter() + .filter_map(|row| match row { + Row::Item { id, .. } => Some(*id), + _ => None, + }) + .collect::>(); + let cut = command(&mut h, EditCommand::Cut); + let copy = command(&mut h, EditCommand::Copy); + let paste = command(&mut h, EditCommand::Paste); + let position = |id| edits.iter().position(|found| *found == id).unwrap(); + assert_eq!(position(copy), position(cut) + 1); + assert_eq!(position(paste), position(copy) + 1); + let paste_match = item(&mut h, |a| matches!(a, MenuAction::PasteMatchStyle)); + assert_eq!(position(paste_match), position(paste) + 1); + finish(&mut h, id, Some(unlink)); + assert_eq!(h.markdown(), "sample link tail"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "[sample link](https://example.com) tail"); + + let id = open_menu(&mut h, ContextTarget::Text); + let menu = rows(&mut h); + assert!(matches!( + &menu[0], + Row::Item { + symbol: Some("info.circle"), + .. + } + )); + assert!(menu.len() > 7); + finish(&mut h, id, None); +} + +fn lock_note(h: &mut Harness<'_>) { + let before = h.active_note(); + let locked = crate::storage::Note { + read_only: Some("Locked".into()), + ..before.clone() + }; + h.external(vec![crate::vault::External::Updated { + previous: Some(before), + note: locked, + }]); +} + +#[gpui::test] +fn copy_uses_the_selection_and_cancel_keeps_the_document_and_clipboard(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "one two three\n")], |_| {}); + h.select(5, 8); + let selection = h.selection(); + let id = open_menu(&mut h, ContextTarget::Text); + let copy = command(&mut h, EditCommand::Copy); + assert_eq!(state(&mut h, copy), (true, None)); + finish(&mut h, id, Some(copy)); + assert_eq!(clipboard(&mut h), "two"); + assert_eq!(h.markdown(), "one two three"); + assert_eq!(h.selection(), selection); + + let id = open_menu(&mut h, ContextTarget::Text); + finish(&mut h, id, None); + assert_eq!(h.markdown(), "one two three"); + assert_eq!(h.selection(), selection); + assert_eq!(clipboard(&mut h), "two"); + h.app + .update(h.cx, |app, _| assert!(app.context_menus.pending.is_none())); +} + +#[gpui::test] +fn paste_is_undoable_and_persists_through_the_existing_edit_pipeline(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "one two three\n")], |_| {}); + h.select(5, 8); + h.cx.write_to_clipboard(ClipboardItem::new_string("new".into())); + let id = open_menu(&mut h, ContextTarget::Text); + let paste = command(&mut h, EditCommand::Paste); + finish(&mut h, id, Some(paste)); + assert_eq!(h.markdown(), "one new three"); + h.assert_round_trip("a context-menu paste"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "one two three"); + h.assert_round_trip("undoing a context-menu paste"); +} + +#[gpui::test] +fn asynchronous_image_paste_is_separate_from_vim_insert_typing(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "start\n")], |p| p.vim_mode = true); + h.keys("cmd-up A"); + h.type_text("before"); + assert_eq!(h.markdown(), "startbefore"); + let png = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/icon/markraft-menubar.png" + )) + .expect("an image to paste"); + h.cx.write_to_clipboard(ClipboardItem::new_image(&gpui::Image::from_bytes( + gpui::ImageFormat::Png, + png, + ))); + let id = open_menu(&mut h, ContextTarget::Text); + let paste = command(&mut h, EditCommand::Paste); + assert_eq!(state(&mut h, paste), (true, None)); + finish(&mut h, id, Some(paste)); + h.wait_until(|h| h.markdown().ends_with(".png)")); + let with_image = h.markdown(); + assert!(with_image.starts_with("startbefore![image](assets/")); + assert!(with_image.ends_with(".png)")); + h.type_text("after"); + assert_eq!(h.markdown(), format!("{with_image}after")); + h.assert_round_trip("an asynchronous context-menu image paste in Vim Insert mode"); + h.keys("escape u"); + assert_eq!(h.markdown(), with_image); + h.keys("u"); + assert_eq!(h.markdown(), "startbefore"); + h.keys("u"); + assert_eq!(h.markdown(), "start"); + h.assert_round_trip("undoing a context-menu image paste and its surrounding typing"); +} + +#[gpui::test] +fn readonly_menus_allow_copy_and_refuse_mutations(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "one two\n")], |_| {}); + h.select(1, 4); + h.cx.write_to_clipboard(ClipboardItem::new_string("new".into())); + lock_note(&mut h); + let id = open_menu(&mut h, ContextTarget::Text); + for edit in [EditCommand::Cut, EditCommand::Paste] { + let selected = command(&mut h, edit); + assert_eq!(state(&mut h, selected), (false, None)); + } + let paste_match = item(&mut h, |a| matches!(a, MenuAction::PasteMatchStyle)); + assert_eq!(state(&mut h, paste_match), (false, None)); + let copy = command(&mut h, EditCommand::Copy); + assert_eq!(state(&mut h, copy), (true, None)); + finish(&mut h, id, Some(copy)); + assert_eq!(clipboard(&mut h), "one"); + assert_eq!(h.markdown(), "one two"); +} + +#[gpui::test] +fn completion_rechecks_permissions_after_the_menu_was_built(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "one two\n")], |_| {}); + h.select(1, 4); + h.cx.write_to_clipboard(ClipboardItem::new_string("unchanged".into())); + let id = open_menu(&mut h, ContextTarget::Text); + let cut = command(&mut h, EditCommand::Cut); + assert_eq!(state(&mut h, cut), (true, None)); + lock_note(&mut h); + h.app + .update(h.cx, |app, cx| assert!(app.context_menu_current(id, cx))); + finish(&mut h, id, Some(cut)); + assert_eq!(h.markdown(), "one two"); + assert_eq!(clipboard(&mut h), "unchanged"); +} + +#[gpui::test] +fn selection_or_document_changes_expire_a_pending_command(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "one two\n")], |_| {}); + for change_document in [false, true] { + h.select(1, 4); + h.cx.write_to_clipboard(ClipboardItem::new_string("unchanged".into())); + let id = open_menu(&mut h, ContextTarget::Text); + let cut = command(&mut h, EditCommand::Cut); + if change_document { + h.type_text("new"); + } else { + h.select(5, 8); + } + let expected = h.markdown(); + finish(&mut h, id, Some(cut)); + assert_eq!(h.markdown(), expected); + assert_eq!(clipboard(&mut h), "unchanged"); + h.app + .update(h.cx, |app, _| assert!(app.context_menus.pending.is_none())); + } +} + +#[gpui::test] +fn switching_notes_or_replacing_a_popup_cannot_deliver_an_old_command(cx: &mut TestAppContext) { + let mut h = open_with( + cx, + &[("first.md", "first\n"), ("second.md", "second\n")], + |_| {}, + ); + h.browse_to("first"); + h.select(1, 6); + h.cx.write_to_clipboard(ClipboardItem::new_string("unchanged".into())); + let old = open_menu(&mut h, ContextTarget::Text); + let cut = command(&mut h, EditCommand::Cut); + let current = open_menu(&mut h, ContextTarget::Text); + finish(&mut h, old, Some(cut)); + h.app.update(h.cx, |app, cx| { + assert!(app.context_menu_current(current, cx)) + }); + h.browse_to("second"); + finish(&mut h, current, Some(cut)); + assert_eq!(h.markdown(), "second"); + assert_eq!(clipboard(&mut h), "unchanged"); + h.browse_to("first"); + assert_eq!(h.markdown(), "first"); +} + +#[gpui::test] +fn code_copy_targets_the_clicked_block_without_moving_the_selection(cx: &mut TestAppContext) { + let mut h = open_with( + cx, + &[( + "note.md", + "paragraph\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n", + )], + |_| {}, + ); + h.select(1, 10); + let selection = h.selection(); + let pos = h.app.update(h.cx, |app, cx| { + let editor = app.editor().read(cx); + (0..editor.state().doc().content().size()) + .find(|pos| editor.code_text_at(*pos).is_some()) + .expect("a code block") + }); + let id = open_menu(&mut h, ContextTarget::CodeBlock { pos }); + let copy = item( + &mut h, + |action| matches!(action, MenuAction::CopyCode(found) if *found == pos), + ); + let menu = rows(&mut h); + assert_item(&menu[0], copy); + assert!(matches!(menu[2], Row::Separator)); + assert!(row_state(&menu, command(&mut h, EditCommand::Cut)).is_some()); + finish(&mut h, id, Some(copy)); + assert_eq!(clipboard(&mut h), "let x = 1;\nlet y = 2;"); + assert_eq!(h.selection(), selection); +} + +#[gpui::test] +fn link_address_copy_uses_the_clicked_object_and_preserves_unrelated_selection( + cx: &mut TestAppContext, +) { + let mut h = open_with( + cx, + &[("note.md", "[link](https://example.com) tail\n")], + |_| {}, + ); + let tail = "[link](https://example.com) ".chars().count() + 1; + h.select(tail, tail + 4); + let selection = h.selection(); + let id = open_menu( + &mut h, + ContextTarget::Link { + url: "https://example.com".into(), + range: 2..6, + }, + ); + h.app.update(h.cx, |app, _| { + assert!( + !app.context_menus + .pending + .as_ref() + .unwrap() + .actions + .iter() + .any(|action| matches!( + action, + MenuAction::Intent(Intent::EditLink | Intent::Unlink) + )) + ); + }); + let copy = item(&mut h, |action| matches!(action, MenuAction::CopyUrl(_))); + finish(&mut h, id, Some(copy)); + assert_eq!(clipboard(&mut h), "https://example.com"); + assert_eq!(h.selection(), selection); + assert_eq!(h.markdown(), "[link](https://example.com) tail"); +} + +#[gpui::test] +fn empty_selection_and_clipboard_disable_the_corresponding_actions(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "one two\n")], |_| {}); + h.select(1, 1); + h.cx.write_to_clipboard(ClipboardItem::new_string(String::new())); + let id = open_menu(&mut h, ContextTarget::Text); + for edit in [EditCommand::Copy, EditCommand::Cut, EditCommand::Paste] { + let selected = command(&mut h, edit); + assert_eq!(state(&mut h, selected), (false, None)); + } + let paste_match = item(&mut h, |a| matches!(a, MenuAction::PasteMatchStyle)); + assert_eq!(state(&mut h, paste_match), (false, None)); + finish(&mut h, id, None); + assert_eq!(h.markdown(), "one two"); +} + +#[gpui::test] +fn identical_link_destinations_do_not_make_a_cross_link_selection_editable( + cx: &mut TestAppContext, +) { + let mut h = open_with( + cx, + &[( + "note.md", + "[one](https://example.com)[two](https://example.com)\n", + )], + |_| {}, + ); + let second = "[one](https://example.com)[".chars().count() + 1; + h.select(2, second + 3); + h.app.update(h.cx, |app, cx| { + assert_eq!( + app.editor().read(cx).active_link().as_deref(), + Some("https://example.com") + ); + }); + let id = open_menu( + &mut h, + ContextTarget::Link { + url: "https://example.com".into(), + range: 2..5, + }, + ); + h.app.update(h.cx, |app, _| { + assert!( + !app.context_menus + .pending + .as_ref() + .unwrap() + .actions + .iter() + .any(|action| matches!( + action, + MenuAction::Intent(Intent::EditLink | Intent::Unlink) + )) + ); + }); + finish(&mut h, id, None); + + h.select(2, 5); + let id = open_menu( + &mut h, + ContextTarget::Link { + url: "https://example.com".into(), + range: 2..5, + }, + ); + let unlink = item(&mut h, |action| { + matches!(action, MenuAction::Intent(Intent::Unlink)) + }); + assert_eq!(state(&mut h, unlink), (true, None)); + finish(&mut h, id, Some(unlink)); + assert_eq!(h.markdown(), "one[two](https://example.com)"); + h.assert_round_trip("unlinking only the clicked link"); +} + +#[gpui::test] +fn format_checks_and_table_commands_follow_the_current_selection(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "**bold** plain\n")], |_| {}); + h.select(1, 5); + let id = open_menu(&mut h, ContextTarget::Text); + let bold = item(&mut h, |action| { + matches!(action, MenuAction::Intent(Intent::Mark(doc::Inline::Bold))) + }); + let italic = item(&mut h, |action| { + matches!( + action, + MenuAction::Intent(Intent::Mark(doc::Inline::Italic)) + ) + }); + assert_eq!(state(&mut h, bold), (true, Some(true))); + assert_eq!(state(&mut h, italic), (true, Some(false))); + finish(&mut h, id, Some(bold)); + assert_eq!(h.markdown(), "bold plain"); + h.assert_round_trip("a context-menu format toggle"); + + let mut h = open_with( + cx, + &[("table.md", "| a | b |\n| --- | --- |\n| 1 | 2 |\n")], + |_| {}, + ); + h.keys("cmd-down"); + let target = h.app.update(h.cx, |app, cx| { + let editor = app.editor().read(cx); + let resolved = editor.state().doc().resolve(editor.head()).unwrap(); + // Paragraph, cell, row and table are the table's schema structure. + ContextTarget::Table { + pos: resolved.before(1), + cell: resolved.before(3), + } + }); + let id = open_menu(&mut h, target); + let menu = rows(&mut h); + assert!(matches!(menu[0], Row::Submenu { .. })); + assert!(matches!(menu[1], Row::Separator)); + assert!(row_state(&menu, command(&mut h, EditCommand::Cut)).is_some()); + let add_row = item(&mut h, |action| { + matches!( + action, + MenuAction::Intent(Intent::Table(TableEdit::RowAfter)) + ) + }); + assert_eq!(state(&mut h, add_row), (true, None)); + finish(&mut h, id, Some(add_row)); + assert_eq!(h.markdown().lines().count(), 4); + h.assert_round_trip("a context-menu table row"); +} + +#[gpui::test] +fn transformations_preserve_marks_and_have_independent_undo(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "**hello** world\n")], |_| {}); + h.select(1, 16); + let id = open_menu(&mut h, ContextTarget::Text); + let uppercase = item(&mut h, |action| { + matches!( + action, + MenuAction::Transform(markraft_gpui::TextTransformation::Uppercase) + ) + }); + assert_eq!(state(&mut h, uppercase), (true, None)); + finish(&mut h, id, Some(uppercase)); + assert_eq!(h.markdown(), "**HELLO** WORLD"); + h.assert_round_trip("transforming reading text with formatting"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "**hello** world"); +} + +#[cfg(target_os = "macos")] +#[gpui::test] +fn chinese_transformations_preserve_context_across_marks_and_source(cx: &mut TestAppContext) { + use markraft_gpui::TextTransformation; + let source = "**头**发 [里**面**](https://example.com/里面) 发型\n"; + let mut h = open_with(cx, &[("note.md", source)], |_| {}); + h.select(1, source.trim_end().chars().count() + 1); + let id = open_menu(&mut h, ContextTarget::Text); + let traditional = item(&mut h, |action| { + matches!( + action, + MenuAction::Transform(TextTransformation::TraditionalChinese) + ) + }); + assert_eq!(state(&mut h, traditional), (true, None)); + finish(&mut h, id, Some(traditional)); + assert_eq!( + h.markdown(), + "**頭**髮 [裡**面**](https://example.com/里面) 髮型" + ); + h.assert_round_trip("contextual Chinese transformation across formatting"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), source.trim_end()); + + // Read-only validation gates Chinese conversion through the same menu session. + h.select(1, source.trim_end().chars().count() + 1); + lock_note(&mut h); + let id = open_menu(&mut h, ContextTarget::Text); + let traditional = item(&mut h, |action| { + matches!( + action, + MenuAction::Transform(TextTransformation::TraditionalChinese) + ) + }); + assert_eq!(state(&mut h, traditional), (false, None)); + finish(&mut h, id, Some(traditional)); + assert_eq!(h.markdown(), source.trim_end()); +} + +#[gpui::test] +fn transformations_hide_empty_submenu_for_uncased_text(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "123 😀 中文\n")], |_| {}); + h.select(1, 9); + let id = open_menu(&mut h, ContextTarget::Text); + assert!( + !rows(&mut h) + .iter() + .any(|row| matches!(row, Row::Submenu { label, .. } if label == "Transformations")) + ); + finish(&mut h, id, None); +} + +#[gpui::test] +fn asynchronous_service_writeback_rechecks_snapshot_and_readonly(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "hello world\n")], |_| {}); + h.select(1, 6); + let (note, editor, request) = h.app.update(h.cx, |app, cx| { + let editor = app.editor(); + ( + app.library.active_id.clone(), + editor.downgrade(), + editor + .read(cx) + .context_snapshot(point(px(0.), px(0.)), ContextTarget::Text), + ) + }); + let applied = h.app.update(h.cx, |app, cx| { + app.apply_service_text(¬e, &editor, &request, "goodbye", cx) + }); + assert!(applied); + assert_eq!(h.markdown(), "goodbye world"); + h.assert_round_trip("a native service replacement"); + let applied = h.app.update(h.cx, |app, cx| { + app.apply_service_text(¬e, &editor, &request, "stale", cx) + }); + assert!(!applied); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "hello world"); + h.select(1, 6); + let request = h.app.update(h.cx, |app, cx| { + app.editor() + .read(cx) + .context_snapshot(point(px(0.), px(0.)), ContextTarget::Text) + }); + lock_note(&mut h); + let applied = h.app.update(h.cx, |app, cx| { + app.apply_service_text(¬e, &editor, &request, "locked", cx) + }); + assert!(!applied); + assert_eq!(h.markdown(), "hello world"); +} + +#[gpui::test] +fn proofreading_preserves_local_styles_and_undoes_as_one_edit(cx: &mut TestAppContext) { + use super::TextReplacement; + + let source = "**This** are a smple sentnce."; + let corrected = "**This** is a simple sentence."; + let mut h = open_with(cx, &[("note.md", source)], |_| {}); + h.keys("cmd-a"); + let (note, editor, request) = h.app.update(h.cx, |app, cx| { + let editor = app.editor(); + ( + app.library.active_id.clone(), + editor.downgrade(), + editor + .read(cx) + .context_snapshot(Default::default(), ContextTarget::Text), + ) + }); + assert!(h.app.update(h.cx, |app, cx| app.apply_service_replacement( + ¬e, + &editor, + &request, + TextReplacement::PreserveStyles("This is a simple sentence.".into()), + cx, + ))); + assert_eq!(h.markdown(), corrected); + h.assert_round_trip("proofreading preserves the unchanged bold word"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), source); + h.keys("cmd-shift-z"); + assert_eq!(h.markdown(), corrected); +} + +#[gpui::test] +fn service_origins_apply_distinct_styles_even_when_returned_text_is_unchanged( + cx: &mut TestAppContext, +) { + use super::TextReplacement; + + let source = "**red** blue"; + let mut h = open_with(cx, &[("note.md", source)], |_| {}); + h.keys("cmd-a"); + let (note, editor, request) = h.app.update(h.cx, |app, cx| { + let editor = app.editor(); + ( + app.library.active_id.clone(), + editor.downgrade(), + editor + .read(cx) + .context_snapshot(Default::default(), ContextTarget::Text), + ) + }); + assert!(h.app.update(h.cx, |app, cx| app.apply_service_replacement( + ¬e, + &editor, + &request, + TextReplacement::PreserveStyles("red blue".into()), + cx, + ))); + assert_eq!(h.markdown(), source); + // Plain Services change every character's style even for identical text; + // the asynchronous application boundary must not short-circuit this case. + assert!(h.app.update(h.cx, |app, cx| app.apply_service_replacement( + ¬e, + &editor, + &request, + TextReplacement::PlainText("red blue".into()), + cx, + ))); + assert_eq!(h.markdown(), "**red blue**"); + h.app.read_with(h.cx, |app, cx| { + let state = app.editor().read(cx).state(); + assert!(state.selection().is_empty(state.doc())); + }); + h.assert_round_trip("ordinary Services inherit the first character style"); + assert!(!h.app.update(h.cx, |app, cx| app.apply_service_replacement( + ¬e, + &editor, + &request, + TextReplacement::PlainText("stale".into()), + cx, + ))); + h.keys("cmd-z"); + assert_eq!(h.markdown(), source); + + h.keys("cmd-a"); + let request = h.app.update(h.cx, |app, cx| { + app.editor() + .read(cx) + .context_snapshot(Default::default(), ContextTarget::Text) + }); + lock_note(&mut h); + assert!(!h.app.update(h.cx, |app, cx| app.apply_service_replacement( + ¬e, + &editor, + &request, + TextReplacement::PlainText("red blue".into()), + cx, + ))); + assert_eq!(h.markdown(), source); +} + +#[gpui::test] +fn readonly_transformations_are_disabled(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "hello world\n")], |_| {}); + h.select(1, 6); + lock_note(&mut h); + let id = open_menu(&mut h, ContextTarget::Text); + let uppercase = item(&mut h, |action| { + matches!( + action, + MenuAction::Transform(markraft_gpui::TextTransformation::Uppercase) + ) + }); + assert_eq!(state(&mut h, uppercase), (false, None)); + finish(&mut h, id, Some(uppercase)); + assert_eq!(h.markdown(), "hello world"); +} + +#[gpui::test] +fn paste_matching_style_uses_target_marks_and_persists(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "**word** tail\n")], |_| {}); + h.select(1, 9); + h.cx.write_to_clipboard(ClipboardItem::new_string("new".into())); + let id = open_menu(&mut h, ContextTarget::Text); + let paste = item(&mut h, |action| { + matches!(action, MenuAction::PasteMatchStyle) + }); + assert_eq!(state(&mut h, paste), (true, None)); + finish(&mut h, id, Some(paste)); + assert_eq!(h.markdown(), "**new** tail"); + h.assert_round_trip("paste matching target style"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "**word** tail"); +} + +#[gpui::test] +fn smart_quote_preference_updates_pairing_and_roundtrips(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "word\n")], |_| {}); + let id = open_menu(&mut h, ContextTarget::Text); + let quotes = item(&mut h, |action| { + matches!( + action, + MenuAction::CheckSetting(crate::storage::TextCheckingSetting::Quotes) + ) + }); + assert_eq!(state(&mut h, quotes), (true, Some(false))); + finish(&mut h, id, Some(quotes)); + h.app.update(h.cx, |app, _| { + assert!(app.preferences.text_checking.quotes); + assert!(!app.quote_pairs.load(std::sync::atomic::Ordering::Relaxed)); + }); + let id = open_menu(&mut h, ContextTarget::Text); + assert_eq!(state(&mut h, quotes), (true, Some(true))); + finish(&mut h, id, Some(quotes)); + h.app.update(h.cx, |app, _| { + assert!(!app.preferences.text_checking.quotes); + assert!(app.quote_pairs.load(std::sync::atomic::Ordering::Relaxed)); + }); +} + +#[gpui::test] +fn prose_menu_follows_native_group_order_and_exposes_checking_panels(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "A simple sentence.\n")], |_| {}); + h.app.update(h.cx, |app, _| assert!(app.platform.is_none())); + h.select(3, 9); + let id = open_menu(&mut h, ContextTarget::Text); + let menu = rows(&mut h); + let labels = menu + .iter() + .filter_map(|row| match row { + Row::Item { label, .. } | Row::Submenu { label, .. } => Some(label.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!( + labels, + [ + "Look Up “simple”", + "Search the Web", + "Cut", + "Copy", + "Paste", + "Paste and Match Style", + "Copy / Paste As", + "Share…", + "Paragraph", + "Format", + "Insert", + "Spelling and Grammar", + "Substitutions", + "Transformations", + "Speech" + ] + ); + assert!( + row_state( + &menu, + item(&mut h, |action| matches!( + action, + MenuAction::CheckingPanel(super::CheckingPanel::Spelling) + )) + ) + .is_some() + ); + assert!( + row_state( + &menu, + item(&mut h, |action| matches!( + action, + MenuAction::CheckingPanel(super::CheckingPanel::Substitutions) + )) + ) + .is_some() + ); + fn has_label(rows: &[Row], expected: &str) -> bool { + rows.iter().any(|row| match row { + Row::Item { label, .. } => label == expected, + Row::Submenu { children, .. } => has_label(children, expected), + _ => false, + }) + } + assert!(has_label(&menu, "Show Spelling and Grammar")); + assert!(has_label(&menu, "Show Substitutions")); + assert!(!has_label(&menu, "Hide Spelling and Grammar")); + assert!(!has_label(&menu, "Hide Substitutions")); + finish(&mut h, id, None); +} + +#[gpui::test] +fn learned_spelling_can_be_reversed_without_offering_learn_again(cx: &mut TestAppContext) { + let h = open_with(cx, &[("note.md", "Markrafttestword\n")], |_| {}); + h.app.update(h.cx, |app, cx| { + let mut menu = super::MenuBuilder { + app, + cx, + actions: Vec::new(), + }; + // Headless tests do not use the user's system dictionary. Supply the + // native learned state to the same builder that handles spelling results. + let rows = menu.spelling_word("Markrafttestword", true); + assert!(matches!( + rows.as_slice(), + [Row::Item { label, enabled: true, .. }] if label == "Unlearn Spelling" + )); + assert!(matches!( + menu.actions.as_slice(), + [MenuAction::UnlearnSpelling(word)] if word == "Markrafttestword" + )); + + menu.actions.clear(); + let rows = menu.spelling_word("Markrafttestword", false); + assert_eq!(rows.len(), 2); + assert!(matches!( + menu.actions.as_slice(), + [MenuAction::IgnoreSpelling(ignored), MenuAction::LearnSpelling(learned)] + if ignored == "Markrafttestword" && learned == ignored + )); + }); +} + +#[gpui::test] +fn markdown_context_formats_preserve_content_and_undo(cx: &mut TestAppContext) { + let mut h = open_with( + cx, + &[("note.md", "**Hello** *世界* [link](https://example.com)\n")], + |_| {}, + ); + h.keys("cmd-a"); + let id = open_menu(&mut h, ContextTarget::Text); + let clear = item(&mut h, |a| { + matches!( + a, + MenuAction::Markdown(super::MarkdownAction::ClearFormatting) + ) + }); + assert_eq!(state(&mut h, clear), (true, None)); + finish(&mut h, id, Some(clear)); + assert_eq!(h.markdown(), "Hello 世界 link"); + h.assert_round_trip("clearing all inline styles"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "**Hello** *世界* [link](https://example.com)"); + + h.select(3, 8); + let id = open_menu(&mut h, ContextTarget::Text); + let highlight = item(&mut h, |a| { + matches!(a, MenuAction::Intent(Intent::Mark(doc::Inline::Highlight))) + }); + assert_eq!(state(&mut h, highlight), (true, Some(false))); + finish(&mut h, id, Some(highlight)); + assert!(h.markdown().contains("==")); + h.assert_round_trip("highlighting existing bold text"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "**Hello** *世界* [link](https://example.com)"); +} + +#[gpui::test] +fn paragraph_context_commands_and_hyperlink_are_available(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "alpha\n")], |_| {}); + h.select(1, 6); + let id = open_menu(&mut h, ContextTarget::Text); + let link = item(&mut h, |a| matches!(a, MenuAction::Intent(Intent::Link))); + assert_eq!(state(&mut h, link), (true, None)); + let heading = item(&mut h, |a| { + matches!(a, MenuAction::Intent(Intent::Block(doc::Block::Heading(2)))) + }); + finish(&mut h, id, Some(heading)); + assert_eq!(h.markdown(), "## alpha"); + h.assert_round_trip("a context heading change"); + h.keys("cmd-z"); + assert_eq!(h.markdown(), "alpha"); +} + +#[gpui::test] +fn copy_formats_in_menu_use_selection_and_remain_available_readonly(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "**Hello** 世界\n")], |_| {}); + lock_note(&mut h); + h.keys("cmd-a"); + let id = open_menu(&mut h, ContextTarget::Text); + let copy = item(&mut h, |a| { + matches!(a, MenuAction::CopyAs(markraft_gpui::CopyFormat::PlainText)) + }); + assert_eq!(state(&mut h, copy), (true, None)); + let clear = item(&mut h, |a| { + matches!( + a, + MenuAction::Markdown(super::MarkdownAction::ClearFormatting) + ) + }); + let math = item(&mut h, |a| { + matches!(a, MenuAction::Markdown(super::MarkdownAction::InsertMath)) + }); + let image = item(&mut h, |a| { + matches!(a, MenuAction::Markdown(super::MarkdownAction::InsertImage)) + }); + let paragraph = item(&mut h, |a| { + matches!(a, MenuAction::InsertParagraph { before: false }) + }); + let table = item(&mut h, |a| { + matches!(a, MenuAction::Intent(Intent::InsertTable)) + }); + for action in [clear, math, image, paragraph, table] { + assert!(!state(&mut h, action).0); + } + finish(&mut h, id, Some(copy)); + assert_eq!(clipboard(&mut h), "Hello 世界"); + assert_eq!(h.markdown(), "**Hello** 世界"); +} + +#[gpui::test] +fn insert_math_keeps_prose_and_places_typing_inside_delimiters(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "original\n")], |_| {}); + h.keys("cmd-up"); + let id = open_menu(&mut h, ContextTarget::Text); + let math = item(&mut h, |a| { + matches!(a, MenuAction::Markdown(super::MarkdownAction::InsertMath)) + }); + assert_eq!(state(&mut h, math), (true, None)); + finish(&mut h, id, Some(math)); + h.type_text("x^2"); + assert_eq!(h.markdown(), "original\n\n$$\nx^2\n$$"); + h.assert_round_trip("inserting a display formula"); + h.keys("cmd-z cmd-z"); + assert_eq!(h.markdown(), "original"); +} + +#[gpui::test] +fn insert_paragraph_after_code_preserves_code_and_uses_new_caret(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "```rust\nlet x = 1;\n```\n")], |_| {}); + let id = open_menu(&mut h, ContextTarget::CodeBlock { pos: 0 }); + let after = item(&mut h, |a| { + matches!(a, MenuAction::InsertParagraph { before: false }) + }); + assert_eq!(state(&mut h, after), (true, None)); + finish(&mut h, id, Some(after)); + h.type_text("after"); + assert_eq!(h.markdown(), "```rust\nlet x = 1;\n```\n\nafter"); + h.assert_round_trip("a paragraph after code"); + h.keys("cmd-z cmd-z"); + assert_eq!(h.markdown(), "```rust\nlet x = 1;\n```"); +} + +#[gpui::test] +fn full_document_selection_disables_math_and_can_copy_clicked_table(cx: &mut TestAppContext) { + let mut h = open_with( + cx, + &[("note.md", "| a | b |\n| - | - |\n| 1 | 2 |\n\ntail\n")], + |_| {}, + ); + h.keys("cmd-a"); + let original = h.markdown(); + let id = open_menu(&mut h, ContextTarget::Table { pos: 0, cell: 3 }); + let math = item(&mut h, |a| { + matches!(a, MenuAction::Markdown(super::MarkdownAction::InsertMath)) + }); + assert!(!state(&mut h, math).0); + let copy = item(&mut h, |a| matches!(a, MenuAction::CopyTable(0))); + assert!(state(&mut h, copy).0); + finish(&mut h, id, Some(copy)); + let copied = clipboard(&mut h); + let cells = copied + .lines() + .map(|line| line.split('|').map(str::trim).collect::>()) + .collect::>(); + assert_eq!(cells[0], ["", "a", "b", ""]); + assert_eq!(cells[2], ["", "1", "2", ""]); + assert!(!copied.contains("tail")); + assert_eq!(h.markdown(), original); +} diff --git a/crates/markraft-app/src/app/ui/editing.rs b/crates/markraft-app/src/app/ui/editing.rs new file mode 100644 index 00000000..f134f742 --- /dev/null +++ b/crates/markraft-app/src/app/ui/editing.rs @@ -0,0 +1,168 @@ +//! State shared by editing controls. Execution still belongs to the editor's +//! actions and transaction guards; querying a control never applies an edit. + +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum EditCommand { + Cut, + Copy, + Paste, + PastePlain, + PasteMarkdown, +} + +impl EditCommand { + pub(super) fn action(self) -> Box { + match self { + Self::Cut => Box::new(markraft_gpui::Cut), + Self::Copy => Box::new(markraft_gpui::Copy), + Self::Paste => Box::new(markraft_gpui::Paste), + Self::PastePlain => Box::new(markraft_gpui::PastePlain), + Self::PasteMarkdown => Box::new(markraft_gpui::PasteMarkdown), + } + } + + pub(super) fn label(self) -> &'static str { + match self { + Self::Cut => "menu.cut", + Self::Copy => "menu.copy", + Self::Paste => "menu.paste", + Self::PastePlain => "command.paste-as-plain-text", + Self::PasteMarkdown => "command.paste-as-markdown", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct CommandState { + pub enabled: bool, + pub checked: Option, +} + +impl CommandState { + fn enabled(enabled: bool) -> Self { + Self { + enabled, + checked: None, + } + } +} + +impl MarkraftApp { + /// Only editing intents participate. Other application actions retain their + /// existing availability and lifecycle rules. + pub(super) fn editing_state(&self, intent: &Intent, cx: &App) -> Option { + if !matches!( + intent, + Intent::Edit(_) + | Intent::PastePlain + | Intent::PasteMarkdown + | Intent::Mark(_) + | Intent::Block(_) + | Intent::Undo + | Intent::Redo + | Intent::EditLink + | Intent::Link + | Intent::InsertTable + | Intent::Unlink + | Intent::CopyLink + | Intent::OpenLink + | Intent::Table(_) + | Intent::ChooseCodeLanguage + ) { + return None; + } + let editor = self.editor().read(cx); + let writable = !self.is_reloading() + && self.library.active_note().read_only.is_none() + && !editor.is_composing(); + let state = match intent { + Intent::Edit(command) => { + let caps = editor.edit_capabilities(cx); + CommandState::enabled(match command { + EditCommand::Copy => caps.copy, + EditCommand::Cut => writable && caps.cut, + EditCommand::Paste => writable && caps.paste, + EditCommand::PastePlain => writable && caps.paste_plain, + EditCommand::PasteMarkdown => writable && caps.paste_markdown, + }) + } + Intent::PastePlain => { + return self.editing_state(&Intent::Edit(EditCommand::PastePlain), cx); + } + Intent::PasteMarkdown => { + return self.editing_state(&Intent::Edit(EditCommand::PasteMarkdown), cx); + } + Intent::Mark(mark) => CommandState { + enabled: writable && editor.can_toggle_mark(mark.mark()), + checked: Some(mark.is_active(&editor.active_marks())), + }, + Intent::Block(block) => CommandState { + enabled: writable && (block.command())(editor.state()).is_some(), + checked: Some( + doc::Block::active(editor.state(), &editor.projection()) == Some(*block), + ), + }, + Intent::Undo => CommandState::enabled( + writable && markraft_core::history::undo_depth(editor.state()) > 0, + ), + Intent::Redo => CommandState::enabled( + writable && markraft_core::history::redo_depth(editor.state()) > 0, + ), + Intent::EditLink | Intent::Unlink => { + CommandState::enabled(writable && editor.active_link().is_some()) + } + Intent::Link => CommandState::enabled( + writable + && !doc::types().in_verbatim_block_at(editor.state()) + && markraft_commonmark::Formatter::new(self.house.clone()) + .set_link("https://example.com", "")(editor.state()) + .is_ok_and(|spec| spec.is_some()), + ), + Intent::InsertTable => CommandState::enabled( + writable + && editor.can_table(markraft_gpui::TableOp::Insert { + rows: 2, + columns: 3, + }), + ), + Intent::CopyLink | Intent::OpenLink => { + CommandState::enabled(editor.active_link().is_some()) + } + Intent::Table(edit) => { + CommandState::enabled(writable && editor.can_table((*edit).into())) + } + Intent::ChooseCodeLanguage => CommandState::enabled( + writable + && doc::Block::active(editor.state(), &editor.projection()) + == Some(doc::Block::Code), + ), + _ => return None, + }; + Some(state) + } + + pub(super) fn editing_enabled(&self, intent: &Intent, cx: &App) -> bool { + self.editing_state(intent, cx) + .is_none_or(|state| state.enabled) + } + + pub(super) fn edit_selection( + &mut self, + command: EditCommand, + window: &mut Window, + cx: &mut Context, + ) { + if !self.editing_enabled(&Intent::Edit(command), cx) { + return; + } + self.close_popover(cx); + self.set_panel(Panel::Editor, cx); + self.focus_editor(window, cx); + // Explicitly dispatch to this editor. A native popup temporarily owns + // keyboard focus, so the global active responder is not the target. + let focus = self.editor().focus_handle(cx); + focus.dispatch_action(command.action().as_ref(), window, cx); + } +} diff --git a/crates/markraft-app/src/app/ui/formatting.rs b/crates/markraft-app/src/app/ui/formatting.rs index 37137688..6ab50681 100644 --- a/crates/markraft-app/src/app/ui/formatting.rs +++ b/crates/markraft-app/src/app/ui/formatting.rs @@ -240,6 +240,7 @@ impl MarkraftApp { Intent::FormatMenu(menu) => Some(menu), _ => None, }; + let enabled = self.editing_enabled(&intent, cx); let expanded = menu.is_some() && self.interaction.format_menu() == menu; let active = toggled == Some(true); // A control that takes a row, a column or the whole table away is written in @@ -258,6 +259,7 @@ impl MarkraftApp { .id(id) .role(Role::Button) .aria_label(label.clone()) + .when(!enabled, |s| s.opacity(0.45)) .when_some(menu, |s, _| s.aria_expanded(expanded)) // A control that opens a menu reports whether the menu is open; it is // not a toggle, whatever fill the format in force gives it. @@ -305,6 +307,7 @@ impl MarkraftApp { Intent::FormatMenu(menu) => Some(menu), _ => None, }; + let enabled = self.editing_enabled(&intent, cx); let expanded = menu.is_some() && self.interaction.format_menu() == menu; let selected = toggled == Some(true) && menu.is_none(); self.format_button_base(id, label, kind, intent, toggled, cx) @@ -321,7 +324,13 @@ impl MarkraftApp { } else { 0. })) - .opacity(if expanded { 0.8 } else { 1. }) + .opacity(if !enabled { + 0.45 + } else if expanded { + 0.8 + } else { + 1. + }) .hover(|s| s.bg(self.chrome_fill(if selected { 0.10 } else { 0.05 }))) .active(|s| { s.bg(self.chrome_fill(if selected { 0.10 } else { 0.05 })) diff --git a/crates/markraft-app/src/app/ui/text_checking.rs b/crates/markraft-app/src/app/ui/text_checking.rs new file mode 100644 index 00000000..b05e59d1 --- /dev/null +++ b/crates/markraft-app/src/app/ui/text_checking.rs @@ -0,0 +1,1328 @@ +//! Native prose checking and substitutions, guarded by the editor snapshot. +use super::*; +use crate::platform::checking_panel::{ + CheckingPanel, CheckingPanelSession, PanelCommand, PanelEvent, SubstitutionKind, +}; +use crate::platform::text_checking::{CheckKind, CheckOptions, SpellDocument, TextCheck}; +use crate::storage::TextCheckingSetting; +use markraft_gpui::ContextRequest; +use std::{collections::HashMap, ops::Range, rc::Rc, time::Duration}; +use unicode_segmentation::UnicodeSegmentation; + +// Native checking is synchronous and main-thread only. Bound each call and +// yield between chunks so typing and note switches can cancel a large scan. +const CHECK_CHUNK_CHARS: usize = 2048; +const SMART_CONTEXT_CHARS: usize = 1024; + +#[derive(Default)] +pub(super) struct TextChecking { + documents: HashMap>, + generation: u64, + panel: Option, + panel_serial: u64, +} + +struct PanelTarget { + id: u64, + generation: u64, + checker: Rc, + session: CheckingPanelSession, + note: String, + editor: WeakEntity, + request: ContextRequest, +} + +/// What started a check of the active note. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::app) enum CheckTrigger { + /// The document, the note or a setting changed. + Changed, + /// Typing committed text: substitutions run at that boundary, before the + /// debounced scan. + Typed, + /// The user asked for the next issue: scan at once, select it, and say + /// so when there is none. + Requested, +} + +/// A grammar issue as the native panel presents it: phrase, detail, corrections. +type Grammar = (String, String, Vec); +type Diagnostic = (Range, Option); + +/// One scheduled check. Each step that waited reads the editor again through +/// this, so a newer check, another note or a changed document ends the run. +struct CheckRun { + generation: u64, + note: String, + editor: WeakEntity, + snapshot: ContextRequest, + checker: Rc, + settings: crate::storage::TextCheckingPreferences, + explicit: bool, +} + +impl CheckRun { + /// The editor being checked, while this is the latest check of the + /// active note. + fn editor(&self, app: &MarkraftApp) -> Option> { + if app.context_menus.checking.generation != self.generation + || app.library.active_id != self.note + { + return None; + } + self.editor + .upgrade() + .filter(|editor| *editor == app.editor()) + } + + /// [`Self::editor`], while it still shows the document the check read. + fn unchanged_editor(&self, app: &MarkraftApp, cx: &App) -> Option> { + self.editor(app) + .filter(|editor| editor.read(cx).context_document_is_current(&self.snapshot)) + } + + /// Apply the substitution typing just completed. Whether one was applied: + /// its edit schedules the next check, so this run ends there. + async fn substitute( + &self, + this: &WeakEntity, + cx: &mut gpui::AsyncApp, + request: ContextRequest, + boundary: TypingBoundary, + ) -> bool { + cx.background_executor() + .timer(Duration::from_millis(1)) + .await; + let text = this + .update(cx, |app, cx| { + self.editor(app)?.read(cx).context_text(&request) + }) + .ok() + .flatten(); + let Some(text) = text.filter(|text| text.text.chars().count() <= CHECK_CHUNK_CHARS) else { + return false; + }; + let mut checks = self.checker.check( + &text.text, + CheckOptions { + quotes: self.settings.quotes, + dashes: self.settings.dashes, + replacements: self.settings.replacements, + correction: self.settings.correction, + links: true, + ..Default::default() + }, + ); + // URL contents are never spelling-corrected or expanded as + // text snippets; link detection itself remains opt-in. + exclude_url_edits(&mut checks, self.settings.links); + keep_spelled_links(&mut checks, &text.text); + this.update(cx, |app, cx| { + let Some(editor) = self.editor(app) else { + return false; + }; + app.apply_substitution(&editor, &request, boundary, checks, cx) + }) + .unwrap_or(false) + } + + /// Check the document in bounded chunks, yielding between them. `None` + /// when the run ended before the last chunk. + async fn scan( + &self, + this: &WeakEntity, + cx: &mut gpui::AsyncApp, + ) -> Option> { + let projection = this + .update(cx, |app, cx| { + Some(self.unchanged_editor(app, cx)?.read(cx).projection()) + }) + .ok() + .flatten()?; + let mut diagnostics = Vec::new(); + if !(self.settings.spelling || self.settings.grammar || self.explicit) { + return Some(diagnostics); + } + for (index, line) in projection.lines().iter().enumerate() { + let Some(text) = projection.line_text(index) else { + continue; + }; + for chunk in checking_chunks(text, CHECK_CHUNK_CHARS) { + let (Some(from), Some(to)) = ( + line.offset_to_pos(chunk.start), + line.offset_to_pos(chunk.end), + ) else { + continue; + }; + let source = from..to; + let reading = this + .update(cx, |app, cx| { + let editor = self.unchanged_editor(app, cx)?; + let editor = editor.read(cx); + Some( + editor + .context_source_range_snapshot(source.clone()) + .and_then(|request| editor.context_text(&request)), + ) + }) + .ok() + .flatten()?; + let Some(reading) = + reading.filter(|reading| reading.text.chars().count() <= CHECK_CHUNK_CHARS) + else { + continue; + }; + let mut checks = self.checker.check( + &reading.text, + CheckOptions { + spelling: self.settings.spelling || self.explicit, + grammar: self.settings.grammar, + links: true, + ..Default::default() + }, + ); + exclude_url_edits(&mut checks, false); + let found = this + .update(cx, |app, cx| { + let editor = self.unchanged_editor(app, cx)?; + diagnostics_in(editor.read(cx), source, checks) + }) + .ok() + .flatten()?; + diagnostics.extend(found); + cx.background_executor() + .timer(Duration::from_millis(1)) + .await; + } + } + Some(diagnostics) + } +} + +/// The spelling and grammar results of one checked chunk that fall on prose, +/// as document ranges. +fn diagnostics_in( + editor: &EditorView, + source: Range, + checks: Vec, +) -> Option> { + // Rebuild with the current selection: moving the caret does not + // invalidate unchanged document diagnostics. + let request = editor.context_source_range_snapshot(source)?; + Some( + checks + .into_iter() + .filter(|check| matches!(check.kind, CheckKind::Spelling | CheckKind::Grammar)) + .filter_map(|check| { + let range = editor.context_text_range(&request, check.range.clone())?; + editor.context_is_prose(&range).then(|| { + let grammar = (check.kind == CheckKind::Grammar).then(|| { + ( + editor + .context_text(&range) + .map(|text| text.text) + .unwrap_or_default(), + check.detail.unwrap_or_default(), + check.suggestions, + ) + }); + (range.text_range(), grammar) + }) + }) + .collect(), + ) +} + +#[derive(Clone, Copy)] +struct TypingBoundary { + caret: usize, + completed_end: Option, +} + +impl TypingBoundary { + fn accepts(self, kind: CheckKind, end: usize) -> bool { + match kind { + CheckKind::Quote | CheckKind::Dash => { + end == self.caret || Some(end) == self.completed_end + } + CheckKind::Correction | CheckKind::Replacement | CheckKind::Link => { + Some(end) == self.completed_end + } + _ => false, + } + } +} + +impl MarkraftApp { + pub(super) fn checking_panel_label(&self, panel: CheckingPanel) -> &'static str { + // Headless menus must never instantiate AppKit's shared panels. + let visible = self.platform.is_some() + && self + .spell_document() + .is_some_and(|checker| checker.panel_visible(panel)); + panel.menu_label(visible) + } + + pub(in crate::app) fn cancel_checking_panel(&mut self) { + if let Some(target) = self.context_menus.checking.panel.take() { + target.checker.hide_panel(CheckingPanel::Spelling); + target.checker.hide_panel(CheckingPanel::Substitutions); + } + } + + /// Follow deliberate selection changes, while invalidating already queued actions. + pub(in crate::app) fn sync_checking_panel(&mut self, cx: &App) { + let Some(target) = self.context_menus.checking.panel.as_ref() else { + return; + }; + let editor = self.editor(); + if (!target.checker.panel_visible(CheckingPanel::Spelling) + && !target.checker.panel_visible(CheckingPanel::Substitutions)) + || target.note != self.library.active_id + || target.editor.upgrade().as_ref() != Some(&editor) + || self.interaction.panel() != Panel::Editor + { + self.cancel_checking_panel(); + } else if !editor.read(cx).context_is_current(&target.request) { + self.refresh_checking_panel_target(cx); + } + } + + pub(super) fn show_checking_panel( + &mut self, + panel: CheckingPanel, + window: &mut Window, + cx: &mut Context, + ) { + use futures_util::StreamExt; + self.sync_checking_panel(cx); + self.ensure_spell_document(); + let Some(checker) = self.spell_document() else { + return; + }; + if checker.panel_visible(panel) { + checker.hide_panel(panel); + if !checker.panel_visible(CheckingPanel::Spelling) + && !checker.panel_visible(CheckingPanel::Substitutions) + { + self.cancel_checking_panel(); + } + return; + } + if self.context_menus.checking.panel.is_some() { + checker.show_panel(panel); + if matches!(panel, CheckingPanel::Spelling) { + self.schedule_text_checking(CheckTrigger::Requested, cx); + } + return; + } + let editor = self.editor(); + let request = editor + .read(cx) + .context_snapshot(Default::default(), markraft_gpui::ContextTarget::Text); + let editable = self.library.active_note().read_only.is_none() && !self.is_reloading(); + let Ok((session, mut commands)) = CheckingPanelSession::attach( + window, + &checker, + self.preferences.text_checking, + editable, + ) else { + return; + }; + session.update( + 0, + self.preferences.text_checking, + editable, + editor + .read(cx) + .context_text(&request) + .map_or(0, |text| text.text.encode_utf16().count()), + ); + self.context_menus.checking.panel_serial = + self.context_menus.checking.panel_serial.wrapping_add(1); + let id = self.context_menus.checking.panel_serial; + self.context_menus.checking.panel = Some(PanelTarget { + id, + generation: 0, + checker: checker.clone(), + session, + note: self.library.active_id.clone(), + editor: editor.downgrade(), + request, + }); + checker.show_panel(panel); + if matches!(panel, CheckingPanel::Spelling) { + self.schedule_text_checking(CheckTrigger::Requested, cx); + } + cx.spawn_in(window, async move |this, cx| { + while let Some(command) = commands.next().await { + if cx + .update(|window, cx| { + this.update(cx, |app, cx| { + app.handle_checking_panel(id, command, window, cx) + }) + }) + .is_err() + { + break; + } + } + }) + .detach(); + } + + fn handle_checking_panel( + &mut self, + id: u64, + event: PanelEvent, + window: &mut Window, + cx: &mut Context, + ) { + if matches!(event.command, PanelCommand::Focus) { + if let Some(target) = self + .context_menus + .checking + .panel + .as_ref() + .filter(|target| target.id == id) + { + target.session.sync_focus(); + } + self.sync_checking_panel(cx); + return; + } + let Some(target) = self + .context_menus + .checking + .panel + .as_ref() + .filter(|target| target.id == id && target.generation == event.generation) + else { + return; + }; + let editor = self.editor(); + if (!target.checker.panel_visible(CheckingPanel::Spelling) + && !target.checker.panel_visible(CheckingPanel::Substitutions)) + || target.note != self.library.active_id + || target.editor.upgrade().as_ref() != Some(&editor) + || !editor.read(cx).context_is_current(&target.request) + { + self.cancel_checking_panel(); + return; + } + let request = target.request.clone(); + match event.command { + PanelCommand::Focus => { + unreachable!("focus notifications are handled before editor commands") + } + PanelCommand::Next => self.schedule_text_checking(CheckTrigger::Requested, cx), + PanelCommand::Replace(text) => { + if self.library.active_note().read_only.is_none() && !self.is_reloading() { + editor.update(cx, |editor, cx| { + editor.replace_context_text(&request, &text, cx); + }); + self.refresh_checking_panel_target(cx); + self.schedule_text_checking(CheckTrigger::Requested, cx); + } + } + PanelCommand::Ignore => { + if let Some(text) = editor.read(cx).context_text(&request) + && let Some(checker) = self.spell_document() + { + checker.ignore(&text.text); + self.schedule_text_checking(CheckTrigger::Requested, cx); + } + } + PanelCommand::Settings(changes) => { + self.apply_panel_settings(changes, window, cx); + self.refresh_checking_panel_target(cx); + } + PanelCommand::Substitute { document } => { + let request = if document { + editor.read(cx).context_document_snapshot() + } else { + request + }; + self.apply_selected_substitutions(&request, SubstitutionKind::All, cx); + self.refresh_checking_panel_target(cx); + } + } + } + + fn apply_panel_settings( + &mut self, + changes: Vec<(TextCheckingSetting, bool)>, + window: &mut Window, + cx: &mut Context, + ) { + let mut settings = self.preferences.text_checking; + for (setting, value) in changes { + settings.set(setting, value); + } + if settings != self.preferences.text_checking { + self.set_preference(crate::storage::Pref::TextChecking(settings), window, cx); + } + } + + fn refresh_checking_panel_target(&mut self, cx: &App) { + let editor = self.editor(); + let request = editor + .read(cx) + .context_snapshot(Default::default(), markraft_gpui::ContextTarget::Text); + let text = editor + .read(cx) + .context_text(&request) + .map(|text| text.text) + .unwrap_or_default(); + let editable = self.library.active_note().read_only.is_none() && !self.is_reloading(); + if let Some(target) = self.context_menus.checking.panel.as_mut() { + // A preference refresh changes no editor target. Keep queued native + // setters valid until the actual document or selection changes. + if !editor.read(cx).context_is_current(&target.request) { + target.generation = target.generation.wrapping_add(1); + } + target.request = request; + target.session.update( + target.generation, + self.preferences.text_checking, + editable, + text.encode_utf16().count(), + ); + if let Some(checker) = self.spell_document() { + // A selection is not evidence of a misspelling. Only a completed + // scan may put a word or grammar issue into the native panel. + checker.update_panel(""); + } + } + } + + pub(super) fn apply_selected_substitutions( + &mut self, + request: &ContextRequest, + kind: SubstitutionKind, + cx: &mut Context, + ) -> bool { + if self.library.active_note().read_only.is_some() || self.is_reloading() { + return false; + } + self.ensure_spell_document(); + let Some(checker) = self.spell_document() else { + return false; + }; + let editor = self.editor(); + let Some(text) = editor.read(cx).context_text(request) else { + return false; + }; + let settings = self.preferences.text_checking; + let options = match kind { + SubstitutionKind::Quotes => CheckOptions { + quotes: true, + ..Default::default() + }, + SubstitutionKind::Dashes => CheckOptions { + dashes: true, + ..Default::default() + }, + SubstitutionKind::Text => CheckOptions { + replacements: true, + ..Default::default() + }, + SubstitutionKind::Links => CheckOptions { + links: true, + ..Default::default() + }, + SubstitutionKind::All => CheckOptions { + quotes: settings.quotes, + dashes: settings.dashes, + replacements: settings.replacements, + links: settings.links, + ..Default::default() + }, + }; + let mut checks = Vec::new(); + let characters = text.text.chars().collect::>(); + for chunk in checking_chunks(&text.text, CHECK_CHUNK_CHARS) { + let part: String = characters[chunk.clone()].iter().collect(); + let mut detected = checker.check( + &part, + CheckOptions { + links: true, + ..options + }, + ); + exclude_url_edits(&mut detected, options.links); + for mut check in detected { + check.range = (check.range.start + chunk.start)..(check.range.end + chunk.start); + checks.push(check); + } + } + self.apply_checked_substitutions(request, checks, cx) + } + + fn apply_checked_substitutions( + &mut self, + request: &ContextRequest, + checks: Vec, + cx: &mut Context, + ) -> bool { + if self.library.active_note().read_only.is_some() || self.is_reloading() { + return false; + } + let editor = self.editor(); + // Native checks address reading text, which may cross concealed source + // or include code. Only independently writable prose results proceed; + // the editor then validates and commits them as one transaction. + let checks = checks + .into_iter() + .filter(|check| { + editor + .read(cx) + .context_text_range(request, check.range.clone()) + .is_some_and(|range| editor.read(cx).context_is_prose(&range)) + }) + .collect::>(); + let edits = checks + .iter() + .filter_map(|check| { + check + .replacement + .clone() + .map(|text| (check.range.clone(), text)) + }) + .collect(); + let links = checks + .into_iter() + .filter_map(|check| check.url.map(|url| (check.range, url))) + .collect::>(); + editor.update(cx, |editor, cx| { + editor.apply_context_text_checks(request, edits, links, cx) + }) + } + + pub(super) fn spell_document(&self) -> Option> { + self.context_menus + .checking + .documents + .get(&self.library.active_id) + .cloned() + } + + pub(super) fn ensure_spell_document(&mut self) { + if self.platform.is_some() + && !self + .context_menus + .checking + .documents + .contains_key(&self.library.active_id) + && let Ok(document) = SpellDocument::new() + { + self.context_menus + .checking + .documents + .insert(self.library.active_id.clone(), Rc::new(document)); + } + } + + pub(super) fn toggle_text_checking( + &mut self, + setting: TextCheckingSetting, + window: &mut Window, + cx: &mut Context, + ) { + let mut settings = self.preferences.text_checking; + settings.set(setting, !settings.get(setting)); + self.set_preference(crate::storage::Pref::TextChecking(settings), window, cx); + } + + pub(in crate::app) fn schedule_text_checking( + &mut self, + trigger: CheckTrigger, + cx: &mut Context, + ) { + self.context_menus.checking.generation = + self.context_menus.checking.generation.wrapping_add(1); + if self.platform.is_none() || self.interaction.panel() != Panel::Editor { + return; + } + self.ensure_spell_document(); + let Some(checker) = self.spell_document() else { + return; + }; + let editor = self.editor(); + let settings = self.preferences.text_checking; + let smart_enabled = settings.quotes + || settings.dashes + || settings.replacements + || settings.correction + || settings.links; + let smart = (trigger == CheckTrigger::Typed && smart_enabled) + .then(|| typing_context(editor.read(cx))) + .flatten(); + let run = CheckRun { + generation: self.context_menus.checking.generation, + note: self.library.active_id.clone(), + editor: editor.downgrade(), + snapshot: editor.read(cx).context_document_snapshot(), + checker, + settings, + explicit: trigger == CheckTrigger::Requested, + }; + cx.spawn(async move |this, cx| { + // Smart punctuation must be handled at the typing boundary, before + // the longer spelling debounce lets the caret advance into a word. + if let Some((request, boundary)) = smart + && run.substitute(&this, cx, request, boundary).await + { + return; + } + if !run.explicit { + cx.background_executor() + .timer(Duration::from_millis(400)) + .await; + } + let Some(diagnostics) = run.scan(&this, cx).await else { + return; + }; + let _ = this.update(cx, |app, cx| app.publish_diagnostics(&run, diagnostics, cx)); + }) + .detach(); + } + + /// Show a finished scan. An explicit check also selects the next issue + /// after the caret, wrapping to the first, or reports that none is left. + fn publish_diagnostics( + &mut self, + run: &CheckRun, + diagnostics: Vec, + cx: &mut Context, + ) { + let Some(editor) = run.unchanged_editor(self, cx) else { + return; + }; + let mut selected_grammar = None; + let mut selected_spelling = None; + if run.explicit { + let head = editor + .read(cx) + .context_snapshot(Default::default(), markraft_gpui::ContextTarget::Text) + .text_range() + .end; + if let Some((range, grammar)) = diagnostics + .iter() + .find(|(range, _)| range.start >= head) + .or_else(|| diagnostics.first()) + { + selected_grammar = grammar.clone(); + if grammar.is_none() { + let editor = editor.read(cx); + selected_spelling = editor + .context_source_range_snapshot(range.clone()) + .and_then(|request| editor.context_text(&request)) + .map(|text| text.text); + } + editor.update(cx, |editor, cx| { + editor.dispatch( + [markraft_core::TransactionSpec::new() + .selection(markraft_core::Selection::text(range.start, range.end))], + cx, + ) + }); + } else { + self.inform(Message::new("notice.spelling-complete"), cx); + } + } + editor.update(cx, |editor, cx| { + editor.set_text_diagnostics( + diagnostics.into_iter().map(|(range, _)| range).collect(), + cx, + ) + }); + if run.explicit { + self.refresh_checking_panel_target(cx); + if self.context_menus.checking.panel.is_some() { + if let Some((phrase, detail, suggestions)) = selected_grammar { + run.checker + .update_grammar_panel(&phrase, &detail, &suggestions); + } else if let Some(word) = selected_spelling { + run.checker.update_panel(&word); + } + } + } + } + + fn apply_substitution( + &mut self, + editor: &Entity, + paragraph: &ContextRequest, + boundary: TypingBoundary, + checks: Vec, + cx: &mut Context, + ) -> bool { + if self.library.active_note().read_only.is_some() || self.is_reloading() { + return false; + } + for check in checks.into_iter().rev() { + let Some(request) = editor + .read(cx) + .context_text_range(paragraph, check.range.clone()) + else { + continue; + }; + if !boundary.accepts(check.kind, request.text_range().end) + || !editor.read(cx).context_is_prose(&request) + { + continue; + } + if let Some(replacement) = check.replacement { + if editor.update(cx, |editor, cx| { + // Automated checks keep the typing caret, including a + // delimiter or a new paragraph after the corrected word. + editor.apply_context_text_checks( + paragraph, + vec![(check.range, replacement)], + Vec::new(), + cx, + ) + }) { + return true; + } + } else if let Some(url) = check.url + && editor.update(cx, |editor, cx| editor.set_context_link(&request, &url, cx)) + { + return true; + } + } + false + } +} + +fn typing_context(editor: &EditorView) -> Option<(ContextRequest, TypingBoundary)> { + editor.context_paragraph_snapshot_at_caret()?; + let projection = editor.projection(); + let caret = editor.head(); + let (index, offset) = projection.pos_to_line_offset(caret)?; + let (line_index, completed_end) = if offset == 0 && index > 0 { + (index - 1, Some(projection.line(index - 1)?.to())) + } else { + let previous = projection + .line_text(index)? + .chars() + .nth(offset.checked_sub(1)?); + let completed = previous + .filter(|character| is_word_boundary(*character)) + .and_then(|_| projection.line(index)?.offset_to_pos(offset - 1)); + (index, completed) + }; + let line = projection.line(line_index)?; + let end_offset = if line_index == index { + offset + } else { + line.len() + }; + let from = line.offset_to_pos(end_offset.saturating_sub(SMART_CONTEXT_CHARS))?; + let to = line.offset_to_pos((end_offset + 2).min(line.len()))?; + let request = editor.context_source_range_snapshot(from..to)?; + Some(( + request, + TypingBoundary { + caret, + completed_end, + }, + )) +} + +fn is_word_boundary(character: char) -> bool { + !character.is_alphanumeric() && !matches!(character, '_' | '\'' | '’') +} + +fn exclude_url_edits(checks: &mut Vec, include_links: bool) { + let links = checks + .iter() + .filter(|check| check.kind == CheckKind::Link) + .map(|check| check.range.clone()) + .collect::>(); + checks.retain(|check| { + if check.kind == CheckKind::Link { + return include_links; + } + !links + .iter() + .any(|link| check.range.start < link.end && link.start < check.range.end) + }); +} + +/// Typing links only text that spells its own scheme. The detector also reads +/// `main.rs` or `a@b.com` as an address, and linking those rewrites source the +/// writer did not ask to change; the explicit command still links them. +fn keep_spelled_links(checks: &mut Vec, text: &str) { + checks.retain(|check| { + check.kind != CheckKind::Link || { + let label = text + .chars() + .skip(check.range.start) + .take(check.range.len()) + .collect::(); + label.split_once(':').is_some_and(|(scheme, rest)| { + !rest.is_empty() + && scheme.starts_with(|first: char| first.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }) + } + }); +} + +/// Preserve complete Unicode words while bounding each native call. An isolated +/// token exceeding the limit is skipped instead of being diagnosed in fragments. +fn checking_chunks(text: &str, limit: usize) -> Vec> { + let mut chunks = Vec::new(); + let mut start = 0; + let mut end = 0; + for word in text.split_word_bounds() { + let length = word.chars().count(); + if end - start + length > limit { + if start < end { + chunks.push(start..end); + } + start = end; + } + end += length; + if length > limit { + start = end; + } + } + if start < end { + chunks.push(start..end); + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + use ::core::prelude::v1::test; + + #[::core::prelude::v1::test] + fn substitutions_only_accept_the_current_typing_boundary() { + let boundary = TypingBoundary { + caret: 12, + completed_end: Some(11), + }; + assert!(boundary.accepts(CheckKind::Quote, 12)); + assert!(boundary.accepts(CheckKind::Replacement, 11)); + assert!(!boundary.accepts(CheckKind::Replacement, 12)); + assert!(!boundary.accepts(CheckKind::Correction, 10)); + assert!( + !TypingBoundary { + caret: 12, + completed_end: None + } + .accepts(CheckKind::Correction, 11) + ); + assert!(is_word_boundary(' ')); + assert!(is_word_boundary('.')); + assert!(!is_word_boundary('a')); + assert!(!is_word_boundary('中')); + assert!(!is_word_boundary('’')); + } + + #[::core::prelude::v1::test] + fn checking_chunks_preserve_words_and_unicode_offsets() { + assert_eq!(checking_chunks("hello world", 6), vec![0..6, 6..11]); + let text = "中文 😀 cafe\u{301}"; + let ranges = checking_chunks(text, 6); + assert!(ranges.iter().all(|range| range.len() <= 6)); + let rebuilt = ranges + .into_iter() + .map(|range| { + text.chars() + .skip(range.start) + .take(range.len()) + .collect::() + }) + .collect::(); + assert_eq!(rebuilt, text); + assert_eq!( + checking_chunks("ok exceptionallylong end", 6), + vec![0..3, 20..24] + ); + } + #[::core::prelude::v1::test] + fn url_spans_do_not_receive_spelling_or_snippet_edits() { + let make = |kind, range| TextCheck { + kind, + range, + replacement: None, + suggestions: Vec::new(), + detail: None, + url: None, + data: None, + }; + let mut checks = vec![ + make(CheckKind::Spelling, 0..4), + make(CheckKind::Link, 5..25), + make(CheckKind::Spelling, 8..14), + make(CheckKind::Replacement, 18..22), + ]; + exclude_url_edits(&mut checks, true); + assert_eq!( + checks.iter().map(|check| check.kind).collect::>(), + vec![CheckKind::Spelling, CheckKind::Link] + ); + exclude_url_edits(&mut checks, false); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0].range, 0..4); + } + fn replacement(range: Range, text: &str) -> TextCheck { + TextCheck { + kind: CheckKind::Replacement, + range, + replacement: Some(text.into()), + suggestions: Vec::new(), + detail: None, + url: None, + data: None, + } + } + + #[gpui::test] + fn native_panel_settings_apply_explicit_values_without_losing_other_changes( + cx: &mut gpui::TestAppContext, + ) { + let h = crate::e2e::harness::open_with(cx, &[("note.md", "hello")], |preferences| { + preferences.text_checking.grammar = false; + preferences.text_checking.quotes = false; + }); + let app = h.app.clone(); + h.cx.update(|window, cx| { + app.update(cx, |app, cx| { + let before = app.preferences.text_checking; + app.apply_panel_settings(vec![(TextCheckingSetting::Grammar, true)], window, cx); + app.apply_panel_settings(vec![(TextCheckingSetting::Grammar, true)], window, cx); + app.apply_panel_settings(vec![(TextCheckingSetting::Quotes, true)], window, cx); + let mut expected = before; + expected.grammar = true; + expected.quotes = true; + assert_eq!(app.preferences.text_checking, expected); + app.apply_panel_settings( + vec![ + (TextCheckingSetting::Grammar, false), + (TextCheckingSetting::Replacements, false), + ], + window, + cx, + ); + expected.grammar = false; + expected.replacements = false; + assert_eq!(app.preferences.text_checking, expected); + assert_eq!(app.editor().read(cx).text(), "hello"); + }); + }); + } + + #[gpui::test] + fn explicit_replacements_are_one_undo_and_preserve_unicode(cx: &mut gpui::TestAppContext) { + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", "😀 hello -- world")], |_| {}); + h.select(1, 17); + let applied = h.app.update(h.cx, |app, cx| { + let request = app + .editor() + .read(cx) + .context_snapshot(Default::default(), markraft_gpui::ContextTarget::Text); + app.apply_checked_substitutions( + &request, + vec![replacement(2..7, "hi"), replacement(8..10, "—")], + cx, + ) + }); + assert!(applied); + h.cx.run_until_parked(); + assert_eq!( + h.app + .update(h.cx, |app, cx| app.editor().read(cx).text().to_owned()), + "😀 hi — world" + ); + h.keys("cmd-z"); + assert_eq!( + h.app + .update(h.cx, |app, cx| app.editor().read(cx).text().to_owned()), + "😀 hello -- world" + ); + } + + #[gpui::test] + fn explicit_substitutions_filter_code_and_nonliteral_ranges_in_mixed_selections( + cx: &mut gpui::TestAppContext, + ) { + let source = "plain -- text `code -- text` &\n\n```\nblock -- text\n```"; + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", source)], |_| {}); + h.keys("cmd-a"); + let applied = h.app.update(h.cx, |app, cx| { + let request = app + .editor() + .read(cx) + .context_snapshot(Default::default(), markraft_gpui::ContextTarget::Text); + let text = app.editor().read(cx).context_text(&request).unwrap(); + let characters = text.text.chars().collect::>(); + let mut checks = characters + .windows(2) + .enumerate() + .filter(|(_, pair)| *pair == ['-', '-']) + .map(|(index, _)| replacement(index..index + 2, "—")) + .collect::>(); + assert_eq!(checks.len(), 3); + let entity = characters.iter().position(|ch| *ch == '&').unwrap(); + checks.push(replacement(entity..entity + 1, "and")); + app.apply_checked_substitutions(&request, checks, cx) + }); + assert!(applied); + assert_eq!(h.markdown(), source.replacen("--", "—", 1)); + h.keys("cmd-z"); + assert_eq!(h.markdown(), source); + } + + #[gpui::test] + fn explicit_replacements_reject_a_changed_selection(cx: &mut gpui::TestAppContext) { + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", "hello -- world")], |_| {}); + h.select(1, 15); + let request = h.app.update(h.cx, |app, cx| { + app.editor() + .read(cx) + .context_snapshot(Default::default(), markraft_gpui::ContextTarget::Text) + }); + h.select(1, 6); + assert!( + !h.app + .update(h.cx, |app, cx| app.apply_checked_substitutions( + &request, + vec![replacement(6..8, "—")], + cx + )) + ); + assert_eq!( + h.app + .update(h.cx, |app, cx| app.editor().read(cx).text().to_owned()), + "hello -- world" + ); + } + + #[gpui::test] + fn explicit_replacements_recheck_read_only_at_execution(cx: &mut gpui::TestAppContext) { + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", "hello -- world")], |_| {}); + h.select(1, 15); + let applied = h.app.update(h.cx, |app, cx| { + let request = app + .editor() + .read(cx) + .context_snapshot(Default::default(), markraft_gpui::ContextTarget::Text); + let id = app.library.active_id.clone(); + app.library + .update_read_only(&id, Some(Message::new("notice.text-service-rejected"))); + app.apply_checked_substitutions(&request, vec![replacement(6..8, "—")], cx) + }); + assert!(!applied); + assert_eq!( + h.app + .update(h.cx, |app, cx| app.editor().read(cx).text().to_owned()), + "hello -- world" + ); + } + + #[gpui::test] + fn a_completed_snippet_preserves_the_delimiter_and_has_its_own_undo( + cx: &mut gpui::TestAppContext, + ) { + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", "omw")], |_| {}); + h.select(4, 4); + h.type_text(" "); + let applied = h.app.update(h.cx, |app, cx| { + let editor = app.editor(); + let (request, boundary) = typing_context(editor.read(cx)).expect("typed prose"); + app.apply_substitution( + &editor, + &request, + boundary, + vec![TextCheck { + kind: CheckKind::Replacement, + range: 0..3, + replacement: Some("on my way".into()), + suggestions: Vec::new(), + detail: None, + url: None, + data: None, + }], + cx, + ) + }); + assert!(applied); + h.cx.run_until_parked(); + assert_eq!( + h.app + .update(h.cx, |app, cx| app.editor().read(cx).text().to_owned()), + "on my way " + ); + h.keys("cmd-z"); + assert_eq!( + h.app + .update(h.cx, |app, cx| app.editor().read(cx).text().to_owned()), + "omw " + ); + } + + #[gpui::test] + fn automatic_substitutions_keep_the_caret_for_continued_typing(cx: &mut gpui::TestAppContext) { + for (source, caret, range, kind, result, continuation, expected) in [ + ("\"", 2, 0..1, CheckKind::Quote, "“", "hello", "“hello"), + ( + "hello --", + 9, + 6..8, + CheckKind::Dash, + "—", + " world", + "hello — world", + ), + ( + "omw tail", + 5, + 0..3, + CheckKind::Replacement, + "on my way", + "next", + "on my way nexttail", + ), + ("**--**", 5, 0..2, CheckKind::Dash, "—", "x", "**—x**"), + ] { + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", source)], |_| {}); + h.select(caret, caret); + assert!( + h.app.update(h.cx, |app, cx| { + let editor = app.editor(); + let (request, boundary) = + typing_context(editor.read(cx)).expect("typing boundary"); + let mut check = replacement(range, result); + check.kind = kind; + app.apply_substitution(&editor, &request, boundary, vec![check], cx) + }), + "{source}" + ); + h.type_text(continuation); + assert_eq!(h.markdown(), expected, "{source}"); + } + } + + #[test] + fn typing_links_only_text_that_spells_a_scheme() { + let text = "see main.rs, a@b.com, https://example.com and mailto:a@b.com"; + let link = |label: &str| { + let start = text.find(label).unwrap(); + let start = text[..start].chars().count(); + TextCheck { + kind: CheckKind::Link, + range: start..start + label.chars().count(), + replacement: None, + suggestions: Vec::new(), + detail: None, + url: Some(format!("http://{label}")), + data: None, + } + }; + let mut checks = vec![ + link("main.rs"), + link("a@b.com"), + link("https://example.com"), + link("mailto:a@b.com"), + replacement(0..3, "See"), + ]; + keep_spelled_links(&mut checks, text); + let kept = checks + .iter() + .map(|check| (check.kind, check.range.start)) + .collect::>(); + assert_eq!( + kept, + vec![ + (CheckKind::Link, 22), + (CheckKind::Link, 46), + (CheckKind::Replacement, 0), + ] + ); + } + + #[gpui::test] + fn existing_links_do_not_cancel_other_substitutions_or_new_links( + cx: &mut gpui::TestAppContext, + ) { + let source = "\"hello\" -- world\n\nhttps://example.com\n\n[example.org](https://custom.test)\n\nnew.example.org"; + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", source)], |_| {}); + assert!(h.app.update(h.cx, |app, cx| { + let editor = app.editor(); + let request = editor.read(cx).context_document_snapshot(); + let text = editor.read(cx).context_text(&request).unwrap().text; + let mut checks = vec![ + replacement(0..1, "“"), + replacement(6..7, "”"), + replacement(8..10, "—"), + ]; + for (label, url) in [ + ("https://example.com", "https://example.com"), + ("example.org", "http://example.org"), + ("new.example.org", "http://new.example.org"), + ] { + let start = text.find(label).unwrap(); + checks.push(TextCheck { + kind: CheckKind::Link, + range: start..start + label.len(), + replacement: None, + suggestions: Vec::new(), + detail: None, + url: Some(url.into()), + data: None, + }); + } + app.apply_checked_substitutions(&request, checks, cx) + })); + assert_eq!( + h.markdown(), + "“hello” — world\n\nhttps://example.com\n\n[example.org](https://custom.test)\n\n[new.example.org](http://new.example.org)" + ); + h.keys("cmd-z"); + assert_eq!(h.markdown(), source); + } + + #[gpui::test] + fn return_completes_a_snippet_in_the_previous_paragraph(cx: &mut gpui::TestAppContext) { + let mut h = crate::e2e::harness::open_with(cx, &[("note.md", "omw")], |_| {}); + h.select(4, 4); + h.keys("enter"); + let applied = h.app.update(h.cx, |app, cx| { + let editor = app.editor(); + let (request, boundary) = typing_context(editor.read(cx)).expect("paragraph boundary"); + app.apply_substitution( + &editor, + &request, + boundary, + vec![TextCheck { + kind: CheckKind::Replacement, + range: 0..3, + replacement: Some("on my way".into()), + suggestions: Vec::new(), + detail: None, + url: None, + data: None, + }], + cx, + ) + }); + assert!(applied); + h.cx.run_until_parked(); + assert_eq!( + h.app + .update(h.cx, |app, cx| app.editor().read(cx).text().to_owned()), + "on my way\n" + ); + h.type_text("next"); + assert_eq!(h.markdown(), "on my way\n\nnext"); + } +} diff --git a/crates/markraft-app/src/doc.rs b/crates/markraft-app/src/doc.rs index 4e9ebc81..a514f97c 100644 --- a/crates/markraft-app/src/doc.rs +++ b/crates/markraft-app/src/doc.rs @@ -68,10 +68,19 @@ fn formatter(house: &HouseStyleHandle) -> Formatter { /// The input rules and corrections a CommonMark editor wants. The input rules — `# `, /// `- `, `> ` and the rest turning a line into a block — run while `shortcuts` holds, /// and brackets and quotes pair while `pairs` does. +#[cfg(test)] pub fn extensions(shortcuts: Arc, pairs: Arc) -> Extension { + extensions_with_quotes(shortcuts, pairs, Arc::new(AtomicBool::new(true))) +} + +pub fn extensions_with_quotes( + shortcuts: Arc, + pairs: Arc, + quotes: Arc, +) -> Extension { Extension::all([ markraft_commonmark::commonmark_extensions_with_shortcuts(schema(), shortcuts), - markraft_commonmark::commonmark_auto_pairs(pairs), + markraft_commonmark::commonmark_auto_pairs_with_quotes(pairs, quotes), ]) } @@ -170,6 +179,18 @@ impl DocumentKind for MarkdownKind { Some(worded(command, self.refusal.clone())) } + fn replace_reading( + &self, + range: std::ops::Range, + text: &str, + policy: markraft_core::kind::ReadingReplacementPolicy, + ) -> Option { + Some(worded( + self.formatter().replace_reading(range, text, policy), + self.refusal.clone(), + )) + } + /// Every style open at the caret is closed before the cut and opened again /// after it. fn wrap_split(&self, split: Command) -> Command { @@ -664,9 +685,8 @@ pub enum Inline { Italic, Code, Strikethrough, - /// Present when HTML paste carries underline, which Markdown spells as - /// `…`; no control of the interface offers it. - #[allow(dead_code)] + Highlight, + /// Markdown spells underline as `…`. Underline, } @@ -677,6 +697,7 @@ impl Inline { Inline::Italic => md::EM, Inline::Code => md::CODE, Inline::Strikethrough => md::STRIKETHROUGH, + Inline::Highlight => md::HIGHLIGHT, Inline::Underline => md::UNDERLINE, }) } diff --git a/crates/markraft-app/src/platform.rs b/crates/markraft-app/src/platform.rs index f104b6de..03fb08b0 100644 --- a/crates/markraft-app/src/platform.rs +++ b/crates/markraft-app/src/platform.rs @@ -2,9 +2,15 @@ //! //! Create and use this object on AppKit's main thread. GPUI owns the native //! window; native pointers below are borrowed only for the duration of a call. +pub(crate) mod checking_panel; +pub(crate) mod context_menu; pub(crate) mod locale; pub(crate) mod print; pub(crate) mod symbols; +pub(crate) mod text_checking; +pub(crate) mod text_requestor; +pub(crate) mod text_services; +pub(crate) mod translation; use crate::locale::Message; use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState, hotkey::HotKey}; @@ -21,7 +27,7 @@ use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use std::{ffi::CStr, path::Path, ptr, str::FromStr}; use tray_icon::{ Icon, TrayIcon, TrayIconBuilder, - menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem}, + menu::{Menu, MenuId, MenuItem, PredefinedMenuItem}, }; // Minimal AppKit geometry for struct-returning messages; objc2 checks the encoding. @@ -488,7 +494,7 @@ impl Platform { events.push(which.event()); } } - while let Ok(event) = MenuEvent::receiver().try_recv() { + for event in context_menu::take_application_events() { if let Some((_, action)) = self.menu_actions.iter().find(|(id, _)| *id == event.id) { events.push(*action); } diff --git a/crates/markraft-app/src/platform/checking_panel.rs b/crates/markraft-app/src/platform/checking_panel.rs new file mode 100644 index 00000000..ed4fbe9c --- /dev/null +++ b/crates/markraft-app/src/platform/checking_panel.rs @@ -0,0 +1,675 @@ +//! Public AppKit spelling/substitution panels bridged to guarded editor commands. +use super::{native_view, text_checking::SpellDocument}; +use crate::{ + locale::Message, + storage::{TextCheckingPreferences, TextCheckingSetting}, +}; +use futures_channel::mpsc; +use objc2::{ + DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, + rc::Retained, + runtime::{AnyObject, NSObjectProtocol}, +}; +use objc2_app_kit::{ + NSApplication, NSResponder, NSWindow, NSWindowDidBecomeKeyNotification, + NSWindowDidResignKeyNotification, NSWindowWillCloseNotification, +}; +use objc2_foundation::{NSNotification, NSNotificationCenter, NSRange, NSString}; +use std::cell::{Cell, RefCell}; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum CheckingPanel { + Spelling, + Substitutions, +} + +impl CheckingPanel { + pub(crate) fn menu_label(self, visible: bool) -> &'static str { + match (self, visible) { + (Self::Spelling, false) => "command.show-spelling", + (Self::Spelling, true) => "command.hide-spelling", + (Self::Substitutions, false) => "command.show-substitutions", + (Self::Substitutions, true) => "command.hide-substitutions", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum SubstitutionKind { + Quotes, + Dashes, + Text, + Links, + All, +} + +#[derive(Debug)] +pub(crate) enum PanelCommand { + Focus, + Next, + Replace(String), + Ignore, + Settings(Vec<(TextCheckingSetting, bool)>), + Substitute { document: bool }, +} + +pub(crate) struct PanelEvent { + pub generation: u64, + pub command: PanelCommand, +} + +struct State { + generation: u64, + events: Option>, + settings: TextCheckingPreferences, + editable: bool, + range: NSRange, + tag: isize, +} + +impl State { + fn update_settings(&mut self, changes: impl IntoIterator) { + if self.events.is_none() { + return; + } + let changes = changes + .into_iter() + .filter(|(setting, value)| { + let changed = self.settings.get(*setting) != *value; + self.settings.set(*setting, *value); + changed + }) + .collect::>(); + if !changes.is_empty() { + self.send(PanelCommand::Settings(changes)); + } + } + + fn set_checking_types(&mut self, types: u64) { + use objc2_foundation::NSTextCheckingType as Type; + self.update_settings( + [ + (TextCheckingSetting::Spelling, Type::Spelling.0), + (TextCheckingSetting::Grammar, Type::Grammar.0), + (TextCheckingSetting::Correction, Type::Correction.0), + (TextCheckingSetting::Quotes, Type::Quote.0), + (TextCheckingSetting::Dashes, Type::Dash.0), + (TextCheckingSetting::Replacements, Type::Replacement.0), + (TextCheckingSetting::Links, Type::Link.0), + ( + TextCheckingSetting::DataDetectors, + Type::Date.0 + | Type::Address.0 + | Type::PhoneNumber.0 + | Type::TransitInformation.0, + ), + ] + .into_iter() + .map(|(setting, mask)| (setting, types & mask != 0)), + ); + } + + fn send(&self, command: PanelCommand) { + if let Some(events) = &self.events { + let _ = events.unbounded_send(PanelEvent { + generation: self.generation, + command, + }); + } + } +} + +define_class!( + #[unsafe(super(NSResponder))] + #[thread_kind = MainThreadOnly] + #[ivars = RefCell] + struct CheckingResponder; + unsafe impl NSObjectProtocol for CheckingResponder {} + impl CheckingResponder { + #[unsafe(method(acceptsFirstResponder))] + fn accepts_first_responder(&self) -> bool { true } + #[unsafe(method(resignFirstResponder))] + fn resign_first_responder(&self) -> bool { true } + #[unsafe(method(checkingPanelFocusChanged:))] + fn focus_changed(&self, _: &NSNotification) { self.ivars().borrow().send(PanelCommand::Focus); } + #[unsafe(method(validateUserInterfaceItem:))] + fn validate(&self, item: &AnyObject) -> bool { self.validate_action(item) } + #[unsafe(method(validateMenuItem:))] + fn validate_menu(&self, item: &AnyObject) -> bool { self.validate_action(item) } + #[unsafe(method(checkSpelling:))] + fn check(&self, _: Option<&AnyObject>) { self.ivars().borrow().send(PanelCommand::Next); } + #[unsafe(method(changeSpelling:))] + fn change(&self, sender: &AnyObject) { + let text: Option> = unsafe { msg_send![sender, stringValue] }; + if let Some(text) = text { self.ivars().borrow().send(PanelCommand::Replace(text.to_string())); } + } + #[unsafe(method(ignoreSpelling:))] + fn ignore(&self, _: Option<&AnyObject>) { self.ivars().borrow().send(PanelCommand::Ignore); } + #[unsafe(method(checkTextInSelection:))] + fn selection(&self, _: Option<&AnyObject>) { self.ivars().borrow().send(PanelCommand::Substitute { document: false }); } + #[unsafe(method(checkTextInDocument:))] + fn document(&self, _: Option<&AnyObject>) { self.ivars().borrow().send(PanelCommand::Substitute { document: true }); } + #[unsafe(method(toggleContinuousSpellChecking:))] + fn spelling(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::Spelling); } + #[unsafe(method(toggleGrammarChecking:))] + fn grammar(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::Grammar); } + #[unsafe(method(toggleAutomaticSpellingCorrection:))] + fn correction(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::Correction); } + #[unsafe(method(toggleAutomaticQuoteSubstitution:))] + fn quotes(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::Quotes); } + #[unsafe(method(toggleAutomaticDashSubstitution:))] + fn dashes(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::Dashes); } + #[unsafe(method(toggleAutomaticTextReplacement:))] + fn text(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::Replacements); } + #[unsafe(method(toggleAutomaticLinkDetection:))] + fn links(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::Links); } + #[unsafe(method(toggleAutomaticDataDetection:))] + fn data(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::DataDetectors); } + #[unsafe(method(toggleSmartInsertDelete:))] + fn smart_insert(&self, _: Option<&AnyObject>) { self.toggle(TextCheckingSetting::SmartInsertDelete); } + #[unsafe(method(smartInsertDeleteEnabled))] + fn has_smart_insert(&self) -> bool { self.ivars().borrow().settings.smart_insert_delete } + #[unsafe(method(isContinuousSpellCheckingEnabled))] + fn has_spelling(&self) -> bool { self.ivars().borrow().settings.spelling } + #[unsafe(method(isGrammarCheckingEnabled))] + fn has_grammar(&self) -> bool { self.ivars().borrow().settings.grammar } + #[unsafe(method(isAutomaticSpellingCorrectionEnabled))] + fn has_correction(&self) -> bool { self.ivars().borrow().settings.correction } + #[unsafe(method(isAutomaticQuoteSubstitutionEnabled))] + fn has_quotes(&self) -> bool { self.ivars().borrow().settings.quotes } + #[unsafe(method(isAutomaticDashSubstitutionEnabled))] + fn has_dashes(&self) -> bool { self.ivars().borrow().settings.dashes } + #[unsafe(method(isAutomaticTextReplacementEnabled))] + fn has_text(&self) -> bool { self.ivars().borrow().settings.replacements } + #[unsafe(method(isAutomaticLinkDetectionEnabled))] + fn has_links(&self) -> bool { self.ivars().borrow().settings.links } + #[unsafe(method(isAutomaticDataDetectionEnabled))] + fn has_data(&self) -> bool { self.ivars().borrow().settings.data_detectors } + #[unsafe(method(setSmartInsertDeleteEnabled:))] + fn set_smart_insert(&self, value: bool) { self.set(TextCheckingSetting::SmartInsertDelete, value); } + #[unsafe(method(setContinuousSpellCheckingEnabled:))] + fn set_spelling(&self, value: bool) { self.set(TextCheckingSetting::Spelling, value); } + #[unsafe(method(setGrammarCheckingEnabled:))] + fn set_grammar(&self, value: bool) { self.set(TextCheckingSetting::Grammar, value); } + #[unsafe(method(setAutomaticSpellingCorrectionEnabled:))] + fn set_correction(&self, value: bool) { self.set(TextCheckingSetting::Correction, value); } + #[unsafe(method(setAutomaticQuoteSubstitutionEnabled:))] + fn set_quotes(&self, value: bool) { self.set(TextCheckingSetting::Quotes, value); } + #[unsafe(method(setAutomaticDashSubstitutionEnabled:))] + fn set_dashes(&self, value: bool) { self.set(TextCheckingSetting::Dashes, value); } + #[unsafe(method(setAutomaticTextReplacementEnabled:))] + fn set_text(&self, value: bool) { self.set(TextCheckingSetting::Replacements, value); } + #[unsafe(method(setAutomaticLinkDetectionEnabled:))] + fn set_links(&self, value: bool) { self.set(TextCheckingSetting::Links, value); } + #[unsafe(method(setAutomaticDataDetectionEnabled:))] + fn set_data(&self, value: bool) { self.set(TextCheckingSetting::DataDetectors, value); } + #[unsafe(method(setEnabledTextCheckingTypes:))] + fn set_checking_types(&self, value: u64) { self.ivars().borrow_mut().set_checking_types(value); } + #[unsafe(method(enabledTextCheckingTypes))] + fn enabled_checking_types(&self) -> u64 { + let settings = self.ivars().borrow().settings; + super::text_checking::CheckOptions { + spelling: settings.spelling, grammar: settings.grammar, quotes: settings.quotes, + dashes: settings.dashes, replacements: settings.replacements, + correction: settings.correction, links: settings.links, data_detectors: settings.data_detectors, + }.mask() + } + #[unsafe(method(isEditable))] + fn editable(&self) -> bool { self.ivars().borrow().editable } + #[unsafe(method(selectedRange))] + fn selected_range(&self) -> NSRange { self.ivars().borrow().range } + #[unsafe(method(spellCheckerDocumentTag))] + fn document_tag(&self) -> isize { self.ivars().borrow().tag } + } +); + +impl CheckingResponder { + fn validate_action(&self, item: &AnyObject) -> bool { + let action: objc2::runtime::Sel = unsafe { msg_send![item, action] }; + let state = self.ivars().borrow(); + if state.events.is_none() { + return false; + } + if action == objc2::sel!(changeSpelling:) || action == objc2::sel!(checkTextInSelection:) { + return state.editable && state.range.length != 0; + } + if action == objc2::sel!(checkTextInDocument:) { + return state.editable; + } + true + } + fn set(&self, setting: TextCheckingSetting, value: bool) { + self.ivars() + .borrow_mut() + .update_settings([(setting, value)]); + } + + fn toggle(&self, setting: TextCheckingSetting) { + let mut state = self.ivars().borrow_mut(); + let value = !state.settings.get(setting); + state.update_settings([(setting, value)]); + } +} + +/// Own the panel's action target until the editor context changes or another menu opens. +pub(crate) struct CheckingPanelSession { + view: Retained, + previous: Option>, + responder: Retained, + owner: Retained, + panels: [Retained; 2], + center: Retained, + proxy_active: Cell, +} + +impl CheckingPanelSession { + pub(crate) fn attach( + window: &gpui::Window, + checker: &SpellDocument, + settings: TextCheckingPreferences, + editable: bool, + ) -> Result<(Self, mpsc::UnboundedReceiver), Message> { + let failure = || Message::new("error.native-window-control"); + let mtm = MainThreadMarker::new().ok_or_else(failure)?; + let view = unsafe { Retained::retain(native_view(window)?.cast::()) } + .ok_or_else(failure)?; + let owner: Option> = unsafe { msg_send![&*view, window] }; + let owner = owner.ok_or_else(failure)?; + let native_checker: Retained = + unsafe { msg_send![objc2::class!(NSSpellChecker), sharedSpellChecker] }; + let panels = unsafe { + [ + msg_send![&*native_checker, spellingPanel], + msg_send![&*native_checker, substitutionsPanel], + ] + }; + let previous = unsafe { view.nextResponder() }; + let (events, receiver) = mpsc::unbounded(); + let responder = CheckingResponder::alloc(mtm).set_ivars(RefCell::new(State { + generation: 0, + events: Some(events), + settings, + editable, + range: NSRange::new(0, 0), + tag: checker.tag(), + })); + let responder: Retained = unsafe { msg_send![super(responder), init] }; + unsafe { + responder.setNextResponder(previous.as_deref()); + view.setNextResponder(Some(&responder)); + } + let center = NSNotificationCenter::defaultCenter(); + for observed in [&owner, &panels[0], &panels[1]] { + for name in unsafe { + [ + NSWindowDidBecomeKeyNotification, + NSWindowDidResignKeyNotification, + NSWindowWillCloseNotification, + ] + } { + unsafe { + center.addObserver_selector_name_object( + &responder, + objc2::sel!(checkingPanelFocusChanged:), + Some(name), + Some(observed), + ); + } + } + } + Ok(( + Self { + view, + previous, + responder, + owner, + panels, + center, + proxy_active: Cell::new(false), + }, + receiver, + )) + } + + /// AppKit reads checking properties directly from the document's first + /// responder. Borrow that position only while one of our panels is key. + /// GPUI keeps its original input view and input context when editing resumes. + pub(crate) fn sync_focus(&self) { + let app = NSApplication::sharedApplication(self.responder.mtm()); + let panel_is_key = app.keyWindow().as_ref().is_some_and(|key| { + self.panels + .iter() + .any(|panel| std::ptr::eq(&**panel, &**key)) + }); + let first = self.owner.firstResponder(); + let next = unsafe { self.view.nextResponder() }; + let owns_editor = same_responder(first.as_deref(), Some(&self.view)) + && same_responder(next.as_deref(), Some(&self.responder)); + match focus_action(panel_is_key, self.proxy_active.get(), owns_editor) { + FocusAction::Restore => { + self.restore_focus(); + return; + } + FocusAction::Keep => return, + FocusAction::Borrow => {} + } + unsafe { + // Remove view -> proxy before installing proxy -> view. There must + // never be a cycle, including during AppKit focus callbacks. + self.view.setNextResponder(self.previous.as_deref()); + self.responder.setNextResponder(Some(&self.view)); + } + if self.owner.makeFirstResponder(Some(&self.responder)) { + self.proxy_active.set(true); + } else { + unsafe { + self.responder.setNextResponder(self.previous.as_deref()); + self.view.setNextResponder(Some(&self.responder)); + } + } + self.update_native_panels(); + } + + fn restore_focus(&self) { + if !self.proxy_active.get() { + return; + } + if !release_owned_focus( + || { + same_responder( + self.owner.firstResponder().as_deref(), + Some(&self.responder), + ) + }, + || { + self.owner.makeFirstResponder(Some(&self.view)); + }, + || { + self.owner.makeFirstResponder(None); + }, + ) { + return; + } + self.proxy_active.set(false); + let next = unsafe { self.view.nextResponder() }; + unsafe { + // Break proxy -> view before restoring view -> proxy. + self.responder.setNextResponder(self.previous.as_deref()); + if same_responder(next.as_deref(), self.previous.as_deref()) { + self.view.setNextResponder(Some(&self.responder)); + } + } + self.update_native_panels(); + } + + fn update_native_panels(&self) { + unsafe { + let checker: Retained = + msg_send![objc2::class!(NSSpellChecker), sharedSpellChecker]; + let _: () = msg_send![&*checker, updatePanels]; + } + } + + pub(crate) fn update( + &self, + generation: u64, + settings: TextCheckingPreferences, + editable: bool, + selected_length: usize, + ) { + let mut state = self.responder.ivars().borrow_mut(); + state.generation = generation; + state.settings = settings; + state.editable = editable; + state.range = NSRange::new(0, selected_length); + } +} + +impl Drop for CheckingPanelSession { + fn drop(&mut self) { + unsafe { + self.center.removeObserver(&self.responder); + } + self.responder.ivars().borrow_mut().events.take(); + self.restore_focus(); + let current = unsafe { self.view.nextResponder() }; + if current + .as_deref() + .is_some_and(|value| std::ptr::eq(value, &*self.responder as &NSResponder)) + { + unsafe { + self.view.setNextResponder(self.previous.as_deref()); + } + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum FocusAction { + Keep, + Borrow, + Restore, +} + +fn focus_action(panel_is_key: bool, borrowed: bool, owns_editor: bool) -> FocusAction { + match (panel_is_key, borrowed, owns_editor) { + (false, true, _) => FocusAction::Restore, + (true, false, true) => FocusAction::Borrow, + _ => FocusAction::Keep, + } +} + +/// A focus change may be refused or another control may take ownership during +/// it. Recheck actual ownership before falling back to clearing firstResponder. +fn release_owned_focus( + is_owned: impl Fn() -> bool, + restore: impl FnOnce(), + clear: impl FnOnce(), +) -> bool { + if is_owned() { + restore(); + if is_owned() { + clear(); + } + } + !is_owned() +} + +fn same_responder(left: Option<&NSResponder>, right: Option<&NSResponder>) -> bool { + match (left, right) { + (Some(left), Some(right)) => std::ptr::eq(left, right), + (None, None) => true, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn panel_focus_restoration_clears_a_refused_target_without_overwriting_new_focus() { + let owned = Cell::new(true); + let cleared = Cell::new(false); + assert!(release_owned_focus( + || owned.get(), + || {}, // The original editor refused first responder. + || { + cleared.set(true); + owned.set(false); + }, + )); + assert!(cleared.get()); + assert!(!owned.get()); + + let restored = Cell::new(false); + assert!(release_owned_focus( + || false, // Another control already owns focus. + || restored.set(true), + || panic!("must not clear another control's focus"), + )); + assert!(!restored.get()); + + owned.set(true); + assert!(release_owned_focus( + || owned.get(), + || owned.set(false), + || panic!("successful restoration must not clear focus"), + )); + assert!(!release_owned_focus(|| true, || {}, || {})); + } + + #[test] + fn panel_focus_borrowing_requires_an_active_panel_and_owned_editor() { + assert_eq!(focus_action(true, false, true), FocusAction::Borrow); + assert_eq!(focus_action(true, false, false), FocusAction::Keep); + assert_eq!(focus_action(false, false, true), FocusAction::Keep); + assert_eq!(focus_action(false, false, false), FocusAction::Keep); + assert_eq!(focus_action(true, true, false), FocusAction::Keep); + assert_eq!(focus_action(false, true, true), FocusAction::Restore); + // Restore wiring even if another control has already taken focus; + // restore_focus separately preserves that control's firstResponder. + assert_eq!(focus_action(false, true, false), FocusAction::Restore); + } + + #[test] + fn checking_responder_exposes_writable_public_text_checking_properties() { + use objc2::{ClassType, sel}; + for selector in [ + sel!(acceptsFirstResponder), + sel!(checkingPanelFocusChanged:), + sel!(setSmartInsertDeleteEnabled:), + sel!(setContinuousSpellCheckingEnabled:), + sel!(setGrammarCheckingEnabled:), + sel!(setAutomaticSpellingCorrectionEnabled:), + sel!(setAutomaticQuoteSubstitutionEnabled:), + sel!(setAutomaticDashSubstitutionEnabled:), + sel!(setAutomaticTextReplacementEnabled:), + sel!(setAutomaticLinkDetectionEnabled:), + sel!(setAutomaticDataDetectionEnabled:), + sel!(setEnabledTextCheckingTypes:), + ] { + assert!( + CheckingResponder::class() + .instance_method(selector) + .is_some(), + "{selector}" + ); + } + } + + fn state_with_events() -> (State, mpsc::UnboundedReceiver) { + let (events, receiver) = mpsc::unbounded(); + ( + State { + generation: 7, + events: Some(events), + settings: TextCheckingPreferences::default(), + editable: true, + range: NSRange::new(0, 5), + tag: 0, + }, + receiver, + ) + } + + #[test] + fn panel_setters_emit_explicit_values_and_are_idempotent() { + let (mut state, mut receiver) = state_with_events(); + state.settings.grammar = false; + state.settings.quotes = false; + state.update_settings([(TextCheckingSetting::Grammar, true)]); + state.update_settings([(TextCheckingSetting::Grammar, true)]); + state.update_settings([(TextCheckingSetting::Quotes, true)]); + assert!(state.settings.grammar); + assert!(state.settings.quotes); + for setting in [TextCheckingSetting::Grammar, TextCheckingSetting::Quotes] { + let event = receiver.try_recv().unwrap(); + assert_eq!(event.generation, 7); + let PanelCommand::Settings(changes) = event.command else { + panic!("expected settings"); + }; + assert_eq!(changes, vec![(setting, true)]); + } + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + state.events.take(); + state.update_settings([(TextCheckingSetting::Grammar, false)]); + assert!(state.settings.grammar); + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Closed) + )); + } + + #[test] + fn checking_type_mask_updates_one_batch_and_preserves_smart_copy() { + use objc2_foundation::NSTextCheckingType as Type; + let (mut state, mut receiver) = state_with_events(); + let smart_copy = state.settings.smart_insert_delete; + state.set_checking_types(Type::Grammar.0 | Type::Link.0 | Type::PhoneNumber.0); + assert!(!state.settings.spelling); + assert!(state.settings.grammar); + assert!(!state.settings.correction); + assert!(!state.settings.quotes); + assert!(!state.settings.dashes); + assert!(!state.settings.replacements); + assert!(state.settings.links); + assert!(state.settings.data_detectors); + assert_eq!(state.settings.smart_insert_delete, smart_copy); + assert!(matches!( + receiver.try_recv().unwrap().command, + PanelCommand::Settings(_) + )); + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + } + + #[test] + fn panel_menu_labels_follow_native_visibility() { + for (panel, show, hide) in [ + ( + CheckingPanel::Spelling, + "command.show-spelling", + "command.hide-spelling", + ), + ( + CheckingPanel::Substitutions, + "command.show-substitutions", + "command.hide-substitutions", + ), + ] { + assert_eq!(panel.menu_label(false), show); + assert_eq!(panel.menu_label(true), hide); + assert_eq!(panel.menu_label(false), show); + } + } + + #[test] + fn queued_panel_actions_keep_the_selection_generation_that_issued_them() { + let (events, mut receiver) = mpsc::unbounded(); + let mut state = State { + generation: 4, + events: Some(events), + settings: TextCheckingPreferences::default(), + editable: true, + range: NSRange::new(0, 5), + tag: 0, + }; + state.send(PanelCommand::Replace("old target".into())); + state.generation = 5; + state.send(PanelCommand::Replace("new target".into())); + assert_eq!(receiver.try_recv().unwrap().generation, 4); + assert_eq!(receiver.try_recv().unwrap().generation, 5); + state.events.take(); + state.send(PanelCommand::Replace("cancelled".into())); + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Closed) + )); + } +} diff --git a/crates/markraft-app/src/platform/context_menu.rs b/crates/markraft-app/src/platform/context_menu.rs new file mode 100644 index 00000000..2756baee --- /dev/null +++ b/crates/markraft-app/src/platform/context_menu.rs @@ -0,0 +1,470 @@ +//! Native menu tracking without retaining a GPUI entity or application borrow. +//! +//! Muda shares one event receiver between all menus. Both tracking and the tray +//! poll go through this router so either can run inside AppKit's nested loop. + +mod caption; +pub(crate) use caption::selection_label; + +use super::{NSPoint, NSRect, native_view, native_window}; +use crate::locale::Message; +use gpui::{Pixels, Point, Window}; +use objc2::{MainThreadMarker, class, msg_send, rc::Retained, runtime::AnyObject, sel}; +use objc2_app_kit::NSImage; +use objc2_foundation::{NSArray, NSString}; +use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + ptr, + rc::Rc, +}; +use tray_icon::menu::{ + CheckMenuItem, ContextMenu, IsMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, + Submenu, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum TitleStyle { + Bold, + Italic, + Underline, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ItemAppearance { + title_style: Option, +} + +/// Semantic menu data. Item IDs belong to the caller and are local to this menu. +#[derive(Clone, Debug)] +pub(crate) enum Row { + Item { + id: usize, + label: String, + enabled: bool, + checked: Option, + symbol: Option<&'static str>, + appearance: ItemAppearance, + }, + Separator, + WritingTools, + Submenu { + label: String, + enabled: bool, + children: Vec, + symbol: Option<&'static str>, + }, + DetectedData { + result: super::text_checking::DetectedData, + position: Point, + }, +} + +impl Row { + pub(crate) fn with_title_style(mut self, style: TitleStyle) -> Self { + if let Self::Item { appearance, .. } = &mut self { + appearance.title_style = Some(style); + } + self + } + + pub(crate) fn with_symbol(mut self, name: &'static str) -> Self { + match &mut self { + Self::Item { symbol, .. } | Self::Submenu { symbol, .. } => *symbol = Some(name), + _ => {} + } + self + } +} + +/// Owns the native objects while a deferred presentation is pending or tracking. +/// This must be created, shown, and dropped on the main thread. +pub(crate) struct PreparedMenu { + menu: Menu, + view: Retained, + window: Retained, + items: Vec<(MenuId, usize)>, + _system_menus: Vec>, +} + +impl PreparedMenu { + pub(crate) fn new( + rows: &[Row], + window: &Window, + writing_tools: Option<&[Retained]>, + ) -> Result { + MainThreadMarker::new().ok_or_else(|| failure("menu prepared off the main thread"))?; + // GPUI owns both objects. Keep them alive while presentation is deferred, + // then check that the view is still attached to this visible window. + let view = unsafe { Retained::retain(native_view(window)?) } + .ok_or_else(|| failure("the editor view is unavailable"))?; + let native_window = unsafe { Retained::retain(native_window(window)?) } + .ok_or_else(|| failure("the editor window is unavailable"))?; + let menu = Menu::new(); + let mut items = Vec::new(); + for row in rows + .iter() + .filter(|row| !matches!(row, Row::DetectedData { .. } | Row::WritingTools)) + { + menu.append(&*build_row(row, &mut items)?) + .map_err(failure)?; + } + let mut system_menus = Vec::new(); + decorate( + menu.ns_menu().cast(), + rows, + window, + &mut system_menus, + writing_tools.unwrap_or_default(), + )?; + if writing_tools.is_some() { + // A coordinated snapshot gets exactly one group: AppKit's Services + // group for selected text, or our explicit group for an empty caret. + // The Services insertion is independent of this native menu option. + unsafe { + let native = menu.ns_menu().cast::(); + let _: () = msg_send![native, setAutomaticallyInsertsWritingToolsItems: false]; + } + } + Ok(Self { + menu, + view, + window: native_window, + items, + _system_menus: system_menus, + }) + } + + /// Track synchronously, returning an item ID or `None` when dismissed. + /// + /// Call from the foreground executor **outside** a GPUI entity/window update: + /// AppKit runs a nested event loop here and may dispatch GPUI callbacks. + pub(crate) fn show(self, position: Point) -> Option { + MainThreadMarker::new()?; + let attached: *mut AnyObject = unsafe { msg_send![&*self.view, window] }; + let visible: bool = unsafe { msg_send![&*self.window, isVisible] }; + if attached != Retained::as_ptr(&self.window).cast_mut() || !visible { + return None; + } + let registration = Registration::new(&self.items); + let bounds: NSRect = unsafe { msg_send![&*self.view, bounds] }; + let flipped: bool = unsafe { msg_send![&*self.view, isFlipped] }; + let point = view_point(position, bounds, flipped); + let native = self.menu.ns_menu().cast::(); + // Access the NSMenu without keeping a Muda RefCell borrow through the + // nested loop. GPUI positions are logical points, like AppKit's bounds. + unsafe { + let location: NSPoint = + msg_send![&*self.view, convertPoint: point, toView: ptr::null_mut::()]; + let number: isize = msg_send![&*self.window, windowNumber]; + let event: Option> = msg_send![class!(NSEvent), + mouseEventWithType: 3usize, location: location, modifierFlags: 0usize, + timestamp: 0.0f64, windowNumber: number, context: ptr::null_mut::(), + eventNumber: 0isize, clickCount: 1isize, pressure: 1.0f32 + ]; + // AppKit inserts Services using the snapshot in the responder chain. + // Writing Tools requests coordinator context, but AppKit may return + // the result through the same pasteboard bridge as ordinary Services. + if let Some(event) = event { + let _: () = msg_send![class!(NSMenu), popUpContextMenu: native, + withEvent: &*event, forView: &*self.view]; + } + } + drain_events(); + registration.selected.get() + } +} + +fn view_point(position: Point, bounds: NSRect, flipped: bool) -> NSPoint { + let x = f32::from(position.x) as f64; + let y = f32::from(position.y) as f64; + NSPoint { + x: bounds.origin.x + x, + y: bounds.origin.y + if flipped { y } else { bounds.size.y - y }, + } +} + +fn build_row(row: &Row, ids: &mut Vec<(MenuId, usize)>) -> Result, Message> { + match row { + Row::Item { + id, + label, + enabled, + checked, + .. + } => { + let item: Box = match checked { + Some(checked) => Box::new(CheckMenuItem::new(label, *enabled, *checked, None)), + None => Box::new(MenuItem::new(label, *enabled, None)), + }; + ids.push((item.id().clone(), *id)); + Ok(item) + } + Row::Separator => Ok(Box::new(PredefinedMenuItem::separator())), + Row::Submenu { + label, + enabled, + children, + .. + } => { + let submenu = Submenu::new(label, *enabled); + for child in children { + submenu.append(&*build_row(child, ids)?).map_err(failure)?; + } + Ok(Box::new(submenu)) + } + Row::DetectedData { .. } | Row::WritingTools => { + unreachable!("system items are inserted after native construction") + } + } +} + +/// Keep symbols as native template images so AppKit supplies selection, disabled, +/// dark-mode and display-scale rendering, just as it does for its own menu items. +fn decorate( + menu: *mut AnyObject, + rows: &[Row], + window: &Window, + system_menus: &mut Vec>, + writing_tools: &[Retained], +) -> Result<(), Message> { + let mut index = 0isize; + for row in rows { + if matches!(row, Row::WritingTools) { + if !writing_tools.is_empty() { + unsafe { + let separator: Retained = + msg_send![class!(NSMenuItem), separatorItem]; + let _: () = msg_send![menu, insertItem: &*separator, atIndex: index]; + index += 1; + for item in writing_tools { + let copied: Retained = msg_send![&**item, copy]; + let _: () = msg_send![menu, insertItem: &*copied, atIndex: index]; + index += 1; + } + } + } + continue; + } + if let Row::DetectedData { result, position } = row { + let source = + super::text_services::TextAnchor::new(window, *position)?.data_menu(result)?; + unsafe { + let items: Retained> = msg_send![&*source, itemArray]; + for item in items.iter() { + let copied: Retained = msg_send![&*item, copy]; + let _: () = msg_send![menu, insertItem: &*copied, atIndex: index]; + index += 1; + } + } + system_menus.push(source); + continue; + } + let item: *mut AnyObject = unsafe { msg_send![menu, itemAtIndex: index] }; + let symbol = match row { + Row::Item { symbol, .. } | Row::Submenu { symbol, .. } => *symbol, + _ => None, + }; + if let Some(symbol) = symbol + && let Some(image) = NSImage::imageWithSystemSymbolName_accessibilityDescription( + &NSString::from_str(symbol), + None, + ) + { + image.setTemplate(true); + unsafe { + let _: () = msg_send![item, setImage: &*image]; + } + prefer_visible_image(item); + } + if let Row::Submenu { children, .. } = row { + let submenu: *mut AnyObject = unsafe { msg_send![item, submenu] }; + decorate(submenu, children, window, system_menus, writing_tools)?; + } + if let Row::Item { + label, appearance, .. + } = row + { + decorate_item(item, label, appearance); + } + index += 1; + } + Ok(()) +} + +fn decorate_item(item: *mut AnyObject, label: &str, appearance: &ItemAppearance) { + use objc2_app_kit::{NSFontAttributeName, NSUnderlineStyleAttributeName}; + unsafe { + if let Some(style) = appearance.title_style { + let attributes: Retained = msg_send![class!(NSMutableDictionary), new]; + match style { + TitleStyle::Bold | TitleStyle::Italic => { + let font: Retained = + msg_send![class!(NSFont), menuFontOfSize: 0.0f64]; + let manager: *mut AnyObject = + msg_send![class!(NSFontManager), sharedFontManager]; + let mask = if matches!(style, TitleStyle::Bold) { + 2usize + } else { + 1usize + }; + let font: Retained = + msg_send![manager, convertFont: &*font, toHaveTrait: mask]; + let _: () = + msg_send![&*attributes, setObject: &*font, forKey: NSFontAttributeName]; + } + TitleStyle::Underline => { + let value: Retained = + msg_send![class!(NSNumber), numberWithInteger: 1isize]; + let _: () = msg_send![&*attributes, setObject: &*value, forKey: NSUnderlineStyleAttributeName]; + } + } + let title: objc2::rc::Allocated = + msg_send![class!(NSAttributedString), alloc]; + let title: Retained = msg_send![title, initWithString: &*NSString::from_str(label), attributes: &*attributes]; + let _: () = msg_send![item, setAttributedTitle: &*title]; + } + } +} + +fn prefer_visible_image(item: *mut AnyObject) { + // macOS 27 hides images by default; NSTextView explicitly keeps these + // symbols visible. Earlier releases already display assigned images. + unsafe { + let available: bool = + msg_send![item, respondsToSelector: sel!(setPreferredImageVisibility:)]; + if available { + let _: () = msg_send![item, setPreferredImageVisibility: 1isize]; + } + } +} + +fn failure(detail: impl std::fmt::Display) -> Message { + log::warn!("the context menu could not be displayed: {detail}"); + Message::new("error.native-window-control") +} + +type MenuSelection = Rc>>; + +#[derive(Default)] +struct EventRouter { + active: HashMap, + application: Vec, +} + +impl EventRouter { + fn route(&mut self, event: MenuEvent) { + if let Some((id, selected)) = self.active.get(&event.id) { + selected.set(Some(*id)); + } else { + self.application.push(event); + } + } +} + +thread_local! { + static ROUTER: RefCell = RefCell::new(EventRouter::default()); +} + +/// The only consumer of Muda's global receiver. No borrow spans native tracking. +fn drain_events() { + ROUTER.with(|router| { + let mut router = router.borrow_mut(); + while let Ok(event) = MenuEvent::receiver().try_recv() { + router.route(event); + } + }); +} + +pub(super) fn take_application_events() -> Vec { + drain_events(); + ROUTER.with(|router| std::mem::take(&mut router.borrow_mut().application)) +} + +struct Registration { + ids: Vec, + selected: MenuSelection, +} + +impl Registration { + fn new(items: &[(MenuId, usize)]) -> Self { + let selected = Rc::new(Cell::new(None)); + ROUTER.with(|router| { + let mut router = router.borrow_mut(); + for (id, action) in items { + router + .active + .insert(id.clone(), (*action, selected.clone())); + } + }); + Self { + ids: items.iter().map(|(id, _)| id.clone()).collect(), + selected, + } + } +} + +impl Drop for Registration { + fn drop(&mut self) { + ROUTER.with(|router| { + let mut router = router.borrow_mut(); + for id in &self.ids { + router.active.remove(id); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn routes_context_results_without_consuming_application_events() { + let mut router = EventRouter::default(); + let selected = Rc::new(Cell::new(None)); + let context_id = MenuId::new("context"); + let tray_id = MenuId::new("tray"); + router + .active + .insert(context_id.clone(), (7, selected.clone())); + router.route(MenuEvent { + id: tray_id.clone(), + }); + router.route(MenuEvent { id: context_id }); + assert_eq!(selected.get(), Some(7)); + assert_eq!(router.application.len(), 1); + assert_eq!(router.application[0].id, tray_id); + } + + #[test] + fn nested_menus_keep_results_separate() { + let mut router = EventRouter::default(); + let first = Rc::new(Cell::new(None)); + let second = Rc::new(Cell::new(None)); + router + .active + .insert(MenuId::new("first"), (1, first.clone())); + router + .active + .insert(MenuId::new("second"), (2, second.clone())); + router.route(MenuEvent { + id: MenuId::new("second"), + }); + assert_eq!(first.get(), None); + assert_eq!(second.get(), Some(2)); + } + + #[test] + fn menu_coordinates_respect_native_view_orientation() { + let bounds = NSRect { + origin: NSPoint { x: 2., y: 3. }, + size: NSPoint { x: 600., y: 400. }, + }; + let position = gpui::point(gpui::px(20.), gpui::px(30.)); + let flipped = view_point(position, bounds, true); + assert_eq!((flipped.x, flipped.y), (22., 33.)); + let unflipped = view_point(position, bounds, false); + assert_eq!((unflipped.x, unflipped.y), (22., 373.)); + } +} diff --git a/crates/markraft-app/src/platform/context_menu/caption.rs b/crates/markraft-app/src/platform/context_menu/caption.rs new file mode 100644 index 00000000..64d2bc16 --- /dev/null +++ b/crates/markraft-app/src/platform/context_menu/caption.rs @@ -0,0 +1,67 @@ +//! The selection excerpt shared by Look Up and Translate. + +use objc2::AllocAnyThread; +use objc2_foundation::{NSArray, NSCharacterSet, NSString}; +use objc2_natural_language::{NLTagSchemeTokenType, NLTagger, NLTokenUnit}; + +pub(crate) fn selection_label(text: &str) -> String { + // NSTextView replaces each line separator and NBSP independently. Tabs and + // repeated interior spaces remain visible; split_whitespace loses them. + let whitespace = NSCharacterSet::whitespaceAndNewlineCharacterSet(); + let text = NSString::from_str(text) + .componentsSeparatedByCharactersInSet(&NSCharacterSet::newlineCharacterSet()) + .componentsJoinedByString(&NSString::from_str(" ")) + .stringByReplacingOccurrencesOfString_withString( + &NSString::from_str("\u{a0}"), + &NSString::from_str(" "), + ) + .stringByTrimmingCharactersInSet(&whitespace); + const LIMIT: usize = 30; + if text.length() <= LIMIT { + return text.to_string(); + } + + // NLTagger matches the native menu's linguistic boundaries, including + // contractions ("do" / "n't"). NSString/NLTokenizer word enumeration does + // not. A single token longer than the limit falls back to the length cap. + let mut end = LIMIT; + unsafe { + if let Some(scheme) = NLTagSchemeTokenType { + let tagger = + NLTagger::initWithTagSchemes(NLTagger::alloc(), &NSArray::from_slice(&[scheme])); + tagger.setString(Some(&text)); + let token = tagger.tokenRangeAtIndex_unit(LIMIT, NLTokenUnit::Word); + if token.location > 0 && token.location < LIMIT { + end = token.location; + } + } + } + // Keep valid Unicode even if the fallback cap lands inside a surrogate pair. + let mut units = 0; + let mut label: String = text + .to_string() + .chars() + .take_while(|ch| { + units += ch.len_utf16(); + units <= end + }) + .collect(); + label = NSString::from_str(&label) + .stringByTrimmingCharactersInSet(&whitespace) + .to_string(); + label.push('…'); + label +} + +#[cfg(test)] +mod tests { + #[test] + fn captions_match_native_text_view_reference() { + let cases: Vec<(String, String)> = + serde_json::from_str(include_str!("fixtures/captions.json")).unwrap(); + assert!(cases.len() >= 30); + for (input, expected) in cases { + assert_eq!(super::selection_label(&input), expected, "{input:?}"); + } + } +} diff --git a/crates/markraft-app/src/platform/context_menu/fixtures/captions.json b/crates/markraft-app/src/platform/context_menu/fixtures/captions.json new file mode 100644 index 00000000..b08b0b6f --- /dev/null +++ b/crates/markraft-app/src/platform/context_menu/fixtures/captions.json @@ -0,0 +1,222 @@ +[ + [ + "Paragraph style QA\nSecond paragraph.", + "Paragraph style QA Second…" + ], + [ + "one two three four five six seven eight nine ten", + "one two three four five six…" + ], + [ + "The quick brown fox jumps over the lazy dog", + "The quick brown fox jumps over…" + ], + [ + " hello\t\nworld ", + "hello\t world" + ], + [ + "one\n\ntwo", + "one two" + ], + [ + " a b ", + "a b" + ], + [ + "a\t\tb", + "a\t\tb" + ], + [ + "a\r\nb", + "a b" + ], + [ + "a\u000bb", + "a b" + ], + [ + "a\fb", + "a b" + ], + [ + "a…b", + "a b" + ], + [ + "a
b", + "a b" + ], + [ + "a
b", + "a b" + ], + [ + "a b", + "a b" + ], + [ + "a b", + "a b" + ], + [ + "a b", + "a b" + ], + [ + "a b", + "a b" + ], + [ + "a​b", + "a​b" + ], + [ + "abc-def-ghi-jkl-mno-pqr-stu-vwx-yz", + "abc-def-ghi-jkl-mno-pqr-stu-…" + ], + [ + "abc\/def\/ghi\/jkl\/mno\/pqr\/stu\/vwx\/yz", + "abc\/def\/ghi\/jkl\/mno\/pqr\/stu\/…" + ], + [ + "abc,def,ghi,jkl,mno,pqr,stu,vwx,yz", + "abc,def,ghi,jkl,mno,pqr,stu,…" + ], + [ + "abc—def—ghi—jkl—mno—pqr—stu—vwx—yz", + "abc—def—ghi—jkl—mno—pqr—stu—…" + ], + [ + "这是一个用于比较原生右键菜单标题截断规则的中文字符串及更多文字", + "这是一个用于比较原生右键菜单标题截断规则的中文字符串及更多…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ], + [ + "éééééééééééééééééééé", + "ééééééééééééééé…" + ], + [ + "👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦", + "👨‍👩‍👧‍👦👨‍👩‍👧‍👦…" + ], + [ + "12345678901234567890123456789😀X", + "12345678901234567890123456789…" + ], + [ + "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀X", + "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀…" + ], + [ + "a b", + "a…" + ], + [ + "é́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́́ more", + "é́́́́́́́́́́́́́́́́́́́́́́́́́́́́…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaa don't…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaaa don't…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaaaa do…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaaaaa do…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaaaaaa do…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa don't stop", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这…" + ], + [ + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲这是一个测试句子", + "甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲甲…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaa xyz…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaaa xyz…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaaaa xyz…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaaaaa xyz…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ], + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa xyz abcdef", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + ] +] diff --git a/crates/markraft-app/src/platform/context_menu/fixtures/captions.swift b/crates/markraft-app/src/platform/context_menu/fixtures/captions.swift new file mode 100644 index 00000000..f2db556e --- /dev/null +++ b/crates/markraft-app/src/platform/context_menu/fixtures/captions.swift @@ -0,0 +1,46 @@ +// Run on an English macOS installation: +// swift crates/markraft-app/src/platform/context_menu/fixtures/captions.swift > crates/markraft-app/src/platform/context_menu/fixtures/captions.json +// Synthetic, offscreen NSTextView menus only; no popup, clipboard or preferences. +import AppKit + +let app = NSApplication.shared +let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) +let view = NSTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 400)) +window.contentView = view +let samples = [ + "Paragraph style QA\nSecond paragraph.", + "one two three four five six seven eight nine ten", + "The quick brown fox jumps over the lazy dog", + " hello\t\nworld ", "one\n\ntwo", " a b ", "a\t\tb", + "a\r\nb", "a\u{000B}b", "a\u{000C}b", "a\u{0085}b", + "a\u{2028}b", "a\u{2029}b", "a\u{2003}b", "a\u{00A0}b", + "a\u{2007}b", "a\u{202F}b", "a\u{200B}b", + "abc-def-ghi-jkl-mno-pqr-stu-vwx-yz", + "abc/def/ghi/jkl/mno/pqr/stu/vwx/yz", + "abc,def,ghi,jkl,mno,pqr,stu,vwx,yz", + "abc—def—ghi—jkl—mno—pqr—stu—vwx—yz", + "这是一个用于比较原生右键菜单标题截断规则的中文字符串及更多文字", + String(repeating: "a", count: 30), String(repeating: "a", count: 31), + String(repeating: "e\u{301}", count: 20), + String(repeating: "👨‍👩‍👧‍👦", count: 5), + "12345678901234567890123456789😀X", String(repeating: "😀", count: 15) + "X", + "a" + String(repeating: " ", count: 40) + "b", + "e" + String(repeating: "\u{301}", count: 40) + " more", +] + (23...30).map { String(repeating: "a", count: $0) + " don't stop" } + + (23...30).map { String(repeating: "甲", count: $0) + "这是一个测试句子" } + + (23...30).map { String(repeating: "a", count: $0) + " xyz abcdef" } + +var cases: [[String]] = [] +for sample in samples { + view.string = sample + view.setSelectedRange(NSRange(location: 0, length: (sample as NSString).length)) + let event = NSEvent.mouseEvent(with: .rightMouseDown, location: NSPoint(x: 8, y: 380), + modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: 1)! + let title = view.menu(for: event)!.items.first { $0.title.hasPrefix("Look Up “") }!.title + let caption = String(title.dropFirst("Look Up “".count).dropLast()) + cases.append([sample, caption]) +} +let data = try JSONSerialization.data(withJSONObject: cases, options: [.prettyPrinted, .sortedKeys]) +print(String(decoding: data, as: UTF8.self)) diff --git a/crates/markraft-app/src/platform/native_presentation.swift b/crates/markraft-app/src/platform/native_presentation.swift new file mode 100644 index 00000000..bbf58e13 --- /dev/null +++ b/crates/markraft-app/src/platform/native_presentation.swift @@ -0,0 +1,24 @@ +import AppKit + +// Keep native presentations outside the editor's virtual accessibility tree. +// GPUI's AccessKit adapter remains attached to its original content view; this +// ordinary AppKit container exposes that view and native hosts as siblings. +// The window owns this boundary for its lifetime, independently of any session. +@MainActor +final class NativePresentationRoot: NSView { + static func containing(_ view: NSView) -> NativePresentationRoot? { + guard let window = view.window, let content = window.contentView else { return nil } + if let root = content as? NativePresentationRoot { return root } + + let responder = window.firstResponder + let root = NativePresentationRoot(frame: content.frame) + window.contentView = root + content.frame = root.bounds + content.autoresizingMask = [.width, .height] + root.addSubview(content) + // Changing a window's content view may clear its first responder. + // Restore the existing editor responder, not a second text input view. + if let responder { window.makeFirstResponder(responder) } + return root + } +} diff --git a/crates/markraft-app/src/platform/text_checking.rs b/crates/markraft-app/src/platform/text_checking.rs new file mode 100644 index 00000000..63b3edfb --- /dev/null +++ b/crates/markraft-app/src/platform/text_checking.rs @@ -0,0 +1,575 @@ +//! Native text checking with explicit document lifetimes and scalar-indexed results. + +use crate::locale::Message; +use objc2::{MainThreadMarker, class, msg_send, rc::Retained, runtime::AnyObject}; +use objc2_foundation::{ + NSArray, NSDataDetector, NSDictionary, NSGrammarCorrections, NSGrammarRange, + NSGrammarUserDescription, NSMatchingOptions, NSRange, NSString, NSTextCheckingResult, + NSTextCheckingType, +}; +use std::{ops::Range, ptr}; + +const DATA_DETECTOR_TYPES: u64 = NSTextCheckingType::Date.0 + | NSTextCheckingType::Address.0 + | NSTextCheckingType::PhoneNumber.0 + | NSTextCheckingType::TransitInformation.0; + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CheckOptions { + pub spelling: bool, + pub grammar: bool, + pub quotes: bool, + pub dashes: bool, + pub replacements: bool, + pub correction: bool, + pub links: bool, + pub data_detectors: bool, +} + +impl CheckOptions { + pub(super) fn mask(self) -> u64 { + [ + (self.spelling, NSTextCheckingType::Spelling), + (self.grammar, NSTextCheckingType::Grammar), + (self.quotes, NSTextCheckingType::Quote), + (self.dashes, NSTextCheckingType::Dash), + (self.replacements, NSTextCheckingType::Replacement), + (self.correction, NSTextCheckingType::Correction), + (self.links, NSTextCheckingType::Link), + (self.data_detectors, NSTextCheckingType::Date), + (self.data_detectors, NSTextCheckingType::Address), + (self.data_detectors, NSTextCheckingType::PhoneNumber), + (self.data_detectors, NSTextCheckingType::TransitInformation), + ] + .into_iter() + .filter(|(enabled, _)| *enabled) + .fold(0, |mask, (_, kind)| mask | kind.0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CheckKind { + Spelling, + Grammar, + Quote, + Dash, + Replacement, + Correction, + Link, + Date, + Address, + Phone, + Transit, +} + +/// An immutable detector result together with the exact string it checked. +/// The native object includes metadata required by AppKit's data actions. +#[derive(Clone)] +pub(crate) struct DetectedData { + pub(super) result: Retained, + pub(super) text: Retained, +} + +impl std::fmt::Debug for DetectedData { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DetectedData") + .field("range", &self.result.range()) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Debug)] +pub(crate) struct TextCheck { + pub kind: CheckKind, + /// Unicode scalar offsets, never UTF-8 bytes or UTF-16 code units. + pub range: Range, + pub replacement: Option, + /// Grammar corrections. Spelling guesses come from `SpellDocument::guesses`. + pub suggestions: Vec, + pub detail: Option, + pub url: Option, + /// Preserve the original detector result for the system's data menu. + pub data: Option, +} + +/// Keep one instance per editor document so ignored words do not leak between notes. +pub(crate) struct SpellDocument { + checker: Retained, + detector: Retained, + tag: isize, +} + +impl SpellDocument { + pub(crate) fn new() -> Result { + MainThreadMarker::new().ok_or_else(failure)?; + let checker: Option> = + unsafe { msg_send![class!(NSSpellChecker), sharedSpellChecker] }; + let detector = NSDataDetector::dataDetectorWithTypes_error(DATA_DETECTOR_TYPES) + .map_err(|_| failure())?; + let checker = checker.ok_or_else(failure)?; + let tag = unsafe { msg_send![class!(NSSpellChecker), uniqueSpellDocumentTag] }; + Ok(Self { + checker, + detector, + tag, + }) + } + + /// Check the provided prose snapshot. Callers should exclude code/markup and + /// keep this bounded to a paragraph or explicit user-requested selection. + pub(crate) fn check(&self, text: &str, options: CheckOptions) -> Vec { + if MainThreadMarker::new().is_none() || text.is_empty() || options.mask() == 0 { + return Vec::new(); + } + let string = NSString::from_str(text); + let native_range = NSRange::new(0, text.encode_utf16().count()); + let mut results = Vec::new(); + let checking_types = options.mask() & !DATA_DETECTOR_TYPES; + if checking_types != 0 { + let checked: Retained> = unsafe { + msg_send![&*self.checker, + checkString: &*string, + range: native_range, + types: checking_types, + options: ptr::null::(), + inSpellDocumentWithTag: self.tag, + orthography: ptr::null_mut::<*mut AnyObject>(), + wordCount: ptr::null_mut::()] + }; + results.extend(checked.iter()); + } + if options.data_detectors { + // NSSpellChecker omits phone results on some macOS versions even + // when requested. NSDataDetector supplies every supported data kind + // and preserves the native metadata that AppKit's menus require. + let detected = self.detector.matchesInString_options_range( + &string, + NSMatchingOptions::empty(), + native_range, + ); + results.extend(detected.iter()); + } + let mut checks = Vec::new(); + for result in results { + let Some(kind) = check_kind(result.resultType()) else { + continue; + }; + let native_range = result.range(); + let Some(range) = utf16_to_scalar_range(text, native_range) else { + continue; + }; + if kind == CheckKind::Grammar { + // Grammar result ranges cover a sentence; each detail identifies + // the actual questionable phrase relative to that sentence. + if let Some(details) = result.grammarDetails() { + for detail in details.iter() { + let local: NSRange = unsafe { detail.objectForKey(NSGrammarRange) } + .map(|value| unsafe { msg_send![&*value, rangeValue] }) + .unwrap_or(NSRange::new(0, native_range.length)); + let Some(absolute) = nested_range(native_range, local) else { + continue; + }; + let Some(range) = utf16_to_scalar_range(text, absolute) else { + continue; + }; + let suggestions: Option>> = + unsafe { msg_send![&*detail, objectForKey: NSGrammarCorrections] }; + let description: Option> = + unsafe { msg_send![&*detail, objectForKey: NSGrammarUserDescription] }; + checks.push(TextCheck { + kind, + range, + replacement: None, + suggestions: suggestions + .map(|items| items.iter().map(|item| item.to_string()).collect()) + .unwrap_or_default(), + detail: description.map(|value| value.to_string()), + url: None, + data: None, + }); + } + } + continue; + } + let replacement = match kind { + CheckKind::Quote + | CheckKind::Dash + | CheckKind::Replacement + | CheckKind::Correction => { + result.replacementString().map(|value| value.to_string()) + } + _ => None, + }; + let url = if kind == CheckKind::Link { + result + .URL() + .and_then(|url| url.absoluteString()) + .map(|value| value.to_string()) + } else { + None + }; + let data = matches!( + kind, + CheckKind::Date | CheckKind::Address | CheckKind::Phone | CheckKind::Transit + ) + .then(|| DetectedData { + result: result.clone(), + text: string.clone(), + }); + checks.push(TextCheck { + kind, + range, + replacement, + suggestions: Vec::new(), + detail: None, + url, + data, + }); + } + checks.sort_by_key(|check| (check.range.start, check.range.end)); + checks + } + + pub(crate) fn tag(&self) -> isize { + self.tag + } + + pub(crate) fn panel_visible(&self, panel: super::checking_panel::CheckingPanel) -> bool { + use super::checking_panel::CheckingPanel; + unsafe { + let panel: Retained = match panel { + CheckingPanel::Spelling => msg_send![&*self.checker, spellingPanel], + CheckingPanel::Substitutions => msg_send![&*self.checker, substitutionsPanel], + }; + msg_send![&*panel, isVisible] + } + } + + pub(crate) fn hide_panel(&self, panel: super::checking_panel::CheckingPanel) { + use super::checking_panel::CheckingPanel; + unsafe { + let panel: Retained = match panel { + CheckingPanel::Spelling => msg_send![&*self.checker, spellingPanel], + CheckingPanel::Substitutions => msg_send![&*self.checker, substitutionsPanel], + }; + let _: () = msg_send![&*panel, orderOut: ptr::null::()]; + } + } + + pub(crate) fn show_panel(&self, panel: super::checking_panel::CheckingPanel) { + use super::checking_panel::CheckingPanel; + unsafe { + let panel: Retained = match panel { + CheckingPanel::Spelling => msg_send![&*self.checker, spellingPanel], + CheckingPanel::Substitutions => msg_send![&*self.checker, substitutionsPanel], + }; + let _: () = msg_send![&*self.checker, updatePanels]; + let _: () = msg_send![&*panel, makeKeyAndOrderFront: ptr::null::()]; + } + } + + pub(crate) fn update_panel(&self, word: &str) { + unsafe { + let _: () = msg_send![&*self.checker, updateSpellingPanelWithMisspelledWord: &*NSString::from_str(word)]; + let _: () = msg_send![&*self.checker, updatePanels]; + } + } + + pub(crate) fn update_grammar_panel( + &self, + phrase: &str, + description: &str, + suggestions: &[String], + ) { + let text = NSString::from_str(phrase); + let description = NSString::from_str(description); + let suggestions = NSArray::from_retained_slice( + &suggestions + .iter() + .map(|text| NSString::from_str(text)) + .collect::>(), + ); + unsafe { + let range: Retained = msg_send![class!(NSValue), valueWithRange: NSRange::new(0, phrase.encode_utf16().count())]; + let detail = NSDictionary::::from_slices( + &[ + NSGrammarRange, + NSGrammarUserDescription, + NSGrammarCorrections, + ], + &[&*range, &*description, &*suggestions], + ); + let _: () = msg_send![&*self.checker, updateSpellingPanelWithGrammarString: &*text, detail: &*detail]; + } + } + + /// Spelling guesses for one misspelled word of `text`, by Unicode scalar + /// range. `check` leaves them out: each word costs a dictionary search, so + /// only the word the user asks about pays for it. + pub(crate) fn guesses(&self, text: &str, range: Range) -> Vec { + if MainThreadMarker::new().is_none() { + return Vec::new(); + } + let Some(range) = scalar_to_utf16_range(text, range) else { + return Vec::new(); + }; + let text = NSString::from_str(text); + let guesses: Option>> = unsafe { + msg_send![&*self.checker, guessesForWordRange: range, inString: &*text, + language: ptr::null::(), inSpellDocumentWithTag: self.tag] + }; + guesses + .map(|items| items.iter().map(|item| item.to_string()).collect()) + .unwrap_or_default() + } + + pub(crate) fn ignore(&self, word: &str) { + if MainThreadMarker::new().is_none() || word.is_empty() { + return; + } + unsafe { + let _: () = msg_send![&*self.checker, ignoreWord: &*NSString::from_str(word), inSpellDocumentWithTag: self.tag]; + } + } + + pub(crate) fn learn(&self, word: &str) { + if MainThreadMarker::new().is_none() || word.is_empty() { + return; + } + unsafe { + let _: () = msg_send![&*self.checker, learnWord: &*NSString::from_str(word)]; + } + } + + pub(crate) fn has_learned(&self, word: &str) -> bool { + if MainThreadMarker::new().is_none() || word.is_empty() { + return false; + } + unsafe { msg_send![&*self.checker, hasLearnedWord: &*NSString::from_str(word)] } + } + + pub(crate) fn unlearn(&self, word: &str) { + if MainThreadMarker::new().is_none() || word.is_empty() { + return; + } + unsafe { + let _: () = msg_send![&*self.checker, unlearnWord: &*NSString::from_str(word)]; + } + } +} + +impl Drop for SpellDocument { + fn drop(&mut self) { + if MainThreadMarker::new().is_some() { + unsafe { + let _: () = msg_send![&*self.checker, closeSpellDocumentWithTag: self.tag]; + } + } + } +} + +fn check_kind(value: NSTextCheckingType) -> Option { + match value { + NSTextCheckingType::Spelling => Some(CheckKind::Spelling), + NSTextCheckingType::Grammar => Some(CheckKind::Grammar), + NSTextCheckingType::Quote => Some(CheckKind::Quote), + NSTextCheckingType::Dash => Some(CheckKind::Dash), + NSTextCheckingType::Replacement => Some(CheckKind::Replacement), + NSTextCheckingType::Correction => Some(CheckKind::Correction), + NSTextCheckingType::Link => Some(CheckKind::Link), + NSTextCheckingType::Date => Some(CheckKind::Date), + NSTextCheckingType::Address => Some(CheckKind::Address), + NSTextCheckingType::PhoneNumber => Some(CheckKind::Phone), + NSTextCheckingType::TransitInformation => Some(CheckKind::Transit), + _ => None, + } +} + +fn nested_range(parent: NSRange, child: NSRange) -> Option { + if child.location.checked_add(child.length)? > parent.length { + return None; + } + Some(NSRange::new( + parent.location.checked_add(child.location)?, + child.length, + )) +} + +/// Reject ranges splitting a surrogate pair, out-of-bounds locations and NSNotFound. +fn utf16_to_scalar_range(text: &str, range: NSRange) -> Option> { + let end = range.location.checked_add(range.length)?; + let mut start_scalar = (range.location == 0).then_some(0); + let mut end_scalar = (end == 0).then_some(0); + let mut utf16 = 0; + for (index, character) in text.chars().enumerate() { + utf16 += character.len_utf16(); + if utf16 == range.location { + start_scalar = Some(index + 1); + } + if utf16 == end { + end_scalar = Some(index + 1); + } + } + Some(start_scalar?..end_scalar?) +} + +fn scalar_to_utf16_range(text: &str, range: Range) -> Option { + let (mut location, mut end) = ( + (range.start == 0).then_some(0), + (range.end == 0).then_some(0), + ); + let mut utf16 = 0; + for (index, character) in text.chars().enumerate() { + utf16 += character.len_utf16(); + if index + 1 == range.start { + location = Some(utf16); + } + if index + 1 == range.end { + end = Some(utf16); + } + } + let location = location?; + Some(NSRange::new(location, end?.checked_sub(location)?)) +} + +fn failure() -> Message { + Message::new("error.native-window-control") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_utf16_without_confusing_bytes_or_graphemes() { + let text = "a😀中文e\u{301}"; + assert_eq!(utf16_to_scalar_range(text, NSRange::new(1, 2)), Some(1..2)); + assert_eq!(utf16_to_scalar_range(text, NSRange::new(3, 2)), Some(2..4)); + assert_eq!(utf16_to_scalar_range(text, NSRange::new(5, 2)), Some(4..6)); + assert_eq!(utf16_to_scalar_range(text, NSRange::new(7, 0)), Some(6..6)); + assert_eq!(utf16_to_scalar_range("", NSRange::new(0, 0)), Some(0..0)); + } + + #[test] + fn scalar_ranges_map_back_to_the_utf16_units_they_came_from() { + let text = "a😀中文e\u{301}"; + for native in [ + NSRange::new(1, 2), + NSRange::new(3, 2), + NSRange::new(5, 2), + NSRange::new(7, 0), + ] { + let scalar = utf16_to_scalar_range(text, native).unwrap(); + assert_eq!(scalar_to_utf16_range(text, scalar), Some(native)); + } + assert_eq!(scalar_to_utf16_range(text, 5..7), None); + assert_eq!(scalar_to_utf16_range(text, 7..7), None); + } + + #[test] + fn rejects_invalid_utf16_boundaries_and_overflow() { + let text = "a😀z"; + assert_eq!(utf16_to_scalar_range(text, NSRange::new(2, 1)), None); + assert_eq!(utf16_to_scalar_range(text, NSRange::new(1, 1)), None); + assert_eq!(utf16_to_scalar_range(text, NSRange::new(4, 1)), None); + assert_eq!( + utf16_to_scalar_range(text, NSRange::new(usize::MAX, 0)), + None + ); + assert_eq!( + utf16_to_scalar_range(text, NSRange::new(usize::MAX, 1)), + None + ); + } + + #[test] + fn grammar_details_are_relative_to_the_parent_sentence() { + assert_eq!( + nested_range(NSRange::new(5, 10), NSRange::new(2, 3)), + Some(NSRange::new(7, 3)) + ); + assert_eq!(nested_range(NSRange::new(5, 10), NSRange::new(9, 2)), None); + assert_eq!( + nested_range(NSRange::new(usize::MAX, 10), NSRange::new(2, 3)), + None + ); + } + #[test] + fn data_detection_mask_covers_all_supported_contextual_kinds() { + assert_eq!(CheckOptions::default().mask(), 0); + assert_eq!( + CheckOptions { + data_detectors: true, + ..Default::default() + } + .mask(), + DATA_DETECTOR_TYPES + ); + assert_eq!( + CheckOptions { + links: true, + data_detectors: true, + ..Default::default() + } + .mask(), + DATA_DETECTOR_TYPES | NSTextCheckingType::Link.0 + ); + } + + #[test] + fn real_data_detector_ranges_map_dates_addresses_phones_and_flights() { + let detector = NSDataDetector::dataDetectorWithTypes_error(DATA_DETECTOR_TYPES) + .expect("system data detector"); + for (text, native_kind, kind, expected) in [ + ( + "😀 Tomorrow at 3pm", + NSTextCheckingType::Date, + CheckKind::Date, + 2..17, + ), + ( + "😀 1 Apple Park Way, Cupertino, CA 95014", + NSTextCheckingType::Address, + CheckKind::Address, + 2..39, + ), + ( + "😀 +1 (415) 555-0123", + NSTextCheckingType::PhoneNumber, + CheckKind::Phone, + 2..19, + ), + ( + "😀 Flight AA 123", + NSTextCheckingType::TransitInformation, + CheckKind::Transit, + 9..15, + ), + ] { + let string = NSString::from_str(text); + let results = detector.matchesInString_options_range( + &string, + NSMatchingOptions::empty(), + NSRange::new(0, text.encode_utf16().count()), + ); + let result = results + .iter() + .find(|result| result.resultType() == native_kind) + .expect("real detected result"); + assert_eq!(check_kind(result.resultType()), Some(kind)); + assert_eq!(utf16_to_scalar_range(text, result.range()), Some(expected)); + // Retain the original object and original input together; never + // rebuild a synthetic result merely from its visible fields. + let snapshot = DetectedData { + result: result.clone(), + text: string.clone(), + }; + assert_eq!( + Retained::as_ptr(&snapshot.result), + Retained::as_ptr(&result) + ); + assert_eq!(snapshot.text.to_string(), text); + } + } +} diff --git a/crates/markraft-app/src/platform/text_requestor.rs b/crates/markraft-app/src/platform/text_requestor.rs new file mode 100644 index 00000000..49b99b58 --- /dev/null +++ b/crates/markraft-app/src/platform/text_requestor.rs @@ -0,0 +1,427 @@ +//! AppKit text services for the GPUI editor, using the public pasteboard bridge. +//! +//! AppKit retains no ownership of a responder's `nextResponder`. A session owns +//! the inserted responder and restores the original chain before releasing it. +//! The caller must keep the session alive while a service is running, and must +//! validate its editor snapshot before applying the returned replacement. + +use super::native_view; +use crate::locale::Message; +use futures_channel::oneshot; +use objc2::rc::Retained; +use objc2::runtime::{AnyObject, NSObjectProtocol}; +use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; +use objc2_app_kit::{ + NSApplication, NSPasteboard, NSPasteboardType, NSResponder, NSServicesMenuRequestor, +}; +use objc2_foundation::{NSArray, NSString}; +use std::cell::RefCell; + +mod writing_tools; + +const STRING_TYPE: &str = "public.utf8-plain-text"; + +/// Native entry points have different formatting contracts. Keep the origin +/// through asynchronous delivery so the editor can apply the matching policy. +#[derive(Debug, PartialEq)] +pub(crate) enum TextReplacement { + PlainText(String), + PreserveStyles(String), +} + +pub(crate) struct TextServiceSnapshot { + pub text: String, + pub editable: bool, + pub prose: bool, +} + +struct Selection { + text: String, + active: bool, + editable: bool, + writing_tools_requested: bool, + replacement: Option>, +} + +impl Selection { + fn supports_writing_tools(&self, prose: bool) -> bool { + self.active && self.editable && prose + } + + fn supports(&self, send_type: Option<&str>, return_type: Option<&str>) -> bool { + self.active + && (send_type.is_none() || !self.text.is_empty()) + && supports_types(send_type, return_type) + && (return_type.is_none() || self.editable) + } + + fn replace_plain_text(&mut self, text: String) -> bool { + // AppKit can deliver a Writing Tools result through Services even after + // requesting coordinator context. Keep that operation's formatting + // contract regardless of which native callback returns its text. + let replacement = if self.writing_tools_requested { + TextReplacement::PreserveStyles(text) + } else { + TextReplacement::PlainText(text) + }; + self.replace(replacement) + } + + fn replace(&mut self, replacement: TextReplacement) -> bool { + if !self.active || !self.editable { + return false; + } + self.active = false; + self.replacement + .take() + .is_some_and(|sender| sender.send(replacement).is_ok()) + } +} + +fn supports_types(send_type: Option<&str>, return_type: Option<&str>) -> bool { + (send_type.is_some() || return_type.is_some()) + && send_type.is_none_or(is_string_type) + && return_type.is_none_or(is_string_type) +} + +fn is_string_type(value: &str) -> bool { + matches!(value, STRING_TYPE | "NSStringPboardType") +} + +define_class!( + #[unsafe(super(NSResponder))] + #[thread_kind = MainThreadOnly] + #[ivars = RefCell] + struct TextRequestor; + + unsafe impl NSObjectProtocol for TextRequestor {} + + unsafe impl NSServicesMenuRequestor for TextRequestor { + #[unsafe(method(writeSelectionToPasteboard:types:))] + fn write_selection(&self, pasteboard: &NSPasteboard, types: &NSArray) -> bool { + self.write_text(pasteboard, types) + } + + #[unsafe(method(readSelectionFromPasteboard:))] + fn read_selection(&self, pasteboard: &NSPasteboard) -> bool { + self.read_text(pasteboard) + } + } + + impl TextRequestor { + #[unsafe(method_id(validRequestorForSendType:returnType:))] + fn valid_requestor( + &self, + send_type: Option<&NSString>, + return_type: Option<&NSString>, + ) -> Option> { + let send_name = send_type.map(ToString::to_string); + let return_name = return_type.map(ToString::to_string); + if self.ivars().borrow().supports(send_name.as_deref(), return_name.as_deref()) { + // The returned requestor has NSObject ownership semantics. + unsafe { Retained::retain((self as *const Self).cast_mut().cast()) } + } else { + // Preserve Services support belonging to other responders. + unsafe { msg_send![super(self), validRequestorForSendType: send_type, returnType: return_type] } + } + } + } +); + +impl TextRequestor { + fn write_text(&self, pasteboard: &NSPasteboard, types: &NSArray) -> bool { + let selection = self.ivars().borrow(); + if !selection.active || selection.text.is_empty() { + return false; + } + let supported: Vec<_> = types + .iter() + .filter(|kind| is_string_type(&kind.to_string())) + .collect(); + if supported.is_empty() { + return false; + } + // Services supply a private pasteboard; never alter the user's + // general clipboard to transfer the selected text. + let types = NSArray::from_retained_slice(&supported); + unsafe { pasteboard.declareTypes_owner(&types, None) }; + let text = NSString::from_str(&selection.text); + supported + .iter() + .all(|kind| pasteboard.setString_forType(&text, kind)) + } + + fn read_text(&self, pasteboard: &NSPasteboard) -> bool { + if !self.ivars().borrow().active || !self.ivars().borrow().editable { + return false; + } + read_replacement(pasteboard) + .is_some_and(|text| self.ivars().borrow_mut().replace_plain_text(text)) + } +} + +fn read_replacement(pasteboard: &NSPasteboard) -> Option { + pasteboard + .stringForType(&NSString::from_str(STRING_TYPE)) + .or_else(|| pasteboard.stringForType(&NSString::from_str("NSStringPboardType"))) + .map(|text| text.to_string()) +} + +/// Own this for the entire asynchronous service interaction, not just menu tracking. +/// Dropping it cancels pending writeback and restores the responder chain. +pub(crate) struct TextServiceSession { + view: Retained, + previous: Option>, + requestor: Retained, + writing_tools: Option, +} + +impl TextServiceSession { + pub(crate) fn attach( + window: &gpui::Window, + snapshot: TextServiceSnapshot, + position: gpui::Point, + line_height: gpui::Pixels, + ) -> Result<(Self, oneshot::Receiver), Message> { + let mtm = + MainThreadMarker::new().ok_or_else(|| Message::new("error.native-window-control"))?; + let view = unsafe { Retained::retain(native_view(window)?.cast::()) } + .ok_or_else(|| Message::new("error.native-window-control"))?; + let types = NSArray::from_retained_slice(&[ + NSString::from_str(STRING_TYPE), + NSString::from_str("NSStringPboardType"), + ]); + NSApplication::sharedApplication(mtm) + .registerServicesMenuSendTypes_returnTypes(&types, &types); + let previous = unsafe { view.nextResponder() }; + let (sender, receiver) = oneshot::channel(); + let requestor = TextRequestor::alloc(mtm).set_ivars(RefCell::new(Selection { + text: snapshot.text, + active: true, + editable: snapshot.editable, + writing_tools_requested: false, + replacement: Some(sender), + })); + let requestor: Retained = unsafe { msg_send![super(requestor), init] }; + unsafe { + requestor.setNextResponder(previous.as_deref()); + view.setNextResponder(Some(&requestor)); + } + // A coordinator represents one replaceable prose snapshot. Other + // selections retain their existing send-only/read-only Services support. + let native_writing = requestor + .ivars() + .borrow() + .supports_writing_tools(snapshot.prose); + let writing_tools = native_writing + .then(|| writing_tools::Snapshot::new(&view, &requestor, position, line_height)) + .flatten(); + Ok(( + Self { + view, + previous, + requestor, + writing_tools, + }, + receiver, + )) + } + + pub(crate) fn cancel(&mut self) { + self.writing_tools.take(); + { + let mut selection = self.requestor.ivars().borrow_mut(); + selection.active = false; + selection.replacement.take(); + } + // Another owner may have changed the chain while a native service was + // open. Restore only the link that this session still owns. + let current = unsafe { self.view.nextResponder() }; + if current + .as_deref() + .is_some_and(|current| std::ptr::eq(current, &*self.requestor as &NSResponder)) + { + unsafe { self.view.setNextResponder(self.previous.as_deref()) }; + } + } + + /// Selected text receives AppKit's standard group through Services. Empty + /// carets need explicit items. Both request context from the coordinator, + /// but AppKit can return the result through either native bridge. + pub(crate) fn writing_tools_items(&self) -> Option<&[Retained]> { + self.writing_tools.as_ref().map(|snapshot| { + let services = self + .requestor + .ivars() + .borrow() + .supports(Some(STRING_TYPE), None); + if services { &[][..] } else { snapshot.items() } + }) + } +} + +impl Drop for TextServiceSession { + fn drop(&mut self) { + self.cancel(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pasteboard_results_preserve_styles_only_after_writing_tools_requests_context() { + for writing_tools_requested in [false, true] { + let (sender, mut receiver) = oneshot::channel(); + let mut selection = Selection { + text: "This are a smple sentnce.".into(), + active: true, + editable: true, + writing_tools_requested, + replacement: Some(sender), + }; + let corrected = "This is a simple sentence.".to_owned(); + assert!(selection.replace_plain_text(corrected.clone())); + let expected = if writing_tools_requested { + TextReplacement::PreserveStyles(corrected) + } else { + TextReplacement::PlainText(corrected) + }; + assert_eq!(receiver.try_recv().unwrap(), Some(expected)); + assert!(!selection.replace_plain_text("Late result".into())); + } + } + + #[test] + fn writing_tools_pasteboard_results_respect_retired_and_readonly_snapshots() { + for (active, editable) in [(false, true), (true, false)] { + let (sender, mut receiver) = oneshot::channel(); + let mut selection = Selection { + text: "Original".into(), + active, + editable, + writing_tools_requested: true, + replacement: Some(sender), + }; + assert!(!selection.replace_plain_text("Corrected".into())); + assert_eq!(receiver.try_recv().unwrap(), None); + } + } + + #[test] + fn native_writing_tools_requires_editable_prose_without_changing_services() { + let (sender, _receiver) = oneshot::channel(); + let mut selection = Selection { + text: "Selected prose".into(), + active: true, + editable: true, + writing_tools_requested: false, + replacement: Some(sender), + }; + assert!(selection.supports_writing_tools(true)); + // Code is still transferable via Services, but has no prose coordinator. + assert!(!selection.supports_writing_tools(false)); + assert!(selection.supports(Some(STRING_TYPE), None)); + // Read-only, protected and noncontiguous snapshots cannot return text. + selection.editable = false; + assert!(!selection.supports_writing_tools(true)); + assert!(selection.supports(Some(STRING_TYPE), None)); + assert!(!selection.supports(Some(STRING_TYPE), Some(STRING_TYPE))); + selection.editable = true; + selection.text.clear(); + // Empty carets keep the coordinator but need its explicit menu group; + // AppKit cannot create a selection-based Services group for them. + assert!(selection.supports_writing_tools(true)); + assert!(!selection.supports(Some(STRING_TYPE), None)); + selection.active = false; + assert!(!selection.supports_writing_tools(true)); + } + + #[test] + fn requestor_recognizes_native_and_legacy_plain_types() { + assert!(supports_types(Some(STRING_TYPE), None)); + assert!(supports_types(Some(STRING_TYPE), Some(STRING_TYPE))); + assert!(supports_types( + Some("NSStringPboardType"), + Some(STRING_TYPE) + )); + assert!(supports_types(None, Some(STRING_TYPE))); + assert!(!supports_types(None, None)); + assert!(!supports_types(Some("public.rtf"), Some(STRING_TYPE))); + assert!(!supports_types(Some("public.html"), Some(STRING_TYPE))); + } + + #[test] + fn insertion_services_need_an_editable_caret_but_no_selected_text() { + let (sender, mut receiver) = oneshot::channel(); + let mut selection = Selection { + text: String::new(), + active: true, + editable: true, + writing_tools_requested: false, + replacement: Some(sender), + }; + assert!(selection.supports(None, Some(STRING_TYPE))); + assert!(!selection.supports(Some(STRING_TYPE), None)); + selection.editable = false; + assert!(!selection.supports(None, Some(STRING_TYPE))); + selection.editable = true; + assert!(selection.replace(TextReplacement::PlainText("Inserted".into()))); + assert_eq!( + receiver.try_recv().unwrap(), + Some(TextReplacement::PlainText("Inserted".into())) + ); + } + + #[test] + fn replacement_is_delivered_once_and_disables_the_snapshot() { + let (sender, mut receiver) = oneshot::channel(); + let mut selection = Selection { + text: "Original".into(), + active: true, + editable: true, + writing_tools_requested: false, + replacement: Some(sender), + }; + assert!(selection.supports(Some(STRING_TYPE), Some(STRING_TYPE))); + assert!(selection.replace(TextReplacement::PlainText("Rewritten".into()))); + assert_eq!( + receiver.try_recv().unwrap(), + Some(TextReplacement::PlainText("Rewritten".into())) + ); + assert!(!selection.supports(Some(STRING_TYPE), None)); + assert!(!selection.replace(TextReplacement::PlainText("Late result".into()))); + } + + #[test] + fn cancelled_selection_does_not_advertise_services() { + let (sender, _receiver) = oneshot::channel(); + let mut selection = Selection { + text: "Original".into(), + active: true, + editable: true, + writing_tools_requested: false, + replacement: Some(sender), + }; + selection.active = false; + selection.replacement.take(); + assert!(!selection.supports(Some(STRING_TYPE), Some(STRING_TYPE))); + assert!(!selection.replace(TextReplacement::PlainText("Late result".into()))); + } + + #[test] + fn read_only_selection_supports_send_only_services() { + let (sender, _receiver) = oneshot::channel(); + let mut selection = Selection { + text: "Original".into(), + active: true, + editable: false, + writing_tools_requested: false, + replacement: Some(sender), + }; + assert!(selection.supports(Some(STRING_TYPE), None)); + assert!(!selection.supports(Some(STRING_TYPE), Some(STRING_TYPE))); + assert!(!selection.replace(TextReplacement::PlainText("Read-only replacement".into()))); + } +} diff --git a/crates/markraft-app/src/platform/text_requestor/writing_tools.rs b/crates/markraft-app/src/platform/text_requestor/writing_tools.rs new file mode 100644 index 00000000..a91d20a2 --- /dev/null +++ b/crates/markraft-app/src/platform/text_requestor/writing_tools.rs @@ -0,0 +1,434 @@ +//! Limited Writing Tools attached to the real editor view, with a guarded snapshot. +use super::*; +use block2::DynBlock; +use objc2::{AllocAnyThread, runtime::ProtocolObject, sel}; +use objc2_app_kit::{ + NSBezierPath, NSMenuItem, NSTextPreview, NSView, NSWritingToolsBehavior, + NSWritingToolsCoordinator, NSWritingToolsCoordinatorAnimationParameters, + NSWritingToolsCoordinatorContext, NSWritingToolsCoordinatorContextScope, + NSWritingToolsCoordinatorDelegate, NSWritingToolsCoordinatorState, + NSWritingToolsCoordinatorTextAnimation, NSWritingToolsCoordinatorTextReplacementReason, + NSWritingToolsResultOptions, +}; +use objc2_foundation::{NSAttributedString, NSPoint, NSRange, NSRect, NSSize, NSValue}; +use std::{cell::Cell, ptr::NonNull}; + +/// Changes stay local until the limited Writing Tools operation finishes. A +/// native operation may deliver several replacements with successive offsets. +struct Buffer { + original: String, + text: String, +} + +impl Buffer { + fn new(text: String) -> Self { + Self { + original: text.clone(), + text, + } + } + + fn replace(&mut self, range: NSRange, replacement: &str) -> bool { + let Some(end) = range.location.checked_add(range.length) else { + return false; + }; + let byte_offset = |offset| { + let mut utf16 = 0; + for (byte, ch) in self.text.char_indices() { + if utf16 == offset { + return Some(byte); + } + utf16 += ch.len_utf16(); + } + (utf16 == offset).then_some(self.text.len()) + }; + let (Some(start), Some(end)) = (byte_offset(range.location), byte_offset(end)) else { + return false; + }; + self.text.replace_range(start..end, replacement); + true + } + + fn finish(&self, active: bool, selection: &mut Selection) -> bool { + active + && self.text != self.original + && selection.replace(TextReplacement::PreserveStyles(self.text.clone())) + } +} + +struct State { + view: Retained, + requestor: Retained, + context: RefCell>>, + buffer: RefCell, + caret: NSRect, + active: Cell, +} + +impl State { + fn active(&self) -> bool { + self.active.get() && self.requestor.ivars().borrow().active + } + + fn owns_context(&self, context: &NSWritingToolsCoordinatorContext) -> bool { + self.active() + && self + .context + .borrow() + .as_ref() + .is_some_and(|current| current.identifier() == context.identifier()) + } + + fn paths(&self, context: &NSWritingToolsCoordinatorContext) -> Retained> { + if self.owns_context(context) { + NSArray::from_retained_slice(&[NSBezierPath::bezierPathWithRect(self.caret)]) + } else { + NSArray::new() + } + } +} + +define_class!( + #[unsafe(super(objc2_foundation::NSObject))] + #[thread_kind = MainThreadOnly] + #[ivars = State] + struct Delegate; + unsafe impl NSObjectProtocol for Delegate {} + unsafe impl NSWritingToolsCoordinatorDelegate for Delegate { + #[unsafe(method(writingToolsCoordinator:requestsContextsForScope:completion:))] + fn contexts( + &self, + _: &NSWritingToolsCoordinator, + _: NSWritingToolsCoordinatorContextScope, + completion: &DynBlock>)>, + ) { + let state = self.ivars(); + let contexts = if state.active() { + let text = { + let mut selection = state.requestor.ivars().borrow_mut(); + selection.writing_tools_requested = true; + selection.text.clone() + }; + *state.buffer.borrow_mut() = Buffer::new(text.clone()); + let attributed = NSAttributedString::initWithString( + NSAttributedString::alloc(), + &NSString::from_str(&text), + ); + let context = NSWritingToolsCoordinatorContext::initWithAttributedString_range( + NSWritingToolsCoordinatorContext::alloc(), + &attributed, + NSRange::new(0, text.encode_utf16().count()), + ); + *state.context.borrow_mut() = Some(context.clone()); + NSArray::from_retained_slice(&[context]) + } else { + NSArray::new() + }; + completion.call((NonNull::from(&*contexts),)); + } + #[unsafe(method(writingToolsCoordinator:replaceRange:inContext:proposedText:reason:animationParameters:completion:))] + fn replace( + &self, + _: &NSWritingToolsCoordinator, + range: NSRange, + context: &NSWritingToolsCoordinatorContext, + text: &NSAttributedString, + reason: NSWritingToolsCoordinatorTextReplacementReason, + _: Option<&NSWritingToolsCoordinatorAnimationParameters>, + completion: &DynBlock, + ) { + let state = self.ivars(); + let accepted = state.owns_context(context) + && state.requestor.ivars().borrow().editable + && reason == NSWritingToolsCoordinatorTextReplacementReason::Noninteractive + && state + .buffer + .borrow_mut() + .replace(range, &text.string().to_string()); + completion.call((if accepted { + text as *const NSAttributedString as *mut NSAttributedString + } else { + std::ptr::null_mut() + },)); + } + #[unsafe(method(writingToolsCoordinator:selectRanges:inContext:completion:))] + fn select( + &self, + _: &NSWritingToolsCoordinator, + _: &NSArray, + _: &NSWritingToolsCoordinatorContext, + completion: &DynBlock, + ) { + // Limited mode keeps its temporary selection inside the system UI. + // The editor's original caret remains the write-back target. + completion.call(()); + } + #[unsafe(method(writingToolsCoordinator:requestsBoundingBezierPathsForRange:inContext:completion:))] + fn bounds( + &self, + _: &NSWritingToolsCoordinator, + _: NSRange, + context: &NSWritingToolsCoordinatorContext, + completion: &DynBlock>)>, + ) { + completion.call((NonNull::from(&*self.ivars().paths(context)),)); + } + #[unsafe(method(writingToolsCoordinator:requestsUnderlinePathsForRange:inContext:completion:))] + fn underlines( + &self, + _: &NSWritingToolsCoordinator, + _: NSRange, + _: &NSWritingToolsCoordinatorContext, + completion: &DynBlock>)>, + ) { + completion.call((NonNull::from(&*NSArray::new()),)); + } + #[unsafe(method(writingToolsCoordinator:prepareForTextAnimation:forRange:inContext:completion:))] + fn prepare( + &self, + _: &NSWritingToolsCoordinator, + _: NSWritingToolsCoordinatorTextAnimation, + _: NSRange, + _: &NSWritingToolsCoordinatorContext, + completion: &DynBlock, + ) { + completion.call(()); + } + #[unsafe(method(writingToolsCoordinator:requestsPreviewForTextAnimation:ofRange:inContext:completion:))] + fn preview( + &self, + _: &NSWritingToolsCoordinator, + _: NSWritingToolsCoordinatorTextAnimation, + _: NSRange, + _: &NSWritingToolsCoordinatorContext, + completion: &DynBlock)>, + ) { + completion.call((std::ptr::null_mut(),)); + } + #[unsafe(method(writingToolsCoordinator:requestsPreviewForRect:inContext:completion:))] + fn preview_rect( + &self, + _: &NSWritingToolsCoordinator, + _: NSRect, + _: &NSWritingToolsCoordinatorContext, + completion: &DynBlock, + ) { + completion.call((std::ptr::null_mut(),)); + } + #[unsafe(method(writingToolsCoordinator:finishTextAnimation:forRange:inContext:completion:))] + fn finish_animation( + &self, + _: &NSWritingToolsCoordinator, + _: NSWritingToolsCoordinatorTextAnimation, + _: NSRange, + _: &NSWritingToolsCoordinatorContext, + completion: &DynBlock, + ) { + completion.call(()); + } + #[unsafe(method(writingToolsCoordinator:willChangeToState:completion:))] + fn state_changed( + &self, + _: &NSWritingToolsCoordinator, + state: NSWritingToolsCoordinatorState, + completion: &DynBlock, + ) { + completion.call(()); + if state == NSWritingToolsCoordinatorState::Inactive { + let state = self.ivars(); + state.buffer.borrow().finish( + state.active.get(), + &mut state.requestor.ivars().borrow_mut(), + ); + } + } + } +); + +pub(super) struct Snapshot { + delegate: Retained, + coordinator: Retained, + previous: Option>, + items: Vec>, +} + +impl Snapshot { + pub(super) fn new( + view: &NSResponder, + requestor: &TextRequestor, + position: gpui::Point, + line_height: gpui::Pixels, + ) -> Option { + let mtm = MainThreadMarker::new()?; + objc2::runtime::AnyClass::get(c"NSWritingToolsCoordinator")?; + if !NSWritingToolsCoordinator::isWritingToolsAvailable(mtm) { + return None; + } + let view = + unsafe { Retained::retain((view as *const NSResponder).cast_mut().cast::())? }; + let bounds = view.bounds(); + let x = bounds.origin.x + f64::from(f32::from(position.x)); + let offset = f64::from(f32::from(position.y)); + let y = bounds.origin.y + + if view.isFlipped() { + offset + } else { + bounds.size.height - offset + }; + let delegate = Delegate::alloc(mtm).set_ivars(State { + view: view.clone(), + requestor: unsafe { Retained::retain((requestor as *const TextRequestor).cast_mut())? }, + context: RefCell::new(None), + buffer: RefCell::new(Buffer::new(requestor.ivars().borrow().text.clone())), + caret: NSRect::new( + NSPoint::new(x, y), + NSSize::new(1.0, f64::from(f32::from(line_height)).max(1.0)), + ), + active: Cell::new(true), + }); + let delegate: Retained = unsafe { msg_send![super(delegate), init] }; + let coordinator = NSWritingToolsCoordinator::initWithDelegate( + NSWritingToolsCoordinator::alloc(mtm), + Some(ProtocolObject::from_ref(&*delegate)), + ); + coordinator.setPreferredBehavior(NSWritingToolsBehavior::Limited); + coordinator.setPreferredResultOptions(NSWritingToolsResultOptions::PlainText); + let previous = view.writingToolsCoordinator(); + view.setWritingToolsCoordinator(Some(&coordinator)); + let mut snapshot = Self { + delegate, + coordinator, + previous, + items: vec![], + }; + // The public factory groups the standard entry points before additional + // transformation actions. Preserve their system metadata and visuals. + let native_items = NSMenuItem::writingToolsItems(mtm); + for item in native_items.iter() { + let group_identifier: Option> = + unsafe { msg_send![&*item, identifier] }; + let children = item.submenu().map_or_else( + || NSArray::from_retained_slice(std::slice::from_ref(&item)), + |menu| menu.itemArray(), + ); + for child in children.iter() { + if child.action() == Some(sel!(showWritingTools:)) && snapshot.items.len() < 3 { + let copied: Retained = unsafe { msg_send![&*child, copy] }; + // NSTextView uses the factory group identity on each flat + // entry. Preserve it when removing the submenu container, + // so AppKit can recognize the already-present native group. + if let Some(identifier) = group_identifier.as_deref() { + unsafe { + let _: () = msg_send![&*copied, setIdentifier: identifier]; + } + } + // Keep the factory's nil target. NSApplication handles this + // responder-chain action for a custom NSView coordinator; + // NSView itself does not implement showWritingTools:. + snapshot.items.push(copied); + } + } + } + (!snapshot.items.is_empty()).then_some(snapshot) + } + + pub(super) fn items(&self) -> &[Retained] { + &self.items + } +} + +impl Drop for Snapshot { + fn drop(&mut self) { + let state = self.delegate.ivars(); + state.active.set(false); + self.coordinator.stopWritingTools(); + if state + .view + .writingToolsCoordinator() + .as_ref() + .is_some_and(|current| std::ptr::eq(&**current, &*self.coordinator)) + { + state + .view + .setWritingToolsCoordinator(self.previous.as_deref()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use objc2::ClassType; + + #[test] + fn public_coordinator_callbacks_are_registered() { + for selector in [ + sel!(writingToolsCoordinator:requestsContextsForScope:completion:), + sel!(writingToolsCoordinator:replaceRange:inContext:proposedText:reason:animationParameters:completion:), + sel!(writingToolsCoordinator:willChangeToState:completion:), + ] { + assert!(Delegate::class().instance_method(selector).is_some()); + } + } + + #[test] + fn system_application_handles_the_standard_writing_tools_action() { + assert!( + NSApplication::class() + .instance_method(sel!(showWritingTools:)) + .is_some() + ); + // Our coordinator delegate supplies content, never an action trampoline + // that incorrectly sends an NSTextView-only implementation to NSView. + assert!( + Delegate::class() + .instance_method(sel!(showWritingTools:)) + .is_none() + ); + } + + #[test] + fn accepted_utf16_edits_are_collected_and_delivered_once() { + let mut buffer = Buffer::new(String::new()); + assert!(buffer.replace(NSRange::new(0, 0), "Hello 😀")); + assert!(!buffer.replace(NSRange::new(7, 1), "invalid surrogate")); + assert!(buffer.replace(NSRange::new(6, 2), "world")); + let (sender, mut receiver) = oneshot::channel(); + let mut selection = Selection { + text: String::new(), + active: true, + editable: true, + writing_tools_requested: false, + replacement: Some(sender), + }; + assert_eq!(receiver.try_recv().unwrap(), None); + assert!(buffer.finish(true, &mut selection)); + assert_eq!( + receiver.try_recv().unwrap(), + Some(TextReplacement::PreserveStyles("Hello world".into())) + ); + assert!(!buffer.finish(true, &mut selection)); + } + + #[test] + fn unchanged_cancel_retired_and_readonly_snapshots_do_not_write_back() { + let mut buffer = Buffer::new(String::new()); + let (sender, mut receiver) = oneshot::channel(); + let mut selection = Selection { + text: String::new(), + active: true, + editable: true, + writing_tools_requested: false, + replacement: Some(sender), + }; + assert!(!buffer.finish(true, &mut selection)); + assert!(buffer.replace(NSRange::new(0, 0), "Composed")); + assert!(!buffer.finish(false, &mut selection)); + selection.editable = false; + assert!(!buffer.finish(true, &mut selection)); + selection.editable = true; + selection.active = false; + assert!(!buffer.finish(true, &mut selection)); + assert_eq!(receiver.try_recv().unwrap(), None); + } +} diff --git a/crates/markraft-app/src/platform/text_services.rs b/crates/markraft-app/src/platform/text_services.rs new file mode 100644 index 00000000..8198b78b --- /dev/null +++ b/crates/markraft-app/src/platform/text_services.rs @@ -0,0 +1,257 @@ +//! Read-only system text integrations. Native presentations run outside GPUI borrows. + +use super::{NSPoint, NSRect, native_view, native_window, text_checking::DetectedData}; +use crate::locale::Message; +use gpui::{Pixels, Point, Window}; +use objc2::{ + MainThreadMarker, class, msg_send, + rc::{Allocated, Retained}, + runtime::AnyObject, +}; +use objc2_foundation::{NSArray, NSAttributedString, NSString, NSURL}; +use std::{cell::RefCell, ptr}; + +#[link(name = "AVFoundation", kind = "framework")] +unsafe extern "C" {} + +thread_local! { + static SPEECH: RefCell>> = const { RefCell::new(None) }; + static SHARE_PICKER: RefCell>> = const { RefCell::new(None) }; +} + +/// Retain the actual GPUI view while a text service is deferred for presentation. +pub(crate) struct TextAnchor { + view: Retained, + window: Retained, + position: Point, +} + +impl TextAnchor { + pub(crate) fn new(window: &Window, position: Point) -> Result { + main_thread()?; + let view = unsafe { Retained::retain(native_view(window)?) }.ok_or_else(failure)?; + let window = unsafe { Retained::retain(native_window(window)?) }.ok_or_else(failure)?; + Ok(Self { + view, + window, + position, + }) + } + + fn point(&self) -> Result { + main_thread()?; + let attached: *mut AnyObject = unsafe { msg_send![&*self.view, window] }; + let visible: bool = unsafe { msg_send![&*self.window, isVisible] }; + if attached != Retained::as_ptr(&self.window).cast_mut() || !visible { + return Err(failure()); + } + let bounds: NSRect = unsafe { msg_send![&*self.view, bounds] }; + let flipped: bool = unsafe { msg_send![&*self.view, isFlipped] }; + Ok(view_point(self.position, bounds, flipped)) + } + + pub(crate) fn show_definition(&self, text: &str) -> Result<(), Message> { + let point = self.point()?; + if text.trim().is_empty() { + return Ok(()); + } + let text = NSAttributedString::from_nsstring(&NSString::from_str(text)); + // NSView explicitly supports this API for custom text views, without NSTextView. + unsafe { + let _: () = + msg_send![&*self.view, showDefinitionForAttributedString: &*text, atPoint: point]; + } + Ok(()) + } + + /// Let AppKit provide the installed system's date, address, phone, and flight actions. + pub(crate) fn data_menu(&self, action: &DetectedData) -> Result, Message> { + let point = self.point()?; + // Only use the original detector result and its exact checked string. + // Synthetic results lack Reveal metadata and can crash inside AppKit. + // Rechecking would also shift relative dates. + let menu: Option> = unsafe { + let checker: *mut AnyObject = msg_send![class!(NSSpellChecker), sharedSpellChecker]; + msg_send![checker, menuForResult: &*action.result, string: &*action.text, + options: ptr::null::(), atLocation: point, inView: &*self.view] + }; + menu.ok_or_else(failure) + } + + pub(crate) fn share(&self, text: &str) -> Result<(), Message> { + let point = self.point()?; + if text.trim().is_empty() { + return Ok(()); + } + let text = NSString::from_str(text); + let items = NSArray::from_slice(&[&*text]); + let picker: Retained = unsafe { + let allocated: Allocated = msg_send![class!(NSSharingServicePicker), alloc]; + msg_send![allocated, initWithItems: &*items] + }; + // A popover outlives the action. Retain its picker until the next invocation, + // and release RefCell borrows before AppKit can enter its nested event loop. + let previous = SHARE_PICKER.with(|slot| slot.replace(Some(picker.clone()))); + if let Some(previous) = previous { + unsafe { + let _: () = msg_send![&*previous, close]; + } + } + let rect = NSRect { + origin: point, + size: NSPoint { x: 1.0, y: 1.0 }, + }; + unsafe { + let _: () = msg_send![&*picker, showRelativeToRect: rect, ofView: &*self.view, preferredEdge: 1usize]; + } + Ok(()) + } +} + +fn view_point(position: Point, bounds: NSRect, flipped: bool) -> NSPoint { + let x = f32::from(position.x) as f64; + let y = f32::from(position.y) as f64; + NSPoint { + x: bounds.origin.x + x, + y: bounds.origin.y + if flipped { y } else { bounds.size.y - y }, + } +} + +pub(crate) fn start_speaking(text: &str) -> Result<(), Message> { + main_thread()?; + if text.trim().is_empty() { + return Ok(()); + } + let synthesizer = SPEECH.with(|slot| { + slot.borrow_mut() + .get_or_insert_with(|| unsafe { msg_send![class!(AVSpeechSynthesizer), new] }) + .clone() + }); + let text = NSString::from_str(text); + unsafe { + // Replace this application's previous utterance instead of queueing selections. + let _: bool = msg_send![&*synthesizer, stopSpeakingAtBoundary: 0isize]; + let utterance: Retained = + msg_send![class!(AVSpeechUtterance), speechUtteranceWithString: &*text]; + let _: () = msg_send![&*synthesizer, speakUtterance: &*utterance]; + } + Ok(()) +} + +pub(crate) fn stop_speaking() { + if MainThreadMarker::new().is_none() { + return; + } + let synthesizer = SPEECH.with(|slot| slot.borrow().clone()); + if let Some(synthesizer) = synthesizer { + unsafe { + let _: bool = msg_send![&*synthesizer, stopSpeakingAtBoundary: 0isize]; + } + } +} + +pub(crate) fn is_speaking() -> bool { + if MainThreadMarker::new().is_none() { + return false; + } + let synthesizer = SPEECH.with(|slot| slot.borrow().clone()); + synthesizer.is_some_and(|synthesizer| unsafe { msg_send![&*synthesizer, isSpeaking] }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OpenWithApplication { + pub name: String, + /// An application bundle file URL supplied by Launch Services. + pub url: String, + pub is_default: bool, +} + +pub(crate) fn applications_for_url(value: &str) -> Vec { + if MainThreadMarker::new().is_none() { + return Vec::new(); + } + let Some(url) = NSURL::URLWithString(&NSString::from_str(value)) else { + return Vec::new(); + }; + unsafe { + let workspace: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + let applications: Retained> = + msg_send![workspace, URLsForApplicationsToOpenURL: &*url]; + let default: Option> = + msg_send![workspace, URLForApplicationToOpenURL: &*url]; + let manager: *mut AnyObject = msg_send![class!(NSFileManager), defaultManager]; + let mut result = Vec::new(); + for application in applications.iter() { + let Some(path) = application.path() else { + continue; + }; + let name: Retained = msg_send![manager, displayNameAtPath: &*path]; + let name = name.to_string(); + let Some(absolute) = application.absoluteString() else { + continue; + }; + let is_default = default + .as_ref() + .is_some_and(|default| NSURL::eq(&application, default)); + result.push(OpenWithApplication { + name: name.strip_suffix(".app").unwrap_or(&name).to_owned(), + url: absolute.to_string(), + is_default, + }); + } + result.sort_by(|left, right| { + right + .is_default + .cmp(&left.is_default) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); + result.dedup_by(|left, right| left.url == right.url); + result + } +} + +/// Submit the explicit application choice to Launch Services. Success means the +/// launch request was submitted; the destination application's load is asynchronous. +pub(crate) fn open_with(value: &str, application: &OpenWithApplication) -> Result<(), Message> { + main_thread()?; + let url = NSURL::URLWithString(&NSString::from_str(value)).ok_or_else(failure)?; + let app = NSURL::URLWithString(&NSString::from_str(&application.url)).ok_or_else(failure)?; + if !app.isFileURL() { + return Err(failure()); + } + let urls = NSArray::from_slice(&[&*url]); + unsafe { + let workspace: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + let configuration: Retained = + msg_send![class!(NSWorkspaceOpenConfiguration), configuration]; + let _: () = msg_send![workspace, openURLs: &*urls, withApplicationAtURL: &*app, configuration: &*configuration, completionHandler: ptr::null::()]; + } + Ok(()) +} + +fn main_thread() -> Result { + MainThreadMarker::new().ok_or_else(failure) +} + +fn failure() -> Message { + Message::new("error.native-window-control") +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{point, px}; + + #[test] + fn text_anchor_respects_view_origin_and_orientation() { + let bounds = NSRect { + origin: NSPoint { x: 10.0, y: 20.0 }, + size: NSPoint { x: 300.0, y: 200.0 }, + }; + let position = point(px(30.0), px(40.0)); + let flipped = view_point(position, bounds, true); + assert_eq!((flipped.x, flipped.y), (40.0, 60.0)); + let ordinary = view_point(position, bounds, false); + assert_eq!((ordinary.x, ordinary.y), (40.0, 180.0)); + } +} diff --git a/crates/markraft-app/src/platform/translation.rs b/crates/markraft-app/src/platform/translation.rs new file mode 100644 index 00000000..3f19ead6 --- /dev/null +++ b/crates/markraft-app/src/platform/translation.rs @@ -0,0 +1,145 @@ +//! Public SwiftUI translation popover, anchored in the native editor view. + +use super::{NSRect, native_view}; +use crate::locale::Message; +use futures_channel::oneshot; +use gpui::{Pixels, Point, Window}; +use objc2::{MainThreadMarker, msg_send}; +use std::cell::RefCell; +use std::ffi::{CStr, CString, c_char, c_void}; +use std::marker::PhantomData; +use std::ptr::NonNull; +use std::rc::Rc; + +unsafe extern "C" { + fn markraft_translation_available() -> bool; + fn markraft_translation_begin( + view: *mut c_void, + x: f64, + y: f64, + text: *const c_char, + editable: bool, + context: *mut c_void, + callback: extern "C" fn(*mut c_void, *const c_char), + ) -> *mut c_void; + fn markraft_translation_cancel(session: *mut c_void); +} + +type Completion = RefCell>>>; + +/// Cancellation dismisses the popover and resolves the receiver with `None`. +/// Keep this alive until the receiver resolves or the editor context expires. +pub(crate) struct Translation { + native: NonNull, + // Swift holds a borrowed pointer to this stable allocation until cancel. + _completion: Box, + _main_thread: PhantomData>, +} + +pub(crate) fn available() -> bool { + unsafe { markraft_translation_available() } +} + +impl Translation { + pub(crate) fn show( + window: &Window, + position: Point, + text: &str, + editable: bool, + ) -> Result<(Self, oneshot::Receiver>), Message> { + MainThreadMarker::new().ok_or_else(|| Message::new("error.native-window-control"))?; + if !available() { + return Err(Message::new("error.native-window-control")); + } + let view = native_view(window)?; + let bounds: NSRect = unsafe { msg_send![view, bounds] }; + let flipped: bool = unsafe { msg_send![view, isFlipped] }; + let x = bounds.origin.x + f64::from(f32::from(position.x)); + let offset_y = f64::from(f32::from(position.y)); + let y = bounds.origin.y + + if flipped { + offset_y + } else { + bounds.size.y - offset_y + }; + let text = CString::new(text).map_err(|_| Message::new("error.native-window-control"))?; + let (sender, receiver) = oneshot::channel(); + let mut completion = Box::new(RefCell::new(Some(sender))); + let native = unsafe { + markraft_translation_begin( + view.cast(), + x, + y, + text.as_ptr(), + editable, + (&mut *completion as *mut Completion).cast(), + completed, + ) + }; + let native = + NonNull::new(native).ok_or_else(|| Message::new("error.native-window-control"))?; + Ok(( + Self { + native, + _completion: completion, + _main_thread: PhantomData, + }, + receiver, + )) + } +} + +extern "C" fn completed(context: *mut c_void, text: *const c_char) { + // Swift invokes this on the main thread at most once. The owning Translation + // keeps the allocation valid until native cancellation has returned. + let completion = unsafe { &*context.cast::() }; + let replacement = if text.is_null() { + None + } else { + Some( + unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned(), + ) + }; + if let Some(sender) = completion.borrow_mut().take() { + let _ = sender.send(replacement); + } +} + +impl Drop for Translation { + fn drop(&mut self) { + unsafe { markraft_translation_cancel(self.native.as_ptr()) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_callback_copies_utf8_text_and_completes_once() { + let (sender, mut receiver) = oneshot::channel(); + let mut completion = Box::new(RefCell::new(Some(sender))); + let context = (&mut *completion as *mut Completion).cast(); + let translated = CString::new("翻译完成 — café").unwrap(); + completed(context, translated.as_ptr()); + drop(translated); + completed(context, std::ptr::null()); + assert_eq!( + receiver.try_recv().unwrap(), + Some(Some("翻译完成 — café".into())) + ); + } + + #[test] + fn native_dismissal_resolves_without_replacement() { + let (sender, mut receiver) = oneshot::channel(); + let mut completion = Box::new(RefCell::new(Some(sender))); + completed( + (&mut *completion as *mut Completion).cast(), + std::ptr::null(), + ); + assert_eq!(receiver.try_recv().unwrap(), Some(None)); + } +} diff --git a/crates/markraft-app/src/platform/translation.swift b/crates/markraft-app/src/platform/translation.swift new file mode 100644 index 00000000..119bf55d --- /dev/null +++ b/crates/markraft-app/src/platform/translation.swift @@ -0,0 +1,120 @@ +// The public system translation presentation is exposed only through SwiftUI. +// Keep that API behind a small C boundary; Rust owns the editor snapshot and +// decides whether a returned replacement still applies to the document. +import AppKit +import SwiftUI +import Translation + +private typealias Completion = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> Void + +@available(macOS 14.4, *) +@MainActor +private final class TranslationModel: ObservableObject { + @Published var presented = false + var completed: ((String?) -> Void)? +} + +@available(macOS 14.4, *) +private struct TranslationContent: View { + @ObservedObject var model: TranslationModel + let text: String + let editable: Bool + + var body: some View { + Color.clear + .frame(width: 1, height: 1) + .translationPresentation( + isPresented: $model.presented, + text: text, + replacementAction: editable ? { + model.completed?($0) + } : nil + ) + .onChange(of: model.presented) { presented in + if !presented { + model.completed?(nil) + } + } + } +} + +@available(macOS 14.4, *) +@MainActor +private final class TranslationSession { + private let model = TranslationModel() + private var host: NSView? + private var completion: Completion? + private let context: UnsafeMutableRawPointer? + private var finished = false + + init(view: NSView, parent: NativePresentationRoot, point: NSPoint, text: String, editable: Bool, + context: UnsafeMutableRawPointer?, completion: @escaping Completion) { + self.context = context + self.completion = completion + let content = TranslationContent(model: model, text: text, editable: editable) + let host = NSHostingView(rootView: content) + // The hosting view supplies only the selection anchor. The system owns + // the translation popover and its layout in a separate native window. + host.sizingOptions = [] + host.frame = NSRect(origin: parent.convert(point, from: view), size: NSSize(width: 1, height: 1)) + self.host = host + parent.addSubview(host) + model.completed = { [weak self] text in self?.finish(text) } + // SwiftUI must first attach its hosting view to the existing key window. + DispatchQueue.main.async { [weak self] in + guard let self, !self.finished else { return } + self.model.presented = true + } + } + + func finish(_ text: String?) { + guard !finished else { return } + finished = true + let callback = completion + completion = nil + model.completed = nil + model.presented = false + // Removing the view in a SwiftUI update can invalidate its presentation + // transaction. Detach after this event finishes, including cancellation. + let oldHost = host + host = nil + DispatchQueue.main.async { oldHost?.removeFromSuperview() } + if let text { + text.withCString { callback?(context, $0) } + } else { + callback?(context, nil) + } + } +} + +@_cdecl("markraft_translation_available") +func translationAvailable() -> Bool { + if #available(macOS 14.4, *) { return true } + return false +} + +@_cdecl("markraft_translation_begin") +@MainActor +func translationBegin( + _ viewPointer: UnsafeMutableRawPointer?, _ x: Double, _ y: Double, + _ text: UnsafePointer?, _ editable: Bool, + _ context: UnsafeMutableRawPointer?, + _ callback: @escaping @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> Void +) -> UnsafeMutableRawPointer? { + guard #available(macOS 14.4, *), let viewPointer, let text else { return nil } + let view = Unmanaged.fromOpaque(viewPointer).takeUnretainedValue() + guard let parent = NativePresentationRoot.containing(view) else { return nil } + let session = TranslationSession( + view: view, parent: parent, point: NSPoint(x: x, y: y), text: String(cString: text), + editable: editable, context: context, completion: callback + ) + return Unmanaged.passRetained(session).toOpaque() +} + +@_cdecl("markraft_translation_cancel") +@MainActor +func translationCancel(_ pointer: UnsafeMutableRawPointer?) { + guard #available(macOS 14.4, *), let pointer else { return } + let session = Unmanaged.fromOpaque(pointer).takeRetainedValue() + session.finish(nil) +} diff --git a/crates/markraft-app/src/storage.rs b/crates/markraft-app/src/storage.rs index aa49745e..e8d563d0 100644 --- a/crates/markraft-app/src/storage.rs +++ b/crates/markraft-app/src/storage.rs @@ -15,6 +15,77 @@ use std::{ use unicode_segmentation::UnicodeSegmentation; use uuid::Uuid; +/// Native prose services are shared across notes; code remains literal. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextCheckingPreferences { + pub spelling: bool, + pub grammar: bool, + pub correction: bool, + pub quotes: bool, + pub dashes: bool, + pub replacements: bool, + pub links: bool, + pub data_detectors: bool, + pub smart_insert_delete: bool, +} + +impl Default for TextCheckingPreferences { + fn default() -> Self { + Self { + spelling: true, + grammar: false, + correction: false, + quotes: false, + dashes: false, + replacements: true, + links: true, + data_detectors: true, + smart_insert_delete: true, + } + } +} + +/// One switch of [`TextCheckingPreferences`], as a menu item or a native +/// panel names it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextCheckingSetting { + SmartInsertDelete, + Spelling, + Grammar, + Correction, + Quotes, + Dashes, + Replacements, + Links, + DataDetectors, +} + +impl TextCheckingPreferences { + pub fn get(&self, setting: TextCheckingSetting) -> bool { + let mut settings = *self; + *settings.slot(setting) + } + + pub fn set(&mut self, setting: TextCheckingSetting, value: bool) { + *self.slot(setting) = value; + } + + fn slot(&mut self, setting: TextCheckingSetting) -> &mut bool { + match setting { + TextCheckingSetting::SmartInsertDelete => &mut self.smart_insert_delete, + TextCheckingSetting::Spelling => &mut self.spelling, + TextCheckingSetting::Grammar => &mut self.grammar, + TextCheckingSetting::Correction => &mut self.correction, + TextCheckingSetting::Quotes => &mut self.quotes, + TextCheckingSetting::Dashes => &mut self.dashes, + TextCheckingSetting::Replacements => &mut self.replacements, + TextCheckingSetting::Links => &mut self.links, + TextCheckingSetting::DataDetectors => &mut self.data_detectors, + } + } +} + /// Folder name under `Documents` used when Settings has no notes folder yet. pub const DEFAULT_NOTES_FOLDER_NAME: &str = "Markraft"; @@ -73,6 +144,7 @@ const LIBRARY_VERSION: u32 = 2; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Preferences { + pub text_checking: TextCheckingPreferences, /// Requested display language; old settings continue to follow the system. pub language: LanguagePreference, pub dark_mode: Option, @@ -327,6 +399,7 @@ impl Default for Preferences { fn default() -> Self { Self { language: LanguagePreference::default(), + text_checking: TextCheckingPreferences::default(), dark_mode: None, auto_height: true, hotkey: "Alt+N".into(), @@ -367,6 +440,7 @@ impl Default for Preferences { /// set, so they have no word here. #[derive(Clone, Debug, PartialEq)] pub enum Pref { + TextChecking(TextCheckingPreferences), Language(LanguagePreference), Theme(Option), AutoHeight(bool), @@ -408,6 +482,7 @@ impl Pref { /// Write this setting into `preferences`. pub fn apply(self, preferences: &mut Preferences) { match self { + Pref::TextChecking(settings) => preferences.text_checking = settings, Pref::Language(language) => preferences.language = language, Pref::Theme(mode) => preferences.dark_mode = mode, Pref::AutoHeight(on) => preferences.auto_height = on, @@ -1197,6 +1272,17 @@ mod tests { /// shows up as a difference. fn every_preference_changed() -> Preferences { let preferences = Preferences { + text_checking: TextCheckingPreferences { + spelling: false, + grammar: true, + correction: true, + quotes: true, + dashes: true, + replacements: false, + links: false, + data_detectors: false, + smart_insert_delete: false, + }, language: LanguagePreference::Locale("future-Language".into()), dark_mode: Some(true), auto_height: false, @@ -1262,6 +1348,7 @@ mod tests { fn every_pref_sets_its_own_field() { let changed = every_preference_changed(); let words = vec![ + Pref::TextChecking(changed.text_checking), Pref::Language(changed.language.clone()), Pref::Theme(changed.dark_mode), Pref::AutoHeight(changed.auto_height), diff --git a/crates/markraft-commonmark/Cargo.toml b/crates/markraft-commonmark/Cargo.toml index 75fc5186..db0a101b 100644 --- a/crates/markraft-commonmark/Cargo.toml +++ b/crates/markraft-commonmark/Cargo.toml @@ -14,6 +14,8 @@ emojis = "0.9" scraper = { version = "0.24", default-features = false } finl_unicode = { version = "1.4.0", default-features = false, features = ["categories"] } unicode-width = { version = "0.2", default-features = false } +unicode-segmentation = "1.12" +similar = { version = "3.2", default-features = false, features = ["std"] } [dev-dependencies] serde = { version = "1", features = ["derive"] } diff --git a/crates/markraft-commonmark/src/commands.rs b/crates/markraft-commonmark/src/commands.rs index f09ca3aa..4609a27a 100644 --- a/crates/markraft-commonmark/src/commands.rs +++ b/crates/markraft-commonmark/src/commands.rs @@ -85,6 +85,8 @@ use crate::textblock::{Item, Items, block_kind, document_context, style_mark, sy use markraft_core::ends::KeptEnds; use markraft_core::protocol::event; +mod reading_replacement; + /// Why a formatting command left the document alone. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CommandRefusal { @@ -149,6 +151,27 @@ pub struct Formatter { } impl Formatter { + /// Replace a source selection's reading text. `policy` either keeps the + /// styles of unchanged characters, new text taking the style of where it + /// lands, or gives the whole replacement the style the selection starts in. + pub fn replace_reading( + &self, + range: Range, + text: impl Into, + policy: markraft_core::kind::ReadingReplacementPolicy, + ) -> FormatCommand { + let text = text.into(); + let house = self.house.clone(); + Arc::new(move |state| { + if !reading_replacement::owns_structure(state, range.clone()) { + return Ok(None); + } + reading_replacement::replace(state, range.clone(), &text, policy, house.get()) + .map(Some) + .ok_or_else(unreadable) + }) + } + /// The commands, spelling in `house`'s style. pub fn new(house: HouseStyleHandle) -> Formatter { Formatter { house } diff --git a/crates/markraft-commonmark/src/commands/reading_replacement.rs b/crates/markraft-commonmark/src/commands/reading_replacement.rs new file mode 100644 index 00000000..64bb75ff --- /dev/null +++ b/crates/markraft-commonmark/src/commands/reading_replacement.rs @@ -0,0 +1,603 @@ +//! Reading-text replacements over source-backed styled paragraphs. +//! +//! The diff addresses reading graphemes, never concealed syntax. Equal units +//! keep their styles; inserted units inherit the edited location. Paragraph +//! boundaries carry the following paragraph's identity, and a new boundary +//! belongs to the paragraph in which it was inserted. + +use super::*; +use markraft_core::kind::ReadingReplacementPolicy; +use similar::{Algorithm, DiffTag, capture_diff_slices_deadline}; +use std::time::{Duration, Instant}; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone)] +struct Token { + unit: Option, + origin: Option<(usize, usize)>, + paragraph: usize, + source: Range, +} + +struct SourcePatch { + old_units: Range, + new_units: Range, + source: Range, + items: Vec, +} + +impl Token { + fn character(&self) -> Option { + match self.unit.as_ref().map(|unit| &unit.content) { + Some(Content::Char(character)) => Some(*character), + Some(Content::Break { .. }) | None => Some('\n'), + Some(Content::Atom(_)) => None, + } + } +} + +pub(super) fn owns_structure(state: &EditorState, selected: Range) -> bool { + let mut position = 0; + state.doc().children().all(|node| { + let end = position + node.node_size(); + let touches = selected.start < end && selected.end > position + || selected.is_empty() && selected.start > position && selected.start < end; + position = end; + !touches || state.schema().node_type(node.type_id()).name() == md::PARAGRAPH + }) +} + +pub(super) fn replace( + state: &EditorState, + selected: Range, + replacement: &str, + policy: ReadingReplacementPolicy, + house: HouseStyle, +) -> Option { + if selected.start > selected.end || selected.end > state.doc().content_size() { + return None; + } + let schema = state.schema(); + let ctx = document_context(schema, state.doc()); + let mut blocks = Vec::new(); + let mut offset = 0; + for node in state.doc().children() { + let end = offset + node.node_size(); + if selected.start < end && selected.end > offset + || selected.is_empty() && selected.start > offset && selected.start < end + { + let block = Block::read(schema, &ctx, node, offset + 1)?; + if block.kind != BlockKind::Paragraph { + return None; + } + blocks.push(block); + } + offset = end; + } + let first = blocks.first()?; + let last = blocks.last()?; + let mut old = Vec::new(); + for (paragraph, block) in blocks.iter().enumerate() { + if paragraph > 0 { + let previous = &blocks[paragraph - 1]; + old.push(Token { + unit: None, + origin: None, + paragraph, + source: previous.start + previous.items.len()..block.start, + }); + } + for (index, unit) in block.units.iter().enumerate() { + old.push(Token { + unit: Some(unit.clone()), + origin: Some((paragraph, index)), + paragraph, + source: block.start + unit.src.start..block.start + unit.src.end, + }); + } + } + // Unselected atoms are opaque source-owned units. Only the selected slice + // must consist entirely of reading characters; neighboring images survive. + let old_text: String = old + .iter() + .map(|token| token.character().unwrap_or('\u{fffc}')) + .collect(); + let start = old.partition_point(|token| token.source.end <= selected.start); + let end = old.partition_point(|token| token.source.start < selected.end); + if start > end + || old[start..end].iter().any(|token| { + token.source.start < selected.start + || token.source.end > selected.end + || token + .unit + .as_ref() + .is_some_and(|unit| unit.styles.iter().any(is_literal)) + }) + { + return None; + } + let boundaries = scalar_boundaries(&old_text); + if !boundaries.contains(&start) || !boundaries.contains(&end) { + return None; + } + // A caret inside hidden syntax is not a reading insertion point. + if start == end && !old.is_empty() { + let at = old.get(start).map(|token| token.source.start); + let before = start.checked_sub(1).map(|index| old[index].source.end); + if at != Some(selected.start) && before != Some(selected.start) { + return None; + } + } + let selected_text: String = old[start..end] + .iter() + .map(Token::character) + .collect::>()?; + let replacement = replacement.replace("\r\n", "\n").replace('\r', "\n"); + if policy == ReadingReplacementPolicy::PreserveUnchanged && selected_text == replacement { + return Some(TransactionSpec::new()); + } + let inserted = match policy { + ReadingReplacementPolicy::PreserveUnchanged => { + diff_replacement(&old, start, &selected_text, &replacement, selected.start) + } + ReadingReplacementPolicy::InheritSelectionStart => { + let inherited = if start < end { + old.get(start) + .filter(|token| token.unit.is_some()) + .or_else(|| start.checked_sub(1).and_then(|index| old.get(index))) + } else { + start + .checked_sub(1) + .and_then(|index| old.get(index)) + .filter(|token| token.unit.is_some()) + .or_else(|| old.get(start)) + }; + let paragraph = inherited.map_or(0, |token| token.paragraph); + let styles = inherited + .and_then(|token| token.unit.as_ref()) + .map_or_else(Vec::new, |unit| unit.styles.clone()); + replacement + .chars() + .map(|character| Token { + unit: (character != '\n').then(|| Unit { + content: Content::Char(character), + styles: styles.clone(), + src: 0..0, + }), + origin: None, + paragraph, + source: selected.start..selected.start, + }) + .collect() + } + }; + let replacement_end = start + inserted.len(); + let mut target = old[..start].to_vec(); + target.extend(inserted); + target.extend_from_slice(&old[end..]); + let mut groups = vec![(0, Vec::new())]; + for token in &target { + if token.unit.is_none() { + groups.push((token.paragraph, Vec::new())); + } else { + groups.last_mut()?.1.push(token.clone()); + } + } + let mut nodes = Vec::new(); + let mut reading_spans = Vec::new(); + let mut position = first.start; + let mut previous_end = first.start; + let mut first_caret = first.start; + for (index, (owner, tokens)) in groups.iter().enumerate() { + let block = &blocks[*owner]; + let (node, read) = render(schema, block, *owner, tokens, house)?; + let empty_caret = position; + if index == 0 { + first_caret = empty_caret; + } + if index > 0 { + let next_start = read + .first() + .map_or(empty_caret, |unit| position + unit.src.start); + reading_spans.push(previous_end..next_start); + } + reading_spans.extend( + read.iter() + .map(|unit| position + unit.src.start..position + unit.src.end), + ); + previous_end = read + .last() + .map_or(empty_caret, |unit| position + unit.src.end); + position += node.node_size(); + nodes.push(node); + } + let target_text: String = target + .iter() + .map(|token| token.character().unwrap_or('\u{fffc}')) + .collect(); + let boundaries = scalar_boundaries(&target_text); + // A newly inserted combining mark can join an unselected neighbor. Native + // selections must enclose that new grapheme, never point into its interior. + let selection_start = if start == replacement_end { + *boundaries.iter().find(|boundary| **boundary >= start)? + } else { + *boundaries + .iter() + .rev() + .find(|boundary| **boundary <= start)? + }; + let selection_end = *boundaries + .iter() + .find(|boundary| **boundary >= replacement_end)?; + let target_from = reading_spans + .get(selection_start) + .map(|span| span.start) + .or_else(|| reading_spans.last().map(|span| span.end)) + .unwrap_or(first_caret); + let target_to = selection_end + .checked_sub(1) + .filter(|_| selection_end > selection_start) + .and_then(|index| reading_spans.get(index)) + .map_or(target_from, |span| span.end); + let selection = if policy == ReadingReplacementPolicy::InheritSelectionStart { + Selection::cursor(target_to) + } else if start == replacement_end { + Selection::cursor(target_from) + } else { + Selection::text(target_from, target_to) + }; + let spec = TransactionSpec::new() + .changes([Change::replace( + first.start - 1, + last.start + last.node.content_size() + 1, + Slice::from_fragment(Fragment::from_nodes(nodes.clone())), + )]) + .selection(selection) + .user_event(event::INPUT_REPLACE) + .scroll_into_view(); + // Corrections may reinterpret Markdown at structural boundaries. Require + // the actual corrected paragraphs to equal the validated intended nodes. + let transaction = state.update([spec.clone()]).ok()?; + let mut actual = Vec::new(); + transaction + .new_doc() + .nodes_between(first.start - 1, position - 1, &mut |node, _, _, _| { + actual.push(node.clone()); + false + }); + if actual.len() != nodes.len() { + return None; + } + for (actual, wanted) in actual.iter().zip(&nodes) { + if actual.type_id() != wanted.type_id() + || Items::from_nodes(schema, actual.children()) + != Items::from_nodes(schema, wanted.children()) + { + return None; + } + } + Some(spec) +} + +fn diff_replacement( + old: &[Token], + start: usize, + selected_text: &str, + replacement: &str, + source_start: usize, +) -> Vec { + let original_graphemes = selected_text.graphemes(true).collect::>(); + let new_graphemes = replacement.graphemes(true).collect::>(); + let original_offsets = scalar_boundaries(selected_text); + let mut inserted = Vec::new(); + // Similar's deadline fallback emits a valid coarser replacement for an + // unresolved region. It never blocks the UI trying to prove a minimal diff. + let deadline = Instant::now() + Duration::from_millis(12); + for operation in capture_diff_slices_deadline( + Algorithm::Myers, + &original_graphemes, + &new_graphemes, + Some(deadline), + ) { + let old_range = operation.old_range(); + let old_start = start + original_offsets[old_range.start]; + let old_end = start + original_offsets[old_range.end]; + if operation.tag() == DiffTag::Equal { + inserted.extend_from_slice(&old[old_start..old_end]); + continue; + } + // A replacement inherits its first removed character. An insertion + // uses the preceding character unless it follows a paragraph boundary. + let inherited = if old_start < old_end { + old.get(old_start) + .filter(|token| token.unit.is_some()) + .or_else(|| old_start.checked_sub(1).and_then(|index| old.get(index))) + } else { + old_start + .checked_sub(1) + .and_then(|index| old.get(index)) + .filter(|token| token.unit.is_some()) + .or_else(|| old.get(old_start)) + }; + let paragraph = inherited.map_or(0, |token| token.paragraph); + let styles = inherited + .and_then(|token| token.unit.as_ref()) + .map_or_else(Vec::new, |unit| unit.styles.clone()); + for grapheme in &new_graphemes[operation.new_range()] { + for character in grapheme.chars() { + inserted.push(Token { + unit: (character != '\n').then(|| Unit { + content: Content::Char(character), + styles: styles.clone(), + src: 0..0, + }), + origin: None, + paragraph, + source: source_start..source_start, + }); + } + } + } + inserted +} + +fn scalar_boundaries(text: &str) -> Vec { + let mut position = 0; + let mut boundaries = vec![0]; + for grapheme in text.graphemes(true) { + position += grapheme.chars().count(); + boundaries.push(position); + } + boundaries +} + +fn render( + schema: &Schema, + block: &Block, + owner: usize, + tokens: &[Token], + house: HouseStyle, +) -> Option<(Node, Vec)> { + if tokens.len() == block.units.len() + && tokens + .iter() + .enumerate() + .all(|(index, token)| token.origin == Some((owner, index))) + { + return Some((block.node.clone(), block.units.clone())); + } + let units = tokens + .iter() + .map(|token| token.unit.clone()) + .collect::>>()?; + let target = units + .iter() + .map(|unit| unit.styles.clone()) + .collect::>(); + // Prefer a bounded source rewrite, retaining untouched entity spellings + // and reference links byte for byte. + let local = local_source(schema, block, owner, tokens, house).or_else(|| { + // Budget exhaustion merges the selected changes into one source patch; + // keep the unselected prefix/suffix instead of respelling the paragraph. + tokens + .iter() + .all(|token| token.origin.is_none_or(|(paragraph, _)| paragraph == owner)) + .then(|| local_single(schema, block, owner, tokens, &units, house)) + .flatten() + }); + if let Some(items) = local { + let derived = derive(block.kind, &items.text(), &block.ctx); + let read = units_of(&items, &derived); + if check(&units, &target, &read).is_ok() { + return Some(( + block + .node + .copy(Fragment::from_nodes(items_to_nodes(schema, &items.0))), + read, + )); + } + } + let (source, atoms) = spell_units( + schema, + block, + units + .iter() + .map(|unit| (&unit.content, unit.styles.as_slice())), + &[], + 0, + true, + house, + ) + .ok()?; + let items = Items(spelled_items(&source, atoms).ok()?); + let derived = derive(block.kind, &items.text(), &block.ctx); + let read = units_of(&items, &derived); + check(&units, &target, &read).is_ok().then(|| { + ( + block + .node + .copy(Fragment::from_nodes(items_to_nodes(schema, &items.0))), + read, + ) + }) +} + +fn local_source( + schema: &Schema, + block: &Block, + owner: usize, + tokens: &[Token], + house: HouseStyle, +) -> Option { + if tokens.iter().any(|token| { + token + .origin + .is_some_and(|(paragraph, _)| paragraph != owner) + }) { + return None; + } + // Plan disjoint changed stretches independently. A proofread sentence may + // fix its first and last word while the reference-link or entity spelling + // in between must remain byte-for-byte intact. + let original = block + .units + .iter() + .enumerate() + .map(|(index, unit)| Token { + unit: Some(unit.clone()), + origin: Some((owner, index)), + paragraph: owner, + source: block.start + unit.src.start..block.start + unit.src.end, + }) + .collect::>(); + // Each local patch rederives its whole paragraph. Bound that cumulative + // work; larger edits use one validated whole-paragraph spelling instead. + let mut remaining_work = 256 * 1024usize; + let mut patch = |old_range: Range, new_range: Range| { + remaining_work = remaining_work.checked_sub(block.items.len().max(1))?; + let mut partial = original[..old_range.start].to_vec(); + partial.extend_from_slice(&tokens[new_range.clone()]); + partial.extend_from_slice(&original[old_range.end..]); + let partial_units = partial + .iter() + .filter_map(|token| token.unit.clone()) + .collect::>(); + let items = local_single(schema, block, owner, &partial, &partial_units, house)?; + let kept = KeptEnds::of(&block.items.0, &items.0, |a, b| a == b); + Some(SourcePatch { + old_units: old_range, + new_units: new_range, + source: kept.old_middle(), + items: items.0[kept.new_middle()].to_vec(), + }) + }; + let mut changes: Vec = Vec::new(); + let (mut old_at, mut new_at) = (0, 0); + while old_at < original.len() || new_at < tokens.len() { + if tokens + .get(new_at) + .is_some_and(|token| token.origin == Some((owner, old_at))) + { + old_at += 1; + new_at += 1; + continue; + } + let next = tokens[new_at..] + .iter() + .enumerate() + .find_map(|(index, token)| { + token + .origin + .map(|(_, index_old)| (new_at + index, index_old)) + }) + .unwrap_or((tokens.len(), original.len())); + let mut change = patch(old_at..next.1, new_at..next.0)?; + while changes + .last() + .is_some_and(|previous| previous.source.end > change.source.start) + { + let previous = changes.pop()?; + change = patch( + previous.old_units.start..change.old_units.end, + previous.new_units.start..change.new_units.end, + )?; + } + changes.push(change); + (old_at, new_at) = (next.1, next.0); + } + let mut items = block.items.0.clone(); + for change in changes.into_iter().rev() { + items.splice(change.source, change.items); + } + Some(Items(items)) +} + +fn local_single( + schema: &Schema, + block: &Block, + owner: usize, + tokens: &[Token], + units: &[Unit], + house: HouseStyle, +) -> Option { + let prefix = tokens + .iter() + .enumerate() + .take_while(|(index, token)| token.origin == Some((owner, *index))) + .count(); + let suffix = tokens[prefix..] + .iter() + .rev() + .zip((prefix..block.units.len()).rev()) + .take_while(|(token, index)| token.origin == Some((owner, *index))) + .count(); + let mut lo = block.units.get(prefix).map_or_else( + || block.units.last().map_or(0, |unit| unit.src.end), + |unit| unit.src.start, + ); + let mut hi = block + .units + .len() + .checked_sub(suffix + 1) + .and_then(|index| block.units.get(index)) + .map_or(lo, |unit| unit.src.end) + .max(lo); + let mut context; + loop { + context = Vec::new(); + let mut grew = false; + for span in &block.derived.styles { + if span.range.end < lo || span.range.start > hi { + continue; + } + let (open, close) = delimiters(&block.derived, span); + if open.end <= lo + && close.start >= hi + && units[prefix..units.len() - suffix] + .iter() + .all(|unit| unit.styles.contains(&span.style)) + { + context.push(span.style.clone()); + } else if span.range.start < lo || span.range.end > hi { + lo = lo.min(span.range.start); + hi = hi.max(span.range.end); + grew = true; + } + } + if !grew { + break; + } + } + let inside = tokens + .iter() + .filter(|token| match token.origin { + Some((_, index)) => { + block.units[index].src.start >= lo && block.units[index].src.end <= hi + } + None => true, + }) + .filter_map(|token| token.unit.as_ref()) + .collect::>(); + let (source, atoms) = spell_units( + schema, + block, + inside + .iter() + .map(|unit| (&unit.content, unit.styles.as_slice())), + &context, + lo, + true, + house, + ) + .ok()?; + let mut items = block.items.0[..lo].to_vec(); + items.extend(spelled_items(&source, atoms).ok()?); + items.extend_from_slice(&block.items.0[hi..]); + let items = Items(items); + let read = units_of(&items, &derive(block.kind, &items.text(), &block.ctx)); + let target = units + .iter() + .map(|unit| unit.styles.clone()) + .collect::>(); + check(units, &target, &read).is_ok().then_some(items) +} diff --git a/crates/markraft-commonmark/src/lib.rs b/crates/markraft-commonmark/src/lib.rs index 98324b46..7c648217 100644 --- a/crates/markraft-commonmark/src/lib.rs +++ b/crates/markraft-commonmark/src/lib.rs @@ -166,7 +166,7 @@ pub use kind::{ CommandRefusal, CommonMarkCodecs, CommonMarkSpelling, FormatCommand, Formatted, Formatter, Inexpressible, commonmark_doc_type_names, }; -pub use pairs::commonmark_auto_pairs; +pub use pairs::{commonmark_auto_pairs, commonmark_auto_pairs_with_quotes}; pub use parse::{MarkdownParser, ParseError, commonmark_options}; pub use preset::{ commonmark_mark_rules, commonmark_node_rules, commonmark_serializer, inline_link_mark_rule, diff --git a/crates/markraft-commonmark/src/pairs.rs b/crates/markraft-commonmark/src/pairs.rs index 7d610970..5d09f676 100644 --- a/crates/markraft-commonmark/src/pairs.rs +++ b/crates/markraft-commonmark/src/pairs.rs @@ -67,6 +67,7 @@ use markraft_core::{ use crate::derive::{DeriveContext, Style, derive}; use crate::textblock::{Items, block_kind}; +use markraft_core::kind::ReadingSelection; use markraft_core::protocol::event; /// Every pair, opener first. @@ -116,11 +117,22 @@ static FIELD: LazyLock>> = /// off and on without rebuilding its states. Turned off, typing is plain /// typing, and the closers already written are left alone. pub fn commonmark_auto_pairs(enabled: Arc) -> Extension { + commonmark_auto_pairs_with_quotes(enabled, Arc::new(AtomicBool::new(true))) +} + +/// Auto-pairing with an independent flag for straight double quotes. A host +/// performing native smart-quote replacement disables quote pairing so it does +/// not leave a second closer behind when the opener becomes a curly quote. +/// Brackets and existing tracked closers retain their ordinary behavior. +pub fn commonmark_auto_pairs_with_quotes( + enabled: Arc, + quotes_enabled: Arc, +) -> Extension { let filter: TransactionFilterFn = Arc::new(move |tr: &Transaction| { if !enabled.load(Ordering::Relaxed) { return None; } - auto_pair(tr) + auto_pair(tr, quotes_enabled.load(Ordering::Relaxed)) }); Extension::all([transaction_filter().of(filter), FIELD.extension()]) } @@ -174,7 +186,7 @@ fn update(value: &Vec, tr: &Transaction) -> Vec { // -- the filter ---------------------------------------------------------------- -fn auto_pair(tr: &Transaction) -> Option> { +fn auto_pair(tr: &Transaction, quotes_enabled: bool) -> Option> { let state = tr.start_state(); if !tr.doc_changed() || !tr.recorded_in_history() @@ -186,7 +198,7 @@ fn auto_pair(tr: &Transaction) -> Option> { if tr.is_user_event(event::INPUT_TYPE) && let Some(typed) = Typed::read(tr) { - return typed.respond(tr); + return typed.respond(tr, quotes_enabled); } take_closers(tr) } @@ -205,9 +217,11 @@ impl Typed { fn read(tr: &Transaction) -> Option { let state = tr.start_state(); let doc = state.doc(); - let Selection::Text { .. } = state.selection() else { + if !matches!(state.selection(), Selection::Text { .. }) + && !ReadingSelection::is(state.selection()) + { return None; - }; + } let range = state.selection().replacement_range(doc); let changes = tr.changes().iter_changes(); let [ @@ -242,7 +256,7 @@ impl Typed { }) } - fn respond(&self, tr: &Transaction) -> Option> { + fn respond(&self, tr: &Transaction, quotes_enabled: bool) -> Option> { let state = tr.start_state(); let schema = state.schema(); let block = Block::at(schema, state.doc(), self.from)?; @@ -250,9 +264,15 @@ impl Typed { return None; } if self.from == self.to { - return self.step_over(tr, &block).or_else(|| self.pair(tr, &block)); + return self.step_over(tr, &block).or_else(|| { + (quotes_enabled || self.character != '"') + .then(|| self.pair(tr, &block)) + .flatten() + }); } - self.wrap(tr, &block) + (quotes_enabled || self.character != '"') + .then(|| self.wrap(tr, &block)) + .flatten() } /// Typing a tracked closer right before it moves the caret past it. @@ -343,13 +363,20 @@ impl Typed { }; let (anchor, head) = (state.selection().anchor(doc), state.selection().head(doc)); let inner = |pos: usize| pos + 1; + // The wrapped text keeps the kind of selection it had. + let selection = match state.schema().mark_id(crate::schema::SYNTAX) { + Some(syntax) if ReadingSelection::is(state.selection()) => { + ReadingSelection::selection(inner(anchor), inner(head), syntax) + } + _ => Selection::text(inner(anchor), inner(head)), + }; Some(vec![ TransactionSpec::new() .changes([ Change::insert(self.from, text(self.character)), Change::insert(self.to, text(closer)), ]) - .selection(Selection::text(inner(anchor), inner(head))) + .selection(selection) .user_event(event::INPUT_TYPE) .scroll_into_view(), ]) diff --git a/crates/markraft-commonmark/tests/pairs.rs b/crates/markraft-commonmark/tests/pairs.rs index c3be7714..9a2c91d3 100644 --- a/crates/markraft-commonmark/tests/pairs.rs +++ b/crates/markraft-commonmark/tests/pairs.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use markraft_commonmark::{ - commonmark_auto_pairs, commonmark_extensions, commonmark_schema, from_markdown, + commonmark_auto_pairs_with_quotes, commonmark_extensions, commonmark_schema, from_markdown, from_markdown_fragment, to_markdown, }; use markraft_core::commands::{ @@ -14,18 +14,28 @@ use markraft_core::commands::{ }; use markraft_core::composition::{CompositionRange, start_composition, update_composition}; use markraft_core::history::{HistoryConfig, history, undo}; +use markraft_core::kind::ReadingSelection; use markraft_core::{EditorState, EditorStateConfig, Extension, Selection, TransactionSpec}; /// An editor over `markdown` with the caret where `|` is, auto-pairing /// switched by the returned flag. fn editor(markdown: &str) -> (EditorState, Arc) { + let (state, enabled, _) = editor_with_quotes(markdown, true); + (state, enabled) +} + +fn editor_with_quotes( + markdown: &str, + quotes: bool, +) -> (EditorState, Arc, Arc) { let (source, caret) = split_caret(markdown); let schema = commonmark_schema(); let doc = from_markdown(&schema, &source).expect("the source parses"); let enabled = Arc::new(AtomicBool::new(true)); + let quotes_enabled = Arc::new(AtomicBool::new(quotes)); let extensions = Extension::all([ commonmark_extensions(&schema), - commonmark_auto_pairs(enabled.clone()), + commonmark_auto_pairs_with_quotes(enabled.clone(), quotes_enabled.clone()), history(HistoryConfig::default()), markraft_core::composition::composition(), ]); @@ -53,7 +63,7 @@ fn editor(markdown: &str) -> (EditorState, Arc) { ) } }; - (state, enabled) + (state, enabled, quotes_enabled) } /// `markdown` without its caret mark `|` or selection marks `‹…›`, and where @@ -238,6 +248,27 @@ fn an_opener_over_a_selection_wraps_it() { assert_eq!(shown(&typed(&state, "x")), "a x| d"); } +#[test] +fn an_opener_over_a_selection_across_concealed_spelling_wraps_it() { + let (state, _) = editor("Mark**raft** two"); + let syntax = state + .schema() + .mark_id(markraft_commonmark::schema::SYNTAX) + .expect("a syntax mark"); + let (from, to) = (pos_of(&state, 0), pos_of(&state, 12)); + let state = apply( + &state, + vec![TransactionSpec::new().selection(ReadingSelection::selection(from, to, syntax))], + ); + let wrapped = typed(&state, "("); + assert_eq!( + to_markdown(wrapped.schema(), wrapped.doc()), + "(Mark**raft**) two" + ); + assert!(ReadingSelection::is(wrapped.selection())); + assert_eq!(shown(&wrapped), "(‹Mark**raft**›) two"); +} + #[test] fn quotes_pair_only_after_something_that_is_not_a_word() { let (state, _) = editor("5|"); @@ -369,3 +400,50 @@ fn a_footnote_and_a_task_type_through() { let state = typed(&state, "[ ] task"); assert_eq!(to_markdown(state.schema(), state.doc()), "- [ ] task"); } + +#[test] +fn smart_quote_mode_has_no_automatic_straight_closer_to_leave_behind() { + fn replace_character(state: &EditorState, position: usize, text: &str) -> EditorState { + let slice = markraft_core::Slice::from_fragment(markraft_core::Fragment::from_node( + state.schema().text(text), + )); + apply( + state, + vec![ + TransactionSpec::new() + .changes([markraft_core::Change::replace( + position, + position + 1, + slice, + )]) + .user_event(markraft_core::protocol::event::INPUT_REPLACE), + ], + ) + } + let (state, _, quotes) = editor_with_quotes("", false); + let state = typed(&state, "\""); + assert_eq!(shown(&state), "\"|"); + let state = replace_character(&state, 1, "“"); + let state = typed(&state, "hello\""); + let state = replace_character(&state, 7, "”"); + assert_eq!(shown(&state), "“hello”|"); + // Turning smart quotes off restores the original pairing immediately. + quotes.store(true, Ordering::Relaxed); + assert_eq!(shown(&typed(&state, " \"")), "“hello” \"|\""); +} + +#[test] +fn suppressing_quote_pairs_keeps_brackets_code_and_existing_closers_working() { + let (state, _, _) = editor_with_quotes("", false); + assert_eq!(shown(&typed(&state, "(hi)")), "(hi)|"); + let (state, _, _) = editor_with_quotes("‹hi›", false); + assert_eq!(shown(&typed(&state, "\"")), "\"|"); + let (state, _, quotes) = editor_with_quotes("", true); + let state = typed(&state, "\"hello"); + quotes.store(false, Ordering::Relaxed); + assert_eq!(shown(&typed(&state, "\"")), "\"hello\"|"); + for enabled in [false, true] { + let (state, _, _) = editor_with_quotes("`code |`", enabled); + assert_eq!(shown(&typed(&state, "\"")), "`code \"|`"); + } +} diff --git a/crates/markraft-commonmark/tests/reading_replacement.rs b/crates/markraft-commonmark/tests/reading_replacement.rs new file mode 100644 index 00000000..1fe00f9d --- /dev/null +++ b/crates/markraft-commonmark/tests/reading_replacement.rs @@ -0,0 +1,276 @@ +//! System service rewrites operate on reading text, preserving source styles. + +mod common; + +use common::Codec; +use markraft_commonmark::{CommonMarkCodecs, Formatter, commonmark_extensions}; +use markraft_core::{ + EditorState, EditorStateConfig, Extension, Selection, Slice, + history::{HistoryConfig, history, redo, undo}, + kind::{Codecs, ReadingReplacementPolicy}, +}; + +fn editor(codec: &Codec, source: &str) -> EditorState { + EditorState::create( + EditorStateConfig::new(codec.schema.clone()) + .doc(codec.parse(source)) + .selection(Selection::All) + .extensions(Extension::all([ + commonmark_extensions(&codec.schema), + history(HistoryConfig::default()), + ])), + ) + .unwrap() +} + +fn reading(codec: &Codec, state: &EditorState) -> String { + CommonMarkCodecs::new(codec.schema.clone(), codec.house.clone()) + .to_text(&Slice::from_fragment(state.doc().content().clone())) +} + +fn replace(codec: &Codec, state: &EditorState, text: &str) -> EditorState { + let range = state.selection().from(state.doc())..state.selection().to(state.doc()); + let command = Formatter::new(codec.house.clone()).replace_reading( + range, + text, + ReadingReplacementPolicy::PreserveUnchanged, + ); + let spec = command(state) + .unwrap() + .expect("supported reading replacement"); + let changed = state.update([spec]).unwrap().state().clone(); + assert_eq!( + reading(codec, &changed), + text, + "{}", + codec.write(changed.doc()) + ); + assert_eq!(codec.parse(&codec.write(changed.doc())), *changed.doc()); + let undone = changed + .update([undo(&changed).unwrap()]) + .unwrap() + .state() + .clone(); + assert_eq!(*undone.doc(), *state.doc()); + let redone = undone + .update([redo(&undone).unwrap()]) + .unwrap() + .state() + .clone(); + assert_eq!(*redone.doc(), *changed.doc()); + changed +} + +#[test] +fn plain_service_partial_replacement_preserves_unselected_styles_and_source() { + let codec = Codec::new(); + let source = "before & **red** _blue_ after © ![image](x.png)"; + let state = editor(&codec, source); + let start = source.find("red").unwrap() + 1; + let end = source.find("blue").unwrap() + 1 + 4; + let command = Formatter::new(codec.house.clone()).replace_reading( + start..end, + "red blue", + ReadingReplacementPolicy::InheritSelectionStart, + ); + let changed = state + .update([command(&state).unwrap().unwrap()]) + .unwrap() + .state() + .clone(); + let written = codec.write(changed.doc()); + assert_eq!( + written, + "before & **red blue** after © ![image](x.png)" + ); + assert_eq!(codec.parse(&written), *changed.doc()); + assert_eq!( + *changed.update([undo(&changed).unwrap()]).unwrap().new_doc(), + *state.doc() + ); +} + +#[test] +fn variable_length_unicode_edits_preserve_unchanged_inline_source() { + let codec = Codec::new(); + let before = editor( + &codec, + "Keep & **café** then _later_.\n\nOther paragraph.", + ); + let after = replace( + &codec, + &before, + "Keep & café 😀 then later.\nOther paragraphs are longer.", + ); + let source = codec.write(after.doc()); + assert!( + source.contains("Keep & **café** 😀 then _later_."), + "{source}" + ); +} + +#[test] +fn changed_words_and_equal_graphemes_keep_their_character_styles() { + let codec = Codec::new(); + let before = editor(&codec, "**hello** _world_ 中文 👩‍💻"); + let after = replace(&codec, &before, "greetings world 中文 👩‍🚀"); + let source = codec.write(after.doc()); + assert!(source.contains("_world_"), "{source}"); + assert!(!source.contains("👩‍💻")); + assert!(source.contains("greetings")); +} + +#[test] +fn paragraph_insertion_and_deletion_support_full_rewrites() { + let codec = Codec::new(); + let before = editor(&codec, "**first**\n\n_second_\n\nthird"); + replace(&codec, &before, "Replacement 😀\nMore words"); + replace(&codec, &before, "Only one paragraph"); + replace(&codec, &before, ""); +} + +#[test] +fn partial_selection_keeps_the_unselected_source_and_styles() { + let codec = Codec::new(); + let source = "before **hello world** after &"; + let initial = editor(&codec, source); + let state = initial + .update([markraft_core::TransactionSpec::new().selection(Selection::text(10, 15))]) + .unwrap() + .state() + .clone(); + let command = Formatter::new(codec.house.clone()).replace_reading( + 10..15, + "hi 😀", + ReadingReplacementPolicy::PreserveUnchanged, + ); + let after = state + .update([command(&state).unwrap().unwrap()]) + .unwrap() + .state() + .clone(); + assert_eq!( + codec.write(after.doc()), + "before **hi 😀 world** after &" + ); + assert_eq!(codec.parse(&codec.write(after.doc())), *after.doc()); +} + +#[test] +fn inserted_combining_marks_keep_selection_on_new_grapheme_boundaries() { + let codec = Codec::new(); + let state = editor(&codec, "ab"); + let command = Formatter::new(codec.house.clone()).replace_reading( + 2..3, + "\u{301}", + ReadingReplacementPolicy::PreserveUnchanged, + ); + let after = state + .update([command(&state).unwrap().unwrap()]) + .unwrap() + .state() + .clone(); + assert_eq!(reading(&codec, &after), "a\u{301}"); + assert_eq!(after.selection(), &Selection::text(1, 3)); +} + +#[test] +fn many_separated_edits_preserve_reading_and_styles_with_bounded_local_spelling() { + let codec = Codec::new(); + let source = format!("**{}**", "a word ".repeat(1000).trim_end()); + let state = editor(&codec, &source); + let output = "b word ".repeat(1000).trim_end().to_owned(); + let after = replace(&codec, &state, &output); + let written = codec.write(after.doc()); + assert!( + written.starts_with("**") || written.starts_with(" Command { if state.selection().is_empty(doc) { return None; } + if matches!(state.selection(), Selection::Custom(_)) { + return super::text::custom_replacement_spec( + state, + Slice::empty(), + ReplacementStyle::Own, + event::DELETE_SELECTION, + ); + } let range = state.selection().replacement_range(doc); let (set, new_doc) = resolve_changes( state, diff --git a/crates/markraft-core/src/commands/mod.rs b/crates/markraft-core/src/commands/mod.rs index 4fda8da3..54e13911 100644 --- a/crates/markraft-core/src/commands/mod.rs +++ b/crates/markraft-core/src/commands/mod.rs @@ -84,7 +84,7 @@ pub use table::{ }; pub use text::{ delete_range, delete_range_changes, insert_hard_break, insert_node, insert_text, - replace_selection, replace_selection_changes, + replace_selection, replace_selection_as, replace_selection_changes, }; use std::sync::Arc; diff --git a/crates/markraft-core/src/commands/text.rs b/crates/markraft-core/src/commands/text.rs index 63294357..1ba482d8 100644 --- a/crates/markraft-core/src/commands/text.rs +++ b/crates/markraft-core/src/commands/text.rs @@ -7,7 +7,7 @@ use crate::mark::MarkSet; use crate::node::Node; use crate::pos::ResolvedPos; use crate::schema::{NodeTypeId, Schema}; -use crate::selection::Selection; +use crate::selection::{ReplacementStyle, Selection}; use crate::slice::{Slice, Token}; use crate::state::{EditorState, TransactionSpec}; @@ -38,6 +38,17 @@ pub(crate) fn insert_text_spec(state: &EditorState, text: &str) -> Option Option Vec< /// caret sits in while the closed nodes in between stay whole. A fully closed /// slice of block content splits the textblock around it instead. pub fn replace_selection(slice: Slice) -> Command { + replace_selection_as(slice, ReplacementStyle::Own, event::INPUT_PASTE) +} + +/// Replace through the same selection contract, saying where the content gets +/// its styles and what the edit is. Typing and literal-text insertion take the +/// receiving style, so a visible selection keeps it; a rich paste brings its own. +pub fn replace_selection_as(slice: Slice, style: ReplacementStyle, event: &str) -> Command { + let event = event.to_owned(); command(move |state| { + if matches!(state.selection(), Selection::Custom(_)) { + return custom_replacement_spec(state, slice.clone(), style, &event); + } let doc = state.doc(); let schema = state.schema(); let range = state.selection().replacement_range(doc); @@ -289,12 +314,50 @@ pub fn replace_selection(slice: Slice) -> Command { TransactionSpec::new() .change_set(set) .selection(Selection::near(schema, &new_doc, end, -1)) - .user_event(event::INPUT_PASTE) + .user_event(&event) .scroll_into_view(), ) }) } +/// Resolve a custom selection's replacement contract without running extension +/// filters during command construction. The hook owns its changes and may own +/// the resulting caret; ordinary command metadata is applied at this boundary. +pub(super) fn custom_replacement_spec( + state: &EditorState, + slice: Slice, + style: ReplacementStyle, + event: &str, +) -> Option { + let Selection::Custom(kind) = state.selection() else { + return None; + }; + let range = kind.replacement_range(state.doc()); + let spec = kind.replace_with_schema( + TransactionSpec::new().user_event(event), + state.doc(), + state.schema(), + slice, + style, + ); + let (changes, doc, explicit_selection) = spec.resolve_edit(state).ok()?; + if changes.is_empty() { + return None; + } + let selection = explicit_selection.unwrap_or_else(|| { + let end = changes + .map_pos(range.to, 1, TrackMode::Simple) + .unwrap_or(doc.content_size()); + Selection::near(state.schema(), &doc, end, -1) + }); + selection.check(&doc, state.schema()).ok()?; + Some( + spec.change_set(changes) + .selection(selection) + .scroll_into_view(), + ) +} + /// The changes [`replace_selection`] performs. /// /// Exposed so a paste handler can combine them with its own edits. diff --git a/crates/markraft-core/src/composition.rs b/crates/markraft-core/src/composition.rs index 3801cf44..074cf510 100644 --- a/crates/markraft-core/src/composition.rs +++ b/crates/markraft-core/src/composition.rs @@ -219,6 +219,22 @@ pub fn update_composition( state: &EditorState, text: &str, caret: usize, +) -> Result { + update_composition_with(state, text, caret, &crate::commands::insert_text(text)) +} + +/// Update composition using the host's insertion contract, for example a +/// source-backed kind that keeps concealed delimiters balanced. The +/// command must insert `text` and leave its caret immediately after that text. +/// +/// # Errors +/// +/// Fails when insertion cannot be expressed or produces an invalid document. +pub fn update_composition_with( + state: &EditorState, + text: &str, + caret: usize, + insert: &crate::commands::Command, ) -> Result { let doc = state.doc(); let (from, to) = match composition_range(state) { @@ -230,17 +246,35 @@ pub fn update_composition( }; let length = text.chars().count(); let (changes, next_doc, end) = if text.is_empty() { - let changes = ChangeSet::create( - state.schema(), - doc, - [Change::delete(from, to).with_fit(Fit::Auto)], - )?; - let next_doc = changes.apply(doc)?; - let mapped = changes - .map_pos(to, 1, TrackMode::Simple) - .unwrap_or(next_doc.content_size()); - let end = Selection::near(state.schema(), &next_doc, mapped, -1).head(&next_doc); - (changes, next_doc, end) + if composition_range(state).is_none() && matches!(state.selection(), Selection::Custom(_)) { + let spec = state.selection().replace_with_schema( + TransactionSpec::new(), + doc, + state.schema(), + crate::Slice::empty(), + crate::ReplacementStyle::Own, + ); + let (changes, next_doc, selected) = spec.resolve_edit(state)?; + let mapped = changes + .map_pos(to, 1, TrackMode::Simple) + .unwrap_or(next_doc.content_size()); + let end = selected + .unwrap_or_else(|| Selection::near(state.schema(), &next_doc, mapped, -1)) + .head(&next_doc); + (changes, next_doc, end) + } else { + let changes = ChangeSet::create( + state.schema(), + doc, + [Change::delete(from, to).with_fit(Fit::Auto)], + )?; + let next_doc = changes.apply(doc)?; + let mapped = changes + .map_pos(to, 1, TrackMode::Simple) + .unwrap_or(next_doc.content_size()); + let end = Selection::near(state.schema(), &next_doc, mapped, -1).head(&next_doc); + (changes, next_doc, end) + } } else { // Share typing's fitted caret and inline-scope handling. Only its // document change is used; the composition remains one transaction. @@ -254,7 +288,7 @@ pub fn update_composition( } else { state.clone() }; - let spec = crate::commands::insert_text(text)(&selected).ok_or_else(|| { + let spec = insert(&selected).ok_or_else(|| { StateError::Node(NodeError::InvalidText( "the schema cannot insert the composition candidate".into(), )) diff --git a/crates/markraft-core/src/kind/chains.rs b/crates/markraft-core/src/kind/chains.rs index e3af877d..7ac074b3 100644 --- a/crates/markraft-core/src/kind/chains.rs +++ b/crates/markraft-core/src/kind/chains.rs @@ -1237,7 +1237,11 @@ pub fn insert_plain(types: &DocTypes, text: &str) -> Command { }) .collect(); let slice = crate::Slice::new(crate::Fragment::from_nodes(nodes?), 1, 1); - crate::commands::replace_selection(slice)(state) + crate::commands::replace_selection_as( + slice, + crate::ReplacementStyle::Receiving, + event::INPUT_TYPE, + )(state) }) } diff --git a/crates/markraft-core/src/kind/conceal.rs b/crates/markraft-core/src/kind/conceal.rs index 9e8cbc61..92c09e38 100644 --- a/crates/markraft-core/src/kind/conceal.rs +++ b/crates/markraft-core/src/kind/conceal.rs @@ -405,6 +405,30 @@ pub fn markup_safe( syntax: Option, range: Range, keep_emptied: bool, +) -> Vec> { + markup_safe_with(projection, syntax, range, |_, _| keep_emptied) +} + +/// Source ranges for replacing visible text while retaining the insertion +/// point's enclosing styles. Empty inner styles go away; only paired wrappers +/// containing the leading content position survive to enclose the new text. +/// Single-run substitutions such as entities are always replaced in full. +pub fn markup_replacement( + projection: &Projection, + syntax: Option, + range: Range, +) -> Vec> { + let insertion = range.start; + markup_safe_with(projection, syntax, range, |runs, content| { + runs.len() > 1 && content.contains(&insertion) + }) +} + +fn markup_safe_with( + projection: &Projection, + syntax: Option, + range: Range, + keep_emptied: impl Fn(&[Range], &Range) -> bool, ) -> Vec> { let mut take = vec![range.clone()]; let mut keep = Vec::new(); @@ -426,7 +450,7 @@ pub fn markup_safe( }; let emptied = !content.is_empty() && range.start <= content.start && content.end <= range.end; - if emptied && !keep_emptied { + if emptied && !keep_emptied(&runs, &content) { take.push(first.start..last.end); } else { keep.extend(runs); diff --git a/crates/markraft-core/src/kind/mod.rs b/crates/markraft-core/src/kind/mod.rs index e46cfdfc..23ef7731 100644 --- a/crates/markraft-core/src/kind/mod.rs +++ b/crates/markraft-core/src/kind/mod.rs @@ -25,6 +25,7 @@ pub mod equations; pub mod footnotes; pub mod math; pub mod reading; +mod reading_selection; pub mod types; use std::ops::Range; @@ -38,6 +39,7 @@ use crate::schema::MarkTypeId; use crate::slice::Slice; use crate::state::{EditorState, TransactionSpec}; +pub use reading_selection::ReadingSelection; pub use types::{CalloutAttrs, DocTypes}; /// A document kind's formatting edit over a state: `Ok(None)` where it does not @@ -46,6 +48,18 @@ pub use types::{CalloutAttrs, DocTypes}; pub type Formatting = Arc Result, String> + Send + Sync>; +/// How plain text that replaces reading text obtains its styles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ReadingReplacementPolicy { + /// A correction: characters the replacement leaves unchanged keep their + /// styles, and new ones take the style of where they land. + #[default] + PreserveUnchanged, + /// A rewrite: the whole replacement takes the styles of the selection's + /// first character, unchanged words included, and the caret follows it. + InheritSelectionStart, +} + /// How a document kind spells itself for a view: the behaviour behind the /// roles [`DocTypes`] names. /// @@ -84,6 +98,23 @@ pub trait DocumentKind: Send + Sync { let _ = (ty, url); None } + /// Replace rendered reading text while preserving this kind's source and + /// styles. `range` uses document positions; `text` contains no markup. + /// A supported unchanged replacement returns an empty transaction, allowing + /// hosts to query capability through the same validation as execution. + /// `Ok(None)` declines this structure and permits the host's existing + /// precise literal replacement. An error refuses the edit and must not + /// fall back to deleting concealed source indiscriminately. + /// `policy` says where the replacement's styles come from. + fn replace_reading( + &self, + range: Range, + text: &str, + policy: ReadingReplacementPolicy, + ) -> Option { + let _ = (range, text, policy); + None + } /// `split`, a command that splits a textblock at the caret, as this kind /// wants it run: one that spells styles in the text closes and reopens /// them around the cut. The default runs `split` as it is. diff --git a/crates/markraft-core/src/kind/reading.rs b/crates/markraft-core/src/kind/reading.rs index 908b58f2..6224aab5 100644 --- a/crates/markraft-core/src/kind/reading.rs +++ b/crates/markraft-core/src/kind/reading.rs @@ -39,10 +39,16 @@ pub fn line_pieces( .zip(pieces) .filter_map(|(run, piece)| { let (text, own) = if let RunContent::Atom(node) = &run.content { - ( - shown_atom_label(types, node).unwrap_or("").to_owned(), - false, - ) + if Some(node.type_id()) == types.hard_break { + // The projection owns this one-character newline. It is + // a text boundary, not an unlabeled embedded object. + (piece.text.to_owned(), piece.own) + } else { + ( + shown_atom_label(types, node).unwrap_or("").to_owned(), + false, + ) + } } else { (piece.text.to_owned(), piece.own) }; diff --git a/crates/markraft-core/src/kind/reading_selection.rs b/crates/markraft-core/src/kind/reading_selection.rs new file mode 100644 index 00000000..f05f14d0 --- /dev/null +++ b/crates/markraft-core/src/kind/reading_selection.rs @@ -0,0 +1,310 @@ +//! A visible-text selection whose source crosses concealed spelling. +//! The range remains the visual selection; only replacement owns delimiter +//! balancing, through the same concealment contract used by reading edits. +use std::any::Any; + +use serde_json::{Value, json}; + +use crate::{ + ChangeDesc, MarkTypeId, Node, NodeError, ReplacementStyle, Schema, Selection, SelectionKind, + Slice, TransactionSpec, +}; + +const TAG: &str = "reading-text"; + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Visible text whose source requires balanced delimiter replacement. +pub struct ReadingSelection { + anchor: usize, + head: usize, + syntax: MarkTypeId, +} + +impl ReadingSelection { + /// Select visible content across concealed spelling. Explicit source + /// selections remain ordinary text selections; this kind owns balanced + /// replacement without widening the visual range. An empty range is a + /// cursor. + pub fn selection(anchor: usize, head: usize, syntax: MarkTypeId) -> Selection { + if anchor == head { + Selection::cursor(head) + } else { + Selection::custom(Box::new(Self { + anchor, + head, + syntax, + })) + } + } + + /// Whether `selection` is a range of visible text across source spelling. + pub fn is(selection: &Selection) -> bool { + matches!(selection, Selection::Custom(kind) if kind.as_any().is::()) + } + + /// Read back what [`Selection::to_json`] wrote for this kind. The syntax + /// mark resolves by its schema name. + /// + /// # Errors + /// + /// Fails when `value` is not this kind's shape or names an unknown mark. + pub fn from_json(schema: &Schema, value: &Value) -> Result { + let number = |name: &str| { + value + .get(name) + .and_then(Value::as_u64) + .map(|n| n as usize) + .ok_or_else(|| NodeError::Json(format!("a selection needs `{name}`"))) + }; + if value.get("type").and_then(Value::as_str) != Some(TAG) { + return Err(NodeError::Json(format!("a selection of type `{TAG}`"))); + } + let syntax = value + .get("syntax") + .and_then(Value::as_str) + .and_then(|name| schema.mark_id(name)) + .ok_or_else(|| NodeError::Json(format!("{TAG} needs a known syntax mark")))?; + Ok(Self::selection(number("anchor")?, number("head")?, syntax)) + } +} + +impl SelectionKind for ReadingSelection { + fn tag(&self) -> &str { + TAG + } + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + fn as_any(&self) -> &dyn Any { + self + } + fn eq_kind(&self, other: &dyn SelectionKind) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + fn anchor(&self, _: &Node) -> usize { + self.anchor + } + fn head(&self, _: &Node) -> usize { + self.head + } + /// Without a schema the ends cannot be checked against inline content; + /// [`Selection::map`] takes [`Self::map_with_schema`] instead. + fn map(&self, doc: &Node, changes: &ChangeDesc) -> Selection { + let map = |position| { + changes + .map_pos(position, 1, Default::default()) + .unwrap_or(doc.content_size()) + .min(doc.content_size()) + }; + Self::selection(map(self.anchor), map(self.head), self.syntax) + } + fn map_with_schema(&self, schema: &Schema, doc: &Node, changes: &ChangeDesc) -> Selection { + match Selection::text(self.anchor, self.head).map(schema, doc, changes) { + Selection::Text { anchor, head } => Self::selection(anchor, head, self.syntax), + other => other, + } + } + fn to_json(&self, schema: &Schema) -> Value { + json!({"anchor":self.anchor, "head":self.head, "syntax":schema.mark_type(self.syntax).name()}) + } + fn content_with_schema(&self, doc: &Node, schema: &Schema) -> Slice { + let range = self.replacement_range(doc); + doc.slice_with_schema(schema, range.from, range.to) + .unwrap_or_else(|_| Slice::empty()) + } + fn replace_with_schema( + &self, + spec: TransactionSpec, + doc: &Node, + schema: &Schema, + slice: Slice, + style: ReplacementStyle, + ) -> TransactionSpec { + let projection = crate::projection::Projection::of(doc, schema); + let selected = self.replacement_range(doc); + let range = selected.from..selected.to; + let inherit = style == ReplacementStyle::Receiving; + let ranges = if slice.is_empty() || !inherit { + crate::kind::conceal::markup_safe(&projection, Some(self.syntax), range, false) + } else { + crate::kind::conceal::markup_replacement(&projection, Some(self.syntax), range) + }; + let (slice, ranges) = if inherit { + continue_paragraph_styles( + doc, + schema, + &projection, + self.syntax, + selected.from..selected.to, + slice, + ranges, + ) + } else { + (slice, ranges) + }; + let Some(first) = ranges.first() else { + return spec; + }; + // Keep the source range's style inheritance at the command boundary. + // An emptied inner span goes away; a partially retained span keeps its + // complete spelling instead of leaving an orphan opening/closing tag. + let mut changes = + crate::commands::replace_selection_changes(schema, doc, first.start, first.end, &slice); + changes.extend(ranges.iter().skip(1).flat_map(|range| { + crate::commands::delete_range_changes(schema, doc, range.start, range.end) + })); + // Additional protected gaps are after the primary insertion. Explicit + // caret mapping therefore follows its end rather than the original + // visual selection's far edge. + if let Ok(set) = crate::ChangeSet::create(schema, doc, changes.clone()) + && let Ok(next) = set.apply(doc) + { + let caret = set + .map_pos(first.end, 1, Default::default()) + .unwrap_or(next.content_size()); + return spec + .change_set(set) + .selection(Selection::near(schema, &next, caret, -1)); + } + spec.changes(changes) + } + fn check(&self, doc: &Node, schema: &Schema) -> Result<(), NodeError> { + Selection::text(self.anchor, self.head).check(doc, schema)?; + if schema.try_mark_type(self.syntax).is_none() { + return Err(NodeError::Json( + "reading selection has an unknown syntax mark".into(), + )); + } + Ok(()) + } +} + +/// Literal paragraph insertion must not stretch source delimiters across block +/// boundaries. Continue the same enclosing styles in each nonempty paragraph, +/// retaining original boundary spelling only when it still encloses content. +fn continue_paragraph_styles( + doc: &Node, + schema: &Schema, + projection: &crate::projection::Projection, + syntax: MarkTypeId, + selected: std::ops::Range, + slice: Slice, + mut ranges: Vec>, +) -> (Slice, Vec>) { + use crate::{ + Fragment, + kind::conceal::{self, Reveal, Shown}, + }; + if slice.open_start() != 1 + || slice.open_end() != 1 + || slice.content().child_count() < 2 + || !slice + .content() + .iter() + .all(|node| schema.node_type(node.type_id()).is_textblock()) + { + return (slice, ranges); + } + let Some(line) = projection + .line_at(selected.start) + .and_then(|index| projection.line(index)) + else { + return (slice, ranges); + }; + let visible = conceal::shown(Some(syntax), line, &Reveal::nothing()); + let has_content = |from: usize, to: usize| { + from < to + && line.runs().iter().zip(&visible).any(|(run, shown)| { + line.abs(run.start) < to + && from < line.abs(run.end) + && match shown { + Shown::Hidden => false, + Shown::Display(text) => !text.is_empty(), + Shown::Source | Shown::Revealed => true, + } + }) + }; + let pairs = conceal::markup_spans(Some(syntax), line) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.clone(); + let last = runs.last()?.clone(); + (runs.len() > 1 && first.end <= selected.start && selected.start < last.start) + .then_some((first, last)) + }) + .collect::>(); + if pairs.is_empty() { + return (slice, ranges); + } + let first_has_text = slice + .content() + .first_child() + .is_some_and(|node| node.content_size() > 0); + let last_has_text = slice + .content() + .last_child() + .is_some_and(|node| node.content_size() > 0); + let mut openings = Vec::new(); + let mut closings = Vec::new(); + let mut first_closings = Vec::new(); + let mut last_openings = Vec::new(); + for (opening, closing) in pairs { + if first_has_text || has_content(opening.end, selected.start) { + first_closings.push(closing.clone()); + } else { + ranges.push(opening.clone()); + } + if last_has_text || has_content(selected.end, closing.start) { + last_openings.push(opening.clone()); + } else { + ranges.push(closing.clone()); + } + openings.push(opening); + closings.push(closing); + } + let spelling = |mut spans: Vec>| { + spans.sort_by_key(|span| span.start); + spans.into_iter().fold(Fragment::empty(), |fragment, span| { + doc.slice(span.start, span.end).map_or_else( + |_| fragment.clone(), + |slice| fragment.append(slice.content()), + ) + }) + }; + let opening = spelling(openings); + let closing = spelling(closings); + let first_closing = spelling(first_closings); + let last_opening = spelling(last_openings); + let last = slice.content().child_count() - 1; + let nodes = slice.content().iter().enumerate().map(|(index, node)| { + let mut content = node.content().clone(); + if index > 0 && (index == last || node.content_size() > 0) { + content = if index == last { + last_opening.append(&content) + } else { + opening.append(&content) + }; + } + if index < last && (index == 0 || node.content_size() > 0) { + content = content.append(if index == 0 { &first_closing } else { &closing }); + } + node.copy(content) + }); + let slice = Slice::new( + Fragment::from_nodes(nodes), + slice.open_start(), + slice.open_end(), + ); + ranges.sort_by_key(|range| range.start); + let mut merged: Vec> = Vec::new(); + for range in ranges { + if let Some(previous) = merged.last_mut() + && range.start <= previous.end + { + previous.end = previous.end.max(range.end); + } else { + merged.push(range); + } + } + (slice, merged) +} diff --git a/crates/markraft-core/src/lib.rs b/crates/markraft-core/src/lib.rs index 5f243e53..64aa4059 100644 --- a/crates/markraft-core/src/lib.rs +++ b/crates/markraft-core/src/lib.rs @@ -203,7 +203,7 @@ pub use schema::{ }; pub use slice::{Slice, Token, min_prefix_delta, node_tokens, tokens_cut, tokens_size}; -pub use selection::{Selection, SelectionKind, SelectionRange}; +pub use selection::{ReplacementStyle, Selection, SelectionKind, SelectionRange}; pub use state::protocol; pub use state::{ Annotation, AnnotationType, ChangeFilterFn, ChangeFilterResult, Compartment, Configuration, diff --git a/crates/markraft-core/src/selection/custom.rs b/crates/markraft-core/src/selection/custom.rs index 418f11ef..22346f94 100644 --- a/crates/markraft-core/src/selection/custom.rs +++ b/crates/markraft-core/src/selection/custom.rs @@ -12,6 +12,17 @@ use crate::slice::Slice; use super::{Selection, SelectionRange}; +/// Where the content replacing a selection gets its styles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ReplacementStyle { + /// The content carries its own styles: a rich paste, a deletion. + #[default] + Own, + /// The content continues the styles of the text it replaces: typing, or + /// plain text put where the selection was. + Receiving, +} + /// A selection kind provided by an extension. /// /// Implementors are values: `clone_box` and `eq_kind` give @@ -48,6 +59,13 @@ pub trait SelectionKind: std::fmt::Debug + Send + Sync { /// back to a built-in selection when its own invariants no longer hold. fn map(&self, doc: &Node, changes: &ChangeDesc) -> Selection; + /// Map with the schema of the resulting document, for a kind whose + /// invariants depend on what its positions sit in. [`Selection::map`] + /// calls this; the default is [`Self::map`]. + fn map_with_schema(&self, _schema: &Schema, doc: &Node, changes: &ChangeDesc) -> Selection { + self.map(doc, changes) + } + /// Serialise the selection. The `tag` is added by the caller. fn to_json(&self, schema: &Schema) -> Value; @@ -69,6 +87,11 @@ pub trait SelectionKind: std::fmt::Debug + Send + Sync { .unwrap_or_else(|_| Slice::empty()) } + /// Clipboard content with access to enclosing semantic scopes. + fn content_with_schema(&self, doc: &Node, _schema: &Schema) -> Slice { + self.content(doc) + } + /// Add a change to `spec` replacing this selection with `slice`. fn replace( &self, @@ -80,6 +103,20 @@ pub trait SelectionKind: std::fmt::Debug + Send + Sync { spec.changes([crate::change::Change::replace(range.from, range.to, slice)]) } + /// Replace with access to the receiving schema and the caller's + /// [`ReplacementStyle`]. Existing kinds retain their replacement contract + /// unless they need schema-aware fitting. + fn replace_with_schema( + &self, + spec: crate::state::TransactionSpec, + doc: &Node, + _schema: &Schema, + slice: Slice, + _style: ReplacementStyle, + ) -> crate::state::TransactionSpec { + self.replace(spec, doc, slice) + } + /// Validate the selection against `doc`. fn check(&self, doc: &Node, schema: &Schema) -> Result<(), NodeError> { let _ = schema; diff --git a/crates/markraft-core/src/selection/mod.rs b/crates/markraft-core/src/selection/mod.rs index 7049acf7..551e3a3d 100644 --- a/crates/markraft-core/src/selection/mod.rs +++ b/crates/markraft-core/src/selection/mod.rs @@ -26,7 +26,7 @@ mod custom; mod json; mod near; -pub use custom::SelectionKind; +pub use custom::{ReplacementStyle, SelectionKind}; use crate::change::ChangeDesc; use crate::error::NodeError; @@ -226,7 +226,8 @@ impl Selection { /// Selected clipboard content, retaining enclosing inline semantic scopes. pub fn content_with_schema(&self, doc: &Node, schema: &Schema) -> Slice { match self { - Selection::Node { .. } | Selection::Custom(_) => self.content(doc), + Selection::Custom(kind) => kind.content_with_schema(doc, schema), + Selection::Node { .. } => self.content(doc), _ => doc .slice_with_schema(schema, self.from(doc), self.to(doc)) .unwrap_or_else(|_| Slice::empty()), @@ -280,7 +281,7 @@ impl Selection { } } Selection::All => Selection::All, - Selection::Custom(kind) => kind.map(doc, changes), + Selection::Custom(kind) => kind.map_with_schema(schema, doc, changes), } } diff --git a/crates/markraft-core/src/state/transaction.rs b/crates/markraft-core/src/state/transaction.rs index ff3d87fb..078e9893 100644 --- a/crates/markraft-core/src/state/transaction.rs +++ b/crates/markraft-core/src/state/transaction.rs @@ -150,6 +150,15 @@ impl TransactionSpec { self.annotate(protocol::user_event().of(event.to_string())) } + /// Replace this spec's event classification without changing the ordinary + /// first-annotation-wins contract of [`Self::annotate`]. A host classifies + /// a command's spec under the broader action it ran the command for. + pub fn replace_user_event(mut self, event: &str) -> Self { + self.annotations + .retain(|annotation| !annotation.is(protocol::user_event())); + self.user_event(event) + } + /// Shorthand for annotating with /// [`add_to_history`](super::protocol::add_to_history). pub fn add_to_history(self, add: bool) -> Self { @@ -192,6 +201,21 @@ impl TransactionSpec { } impl Selection { + /// Replace through the selection kind's schema-aware edit contract. + pub fn replace_with_schema( + &self, + spec: TransactionSpec, + doc: &Node, + schema: &crate::Schema, + slice: Slice, + style: crate::ReplacementStyle, + ) -> TransactionSpec { + match self { + Selection::Custom(kind) => kind.replace_with_schema(spec, doc, schema, slice, style), + _ => self.replace(spec, doc, slice), + } + } + /// Add a change to `spec` replacing this selection with `slice`. pub fn replace(&self, spec: TransactionSpec, doc: &Node, slice: Slice) -> TransactionSpec { match self { @@ -204,6 +228,20 @@ impl Selection { } } +impl TransactionSpec { + /// Resolve an edit for a command's validation without executing filters, + /// appenders or history twice. The original spec keeps all its metadata. + pub(crate) fn resolve_edit( + &self, + state: &EditorState, + ) -> Result<(ChangeSet, Node, Option), StateError> { + let resolved = resolve_inner(state, self, state.doc())?; + let doc = resolved.changes.apply(state.doc())?; + doc.check_from(state.doc(), state.schema())?; + Ok((resolved.changes, doc, resolved.selection)) + } +} + pub(crate) struct Resolved { pub(crate) changes: ChangeSet, pub(crate) selection: Option, diff --git a/crates/markraft-core/src/tests/mark_command_tests.rs b/crates/markraft-core/src/tests/mark_command_tests.rs index 179a659d..624d6476 100644 --- a/crates/markraft-core/src/tests/mark_command_tests.rs +++ b/crates/markraft-core/src/tests/mark_command_tests.rs @@ -506,3 +506,109 @@ fn removing_a_whole_inherited_link_does_not_leave_empty_links() { r#"doc(paragraph(inline_span("ab")))"# ); } + +mod custom_selection_replacement { + use super::*; + use crate::{Change, ChangeDesc, Node, Schema, SelectionKind}; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + /// The visual range and replacement policy deliberately differ, proving + /// commands dispatch to the kind rather than flattening it to two offsets. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct InnerSelection; + + impl SelectionKind for InnerSelection { + fn tag(&self) -> &str { + "inner-test" + } + fn clone_box(&self) -> Box { + Box::new(*self) + } + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn eq_kind(&self, other: &dyn SelectionKind) -> bool { + other.as_any().is::() + } + fn anchor(&self, _: &Node) -> usize { + 1 + } + fn head(&self, _: &Node) -> usize { + 4 + } + fn map(&self, _: &Node, changes: &ChangeDesc) -> Selection { + let range = changes.map_range(1, 4); + Selection::text(range.from, range.to) + } + fn to_json(&self, _: &Schema) -> serde_json::Value { + serde_json::json!({}) + } + // Override the original hook to also verify backward compatibility with + // kinds that do not implement the newer schema-aware hook. + fn replace(&self, spec: TransactionSpec, _: &Node, slice: Slice) -> TransactionSpec { + spec.changes([Change::replace(2, 3, slice).with_fit(crate::Fit::Auto)]) + .selection(Selection::cursor(2)) + } + } + + #[test] + fn custom_selection_commands_honor_replacement_and_caret_without_running_filters() { + let schema = shared_schema(); + let filters = Arc::new(AtomicUsize::new(0)); + let observed = filters.clone(); + let filter: crate::TransactionFilterFn = Arc::new(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + None + }); + let start = state( + doc(&schema, [n(&schema, "paragraph", [t(&schema, "abc")])]), + crate::transaction_filter().of(filter), + ); + let selected = start + .update([TransactionSpec::new().selection(Selection::custom(Box::new(InnerSelection)))]) + .unwrap() + .state() + .clone(); + for (command, expected) in [ + (insert_text("X"), "aXc"), + (insert_node(t(&schema, "X")), "aXc"), + ( + replace_selection(Slice::from_fragment(Fragment::from_node(t(&schema, "X")))), + "aXc", + ), + (delete_selection(), "ac"), + (replace_selection(Slice::empty()), "ac"), + ] { + filters.store(0, Ordering::SeqCst); + let spec = command(&selected).expect("custom replacement applies"); + assert_eq!( + filters.load(Ordering::SeqCst), + 0, + "command construction must be pure" + ); + let result = selected.update([spec]).unwrap(); + assert_eq!(filters.load(Ordering::SeqCst), 1); + assert_eq!( + result.new_doc().text_between( + &schema, + 0, + result.new_doc().content_size(), + None, + None + ), + expected + ); + assert_eq!(result.new_selection(), Selection::cursor(2)); + } + let hard_break = schema.node_id("hard_break").unwrap(); + let broken = run(&selected, &insert_hard_break(hard_break)); + assert_eq!( + schema.describe(broken.doc()), + "doc(paragraph(\"a\", hard_break, \"c\"))" + ); + assert_eq!(broken.selection(), &Selection::cursor(2)); + } +} diff --git a/crates/markraft-gpui/Cargo.toml b/crates/markraft-gpui/Cargo.toml index 842c0770..7b935221 100644 --- a/crates/markraft-gpui/Cargo.toml +++ b/crates/markraft-gpui/Cargo.toml @@ -26,8 +26,9 @@ markraft-commonmark.workspace = true gpui = { workspace = true, features = ["test-support"] } [target.'cfg(target_os = "macos")'.dependencies] -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSPasteboard"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSString", "NSArray"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSPasteboard", "NSTextView", "NSText", "NSView", "NSResponder", "NSAttributedString"] } +objc2 = "0.6.3" +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSCharacterSet", "NSString", "NSArray", "NSAttributedString"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/markraft-gpui/src/clipboard.rs b/crates/markraft-gpui/src/clipboard.rs index e1c482d2..1385d7bc 100644 --- a/crates/markraft-gpui/src/clipboard.rs +++ b/crates/markraft-gpui/src/clipboard.rs @@ -109,6 +109,74 @@ pub(crate) enum PasteMode { Markdown, } +/// The representation requested explicitly by an editor's Copy As menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CopyFormat { + PlainText, + Markdown, + HtmlCode, + RichText, +} + +/// Prepare every representation before touching the clipboard. In particular, +/// HTML source is only text; a rich copy also carries semantic HTML and an +/// exact fragment for a paste back into the editor. +pub(crate) struct PreparedCopy { + text: String, + metadata: Option, + html: Option, +} + +pub(crate) fn prepare_copy( + schema: &Schema, + codecs: Option<&dyn Codecs>, + types: &DocTypes, + slice: &Slice, + format: CopyFormat, +) -> Option { + if slice.is_empty() { + return None; + } + let mut result = PreparedCopy { + text: String::new(), + metadata: None, + html: None, + }; + result.text = match format { + CopyFormat::PlainText => codecs.map_or_else( + || markraft_core::kind::conceal::slice_text(schema, types.syntax, slice), + |codecs| codecs.to_text(slice), + ), + CopyFormat::Markdown => { + let codecs = codecs?; + codecs.to_markup(&codecs.copied(slice))? + } + CopyFormat::HtmlCode => { + let codecs = codecs?; + codecs.to_html(&codecs.copied(slice))? + } + CopyFormat::RichText => { + let codecs = codecs?; + result.html = Some(codecs.to_html(&codecs.copied(slice))?); + result.metadata = Some(metadata(schema, codecs, slice)); + codecs.to_text(slice) + } + }; + Some(result) +} + +pub(crate) fn write_prepared(copy: PreparedCopy, cx: &mut App) { + cx.write_to_clipboard(match copy.metadata { + Some(metadata) => ClipboardItem::new_string_with_metadata(copy.text, metadata), + None => ClipboardItem::new_string(copy.text), + }); + if cx.has_global::() + && let Some(html) = copy.html + { + platform::write_html(&html); + } +} + const METADATA_PREFIX: &str = "markraft-fragment-v1:"; /// The string flavour of `slice`: the kind's markup, or its plain text when the @@ -156,12 +224,33 @@ pub fn write_rich_text( } } -pub(crate) fn write(schema: &Schema, codecs: &dyn Codecs, slice: &Slice, cx: &mut App) { - let metadata = metadata(schema, codecs, slice); +pub(crate) const SMART_PREFIX: &str = "markraft:smart-paste:"; + +pub(crate) fn smart_item(item: &ClipboardItem, cx: &App) -> bool { + item.metadata() + .is_some_and(|metadata| metadata.starts_with(SMART_PREFIX)) + || (cx.has_global::() && platform::is_smart(item.text().as_deref())) +} + +pub(crate) fn write( + schema: &Schema, + codecs: &dyn Codecs, + slice: &Slice, + smart: bool, + cx: &mut App, +) { + let metadata = format!( + "{}{}", + if smart { SMART_PREFIX } else { "" }, + metadata(schema, codecs, slice) + ); cx.write_to_clipboard(ClipboardItem::new_string_with_metadata( markup(codecs, slice), metadata, )); + if smart && cx.has_global::() { + platform::write_smart(); + } // The rich flavour goes on the same item, after it has been written. if cx.has_global::() && let Some(html) = codecs.to_html(slice) @@ -177,6 +266,7 @@ fn metadata(schema: &Schema, codecs: &dyn Codecs, slice: &Slice) -> String { /// The slice a metadata flavour holds. fn from_metadata(schema: &Schema, metadata: &str) -> Option { + let metadata = metadata.strip_prefix(SMART_PREFIX).unwrap_or(metadata); let json = metadata.strip_prefix(METADATA_PREFIX)?; let value = serde_json::from_str(json).ok()?; Slice::from_json(schema, &value).ok() @@ -235,6 +325,32 @@ mod platform { .map(|value| value.to_string()) } + // AppKit's interoperable marker is also emitted by WebKit. It has no payload. + const SMART_TYPE: &str = "NeXT smart paste pasteboard type"; + + pub(super) fn write_smart() { + let pasteboard = NSPasteboard::generalPasteboard(); + let kind = NSString::from_str(SMART_TYPE); + unsafe { + pasteboard.addTypes_owner(&NSArray::from_slice(&[kind.as_ref()]), None); + } + pasteboard.setString_forType(&NSString::from_str(""), &kind); + } + + pub(super) fn is_smart(text: Option<&str>) -> bool { + let pasteboard = NSPasteboard::generalPasteboard(); + pasteboard + .stringForType(unsafe { NSPasteboardTypeString }) + .map(|value| value.to_string()) + .as_deref() + == text + && pasteboard + .availableTypeFromArray(&NSArray::from_slice(&[ + NSString::from_str(SMART_TYPE).as_ref() + ])) + .is_some() + } + pub(super) fn write_html(html: &str) { let pasteboard = NSPasteboard::generalPasteboard(); // AppKit's HTML importer otherwise guesses a legacy encoding for the @@ -256,6 +372,10 @@ mod platform { } pub(super) fn write_html(_: &str) {} + pub(super) fn write_smart() {} + pub(super) fn is_smart(_: Option<&str>) -> bool { + false + } } #[cfg(test)] @@ -264,6 +384,31 @@ mod tests { use super::*; use markraft_commonmark::{CommonMarkCodecs, commonmark_schema}; + #[test] + fn explicit_html_source_and_rich_text_have_distinct_clipboard_flavours() { + let schema = commonmark_schema(); + let codecs = CommonMarkCodecs::new(schema.clone(), Default::default()); + let types = + DocTypes::from_schema_names(&schema, &markraft_commonmark::commonmark_doc_type_names()); + let slice = codecs.from_markup("**你好** & `λ`").unwrap(); + let source = + prepare_copy(&schema, Some(&codecs), &types, &slice, CopyFormat::HtmlCode).unwrap(); + let rich = + prepare_copy(&schema, Some(&codecs), &types, &slice, CopyFormat::RichText).unwrap(); + assert_eq!( + source.text, + "

你好 & λ

" + ); + assert!(source.html.is_none()); + assert!(source.metadata.is_none()); + assert_eq!(rich.text, "你好 & λ"); + assert_eq!(rich.html.as_deref(), Some(source.text.as_str())); + assert_eq!( + from_metadata(&schema, rich.metadata.as_deref().unwrap()), + Some(codecs.copied(&slice)), + ); + } + fn math_paste_at(source: &str, range: std::ops::Range) -> Option { let state = crate::typeahead::tests::state_of(source); let types = DocTypes::from_schema_names( @@ -457,4 +602,21 @@ mod tests { // And it is what the paste path reads when there is no metadata. assert_eq!(codecs.from_html(&html).as_ref(), Some(&slice)); } + + #[gpui::test] + fn smart_marker_preserves_rich_clipboard_roundtrip(cx: &mut gpui::TestAppContext) { + let schema = commonmark_schema(); + let codecs = CommonMarkCodecs::new(schema.clone(), Default::default()); + let document = markraft_commonmark::from_markdown(&schema, "**word**").unwrap(); + let slice = Slice::new(document.content().clone(), 0, 0); + cx.update(|cx| { + write(&schema, &codecs, &slice, true, cx); + let item = cx.read_from_clipboard().unwrap(); + assert!(smart_item(&item, cx)); + let pasted = read_fragment(&schema, &codecs, &item, PasteMode::Formatted, cx).unwrap(); + assert_eq!(markup(&codecs, &pasted), "**word**"); + write(&schema, &codecs, &slice, false, cx); + assert!(!smart_item(&cx.read_from_clipboard().unwrap(), cx)); + }); + } } diff --git a/crates/markraft-gpui/src/context.rs b/crates/markraft-gpui/src/context.rs new file mode 100644 index 00000000..e9066616 --- /dev/null +++ b/crates/markraft-gpui/src/context.rs @@ -0,0 +1,369 @@ +//! Pointer context and capability queries shared by the host's editing menus. + +use crate::{EditorEvent, EditorView, clipboard, links, wiki}; +use gpui::{App, ClipboardEntry, ClipboardItem, Context, MouseDownEvent, Pixels, Point, Window}; +use markraft_core::{Node, Selection, TransactionSpec, projection::Projection, protocol::event}; +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; + +/// Reading text offered to native services, with explicit write-back capabilities. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContextText { + pub text: String, + pub replaceable: bool, + pub transformable: bool, +} + +mod block_actions; +mod copy_formats; +mod pointer; +mod reading_text; +mod smart_edit; +mod transformations; +#[cfg(test)] +pub(crate) use pointer::{pointer_character_in_row, word_at}; +pub use transformations::TextTransformation; + +/// Reading text with one source span per Unicode scalar. Nonliteral display +/// content has no span; paragraph separators can span structural tokens. +/// Consumers must retain the originating document snapshot and reject offsets +/// inside graphemes before using this mapping for a native write-back. +pub struct ContextTextMap { + pub text: String, + pub source: Vec>>, + exact: bool, +} + +impl ContextTextMap { + fn replacement_range(&self) -> Option> { + if !self.exact || self.source.iter().any(Option::is_none) { + return None; + } + let spans: Vec<_> = self.source.iter().flatten().collect(); + if spans.windows(2).any(|pair| pair[0].end != pair[1].start) { + return None; + } + Some(spans.first()?.start..spans.last()?.end) + } +} + +/// The object actually drawn under the pointer, independent of the selection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ContextTarget { + Text, + Link { + url: String, + range: Range, + }, + WikiLink { + pos: usize, + target: String, + embed: bool, + }, + CodeBlock { + pos: usize, + }, + Table { + pos: usize, + cell: usize, + }, + Image { + pos: usize, + }, + Math { + pos: usize, + }, +} + +/// Follow the clicked link through synchronous selection corrections. Changes +/// elsewhere may fold atoms and move it; changes to the link invalidate it. +pub(crate) struct ContextTargetBookmark { + document: Node, + target: ContextTarget, +} + +impl ContextTargetBookmark { + fn new(document: &Node, target: &ContextTarget) -> Option { + matches!( + target, + ContextTarget::Link { .. } | ContextTarget::WikiLink { .. } + ) + .then(|| Self { + document: document.clone(), + target: target.clone(), + }) + } + + pub(crate) fn map(mut self, transactions: &[markraft_core::Transaction]) -> Option { + for transaction in transactions { + if !self.document.ptr_eq(transaction.start_state().doc()) { + return None; + } + let range = match &self.target { + ContextTarget::Link { range, .. } => range.clone(), + ContextTarget::WikiLink { pos, .. } => *pos..*pos + 1, + _ => return None, + }; + if transaction.changes().iter_changes().iter().any(|change| { + let (from, to) = match change { + markraft_core::ChangeRange::Replaced { from_a, to_a, .. } + | markraft_core::ChangeRange::Marked { from_a, to_a, .. } => (*from_a, *to_a), + }; + from < range.end && to > range.start + || from == to && range.start < from && from < range.end + }) { + return None; + } + let mapped = transaction + .changes() + .desc() + .map_range(range.start, range.end); + let next = transaction.new_doc(); + if self.document.slice(range.start, range.end).ok()? + != next.slice(mapped.from, mapped.to).ok()? + { + return None; + } + match &mut self.target { + ContextTarget::Link { range, .. } => *range = mapped.from..mapped.to, + ContextTarget::WikiLink { pos, .. } => *pos = mapped.from, + _ => return None, + } + self.document = next.clone(); + } + Some(self) + } + + fn finish(self, document: &Node) -> Option { + self.document.ptr_eq(document).then_some(self.target) + } +} + +/// A menu's immutable editing target. Hosts additionally validate editor and +/// document identity before executing a command against this snapshot. +#[derive(Clone, Debug)] +pub struct ContextRequest { + pub position: Point, + pub target: ContextTarget, + document: Node, + selection: Selection, + epoch: u64, + text_range: Option>, +} + +impl ContextRequest { + pub fn selection_range(&self) -> Range { + self.selection.from(&self.document)..self.selection.to(&self.document) + } + + pub fn text_range(&self) -> Range { + self.text_range + .clone() + .unwrap_or_else(|| self.selection_range()) + } +} + +/// Structural capabilities, before the host applies its read-only policy. +/// Querying them neither dispatches transactions nor changes the clipboard. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct EditCapabilities { + pub copy: bool, + pub cut: bool, + pub paste: bool, + pub paste_plain: bool, + pub paste_markdown: bool, +} + +/// Selection commands executed synchronously by a validated context menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContextAction { + Copy, + Cut, + Paste, + PastePlain, + PasteMatchStyle, + PasteMarkdown, +} + +enum ContextLinkChange { + Existing, + Add(TransactionSpec), +} + +impl EditorView { + /// Display host spelling/grammar diagnostics without changing document, + /// selection, or history. A later edit moves the ranges beside it and + /// drops the ones it touches. + pub fn set_text_diagnostics(&mut self, mut ranges: Vec>, cx: &mut Context) { + let end = self.state.doc().content_size(); + ranges.retain(|range| !range.is_empty() && range.end <= end); + ranges.sort_by_key(|range| (range.start, range.end)); + ranges.dedup(); + if ranges != self.text_diagnostics { + self.text_diagnostics = ranges; + cx.notify(); + } + } + + /// Capture the whole reading document without moving the user's selection. + pub fn context_document_snapshot(&self) -> ContextRequest { + let mut request = self.context_snapshot(Point::default(), ContextTarget::Text); + request.text_range = Some(0..self.state.doc().content_size()); + request + } + + /// Capture a source range while retaining the selection used for stale + /// result validation. Callers must supply positions in this document. + pub fn context_source_range_snapshot(&self, range: Range) -> Option { + if range.is_empty() || range.end > self.state.doc().content_size() || self.is_composing() { + return None; + } + let mut request = self.context_snapshot(Point::default(), ContextTarget::Text); + request.text_range = Some(range); + Some(request) + } + + /// The prose line currently receiving ordinary typing. A service can read + /// it without moving the caret and narrow it before applying a correction. + pub fn context_paragraph_snapshot_at_caret(&self) -> Option { + if self.is_composing() || !self.state.selection().is_empty(self.state.doc()) { + return None; + } + let projection = self.analysis.projection(); + let (index, _) = projection.pos_to_line_offset(self.head())?; + let line = projection.line(index)?; + if self.types.is_verbatim_block(line) { + return None; + } + let mut request = self.context_snapshot(Point::default(), ContextTarget::Text); + request.text_range = Some(line.from()..line.to()); + Some(request) + } + + /// Keep a menu edit separate from typing, including an extension's explicit + /// undo group. The regular edit funnel still checks guards atomically. + pub fn with_context_edit( + &mut self, + cx: &mut Context, + edit: impl FnOnce(&mut Self, &mut Context) -> R, + ) -> R { + let previous = self.context_edit; + self.context_edit = true; + let result = edit(self, cx); + self.context_edit = previous; + result + } + + /// Open or close the scope [`Self::with_context_edit`] keeps, for a host + /// whose edit runs through its own dispatch rather than a closure here. + /// Returns the previous value, which the host restores afterwards. + pub fn set_context_edit(&mut self, on: bool) -> bool { + std::mem::replace(&mut self.context_edit, on) + } + + pub fn execute_context_action(&mut self, action: ContextAction, cx: &mut Context) { + self.with_context_edit(cx, |view, cx| match action { + ContextAction::Copy => view.copy(cx), + ContextAction::Cut => view.cut(cx), + ContextAction::Paste => view.paste(clipboard::PasteMode::Formatted, cx), + ContextAction::PastePlain => view.paste(clipboard::PasteMode::Plain, cx), + ContextAction::PasteMatchStyle => view.paste_match_style(cx), + ContextAction::PasteMarkdown => view.paste(clipboard::PasteMode::Markdown, cx), + }); + } + + pub fn can_toggle_mark(&self, ty: markraft_core::MarkTypeId) -> bool { + !self.single_line + && !self.is_composing() + && self.mark_command(ty, markraft_core::Attrs::empty())(&self.state) + .is_ok_and(|spec| spec.is_some()) + } + + pub fn can_table(&self, op: crate::TableOp) -> bool { + !self.is_composing() + && self + .table_operation(op) + .is_some_and(|command| command(&self.state).is_some()) + } + + /// Capture a menu target after all pointer-selection extensions have run. + pub fn context_snapshot( + &self, + position: Point, + target: ContextTarget, + ) -> ContextRequest { + ContextRequest { + position, + target, + document: self.state.doc().clone(), + selection: self.state.selection().clone(), + epoch: self.context_epoch, + text_range: None, + } + } + + /// A selection move, composition update, document replacement or edit + /// invalidates a menu, including changes subsequently undone. + pub fn context_is_current(&self, request: &ContextRequest) -> bool { + request.epoch == self.context_epoch + && request.document.ptr_eq(self.state.doc()) + && request.selection == *self.state.selection() + } + + /// Diagnostic work may survive selection movement, but never a document + /// change or an active composition. Hosts still validate editor identity. + pub fn context_document_is_current(&self, request: &ContextRequest) -> bool { + request.document.ptr_eq(self.state.doc()) && !self.is_composing() + } + + pub fn edit_capabilities(&self, cx: &App) -> EditCapabilities { + let item = cx + .read_from_clipboard() + .unwrap_or_else(|| ClipboardItem::new_string(String::new())); + let text = item.text().is_some_and(|text| !text.is_empty()); + let files = self.file_paste + && item.entries().iter().any(|entry| { + matches!( + entry, + ClipboardEntry::Image(_) | ClipboardEntry::ExternalPaths(_) + ) + }); + let rich = !self.single_line && !self.types.in_verbatim_block_at(&self.state); + let formatted = rich + && self.codecs.as_ref().is_some_and(|codecs| { + clipboard::read_fragment( + self.state.schema(), + codecs.as_ref(), + &item, + clipboard::PasteMode::Formatted, + cx, + ) + .is_some_and(|slice| !slice.is_empty()) + }); + let selected = !self.selection_slice().is_empty(); + EditCapabilities { + copy: selected, + cut: selected, + paste: text || files || formatted, + paste_plain: text, + paste_markdown: text || files, + } + } + + /// The literal text of a particular code block, without relocating the caret. + pub fn code_text_at(&self, pos: usize) -> Option<&str> { + let projection = self.analysis.projection(); + let index = projection + .lines() + .iter() + .position(|line| self.types.is_code_block(line) && line.block_before() == Some(pos))?; + projection.line_text(index) + } +} + +#[cfg(test)] +mod pointer_targets; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests; diff --git a/crates/markraft-gpui/src/context/block_actions.rs b/crates/markraft-gpui/src/context/block_actions.rs new file mode 100644 index 00000000..29d32d61 --- /dev/null +++ b/crates/markraft-gpui/src/context/block_actions.rs @@ -0,0 +1,257 @@ +//! Block insertion for context menus, independent of pointer selection changes. + +use std::ops::Range; + +use gpui::Context; +use markraft_core::{ + Attrs, Change, Fragment, MarkSet, Selection, Slice, TransactionSpec, + commands::{changes_spec, structure::can_replace_with}, +}; + +use super::{ContextRequest, ContextTarget}; +use crate::EditorView; + +impl EditorView { + /// Whether a blank paragraph fits beside the clicked object or selected block. + /// The host additionally applies its read-only policy. + pub fn can_insert_context_paragraph(&self, request: &ContextRequest, before: bool) -> bool { + self.context_paragraph_spec(request, before).is_some() + } + + /// Insert without replacing the selected text, then put the caret in the new + /// paragraph. The complete operation is one isolated undo event. + pub fn insert_context_paragraph( + &mut self, + request: &ContextRequest, + before: bool, + cx: &mut Context, + ) -> bool { + let Some(spec) = self.context_paragraph_spec(request, before) else { + return false; + }; + self.dispatch_isolated([spec], cx) + } + + fn context_paragraph_spec( + &self, + request: &ContextRequest, + before: bool, + ) -> Option { + if self.single_line || self.is_composing() || !self.context_is_current(request) { + return None; + } + let range = self.context_block_range(request)?; + let position = if before { range.start } else { range.end }; + let schema = self.state.schema(); + let paragraph = self.types.paragraph?; + let resolved = self.state.doc().resolve(position).ok()?; + let index = resolved.index(resolved.depth()); + if !can_replace_with(schema, resolved.parent(), index, index, &[paragraph]) { + return None; + } + let node = schema + .create( + paragraph, + Attrs::empty(), + MarkSet::empty(), + Fragment::empty(), + ) + .ok()?; + changes_spec( + &self.state, + vec![Change::insert( + position, + Slice::from_fragment(Fragment::from_node(node)), + )], + "input.block", + ) + .map(|spec| spec.selection(Selection::cursor(position + 1))) + } + + fn context_block_range(&self, request: &ContextRequest) -> Option> { + let doc = self.state.doc(); + // A clicked block wins over a selection elsewhere. Inline objects are + // handled through their containing textblock, never by splitting it. + let position = match &request.target { + ContextTarget::Table { pos, .. } => { + let node = doc.node_at(*pos)?; + return (Some(node.type_id()) == self.types.table) + .then_some(*pos..*pos + node.node_size()); + } + ContextTarget::CodeBlock { pos } => { + let node = doc.node_at(*pos)?; + return (Some(node.type_id()) == self.types.code_block) + .then_some(*pos..*pos + node.node_size()); + } + ContextTarget::Image { pos } + | ContextTarget::WikiLink { pos, .. } + | ContextTarget::Math { pos } => *pos, + ContextTarget::Link { range, .. } => range.start, + ContextTarget::Text => { + let range = request.selection_range(); + let first = self.containing_context_block(range.start)?; + if !range.is_empty() + && first != self.containing_context_block(range.end.saturating_sub(1))? + { + return None; + } + range.start + } + }; + let range = self.containing_context_block(position)?; + let resolved = doc.resolve(range.start).ok()?; + // Paragraph insertion in a table always goes beside the entire table; + // inserting paragraphs in rows or cells would corrupt its structure. + for depth in (1..=resolved.depth()).rev() { + if Some(resolved.node(depth).type_id()) == self.types.table { + return Some(resolved.before(depth)..resolved.after(depth)); + } + } + Some(range) + } + + fn containing_context_block(&self, position: usize) -> Option> { + let doc = self.state.doc(); + let schema = self.state.schema(); + let resolved = doc.resolve(position).ok()?; + for depth in (1..=resolved.depth()).rev() { + let node = resolved.node(depth); + if node.is_textblock(schema) { + return Some(resolved.before(depth)..resolved.after(depth)); + } + } + let node = doc.node_at(position)?; + node.is_block(schema) + .then_some(position..position + node.node_size()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DocTypes, Setup}; + use gpui::{AppContext, Point, TestAppContext}; + use markraft_commonmark::{ + commonmark_doc_type_names, commonmark_extensions, commonmark_schema, from_markdown, + }; + use markraft_core::history; + + fn setup(source: &str) -> Setup { + let schema = commonmark_schema(); + Setup::new(schema.clone()) + .types(DocTypes::from_schema_names( + &schema, + &commonmark_doc_type_names(), + )) + .extensions(commonmark_extensions(&schema)) + .doc(from_markdown(&schema, source).unwrap()) + } + + #[gpui::test] + fn context_paragraph_insertion_preserves_objects_and_undo_selection(cx: &mut TestAppContext) { + for (source, table) in [ + ("| 中文 | `a\\|b` |\n| :- | -: |\n| 😀 | z |", true), + ("```rust\nlet 原文 = 42;\n```", false), + ] { + for before in [true, false] { + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.update(cx, |view, cx| { + let original = view.state.doc().clone(); + let selection = view.state.selection().clone(); + let target = if table { + ContextTarget::Table { pos: 0, cell: 3 } + } else { + ContextTarget::CodeBlock { pos: 0 } + }; + let request = view.context_snapshot(Point::default(), target); + assert!(view.can_insert_context_paragraph(&request, before)); + assert!(view.insert_context_paragraph(&request, before, cx)); + assert_eq!(view.state.doc().child_count(), 2); + assert_eq!( + view.state.doc().child(usize::from(before)), + original.child(0) + ); + let cursor = if before { + 1 + } else { + original.content_size() + 1 + }; + assert_eq!(view.state.selection(), &Selection::cursor(cursor)); + assert!(!view.insert_context_paragraph(&request, before, cx)); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.state.doc(), &original); + assert_eq!(view.state.selection(), &selection); + }); + } + } + } + + #[gpui::test] + fn context_paragraph_insertion_stays_inside_quote_and_list_item(cx: &mut TestAppContext) { + for source in ["> ```rust\n> a\n> ```", "- item\n\n ```rust\n a\n ```"] { + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.update(cx, |view, cx| { + let code = view.types.code_block.unwrap(); + let pos = (0..view.state.doc().content_size()) + .find(|pos| { + view.state + .doc() + .node_at(*pos) + .is_some_and(|n| n.type_id() == code) + }) + .unwrap(); + let original = view.state.doc().clone(); + let request = + view.context_snapshot(Point::default(), ContextTarget::CodeBlock { pos }); + assert!(view.insert_context_paragraph(&request, false, cx)); + assert_eq!(view.state.doc().child_count(), 1); + assert_eq!(view.state.doc().node_at(pos), original.node_at(pos)); + assert_eq!(view.state.doc().content_size(), original.content_size() + 2); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.state.doc(), &original); + }); + } + } + + #[gpui::test] + fn context_paragraph_insertion_keeps_selected_text_and_escapes_table_cells( + cx: &mut TestAppContext, + ) { + for (source, from, to) in [ + ("**selected** text", 3, 7), + ("| a | b |\n| - | - |\n| x | y |", 3, 4), + ] { + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.update(cx, |view, cx| { + view.select_range(from, to, cx); + let original = view.state.doc().clone(); + let request = view.context_snapshot(Point::default(), ContextTarget::Text); + assert!(view.insert_context_paragraph(&request, false, cx)); + assert_eq!(view.state.doc().child_count(), 2); + assert_eq!(view.state.doc().child(0), original.child(0)); + assert_eq!( + view.state.selection(), + &Selection::cursor(original.content_size() + 1) + ); + }); + } + } + + #[gpui::test] + fn context_paragraph_insertion_rejects_cross_block_and_stale_requests(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup("first\n\nsecond"), cx)); + view.update(cx, |view, cx| { + view.select_range(2, 10, cx); + let request = view.context_snapshot(Point::default(), ContextTarget::Text); + assert!(!view.can_insert_context_paragraph(&request, true)); + view.select_range(2, 4, cx); + let request = view.context_snapshot(Point::default(), ContextTarget::Text); + assert!(view.can_insert_context_paragraph(&request, true)); + view.select(3, false, cx); + assert!(!view.insert_context_paragraph(&request, true, cx)); + assert_eq!(history::undo_depth(&view.state), 0); + }); + } +} diff --git a/crates/markraft-gpui/src/context/copy_formats.rs b/crates/markraft-gpui/src/context/copy_formats.rs new file mode 100644 index 00000000..b121fcb1 --- /dev/null +++ b/crates/markraft-gpui/src/context/copy_formats.rs @@ -0,0 +1,248 @@ +//! Explicit clipboard representations without changing the editing selection. + +use crate::{EditorView, clipboard}; +use gpui::Context; +use markraft_core::{Fragment, Slice}; + +impl EditorView { + /// Whether the selected fragment can be exported in this representation. + /// Copy is available in read-only documents too. + pub fn can_copy_as(&self, format: clipboard::CopyFormat) -> bool { + self.prepared_copy(format).is_some() + } + + /// Write the requested representation without an editing transaction. + pub fn copy_as(&self, format: clipboard::CopyFormat, cx: &mut Context) -> bool { + let Some(copy) = self.prepared_copy(format) else { + return false; + }; + clipboard::write_prepared(copy, cx); + true + } + + fn prepared_copy(&self, format: clipboard::CopyFormat) -> Option { + if self.is_composing() || self.state.selection().is_empty(self.state.doc()) { + return None; + } + clipboard::prepare_copy( + self.state.schema(), + self.codecs.as_deref().filter(|_| !self.single_line), + &self.types, + &self.selection_slice(), + format, + ) + } + + /// Whether `pos` identifies a complete table in the current document. + pub fn can_copy_table_at(&self, pos: usize) -> bool { + !self.is_composing() && self.table_copy_slice(pos).is_some() + } + + /// Copy the complete table under the pointer, independently of the text + /// selection. The host must validate its context snapshot before calling. + pub fn copy_table_at(&self, pos: usize, cx: &mut Context) -> bool { + if self.is_composing() { + return false; + } + let Some(slice) = self.table_copy_slice(pos) else { + return false; + }; + if let Some(codecs) = self.codecs.as_deref() { + clipboard::write(self.state.schema(), codecs, &slice, false, cx); + } else { + let Some(copy) = clipboard::prepare_copy( + self.state.schema(), + None, + &self.types, + &slice, + clipboard::CopyFormat::PlainText, + ) else { + return false; + }; + clipboard::write_prepared(copy, cx); + } + true + } + + fn table_copy_slice(&self, pos: usize) -> Option { + if self.single_line { + return None; + } + let table_type = self.types.table_types()?.table; + let node = self.state.doc().node_at(pos)?; + (node.type_id() == table_type).then(|| Slice::from_fragment(Fragment::from_node(node))) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::{CopyFormat, EditRejection, Setup}; + use gpui::{AppContext, ClipboardItem, TestAppContext}; + use markraft_commonmark::{ + CommonMarkCodecs, commonmark_doc_type_names, commonmark_extensions, commonmark_schema, + from_markdown, + }; + use markraft_core::{history, kind::DocTypes}; + + fn editor(source: &str, cx: &mut Context) -> EditorView { + let schema = commonmark_schema(); + let setup = Setup::new(schema.clone()) + .types(DocTypes::from_schema_names( + &schema, + &commonmark_doc_type_names(), + )) + .extensions(commonmark_extensions(&schema)) + .doc(from_markdown(&schema, source).unwrap()); + let mut view = EditorView::new(setup, cx); + view.codecs = Some(std::sync::Arc::new(CommonMarkCodecs::new( + schema, + Default::default(), + ))); + view + } + + #[gpui::test] + fn explicit_formats_preserve_structure_and_unicode_without_editing(cx: &mut TestAppContext) { + let view = cx.new(|cx| { + editor( + "# 标题\n\n**café 👩🏽‍💻** & [网站](https://example.com)", + cx, + ) + }); + view.update(cx, |view, cx| { + view.select_all(cx); + let selection = view.state.selection().clone(); + let document = view.state.doc().clone(); + let depth = history::undo_depth(&view.state); + let expected = [ + (CopyFormat::PlainText, "标题\ncafé 👩🏽‍💻 & 网站"), + (CopyFormat::Markdown, "# 标题\n\n**café 👩🏽‍💻** & [网站](https://example.com)"), + (CopyFormat::HtmlCode, "

标题

\n

café 👩🏽‍💻 & 网站

"), + (CopyFormat::RichText, "标题\ncafé 👩🏽‍💻 & 网站"), + ]; + for (format, text) in expected { + assert!(view.can_copy_as(format)); + assert!(view.copy_as(format, cx)); + let item = cx.read_from_clipboard().unwrap(); + assert_eq!(item.text().as_deref(), Some(text), "{format:?}"); + if format == CopyFormat::RichText { + let copied = clipboard::read_fragment( + view.state.schema(), view.codecs.as_deref().unwrap(), &item, + clipboard::PasteMode::Formatted, cx, + ).unwrap(); + assert_eq!(clipboard::markup(view.codecs.as_deref().unwrap(), &copied), expected[1].1); + } else { + assert!(item.metadata().is_none(), "explicit text formats carry only text"); + } + assert_eq!(view.state.doc(), &document); + assert_eq!(view.state.selection(), &selection); + assert_eq!(history::undo_depth(&view.state), depth); + } + }); + } + + #[gpui::test] + fn partial_styled_selection_copies_the_requested_format_in_read_only_document( + cx: &mut TestAppContext, + ) { + let view = cx.new(|cx| { + editor("before **café** after", cx) + .with_document_guard(|_| Err(EditRejection::ReadOnly("Read only".into()))) + }); + view.update(cx, |view, cx| { + view.select_range(10, 14, cx); + for (format, expected) in [ + (CopyFormat::PlainText, "café"), + (CopyFormat::Markdown, "**café**"), + (CopyFormat::HtmlCode, "

café

"), + (CopyFormat::RichText, "café"), + ] { + assert!(view.copy_as(format, cx)); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(expected), + "{format:?}" + ); + } + assert_eq!(history::undo_depth(&view.state), 0); + }); + } + + #[gpui::test] + fn empty_selection_and_unavailable_formats_leave_the_clipboard_unchanged( + cx: &mut TestAppContext, + ) { + let view = cx.new(|cx| editor("some **text**", cx)); + view.update(cx, |view, cx| { + cx.write_to_clipboard(ClipboardItem::new_string("keep".into())); + for format in [ + CopyFormat::PlainText, + CopyFormat::Markdown, + CopyFormat::HtmlCode, + CopyFormat::RichText, + ] { + assert!(!view.can_copy_as(format)); + assert!(!view.copy_as(format, cx)); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("keep") + ); + } + view.select_all(cx); + view.codecs = None; + for format in [ + CopyFormat::Markdown, + CopyFormat::HtmlCode, + CopyFormat::RichText, + ] { + assert!(!view.can_copy_as(format)); + assert!(!view.copy_as(format, cx)); + } + assert!(view.copy_as(CopyFormat::PlainText, cx)); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("some text") + ); + }); + } + + #[gpui::test] + fn copy_table_exports_the_whole_table_without_moving_selection(cx: &mut TestAppContext) { + let view = cx.new(|cx| { + editor( + "intro\n\n| 名称 | 值 |\n| --- | ---: |\n| **甲** | 42 |\n\nafter", + cx, + ) + }); + view.update(cx, |view, cx| { + let table = view.types.table_types().unwrap().table; + let pos = view.state.doc().first_child().unwrap().node_size(); + let selection = view.state.selection().clone(); + let document = view.state.doc().clone(); + assert!(view.can_copy_table_at(pos)); + assert!(view.copy_table_at(pos, cx)); + let item = cx.read_from_clipboard().unwrap(); + let codecs = view.codecs.as_deref().unwrap(); + let copied = clipboard::read_fragment( + view.state.schema(), + codecs, + &item, + clipboard::PasteMode::Formatted, + cx, + ) + .unwrap(); + assert_eq!(copied.content().child_count(), 1); + assert_eq!(copied.content().child(0).type_id(), table); + assert_eq!(codecs.to_text(&copied), "名称\t值\n甲\t42"); + assert!(item.text().unwrap().contains("**甲**")); + assert!(!view.copy_table_at(0, cx)); + assert!(!view.can_copy_table_at(view.state.doc().content_size() + 1)); + assert_eq!(cx.read_from_clipboard().unwrap().text(), item.text()); + assert_eq!(view.state.doc(), &document); + assert_eq!(view.state.selection(), &selection); + assert_eq!(history::undo_depth(&view.state), 0); + }); + } +} diff --git a/crates/markraft-gpui/src/context/pointer.rs b/crates/markraft-gpui/src/context/pointer.rs new file mode 100644 index 00000000..c5fe80ec --- /dev/null +++ b/crates/markraft-gpui/src/context/pointer.rs @@ -0,0 +1,349 @@ +//! What a pointer names: the contextual target under it, the word it picks, +//! and the selection a contextual click settles on. + +use super::*; + +impl EditorView { + pub(crate) fn context_mouse_down( + &mut self, + mouse: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + cx.stop_propagation(); + window.focus(&self.focus, cx); + self.selecting = false; + self.pointer_word_gesture = None; + self.caret.forget_column(); + self.lay_out_at(mouse.position); + let position = self.hit(mouse.position); + let character = self.pointer_character_at(mouse.position); + let target = self.context_target_at(mouse.position, character.unwrap_or(position)); + let target_document = self.state.doc().clone(); + self.context_target_bookmark = ContextTargetBookmark::new(&target_document, &target); + let selected = self.state.selection(); + let doc = self.state.doc(); + let inside = character.is_some_and(|pos| { + selected + .ranges(doc) + .iter() + .any(|range| (range.from..range.to).contains(&pos)) + }); + self.select_context(position, character, inside, &target, cx); + // Follow the original object, never the old screen coordinates, when + // selection corrections fold atoms elsewhere. A rewritten target or an + // untracked document replacement still loses its object commands. + let bookmark = self.context_target_bookmark.take(); + let target = if target_document.ptr_eq(self.state.doc()) { + target + } else { + bookmark + .and_then(|bookmark| bookmark.finish(self.state.doc())) + .unwrap_or(ContextTarget::Text) + }; + // The edit funnel runs extensions synchronously, including Vim's + // selection normalization, before a snapshot becomes visible to hosts. + cx.emit(EditorEvent::ContextMenuRequested( + self.context_snapshot(mouse.position, target), + )); + self.reset_caret_blink(cx); + cx.notify(); + } + + pub(super) fn select_context( + &mut self, + position: usize, + character: Option, + inside: bool, + target: &ContextTarget, + cx: &mut Context, + ) { + let mut by_word = false; + let selection = if inside { + self.state.selection().clone() + } else if let ContextTarget::WikiLink { pos, .. } = target { + // A text caret beside an atom unfolds its source. A contextual + // selection owns the link as an object and keeps it folded. + Selection::node(*pos) + } else if let ContextTarget::Link { range, .. } = target + && let Some(label) = self.context_link_label(range) + { + // Match native text views: a link is one contextual target even + // when its label contains several words or inline formatting. + Selection::text(label.start, label.end) + } else if let Some(selection) = character + .and_then(|pos| self.word_at_character(pos, crate::word_boundary::Intent::Context)) + { + by_word = true; + selection + } else { + Selection::near(self.state.schema(), self.state.doc(), position, 1) + }; + let mut specs = vec![ + TransactionSpec::new() + .selection(selection) + .user_event(event::SELECT_POINTER), + ]; + if self.is_composing() { + specs.push(markraft_core::composition::finish_composition().sequential()); + } + self.edit(cx, false, specs); + if by_word { + self.word_selection = Some(self.state.selection().clone()); + } + } + + pub(super) fn context_link_label(&self, range: &Range) -> Option> { + use markraft_core::kind::{conceal::Reveal, reading}; + + let projection = self.analysis.projection(); + let (first, _) = projection.pos_to_line_offset(range.start)?; + let (last, _) = projection.pos_to_line_offset(range.end)?; + let mut label: Option> = None; + // Link marks can cover the source delimiters and destination. Read + // through the same concealment mapping as search, independently of + // whether the caret currently reveals that syntax on screen. + for index in first..=last { + let line = projection.line(index)?; + for piece in reading::line_pieces(projection, &self.types, index, &Reveal::nothing()) { + let start = line.offset_to_pos(piece.source.start)?.max(range.start); + let end = line.offset_to_pos(piece.source.end)?.min(range.end); + if start < end { + match &mut label { + Some(label) => label.end = end, + None => label = Some(start..end), + } + } + } + } + label + } + + pub(super) fn context_link_range(&self, range: Range, position: usize) -> Range { + use markraft_core::kind::conceal; + + let projection = self.analysis.projection(); + let Some((index, _)) = projection.pos_to_line_offset(range.start) else { + return range; + }; + let line = &projection.lines()[index]; + let spans: Vec<_> = conceal::markup_spans(self.types.syntax, line) + .into_iter() + .filter(|runs| runs.len() > 1) + .filter_map(|runs| Some(runs.first()?.start..runs.last()?.end)) + .filter(|span| span.start >= range.start && span.end <= range.end) + .collect(); + // Equal link marks can merge across adjacent source links. The widest + // paired syntax span owns its nested formatting; single-run escapes + // cannot define a link. A bare URL has no paired syntax, so keep only + // the gap containing the pointer when it neighbours an explicit link. + if let Some(span) = spans + .iter() + .filter(|span| span.contains(&position)) + .max_by_key(|span| span.end - span.start) + { + return span.clone(); + } + let start = spans + .iter() + .filter(|span| span.end <= position) + .map(|span| span.end) + .max() + .unwrap_or(range.start); + let end = spans + .iter() + .filter(|span| span.start > position) + .map(|span| span.start) + .min() + .unwrap_or(range.end); + start..end + } + + /// Hit testing returns the nearest insertion boundary; find the drawn + /// character on either side, so a word's right half still selects that word. + pub(crate) fn pointer_word_at(&self, point: Point) -> Option { + self.pointer_character_at(point).and_then(|position| { + self.word_at_character(position, crate::word_boundary::Intent::Pointer) + }) + } + + pub(super) fn word_at_character( + &self, + position: usize, + intent: crate::word_boundary::Intent, + ) -> Option { + use markraft_core::kind::{conceal::Reveal, reading}; + + let projection = self.analysis.projection(); + let (index, _) = projection.pos_to_line_offset(position)?; + let selection = self.state.selection(); + let reveal = Reveal::at( + selection.from(self.state.doc())..selection.to(self.state.doc()), + markraft_core::composition::composition_range(&self.state) + .map(|range| range.from..range.to), + ); + // This runs before the first click moves the caret and reveals markup. + let pieces = reading::line_pieces(projection, &self.types, index, &reveal); + let range = word_at(projection, position, &pieces, intent)?; + let line = projection.line(index)?; + let from = line.pos_to_offset(range.start)?; + let to = line.pos_to_offset(range.end)?; + let mut covered = from; + let mut reading = false; + for piece in &pieces { + let start = piece.source.start.max(from); + let end = piece.source.end.min(to); + if start < end { + reading |= start > covered || !piece.own; + covered = covered.max(end); + } + } + reading |= covered < to; + Some( + if let Some(syntax) = self.types.syntax.filter(|_| reading) { + markraft_core::kind::ReadingSelection::selection(range.start, range.end, syntax) + } else { + Selection::text(range.start, range.end) + }, + ) + } + + pub(super) fn pointer_character_at(&self, point: Point) -> Option { + if self.frame.rows().is_empty() { + return None; + } + let (row, local) = self.row_under(point); + let offset = row.char_at(local); + pointer_character_in_row(row, self.analysis.projection(), point, offset) + } + + pub(super) fn context_target_at(&self, point: Point, position: usize) -> ContextTarget { + if let Some(pos) = self.wiki_link_under(point) + && let Some(node) = self.wiki_link_at(pos) + { + return ContextTarget::WikiLink { + pos, + target: wiki::wiki_link_target(&node), + embed: wiki::wiki_link_embed(&node), + }; + } + if let Some(url) = self.link_under(point) + && let Some((range, _)) = self + .types + .link + .and_then(|ty| links::link_at(self.state.doc(), ty, position)) + { + return ContextTarget::Link { + url, + range: self.context_link_range(range, position), + }; + } + let Some((row, _)) = self.row_at(position) else { + return ContextTarget::Text; + }; + let projection = self.analysis.projection(); + if let Some(line) = projection.line(row.index) + && let Some(text) = projection.line_text(row.index) + && let Some(span) = crate::math_spans::formula_spans(line, text, &self.types) + .into_iter() + .find(|span| { + row.rectangles(span.source.clone(), false) + .iter() + .any(|bounds| bounds.contains(&point)) + }) + && let Some(pos) = line.offset_to_pos(span.source.start) + { + return ContextTarget::Math { pos }; + } + if let Some(pos) = row.code_pos { + return ContextTarget::CodeBlock { pos }; + } + if let Some(cell) = row.table + && let Some(types) = self.types.table_types() + && let Ok(resolved) = self.state.doc().resolve(row.from) + && let Some(depth) = (1..=resolved.depth()) + .rev() + .find(|&depth| resolved.node(depth).type_id() == types.cell) + { + return ContextTarget::Table { + pos: cell.table, + cell: resolved.before(depth), + }; + } + if let Some(node) = self.state.doc().node_at(position) + && Some(node.type_id()) == self.types.image + { + return ContextTarget::Image { pos: position }; + } + ContextTarget::Text + } +} + +/// Pointer targets use grapheme selection geometry, not insertion affinity. +/// A bidi primary caret can name a logically distant grapheme; try adjacent +/// graphemes first, then the remaining displayed row. This runs on pointer down. +pub(crate) fn pointer_character_in_row( + row: &crate::surface::LayoutLine, + projection: &Projection, + point: Point, + insertion: usize, +) -> Option { + for adjacent in [true, false] { + for (position, grapheme) in projection.graphemes(row.index) { + let start = row.pos_to_offset(position); + let range = start..start + grapheme.chars().count(); + if (range.contains(&insertion) || range.end == insertion) != adjacent { + continue; + } + if row.rectangles(range, false).iter().any(|bounds| { + bounds.size.width > gpui::px(0.) + && bounds.size.height > gpui::px(0.) + && bounds.contains(&point) + }) { + return Some(position); + } + } + } + None +} + +pub(crate) fn word_at( + projection: &Projection, + position: usize, + pieces: &[crate::shown::ShownPiece], + intent: crate::word_boundary::Intent, +) -> Option> { + let (index, offset) = projection.pos_to_line_offset(position)?; + let line = projection.line(index)?; + let source = projection.line_text(index)?.chars().collect::>(); + if source.get(offset) == Some(&'\u{fffc}') { + return None; + } + let mut text = String::new(); + let mut spans = Vec::new(); + for piece in pieces { + if source.get(piece.source.start) == Some(&'\u{fffc}') { + // Labels are one object, not adjacent prose. Preserve the barrier + // even when its displayed label happens to contain word characters. + text.push('\u{fffc}'); + spans.push(piece.source.clone()); + } else { + text.push_str(&piece.text); + spans.extend(piece.text.chars().enumerate().map(|(index, _)| { + if piece.own { + let start = piece.source.start + index; + start..start + 1 + } else { + // An entity or another concealed substitution is atomic in + // source; never return an interior of its spelling. + piece.source.clone() + } + })); + } + } + let character = spans.iter().position(|span| span.contains(&offset))?; + let selected = crate::word_boundary::at(&text, character, intent)?; + let start = line.offset_to_pos(spans.get(selected.start)?.start)?; + let end = line.offset_to_pos(spans.get(selected.end.checked_sub(1)?)?.end)?; + Some(start..end) +} diff --git a/crates/markraft-gpui/src/context/pointer_targets.rs b/crates/markraft-gpui/src/context/pointer_targets.rs new file mode 100644 index 00000000..7946b4c0 --- /dev/null +++ b/crates/markraft-gpui/src/context/pointer_targets.rs @@ -0,0 +1,167 @@ +//! Context targets survive selection corrections without trusting stale geometry. +use super::*; +use crate::Setup; +use gpui::{Entity, MouseButton, TestAppContext, VisualTestContext}; +use markraft_commonmark::{ + commonmark_doc_type_names, commonmark_extensions, commonmark_schema, from_markdown, +}; +use markraft_core::kind::DocTypes; +use std::{cell::RefCell, rc::Rc}; + +fn setup(source: &str) -> Setup { + let schema = commonmark_schema(); + Setup::new(schema.clone()) + .types(DocTypes::from_schema_names( + &schema, + &commonmark_doc_type_names(), + )) + .extensions(commonmark_extensions(&schema)) + .doc(from_markdown(&schema, source).unwrap()) +} + +fn right_click( + view: &Entity, + cx: &mut VisualTestContext, + position: Point, +) -> ContextRequest { + let received = Rc::new(RefCell::new(None)); + let seen = received.clone(); + let subscription = cx.update(|_, cx| { + cx.subscribe(view, move |_, event, _| { + if let EditorEvent::ContextMenuRequested(request) = event { + *seen.borrow_mut() = Some(request.clone()); + } + }) + }); + cx.simulate_mouse_down(position, MouseButton::Right, Default::default()); + cx.run_until_parked(); + drop(subscription); + let request = received.borrow().clone().expect("a context menu request"); + view.read_with(cx, |view, _| { + assert!(view.context_is_current(&request)); + assert!(view.context_target_bookmark.is_none()); + }); + request +} + +fn atom_point(view: &EditorView, label: &str) -> Point { + let mut at = None; + view.state.doc().descendants(&mut |node, pos, _, _| { + if Some(node.type_id()) == view.types.wiki_link && wiki::wiki_link_target(node) == label { + at = Some(pos); + } + true + }); + let pos = at.expect("a folded wiki link"); + let (row, _) = view.row_at(pos).unwrap(); + row.rectangles(row.pos_to_offset(pos)..row.pos_to_offset(pos + 1), false)[0].center() +} + +#[gpui::test] +fn wiki_context_selects_the_atom_and_keeps_its_actions_on_repeated_clicks(cx: &mut TestAppContext) { + let (view, cx) = + cx.add_window_view(|_, cx| EditorView::new(setup("before [[Menu Audit]] after"), cx)); + cx.run_until_parked(); + for _ in 0..2 { + let point = view.read_with(cx, |view, _| atom_point(view, "Menu Audit")); + let request = right_click(&view, cx, point); + let ContextTarget::WikiLink { pos, target, embed } = request.target else { + panic!("right click must retain the wiki target"); + }; + assert_eq!(target, "Menu Audit"); + assert!(!embed); + view.read_with(cx, |view, cx| { + assert_eq!(view.state.selection(), &Selection::node(pos)); + assert!(view.wiki_link_at(pos).is_some()); + assert!(view.edit_capabilities(cx).copy); + }); + } +} + +#[gpui::test] +fn link_context_maps_past_a_wiki_source_that_folds_on_the_first_click(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| { + EditorView::new(setup("[[Earlier]] [Example](https://example.com) tail"), cx) + }); + view.update(cx, |view, cx| view.select_range(1, 1, cx)); + cx.run_until_parked(); + let (point, old_start) = view.read_with(cx, |view, _| { + let projection = view.projection(); + let text = projection.line_text(0).unwrap(); + assert!(text.starts_with("[[Earlier]]")); + let offset = text.find("Example").unwrap(); + let row = &view.frame.rows()[0]; + ( + row.rectangles(offset..offset + 1, false)[0].center(), + row.offset_to_pos(offset), + ) + }); + let request = right_click(&view, cx, point); + let selected = request.selection_range(); + let ContextTarget::Link { url, range } = request.target else { + panic!("the first click must retain the external link"); + }; + assert_eq!(url, "https://example.com"); + assert!(range.start < old_start - 1, "folding must move the target"); + view.read_with(cx, |view, _| { + assert!(view.wiki_link_at(1).is_some()); + assert_eq!(view.active_link().as_deref(), Some("https://example.com")); + assert_eq!(view.context_link_label(&range), Some(selected)); + }); +} + +#[gpui::test] +fn wiki_context_maps_past_another_atom_that_folds(cx: &mut TestAppContext) { + let (view, cx) = + cx.add_window_view(|_, cx| EditorView::new(setup("[[Earlier]] then [[Menu Audit]]"), cx)); + view.update(cx, |view, cx| view.select_range(1, 1, cx)); + cx.run_until_parked(); + let point = view.read_with(cx, |view, _| atom_point(view, "Menu Audit")); + let request = right_click(&view, cx, point); + let ContextTarget::WikiLink { pos, target, .. } = request.target else { + panic!("the clicked wiki link must survive an earlier atom folding"); + }; + assert_eq!(target, "Menu Audit"); + view.read_with(cx, |view, _| { + assert!(view.wiki_link_at(1).is_some()); + assert_eq!(view.state.selection(), &Selection::node(pos)); + assert_eq!( + wiki::wiki_link_target(&view.wiki_link_at(pos).unwrap()), + target + ); + }); +} + +#[gpui::test] +fn context_discards_a_link_rewritten_by_a_pointer_extension(cx: &mut TestAppContext) { + struct RewriteTarget(bool); + impl crate::Extension for RewriteTarget { + fn id(&self) -> &'static str { + "rewrite-context-target" + } + fn update(&mut self, update: &crate::Update, cx: &mut crate::EditorCx<'_>) { + if !self.0 && update.is_user_event(event::SELECT_POINTER) { + self.0 = true; + let state = cx.state(); + let replacement = + from_markdown(state.schema(), "[Other](https://other.example)").unwrap(); + let change = markraft_core::Change::replace( + 0, + state.doc().content_size(), + markraft_core::Slice::from_fragment(replacement.content().clone()), + ); + cx.dispatch([TransactionSpec::new().changes([change])]); + } + } + } + let (view, cx) = + cx.add_window_view(|_, cx| EditorView::new(setup("[Example](https://example.com)"), cx)); + let _extension = view.update(cx, |view, cx| view.add_extension(RewriteTarget(false), cx)); + cx.run_until_parked(); + let point = view.read_with(cx, |view, _| { + view.frame.rows()[0].rectangles(2..3, false)[0].center() + }); + let request = right_click(&view, cx, point); + assert_eq!(request.target, ContextTarget::Text); + view.read_with(cx, |view, _| assert!(view.text().contains("Other"))); +} diff --git a/crates/markraft-gpui/src/context/reading_text.rs b/crates/markraft-gpui/src/context/reading_text.rs new file mode 100644 index 00000000..d276a893 --- /dev/null +++ b/crates/markraft-gpui/src/context/reading_text.rs @@ -0,0 +1,588 @@ +//! Reading text for host text services: what a request reads as, where each +//! character is spelled in source, and the guarded edits that write back. + +use super::*; + +impl EditorView { + /// Automated spelling and substitutions must not interpret code or formulas + /// as prose. Hosts decide whether the particular check applies to URLs. + pub fn context_is_prose(&self, request: &ContextRequest) -> bool { + if !self.context_is_current(request) || self.is_composing() { + return false; + } + let range = request.text_range(); + for position in range { + let Ok(resolved) = self.state.doc().resolve(position) else { + return false; + }; + if self.types.is_verbatim(resolved.parent().type_id()) { + return false; + } + let Some(node) = self.state.doc().node_at(position) else { + continue; + }; + if [self.types.code, self.types.math] + .into_iter() + .flatten() + .any(|ty| node.marks().contains_type(ty)) + { + return false; + } + } + self.context_text(request).is_some() + } + + /// Narrow a service snapshot by Unicode scalar offsets in its reading text. + /// Callers using Cocoa ranges must convert UTF-16 offsets first. + pub fn context_text_range( + &self, + request: &ContextRequest, + range: Range, + ) -> Option { + let mapped = self.context_text_mapping(request)?; + if range.is_empty() || range.end > mapped.source.len() { + return None; + } + let text: String = mapped + .text + .chars() + .skip(range.start) + .take(range.end - range.start) + .collect(); + let source = mapped.source[range].to_vec(); + let narrowed = ContextTextMap { + text, + source, + exact: true, + }; + let mut request = request.clone(); + request.text_range = Some(narrowed.replacement_range()?); + // Rebuild to enforce grapheme and nonliteral source boundaries too. + self.context_text_mapping(&request)?.replacement_range()?; + Some(request) + } + + /// Read the selected rendered text independently of transient syntax reveal. + /// A stale menu or a live input-method composition cannot start a service. + pub fn context_text(&self, request: &ContextRequest) -> Option { + if request.text_range().is_empty() && self.context_insert_position(request).is_some() { + return Some(ContextText { + text: String::new(), + replaceable: true, + transformable: false, + }); + } + let mapped = self.context_text_mapping(request)?; + let replaceable = if let Some(command) = + self.kind + .replace_reading(request.text_range(), &mapped.text, Default::default()) + { + match command(&self.state) { + Ok(Some(_)) => true, + Ok(None) => mapped.replacement_range().is_some(), + Err(_) => false, + } + } else { + mapped.replacement_range().is_some() + }; + Some(ContextText { + replaceable, + transformable: mapped.exact, + text: mapped.text, + }) + } + + /// Link a precise service range without relocating the current selection. + /// CommonMark already links bare URLs; never wrap an existing link again. + pub fn set_context_link( + &mut self, + request: &ContextRequest, + url: &str, + cx: &mut Context, + ) -> bool { + let Some(ContextLinkChange::Add(spec)) = self.context_link_spec(request, url) else { + return false; + }; + self.dispatch_context_check_specs(vec![spec], cx) + } + + /// Add links using reading scalar ranges, with one atomic guard and undo. + pub fn set_context_links( + &mut self, + request: &ContextRequest, + mut links: Vec<(Range, String)>, + cx: &mut Context, + ) -> bool { + links.sort_by_key(|(range, _)| range.start); + if links.windows(2).any(|pair| pair[0].0.end > pair[1].0.start) { + return false; + } + let mut specs = Vec::new(); + for (range, url) in links { + let Some(narrowed) = self.context_text_range(request, range) else { + return false; + }; + let Some(change) = self.context_link_spec(&narrowed, &url) else { + return false; + }; + if let ContextLinkChange::Add(spec) = change { + specs.push(spec); + } + } + self.dispatch_context_check_specs(specs, cx) + } + + pub(super) fn dispatch_context_check_specs( + &mut self, + mut specs: Vec, + cx: &mut Context, + ) -> bool { + if specs.is_empty() { + return false; + } + let Ok(candidate) = self.state.update(specs.clone()) else { + return false; + }; + let selection = self.state.selection().map( + self.state.schema(), + candidate.new_doc(), + candidate.changes().desc(), + ); + specs.push( + TransactionSpec::new() + .selection(selection) + .sequential() + .user_event(event::INPUT_REPLACE), + ); + self.dispatch_isolated(specs, cx) + } + + pub(super) fn context_link_spec( + &self, + request: &ContextRequest, + url: &str, + ) -> Option { + let mapped = self.context_text_mapping(request)?; + let range = mapped.replacement_range()?; + let link = self.types.link?; + let existing = links::link_at(self.state.doc(), link, range.start) + .is_some_and(|(span, _)| span.start <= range.start && range.end <= span.end); + for position in range.clone() { + let node = self.state.doc().node_at(position)?; + if (!existing && node.marks().contains_type(link)) + || [self.types.code, self.types.math] + .into_iter() + .flatten() + .any(|ty| node.marks().contains_type(ty)) + { + return None; + } + if self + .state + .doc() + .resolve(position) + .is_ok_and(|resolved| self.types.is_verbatim(resolved.parent().type_id())) + { + return None; + } + } + // Detectors also report URLs that CommonMark already linked. Keep + // their existing destination, without cancelling unrelated checks. + if existing { + return Some(ContextLinkChange::Existing); + } + let Ok(selected) = self + .state + .update([TransactionSpec::new().selection(Selection::text(range.start, range.end))]) + else { + return None; + }; + let Ok(Some(spec)) = self.link_command(link, Some(url))(selected.state()) else { + return None; + }; + Some(ContextLinkChange::Add(spec)) + } + + /// Replace reading text through the document kind's source-preserving + /// planner, or a precise literal span when the kind has no such planner. + pub fn replace_context_text( + &mut self, + request: &ContextRequest, + replacement: &str, + cx: &mut Context, + ) -> bool { + self.replace_context_text_with_policy( + request, + replacement, + markraft_core::kind::ReadingReplacementPolicy::PreserveUnchanged, + cx, + ) + } + + /// Replace a system result with its explicit presentation policy. Unlike + /// proofreading, a plain-text Services result may change styles even when + /// its characters are identical to the selected reading text. + pub fn replace_context_text_with_policy( + &mut self, + request: &ContextRequest, + replacement: &str, + policy: markraft_core::kind::ReadingReplacementPolicy, + cx: &mut Context, + ) -> bool { + if !self.context_is_current(request) || self.is_composing() { + return false; + } + if let Some(command) = self + .kind + .replace_reading(request.text_range(), replacement, policy) + { + match command(&self.state) { + Ok(Some(spec)) => return self.dispatch_isolated([spec], cx), + Ok(None) => {} + Err(_) => return false, + } + } + let Some(changes) = self.context_replacement_changes(request, replacement) else { + return false; + }; + self.dispatch_isolated( + [TransactionSpec::new() + .changes(changes) + .user_event(event::INPUT_REPLACE)], + cx, + ) + } + + /// Apply nonoverlapping replacements expressed in the snapshot's reading + /// scalar offsets. Validate every source span before committing any change. + pub fn replace_context_text_edits( + &mut self, + request: &ContextRequest, + mut replacements: Vec<(Range, String)>, + cx: &mut Context, + ) -> bool { + replacements.sort_by_key(|(range, _)| range.start); + if replacements + .windows(2) + .any(|pair| pair[0].0.end > pair[1].0.start) + { + return false; + } + let mut changes = Vec::new(); + for (range, replacement) in replacements { + let Some(narrowed) = self.context_text_range(request, range) else { + return false; + }; + if self + .context_text(&narrowed) + .is_some_and(|text| text.text == replacement) + { + continue; + } + let Some(edit) = self.context_replacement_changes(&narrowed, &replacement) else { + return false; + }; + changes.extend(edit); + } + !changes.is_empty() + && self.dispatch_isolated( + [TransactionSpec::new() + .changes(changes) + .user_event(event::INPUT_REPLACE)], + cx, + ) + } + + /// Apply substitutions and detected links from the same checking snapshot. + /// Their reading ranges must not overlap; all edits share one guard and undo. + pub fn apply_context_text_checks( + &mut self, + request: &ContextRequest, + replacements: Vec<(Range, String)>, + links: Vec<(Range, String)>, + cx: &mut Context, + ) -> bool { + let mut ranges: Vec<_> = replacements + .iter() + .chain(links.iter()) + .map(|(range, _)| range.clone()) + .collect(); + ranges.sort_by_key(|range| range.start); + if ranges.windows(2).any(|pair| pair[0].end > pair[1].start) { + return false; + } + let mut specs = Vec::new(); + for (range, replacement) in replacements { + let Some(narrowed) = self.context_text_range(request, range) else { + return false; + }; + if self + .context_text(&narrowed) + .is_some_and(|text| text.text == replacement) + { + continue; + } + let Some(changes) = self.context_replacement_changes(&narrowed, &replacement) else { + return false; + }; + specs.push(TransactionSpec::new().changes(changes)); + } + for (range, url) in links { + let Some(narrowed) = self.context_text_range(request, range) else { + return false; + }; + let Some(change) = self.context_link_spec(&narrowed, &url) else { + return false; + }; + if let ContextLinkChange::Add(spec) = change { + specs.push(spec); + } + } + self.dispatch_context_check_specs(specs, cx) + } + + pub(super) fn context_insert_position(&self, request: &ContextRequest) -> Option { + if !self.context_is_current(request) + || self.is_composing() + || !request.text_range().is_empty() + { + return None; + } + let position = request.text_range().start; + let resolved = self.state.doc().resolve(position).ok()?; + if resolved.depth() != 1 || Some(resolved.parent().type_id()) != self.types.paragraph { + return None; + } + let node = self.state.doc().node_at(position); + if node.as_ref().is_some_and(|node| { + [self.types.code, self.types.math] + .into_iter() + .flatten() + .any(|ty| node.marks().contains_type(ty)) + }) { + return None; + } + if node.as_ref().is_some_and(|node| { + self.types + .syntax + .is_some_and(|syntax| node.marks().contains_type(syntax)) + }) { + return None; + } + Some(position) + } + + pub(super) fn context_replacement_changes( + &self, + request: &ContextRequest, + replacement: &str, + ) -> Option> { + if let Some(position) = self.context_insert_position(request) { + if replacement.is_empty() { + return None; + } + if replacement.contains(['\n', '\r']) && !self.active_marks().is_empty() { + return None; + } + let slice = if let Some(slice) = self.matching_style_slice(replacement) { + slice + } else { + markraft_core::Slice::from_fragment(markraft_core::Fragment::from_node( + self.state + .schema() + .text_marked(replacement, self.active_marks()), + )) + }; + return Some(markraft_core::commands::replace_selection_changes( + self.state.schema(), + self.state.doc(), + position, + position, + &slice, + )); + } + let mapped = self.context_text_mapping(request)?; + let range = mapped.replacement_range()?; + if replacement == mapped.text { + return None; + } + let verbatim = self + .state + .doc() + .resolve(range.start) + .is_ok_and(|resolved| self.types.is_verbatim(resolved.parent().type_id())); + // A line break inside source-delimited emphasis/link text could strand + // its closing delimiter in another paragraph. Only ordinary top-level + // prose or verbatim blocks accept arbitrary new paragraphs here. + if !verbatim + && replacement.contains(['\n', '\r']) + && mapped + .source + .iter() + .flatten() + .filter(|span| span.end == span.start + 1) + .any(|span| { + let doc = self.state.doc(); + !doc.resolve(span.start).is_ok_and(|resolved| { + resolved.depth() == 1 + && Some(resolved.parent().type_id()) == self.types.paragraph + }) || doc + .node_at(span.start) + .is_none_or(|node| !node.marks().is_empty()) + }) + { + return None; + } + Some( + if let Some(codecs) = &self.codecs + && !verbatim + { + markraft_core::commands::replace_selection_changes( + self.state.schema(), + self.state.doc(), + range.start, + range.end, + &codecs.from_text(replacement), + ) + } else { + let change = self.context_text_change(range, replacement)?; + vec![change] + }, + ) + } + + /// Transform reading characters in one guarded transaction while retaining + /// each character's marks and every concealed source delimiter/destination. + pub fn transform_context_text( + &mut self, + request: &ContextRequest, + transformation: TextTransformation, + cx: &mut Context, + ) -> bool { + let mut changes = Vec::new(); + let Some(mapped) = self + .context_text_mapping(request) + .filter(|mapped| mapped.exact) + else { + return false; + }; + let Some(replacements) = transformation.characters(&mapped.text) else { + return false; + }; + for ((original, source), replacement) in + mapped.text.chars().zip(mapped.source).zip(replacements) + { + let Some(source) = source else { continue }; + if replacement == original.to_string() { + continue; + } + let Some(change) = self.context_text_change(source, &replacement) else { + return false; + }; + changes.push(change); + } + !changes.is_empty() + && self.dispatch_isolated( + [TransactionSpec::new() + .changes(changes) + .user_event(event::INPUT_REPLACE)], + cx, + ) + } + + pub(super) fn context_text_change( + &self, + range: Range, + text: &str, + ) -> Option { + use markraft_core::{Change, Fragment, Slice}; + let node = self.state.doc().node_at(range.start)?; + if !node.is_text() { + return None; + } + let slice = if text.is_empty() { + Slice::empty() + } else { + Slice::from_fragment(Fragment::from_node( + self.state.schema().text_marked(text, node.marks().clone()), + )) + }; + Some(Change::replace(range.start, range.end, slice)) + } + + /// Read text and its source provenance in one pass for native document + /// mirrors. This uses the same concealment rules as text services. + pub fn context_text_mapping(&self, request: &ContextRequest) -> Option { + use markraft_core::kind::{conceal::Reveal, reading}; + if !self.context_is_current(request) || self.is_composing() { + return None; + } + let selected = request.text_range(); + if selected.is_empty() { + return None; + } + let projection = self.analysis.projection(); + let mut mapped = ContextTextMap { + text: String::new(), + source: Vec::new(), + exact: true, + }; + let first = projection + .lines() + .partition_point(|line| line.to() < selected.start); + let last = projection + .lines() + .partition_point(|line| line.from() <= selected.end); + for (index, line) in projection.lines().iter().enumerate().take(last).skip(first) { + for piece in reading::line_pieces(projection, &self.types, index, &Reveal::nothing()) { + let from = line.offset_to_pos(piece.source.start)?; + let to = line.offset_to_pos(piece.source.end)?; + if to <= selected.start || from >= selected.end { + continue; + } + if !piece.own { + mapped.exact = false; + if from >= selected.start && to <= selected.end { + mapped + .source + .extend(std::iter::repeat_n(None, piece.text.chars().count())); + mapped.text.push_str(&piece.text); + } + continue; + } + let start = selected.start.saturating_sub(from); + let end = (selected.end.min(to) - from).min(piece.text.chars().count()); + let mut boundary = 0; + let mut boundaries = vec![0]; + for grapheme in piece.text.graphemes(true) { + boundary += grapheme.chars().count(); + boundaries.push(boundary); + } + mapped.exact &= boundaries.contains(&start) && boundaries.contains(&end); + for (offset, character) in piece.text.chars().enumerate().take(end).skip(start) { + let at = line.offset_to_pos(piece.source.start + offset)?; + let after = line.offset_to_pos(piece.source.start + offset + 1)?; + mapped.text.push(character); + mapped.source.push(Some(at..after)); + } + } + if let Some(next) = projection.line(index + 1) + && line.to() >= selected.start + && next.from() <= selected.end + { + mapped.text.push('\n'); + let plain_paragraph = |position| { + self.state.doc().resolve(position).is_ok_and(|resolved| { + resolved.depth() == 1 + && Some(resolved.parent().type_id()) == self.types.paragraph + }) + }; + mapped.source.push( + (plain_paragraph(line.from()) && plain_paragraph(next.from())) + .then_some(line.to()..next.from()), + ); + } + } + (!mapped.text.is_empty()).then_some(mapped) + } +} diff --git a/crates/markraft-gpui/src/context/smart_edit.rs b/crates/markraft-gpui/src/context/smart_edit.rs new file mode 100644 index 00000000..a2e1c7e0 --- /dev/null +++ b/crates/markraft-gpui/src/context/smart_edit.rs @@ -0,0 +1,268 @@ +//! Word-aware clipboard edits: smart deletion, padded paste, and pasting +//! text in the style of where it lands. + +use super::*; + +impl EditorView { + /// Native smart spacing sees the rendered paragraph, while every boundary + /// must map to contiguous literal source. Delimiters and atoms are barriers. + pub(super) fn smart_clipboard_context(&self) -> Option { + if !self.smart_insert_delete + || self.single_line + || self.is_composing() + || self.state.selection().ranges(self.state.doc()).len() > 1 + { + return None; + } + let selection = self.state.selection().from(self.state.doc()) + ..self.state.selection().to(self.state.doc()); + let projection = self.analysis.projection(); + let (index, _) = projection.pos_to_line_offset(selection.start)?; + let line = projection.line(index)?; + if selection.end > line.to() || self.types.is_verbatim_block(line) { + return None; + } + let request = self.context_source_range_snapshot(line.from()..line.to())?; + let mapped = self.context_text_mapping(&request)?; + if !mapped.exact || mapped.source.iter().any(Option::is_none) { + return None; + } + let spans: Vec<_> = mapped.source.iter().flatten().collect(); + let offset = |position| { + spans + .iter() + .position(|span| span.start == position) + .or_else(|| { + spans + .iter() + .position(|span| span.end == position) + .map(|index| index + 1) + }) + }; + let start = offset(selection.start)?; + let end = offset(selection.end)?; + let mut boundaries: Vec<_> = spans.iter().map(|span| span.start).collect(); + boundaries.push(spans.last()?.end); + boundaries[start] = selection.start; + boundaries[end] = selection.end; + let prose_range = if selection.is_empty() { + if start < spans.len() { + (*spans[start]).clone() + } else { + (*spans.last()?).clone() + } + } else { + selection.clone() + }; + let prose = self.context_source_range_snapshot(prose_range)?; + if !self.context_is_prose(&prose) + || self.context_text_mapping(&prose)?.replacement_range()? != prose.text_range() + { + return None; + } + Some(crate::smart_clipboard::Context { + text: mapped.text, + selection: start..end, + boundaries, + }) + } + + pub(super) fn selected_by_word(&self) -> bool { + self.word_selection.as_ref() == Some(self.state.selection()) + } + + pub(crate) fn smart_copy_eligible(&self) -> bool { + self.selected_by_word() + && self + .smart_clipboard_context() + .is_some_and(|context| crate::smart_clipboard::whole_words(&context)) + } + + pub(crate) fn smart_delete_spec(&self) -> Option { + // Only a word picked as a word takes its space along; a caret or a + // range extended character by character deletes what it covers. + if !self.selected_by_word() { + return None; + } + let context = self.smart_clipboard_context()?; + if !crate::smart_clipboard::whole_words(&context) { + return None; + } + let range = crate::smart_clipboard::delete(&context)?; + let from = *context.boundaries.get(range.start)?; + let to = *context.boundaries.get(range.end)?; + let request = self.context_source_range_snapshot(from..to)?; + if self.context_text_mapping(&request)?.replacement_range()? != (from..to) + || !self.context_is_prose(&request) + { + return None; + } + Some( + TransactionSpec::new() + .changes([markraft_core::Change::replace( + from, + to, + markraft_core::Slice::empty(), + )]) + .user_event(event::DELETE), + ) + } + + pub(crate) fn smart_paste_specs( + &self, + spec: TransactionSpec, + item: &ClipboardItem, + cx: &App, + ) -> Vec { + let Some(context) = self + .smart_clipboard_context() + .filter(|_| clipboard::smart_item(item, cx)) + else { + return vec![spec]; + }; + let Ok(candidate) = self.state.update([spec.clone()]) else { + return vec![spec]; + }; + let start = context.boundaries[context.selection.start]; + let end = context.boundaries[context.selection.end]; + let from = candidate + .changes() + .desc() + .map_pos(start, -1, markraft_core::TrackMode::Simple) + .unwrap_or(start); + let to = candidate + .changes() + .desc() + .map_pos(end, 1, markraft_core::TrackMode::Simple) + .unwrap_or(end); + let selection = Selection::text(from, to); + let slice = selection.content_with_schema(candidate.new_doc(), self.state.schema()); + let text = markraft_core::kind::conceal::slice_text( + self.state.schema(), + self.types.syntax, + &slice, + ); + let Some((before, after)) = crate::smart_clipboard::padding(&context, &text) else { + return vec![spec]; + }; + self.padded_paste_specs(spec, from..to, &before, &after) + } + + pub(super) fn padded_paste_specs( + &self, + spec: TransactionSpec, + inserted: Range, + before: &str, + after: &str, + ) -> Vec { + use markraft_core::{Change, Fragment, Slice}; + let mut changes = Vec::new(); + for (position, text) in [(inserted.start, before), (inserted.end, after)] { + if !text.is_empty() { + changes.push(Change::replace( + position, + position, + Slice::from_fragment(Fragment::from_node(self.state.schema().text(text))), + )); + } + } + if changes.is_empty() { + vec![spec] + } else { + vec![ + spec, + TransactionSpec::new() + .changes(changes) + .sequential() + .user_event(event::INPUT_PASTE), + ] + } + } + + pub(super) fn paste_match_style(&mut self, cx: &mut Context) { + if self.single_line || self.types.in_verbatim_block_at(&self.state) || self.codecs.is_none() + { + self.paste(clipboard::PasteMode::Plain, cx); + return; + } + let Some(item) = cx.read_from_clipboard() else { + return; + }; + let Some(text) = item.text() else { + return; + }; + if let Some(spec) = clipboard::math_source_paste(&self.state, &self.types, &text) { + self.edit(cx, false, vec![spec.user_event(event::INPUT_PASTE)]); + return; + } + if text.contains('\n') + && self + .types + .table_types() + .and_then(|types| markraft_core::commands::cell_at(types, &self.state)) + .is_some() + { + self.paste(clipboard::PasteMode::Plain, cx); + return; + } + let Some(slice) = self.matching_style_slice(&text) else { + return; + }; + if let Some(spec) = markraft_core::commands::replace_selection(slice)(&self.state) { + let specs = self.smart_paste_specs(spec.user_event(event::INPUT_PASTE), &item, cx); + self.edit(cx, false, specs); + } + } + + pub(super) fn matching_style_slice(&self, text: &str) -> Option { + use markraft_core::{Fragment, Slice}; + let codecs = self.codecs.as_ref()?; + let selected = self.state.selection(); + let doc = self.state.doc(); + let (from, to) = (selected.from(doc), selected.to(doc)); + let mut marks = self.active_marks(); + // Source delimiters outside the replaced range will continue to provide + // these styles. Spelling them again would nest duplicate delimiters. + if self.types.syntax.is_some() { + let before = from.checked_sub(1).and_then(|pos| doc.node_at(pos)); + let after = doc.node_at(to); + marks = marks.filter(|mark| { + !(before + .as_ref() + .is_some_and(|node| node.marks().contains(mark)) + && after + .as_ref() + .is_some_and(|node| node.marks().contains(mark))) + }); + } + fn styled( + node: &Node, + schema: &markraft_core::Schema, + marks: &markraft_core::MarkSet, + ) -> Node { + if node.is_text() { + let mut combined = node.marks().clone(); + for mark in marks.iter() { + combined = combined.add(schema, mark.clone()); + } + node.mark(combined) + } else { + node.copy(Fragment::from_nodes( + node.children().map(|child| styled(child, schema, marks)), + )) + } + } + let plain = codecs.from_text(text); + let styled = Slice::new( + Fragment::from_nodes( + plain + .content() + .iter() + .map(|node| styled(node, self.state.schema(), &marks)), + ), + plain.open_start(), + plain.open_end(), + ); + Some(codecs.copied(&styled)) + } +} diff --git a/crates/markraft-gpui/src/context/tests.rs b/crates/markraft-gpui/src/context/tests.rs new file mode 100644 index 00000000..64cad4fd --- /dev/null +++ b/crates/markraft-gpui/src/context/tests.rs @@ -0,0 +1,1096 @@ +use super::*; +use crate::Setup; +use gpui::{AppContext, Entity, MouseButton, TestAppContext, VisualTestContext, point, px}; +use markraft_commonmark::{ + commonmark_doc_type_names, commonmark_extensions, commonmark_schema, from_markdown, +}; +use markraft_core::{commands, composition, history, kind::DocTypes}; + +fn setup(source: &str) -> Setup { + let schema = commonmark_schema(); + Setup::new(schema.clone()) + .types(DocTypes::from_schema_names( + &schema, + &commonmark_doc_type_names(), + )) + .extensions(commonmark_extensions(&schema)) + .doc(from_markdown(&schema, source).unwrap()) +} + +struct ReadingKind; + +impl crate::DocumentKind for ReadingKind { + fn replace_reading( + &self, + range: Range, + text: &str, + policy: markraft_core::kind::ReadingReplacementPolicy, + ) -> Option { + let command = markraft_commonmark::Formatter::new(Default::default()) + .replace_reading(range, text, policy); + Some(std::sync::Arc::new(move |state| { + command(state).map_err(|error| error.to_string()) + })) + } +} + +fn range(view: &EditorView) -> Range { + view.state.selection().from(view.state.doc())..view.state.selection().to(view.state.doc()) +} + +fn click_character( + view: &Entity, + cx: &mut VisualTestContext, + offset: usize, + control: bool, +) { + let position = view.read_with(cx, |view, _| { + view.frame.rows()[0].rectangles(offset..offset + 1, false)[0].center() + }); + cx.simulate_mouse_down( + position, + if control { + MouseButton::Left + } else { + MouseButton::Right + }, + gpui::Modifiers { + control, + ..Default::default() + }, + ); + cx.run_until_parked(); +} + +#[gpui::test] +fn smart_clipboard_maps_prose_and_excludes_delimiters_and_code(cx: &mut TestAppContext) { + let view = + cx.new(|cx| EditorView::new(setup("one **two** three"), cx).with_smart_insert_delete(true)); + view.update(cx, |view, cx| { + view.select_range(7, 10, cx); + let context = view.smart_clipboard_context().unwrap(); + assert_eq!(context.text, "one two three"); + assert_eq!(context.selection, 4..7); + view.select_range(5, 12, cx); + assert!(view.smart_clipboard_context().is_none()); + view.set_smart_insert_delete(false); + view.select_range(1, 4, cx); + assert!(view.smart_clipboard_context().is_none()); + }); + let code = + cx.new(|cx| EditorView::new(setup("one `two` three"), cx).with_smart_insert_delete(true)); + code.update(cx, |view, cx| { + view.select_range(6, 9, cx); + assert!(view.smart_clipboard_context().is_none()); + }); +} + +#[gpui::test] +fn only_a_selection_made_by_a_word_gesture_counts_as_a_word(cx: &mut TestAppContext) { + let view = + cx.new(|cx| EditorView::new(setup("one two three"), cx).with_smart_insert_delete(true)); + view.update(cx, |view, cx| { + // The same range, extended by hand: deletion takes it and no more. + view.select_range(5, 8, cx); + assert!(!view.selected_by_word()); + assert!(view.smart_delete_spec().is_none()); + assert!(!view.smart_copy_eligible()); + + view.select_context(6, Some(6), false, &ContextTarget::Text, cx); + assert_eq!(view.state.selection(), &Selection::text(5, 8)); + assert!(view.selected_by_word()); + // Any other selection ends the gesture, and coming back to the + // range does not restore it. + view.select(6, false, cx); + assert!(!view.selected_by_word()); + view.select_range(5, 8, cx); + assert!(!view.selected_by_word()); + }); +} + +#[gpui::test] +fn smart_padding_is_atomic_and_one_undo(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup("one three"), cx)); + view.update(cx, |view, cx| { + view.select(4, false, cx); + let spec = commands::insert_text("two")(&view.state).unwrap(); + let specs = view.padded_paste_specs(spec, 4..7, " ", ""); + assert!(view.dispatch_isolated(specs, cx)); + assert_eq!(view.projection().plain_text(), "one two three"); + assert_eq!(history::undo_depth(&view.state), 1); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.projection().plain_text(), "one three"); + view.document_guard = Some(Box::new(|_| { + Err(crate::EditRejection::Refused("read only".into())) + })); + let spec = commands::insert_text("two")(&view.state).unwrap(); + let specs = view.padded_paste_specs(spec, 4..7, " ", ""); + assert!(!view.dispatch_isolated(specs, cx)); + assert_eq!(view.projection().plain_text(), "one three"); + }); +} + +#[gpui::test] +fn return_only_services_support_empty_paragraphs_and_preserve_enclosing_style( + cx: &mut TestAppContext, +) { + for (source, position, expected) in [("", 1, "inserted"), ("**word**", 5, "**woinsertedrd**")] { + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.update(cx, |view, cx| { + view.codecs = Some(std::sync::Arc::new( + markraft_commonmark::CommonMarkCodecs::new( + view.state.schema().clone(), + Default::default(), + ), + )); + view.select(position, false, cx); + let request = view.context_snapshot(Point::default(), ContextTarget::Text); + assert!(view.context_text(&request).unwrap().replaceable); + assert!(view.replace_context_text(&request, "inserted", cx)); + assert_eq!(view.projection().plain_text(), expected); + }); + } +} + +#[gpui::test] +fn return_only_services_insert_literal_text_at_caret_and_reject_stale_or_readonly( + cx: &mut TestAppContext, +) { + let view = cx.new(|cx| EditorView::new(setup("before after"), cx)); + view.update(cx, |view, cx| { + view.codecs = Some(std::sync::Arc::new( + markraft_commonmark::CommonMarkCodecs::new( + view.state.schema().clone(), + Default::default(), + ), + )); + view.select(8, false, cx); + let request = view.context_snapshot(Point::default(), ContextTarget::Text); + assert_eq!(view.context_text(&request).unwrap().text, ""); + assert!(view.replace_context_text(&request, "*literal* ", cx)); + assert_eq!( + view.context_text(&view.context_document_snapshot()) + .unwrap() + .text, + "before *literal* after" + ); + assert!(!view.replace_context_text(&request, "stale", cx)); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.projection().plain_text(), "before after"); + let request = view.context_snapshot(Point::default(), ContextTarget::Text); + view.document_guard = Some(Box::new(|_| { + Err(crate::EditRejection::Refused("read only".into())) + })); + assert!(!view.replace_context_text(&request, "blocked", cx)); + assert_eq!(view.projection().plain_text(), "before after"); + }); +} + +#[gpui::test] +fn right_click_preserves_selection_or_selects_clicked_word(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| EditorView::new(setup("one two three"), cx)); + cx.run_until_parked(); + view.update(cx, |view, cx| view.select_range(1, 8, cx)); + cx.run_until_parked(); + click_character(&view, cx, 5, false); + view.read_with(cx, |view, _| { + assert_eq!(range(view), 1..8); + assert!(!view.selecting); + }); + click_character(&view, cx, 10, true); + view.read_with(cx, |view, _| { + assert_eq!(range(view), 9..14); + assert!(!view.selecting); + }); + let blank = view.read_with(cx, |view, _| { + let row = &view.frame.rows()[0]; + let last = row.rectangles(row.char_len - 1..row.char_len, false)[0]; + point(last.right() + px(15.), last.center().y) + }); + cx.simulate_mouse_down(blank, MouseButton::Right, Default::default()); + cx.run_until_parked(); + view.read_with(cx, |view, _| assert_eq!(range(view), 14..14)); +} + +#[gpui::test] +fn right_click_word_ranges_keep_contextual_dictionary_and_whitespace(cx: &mut TestAppContext) { + for (source, offset, expected) in [ + ("one two", 4, " "), + ("one...two", 4, "."), + #[cfg(target_os = "macos")] + ("中文编辑器测试", 2, "编辑"), + ] { + let (view, cx) = cx.add_window_view(|_, cx| EditorView::new(setup(source), cx)); + cx.run_until_parked(); + click_character(&view, cx, offset, false); + view.read_with(cx, |view, _| { + let range = range(view); + assert_eq!( + view.projection().text_between(range.start, range.end), + Some(expected) + ); + }); + } +} + +#[gpui::test] +fn right_click_selects_the_entire_link_label_but_preserves_existing_selection( + cx: &mut TestAppContext, +) { + let source = "[sample **bold** link](https://example.com) tail"; + let (view, cx) = cx.add_window_view(|_, cx| EditorView::new(setup(source), cx)); + cx.run_until_parked(); + let expected = 2..source.find(']').unwrap() + 1; + click_character(&view, cx, 3, false); + view.read_with(cx, |view, _| assert_eq!(range(view), expected)); + + // Right-clicking within a partial label selection must not expand it. + view.update(cx, |view, cx| view.select_range(2, 8, cx)); + cx.run_until_parked(); + click_character(&view, cx, 3, false); + view.read_with(cx, |view, _| assert_eq!(range(view), 2..8)); + + // A reversed selection spanning the link and surrounding text is also + // preserved, including its anchor direction. + let end = source.chars().count() + 1; + view.update(cx, |view, cx| view.select_range(end, 2, cx)); + cx.run_until_parked(); + let selected = view.read_with(cx, |view, _| view.state.selection().clone()); + click_character(&view, cx, 3, false); + view.read_with(cx, |view, _| assert_eq!(view.state.selection(), &selected)); + + // Control-click outside the old selection uses the same link policy. + view.update(cx, |view, cx| view.select_range(end - 4, end, cx)); + cx.run_until_parked(); + click_character(&view, cx, 3, true); + view.read_with(cx, |view, _| assert_eq!(range(view), expected)); +} + +#[gpui::test] +fn link_labels_exclude_destinations_and_resolve_character_positions(cx: &mut TestAppContext) { + for (source, position, expected) in [ + ("[中文 标签](https://example.com)", 3, 2..7), + ("[**bold**](https://example.com)", 5, 4..8), + ("", 5, 2..21), + ("https://example.com", 5, 1..20), + ("[a \\* b](https://example.com)", 2, 2..8), + ("[sample link][ref]\n\n[ref]: https://example.com", 3, 2..13), + ( + "[one](https://example.com)[two](https://example.com)", + 3, + 2..5, + ), + ("[one](https://example.com)https://example.com", 3, 2..5), + ("[one](https://example.com)https://example.com", 27, 27..46), + ] { + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.read_with(cx, |view, _| { + let (range, _) = links::link_at(view.state.doc(), view.types.link.unwrap(), position) + .expect("a link at the test position"); + let range = view.context_link_range(range, position); + assert_eq!(view.context_link_label(&range), Some(expected), "{source}"); + }); + } +} + +#[gpui::test] +fn right_click_on_task_marker_does_not_toggle_or_start_drag(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| EditorView::new(setup("- [ ] task"), cx)); + cx.run_until_parked(); + let (position, doc) = view.read_with(cx, |view, _| { + ( + view.frame.rows()[0].task_marker().unwrap().1.center(), + view.state.doc().clone(), + ) + }); + cx.simulate_mouse_down(position, MouseButton::Right, Default::default()); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + assert_eq!(view.state.doc(), &doc); + assert!(!view.selecting); + }); +} + +#[gpui::test] +fn right_click_on_link_does_not_emit_navigation(cx: &mut TestAppContext) { + let (view, cx) = + cx.add_window_view(|_, cx| EditorView::new(setup("[link](https://example.com) tail"), cx)); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let seen = events.clone(); + cx.update(|_, cx| { + cx.subscribe(&view, move |_, event, _| { + seen.borrow_mut().push(event.clone()) + }) + .detach() + }); + cx.run_until_parked(); + let position = view.read_with(cx, |view, _| { + let row = &view.frame.rows()[0]; + row.rectangles(2..3, false)[0].center() + }); + cx.simulate_mouse_down(position, MouseButton::Right, Default::default()); + cx.run_until_parked(); + assert!(!events.borrow().iter().any(|event| matches!( + event, + EditorEvent::LinkClicked | EditorEvent::WikiLinkClicked { .. } + ))); + assert!(events.borrow().iter().any(|event| matches!( + event, + EditorEvent::ContextMenuRequested(ContextRequest { + target: ContextTarget::Link { .. }, + .. + }) + ))); +} + +#[gpui::test] +fn table_context_keeps_clicked_cell_distinct_from_selection_head(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| { + EditorView::new(setup("| first | second |\n| --- | --- |\n| a | b |"), cx) + }); + cx.run_until_parked(); + let (position, expected_cell) = view.update(cx, |view, cx| { + let rows = view.frame.rows(); + let first = rows + .iter() + .find(|row| { + row.table + .is_some_and(|cell| cell.row == 0 && cell.column == 0) + }) + .unwrap(); + let second = rows + .iter() + .find(|row| { + row.table + .is_some_and(|cell| cell.row == 0 && cell.column == 1) + }) + .unwrap(); + let from = first.from; + let to = second.offset_to_pos(second.char_len); + let position = first.rectangles(1..2, false)[0].center(); + let target = view.context_target_at(position, first.offset_to_pos(1)); + let ContextTarget::Table { cell, .. } = target else { + panic!("expected a table target") + }; + view.select_range(from, to, cx); + (position, cell) + }); + cx.run_until_parked(); + let before = view.read_with(cx, |view, _| range(view)); + let received = std::rc::Rc::new(std::cell::RefCell::new(None)); + let seen = received.clone(); + cx.update(|_, cx| { + cx.subscribe(&view, move |_, event, _| { + if let EditorEvent::ContextMenuRequested(request) = event { + *seen.borrow_mut() = Some(request.clone()); + } + }) + .detach() + }); + cx.simulate_mouse_down(position, MouseButton::Right, Default::default()); + cx.run_until_parked(); + let request = received.borrow().clone().unwrap(); + assert_eq!(request.selection_range(), before); + assert!(matches!(request.target, ContextTarget::Table { cell, .. } if cell == expected_cell)); + view.read_with(cx, |view, _| { + let head = commands::cell_at(view.types.table_types().unwrap(), view.state()).unwrap(); + assert_ne!(head.cell, expected_cell); + }); +} + +#[gpui::test] +fn menu_snapshot_captures_selection_after_extensions_settle(cx: &mut TestAppContext) { + struct NormalizePointer; + impl crate::Extension for NormalizePointer { + fn id(&self) -> &'static str { + "normalize-pointer" + } + fn update(&mut self, update: &crate::Update, cx: &mut crate::EditorCx<'_>) { + if update.is_user_event(event::SELECT_POINTER) { + cx.select(Selection::text(1, 4), false); + } + } + } + let (view, cx) = cx.add_window_view(|_, cx| EditorView::new(setup("one two three"), cx)); + let _extension = view.update(cx, |view, cx| view.add_extension(NormalizePointer, cx)); + let received = std::rc::Rc::new(std::cell::RefCell::new(None)); + let seen = received.clone(); + cx.update(|_, cx| { + cx.subscribe(&view, move |_, event, _| { + if let EditorEvent::ContextMenuRequested(request) = event { + *seen.borrow_mut() = Some(request.clone()); + } + }) + .detach() + }); + cx.run_until_parked(); + click_character(&view, cx, 10, false); + let request = received.borrow().clone().unwrap(); + assert_eq!(request.selection_range(), 1..4); + view.read_with(cx, |view, _| assert!(view.context_is_current(&request))); +} + +#[test] +fn word_ranges_map_display_scalars_and_include_whitespace() { + let source = "naïve café 中文 👩‍💻"; + let setup = setup(source); + let projection = Projection::of(setup.doc.as_ref().unwrap(), &setup.schema); + let pieces = crate::shown::line_pieces( + &projection, + &setup.types, + 0, + &markraft_core::kind::conceal::Reveal::nothing(), + ); + assert_eq!( + word_at( + &projection, + 3, + &pieces, + crate::word_boundary::Intent::Context + ), + Some(1..6) + ); + assert_eq!( + word_at( + &projection, + 8, + &pieces, + crate::word_boundary::Intent::Context + ), + Some(7..11) + ); + assert_eq!( + word_at( + &projection, + 6, + &pieces, + crate::word_boundary::Intent::Context + ), + Some(6..7) + ); + #[cfg(target_os = "macos")] + assert_eq!( + word_at( + &projection, + 12, + &pieces, + crate::word_boundary::Intent::Context + ), + Some(12..14) + ); + #[cfg(not(target_os = "macos"))] + assert_eq!( + word_at( + &projection, + 12, + &pieces, + crate::word_boundary::Intent::Context + ), + Some(12..13) + ); + assert_eq!( + word_at( + &projection, + 16, + &pieces, + crate::word_boundary::Intent::Context + ), + Some(15..18) + ); +} + +#[test] +fn pointer_words_follow_concealment_and_preserve_source_provenance() { + use markraft_core::kind::conceal::Reveal; + + let resolve = |source: &str, target: &str, revealed: bool| { + let setup = setup(source); + let projection = Projection::of(setup.doc.as_ref().unwrap(), &setup.schema); + let line = &projection.lines()[0]; + let source = projection.line_text(0).unwrap(); + let offset = source[..source.find(target).unwrap()].chars().count(); + let position = line.offset_to_pos(offset).unwrap(); + let reveal = if revealed { + Reveal::at(line.from()..line.to(), None) + } else { + Reveal::nothing() + }; + let pieces = crate::shown::line_pieces(&projection, &setup.types, 0, &reveal); + word_at( + &projection, + position, + &pieces, + crate::word_boundary::Intent::Pointer, + ) + .map(|range| { + projection + .text_between(range.start, range.end) + .unwrap() + .to_owned() + }) + }; + assert_eq!(resolve("abc", "a", false).as_deref(), Some("abc")); + assert_eq!(resolve("abc", "&", false).as_deref(), Some("abc")); + assert_eq!(resolve("abc", "&", true).as_deref(), Some("&")); + assert_eq!(resolve("**word**", "*", false), None); + assert_eq!(resolve("**word**", "*", true).as_deref(), Some("*")); + assert_eq!(resolve("a[[Notes]]b", "a", false).as_deref(), Some("a")); + assert_eq!(resolve("a[[Notes]]b", "b", false).as_deref(), Some("b")); + assert_eq!(resolve("a[[Notes]]b", "\u{fffc}", false), None); + #[cfg(target_os = "macos")] + assert_eq!(resolve("宇**宙**", "宙", false).as_deref(), Some("宇**宙")); +} + +#[gpui::test] +fn context_commits_composition_without_discarding_candidate(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup(""), cx)); + view.update(cx, |view, cx| { + let candidate = composition::update_composition(&view.state, "中文", 2).unwrap(); + view.dispatch([candidate], cx); + assert!(view.is_composing()); + let candidate_doc = view.state.doc().clone(); + view.select_context(1, Some(1), false, &ContextTarget::Text, cx); + assert!(!view.is_composing()); + assert_eq!(view.state.doc(), &candidate_doc); + assert_eq!(view.committed_document(), &candidate_doc); + assert!(view.context_is_current( + &view.context_snapshot(point(px(0.), px(0.)), ContextTarget::Text) + )); + }); +} + +#[gpui::test] +fn menu_snapshot_expires_after_selection_composition_undo_and_replacement(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup("hello"), cx)); + view.update(cx, |view, cx| { + let snapshot = view.context_snapshot(point(px(0.), px(0.)), ContextTarget::Text); + view.select(2, false, cx); + view.select(1, false, cx); + assert!(!view.context_is_current(&snapshot)); + let snapshot = view.context_snapshot(point(px(0.), px(0.)), ContextTarget::Text); + view.run_command(&commands::insert_text("x"), cx); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert!(!view.context_is_current(&snapshot)); + let snapshot = view.context_snapshot(point(px(0.), px(0.)), ContextTarget::Text); + let candidate = composition::update_composition(&view.state, "中", 1).unwrap(); + view.dispatch([candidate], cx); + assert!(!view.context_is_current(&snapshot)); + view.cancel_composition(cx); + let snapshot = view.context_snapshot(point(px(0.), px(0.)), ContextTarget::Text); + view.replace_doc(view.state.doc().clone(), cx); + assert!(!view.context_is_current(&snapshot)); + }); +} + +#[gpui::test] +fn capabilities_are_read_only_and_keep_file_pastes_available(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup("hello"), cx)); + view.update(cx, |view, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(String::new())); + let snapshot = view.context_snapshot(point(px(0.), px(0.)), ContextTarget::Text); + assert_eq!(view.edit_capabilities(cx), EditCapabilities::default()); + assert!(view.context_is_current(&snapshot)); + view.begin_undo_group(); + view.end_undo_group(); + assert!( + view.context_is_current(&snapshot), + "history boundaries do not move a menu's editing target" + ); + view.select_range(1, 6, cx); + cx.write_to_clipboard(ClipboardItem::new_string("text".into())); + let snapshot = view.context_snapshot(point(px(0.), px(0.)), ContextTarget::Text); + let capabilities = view.edit_capabilities(cx); + assert!( + capabilities.copy + && capabilities.cut + && capabilities.paste + && capabilities.paste_plain + && capabilities.paste_markdown + ); + assert!(view.context_is_current(&snapshot)); + view.file_paste = true; + cx.write_to_clipboard(ClipboardItem::from(ClipboardEntry::ExternalPaths( + gpui::ExternalPaths(vec!["/tmp/image.png".into()].into()), + ))); + let capabilities = view.edit_capabilities(cx); + assert!(capabilities.paste && capabilities.paste_markdown); + assert!( + capabilities.paste_plain, + "external paths also carry their path as text" + ); + cx.write_to_clipboard(ClipboardItem::new_image(&gpui::Image::empty())); + let capabilities = view.edit_capabilities(cx); + assert!(capabilities.paste && capabilities.paste_markdown); + assert!(!capabilities.paste_plain); + }); +} + +#[gpui::test] +fn context_paste_is_its_own_undo_step_inside_explicit_typing_group(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup(""), cx)); + view.update(cx, |view, cx| { + view.begin_undo_group(); + view.run_command(&commands::insert_text("before"), cx); + cx.write_to_clipboard(ClipboardItem::new_string("paste".into())); + view.execute_context_action(ContextAction::PastePlain, cx); + view.run_command(&commands::insert_text("after"), cx); + view.end_undo_group(); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.projection().plain_text(), "beforepaste"); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.projection().plain_text(), "before"); + }); +} + +#[gpui::test] +fn source_preserving_services_support_rich_multiline_replacement_and_atomic_undo( + cx: &mut TestAppContext, +) { + let source = "**hello** _world_\n\nSecond paragraph."; + let view = + cx.new(|cx| EditorView::new(setup(source).kind(std::sync::Arc::new(ReadingKind)), cx)); + view.update(cx, |view, cx| { + let before = view.state.doc().clone(); + let request = view.context_document_snapshot(); + assert!(view.context_text(&request).unwrap().replaceable); + let result = "greetings world\nNew 😀 paragraph\nSecond paragraph."; + assert!(view.replace_context_text(&request, result, cx)); + assert_eq!( + view.context_text(&view.context_document_snapshot()) + .unwrap() + .text, + result + ); + assert!(!view.replace_context_text(&request, "stale", cx)); + assert_eq!(history::undo_depth(&view.state), 1); + view.dispatch([history::undo(&view.state).unwrap()], cx); + assert_eq!(*view.state.doc(), before); + let request = view.context_document_snapshot(); + view.document_guard = Some(Box::new(|_| { + Err(crate::EditRejection::Protected("Locked".into())) + })); + assert!(!view.replace_context_text(&request, result, cx)); + assert_eq!(*view.state.doc(), before); + }); +} + +#[gpui::test] +fn source_preserving_services_preserve_unselected_atoms_for_selection_and_caret( + cx: &mut TestAppContext, +) { + let source = "hello ![image](x.png)"; + for (from, to, replacement, expected) in [ + (1, 6, "hi", "hi ![image](x.png)"), + (1, 1, "before ", "before hello ![image](x.png)"), + (6, 6, " after", "hello after ![image](x.png)"), + ] { + let view = + cx.new(|cx| EditorView::new(setup(source).kind(std::sync::Arc::new(ReadingKind)), cx)); + view.update(cx, |view, cx| { + view.select_range(from, to, cx); + let request = view.context_snapshot(Default::default(), ContextTarget::Text); + assert!(view.context_text(&request).unwrap().replaceable); + assert!(view.replace_context_text(&request, replacement, cx)); + let codecs = markraft_commonmark::CommonMarkCodecs::new( + view.state.schema().clone(), + Default::default(), + ); + let written = markraft_core::kind::Codecs::to_markup( + &codecs, + &markraft_core::Slice::from_fragment(view.state.doc().content().clone()), + ) + .unwrap(); + assert_eq!(written, expected); + let request = view.context_document_snapshot(); + assert!(!view.context_text(&request).unwrap().replaceable); + assert!(!view.replace_context_text(&request, "cannot drop image", cx)); + }); + } +} + +#[gpui::test] +fn plain_service_policy_applies_same_text_style_changes_with_guarded_atomic_undo( + cx: &mut TestAppContext, +) { + use markraft_core::kind::ReadingReplacementPolicy::InheritSelectionStart; + let view = cx.new(|cx| { + EditorView::new( + setup("**red** _blue_").kind(std::sync::Arc::new(ReadingKind)), + cx, + ) + }); + view.update(cx, |view, cx| { + let before = view.state.doc().clone(); + let request = view.context_document_snapshot(); + assert!(view.replace_context_text_with_policy( + &request, + "red blue", + InheritSelectionStart, + cx + )); + assert_eq!(view.projection().plain_text(), "**red blue**"); + assert_eq!(history::undo_depth(&view.state), 1); + assert!(!view.replace_context_text_with_policy( + &request, + "stale", + InheritSelectionStart, + cx + )); + view.dispatch([history::undo(&view.state).unwrap()], cx); + assert_eq!(*view.state.doc(), before); + let request = view.context_document_snapshot(); + view.document_guard = Some(Box::new(|_| { + Err(crate::EditRejection::Protected("Locked".into())) + })); + assert!(!view.replace_context_text_with_policy( + &request, + "red blue", + InheritSelectionStart, + cx + )); + assert_eq!(*view.state.doc(), before); + }); +} + +#[gpui::test] +fn source_preserving_services_keep_the_existing_verbatim_replacement_path(cx: &mut TestAppContext) { + let view = cx.new(|cx| { + EditorView::new( + setup("```\nfirst line\nsecond line\n```").kind(std::sync::Arc::new(ReadingKind)), + cx, + ) + }); + view.update(cx, |view, cx| { + let request = view.context_document_snapshot(); + assert!(view.context_text(&request).unwrap().replaceable); + assert!(view.replace_context_text_with_policy( + &request, + "literal **code**\nwith 😀", + markraft_core::kind::ReadingReplacementPolicy::InheritSelectionStart, + cx, + )); + assert_eq!( + view.context_text(&view.context_document_snapshot()) + .unwrap() + .text, + "literal **code**\nwith 😀" + ); + }); +} + +#[gpui::test] +fn source_preserving_service_capability_rejects_protected_code_and_composition( + cx: &mut TestAppContext, +) { + let view = cx.new(|cx| { + EditorView::new( + setup("plain `code` text").kind(std::sync::Arc::new(ReadingKind)), + cx, + ) + }); + view.update(cx, |view, cx| { + let request = view.context_document_snapshot(); + assert!(!view.context_text(&request).unwrap().replaceable); + assert!(!view.replace_context_text(&request, "replacement", cx)); + view.select_range(1, 6, cx); + let request = view.context_snapshot(Default::default(), ContextTarget::Text); + assert!(view.context_text(&request).unwrap().replaceable); + assert!(view.replace_context_text(&request, "updated", cx)); + assert!(view.projection().plain_text().contains("`code`")); + view.select(1, false, cx); + let request = view.context_document_snapshot(); + view.dispatch( + [composition::update_composition(&view.state, "中", 1).unwrap()], + cx, + ); + assert!(!view.replace_context_text(&request, "blocked", cx)); + }); +} + +#[gpui::test] +fn text_service_reading_hides_markdown_and_maps_document_subranges(cx: &mut TestAppContext) { + let source = "A **bold** [label](https://example.com)\n\n中文 café"; + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.update(cx, |view, cx| { + let request = view.context_document_snapshot(); + let text = view.context_text(&request).unwrap(); + assert_eq!(text.text, "A bold label\n中文 café"); + assert!(!text.replaceable); + assert!(text.transformable); + let label = view.context_text_range(&request, 7..12).unwrap(); + assert_eq!(view.context_text(&label).unwrap().text, "label"); + assert!(view.replace_context_text(&label, "title", cx)); + assert!( + view.projection() + .plain_text() + .contains("[title](https://example.com)") + ); + assert!(!view.replace_context_text(&label, "stale", cx)); + }); +} + +#[gpui::test] +fn text_service_reading_preserves_inline_breaks_before_styles(cx: &mut TestAppContext) { + for source in ["one\n**two**", "one \ntwo", "one\r\ntwo"] { + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.update(cx, |view, _| { + let request = view.context_document_snapshot(); + let mapped = view.context_text_mapping(&request).unwrap(); + assert_eq!(mapped.text, "one\ntwo", "{source:?}"); + let newline = source.replace("\r\n", "\n").find('\n').unwrap() + 1; + assert_eq!(mapped.source[3], Some(newline..newline + 1)); + let word = view.context_text_range(&request, 4..7).unwrap(); + assert_eq!(view.context_text(&word).unwrap().text, "two"); + }); + } +} + +#[gpui::test] +fn text_service_replacement_preserves_formatting_and_has_its_own_undo_step( + cx: &mut TestAppContext, +) { + let view = cx.new(|cx| EditorView::new(setup("**word**"), cx)); + view.update(cx, |view, cx| { + view.begin_undo_group(); + view.select_range(3, 7, cx); + let request = view.context_snapshot(Point::default(), ContextTarget::Text); + assert!(view.context_text(&request).unwrap().replaceable); + assert!(!view.replace_context_text(&request, "broken\nformatting", cx)); + assert!(view.context_is_current(&request)); + assert!(view.replace_context_text(&request, "replacement", cx)); + assert_eq!(view.projection().plain_text(), "**replacement**"); + view.select(view.state.doc().content_size() - 1, false, cx); + view.run_command(&commands::insert_text(" later"), cx); + view.end_undo_group(); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.projection().plain_text(), "**replacement**"); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.projection().plain_text(), "**word**"); + }); +} + +#[gpui::test] +fn text_service_transform_is_atomic_and_preserves_source_syntax(cx: &mut TestAppContext) { + let source = "**straße** [mixed CASE](https://example.com/KeepCase)\n\nélÈVE ΟΣ"; + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.update(cx, |view, cx| { + let before = view.state.doc().clone(); + let request = view.context_document_snapshot(); + assert!(view.transform_context_text(&request, TextTransformation::Uppercase, cx)); + let text = view.projection().plain_text().to_owned(); + assert!( + text.contains("**STRASSE** [MIXED CASE](https://example.com/KeepCase)"), + "{text}" + ); + assert!(text.contains("ÉLÈVE ΟΣ")); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.state.doc(), &before); + let request = view.context_document_snapshot(); + assert!(view.transform_context_text(&request, TextTransformation::Capitalize, cx)); + let text = view.projection().plain_text().to_owned(); + assert!( + text.contains("**Straße** [Mixed Case](https://example.com/KeepCase)"), + "{text}" + ); + assert!(text.contains("Élève Ος"), "{text}"); + }); +} + +#[gpui::test] +fn text_service_refuses_composite_grapheme_and_guarded_replacement(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup("a **b** [c](https://example.com)"), cx)); + view.update(cx, |view, cx| { + let before = view.state.doc().clone(); + let request = view.context_document_snapshot(); + assert!(!view.replace_context_text(&request, "replacement", cx)); + view.document_guard = Some(Box::new(|_| { + Err(crate::EditRejection::Protected("Read only".into())) + })); + assert!(!view.transform_context_text(&request, TextTransformation::Uppercase, cx)); + assert_eq!(view.state.doc(), &before); + assert!(view.context_is_current(&request)); + assert_eq!(history::undo_depth(&view.state), 0); + }); + let view = cx.new(|cx| EditorView::new(setup("e\u{301} & ![picture](image.png)"), cx)); + view.update(cx, |view, cx| { + let request = view.context_document_snapshot(); + let text = view.context_text(&request).unwrap(); + assert_eq!(text.text, "e\u{301} & picture"); + assert!(!text.transformable); + assert!(view.context_text_range(&request, 0..1).is_none()); + assert!(view.context_text_range(&request, 3..4).is_none()); + assert!(view.context_text_range(&request, 5..12).is_none()); + let candidate = composition::update_composition(&view.state, "中", 1).unwrap(); + view.dispatch([candidate], cx); + assert!( + view.context_text(&view.context_document_snapshot()) + .is_none() + ); + }); +} +#[gpui::test] +fn text_service_rewrites_plain_paragraphs_with_literal_newlines(cx: &mut TestAppContext) { + let view = cx.new(|cx| EditorView::new(setup("first\n\nsecond"), cx)); + view.update(cx, |view, cx| { + view.codecs = Some(std::sync::Arc::new( + markraft_commonmark::CommonMarkCodecs::new( + view.state.schema().clone(), + markraft_commonmark::HouseStyleHandle::default(), + ), + )); + let before = view.state.doc().clone(); + let request = view.context_document_snapshot(); + assert!(view.context_text(&request).unwrap().replaceable); + assert!(view.replace_context_text(&request, "*literal*\n# text", cx)); + assert_eq!( + view.context_text(&view.context_document_snapshot()) + .unwrap() + .text, + "*literal*\n# text" + ); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.state.doc(), &before); + }); +} + +#[gpui::test] +fn text_diagnostics_survive_selection_and_follow_untouched_text_through_edits( + cx: &mut TestAppContext, +) { + let view = cx.new(|cx| EditorView::new(setup("hello world"), cx)); + view.update(cx, |view, cx| { + let request = view.context_document_snapshot(); + view.set_text_diagnostics(vec![7..12, 1..6, 7..12, 0..999, 4..4], cx); + assert_eq!(view.text_diagnostics, vec![1..6, 7..12]); + assert_eq!(history::undo_depth(&view.state), 0); + view.select(3, false, cx); + assert!(view.context_document_is_current(&request)); + assert!(!view.context_is_current(&request)); + assert_eq!(view.text_diagnostics, vec![1..6, 7..12]); + view.run_command(&commands::insert_text("x"), cx); + assert!(!view.context_document_is_current(&request)); + assert_eq!(view.text_diagnostics, vec![8..13]); + view.set_text_diagnostics(std::iter::once(1..4).collect(), cx); + view.replace_doc(view.state.doc().clone(), cx); + assert!(view.text_diagnostics.is_empty()); + }); +} +#[gpui::test] +fn prose_services_skip_code_math_and_leave_url_policy_to_host(cx: &mut TestAppContext) { + for (source, word, expected) in [ + ("plain words", "plain", true), + ("`misspelled`", "misspelled", false), + ("```text\nmisspelled\n```", "misspelled", false), + ("$misspelled$", "misspelled", false), + ("https://example.com", "example", true), + ("[misspelled](https://example.com)", "misspelled", true), + ] { + let view = cx.new(|cx| EditorView::new(setup(source), cx)); + view.read_with(cx, |view, _| { + let whole = view.context_document_snapshot(); + let text = view.context_text(&whole).unwrap().text; + let start = text[..text.find(word).unwrap()].chars().count(); + let request = view + .context_text_range(&whole, start..start + word.chars().count()) + .unwrap(); + assert_eq!(view.context_is_prose(&request), expected, "{source}"); + }); + } +} + +#[gpui::test] +fn committed_typing_event_excludes_rewrites_paste_undo_and_uncommitted_ime( + cx: &mut TestAppContext, +) { + let view = cx.new(|cx| EditorView::new(setup(""), cx)); + let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let events = seen.clone(); + cx.update(|cx| { + cx.subscribe(&view, move |_, event, _| { + if let EditorEvent::Changed { text_input, .. } = event { + events.borrow_mut().push(*text_input); + } + }) + .detach() + }); + view.update(cx, |view, cx| { + view.run_command(&commands::insert_text("typed"), cx); + let request = view.context_document_snapshot(); + view.replace_context_text(&request, "replaced", cx); + cx.write_to_clipboard(ClipboardItem::new_string("paste".into())); + view.execute_context_action(ContextAction::PastePlain, cx); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + let candidate = composition::update_composition(&view.state, "中", 1).unwrap(); + view.dispatch([candidate], cx); + view.dispatch([composition::finish_composition()], cx); + }); + cx.run_until_parked(); + assert_eq!(*seen.borrow(), vec![true, false, false, false, false, true]); +} +#[gpui::test] +fn paste_match_style_keeps_destination_format_without_duplicate_source(cx: &mut TestAppContext) { + for (from, to, expected) in [(1, 9, "**new**"), (5, 5, "**wonewrd**")] { + let view = cx.new(|cx| EditorView::new(setup("**word**"), cx)); + view.update(cx, |view, cx| { + view.codecs = Some(std::sync::Arc::new( + markraft_commonmark::CommonMarkCodecs::new( + view.state.schema().clone(), + Default::default(), + ), + )); + view.select_range(from, to, cx); + cx.write_to_clipboard(ClipboardItem::new_string("new".into())); + view.execute_context_action(ContextAction::PasteMatchStyle, cx); + assert_eq!(view.projection().plain_text(), expected); + let undo = history::undo(&view.state).unwrap(); + view.dispatch([undo], cx); + assert_eq!(view.projection().plain_text(), "**word**"); + }); + } + let view = cx.new(|cx| EditorView::new(setup("**word**"), cx)); + view.update(cx, |view, cx| { + view.codecs = Some(std::sync::Arc::new( + markraft_commonmark::CommonMarkCodecs::new( + view.state.schema().clone(), + Default::default(), + ), + )); + view.select_range(1, 9, cx); + cx.write_to_clipboard(ClipboardItem::new_string("*literal*\nnext".into())); + view.execute_context_action(ContextAction::PasteMatchStyle, cx); + let text = view + .context_text(&view.context_document_snapshot()) + .unwrap() + .text; + assert_eq!(text, "*literal*\nnext"); + let strong = view.types.strong.unwrap(); + for (index, line) in view.analysis.projection().lines().iter().enumerate() { + for piece in markraft_core::kind::reading::line_pieces( + view.analysis.projection(), + &view.types, + index, + &markraft_core::kind::conceal::Reveal::nothing(), + ) { + let position = line.offset_to_pos(piece.source.start).unwrap(); + assert!( + view.state + .doc() + .node_at(position) + .unwrap() + .marks() + .contains_type(strong) + ); + } + } + }); +} diff --git a/crates/markraft-gpui/src/context/transformations.rs b/crates/markraft-gpui/src/context/transformations.rs new file mode 100644 index 00000000..f842a4f6 --- /dev/null +++ b/crates/markraft-gpui/src/context/transformations.rs @@ -0,0 +1,197 @@ +//! Context-sensitive text transformations and their menu capabilities. +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextTransformation { + Uppercase, + Lowercase, + Capitalize, + TraditionalChinese, + SimplifiedChinese, +} + +impl TextTransformation { + /// AppKit offers all three case actions for cased letters, including letters + /// with no case mapping. Chinese actions require a conversion that changes + /// the selected text; they do not depend on a detected document language. + pub fn available_for(text: &str) -> Vec { + let mut available = Vec::new(); + if has_cased_letters(text) { + available.extend([Self::Uppercase, Self::Lowercase, Self::Capitalize]); + } + for transformation in [Self::TraditionalChinese, Self::SimplifiedChinese] { + if transformation + .characters(text) + .is_some_and(|characters| characters.concat() != text) + { + available.push(transformation); + } + } + available + } + + /// One replacement per original Unicode scalar, retaining its source marks. + pub(super) fn characters(self, text: &str) -> Option> { + if matches!(self, Self::TraditionalChinese | Self::SimplifiedChinese) { + let transformed = chinese(text, self)?; + return aligned_characters(text, &transformed); + } + Some(case_characters(text, self)) + } +} + +fn aligned_characters(original: &str, transformed: &str) -> Option> { + // The system's Chinese transforms substitute scalars without changing their + // count. Reject a future incompatible result instead of guessing provenance. + (original.chars().count() == transformed.chars().count()).then(|| { + transformed + .chars() + .map(|character| character.to_string()) + .collect() + }) +} + +#[cfg(target_os = "macos")] +fn has_cased_letters(text: &str) -> bool { + use objc2_foundation::{NSCharacterSet, NSString}; + let text = NSString::from_str(text); + [ + NSCharacterSet::uppercaseLetterCharacterSet(), + NSCharacterSet::lowercaseLetterCharacterSet(), + ] + .iter() + .any(|set| text.rangeOfCharacterFromSet(set).length > 0) +} + +#[cfg(not(target_os = "macos"))] +fn has_cased_letters(text: &str) -> bool { + text.chars() + .any(|character| character.is_uppercase() || character.is_lowercase()) +} + +#[cfg(target_os = "macos")] +fn chinese(text: &str, transformation: TextTransformation) -> Option { + use objc2_foundation::NSString; + let identifier = match transformation { + TextTransformation::TraditionalChinese => "Simplified-Traditional", + TextTransformation::SimplifiedChinese => "Traditional-Simplified", + _ => return None, + }; + // Transform the entire selection: converting isolated characters loses + // context in phrases such as 头发 and 里面, even with applyTransform(range:). + NSString::from_str(text) + .stringByApplyingTransform_reverse(&NSString::from_str(identifier), false) + .map(|text| text.to_string()) +} + +#[cfg(not(target_os = "macos"))] +fn chinese(_text: &str, _transformation: TextTransformation) -> Option { + None +} + +fn case_characters(text: &str, transformation: TextTransformation) -> Vec { + let mut output = Vec::new(); + for segment in text.split_word_bounds() { + let word = segment.unicode_words().next().is_some(); + // Lowercasing the complete word retains contextual Unicode mappings, + // such as a Greek sigma at the end of a word. + let lowercase = segment.to_lowercase(); + let mut lower = lowercase.chars(); + for (index, character) in segment.chars().enumerate() { + let lower_count = character.to_lowercase().count(); + let lowered: String = lower.by_ref().take(lower_count).collect(); + output.push(match transformation { + TextTransformation::Uppercase => character.to_uppercase().collect(), + TextTransformation::Lowercase => lowered, + TextTransformation::Capitalize if word && index == 0 => { + character.to_uppercase().collect() + } + TextTransformation::Capitalize if word => lowered, + TextTransformation::Capitalize => character.to_string(), + TextTransformation::TraditionalChinese | TextTransformation::SimplifiedChinese => { + unreachable!("Chinese conversion uses complete selection context") + } + }); + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transformations_reject_ambiguous_scalar_provenance() { + assert_eq!( + aligned_characters("ab", "甲乙"), + Some(vec!["甲".into(), "乙".into()]) + ); + assert_eq!(aligned_characters("a", "ab"), None); + assert_eq!(aligned_characters("ab", "a"), None); + assert_eq!( + aligned_characters("😀a", "😀A"), + Some(vec!["😀".into(), "A".into()]) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn transformations_match_native_contextual_menu_capabilities() { + use TextTransformation::*; + for text in ["abc", "ABC", "İı", "ΟΣ", "ß", "K", "Dž", "ℝ", "𝔸", "ABC"] { + assert_eq!( + TextTransformation::available_for(text), + [Uppercase, Lowercase, Capitalize], + "{text}" + ); + } + for text in [ + "中文", + "123", + "😀", + "ひらがな", + "カタカナ", + "Ⅷ", + "ⓐ", + "ª", + "里", + ] { + assert!(TextTransformation::available_for(text).is_empty(), "{text}"); + } + assert_eq!( + TextTransformation::available_for("编辑器"), + [TraditionalChinese] + ); + assert_eq!( + TextTransformation::available_for("編輯器"), + [SimplifiedChinese] + ); + assert_eq!( + TextTransformation::available_for("编辑器 編輯器"), + [TraditionalChinese, SimplifiedChinese] + ); + assert_eq!( + TextTransformation::available_for("hello 编辑器"), + [Uppercase, Lowercase, Capitalize, TraditionalChinese] + ); + assert_eq!( + TextTransformation::available_for("日本語"), + [SimplifiedChinese] + ); + assert_eq!( + TraditionalChinese + .characters("发头发干干净净后台 里面😀") + .unwrap() + .concat(), + "發頭髮乾乾淨淨後台 裡面😀" + ); + assert_eq!( + SimplifiedChinese + .characters("發頭髮乾乾淨淨後臺 裏面😀") + .unwrap() + .concat(), + "发头发干干净净后台 里面😀" + ); + } +} diff --git a/crates/markraft-gpui/src/extension.rs b/crates/markraft-gpui/src/extension.rs index 790c08af..664d1482 100644 --- a/crates/markraft-gpui/src/extension.rs +++ b/crates/markraft-gpui/src/extension.rs @@ -456,7 +456,7 @@ impl<'a> EditorCx<'a> { let Some(app) = self.app.as_deref_mut() else { return false; }; - clipboard::write(&schema, codecs.as_ref(), slice, app); + clipboard::write(&schema, codecs.as_ref(), slice, false, app); true } /// The slice on the system clipboard, read as ⌘V reads it: this editor's own @@ -661,7 +661,7 @@ impl EditorView { // extension also moved the selection. self.caret.forget_column(); self.reset_caret_blink(cx); - self.publish(cx); + self.publish(false, cx); } for event in events { cx.emit(event); diff --git a/crates/markraft-gpui/src/ime.rs b/crates/markraft-gpui/src/ime.rs index 145f96a0..8453d17c 100644 --- a/crates/markraft-gpui/src/ime.rs +++ b/crates/markraft-gpui/src/ime.rs @@ -57,8 +57,13 @@ pub fn commit_specs( let replacement = state.selection().replacement_range(doc); (replacement.from, replacement.to) }); + let selection = if replacement_is_current_selection(state, from, to) { + state.selection().clone() + } else { + Selection::text(from, to) + }; let select = TransactionSpec::new() - .selection(Selection::text(from, to)) + .selection(selection) .stored_marks(state.stored_marks().cloned()); // The insertion is computed against the selection it replaces, which is // what the first spec establishes; the two travel as one transaction. @@ -79,6 +84,30 @@ pub fn commit_specs( specs } +fn replacement_is_current_selection(state: &EditorState, from: usize, to: usize) -> bool { + let selected = state.selection().replacement_range(state.doc()); + !markraft_core::composition::is_composing(state) && selected.from == from && selected.to == to +} + +fn selected_utf16_range(state: &EditorState) -> Option { + let projection = markraft_core::projection::projection_of(state); + let selection = state.selection(); + if matches!(selection, Selection::All) { + // Select All includes the document's structural edges, which have no + // inline position. Native clients still need a valid selected range. + return Some(UTF16Selection { + range: 0..projection.utf16_len(), + reversed: false, + }); + } + let (anchor, head) = (selection.anchor(state.doc()), selection.head(state.doc())); + let (start, end) = projection.pos_range_to_utf16_range(anchor, head)?; + Some(UTF16Selection { + range: start..end, + reversed: anchor > head, + }) +} + impl EditorView { /// A UTF-16 range over the whole document's text as a position range. fn positions_of(&self, range: &Range) -> Option<(usize, usize)> { @@ -172,13 +201,7 @@ impl EntityInputHandler for EditorView { _: &mut Window, _: &mut Context, ) -> Option { - let doc = self.state().doc(); - let selection = self.state().selection(); - let (anchor, head) = (selection.anchor(doc), selection.head(doc)); - Some(UTF16Selection { - range: self.utf16_of(anchor.min(head), anchor.max(head))?, - reversed: anchor > head, - }) + selected_utf16_range(self.state()) } fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { @@ -239,6 +262,7 @@ impl EntityInputHandler for EditorView { let mut specs = Vec::new(); if let Some(range) = range.as_ref() && let Some((from, to)) = self.positions_of(range) + && !replacement_is_current_selection(self.state(), from, to) { specs.push(markraft_core::composition::start_composition( CompositionRange::new(from, to), @@ -271,7 +295,12 @@ impl EntityInputHandler for EditorView { .as_ref() .map(|tr| tr.state()) .unwrap_or_else(|| self.state()); - let Ok(spec) = markraft_core::composition::update_composition(base, &text, caret) else { + let Ok(spec) = markraft_core::composition::update_composition_with( + base, + &text, + caret, + &chains::insert_plain(&self.types, &text), + ) else { return; }; let spec = if specs.is_empty() { @@ -412,6 +441,21 @@ mod tests { .expect("the caret sits in a line") } + #[test] + fn select_all_reports_a_valid_platform_text_range() { + for text in ["", "Caret style QA", "中文😀\n\nsecond paragraph"] { + let state = state_of(text); + let state = apply( + &state, + vec![TransactionSpec::new().selection(markraft_core::Selection::All)], + ); + let selection = super::selected_utf16_range(&state) + .expect("Select All is a valid native text selection"); + assert_eq!(selection.range, 0..text_of(&state).encode_utf16().count()); + assert!(!selection.reversed); + } + } + #[test] fn control_characters_never_reach_the_document() { use super::printable; diff --git a/crates/markraft-gpui/src/lib.rs b/crates/markraft-gpui/src/lib.rs index 389423cb..1644f9c5 100644 --- a/crates/markraft-gpui/src/lib.rs +++ b/crates/markraft-gpui/src/lib.rs @@ -13,6 +13,7 @@ mod callout; mod caret; mod clipboard; mod completion; +mod context; mod emoji; #[cfg(test)] mod equation_integration; @@ -32,13 +33,19 @@ mod messages; mod shaping; mod shown; mod single_line; +mod smart_clipboard; mod style; mod surface; mod syntax; mod typeahead; mod wiki; +mod word_boundary; pub use callout::{Tone as CalloutTone, callout_heading, callout_tone}; -pub use clipboard::{use_system_pasteboard, write_rich_text}; +pub use clipboard::{CopyFormat, use_system_pasteboard, write_rich_text}; +pub use context::{ + ContextAction, ContextRequest, ContextTarget, ContextText, ContextTextMap, EditCapabilities, + TextTransformation, +}; pub use emoji::{EmojiInsertion, EmojiShortcodes, emoji_menu}; pub use extension::{ ActionHandler, CaretShape, EXTENSION_ORIGIN_PREFIX, EditorCx, Extension, ExtensionHandle, @@ -282,10 +289,15 @@ fn list_key_bindings() -> [KeyBinding; 3] { #[derive(Clone, Debug)] pub enum EditorEvent { + /// A pointer requested editing commands after the selection settled. + ContextMenuRequested(ContextRequest), /// The host owns writing pasted files and images to its document location. FilesPasted(ClipboardItem), Changed { revision: u64, + /// Committed typing, including an input-method candidate, rather than + /// a paste, history action, host replacement, or document reload. + text_input: bool, }, /// A link was clicked without ⌘; the caret is now inside it. LinkClicked, @@ -581,15 +593,29 @@ pub(crate) enum VerticalMove { Stay, } +/// The first press owns the word target for an OS multi-click sequence, even +/// when placing its caret reveals source and changes the following frame. +struct PointerWordGesture { + document: Node, + epoch: u64, + modifiers: gpui::Modifiers, + word: Option, +} + pub struct EditorView { document_guard: Option, transaction_guard: Option, edit_error: Option, file_paste: bool, + smart_insert_delete: bool, state: EditorState, analysis: markraft_core::kind::analysis::DocumentAnalysis, /// The find query and its hits. See [`find`]. find: markraft_core::StateField, + /// Host-provided spelling/grammar ranges for the current document only. + text_diagnostics: Vec>, + /// Present only while a right-click selection and its extensions settle. + context_target_bookmark: Option, pub(crate) types: DocTypes, /// The host's document kind; see [`Setup::kind`]. kind: Arc, @@ -630,6 +656,13 @@ pub struct EditorView { /// The caret's own view state: break side, kept column, pending reveal. pub(crate) caret: caret::CaretView, selecting: bool, + pointer_word_gesture: Option, + /// The selection a word gesture made, while it is still the selection. + /// Smart deletion and smart copy apply to a word picked as one, not to a + /// range that happens to end on word boundaries. + word_selection: Option, + context_epoch: u64, + context_edit: bool, published_revision: u64, undo_group_depth: usize, pub(crate) accessible_text: Rc>, @@ -719,7 +752,10 @@ impl EditorView { transaction_guard: None, edit_error: None, file_paste: false, + smart_insert_delete: false, find, + text_diagnostics: Vec::new(), + context_target_bookmark: None, types, codecs: kind.codecs(), spelling: kind.spelling(), @@ -742,7 +778,11 @@ impl EditorView { unmeasured: std::cell::Cell::new(false), caret: caret::CaretView::default(), selecting: false, + pointer_word_gesture: None, + word_selection: None, published_revision: 0, + context_epoch: 0, + context_edit: false, undo_group_depth: 0, accessible_text: Rc::default(), focus_subscriptions: None, @@ -837,6 +877,16 @@ impl EditorView { self.edit_error.take() } + pub fn with_smart_insert_delete(mut self, enabled: bool) -> Self { + self.smart_insert_delete = enabled; + self + } + + /// Enable the platform's word-aware clipboard spacing behavior. + pub fn set_smart_insert_delete(&mut self, enabled: bool) { + self.smart_insert_delete = enabled; + } + pub fn with_file_paste(mut self, enabled: bool) -> Self { self.file_paste = enabled; self @@ -1358,12 +1408,14 @@ impl EditorView { ); self.find = find; self.state = state; + self.text_diagnostics.clear(); + self.context_epoch = self.context_epoch.wrapping_add(1); self.sync_document_analysis(); self.extension_selection = self.state.selection().clone(); self.undo_group_depth = 0; self.scroll.set_offset(point(px(0.), px(0.))); self.reset_caret_blink(cx); - self.publish(cx); + self.publish(false, cx); self.run_extensions( extension::Update { committed: true, @@ -1407,6 +1459,33 @@ impl EditorView { return None; } }; + self.context_target_bookmark = self + .context_target_bookmark + .take() + .and_then(|bookmark| bookmark.map(&transactions)); + if self.word_selection.as_ref() != Some(self.state.selection()) { + self.word_selection = None; + } + if transactions.iter().any(|transaction| { + let before = transaction.start_state(); + let after = transaction.state(); + transaction.doc_changed() + || before.selection() != after.selection() + || before.stored_marks() != after.stored_marks() + || markraft_core::composition::composition_range(before) + != markraft_core::composition::composition_range(after) + }) { + self.context_epoch = self.context_epoch.wrapping_add(1); + } + // An edit moves the marks beside it and takes only the ones it touched; + // the host's next check restores those that are still wrong. + for transaction in transactions.iter().filter(|tr| tr.doc_changed()) { + self.text_diagnostics.retain_mut(|range| { + let mapped = transaction.changes().map_range(range.start, range.end); + *range = mapped.from..mapped.to; + !mapped.deleted && mapped.from < mapped.to + }); + } self.sync_document_analysis(); Some(transactions) } @@ -1421,7 +1500,12 @@ impl EditorView { discarding: bool, specs: Vec, ) -> Option { - self.edit_with_isolation(cx, discarding, specs, None) + self.edit_with_isolation( + cx, + discarding, + specs, + self.context_edit.then_some(self.undo_group_depth), + ) } fn edit_with_isolation( @@ -1436,7 +1520,21 @@ impl EditorView { let changed = transactions.iter().any(Transaction::doc_changed); self.caret.moved_by_edit(); if changed || composing != self.is_composing() { - self.publish(cx); + let text_input = !discarding + && !self.context_edit + && !self.is_composing() + && (transactions + .iter() + .find(|transaction| transaction.user_event_name().is_some()) + .is_some_and(|transaction| { + transaction.is_user_event(markraft_core::protocol::event::INPUT_TYPE) + }) + || (composing + && !changed + && transactions + .iter() + .all(|transaction| transaction.user_event_name().is_none()))); + self.publish(text_input, cx); } self.reset_caret_blink(cx); cx.notify(); @@ -1708,45 +1806,35 @@ impl EditorView { }) } - /// Run one of the catalogue's table commands. - /// - /// `false` where the schema declares no table types, and where the command - /// does not apply — which, for all but [`TableOp::Insert`], means - /// the caret is not in a table. - fn table_command( - &mut self, - build: impl FnOnce(markraft_core::commands::TableTypes) -> Command, - cx: &mut Context, - ) -> bool { - if self.single_line { - return false; - } - let Some(types) = self.types.table_types() else { - return false; - }; - self.run_command(&build(types), cx) - } - /// One edit of the table the caret is in, or a new table where there is /// none: what every table control resolves to. `false` where it does not /// apply — outside a table for every shape but [`TableOp::Insert`], and /// in a single-line editor always. pub fn table(&mut self, op: TableOp, cx: &mut Context) -> bool { + if let Some(command) = self.table_operation(op) { + self.run_command(&command, cx) + } else { + false + } + } + + fn table_operation(&self, op: TableOp) -> Option { use markraft_core::commands as c; - self.table_command( - move |types| match op { - TableOp::Insert { rows, columns } => c::insert_table(types, rows, columns), - TableOp::AddRowBefore => c::add_row_before(types), - TableOp::AddRowAfter => c::add_row_after(types), - TableOp::AddColumnBefore => c::add_column_before(types), - TableOp::AddColumnAfter => c::add_column_after(types), - TableOp::DeleteRow => c::delete_row(types), - TableOp::DeleteColumn => c::delete_column(types), - TableOp::DeleteTable => c::delete_table(types), - TableOp::SetAlignment(alignment) => c::set_column_alignment(types, alignment), - }, - cx, - ) + if self.single_line { + return None; + } + let types = self.types.table_types()?; + Some(match op { + TableOp::Insert { rows, columns } => c::insert_table(types, rows, columns), + TableOp::AddRowBefore => c::add_row_before(types), + TableOp::AddRowAfter => c::add_row_after(types), + TableOp::AddColumnBefore => c::add_column_before(types), + TableOp::AddColumnAfter => c::add_column_after(types), + TableOp::DeleteRow => c::delete_row(types), + TableOp::DeleteColumn => c::delete_column(types), + TableOp::DeleteTable => c::delete_table(types), + TableOp::SetAlignment(alignment) => c::set_column_alignment(types, alignment), + }) } /// Window bounds of the first line of the selection, or of the link touching the @@ -1785,10 +1873,11 @@ impl EditorView { ) } - fn publish(&mut self, cx: &mut Context) { + fn publish(&mut self, text_input: bool, cx: &mut Context) { self.published_revision += 1; cx.emit(EditorEvent::Changed { revision: self.published_revision, + text_input, }); cx.notify(); } @@ -2228,17 +2317,30 @@ impl EditorView { return; } let schema = self.state.schema().clone(); + let smart = self.smart_copy_eligible(); match self.codecs.clone().filter(|_| !self.single_line) { - Some(codecs) => clipboard::write(&schema, codecs.as_ref(), &slice, cx), + Some(codecs) => clipboard::write(&schema, codecs.as_ref(), &slice, smart, cx), // Without codecs there is only one flavour to write, and a // single-line editor holds nothing but text anyway. None => { let text = conceal::slice_text(&schema, self.types.syntax, &slice); - cx.write_to_clipboard(ClipboardItem::new_string(text)); + cx.write_to_clipboard(ClipboardItem::new_string_with_metadata( + text, + (if smart { clipboard::SMART_PREFIX } else { "" }).to_string(), + )); } } } + fn cut(&mut self, cx: &mut Context) { + self.copy(cx); + if let Some(spec) = self.smart_delete_spec() { + self.edit(cx, false, vec![spec]); + } else { + self.run_command(&markraft_core::commands::delete_selection(), cx); + } + } + fn paste(&mut self, mode: clipboard::PasteMode, cx: &mut Context) { let item = cx .read_from_clipboard() @@ -2317,7 +2419,15 @@ impl EditorView { } let slice = markraft_core::Slice::from_fragment(markraft_core::Fragment::from_nodes(nodes)); - markraft_core::commands::replace_selection(slice)(&self.state) + if matches!(mode, clipboard::PasteMode::Plain) { + markraft_core::commands::replace_selection_as( + slice, + markraft_core::ReplacementStyle::Receiving, + event::INPUT_PASTE, + )(&self.state) + } else { + markraft_core::commands::replace_selection(slice)(&self.state) + } } else if let Some(codecs) = self .codecs .clone() @@ -2325,7 +2435,11 @@ impl EditorView { .filter(|_| !self.single_line && !verbatim) { // The kind reads plain text as the characters it is. - markraft_core::commands::replace_selection(codecs.from_text(text))(&self.state) + markraft_core::commands::replace_selection_as( + codecs.from_text(text), + markraft_core::ReplacementStyle::Receiving, + event::INPUT_PASTE, + )(&self.state) } else if literal { let text = single_line::text(text, self.single_line); chains::insert_plain(&self.types, &text)(&self.state) @@ -2359,11 +2473,20 @@ impl EditorView { None }; if let Some(spec) = spec { - self.edit(cx, false, vec![spec.user_event(event::INPUT_PASTE)]); + // A literal paste runs the typing command; whatever built the + // spec, the action is a paste. + let specs = + self.smart_paste_specs(spec.replace_user_event(event::INPUT_PASTE), &item, cx); + self.edit(cx, false, specs); } } fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context) { + let previous_word_gesture = self.pointer_word_gesture.take(); + if event.modifiers.control { + self.context_mouse_down(event, window, cx); + return; + } window.focus(&self.focus, cx); self.selecting = true; self.caret.forget_column(); @@ -2435,6 +2558,24 @@ impl EditorView { ); return; } + // Resolve the clicked word against the frame the pointer actually saw. + // Moving the caret can reveal source delimiters and change that layout. + self.lay_out_at(event.position); + let pointer_word = if event.click_count == 2 { + previous_word_gesture + .filter(|gesture| { + gesture.document.ptr_eq(self.state.doc()) + && gesture.epoch == self.context_epoch + && gesture.modifiers == event.modifiers + }) + .map(|gesture| gesture.word) + .unwrap_or_else(|| self.pointer_word_at(event.position)) + } else if event.click_count == 1 { + self.pointer_word_at(event.position) + } else { + None + }; + let pointer_document = self.state.doc().clone(); self.select_point(event.position, event.modifiers.shift, cx); if event.click_count == 1 && !event.modifiers.shift { self.caret_into_source(event.position, cx); @@ -2466,18 +2607,44 @@ impl EditorView { { self.select_range(line.from(), line.to(), cx); } - } else if event.click_count == 2 { - let from = self - .analysis - .projection() - .prev_word_boundary(head) - .unwrap_or(head); - let to = self - .analysis - .projection() - .next_word_boundary(from) - .unwrap_or(head); - self.select_range(from, to, cx); + } else if event.click_count == 2 && pointer_document.ptr_eq(self.state.doc()) { + if let Some(selection) = &pointer_word { + self.edit( + cx, + false, + vec![ + TransactionSpec::new() + .selection(selection.clone()) + .user_event(event::SELECT_POINTER), + ], + ); + } else { + // Keep the existing whitespace and outside-text behavior; + // actual characters use pointer-word rather than caret semantics. + let from = self + .analysis + .projection() + .prev_word_boundary(head) + .unwrap_or(head); + let to = self + .analysis + .projection() + .next_word_boundary(from) + .unwrap_or(head); + self.select_range(from, to, cx); + } + self.word_selection = Some(self.state.selection().clone()); + } + if event.click_count == 1 && pointer_document.ptr_eq(self.state.doc()) { + // Store after this press settles its own caret/source selection. + // Later edits, selection changes, marks, or composition invalidate + // the target through the same epoch used by contextual snapshots. + self.pointer_word_gesture = Some(PointerWordGesture { + document: pointer_document, + epoch: self.context_epoch, + modifiers: event.modifiers, + word: pointer_word, + }); } self.reset_caret_blink(cx); cx.notify(); @@ -2541,6 +2708,7 @@ impl Render for EditorView { }), cx.on_blur(&self.focus, window, |this, window, cx| { this.selecting = false; + this.pointer_word_gesture = None; this.sync_caret_focus(window, cx); // The input method owns commit/unmark ordering. A host that // dismisses an editor can explicitly cancel composition. @@ -2571,6 +2739,7 @@ impl Render for EditorView { .track_focus(&self.focus) .cursor(CursorStyle::IBeam) .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down)) + .on_mouse_down(MouseButton::Right, cx.listener(Self::context_mouse_down)) .on_mouse_move(cx.listener(Self::mouse_move)) .on_hover(cx.listener(|this, hovered: &bool, _, cx| { if !*hovered { @@ -2705,8 +2874,21 @@ impl EditorView { })); }; } - run!(Backspace, chains::backspace); - run!(Delete, chains::delete_forward); + root = root + .on_action(cx.listener(|this, _: &Backspace, _, cx| { + if let Some(spec) = this.smart_delete_spec() { + this.edit(cx, false, vec![spec]); + } else if !this.run_command(&chains::backspace(&this.types), cx) { + cx.propagate(); + } + })) + .on_action(cx.listener(|this, _: &Delete, _, cx| { + if let Some(spec) = this.smart_delete_spec() { + this.edit(cx, false, vec![spec]); + } else if !this.run_command(&chains::delete_forward(&this.types), cx) { + cx.propagate(); + } + })); run!(ParagraphStart, |_: &DocTypes| chains::textblock_edge(false)); run!(ParagraphEnd, |_: &DocTypes| chains::textblock_edge(true)); run!(DeleteToParagraphEnd, chains::delete_to_textblock_end); @@ -2946,9 +3128,7 @@ impl EditorView { })) .on_action(cx.listener(|this, _: &Copy, _, cx| this.copy(cx))) .on_action(cx.listener(|this, _: &Cut, _, cx| { - this.copy(cx); - let command = markraft_core::commands::delete_selection(); - this.run_command(&command, cx); + this.cut(cx); })) .on_action( cx.listener(|this, _: &Paste, _, cx| { @@ -2988,6 +3168,12 @@ fn list(types: &DocTypes, ty: Option, item: Option) -> C } } +#[cfg(test)] +mod pointer_word_tests; + +#[cfg(test)] +mod reading_selection_tests; + #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod link_tests { diff --git a/crates/markraft-gpui/src/pointer_word_tests.rs b/crates/markraft-gpui/src/pointer_word_tests.rs new file mode 100644 index 00000000..30ac9168 --- /dev/null +++ b/crates/markraft-gpui/src/pointer_word_tests.rs @@ -0,0 +1,313 @@ +//! Pointer-word behavior through the shared shaped selection geometry. +use crate::{EditorStyle, context, surface}; +use gpui::{NoopTextSystem, TextSystem, WindowTextSystem, px}; +use markraft_core::{ + EditorState, + kind::{DocTypes, SourceSpelling}, + projection::projection_of, +}; +use std::sync::Arc; + +pub(crate) fn verify( + state: &EditorState, + types: &DocTypes, + style: &EditorStyle, + spelling: &dyn SourceSpelling, + targets: &[(&str, &str)], +) { + let projection = projection_of(state); + let images = crate::images::Images::default(); + let text_system = + WindowTextSystem::new(Arc::new(TextSystem::new(Arc::new(NoopTextSystem::new())))); + for revealed in [false, true] { + let input = surface::ShapeInput { + messages: &crate::EditorMessages::ENGLISH, + images: &images, + maths: None, + equations: None, + scale_factor: 1., + doc: state.doc(), + types, + projection: &projection, + style, + single_line: false, + wiki: None, + spelling: Some(spelling), + selection: if revealed { + projection.lines()[0].from()..projection.lines()[0].to() + } else { + usize::MAX..usize::MAX + }, + composition: None, + }; + let mut lines = surface::Lines::default(); + lines.sync(&input, &projection, px(1600.), 0); + lines.lay_out_range(&input, 0..projection.line_count(), &text_system); + lines.set_shown(std::iter::once(0..projection.line_count()).collect()); + let rows: Vec<_> = lines.shown().into_iter().map(|(_, row)| row).collect(); + let row = &rows[0]; + let text = projection.line_text(0).unwrap(); + for &(target, expected) in targets { + let start = text[..text.find(target).unwrap()].chars().count(); + let end = start + target.chars().count(); + let rects = row.rectangles(start..end, false); + assert!( + !rects.is_empty(), + "{target} must have visible selection geometry" + ); + for rect in rects { + for fraction in [0.25, 0.75] { + let mut point = rect.center(); + point.x = rect.left() + rect.size.width * fraction; + let insertion = row.char_at(point - row.origin); + let character = context::pointer_character_in_row( + row, + &projection, + point, + insertion, + ) + .unwrap_or_else(|| { + panic!( + "no character for {target}, revealed={revealed}, fraction={fraction}" + ) + }); + let reveal = markraft_core::kind::conceal::Reveal::at( + input.selection.clone(), + input.composition.clone(), + ); + let pieces = crate::shown::line_pieces(&projection, types, row.index, &reveal); + let word = context::word_at( + &projection, + character, + &pieces, + crate::word_boundary::Intent::Pointer, + ) + .unwrap(); + assert_eq!( + projection.text_between(word.start, word.end), + Some(expected), + "target={target}, revealed={revealed}, fraction={fraction}" + ); + assert!( + projection + .graphemes(0) + .any(|(position, _)| position == character) + ); + } + } + } + } +} + +#[cfg(test)] +mod unit { + use super::verify; + use crate::EditorStyle; + use markraft_commonmark::{ + CommonMarkSpelling, commonmark_doc_type_names, commonmark_extensions, commonmark_schema, + from_markdown, + }; + use markraft_core::{EditorState, EditorStateConfig, Extension, kind::DocTypes}; + + #[test] + fn non_native_pointer_words_respect_both_halves_graphemes_and_markup() { + for (source, targets) in [ + ("a x b", vec![("x", "x")]), + ("a **word** b", vec![("w", "word"), ("d", "word")]), + ( + "left (naïve) e\u{301} 👩🏽‍💻 אבג right", + vec![ + ("(", "("), + ("ï", "naïve"), + ("e\u{301}", "e\u{301}"), + ("👩🏽‍💻", "👩🏽‍💻"), + ("ג", "אבג"), + ], + ), + ] { + let schema = commonmark_schema(); + let state = EditorState::create( + EditorStateConfig::new(schema.clone()) + .doc(from_markdown(&schema, source).unwrap()) + .extensions(Extension::all([ + markraft_core::projection::projection(), + commonmark_extensions(&schema), + ])), + ) + .unwrap(); + let types = DocTypes::from_schema_names(&schema, &commonmark_doc_type_names()); + verify( + &state, + &types, + &EditorStyle::default(), + &CommonMarkSpelling::new(schema), + &targets, + ); + } + } + #[gpui::test] + fn double_click_uses_the_drawn_word_before_caret_reveals_markup(cx: &mut gpui::TestAppContext) { + use crate::{EditorView, Setup}; + use gpui::{MouseButton, MouseDownEvent}; + for (source, target, expected) in [ + ("a x b\n\nother", "x", "x"), + ("a **word** b\n\nother", "w", "word"), + ("(naïve) e\u{301} 👩🏽‍💻\n\nother", "e\u{301}", "e\u{301}"), + ("one two\n\nother", " ", " "), + ("one...two\n\nother", ".", "."), + #[cfg(target_os = "macos")] + ("中文编辑器测试\n\nother", "编", "编辑器"), + #[cfg(target_os = "macos")] + ("中文编**辑**器测试\n\nother", "编", "编**辑**器"), + #[cfg(target_os = "macos")] + ("宇**宙**洪荒\n\nother", "宇", "宇**宙"), + ("abc tail\n\nother", "a", "abc"), + ] { + for (fraction, sequence) in [(0.25, false), (0.75, false), (0.25, true), (0.75, true)] { + let (view, cx) = cx.add_window_view(|_, cx| { + let schema = commonmark_schema(); + let setup = Setup::new(schema.clone()) + .types(DocTypes::from_schema_names( + &schema, + &commonmark_doc_type_names(), + )) + .extensions(commonmark_extensions(&schema)) + .doc(from_markdown(&schema, source).unwrap()); + let mut view = EditorView::new(setup, cx); + view.spelling = Some(std::sync::Arc::new(CommonMarkSpelling::new(schema))); + view.select(view.state.doc().content().size() - 1, false, cx); + view + }); + cx.run_until_parked(); + let point = view.read_with(cx, |view, _| { + let projection = view.projection(); + let text = projection.line_text(0).unwrap(); + let start = text[..text.find(target).unwrap()].chars().count(); + let rect = view.frame.rows()[0] + .rectangles(start..start + target.chars().count(), false)[0]; + gpui::point(rect.left() + rect.size.width * fraction, rect.center().y) + }); + if sequence { + cx.simulate_event(MouseDownEvent { + position: point, + button: MouseButton::Left, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + }); + cx.simulate_mouse_up(point, MouseButton::Left, Default::default()); + cx.run_until_parked(); + } + cx.simulate_event(MouseDownEvent { + position: point, + button: MouseButton::Left, + modifiers: Default::default(), + click_count: 2, + first_mouse: false, + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + let selection = view.state.selection(); + assert_eq!( + view.projection().text_between( + selection.from(view.state.doc()), + selection.to(view.state.doc()) + ), + Some(expected) + ); + }); + if let Some(edited) = match source { + "宇**宙**洪荒\n\nother" => Some("X洪荒\nother"), + "abc tail\n\nother" => Some("X tail\nother"), + _ => None, + } { + view.update(cx, |view, cx| { + let original = view.state.doc().clone(); + let command = markraft_core::kind::chains::insert_plain(&view.types, "X"); + assert!(view.run_command(&command, cx)); + let reading = crate::shown::ShownText::build( + &view.projection(), + &view.types, + &markraft_core::kind::conceal::Reveal::nothing(), + ); + assert_eq!(reading.text(), edited); + let undo = markraft_core::history::undo(&view.state).unwrap(); + assert!(view.dispatch([undo], cx)); + assert_eq!(view.state.doc(), &original); + }); + } + } + } + } + #[gpui::test] + fn multi_click_target_expires_after_intervening_selection_edit_or_context_click( + cx: &mut gpui::TestAppContext, + ) { + use crate::{EditorView, Setup}; + use gpui::{MouseButton, MouseDownEvent}; + for interruption in ["selection", "edit", "context"] { + let (view, cx) = cx.add_window_view(|_, cx| { + let schema = commonmark_schema(); + EditorView::new( + Setup::new(schema.clone()) + .types(DocTypes::from_schema_names( + &schema, + &commonmark_doc_type_names(), + )) + .extensions(commonmark_extensions(&schema)) + .doc(from_markdown(&schema, "first second third").unwrap()), + cx, + ) + }); + cx.run_until_parked(); + let first = view.read_with(cx, |view, _| { + view.frame.rows()[0].rectangles(1..2, false)[0].center() + }); + cx.simulate_mouse_down(first, MouseButton::Left, Default::default()); + cx.simulate_mouse_up(first, MouseButton::Left, Default::default()); + cx.run_until_parked(); + view.update(cx, |view, cx| match interruption { + "selection" => view.select(view.state.doc().content().size() - 1, false, cx), + "edit" => { + view.run_command(&markraft_core::commands::insert_text("changed"), cx); + } + _ => {} + }); + cx.run_until_parked(); + let second = view.read_with(cx, |view, _| { + let projection = view.projection(); + let text = projection.line_text(0).unwrap(); + let offset = text[..text.find("second").unwrap()].chars().count() + 2; + view.frame.rows()[0].rectangles(offset..offset + 1, false)[0].center() + }); + if interruption == "context" { + cx.simulate_mouse_down(second, MouseButton::Right, Default::default()); + cx.simulate_mouse_up(second, MouseButton::Right, Default::default()); + cx.run_until_parked(); + } + cx.simulate_event(MouseDownEvent { + position: second, + button: MouseButton::Left, + modifiers: Default::default(), + click_count: 2, + first_mouse: false, + }); + cx.run_until_parked(); + view.read_with(cx, |view, _| { + let selection = view.state.selection(); + assert_eq!( + view.projection().text_between( + selection.from(view.state.doc()), + selection.to(view.state.doc()) + ), + Some("second"), + "{interruption}" + ); + assert!( + view.pointer_word_gesture.is_none(), + "second press consumes the target" + ); + }); + } + } +} diff --git a/crates/markraft-gpui/src/reading_selection_tests.rs b/crates/markraft-gpui/src/reading_selection_tests.rs new file mode 100644 index 00000000..480d521d --- /dev/null +++ b/crates/markraft-gpui/src/reading_selection_tests.rs @@ -0,0 +1,220 @@ +//! Visible-selection edits share the core selection contract across input paths. +use markraft_commonmark::{ + commonmark_doc_type_names, commonmark_extensions, commonmark_schema, from_markdown, +}; +use markraft_core::{ + Change, EditorState, EditorStateConfig, Extension, Fragment, ReplacementStyle, Selection, + Slice, TransactionSpec, commands, composition, history, + kind::{DocTypes, ReadingSelection, chains, conceal::Reveal}, + projection::projection_of, +}; + +fn state(source: &str, target: &str) -> (EditorState, DocTypes) { + let schema = commonmark_schema(); + let types = DocTypes::from_schema_names(&schema, &commonmark_doc_type_names()); + let state = EditorState::create( + EditorStateConfig::new(schema.clone()) + .doc(from_markdown(&schema, source).unwrap()) + .extensions(Extension::all([ + markraft_core::projection::projection(), + commonmark_extensions(&schema), + history::history(Default::default()), + composition::composition(), + ])), + ) + .unwrap(); + let projection = projection_of(&state); + let text = projection.line_text(0).unwrap(); + let start = text[..text.find(target).unwrap()].chars().count(); + let line = &projection.lines()[0]; + let selected = ReadingSelection::selection( + line.offset_to_pos(start).unwrap(), + line.offset_to_pos(start + target.chars().count()).unwrap(), + types.syntax.unwrap(), + ); + ( + apply(&state, vec![TransactionSpec::new().selection(selected)]), + types, + ) +} + +fn apply(state: &EditorState, specs: Vec) -> EditorState { + state + .update_with_appended(specs) + .unwrap() + .last() + .unwrap() + .state() + .clone() +} + +fn reading(state: &EditorState, types: &DocTypes) -> String { + crate::shown::ShownText::build(&projection_of(state), types, &Reveal::nothing()) + .text() + .to_owned() +} + +#[test] +fn reading_selection_replacement_deletion_paste_and_undo_balance_markup() { + for source in ["宇**宙**洪荒"] { + let (state, types) = state(source, "宇**宙"); + for (command, expected) in [ + (commands::insert_text("X"), "X洪荒"), + (chains::insert_plain(&types, "X"), "X洪荒"), + (commands::delete_selection(), "洪荒"), + (chains::backspace(&types), "洪荒"), + (chains::delete_forward(&types), "洪荒"), + ( + commands::replace_selection(Slice::from_fragment(Fragment::from_node( + state.schema().text("X"), + ))), + "X洪荒", + ), + ] { + let edited = apply(&state, vec![command(&state).unwrap()]); + assert_eq!(reading(&edited, &types), expected, "{source}"); + assert!(edited.selection().is_cursor()); + let restored = apply(&edited, vec![history::undo(&edited).unwrap()]); + assert_eq!(restored.doc(), state.doc()); + assert_eq!(restored.selection(), state.selection()); + } + } +} + +#[test] +fn reading_selection_mapping_json_and_source_edits_remain_distinct() { + let (state, types) = state("宇**宙**洪荒", "宇**宙"); + let selected = state.selection(); + assert_eq!( + ReadingSelection::from_json(state.schema(), &selected.to_json(state.schema())).unwrap(), + *selected + ); + let range = selected.replacement_range(state.doc()); + assert_eq!( + selected.content_with_schema(state.doc(), state.schema()), + Selection::text(range.from, range.to).content_with_schema(state.doc(), state.schema()) + ); + let moved = apply( + &state, + vec![TransactionSpec::new().changes([Change::insert( + 1, + Slice::from_fragment(Fragment::from_node(state.schema().text("pre "))), + )])], + ); + assert!(ReadingSelection::is(moved.selection())); + assert_eq!(moved.selection().from(moved.doc()), range.from + 4); + let navigation = commands::move_by_grapheme(commands::Direction::Forward, false); + let navigated = apply(&state, vec![navigation(&state).unwrap()]); + assert!(navigated.selection().is_cursor()); + let source = apply( + &state, + vec![TransactionSpec::new().selection(Selection::text(range.from, range.to))], + ); + let literal = apply( + &source, + vec![chains::insert_plain(&types, "X")(&source).unwrap()], + ); + assert_eq!( + projection_of(&literal).plain_text(), + "X**洪荒", + "explicit source editing stays literal" + ); +} + +#[test] +fn reading_selection_typing_keeps_the_starting_style_in_source() { + for (source, target, expected) in [ + ("**宇**宙洪荒", "宇**宙", "**X**洪荒"), + ("***宇***宙洪荒", "宇***宙", "***X***洪荒"), + ("宇**宙洪**荒", "宇**宙", "X**洪**荒"), + ("宇**宙**洪荒", "宇**宙", "X洪荒"), + ] { + let (state, types) = state(source, target); + let edited = apply( + &state, + vec![chains::insert_plain(&types, "X")(&state).unwrap()], + ); + assert_eq!(projection_of(&edited).plain_text(), expected); + let restored = apply(&edited, vec![history::undo(&edited).unwrap()]); + assert_eq!(restored.doc(), state.doc()); + assert_eq!(restored.selection(), state.selection()); + } +} + +#[test] +fn reading_selection_distinguishes_plain_style_inheritance_from_rich_paste() { + use markraft_core::kind::Codecs; + let (state, types) = state("**宇**宙洪荒", "宇**宙"); + let codecs = + markraft_commonmark::CommonMarkCodecs::new(state.schema().clone(), Default::default()); + let plain = commands::replace_selection_as( + codecs.from_text("X"), + ReplacementStyle::Receiving, + markraft_core::protocol::event::INPUT_PASTE, + )(&state) + .unwrap(); + let pasted = state.update([plain]).unwrap(); + assert!(pasted.is_user_event(markraft_core::protocol::event::INPUT_PASTE)); + assert_eq!(projection_of(pasted.state()).plain_text(), "**X**洪荒"); + let plain = apply( + &state, + vec![ + commands::replace_selection_as( + codecs.from_text("X"), + ReplacementStyle::Receiving, + markraft_core::protocol::event::INPUT_TYPE, + )(&state) + .unwrap(), + ], + ); + assert_eq!(projection_of(&plain).plain_text(), "**X**洪荒"); + let unstyled = commands::replace_selection(codecs.from_text("X"))(&state).unwrap(); + assert_eq!( + projection_of(&apply(&state, vec![unstyled])).plain_text(), + "X洪荒" + ); + let rich = from_markdown(state.schema(), "*X*").unwrap(); + let rich = + commands::replace_selection(Slice::new(rich.content().clone(), 1, 1))(&state).unwrap(); + assert_eq!( + projection_of(&apply(&state, vec![rich])).plain_text(), + "*X*洪荒" + ); + let multiline = chains::insert_plain(&types, "X\nY")(&state).unwrap(); + let multiline = apply(&state, vec![multiline]); + assert_eq!(reading(&multiline, &types), "X\nY洪荒"); +} + +#[test] +fn reading_selection_composition_and_commit_use_actual_inserted_range() { + for (source, target) in [("宇**宙**洪荒", "宇**宙"), ("**宇**宙洪荒", "宇**宙")] { + let (state, types) = state(source, target); + let committed = apply(&state, crate::ime::commit_specs(&state, &types, None, "X")); + assert_eq!(reading(&committed, &types), "X洪荒"); + let first = composition::update_composition_with( + &state, + "候选", + 2, + &chains::insert_plain(&types, "候选"), + ) + .unwrap(); + let composing = apply(&state, vec![first]); + assert_eq!(reading(&composing, &types), "候选洪荒"); + let range = composition::composition_range(&composing).unwrap(); + assert_eq!( + projection_of(&composing).text_between(range.from, range.to), + Some("候选") + ); + let committed = apply( + &composing, + crate::ime::commit_specs(&composing, &types, None, "最终"), + ); + assert_eq!(reading(&committed, &types), "最终洪荒"); + let restored = apply(&committed, vec![history::undo(&committed).unwrap()]); + assert_eq!(restored.doc(), state.doc()); + assert_eq!(restored.selection(), state.selection()); + let empty = composition::update_composition(&state, "", 0).unwrap(); + let deleted = apply(&state, vec![empty]); + assert_eq!(reading(&deleted, &types), "洪荒"); + } +} diff --git a/crates/markraft-gpui/src/shown.rs b/crates/markraft-gpui/src/shown.rs index 838be8cf..100b6b4a 100644 --- a/crates/markraft-gpui/src/shown.rs +++ b/crates/markraft-gpui/src/shown.rs @@ -57,6 +57,21 @@ mod tests { assert_eq!(text.matches("**"), Vec::>::new()); } + #[test] + fn inline_breaks_separate_words_and_keep_search_offsets() { + let (text, projection) = shown("one\n**two**"); + assert_eq!(text.text(), "one\ntwo"); + assert!(text.matches("onetwo").is_empty()); + assert_eq!( + text.matches("two"), + vec![line_offset(&projection, 6)..line_offset(&projection, 9)] + ); + assert_eq!( + text.matches("\n"), + vec![line_offset(&projection, 3)..line_offset(&projection, 4)] + ); + } + #[test] fn a_wiki_link_is_found_by_its_label_and_selects_the_atom() { let (text, projection) = shown("see [[Notes]] now"); diff --git a/crates/markraft-gpui/src/smart_clipboard.rs b/crates/markraft-gpui/src/smart_clipboard.rs new file mode 100644 index 00000000..51aa8bb2 --- /dev/null +++ b/crates/markraft-gpui/src/smart_clipboard.rs @@ -0,0 +1,131 @@ +//! AppKit decides word boundaries and whitespace; the editor maps the result +//! back to guarded source edits without giving a native text view the document. + +use std::ops::Range; + +pub(crate) struct Context { + pub text: String, + pub selection: Range, + pub boundaries: Vec, +} + +#[cfg(target_os = "macos")] +mod platform { + use super::*; + use objc2::{MainThreadMarker, MainThreadOnly}; + use objc2_app_kit::{NSSelectionGranularity, NSTextView}; + use objc2_foundation::{NSRange, NSRect, NSString}; + + fn view(text: &str) -> Option> { + let mtm = MainThreadMarker::new()?; + let view = NSTextView::initWithFrame(NSTextView::alloc(mtm), NSRect::ZERO); + view.setString(&NSString::from_str(text)); + view.setSmartInsertDeleteEnabled(true); + Some(view) + } + + fn native_range(context: &Context) -> NSRange { + let start = context + .text + .chars() + .take(context.selection.start) + .map(char::len_utf16) + .sum(); + let length = context + .text + .chars() + .skip(context.selection.start) + .take(context.selection.len()) + .map(char::len_utf16) + .sum(); + NSRange::new(start, length) + } + + pub(crate) fn whole_words(context: &Context) -> bool { + let range = native_range(context); + if range.length == 0 { + return false; + } + let Some(view) = view(&context.text) else { + return false; + }; + view.selectionRangeForProposedRange_granularity(range, NSSelectionGranularity::SelectByWord) + == range + } + + pub(crate) fn delete(context: &Context) -> Option> { + let view = view(&context.text)?; + let range = view.smartDeleteRangeForProposedRange(native_range(context)); + scalar_range( + &context.text, + range.location..range.location.checked_add(range.length)?, + ) + } + + pub(crate) fn padding(context: &Context, pasted: &str) -> Option<(String, String)> { + let view = view(&context.text)?; + let (mut before, mut after) = (None, None); + view.smartInsertForString_replacingRange_beforeString_afterString( + &NSString::from_str(pasted), + native_range(context), + Some(&mut before), + Some(&mut after), + ); + Some(( + before.map(|value| value.to_string()).unwrap_or_default(), + after.map(|value| value.to_string()).unwrap_or_default(), + )) + } +} + +#[cfg(not(target_os = "macos"))] +mod platform { + use super::*; + pub(crate) fn whole_words(_: &Context) -> bool { + false + } + pub(crate) fn delete(_: &Context) -> Option> { + None + } + pub(crate) fn padding(_: &Context, _: &str) -> Option<(String, String)> { + None + } +} + +pub(crate) use platform::{delete, padding, whole_words}; + +/// Native ranges must land on scalar boundaries, never inside a surrogate pair. +#[cfg(any(target_os = "macos", test))] +fn scalar_range(text: &str, range: Range) -> Option> { + let mut utf16 = 0; + let mut from = None; + let mut to = None; + for (scalar, character) in text + .chars() + .map(Some) + .chain(std::iter::once(None)) + .enumerate() + { + if utf16 == range.start { + from = Some(scalar); + } + if utf16 == range.end { + to = Some(scalar); + } + if let Some(character) = character { + utf16 += character.len_utf16(); + } + } + Some(from?..to?) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cocoa_ranges_preserve_unicode_and_reject_surrogate_interiors() { + assert_eq!(scalar_range("a🦀 word", 4..8), Some(3..7)); + assert_eq!(scalar_range("a🦀 word", 2..8), None); + assert_eq!(scalar_range("a🦀 word", 8..9), None); + } +} diff --git a/crates/markraft-gpui/src/surface/mod.rs b/crates/markraft-gpui/src/surface/mod.rs index 783e902b..cc5a734c 100644 --- a/crates/markraft-gpui/src/surface/mod.rs +++ b/crates/markraft-gpui/src/surface/mod.rs @@ -507,6 +507,7 @@ impl Element for EditorSurface { .filter(|decoration| decoration.spec().attrs.get(crate::find::ROLE).is_some()) .filter_map(|decoration| decoration.range()) .collect(); + let text_diagnostics = editor.text_diagnostics.clone(); let marked = markraft_core::composition::composition_range(state) .map(|range| (range.from, range.to)); let focused = editor.focus.is_focused(window); @@ -784,6 +785,26 @@ impl Element for EditorSurface { if let Some(marker) = &row.marker { paint_marker(row, marker, &style, window, cx); } + if !inert && row.code_pos.is_none() { + for diagnostic in &text_diagnostics { + if diagnostic.end <= row.from || diagnostic.start >= row.to() { + continue; + } + let from = row.pos_to_offset(diagnostic.start.max(row.from)); + let to = row.pos_to_offset(diagnostic.end.min(row.to())); + for rect in row.rectangles(from..to.min(row.char_len), false) { + window.paint_underline( + point(rect.left(), rect.bottom() - px(2.)), + rect.size.width, + &UnderlineStyle { + thickness: px(1.), + color: Some(rgb(0xd93025).into()), + wavy: true, + }, + ); + } + } + } if row.index == 0 && !inert && let Some(text) = placeholder.as_ref().filter(|text| !text.is_empty()) diff --git a/crates/markraft-gpui/src/word_boundary.rs b/crates/markraft-gpui/src/word_boundary.rs new file mode 100644 index 00000000..bfac72b4 --- /dev/null +++ b/crates/markraft-gpui/src/word_boundary.rs @@ -0,0 +1,136 @@ +//! Platform word selection in display-scalar coordinates, independent of +//! document syntax, layout direction and pointer geometry. +use std::ops::Range; + +#[derive(Clone, Copy)] +pub(crate) enum Intent { + Context, + Pointer, +} + +#[cfg(target_os = "macos")] +pub(crate) fn at(text: &str, character: usize, intent: Intent) -> Option> { + use objc2_app_kit::NSAttributedStringAppKitAdditions; + use objc2_foundation::{NSAttributedString, NSString}; + + let boundaries = text + .chars() + .scan(0, |utf16, character| { + let start = *utf16; + *utf16 += character.len_utf16(); + Some(start) + }) + .chain([text.encode_utf16().count()]) + .collect::>(); + let location = *boundaries.get(character)?; + if location >= *boundaries.last()? { + return None; + } + // Contextual selection uses AppKit's attributed-string word operation. + // Pointer selection follows the distinct TextKit 2 navigation contract. + let native = NSString::from_str(text); + let range = match intent { + Intent::Context => NSAttributedString::from_nsstring(&native).doubleClickAtIndex(location), + Intent::Pointer => pointer_range(&native, location)?, + }; + let end = range.location.checked_add(range.length)?; + let from = boundaries.binary_search(&range.location).ok()?; + let to = boundaries.binary_search(&end).ok()?; + (from < to).then_some(from..to) +} + +#[cfg(target_os = "macos")] +fn pointer_range( + text: &objc2_foundation::NSString, + location: usize, +) -> Option { + use objc2::{ + class, msg_send, + rc::{Allocated, Retained}, + runtime::AnyObject, + }; + use objc2_foundation::{NSArray, NSRange}; + + // TextEdit's TextKit 2 double-click navigation has different dictionary + // boundaries from its contextual selection (for example 编辑器 versus 编辑). + // These content/layout objects have no UI-thread-only view. Keep the whole + // graph local to this call: it has no container, display, delegate or shared + // mutable state, and never triggers layout or participates in focus. + unsafe { + let content: Retained = msg_send![class!(NSTextContentStorage), new]; + let manager: Retained = msg_send![class!(NSTextLayoutManager), new]; + let _: () = msg_send![&*content, addTextLayoutManager: &*manager]; + let alloc: Allocated = msg_send![class!(NSTextStorage), alloc]; + let storage: Retained = msg_send![alloc, initWithString: text]; + let _: () = msg_send![&*content, setTextStorage: &*storage]; + let document: Retained = msg_send![&*content, documentRange]; + let start: Retained = msg_send![&*document, location]; + let at: Option> = msg_send![&*content, locationFromLocation: &*start, withOffset: isize::try_from(location).ok()?]; + let alloc: Allocated = msg_send![class!(NSTextSelection), alloc]; + let selection: Retained = + msg_send![alloc, initWithLocation: &*at?, affinity: 1isize]; + let navigation: Retained = msg_send![&*manager, textSelectionNavigation]; + let result: Retained = msg_send![&*navigation, textSelectionForSelectionGranularity: 1isize, enclosingTextSelection: &*selection]; + let ranges: Retained> = msg_send![&*result, textRanges]; + if ranges.len() != 1 { + return None; + } + let range = ranges.objectAtIndex(0); + let from: Retained = msg_send![&*range, location]; + let to: Retained = msg_send![&*range, endLocation]; + let offset: isize = msg_send![&*content, offsetFromLocation: &*start, toLocation: &*from]; + let length: isize = msg_send![&*content, offsetFromLocation: &*from, toLocation: &*to]; + Some(NSRange::new( + usize::try_from(offset).ok()?, + usize::try_from(length).ok()?, + )) + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn at(text: &str, character: usize, _: Intent) -> Option> { + use unicode_segmentation::UnicodeSegmentation; + let mut start = 0; + for word in text.split_word_bounds() { + let end = start + word.chars().count(); + if (start..end).contains(&character) { + return Some(start..end); + } + start = end; + } + None +} + +#[cfg(test)] +mod tests { + use super::{Intent, at}; + + #[test] + fn punctuation_spaces_and_graphemes_are_pointer_targets() { + for (text, position, expected) in [ + ("one two", 4, 3..6), + ("one...two", 4, 4..5), + ("a🦀b", 1, 1..2), + ("a 👨‍👩‍👧‍👦 b", 5, 2..9), + ("a e\u{301} b", 3, 2..4), + ] { + for intent in [Intent::Context, Intent::Pointer] { + assert_eq!(at(text, position, intent), Some(expected.clone()), "{text}"); + } + } + assert_eq!(at("", 0, Intent::Pointer), None); + assert_eq!(at("a", 1, Intent::Pointer), None); + assert_eq!(at("🦀", usize::MAX, Intent::Pointer), None); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_dictionary_words_use_scalar_coordinates() { + for position in 2..4 { + assert_eq!(at("🦀 宇宙 洪荒", position, Intent::Context), Some(2..4)); + } + assert_eq!(at("example.com", 7, Intent::Context), Some(0..11)); + assert_eq!(at("中文编辑器测试", 2, Intent::Context), Some(2..4)); + assert_eq!(at("中文编辑器测试", 2, Intent::Pointer), Some(2..5)); + } +} diff --git a/crates/markraft-vim/src/command.rs b/crates/markraft-vim/src/command.rs index 911fadd9..f84d284e 100644 --- a/crates/markraft-vim/src/command.rs +++ b/crates/markraft-vim/src/command.rs @@ -766,11 +766,12 @@ pub(crate) fn settle(state: &mut State, cx: &mut impl Host, replaced: bool) -> O }; } else if state.mode == Mode::Normal { // A ranged *text* selection in Normal mode was made with the mouse; leave it - // alone so that ⌘C still copies it. The next motion collapses it. + // alone so that ⌘C still copies it. The next motion collapses it. A word + // picked across concealed spelling is the same gesture in another kind. let ranged = matches!( cx.state().selection(), Selection::Text { anchor, head, .. } if anchor != head - ); + ) || markraft_core::kind::ReadingSelection::is(cx.state().selection()); if !ranged { let head = host::head(cx); let clamped = clamp(&projection, head); diff --git a/crates/markraft-vim/src/tests.rs b/crates/markraft-vim/src/tests.rs index 6c5c14d3..b4d99fc8 100644 --- a/crates/markraft-vim/src/tests.rs +++ b/crates/markraft-vim/src/tests.rs @@ -1525,6 +1525,25 @@ fn a_table_that_is_the_whole_document_leaves_the_smallest_one_the_schema_allows( assert_eq!(keys.keys("u").markdown(), "| a | b |\n| --- | --- |"); } +#[test] +fn a_word_selected_across_concealed_spelling_survives_normal_mode() { + let mut keys = Keys::new("Mark**raft** two").at(0, 0); + let (from, to) = (keys.pos(0, 0), keys.pos(0, 12)); + let syntax = keys.host.types().syntax.expect("a syntax mark"); + keys.host.select( + markraft_core::kind::ReadingSelection::selection(from, to, syntax), + false, + ); + keys.settle(); + let selection = keys.host.state().selection(); + assert!(markraft_core::kind::ReadingSelection::is(selection)); + let doc = keys.host.state().doc(); + assert_eq!((selection.from(doc), selection.to(doc)), (from, to)); + // The next motion collapses it, as it does a dragged selection. + keys.keys("l"); + assert!(keys.host.state().selection().is_cursor()); +} + #[test] fn a_paste_that_is_not_a_row_lands_beside_the_table_rather_than_in_it() { let mut keys = Keys::new(TABLE).at(0, 0); diff --git a/docs/architecture.md b/docs/architecture.md index 4f8f315b..85178a4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -377,6 +377,76 @@ Its library generation records where it came from; it does not authorise an edit to a later note. Only Markdown export exists today. HTML or PDF export should derive from a snapshot, never from live screen caches. +### Editor context menus + +The editor resolves a secondary click independently of primary-click actions. +It settles the pointer selection and composition, then emits a +`ContextMenuRequested` snapshot containing the document, selection and clicked +object. The app combines the editor's capabilities with note permissions to build +the menu. Editing controls share the same applicability queries. + +Clicking within an existing selection preserves it. Otherwise a link selects its +entire label, ordinary text selects the clicked word, and blank space places the +caret. Object-specific commands precede generic editing and formatting commands. + +The platform presents an AppKit menu after the selection has painted, outside +GPUI entity and window updates. Its event router preserves tray events while +native menu tracking runs. A result is local to one popup: the app validates its +note, editor and snapshot again before executing it. Changing the document, +selection or active note invalidates that result. + +**Invariant:** a context menu does not create another editing pipeline. Commands +use the existing clipboard, transactions and source guard, with an isolated undo +boundary for menu edits. Asynchronous attachment imports also have their own +undo boundary. Native text services use reading-text snapshots: hidden Markdown +syntax and link destinations are not exposed as selected prose. Case transformations +map literal characters back to source spans while retaining formatting. Arbitrary +rewrites require contiguous literal spans (or unformatted top-level paragraphs); +composite selections advertise read-only services rather than discard hidden syntax. +Returned text passes the same source guard, permission check and isolated history. + +A word picked across concealed spelling is a `ReadingSelection`, a custom selection +kind defined in the core's `kind` layer: the selection layer knows only the +`SelectionKind` contract and depends on neither commands nor projection. A command +that replaces a custom selection passes a `ReplacementStyle`, which says whether +the content brings its own styles or continues those of the text it replaces. +`Selection::from_json` reads the built-in kinds; `ReadingSelection::from_json` +reads this one. + +An owned `NSServicesMenuRequestor` temporarily joins the native responder chain. +`NSMenu.popUpContextMenu` inserts system Services and available Writing Tools. +Writeback stays alive after menu tracking and expires when the editor context changes. +Writing Tools can request coordinator context and then return text through the Services +pasteboard bridge. The session records that request so both return paths preserve +unchanged source styles. Ordinary Services still inherit the selection's starting style. + +Translation uses the public SwiftUI presentation API, weak-linked behind macOS 14.4 +availability. The small Swift bridge is compiled by `xcrun swiftc`. It requires an +Xcode SDK providing the Translation framework. Lookup, sharing, speech, and Open With +use public AppKit or AVFoundation APIs. Detected-data menus retain the original +system result and its checked string, including native detector metadata. + +The translation host currently inherits GPUI's AccessKit content-view accessibility +tree, which does not expose native hosting subviews. Translation generation has +been checked on macOS; automated replacement-button interaction remains unverified +because the same coordinate-click issue also occurs in TextEdit's native popover. +The Rust callback and guarded replacement paths have separate test coverage. + +Each note owns its spelling document tag, so ignored words remain document-local. +Prose checks run in bounded Unicode chunks after a debounce and yield between chunks; +code, formulas and URL contents are excluded. Cocoa UTF-16 ranges are checked and +converted to document scalar positions. A check returns spelling ranges only; +guesses are fetched for the one word a menu asks about. Diagnostics survive selection +changes. An edit moves the diagnostics beside it and removes those it touches, and +the next check restores the ones that still apply. Smart substitutions run only at a +fresh typing/IME commit boundary, never after paste, undo or service writeback. At that +boundary a detected link is applied only when the text spells its own scheme; the +explicit Add Links command applies every detected link. Smart deletion and smart copy +apply to a selection made by a word gesture (double-click or contextual click), not +to a range extended by hand that ends on word boundaries. Enabling smart quotes disables +new straight-quote auto-pairs while preserving other delimiters. Settings persist +through the existing preferences pipeline. + ### Screen resources Images, math rasters and syntax highlighting are view-owned caches with From ef6e7de468fbc08a15699910768ea7c0a30626f7 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 5 Oct 2026 01:05:16 +1100 Subject: [PATCH 3/6] fix(editor): keep the checking panel open across menu formatting A command that leaves the editor in front no longer closes the spelling or substitutions panel; the panel is cancelled only when another panel takes the editor's place. Replacing the document also ends a word-gesture selection, so a later selection at the same range is not treated as one. --- crates/markraft-app/src/app/interaction.rs | 6 +++++- crates/markraft-gpui/src/context/tests.rs | 5 +++++ crates/markraft-gpui/src/lib.rs | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/markraft-app/src/app/interaction.rs b/crates/markraft-app/src/app/interaction.rs index 97d0a57d..ee5db311 100644 --- a/crates/markraft-app/src/app/interaction.rs +++ b/crates/markraft-app/src/app/interaction.rs @@ -214,7 +214,11 @@ impl MarkraftApp { pub(super) fn set_panel(&mut self, panel: Panel, cx: &mut Context) { self.context_menus.dismiss(); - self.cancel_checking_panel(); + // A command that keeps the editor in front leaves its checking panel + // open; the panel belongs to the editor and goes when the editor does. + if self.interaction.panel() != panel { + self.cancel_checking_panel(); + } self.leave_input(cx); self.interaction.switch_panel(panel); } diff --git a/crates/markraft-gpui/src/context/tests.rs b/crates/markraft-gpui/src/context/tests.rs index 64cad4fd..debc6b4d 100644 --- a/crates/markraft-gpui/src/context/tests.rs +++ b/crates/markraft-gpui/src/context/tests.rs @@ -105,6 +105,11 @@ fn only_a_selection_made_by_a_word_gesture_counts_as_a_word(cx: &mut TestAppCont assert!(!view.selected_by_word()); view.select_range(5, 8, cx); assert!(!view.selected_by_word()); + // A replaced document ends it too, whatever is selected afterwards. + view.select_context(6, Some(6), false, &ContextTarget::Text, cx); + assert!(view.selected_by_word()); + view.replace_doc(view.state.doc().clone(), cx); + assert!(view.word_selection.is_none()); }); } diff --git a/crates/markraft-gpui/src/lib.rs b/crates/markraft-gpui/src/lib.rs index 1644f9c5..a048d91d 100644 --- a/crates/markraft-gpui/src/lib.rs +++ b/crates/markraft-gpui/src/lib.rs @@ -1409,6 +1409,7 @@ impl EditorView { self.find = find; self.state = state; self.text_diagnostics.clear(); + self.word_selection = None; self.context_epoch = self.context_epoch.wrapping_add(1); self.sync_document_analysis(); self.extension_selection = self.state.selection().clone(); From fdf5d57fb969b060c9fdf227086f425d04ace6e4 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 5 Oct 2026 02:55:50 +1100 Subject: [PATCH 4/6] fix(editor): anchor text popovers and restore native responders Use selection geometry for native text popovers after the selection is painted. Retire text-service responders before closing checking panels. Keep tall-table controls inside the viewport. Add regression tests and document the native presentation lifecycle. --- .../markraft-app/src/app/ui/context_menu.rs | 86 ++++++----- .../src/app/ui/context_menu/tests.rs | 44 ++++++ crates/markraft-app/src/app/ui/table.rs | 49 ++++-- .../markraft-app/src/app/ui/text_checking.rs | 3 + crates/markraft-app/src/platform.rs | 1 + .../src/platform/checking_panel.rs | 3 +- .../markraft-app/src/platform/context_menu.rs | 19 ++- .../src/platform/text_geometry.rs | 55 +++++++ .../src/platform/text_requestor.rs | 15 +- .../text_requestor/selection_anchor.rs | 140 ++++++++++++++++++ .../platform/text_requestor/writing_tools.rs | 25 +--- .../src/platform/text_services.rs | 58 ++++---- .../markraft-app/src/platform/translation.rs | 26 ++-- .../src/platform/translation.swift | 26 +++- docs/architecture.md | 25 +++- 15 files changed, 443 insertions(+), 132 deletions(-) create mode 100644 crates/markraft-app/src/platform/text_geometry.rs create mode 100644 crates/markraft-app/src/platform/text_requestor/selection_anchor.rs diff --git a/crates/markraft-app/src/app/ui/context_menu.rs b/crates/markraft-app/src/app/ui/context_menu.rs index 764916de..404b6d1e 100644 --- a/crates/markraft-app/src/app/ui/context_menu.rs +++ b/crates/markraft-app/src/app/ui/context_menu.rs @@ -662,12 +662,8 @@ impl MenuBuilder<'_> { .context_text_range(¶graph, check.range) .filter(|detected| editor.context_is_prose(detected))?; let range = detected.text_range(); - (range.start < selection.end && range.end > selection.start).then_some( - Row::DetectedData { - result, - position: request.position, - }, - ) + (range.start < selection.end && range.end > selection.start) + .then_some(Row::DetectedData { result }) }) } @@ -861,24 +857,8 @@ impl MarkraftApp { if self.platform.is_none() { return; } - self.attach_text_services(id, window, cx); - let writing_tools = self - .context_menus - .native - .as_ref() - .and_then(|session| session._requestor.as_ref()) - .and_then(TextServiceSession::writing_tools_items); - let prepared = match PreparedMenu::new(&rows, window, writing_tools) { - Ok(menu) => menu, - Err(error) => { - self.context_menus.dismiss_popup(); - self.feedback.set_error(error); - cx.notify(); - return; - } - }; // AppKit tracking can suspend GPUI presentation. Let the pointer's new - // selection paint before handing event delivery to the native popup. + // selection paint before reading its anchor and tracking the popup. let (painted, ready) = futures_channel::oneshot::channel(); window.on_next_frame(move |_, _| { let _ = painted.send(()); @@ -887,12 +867,41 @@ impl MarkraftApp { if ready.await.is_err() { return; } - let valid = this - .update(cx, |app, cx| app.context_menu_current(id, cx)) - .unwrap_or(false); - if !valid { + let prepared = cx + .update(|window, cx| { + this.update(cx, |app, cx| { + if !app.context_menu_current(id, cx) { + return None; + } + app.attach_text_services(id, window, cx); + let writing_tools = app + .context_menus + .native + .as_ref() + .and_then(|session| session._requestor.as_ref()) + .and_then(TextServiceSession::writing_tools_items); + match PreparedMenu::new( + &rows, + window, + app.text_service_bounds(position, cx), + writing_tools, + ) { + Ok(menu) => Some(menu), + Err(error) => { + app.context_menus.dismiss_popup(); + app.feedback.set_error(error); + cx.notify(); + None + } + } + }) + }) + .ok() + .and_then(Result::ok) + .flatten(); + let Some(prepared) = prepared else { return; - } + }; // No App, Window or entity borrow crosses AppKit's nested event loop. let selected = prepared.show(position); let _ = cx.update(|window, cx| { @@ -904,6 +913,17 @@ impl MarkraftApp { .detach(); } + fn text_service_bounds(&self, position: Point, cx: &App) -> Bounds { + let editor = self.editor().read(cx); + editor.anchor_bounds().unwrap_or_else(|| { + let style = editor.style(); + Bounds::new( + position, + size(px(1.), style.body_size * style.line_height_ratio), + ) + }) + } + fn attach_text_services(&mut self, id: u64, window: &Window, cx: &mut Context) { let session = self.context_menus.pending.as_ref().expect("a pending menu"); let Some(text) = self.editor().read(cx).context_text(&session.request) else { @@ -911,8 +931,7 @@ impl MarkraftApp { }; let editable = text.replaceable && self.library.active_note().read_only.is_none(); let prose = self.editor().read(cx).context_is_prose(&session.request); - let editor_style = self.editor().read(cx).style(); - let line_height = editor_style.body_size * editor_style.line_height_ratio; + let anchor = self.text_service_bounds(session.request.position, cx); let Ok((requestor, returned)) = TextServiceSession::attach( window, TextServiceSnapshot { @@ -920,8 +939,7 @@ impl MarkraftApp { editable, prose, }, - session.request.position, - line_height, + anchor, ) else { return; }; @@ -1047,7 +1065,7 @@ impl MarkraftApp { text_services::stop_speaking(); return; } - let anchor = match TextAnchor::new(window, position) { + let anchor = match TextAnchor::new(window, self.text_service_bounds(position, cx)) { Ok(anchor) => anchor, Err(error) => { self.feedback.set_error(error); @@ -1138,7 +1156,7 @@ impl MarkraftApp { .is_some_and(|text| text.replaceable); let shown = crate::platform::translation::Translation::show( window, - session.request.position, + self.text_service_bounds(session.request.position, cx), text, editable, ); diff --git a/crates/markraft-app/src/app/ui/context_menu/tests.rs b/crates/markraft-app/src/app/ui/context_menu/tests.rs index 2133861d..987bcd09 100644 --- a/crates/markraft-app/src/app/ui/context_menu/tests.rs +++ b/crates/markraft-app/src/app/ui/context_menu/tests.rs @@ -184,6 +184,50 @@ fn copy_uses_the_selection_and_cancel_keeps_the_document_and_clipboard(cx: &mut .update(h.cx, |app, _| assert!(app.context_menus.pending.is_none())); } +#[gpui::test] +fn cancelling_checking_retires_services_retained_after_menu_escape(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "hello world\n")], |_| {}); + h.select(1, 6); + let id = open_menu(&mut h, ContextTarget::Text); + // Supply the application session without instantiating AppKit in a headless + // test. The native responder restoration is covered by device validation. + h.app.update(h.cx, |app, _| { + let pending = app.context_menus.pending.as_ref().unwrap(); + app.context_menus.native = Some(super::NativeSession { + id, + note: pending.note.clone(), + editor: pending.editor.clone(), + request: pending.request.clone(), + _requestor: None, + _translation: None, + }); + }); + finish(&mut h, id, None); + h.app.update(h.cx, |app, _| { + // AppKit reports no menu action when a Service handles the selection. + // Escape alone must preserve that asynchronous session. + assert_eq!(app.context_menus.native.as_ref().unwrap().id, id); + app.cancel_checking_panel(); + // Returned native results require this session to still be active. + assert!(app.context_menus.native.is_none()); + assert!(app.context_menus.pending.is_none()); + }); + assert_eq!(h.markdown(), "hello world"); +} + +#[gpui::test] +fn cancelling_checking_rejects_a_queued_menu_action(cx: &mut TestAppContext) { + let mut h = open_with(cx, &[("note.md", "hello world\n")], |_| {}); + h.select(1, 6); + let id = open_menu(&mut h, ContextTarget::Text); + let cut = command(&mut h, EditCommand::Cut); + h.cx.write_to_clipboard(ClipboardItem::new_string("keep".into())); + h.app.update(h.cx, |app, _| app.cancel_checking_panel()); + finish(&mut h, id, Some(cut)); + assert_eq!(h.markdown(), "hello world"); + assert_eq!(clipboard(&mut h), "keep"); +} + #[gpui::test] fn paste_is_undoable_and_persists_through_the_existing_edit_pipeline(cx: &mut TestAppContext) { let mut h = open_with(cx, &[("note.md", "one two three\n")], |_| {}); diff --git a/crates/markraft-app/src/app/ui/table.rs b/crates/markraft-app/src/app/ui/table.rs index 985a0eb7..b30c8fce 100644 --- a/crates/markraft-app/src/app/ui/table.rs +++ b/crates/markraft-app/src/app/ui/table.rs @@ -165,17 +165,7 @@ impl MarkraftApp { }; let anchor = table.bounds; let viewport = window.bounds().size; - // In the row the grid keeps clear above itself, unless that would leave the pill - // in the band the title and the action capsule float in, where it would sit on - // top of them; then it goes below. - // The row is a little shorter than the pill, which reaches into the gap every - // block keeps below itself rather than into the block. - let above = anchor.top() - HEIGHT - (TABLE_TOOLBAR_ROOM - HEIGHT).abs(); - let top = if above < TOOLBAR_HEIGHT + GAP { - anchor.bottom() + GAP - } else { - above - }; + let top = toolbar_top(anchor, viewport.height); // Right-aligned to the grid, or left-aligned to it when the pill is the wider of // the two, and shifted rather than clipped at either edge of the window. let left = if WIDTH < anchor.size.width { @@ -221,3 +211,40 @@ impl MarkraftApp { Some(pill) } } + +/// Prefer the space above the table, then below it. A table taller than the +/// viewport leaves neither space, so keep the controls inside the editor area. +fn toolbar_top(anchor: Bounds, viewport_height: Pixels) -> Pixels { + let min_top = TOOLBAR_HEIGHT + GAP; + let max_top = (viewport_height - FOOTER_HEIGHT - GAP - HEIGHT).max(min_top); + let above = anchor.top() - HEIGHT - (TABLE_TOOLBAR_ROOM - HEIGHT).abs(); + let preferred = if above < min_top { + anchor.bottom() + GAP + } else { + above + }; + preferred.clamp(min_top, max_top) +} + +#[cfg(test)] +mod tests { + use super::{FOOTER_HEIGHT, GAP, HEIGHT, TOOLBAR_HEIGHT, toolbar_top}; + use gpui::{Bounds, point, px, size}; + + #[test] + fn tall_table_toolbar_stays_above_footer() { + let height = px(700.); + let table = Bounds::new(point(px(75.), px(0.)), size(px(300.), height)); + let top = toolbar_top(table, height); + assert!(top >= TOOLBAR_HEIGHT + GAP); + assert_eq!(top + HEIGHT + GAP, height - FOOTER_HEIGHT); + } + + #[test] + fn table_toolbar_prefers_available_space_outside_grid() { + let table = Bounds::new(point(px(75.), px(200.)), size(px(300.), px(100.))); + assert!(toolbar_top(table, px(700.)) + HEIGHT <= table.top()); + let table = Bounds::new(point(px(75.), TOOLBAR_HEIGHT), size(px(300.), px(100.))); + assert_eq!(toolbar_top(table, px(700.)), table.bottom() + GAP); + } +} diff --git a/crates/markraft-app/src/app/ui/text_checking.rs b/crates/markraft-app/src/app/ui/text_checking.rs index b05e59d1..d5698fce 100644 --- a/crates/markraft-app/src/app/ui/text_checking.rs +++ b/crates/markraft-app/src/app/ui/text_checking.rs @@ -264,6 +264,9 @@ impl MarkraftApp { } pub(in crate::app) fn cancel_checking_panel(&mut self) { + // A cancelled popup can leave a text requestor above the panel's + // responder. Unwind it first, while its saved predecessor is still live. + self.context_menus.dismiss(); if let Some(target) = self.context_menus.checking.panel.take() { target.checker.hide_panel(CheckingPanel::Spelling); target.checker.hide_panel(CheckingPanel::Substitutions); diff --git a/crates/markraft-app/src/platform.rs b/crates/markraft-app/src/platform.rs index 03fb08b0..f65acb81 100644 --- a/crates/markraft-app/src/platform.rs +++ b/crates/markraft-app/src/platform.rs @@ -8,6 +8,7 @@ pub(crate) mod locale; pub(crate) mod print; pub(crate) mod symbols; pub(crate) mod text_checking; +pub(crate) mod text_geometry; pub(crate) mod text_requestor; pub(crate) mod text_services; pub(crate) mod translation; diff --git a/crates/markraft-app/src/platform/checking_panel.rs b/crates/markraft-app/src/platform/checking_panel.rs index ed4fbe9c..30a8d82a 100644 --- a/crates/markraft-app/src/platform/checking_panel.rs +++ b/crates/markraft-app/src/platform/checking_panel.rs @@ -251,7 +251,8 @@ impl CheckingResponder { } } -/// Own the panel's action target until the editor context changes or another menu opens. +/// Own the panel's action target while a checking panel follows this editor. +/// Drop any text-service session layered above this responder before dropping it. pub(crate) struct CheckingPanelSession { view: Retained, previous: Option>, diff --git a/crates/markraft-app/src/platform/context_menu.rs b/crates/markraft-app/src/platform/context_menu.rs index 2756baee..f18e4967 100644 --- a/crates/markraft-app/src/platform/context_menu.rs +++ b/crates/markraft-app/src/platform/context_menu.rs @@ -8,7 +8,7 @@ pub(crate) use caption::selection_label; use super::{NSPoint, NSRect, native_view, native_window}; use crate::locale::Message; -use gpui::{Pixels, Point, Window}; +use gpui::{Bounds, Pixels, Point, Window}; use objc2::{MainThreadMarker, class, msg_send, rc::Retained, runtime::AnyObject, sel}; use objc2_app_kit::NSImage; use objc2_foundation::{NSArray, NSString}; @@ -56,7 +56,6 @@ pub(crate) enum Row { }, DetectedData { result: super::text_checking::DetectedData, - position: Point, }, } @@ -91,6 +90,7 @@ impl PreparedMenu { pub(crate) fn new( rows: &[Row], window: &Window, + anchor: Bounds, writing_tools: Option<&[Retained]>, ) -> Result { MainThreadMarker::new().ok_or_else(|| failure("menu prepared off the main thread"))?; @@ -114,6 +114,7 @@ impl PreparedMenu { menu.ns_menu().cast(), rows, window, + anchor, &mut system_menus, writing_tools.unwrap_or_default(), )?; @@ -225,6 +226,7 @@ fn decorate( menu: *mut AnyObject, rows: &[Row], window: &Window, + anchor: Bounds, system_menus: &mut Vec>, writing_tools: &[Retained], ) -> Result<(), Message> { @@ -246,9 +248,9 @@ fn decorate( } continue; } - if let Row::DetectedData { result, position } = row { + if let Row::DetectedData { result } = row { let source = - super::text_services::TextAnchor::new(window, *position)?.data_menu(result)?; + super::text_services::TextAnchor::new(window, anchor)?.data_menu(result)?; unsafe { let items: Retained> = msg_send![&*source, itemArray]; for item in items.iter() { @@ -279,7 +281,14 @@ fn decorate( } if let Row::Submenu { children, .. } = row { let submenu: *mut AnyObject = unsafe { msg_send![item, submenu] }; - decorate(submenu, children, window, system_menus, writing_tools)?; + decorate( + submenu, + children, + window, + anchor, + system_menus, + writing_tools, + )?; } if let Row::Item { label, appearance, .. diff --git a/crates/markraft-app/src/platform/text_geometry.rs b/crates/markraft-app/src/platform/text_geometry.rs new file mode 100644 index 00000000..06124768 --- /dev/null +++ b/crates/markraft-app/src/platform/text_geometry.rs @@ -0,0 +1,55 @@ +//! Shared selection geometry for native text presentations. + +use gpui::{Bounds, Pixels}; +use objc2_foundation::{NSPoint, NSRect, NSSize}; + +/// Convert GPUI window-local, top-down points into the native view's bounds. +/// A caret needs a nonempty width to avoid AppKit's whole-view fallback. +pub(crate) fn view_rect(bounds: Bounds, native: NSRect, flipped: bool) -> NSRect { + let x = f64::from(f32::from(bounds.origin.x)); + let y = f64::from(f32::from(bounds.origin.y)); + let width = f64::from(f32::from(bounds.size.width)).max(1.0); + let height = f64::from(f32::from(bounds.size.height)).max(1.0); + NSRect::new( + NSPoint::new( + native.origin.x + x, + native.origin.y + + if flipped { + y + } else { + native.size.height - y - height + }, + ), + NSSize::new(width, height), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{point, px, size}; + + #[test] + fn selection_rect_preserves_dimensions_in_both_coordinate_systems() { + let selection = Bounds::new(point(px(30.), px(40.)), size(px(80.), px(20.))); + let native = NSRect::new(NSPoint::new(10., 15.), NSSize::new(500., 300.)); + assert_eq!( + view_rect(selection, native, true), + NSRect::new(NSPoint::new(40., 55.), NSSize::new(80., 20.)), + ); + assert_eq!( + view_rect(selection, native, false), + NSRect::new(NSPoint::new(40., 255.), NSSize::new(80., 20.)), + ); + } + + #[test] + fn caret_rect_stays_nonempty_at_the_bottom_edge() { + let caret = Bounds::new(point(px(20.), px(80.)), size(px(0.), px(20.))); + let native = NSRect::new(NSPoint::new(0., 0.), NSSize::new(200., 100.)); + assert_eq!( + view_rect(caret, native, false), + NSRect::new(NSPoint::new(20., 0.), NSSize::new(1., 20.)), + ); + } +} diff --git a/crates/markraft-app/src/platform/text_requestor.rs b/crates/markraft-app/src/platform/text_requestor.rs index 49b99b58..d6340e5d 100644 --- a/crates/markraft-app/src/platform/text_requestor.rs +++ b/crates/markraft-app/src/platform/text_requestor.rs @@ -12,11 +12,12 @@ use objc2::rc::Retained; use objc2::runtime::{AnyObject, NSObjectProtocol}; use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; use objc2_app_kit::{ - NSApplication, NSPasteboard, NSPasteboardType, NSResponder, NSServicesMenuRequestor, + NSApplication, NSPasteboard, NSPasteboardType, NSResponder, NSServicesMenuRequestor, NSView, }; use objc2_foundation::{NSArray, NSString}; use std::cell::RefCell; +mod selection_anchor; mod writing_tools; const STRING_TYPE: &str = "public.utf8-plain-text"; @@ -174,19 +175,20 @@ pub(crate) struct TextServiceSession { previous: Option>, requestor: Retained, writing_tools: Option, + anchor: Option, } impl TextServiceSession { pub(crate) fn attach( window: &gpui::Window, snapshot: TextServiceSnapshot, - position: gpui::Point, - line_height: gpui::Pixels, + bounds: gpui::Bounds, ) -> Result<(Self, oneshot::Receiver), Message> { let mtm = MainThreadMarker::new().ok_or_else(|| Message::new("error.native-window-control"))?; - let view = unsafe { Retained::retain(native_view(window)?.cast::()) } + let native = unsafe { Retained::retain(native_view(window)?.cast::()) } .ok_or_else(|| Message::new("error.native-window-control"))?; + let view: Retained = native.clone().into_super(); let types = NSArray::from_retained_slice(&[ NSString::from_str(STRING_TYPE), NSString::from_str("NSStringPboardType"), @@ -213,8 +215,9 @@ impl TextServiceSession { .ivars() .borrow() .supports_writing_tools(snapshot.prose); + let anchor = selection_anchor::SelectionAnchor::new(&native, bounds); let writing_tools = native_writing - .then(|| writing_tools::Snapshot::new(&view, &requestor, position, line_height)) + .then(|| writing_tools::Snapshot::new(&view, &requestor, bounds)) .flatten(); Ok(( Self { @@ -222,6 +225,7 @@ impl TextServiceSession { previous, requestor, writing_tools, + anchor, }, receiver, )) @@ -229,6 +233,7 @@ impl TextServiceSession { pub(crate) fn cancel(&mut self) { self.writing_tools.take(); + self.anchor.take(); { let mut selection = self.requestor.ivars().borrow_mut(); selection.active = false; diff --git a/crates/markraft-app/src/platform/text_requestor/selection_anchor.rs b/crates/markraft-app/src/platform/text_requestor/selection_anchor.rs new file mode 100644 index 00000000..93e6be90 --- /dev/null +++ b/crates/markraft-app/src/platform/text_requestor/selection_anchor.rs @@ -0,0 +1,140 @@ +//! Snapshot geometry for the public Writing Tools pasteboard presentation. +//! +//! AppKit asks the contextual menu's NSView for `selectionAnchorRect`, not its +//! Services requestor. Read only native snapshot data in that callback: calling +//! GPUI's text-input client here could reenter an outstanding editor update. + +use crate::platform::text_geometry::view_rect; +use gpui::{Bounds, Pixels}; +use objc2::{ + ClassType, MainThreadMarker, Message, + encode::Encode, + ffi, + rc::Retained, + runtime::{AnyClass, AnyObject, AnyProtocol, Imp, ProtocolBuilder, Sel}, + sel, +}; +use objc2_app_kit::NSView; +use objc2_foundation::{NSRect, NSValue}; +use std::{ffi::CString, ptr}; + +// The address is a process-lifetime association key, unique to this module. +static ANCHOR_KEY: u8 = 0; + +/// Keep the selection anchor installed until its asynchronous service retires. +/// The NSView is main-thread-only, so this guard cannot cross threads. +/// The app retires the previous service before attaching another to this view. +pub(super) struct SelectionAnchor { + view: Retained, + value: Retained, + previous: Option>, +} + +impl SelectionAnchor { + pub(super) fn new(view: &NSView, bounds: Bounds) -> Option { + MainThreadMarker::new()?; + if !install_selector(view.class()) { + return None; + } + let rect = view_rect(bounds, view.bounds(), view.isFlipped()); + let value = NSValue::new(rect); + // The association owns its value. Retain the predecessor before replacing + // it so restoration does not rely on an autorelease. + let previous = unsafe { Retained::retain(associated(view).cast_mut()) }; + unsafe { set_associated(view, Retained::as_ptr(&value).cast_mut().cast()) }; + Some(Self { + view: view.retain(), + value, + previous, + }) + } +} + +impl Drop for SelectionAnchor { + fn drop(&mut self) { + // Do not overwrite a newer owner's geometry. + if ptr::eq(associated(&self.view), Retained::as_ptr(&self.value).cast()) { + let previous = self + .previous + .as_ref() + .map_or(ptr::null_mut(), |value| Retained::as_ptr(value).cast_mut()); + unsafe { set_associated(&self.view, previous) }; + } + } +} + +fn associated(view: &NSView) -> *const AnyObject { + // AppKit and this module access the association only on the main thread. + unsafe { + ffi::objc_getAssociatedObject( + (view as *const NSView).cast(), + (&raw const ANCHOR_KEY).cast(), + ) + } +} + +unsafe fn set_associated(view: &NSView, value: *mut AnyObject) { + unsafe { + ffi::objc_setAssociatedObject( + (view as *const NSView).cast_mut().cast(), + (&raw const ANCHOR_KEY).cast(), + value, + ffi::OBJC_ASSOCIATION_RETAIN_NONATOMIC, + ) + }; +} + +fn install_selector(class: &AnyClass) -> bool { + // Extend only the concrete editor view class, never all AppKit views. + if ptr::eq(class, NSView::class()) { + return false; + } + let selector = sel!(selectionAnchorRect); + type AnchorMethod = unsafe extern "C-unwind" fn(&NSView, Sel) -> NSRect; + // IMP erases the signature. The callback and encoding below both implement + // the public NSRect-returning, zero-argument instance method, including the + // target architecture's struct-return ABI. + let implementation: Imp = + unsafe { std::mem::transmute::(selection_anchor_rect as AnchorMethod) }; + if let Some(method) = class.instance_method(selector) { + // GPUI or another integration may already provide the public selector. + // Respect that implementation; never replace or swizzle it. + return ptr::fn_addr_eq(method.implementation(), implementation); + } + // Unlike an Objective-C @protocol reference, Rust bindings do not emit + // protocol metadata. AppKit may not register this optional protocol itself. + let protocol = AnyProtocol::get(c"NSViewContentSelectionInfo").or_else(|| { + let mut builder = ProtocolBuilder::new(c"NSViewContentSelectionInfo")?; + builder.add_protocol(AnyProtocol::get(c"NSObject")?); + builder.add_method_description::<(), NSRect>(selector, false); + Some(builder.register()) + }); + let Some(protocol) = protocol else { + return false; + }; + let encoding = CString::new(format!("{}@:", NSRect::ENCODING)) + .expect("Objective-C type encodings contain no NUL"); + let class_ptr = (class as *const AnyClass).cast_mut(); + // class_addMethod copies the encoding and refuses to replace an existing + // method. Only this public selector is added; the view's class is unchanged. + if !unsafe { ffi::class_addMethod(class_ptr, selector, implementation, encoding.as_ptr()) } + .as_bool() + { + return false; + } + if !class.conforms_to(protocol) { + unsafe { ffi::class_addProtocol(class_ptr, protocol) }; + } + true +} + +unsafe extern "C-unwind" fn selection_anchor_rect(view: &NSView, _: Sel) -> NSRect { + let value = associated(view); + if value.is_null() { + // Preserve AppKit's ordinary whole-view anchor outside a live session. + return view.bounds(); + } + // This private key stores only an NSValue containing an NSRect. Its owning + // association remains alive throughout this synchronous main-thread call. + unsafe { (&*value.cast::()).get::() } +} diff --git a/crates/markraft-app/src/platform/text_requestor/writing_tools.rs b/crates/markraft-app/src/platform/text_requestor/writing_tools.rs index a91d20a2..244c7d01 100644 --- a/crates/markraft-app/src/platform/text_requestor/writing_tools.rs +++ b/crates/markraft-app/src/platform/text_requestor/writing_tools.rs @@ -10,7 +10,7 @@ use objc2_app_kit::{ NSWritingToolsCoordinatorTextAnimation, NSWritingToolsCoordinatorTextReplacementReason, NSWritingToolsResultOptions, }; -use objc2_foundation::{NSAttributedString, NSPoint, NSRange, NSRect, NSSize, NSValue}; +use objc2_foundation::{NSAttributedString, NSRange, NSRect, NSValue}; use std::{cell::Cell, ptr::NonNull}; /// Changes stay local until the limited Writing Tools operation finishes. A @@ -61,7 +61,7 @@ struct State { requestor: Retained, context: RefCell>>, buffer: RefCell, - caret: NSRect, + anchor: NSRect, active: Cell, } @@ -81,7 +81,7 @@ impl State { fn paths(&self, context: &NSWritingToolsCoordinatorContext) -> Retained> { if self.owns_context(context) { - NSArray::from_retained_slice(&[NSBezierPath::bezierPathWithRect(self.caret)]) + NSArray::from_retained_slice(&[NSBezierPath::bezierPathWithRect(self.anchor)]) } else { NSArray::new() } @@ -256,8 +256,7 @@ impl Snapshot { pub(super) fn new( view: &NSResponder, requestor: &TextRequestor, - position: gpui::Point, - line_height: gpui::Pixels, + anchor: gpui::Bounds, ) -> Option { let mtm = MainThreadMarker::new()?; objc2::runtime::AnyClass::get(c"NSWritingToolsCoordinator")?; @@ -266,24 +265,14 @@ impl Snapshot { } let view = unsafe { Retained::retain((view as *const NSResponder).cast_mut().cast::())? }; - let bounds = view.bounds(); - let x = bounds.origin.x + f64::from(f32::from(position.x)); - let offset = f64::from(f32::from(position.y)); - let y = bounds.origin.y - + if view.isFlipped() { - offset - } else { - bounds.size.height - offset - }; + let anchor = + crate::platform::text_geometry::view_rect(anchor, view.bounds(), view.isFlipped()); let delegate = Delegate::alloc(mtm).set_ivars(State { view: view.clone(), requestor: unsafe { Retained::retain((requestor as *const TextRequestor).cast_mut())? }, context: RefCell::new(None), buffer: RefCell::new(Buffer::new(requestor.ivars().borrow().text.clone())), - caret: NSRect::new( - NSPoint::new(x, y), - NSSize::new(1.0, f64::from(f32::from(line_height)).max(1.0)), - ), + anchor, active: Cell::new(true), }); let delegate: Retained = unsafe { msg_send![super(delegate), init] }; diff --git a/crates/markraft-app/src/platform/text_services.rs b/crates/markraft-app/src/platform/text_services.rs index 8198b78b..c7ba8fe1 100644 --- a/crates/markraft-app/src/platform/text_services.rs +++ b/crates/markraft-app/src/platform/text_services.rs @@ -1,14 +1,14 @@ //! Read-only system text integrations. Native presentations run outside GPUI borrows. -use super::{NSPoint, NSRect, native_view, native_window, text_checking::DetectedData}; +use super::{native_view, native_window, text_checking::DetectedData}; use crate::locale::Message; -use gpui::{Pixels, Point, Window}; +use gpui::{Bounds, Pixels, Window}; use objc2::{ MainThreadMarker, class, msg_send, rc::{Allocated, Retained}, runtime::AnyObject, }; -use objc2_foundation::{NSArray, NSAttributedString, NSString, NSURL}; +use objc2_foundation::{NSArray, NSAttributedString, NSPoint, NSRect, NSString, NSURL}; use std::{cell::RefCell, ptr}; #[link(name = "AVFoundation", kind = "framework")] @@ -23,22 +23,22 @@ thread_local! { pub(crate) struct TextAnchor { view: Retained, window: Retained, - position: Point, + bounds: Bounds, } impl TextAnchor { - pub(crate) fn new(window: &Window, position: Point) -> Result { + pub(crate) fn new(window: &Window, bounds: Bounds) -> Result { main_thread()?; let view = unsafe { Retained::retain(native_view(window)?) }.ok_or_else(failure)?; let window = unsafe { Retained::retain(native_window(window)?) }.ok_or_else(failure)?; Ok(Self { view, window, - position, + bounds, }) } - fn point(&self) -> Result { + fn rect(&self) -> Result { main_thread()?; let attached: *mut AnyObject = unsafe { msg_send![&*self.view, window] }; let visible: bool = unsafe { msg_send![&*self.window, isVisible] }; @@ -47,11 +47,15 @@ impl TextAnchor { } let bounds: NSRect = unsafe { msg_send![&*self.view, bounds] }; let flipped: bool = unsafe { msg_send![&*self.view, isFlipped] }; - Ok(view_point(self.position, bounds, flipped)) + Ok(super::text_geometry::view_rect( + self.bounds, + bounds, + flipped, + )) } pub(crate) fn show_definition(&self, text: &str) -> Result<(), Message> { - let point = self.point()?; + let point = anchor_point(self.rect()?); if text.trim().is_empty() { return Ok(()); } @@ -66,7 +70,7 @@ impl TextAnchor { /// Let AppKit provide the installed system's date, address, phone, and flight actions. pub(crate) fn data_menu(&self, action: &DetectedData) -> Result, Message> { - let point = self.point()?; + let point = anchor_point(self.rect()?); // Only use the original detector result and its exact checked string. // Synthetic results lack Reveal metadata and can crash inside AppKit. // Rechecking would also shift relative dates. @@ -79,7 +83,7 @@ impl TextAnchor { } pub(crate) fn share(&self, text: &str) -> Result<(), Message> { - let point = self.point()?; + let rect = self.rect()?; if text.trim().is_empty() { return Ok(()); } @@ -97,10 +101,6 @@ impl TextAnchor { let _: () = msg_send![&*previous, close]; } } - let rect = NSRect { - origin: point, - size: NSPoint { x: 1.0, y: 1.0 }, - }; unsafe { let _: () = msg_send![&*picker, showRelativeToRect: rect, ofView: &*self.view, preferredEdge: 1usize]; } @@ -108,13 +108,12 @@ impl TextAnchor { } } -fn view_point(position: Point, bounds: NSRect, flipped: bool) -> NSPoint { - let x = f32::from(position.x) as f64; - let y = f32::from(position.y) as f64; - NSPoint { - x: bounds.origin.x + x, - y: bounds.origin.y + if flipped { y } else { bounds.size.y - y }, - } +// Point-only AppKit APIs use the center of the same selection rectangle. +fn anchor_point(rect: NSRect) -> NSPoint { + NSPoint::new( + rect.origin.x + rect.size.width / 2., + rect.origin.y + rect.size.height / 2., + ) } pub(crate) fn start_speaking(text: &str) -> Result<(), Message> { @@ -240,18 +239,11 @@ fn failure() -> Message { #[cfg(test)] mod tests { use super::*; - use gpui::{point, px}; + use objc2_foundation::NSSize; #[test] - fn text_anchor_respects_view_origin_and_orientation() { - let bounds = NSRect { - origin: NSPoint { x: 10.0, y: 20.0 }, - size: NSPoint { x: 300.0, y: 200.0 }, - }; - let position = point(px(30.0), px(40.0)); - let flipped = view_point(position, bounds, true); - assert_eq!((flipped.x, flipped.y), (40.0, 60.0)); - let ordinary = view_point(position, bounds, false); - assert_eq!((ordinary.x, ordinary.y), (40.0, 180.0)); + fn point_presentations_anchor_at_selection_center() { + let rect = NSRect::new(NSPoint::new(40., 60.), NSSize::new(80., 20.)); + assert_eq!(anchor_point(rect), NSPoint::new(80., 70.)); } } diff --git a/crates/markraft-app/src/platform/translation.rs b/crates/markraft-app/src/platform/translation.rs index 3f19ead6..26268105 100644 --- a/crates/markraft-app/src/platform/translation.rs +++ b/crates/markraft-app/src/platform/translation.rs @@ -1,10 +1,11 @@ //! Public SwiftUI translation popover, anchored in the native editor view. -use super::{NSRect, native_view}; +use super::{native_view, text_geometry}; use crate::locale::Message; use futures_channel::oneshot; -use gpui::{Pixels, Point, Window}; +use gpui::{Bounds, Pixels, Window}; use objc2::{MainThreadMarker, msg_send}; +use objc2_foundation::NSRect; use std::cell::RefCell; use std::ffi::{CStr, CString, c_char, c_void}; use std::marker::PhantomData; @@ -17,6 +18,8 @@ unsafe extern "C" { view: *mut c_void, x: f64, y: f64, + width: f64, + height: f64, text: *const c_char, editable: bool, context: *mut c_void, @@ -43,7 +46,7 @@ pub(crate) fn available() -> bool { impl Translation { pub(crate) fn show( window: &Window, - position: Point, + bounds: Bounds, text: &str, editable: bool, ) -> Result<(Self, oneshot::Receiver>), Message> { @@ -52,24 +55,19 @@ impl Translation { return Err(Message::new("error.native-window-control")); } let view = native_view(window)?; - let bounds: NSRect = unsafe { msg_send![view, bounds] }; + let native_bounds: NSRect = unsafe { msg_send![view, bounds] }; let flipped: bool = unsafe { msg_send![view, isFlipped] }; - let x = bounds.origin.x + f64::from(f32::from(position.x)); - let offset_y = f64::from(f32::from(position.y)); - let y = bounds.origin.y - + if flipped { - offset_y - } else { - bounds.size.y - offset_y - }; + let anchor = text_geometry::view_rect(bounds, native_bounds, flipped); let text = CString::new(text).map_err(|_| Message::new("error.native-window-control"))?; let (sender, receiver) = oneshot::channel(); let mut completion = Box::new(RefCell::new(Some(sender))); let native = unsafe { markraft_translation_begin( view.cast(), - x, - y, + anchor.origin.x, + anchor.origin.y, + anchor.size.width, + anchor.size.height, text.as_ptr(), editable, (&mut *completion as *mut Completion).cast(), diff --git a/crates/markraft-app/src/platform/translation.swift b/crates/markraft-app/src/platform/translation.swift index 119bf55d..1d26a2a3 100644 --- a/crates/markraft-app/src/platform/translation.swift +++ b/crates/markraft-app/src/platform/translation.swift @@ -19,10 +19,11 @@ private struct TranslationContent: View { @ObservedObject var model: TranslationModel let text: String let editable: Bool + let anchorSize: NSSize var body: some View { Color.clear - .frame(width: 1, height: 1) + .frame(width: anchorSize.width, height: anchorSize.height) .translationPresentation( isPresented: $model.presented, text: text, @@ -38,6 +39,14 @@ private struct TranslationContent: View { } } +@available(macOS 14.4, *) +@MainActor +private final class TranslationAnchorView: NSHostingView { + // The transparent anchor can cover selected text. Let pointer events reach + // the editor; the translation popover handles input in its own window. + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + @available(macOS 14.4, *) @MainActor private final class TranslationSession { @@ -47,16 +56,19 @@ private final class TranslationSession { private let context: UnsafeMutableRawPointer? private var finished = false - init(view: NSView, parent: NativePresentationRoot, point: NSPoint, text: String, editable: Bool, + init(view: NSView, parent: NativePresentationRoot, anchor: NSRect, text: String, editable: Bool, context: UnsafeMutableRawPointer?, completion: @escaping Completion) { self.context = context self.completion = completion - let content = TranslationContent(model: model, text: text, editable: editable) - let host = NSHostingView(rootView: content) + let frame = parent.convert(anchor, from: view) + let content = TranslationContent( + model: model, text: text, editable: editable, anchorSize: frame.size + ) + let host = TranslationAnchorView(rootView: content) // The hosting view supplies only the selection anchor. The system owns // the translation popover and its layout in a separate native window. host.sizingOptions = [] - host.frame = NSRect(origin: parent.convert(point, from: view), size: NSSize(width: 1, height: 1)) + host.frame = frame self.host = host parent.addSubview(host) model.completed = { [weak self] text in self?.finish(text) } @@ -97,6 +109,7 @@ func translationAvailable() -> Bool { @MainActor func translationBegin( _ viewPointer: UnsafeMutableRawPointer?, _ x: Double, _ y: Double, + _ width: Double, _ height: Double, _ text: UnsafePointer?, _ editable: Bool, _ context: UnsafeMutableRawPointer?, _ callback: @escaping @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> Void @@ -105,7 +118,8 @@ func translationBegin( let view = Unmanaged.fromOpaque(viewPointer).takeUnretainedValue() guard let parent = NativePresentationRoot.containing(view) else { return nil } let session = TranslationSession( - view: view, parent: parent, point: NSPoint(x: x, y: y), text: String(cString: text), + view: view, parent: parent, anchor: NSRect(x: x, y: y, width: width, height: height), + text: String(cString: text), editable: editable, context: context, completion: callback ) return Unmanaged.passRetained(session).toOpaque() diff --git a/docs/architecture.md b/docs/architecture.md index 85178a4e..33f65574 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -420,17 +420,32 @@ Writing Tools can request coordinator context and then return text through the S pasteboard bridge. The session records that request so both return paths preserve unchanged source styles. Ordinary Services still inherit the selection's starting style. +Writing Tools anchors its pasteboard popover through `NSViewContentSelectionInfo`. +The app supplies the selection's first visual line as `selectionAnchorRect` in +native view coordinates. Its session stores that rectangle on the GPUI view, +so AppKit can query it without reentering a GPUI update. An existing view +implementation takes precedence. Closing the session releases its stored anchor. +Translation and sharing use the same selection rectangle. Lookup and detected-data +presentations use its center because their AppKit APIs accept a point. Native text +presentations share one coordinate conversion for flipped and unflipped views. +The transparent translation anchor passes pointer events through to the editor. +The context menu itself stays at the pointer; control menus keep their own anchors. + +Checking panels retain their action responder across context menus. A text-service +session can sit above that responder and retain it as its restoration target. +Closing the checking session first retires the pending menu and text-service session, +then releases the panel responder. This order preserves the original responder +chain and rejects any queued result from the retired sessions. + Translation uses the public SwiftUI presentation API, weak-linked behind macOS 14.4 availability. The small Swift bridge is compiled by `xcrun swiftc`. It requires an Xcode SDK providing the Translation framework. Lookup, sharing, speech, and Open With use public AppKit or AVFoundation APIs. Detected-data menus retain the original system result and its checked string, including native detector metadata. -The translation host currently inherits GPUI's AccessKit content-view accessibility -tree, which does not expose native hosting subviews. Translation generation has -been checked on macOS; automated replacement-button interaction remains unverified -because the same coordinate-click issue also occurs in TextEdit's native popover. -The Rust callback and guarded replacement paths have separate test coverage. +Native popover accessibility varies by macOS version. Translation generation, +replacement-button interaction and undo have been verified on macOS 27. +The Rust callback and guarded replacement paths have separate headless test coverage. Each note owns its spelling document tag, so ignored words remain document-local. Prose checks run in bounded Unicode chunks after a debounce and yield between chunks; From 0500e903a054678a8f5d0da299c43e1445c01734 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 5 Oct 2026 12:13:02 +1100 Subject: [PATCH 5/6] fix(editor): align native lookup highlights with painted text Use the painted baseline and font runs for AppKit lookup. Preserve CJK fallback styles and UTF-16 ranges. --- .../markraft-app/src/app/ui/context_menu.rs | 18 +- .../src/platform/text_geometry.rs | 20 +- .../src/platform/text_services.rs | 57 ++++- crates/markraft-gpui/src/context.rs | 20 +- .../markraft-gpui/src/context/presentation.rs | 52 +++++ crates/markraft-gpui/src/context/tests.rs | 45 ++++ crates/markraft-gpui/src/lib.rs | 4 +- .../markraft-gpui/src/surface/layout_line.rs | 83 +++++++ crates/markraft-gpui/src/surface/shape.rs | 17 +- crates/markraft-gpui/src/surface/tests.rs | 205 ++++++++++++++++++ 10 files changed, 500 insertions(+), 21 deletions(-) create mode 100644 crates/markraft-gpui/src/context/presentation.rs diff --git a/crates/markraft-app/src/app/ui/context_menu.rs b/crates/markraft-app/src/app/ui/context_menu.rs index 404b6d1e..d506f2bf 100644 --- a/crates/markraft-app/src/app/ui/context_menu.rs +++ b/crates/markraft-app/src/app/ui/context_menu.rs @@ -1050,7 +1050,7 @@ impl MarkraftApp { &mut self, service: TextService, text: String, - position: Point, + request: &ContextRequest, window: &mut Window, cx: &mut Context, ) { @@ -1065,7 +1065,14 @@ impl MarkraftApp { text_services::stop_speaking(); return; } - let anchor = match TextAnchor::new(window, self.text_service_bounds(position, cx)) { + let presentation = matches!(service, TextService::Lookup) + .then(|| { + self.editor() + .read(cx) + .context_text_presentation(request, cx) + }) + .flatten(); + let anchor = match TextAnchor::new(window, self.text_service_bounds(request.position, cx)) { Ok(anchor) => anchor, Err(error) => { self.feedback.set_error(error); @@ -1075,7 +1082,10 @@ impl MarkraftApp { }; cx.spawn(async move |this, cx| { let result = match service { - TextService::Lookup => anchor.show_definition(&text), + TextService::Lookup => presentation.as_ref().map_or_else( + || Err(Message::new("error.native-window-control")), + |presentation| anchor.show_definition(presentation), + ), TextService::Share => anchor.share(&text), TextService::Speak => text_services::start_speaking(&text), _ => Ok(()), @@ -1304,7 +1314,7 @@ impl MarkraftApp { self.focus_editor(window, cx); } MenuAction::Text(service, text) => { - self.present_text_service(service, text, session.request.position, window, cx) + self.present_text_service(service, text, &session.request, window, cx) } MenuAction::OpenWith(url, application) => { if let Err(error) = text_services::open_with(&url, &application) { diff --git a/crates/markraft-app/src/platform/text_geometry.rs b/crates/markraft-app/src/platform/text_geometry.rs index 06124768..f0862081 100644 --- a/crates/markraft-app/src/platform/text_geometry.rs +++ b/crates/markraft-app/src/platform/text_geometry.rs @@ -1,8 +1,18 @@ //! Shared selection geometry for native text presentations. -use gpui::{Bounds, Pixels}; +use gpui::{Bounds, Pixels, Point}; use objc2_foundation::{NSPoint, NSRect, NSSize}; +/// Convert an exact text baseline without adding rectangle or caret dimensions. +pub(crate) fn view_point(point: Point, native: NSRect, flipped: bool) -> NSPoint { + let x = f64::from(f32::from(point.x)); + let y = f64::from(f32::from(point.y)); + NSPoint::new( + native.origin.x + x, + native.origin.y + if flipped { y } else { native.size.height - y }, + ) +} + /// Convert GPUI window-local, top-down points into the native view's bounds. /// A caret needs a nonempty width to avoid AppKit's whole-view fallback. pub(crate) fn view_rect(bounds: Bounds, native: NSRect, flipped: bool) -> NSRect { @@ -29,6 +39,14 @@ mod tests { use super::*; use gpui::{point, px, size}; + #[test] + fn text_baseline_preserves_the_character_origin() { + let baseline = point(px(30.), px(54.)); + let native = NSRect::new(NSPoint::new(10., 15.), NSSize::new(500., 300.)); + assert_eq!(view_point(baseline, native, true), NSPoint::new(40., 69.)); + assert_eq!(view_point(baseline, native, false), NSPoint::new(40., 261.)); + } + #[test] fn selection_rect_preserves_dimensions_in_both_coordinate_systems() { let selection = Bounds::new(point(px(30.), px(40.)), size(px(80.), px(20.))); diff --git a/crates/markraft-app/src/platform/text_services.rs b/crates/markraft-app/src/platform/text_services.rs index c7ba8fe1..1fe17122 100644 --- a/crates/markraft-app/src/platform/text_services.rs +++ b/crates/markraft-app/src/platform/text_services.rs @@ -3,12 +3,16 @@ use super::{native_view, native_window, text_checking::DetectedData}; use crate::locale::Message; use gpui::{Bounds, Pixels, Window}; +use markraft_gpui::{ContextFontRun, ContextTextPresentation}; use objc2::{ MainThreadMarker, class, msg_send, rc::{Allocated, Retained}, runtime::AnyObject, }; -use objc2_foundation::{NSArray, NSAttributedString, NSPoint, NSRect, NSString, NSURL}; +use objc2_app_kit::NSFontAttributeName; +use objc2_foundation::{ + NSArray, NSMutableAttributedString, NSPoint, NSRange, NSRect, NSString, NSURL, +}; use std::{cell::RefCell, ptr}; #[link(name = "AVFoundation", kind = "framework")] @@ -54,14 +58,30 @@ impl TextAnchor { )) } - pub(crate) fn show_definition(&self, text: &str) -> Result<(), Message> { - let point = anchor_point(self.rect()?); - if text.trim().is_empty() { + pub(crate) fn show_definition( + &self, + presentation: &ContextTextPresentation, + ) -> Result<(), Message> { + self.rect()?; + if presentation.text.trim().is_empty() { return Ok(()); } - let text = NSAttributedString::from_nsstring(&NSString::from_str(text)); - // NSView explicitly supports this API for custom text views, without NSTextView. + let text = + NSMutableAttributedString::from_nsstring(&NSString::from_str(&presentation.text)); unsafe { + for run in &presentation.runs { + let font = lookup_font(run); + text.addAttribute_value_range( + NSFontAttributeName, + &font, + NSRange::new(run.range.start, run.range.len()), + ); + } + let bounds: NSRect = msg_send![&*self.view, bounds]; + let flipped: bool = msg_send![&*self.view, isFlipped]; + let point = super::text_geometry::view_point(presentation.baseline, bounds, flipped); + // AppKit redraws a highlighted copy at the first character's baseline, + // not the selection center. Matching its font avoids a second-sized word. let _: () = msg_send![&*self.view, showDefinitionForAttributedString: &*text, atPoint: point]; } @@ -108,7 +128,28 @@ impl TextAnchor { } } -// Point-only AppKit APIs use the center of the same selection rectangle. +fn lookup_font(run: &ContextFontRun) -> Retained { + let family = gpui::font_name_with_fallbacks(&run.font.family, ".AppleSystemUIFont"); + let size = f64::from(f32::from(run.font_size)); + unsafe { + let manager: *mut AnyObject = msg_send![class!(NSFontManager), sharedFontManager]; + let traits = if run.font.style == gpui::FontStyle::Italic { + 1usize + } else { + 0 + }; + let weight = if run.font.weight >= gpui::FontWeight::BOLD { + 9isize + } else { + 5 + }; + let font: Option> = msg_send![manager, + fontWithFamily: &*NSString::from_str(family), traits: traits, weight: weight, size: size]; + font.unwrap_or_else(|| msg_send![class!(NSFont), systemFontOfSize: size]) + } +} + +// Data detector menus use the center of the selection rectangle. fn anchor_point(rect: NSRect) -> NSPoint { NSPoint::new( rect.origin.x + rect.size.width / 2., @@ -242,7 +283,7 @@ mod tests { use objc2_foundation::NSSize; #[test] - fn point_presentations_anchor_at_selection_center() { + fn data_detector_menus_anchor_at_selection_center() { let rect = NSRect::new(NSPoint::new(40., 60.), NSSize::new(80., 20.)); assert_eq!(anchor_point(rect), NSPoint::new(80., 70.)); } diff --git a/crates/markraft-gpui/src/context.rs b/crates/markraft-gpui/src/context.rs index e9066616..94cd8352 100644 --- a/crates/markraft-gpui/src/context.rs +++ b/crates/markraft-gpui/src/context.rs @@ -1,7 +1,9 @@ //! Pointer context and capability queries shared by the host's editing menus. use crate::{EditorEvent, EditorView, clipboard, links, wiki}; -use gpui::{App, ClipboardEntry, ClipboardItem, Context, MouseDownEvent, Pixels, Point, Window}; +use gpui::{ + App, ClipboardEntry, ClipboardItem, Context, Font, MouseDownEvent, Pixels, Point, Window, +}; use markraft_core::{Node, Selection, TransactionSpec, projection::Projection, protocol::event}; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; @@ -14,9 +16,25 @@ pub struct ContextText { pub transformable: bool, } +/// Painted text metrics for a native service that redraws the selected text. +pub struct ContextTextPresentation { + pub text: String, + /// First character's baseline origin in window-local, top-down points. + pub baseline: Point, + pub runs: Vec, +} + +pub struct ContextFontRun { + /// UTF-16 range within the reading text, not the Markdown source. + pub range: Range, + pub font: Font, + pub font_size: Pixels, +} + mod block_actions; mod copy_formats; mod pointer; +mod presentation; mod reading_text; mod smart_edit; mod transformations; diff --git a/crates/markraft-gpui/src/context/presentation.rs b/crates/markraft-gpui/src/context/presentation.rs new file mode 100644 index 00000000..b98ddc73 --- /dev/null +++ b/crates/markraft-gpui/src/context/presentation.rs @@ -0,0 +1,52 @@ +//! Native lookup redraws the reading text, so its metrics must come from paint. + +use super::*; + +impl EditorView { + /// Snapshot the reading text and its painted fonts after the context selection + /// has painted. Unpainted content inherits the first visible character's font. + pub fn context_text_presentation( + &self, + request: &ContextRequest, + cx: &App, + ) -> Option { + let mapped = self.context_text_mapping(request)?; + let presentation = |source: &Option>| { + let (row, offset) = self.row_at(source.as_ref()?.start)?; + row.text_presentation_at(offset, cx.text_system()) + }; + let first = mapped.source.first().and_then(presentation).or_else(|| { + // Atom labels have no literal source mapping. Their painted slot + // still supplies a baseline; do not jump to a later literal word. + let (row, offset) = self.row_at(request.text_range().start)?; + row.text_presentation_at(offset, cx.text_system()) + })?; + let fallback = gpui::font(self.style().font_family.clone()); + let mut runs: Vec = Vec::new(); + let mut offset = 0; + for (ch, source) in mapped.text.chars().zip(&mapped.source) { + let painted = presentation(source); + let painted = painted.as_ref().unwrap_or(&first); + let font = painted.font.as_ref().unwrap_or(&fallback); + let end = offset + ch.len_utf16(); + if let Some(previous) = runs.last_mut() + && previous.font == *font + && previous.font_size == painted.font_size + { + previous.range.end = end; + } else { + runs.push(ContextFontRun { + range: offset..end, + font: font.clone(), + font_size: painted.font_size, + }); + } + offset = end; + } + Some(ContextTextPresentation { + text: mapped.text, + baseline: first.baseline, + runs, + }) + } +} diff --git a/crates/markraft-gpui/src/context/tests.rs b/crates/markraft-gpui/src/context/tests.rs index debc6b4d..6600f644 100644 --- a/crates/markraft-gpui/src/context/tests.rs +++ b/crates/markraft-gpui/src/context/tests.rs @@ -38,6 +38,51 @@ fn range(view: &EditorView) -> Range { view.state.selection().from(view.state.doc())..view.state.selection().to(view.state.doc()) } +#[gpui::test] +fn lookup_presentation_uses_reading_text_and_utf16_ranges(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| EditorView::new(setup("a **bold** 😀 word"), cx)); + cx.run_until_parked(); + let request = view.update(cx, |view, cx| { + view.select_range(1, view.state.doc().content_size() - 1, cx); + view.context_snapshot(Point::default(), ContextTarget::Text) + }); + cx.run_until_parked(); + view.read_with(cx, |view, cx| { + let presentation = view.context_text_presentation(&request, cx).unwrap(); + assert_eq!(presentation.text, "a bold 😀 word"); + assert_eq!(presentation.runs.first().unwrap().range.start, 0); + assert_eq!(presentation.runs.last().unwrap().range.end, 14); + assert!( + presentation + .runs + .windows(2) + .all(|pair| pair[0].range.end == pair[1].range.start) + ); + let bounds = view.anchor_bounds().unwrap(); + assert_eq!(presentation.baseline.x, bounds.left()); + assert!(presentation.baseline.y > bounds.top()); + assert!(presentation.baseline.y < bounds.bottom()); + }); + view.update(cx, |view, cx| { + view.select(1, false, cx); + assert!(view.context_text_presentation(&request, cx).is_none()); + }); +} + +#[gpui::test] +fn lookup_presentation_keeps_nonliteral_labels_lookupable(cx: &mut TestAppContext) { + let (view, cx) = cx.add_window_view(|_, cx| EditorView::new(setup("[[Dictionary]] tail"), cx)); + cx.run_until_parked(); + view.read_with(cx, |view, cx| { + let request = view.context_source_range_snapshot(1..2).unwrap(); + let mapped = view.context_text_mapping(&request).unwrap(); + assert!(mapped.source.iter().all(Option::is_none)); + let presentation = view.context_text_presentation(&request, cx).unwrap(); + assert_eq!(presentation.text, "Dictionary"); + assert_eq!(presentation.runs.last().unwrap().range.end, 10); + }); +} + fn click_character( view: &Entity, cx: &mut VisualTestContext, diff --git a/crates/markraft-gpui/src/lib.rs b/crates/markraft-gpui/src/lib.rs index a048d91d..bd78b4b0 100644 --- a/crates/markraft-gpui/src/lib.rs +++ b/crates/markraft-gpui/src/lib.rs @@ -43,8 +43,8 @@ mod word_boundary; pub use callout::{Tone as CalloutTone, callout_heading, callout_tone}; pub use clipboard::{CopyFormat, use_system_pasteboard, write_rich_text}; pub use context::{ - ContextAction, ContextRequest, ContextTarget, ContextText, ContextTextMap, EditCapabilities, - TextTransformation, + ContextAction, ContextFontRun, ContextRequest, ContextTarget, ContextText, ContextTextMap, + ContextTextPresentation, EditCapabilities, TextTransformation, }; pub use emoji::{EmojiInsertion, EmojiShortcodes, emoji_menu}; pub use extension::{ diff --git a/crates/markraft-gpui/src/surface/layout_line.rs b/crates/markraft-gpui/src/surface/layout_line.rs index 4076ece8..d533b56c 100644 --- a/crates/markraft-gpui/src/surface/layout_line.rs +++ b/crates/markraft-gpui/src/surface/layout_line.rs @@ -4,6 +4,13 @@ use super::*; +/// The actual painted face and baseline at one source character. +pub(crate) struct TextPresentation { + pub(crate) baseline: Point, + pub(crate) font: Option, + pub(crate) font_size: Pixels, +} + /// Inline code, superscript or subscript on one visual row. Text runs share one font /// size, so the main line only reserves the space and the text is painted again, /// smaller, centred in that slot. Around code, what is left of the slot on either side @@ -18,6 +25,8 @@ pub(super) struct InlineCode { pub(super) left: Pixels, pub(super) slot: Pixels, pub(super) line: Rc, + /// Requested faces by UTF-8 byte length, before Core Text glyph fallback. + pub(super) font_runs: Vec<(usize, Font)>, /// A script: no pill behind it, and painted `lift` above the row — below /// it for a subscript, whose lift is negative. pub(super) raised: bool, @@ -256,6 +265,8 @@ pub(crate) struct TableCell { #[derive(Clone)] pub(crate) struct LayoutRow { pub(super) line: Rc, + /// Requested faces by UTF-8 byte length, before Core Text glyph fallback. + pub(super) font_runs: Vec<(usize, Font)>, pub(super) paint_rows: Vec, /// `char` offset of the row's start within the projection line. pub(super) char_start: usize, @@ -813,6 +824,78 @@ impl LayoutLine { self.origin + point(position.x, self.visual_text_top(visual)) } + /// Describe the glyph under a source offset for native text presentations. + /// The baseline follows GPUI's paint metrics, including smaller inline text. + pub(crate) fn text_presentation_at( + &self, + offset: usize, + text_system: &gpui::TextSystem, + ) -> Option { + if self.rows.is_empty() || offset >= self.char_len { + return None; + } + let (index, byte) = self.locate(offset); + let row = &self.rows[index]; + let visual = self.visual_for_offset(offset, false); + let code = row + .inline_code + .iter() + .find(|code| code.range.contains(&byte)); + let (layout, local_byte, left, top): (&gpui::LineLayout, _, _, _) = if let Some(code) = code + { + ( + &code.line, + byte - code.range.start, + code.text_left(), + self.visual_text_top(visual) - code.lift, + ) + } else { + // GPUI's position query gives a wrap-boundary byte to the previous + // row's caret. Native text starts on the following row instead. + let local_visual = visual - row.visual_start; + let left = if local_visual == 0 { + Pixels::ZERO + } else { + let boundary = row.line.wrap_boundaries().get(local_visual - 1)?; + -row.line.runs()[boundary.run_ix].glyphs[boundary.glyph_ix] + .position + .x + }; + ( + &*row.line.unwrapped_layout, + byte, + left, + self.visual_text_top(visual), + ) + }; + // Ligatures and combining characters can share a glyph. Use the glyph + // starting at or before the character, not the next font run. + let (glyph, font_id) = layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter().map(move |glyph| (glyph, run.font_id))) + .filter(|(glyph, _)| glyph.index <= local_byte) + .max_by_key(|(glyph, _)| glyph.index)?; + let baseline = + top + (self.line_height - layout.ascent - layout.descent) / 2. + layout.ascent; + let font = text_system.get_font_for_id(font_id).or_else(|| { + // Platform fallback fonts are not always in GPUI's reverse cache. + // Preserve the requested face's traits and let AppKit fallback too. + let runs = code.map_or(&row.font_runs, |code| &code.font_runs); + let mut end = 0; + runs.iter().find_map(|(len, font)| { + end += *len; + (local_byte < end).then(|| font.clone()) + }) + }); + Some(TextPresentation { + baseline: self.origin + + point(left + glyph.position.x + self.row_shift(visual), baseline), + font, + font_size: layout.font_size, + }) + } + /// Where a position strictly inside inline code is drawn. Its edges keep the /// slot's own bounds, so the caret rests outside the pill there. pub(super) fn inline_code_x(&self, index: usize, byte: usize, visual: usize) -> Option { diff --git a/crates/markraft-gpui/src/surface/shape.rs b/crates/markraft-gpui/src/surface/shape.rs index accccd37..2ef45876 100644 --- a/crates/markraft-gpui/src/surface/shape.rs +++ b/crates/markraft-gpui/src/surface/shape.rs @@ -292,13 +292,11 @@ pub(super) fn shape_line( .collect(); keep_line_breaking_rules(&mut wrapped, wrap_width, &glue); } + let local_runs = + text_layout::slice_runs(&runs.runs, byte_start..byte_start + wrapped.text.len()); let paint_rows = if text.objects.is_empty() { Vec::new() } else { - let local_runs = text_layout::slice_runs( - &runs.runs, - byte_start..byte_start + wrapped.text.len(), - ); text_layout::paint_rows(&wrapped, font_size, &local_runs, text_system) }; byte_start += wrapped.text.len() + 1; @@ -307,6 +305,10 @@ pub(super) fn shape_line( visual_start, inline_code: Vec::new(), paint_rows, + font_runs: local_runs + .into_iter() + .map(|run| (run.len, run.font)) + .collect(), line: Rc::new(wrapped), }; char_start += row.char_len() + 1; @@ -692,10 +694,11 @@ pub(super) fn shape_inline_code( let row_text = layout.rows[index].text().to_owned(); let absolute = row_byte_start(layout, index); let absolute = absolute + part.start..absolute + part.end; + let local_runs = piece_runs(range.start, runs, absolute); let line = text_system.shape_line( row_text[part.clone()].to_owned().into(), font_size * scale, - &piece_runs(range.start, runs, absolute), + &local_runs, None, ); // The slot comes from the whole line's rectangles, so its row @@ -707,6 +710,10 @@ pub(super) fn shape_inline_code( left: slot.origin.x - layout.origin.x, slot: slot.size.width, line: Rc::new(line), + font_runs: local_runs + .into_iter() + .map(|run| (run.len, run.font)) + .collect(), raised: *raised, lift, }); diff --git a/crates/markraft-gpui/src/surface/tests.rs b/crates/markraft-gpui/src/surface/tests.rs index db428f86..216d4ad5 100644 --- a/crates/markraft-gpui/src/surface/tests.rs +++ b/crates/markraft-gpui/src/surface/tests.rs @@ -418,6 +418,211 @@ fn text_system() -> WindowTextSystem { WindowTextSystem::new(Arc::new(TextSystem::new(Arc::new(NoopTextSystem::new())))) } +fn presentation_line( + text: &str, + face: gpui::Font, + font_size: Pixels, + width: Pixels, +) -> (LayoutLine, WindowTextSystem) { + let system = text_system(); + let runs = [TextRun { + len: text.len(), + font: face, + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }]; + let wrapped = system + .shape_text(text.to_owned().into(), font_size, &runs, Some(width), None) + .unwrap(); + let mut line = rows_of("plain").remove(0); + line.char_len = text.chars().count(); + line.line_height = px(40.); + line.origin = point(px(80.), px(120.)); + let mut char_start = 0; + let mut visual_start = 0; + for wrapped in wrapped { + let row = LayoutRow { + font_runs: vec![(wrapped.text.len(), runs[0].font.clone())], + line: std::rc::Rc::new(wrapped), + char_start, + visual_start, + inline_code: Vec::new(), + paint_rows: Vec::new(), + }; + char_start += row.char_len() + 1; + visual_start += row.visual_rows(); + line.rows.push(row); + } + (line, system) +} + +fn assert_presentation_pixels(actual: Pixels, expected: Pixels) { + assert!( + f32::from(actual - expected).abs() < 0.001, + "expected {expected:?}, got {actual:?}", + ); +} + +#[test] +fn text_presentation_uses_painted_baseline_and_configured_bold_face() { + let mut face = font("Custom Serif"); + face.weight = gpui::FontWeight::BOLD; + let (line, system) = presentation_line("heading", face.clone(), px(28.), px(500.)); + let shown = line.text_presentation_at(0, &system).unwrap(); + let shaped = &line.rows[0].line; + let expected = line.origin.y + + (line.line_height - shaped.ascent() - shaped.descent()) / 2. + + shaped.ascent(); + assert_presentation_pixels(shown.baseline.x, line.origin.x); + assert_presentation_pixels(shown.baseline.y, expected); + assert_ne!(shown.baseline.y, line.origin.y + line.line_height / 2.); + assert_ne!(shown.baseline.y, line.origin.y + line.line_height); + // NoopTextSystem shapes with unmapped FontId(0), exercising the retained + // requested face rather than GPUI's reverse font cache. + assert_eq!(shown.font, Some(face)); + assert_eq!(shown.font_size, px(28.)); + assert!(line.text_presentation_at(line.char_len, &system).is_none()); +} + +#[test] +fn text_presentation_follows_wraps_alignment_and_hard_newlines() { + let (mut line, system) = presentation_line( + "first second third fourth\nlast", + font("Custom Serif"), + px(18.), + px(100.), + ); + let row = &line.rows[0]; + assert!(row.visual_rows() > 1); + let byte = row.wrap_starts()[1]; + let offset = row.text()[..byte].chars().count(); + line.row_shifts = vec![px(7.); line.visual_rows()]; + let shown = line.text_presentation_at(offset, &system).unwrap(); + assert_presentation_pixels(shown.baseline.x, line.origin.x + px(7.)); + let first = line.text_presentation_at(0, &system).unwrap(); + assert_presentation_pixels(shown.baseline.y - first.baseline.y, line.line_height); + let last = line + .text_presentation_at(line.rows[1].char_start, &system) + .unwrap(); + assert_presentation_pixels(last.baseline.x, line.origin.x + px(7.)); + assert_presentation_pixels( + last.baseline.y - first.baseline.y, + line.line_height * line.rows[1].visual_start as f32, + ); +} + +#[test] +fn text_presentation_uses_inline_repaint_size_and_lift() { + let face = font("Custom Mono"); + let (mut line, system) = presentation_line("word", face.clone(), px(20.), px(500.)); + let small = system.shape_line( + "word".into(), + px(14.), + &[TextRun { + len: 4, + font: face.clone(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + let expected = + line.origin.y + (line.line_height - small.ascent - small.descent) / 2. + small.ascent + - px(6.); + line.rows[0].inline_code.push(super::InlineCode { + range: 0..4, + visual_row: 0, + left: px(10.), + slot: small.width, + line: std::rc::Rc::new(small), + font_runs: vec![(4, face.clone())], + raised: true, + lift: px(6.), + }); + let shown = line.text_presentation_at(0, &system).unwrap(); + assert_presentation_pixels(shown.baseline.x, line.origin.x + px(10.)); + assert_presentation_pixels(shown.baseline.y, expected); + assert_eq!(shown.font_size, px(14.)); + assert_eq!(shown.font, Some(face)); +} + +#[test] +fn text_presentation_follows_text_band_below_tall_inline_objects() { + let (mut line, system) = presentation_line("word", font("Custom Serif"), px(20.), px(500.)); + let ordinary = line.text_presentation_at(0, &system).unwrap(); + line.visuals.push(super::VisualRow { + top: px(0.), + height: px(90.), + baseline: px(70.), + text_top: px(30.), + }); + let shown = line.text_presentation_at(0, &system).unwrap(); + assert_presentation_pixels(shown.baseline.y, ordinary.baseline.y + px(30.)); +} + +#[test] +fn text_presentation_keeps_configured_face_for_an_unmapped_fallback_font() { + let (line, system) = presentation_line("word", font("Custom Serif"), px(20.), px(500.)); + let known = line.text_presentation_at(0, &system).unwrap(); + let without_font_mapping = TextSystem::new(Arc::new(NoopTextSystem::new())); + let fallback = line.text_presentation_at(0, &without_font_mapping).unwrap(); + assert_eq!(fallback.font, Some(font("Custom Serif"))); + assert_eq!(fallback.baseline, known.baseline); + assert_eq!(fallback.font_size, known.font_size); +} + +#[test] +fn text_presentation_retains_shaped_faces_across_multibyte_style_runs() { + let source = "**世界** plain *italic*"; + let rows = shaped(source); + let system = text_system(); + let first = rows[0].text_presentation_at(2, &system).unwrap(); + let second = rows[0].text_presentation_at(3, &system).unwrap(); + let plain = rows[0].text_presentation_at(7, &system).unwrap(); + let italic_offset = source[..source.find("italic").unwrap()].chars().count(); + let italic = rows[0] + .text_presentation_at(italic_offset, &system) + .unwrap(); + assert_eq!(first.font.as_ref().unwrap().weight, gpui::FontWeight::BOLD); + assert_eq!(first.font, second.font); + assert_eq!(plain.font.unwrap().weight, gpui::FontWeight::NORMAL); + assert_eq!(italic.font.unwrap().style, gpui::FontStyle::Italic); +} + +#[test] +fn text_presentation_uses_logical_glyph_indices_in_visual_order() { + let (mut line, system) = presentation_line("ab", font("Custom Serif"), px(20.), px(500.)); + let wrapped = std::rc::Rc::get_mut(&mut line.rows[0].line) + .expect("the fixture owns its wrapped line exclusively"); + let original = &wrapped.unwrapped_layout; + let mut runs = original.runs.clone(); + let second_x = runs[0].glyphs[1].position.x; + // A right-to-left shaped run presents its later logical character first. + runs[0].glyphs.reverse(); + runs[0].glyphs[0].position.x = px(0.); + runs[0].glyphs[1].position.x = second_x; + **wrapped = Arc::new(gpui::WrappedLineLayout { + unwrapped_layout: Arc::new(gpui::LineLayout { + font_size: original.font_size, + width: original.width, + ascent: original.ascent, + descent: original.descent, + runs, + len: original.len, + }), + wrap_boundaries: wrapped.wrap_boundaries.clone(), + wrap_width: wrapped.wrap_width, + }); + let first = line.text_presentation_at(0, &system).unwrap(); + let second = line.text_presentation_at(1, &system).unwrap(); + assert_presentation_pixels(first.baseline.x, line.origin.x + second_x); + assert_presentation_pixels(second.baseline.x, line.origin.x); +} + /// Every line of `source`, laid out at a note's width. fn shaped(source: &str) -> Vec { shaped_with(source, callout_types()) From a094a8e6c0c0313e2e6bca9de43e91857fd36363 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 5 Oct 2026 12:13:20 +1100 Subject: [PATCH 6/6] fix(editor): center the code language search field --- crates/markraft-app/src/app/ui/code.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/markraft-app/src/app/ui/code.rs b/crates/markraft-app/src/app/ui/code.rs index 9ee2da1f..3d5a5d5a 100644 --- a/crates/markraft-app/src/app/ui/code.rs +++ b/crates/markraft-app/src/app/ui/code.rs @@ -89,6 +89,8 @@ impl MarkraftApp { let editor = self.editor().read(cx); let active = editor.code_language(pos)?.trim().to_lowercase(); let anchor = editor.code_language_bounds(pos)?; + let query_style = self.query().read(cx).style(); + let query_height = query_style.body_size * query_style.line_height_ratio; let languages = self.matching_code_languages(cx); let empty = languages.is_empty(); let viewport = window.bounds().size; @@ -201,10 +203,11 @@ impl MarkraftApp { .h(px(40.)) .flex_shrink_0() .px_3() - .pt(px(5.)) + .flex() + .items_center() .border_b_1() .border_color(self.border_color()) - .child(self.query_field(cx)), + .child(self.query_field(cx).h(query_height)), ) .child(list), )