feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)

Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.

Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
  via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
  Anchored at the selection's bottom-center (CSS pixels mapped into
  NSView coords), so the inline Lookup HUD appears just below the
  highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
  pageSheet on iPhone (medium → large drag-to-expand) and as a
  formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
  dispatched without createChooser so users get the standard system
  disambiguation dialog with "Just once / Always" buttons. Reports
  unavailable=true when no app handles the intent so the TS layer can
  silently skip rather than open an empty chooser.

Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
This commit is contained in:
loveheaven
2026-05-19 13:03:52 +08:00
committed by GitHub
parent d35d2002c4
commit 05da6bdf43
23 changed files with 1074 additions and 10 deletions
@@ -166,6 +166,19 @@ pub(crate) async fn open_external_url<R: Runtime>(
app.native_bridge().open_external_url(payload)
}
/// See [`ShowLookupPopoverRequest`] in `models.rs` for platform-by-
/// platform behavior. The mobile bridge dispatches into the iOS /
/// Android plugin; desktop returns `UnsupportedPlatformError` and the
/// TS layer keeps the macOS-specific path going through the
/// top-level `show_lookup_popover` Tauri command (AppKit HUD).
#[command]
pub(crate) async fn show_lookup_popover<R: Runtime>(
app: AppHandle<R>,
payload: ShowLookupPopoverRequest,
) -> Result<ShowLookupPopoverResponse> {
app.native_bridge().show_lookup_popover(payload)
}
#[command]
pub(crate) async fn select_directory<R: Runtime>(
app: AppHandle<R>,
@@ -166,6 +166,19 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
/// Desktop has no mobile-style "system dictionary intent" surface;
/// macOS's HUD is invoked through a separate top-level Tauri
/// command (`show_lookup_popover` in `src/macos/system_dictionary.rs`),
/// and Linux/Windows have no native target. Return
/// UnsupportedPlatformError here so the TS layer doesn't
/// accidentally dispatch through the mobile plugin on desktop.
pub fn show_lookup_popover(
&self,
_payload: ShowLookupPopoverRequest,
) -> crate::Result<ShowLookupPopoverResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
@@ -76,6 +76,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_screen_brightness,
commands::get_external_sdcard_path,
commands::open_external_url,
commands::show_lookup_popover,
commands::select_directory,
commands::get_storefront_region_code,
commands::request_manage_storage_permission,
@@ -216,6 +216,17 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn show_lookup_popover(
&self,
payload: ShowLookupPopoverRequest,
) -> crate::Result<ShowLookupPopoverResponse> {
self.0
.run_mobile_plugin("show_lookup_popover", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn select_directory(&self) -> crate::Result<SelectDirectoryResponse> {
self.0
@@ -222,6 +222,36 @@ pub struct OpenExternalUrlResponse {
pub error: Option<String>,
}
/// Hand a word off to the platform's native dictionary surface.
///
/// On iOS this presents `UIReferenceLibraryViewController` modally
/// (the same UI Apple uses for `Look Up` in UIKit text views). On
/// Android it dispatches `ACTION_PROCESS_TEXT` so any installed
/// dictionary app (ColorDict, GoldenDict, 欧路, etc.) can handle the
/// word; we don't bind to a specific package so users can stick with
/// their preferred dictionary. Desktop platforms return
/// `UnsupportedPlatformError` — macOS goes through a separate native
/// command in `src/macos/system_dictionary.rs` that uses the AppKit
/// HUD surface, which doesn't exist on iOS/Android.
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowLookupPopoverRequest {
pub word: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowLookupPopoverResponse {
pub success: bool,
/// `unavailable` is set on Android when no app responded to the
/// `ACTION_PROCESS_TEXT` intent (i.e. the user has no dictionary
/// installed). The TS layer can surface a "no dictionary app"
/// hint without us having to push a localized string from
/// native code.
pub unavailable: Option<bool>,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectDirectoryResponse {