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..ee5db311 100644
--- a/crates/markraft-app/src/app/interaction.rs
+++ b/crates/markraft-app/src/app/interaction.rs
@@ -213,6 +213,12 @@ impl MarkraftApp {
}
pub(super) fn set_panel(&mut self, panel: Panel, cx: &mut Context) {
+ self.context_menus.dismiss();
+ // 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-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/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),
)
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..d506f2bf
--- /dev/null
+++ b/crates/markraft-app/src/app/ui/context_menu.rs
@@ -0,0 +1,1360 @@
+//! 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 })
+ })
+ }
+
+ 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;
+ }
+ // AppKit tracking can suspend GPUI presentation. Let the pointer's new
+ // 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(());
+ });
+ cx.spawn_in(window, async move |this, cx| {
+ if ready.await.is_err() {
+ return;
+ }
+ 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| {
+ let _ = this.update(cx, |app, cx| {
+ app.finish_context_menu(id, selected, window, cx)
+ });
+ });
+ })
+ .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 {
+ 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 anchor = self.text_service_bounds(session.request.position, cx);
+ let Ok((requestor, returned)) = TextServiceSession::attach(
+ window,
+ TextServiceSnapshot {
+ text: text.text,
+ editable,
+ prose,
+ },
+ anchor,
+ ) 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,
+ request: &ContextRequest,
+ 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 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);
+ cx.notify();
+ return;
+ }
+ };
+ cx.spawn(async move |this, cx| {
+ let result = match service {
+ 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(()),
+ };
+ 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,
+ self.text_service_bounds(session.request.position, cx),
+ 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, 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(", "{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..987bcd09
--- /dev/null
+++ b/crates/markraft-app/src/app/ui/context_menu/tests.rs
@@ -0,0 +1,1083 @@
+//! 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 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")], |_| {});
+ 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);
+ 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/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
new file mode 100644
index 00000000..d5698fce
--- /dev/null
+++ b/crates/markraft-app/src/app/ui/text_checking.rs
@@ -0,0 +1,1331 @@
+//! 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) {
+ // 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);
+ }
+ }
+
+ /// 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..f65acb81 100644
--- a/crates/markraft-app/src/platform.rs
+++ b/crates/markraft-app/src/platform.rs
@@ -2,9 +2,16 @@
//!
//! 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_geometry;
+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 +28,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 +495,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..30a8d82a
--- /dev/null
+++ b/crates/markraft-app/src/platform/checking_panel.rs
@@ -0,0 +1,676 @@
+//! 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 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>,
+ 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..f18e4967
--- /dev/null
+++ b/crates/markraft-app/src/platform/context_menu.rs
@@ -0,0 +1,479 @@
+//! 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::{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};
+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,
+ },
+}
+
+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,
+ anchor: Bounds,
+ 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,
+ anchor,
+ &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,
+ anchor: Bounds,
+ 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 } = row {
+ let source =
+ super::text_services::TextAnchor::new(window, anchor)?.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,
+ anchor,
+ 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"
+ ],
+ [
+ "ab",
+ "ab"
+ ],
+ [
+ "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_geometry.rs b/crates/markraft-app/src/platform/text_geometry.rs
new file mode 100644
index 00000000..f0862081
--- /dev/null
+++ b/crates/markraft-app/src/platform/text_geometry.rs
@@ -0,0 +1,73 @@
+//! Shared selection geometry for native text presentations.
+
+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 {
+ 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 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.)));
+ 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
new file mode 100644
index 00000000..d6340e5d
--- /dev/null
+++ b/crates/markraft-app/src/platform/text_requestor.rs
@@ -0,0 +1,432 @@
+//! 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, NSView,
+};
+use objc2_foundation::{NSArray, NSString};
+use std::cell::RefCell;
+
+mod selection_anchor;
+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,
+ anchor: Option,
+}
+
+impl TextServiceSession {
+ pub(crate) fn attach(
+ window: &gpui::Window,
+ snapshot: TextServiceSnapshot,
+ bounds: gpui::Bounds,
+ ) -> Result<(Self, oneshot::Receiver), Message> {
+ let mtm =
+ MainThreadMarker::new().ok_or_else(|| Message::new("error.native-window-control"))?;
+ 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"),
+ ]);
+ 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 anchor = selection_anchor::SelectionAnchor::new(&native, bounds);
+ let writing_tools = native_writing
+ .then(|| writing_tools::Snapshot::new(&view, &requestor, bounds))
+ .flatten();
+ Ok((
+ Self {
+ view,
+ previous,
+ requestor,
+ writing_tools,
+ anchor,
+ },
+ receiver,
+ ))
+ }
+
+ pub(crate) fn cancel(&mut self) {
+ self.writing_tools.take();
+ self.anchor.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/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
new file mode 100644
index 00000000..244c7d01
--- /dev/null
+++ b/crates/markraft-app/src/platform/text_requestor/writing_tools.rs
@@ -0,0 +1,423 @@
+//! 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, NSRange, NSRect, 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 |