Files
readest/apps/readest-app/src-tauri/src/macos/system_dictionary.rs
T
loveheaven 05da6bdf43 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.
2026-05-19 07:03:52 +02:00

366 lines
14 KiB
Rust

/// macOS native dictionary "Look Up" HUD bridge.
///
/// Renders the small floating definition popover that the system shows
/// for the right-click → Look Up menu item, **without** raising
/// Dictionary.app to the foreground.
///
/// Background — why we DON'T use `NSPerformService("Look Up in
/// Dictionary", …)`: that service is registered by Dictionary.app
/// itself (`NSMessage = doLookupService` in its Info.plist) and its
/// implementation is "bring Dictionary.app to the front and show the
/// word", which is exactly the surface we want to avoid. The actual
/// inline HUD comes from a different AppKit entry point:
///
/// -[NSView showDefinitionForAttributedString:atPoint:]
///
/// We call it on the Tauri window's `contentView` (the WKWebView's
/// host view), positioning the HUD at the view's center. AppKit
/// shifts the popover to keep it on-screen, so the center is a safe
/// default when we can't get a per-selection position from the JS
/// side.
use std::sync::Mutex;
use block::ConcreteBlock;
use cocoa::base::{id, nil};
use cocoa::foundation::{NSPoint, NSRect, NSString};
use objc::runtime::Object;
use objc::{class, msg_send, sel, sel_impl};
use serde::Deserialize;
use tauri::Manager;
/// Optional positional hint forwarded from the JS bridge. Coordinates
/// are in the webview viewport's CSS-pixel space (origin top-left,
/// Y-down) and refer to the **bottom-left baseline** of the selection
/// — that is the same anchor AppKit's
/// `-[NSView showDefinitionForAttributedString:atPoint:]` interprets
/// `atPoint` as, so the HUD label that AppKit re-draws lands on top
/// of the original word instead of being shifted to the right.
///
/// `scale` is the JS `window.devicePixelRatio`. NSView's
/// `showDefinitionForAttributedString:atPoint:` takes view-local
/// points, which on standard Tauri/macOS already match CSS pixels
/// (the WKWebView reports its bounds in points, and the JS viewport
/// is sized in those same points). So `scale` is informational for
/// future tuning — we currently ignore it and treat the JS rect as
/// if already in points. Logged in `info!` so we can revisit if we
/// see drift on Retina hosts.
///
/// `font_size`, `font_family` and `color` describe the typography of
/// the underlying paragraph so we can build an NSAttributedString
/// that visually matches the original word. Without them AppKit
/// falls back to the default 13 pt system font, which is what made
/// our HUD look like a tiny sticky note next to the selection.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LookupAnchor {
x: f64,
y: f64,
#[serde(default = "default_scale")]
scale: f64,
#[serde(default)]
font_size: Option<f64>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
color: Option<String>,
}
fn default_scale() -> f64 {
1.0
}
#[tauri::command]
pub fn show_lookup_popover(
app: tauri::AppHandle,
word: String,
window_label: Option<String>,
anchor: Option<LookupAnchor>,
) -> Result<(), String> {
let trimmed = word.trim();
if trimmed.is_empty() {
return Err("empty word".into());
}
// Resolve the target Tauri window. The annotator passes its own
// label (typically `main` or `reader-<id>`); fall back to `main`
// if absent so manual invocations from devtools still work.
let label = window_label.as_deref().unwrap_or("main");
let window = app
.get_webview_window(label)
.or_else(|| app.get_webview_window("main"))
.ok_or_else(|| format!("window not found: {label}"))?;
let ns_window_ptr = window.ns_window().map_err(|e| e.to_string())? as *mut Object;
let ns_window = ThreadSafeNSWindow(ns_window_ptr);
let owned = trimmed.to_owned();
run_on_main_thread(move || unsafe {
// Move the wrapper itself into the closure so the auto-trait
// checker uses our `Send` impl, then unwrap the pointer.
let ns_window = ns_window;
show_definition_for_word(ns_window.0, &owned, anchor.as_ref());
});
Ok(())
}
/// SAFETY: must be called on the main thread; `ns_window` must point
/// to a live NSWindow.
unsafe fn show_definition_for_word(
ns_window: *mut Object,
word: &str,
anchor: Option<&LookupAnchor>,
) {
if ns_window.is_null() {
log::warn!("[system_dictionary] ns_window is null; skipping HUD");
return;
}
// Resolve `contentView` — a plain NSView that hosts the WKWebView
// and whose `showDefinitionForAttributedString:atPoint:` we can
// call. The view exists for the lifetime of the window, so the
// pointer is safe to use synchronously here.
let content_view: *mut Object = msg_send![ns_window, contentView];
if content_view.is_null() {
log::warn!("[system_dictionary] contentView is null; skipping HUD");
return;
}
let word_ns: id = NSString::alloc(nil).init_str(word);
let attributed = build_attributed_string(word_ns, anchor);
if attributed.is_null() {
log::warn!("[system_dictionary] failed to build NSAttributedString");
return;
}
let bounds: NSRect = msg_send![content_view, bounds];
// Map JS viewport coords (origin top-left, Y-down) into NSView
// coords (origin bottom-left, Y-up). When no anchor is supplied,
// fall back to the contentView's center.
let point = match anchor {
Some(a) => {
// Clamp to view bounds so an off-page selection (e.g. user
// scrolled away before the command landed) doesn't push
// the HUD outside its host view.
let x =
a.x.clamp(bounds.origin.x, bounds.origin.x + bounds.size.width);
let y_topdown =
a.y.clamp(bounds.origin.y, bounds.origin.y + bounds.size.height);
let y = bounds.origin.y + bounds.size.height - y_topdown;
log::debug!(
"[system_dictionary] anchor=({:.1},{:.1}) scale={:.2} → ns=({:.1},{:.1})",
a.x,
a.y,
a.scale,
x,
y
);
NSPoint::new(x, y)
}
None => NSPoint::new(
bounds.origin.x + bounds.size.width / 2.0,
bounds.origin.y + bounds.size.height / 2.0,
),
};
let _: () =
msg_send![content_view, showDefinitionForAttributedString: attributed atPoint: point];
// Release our retain on the attributed string. `alloc/init`
// returns a +1 retained instance and we hold no further reference.
let _: () = msg_send![attributed, release];
}
/// Build the `NSMutableAttributedString` we hand to AppKit. When the
/// JS side supplies a `font_size` / `font_family` / `color`, we mirror
/// them via NSFont / NSForegroundColor attributes so the small label
/// AppKit re-renders matches the underlying paragraph. Without these
/// attributes AppKit defaults to 13 pt system font and the HUD label
/// looks visibly smaller than the original word — exactly the bug
/// observed before this helper existed.
///
/// SAFETY: must run on the main thread; `word_ns` must be a valid
/// `NSString*` (typically from `NSString::alloc(nil).init_str(...)`).
/// Returns a `+1` retained `NSMutableAttributedString*` — caller is
/// responsible for releasing it.
unsafe fn build_attributed_string(word_ns: id, anchor: Option<&LookupAnchor>) -> *mut Object {
let mutable_class = class!(NSMutableAttributedString);
let attributed: *mut Object = msg_send![mutable_class, alloc];
let attributed: *mut Object = msg_send![attributed, initWithString: word_ns];
if attributed.is_null() {
return attributed;
}
// Apply font + colour attributes when we have hints. Skipping the
// attribute when the corresponding hint is missing lets AppKit
// fall back to its own defaults (still better than a blank label).
let length: cocoa::foundation::NSUInteger = msg_send![attributed, length];
let full_range = cocoa::foundation::NSRange::new(0, length);
if let Some(a) = anchor {
let font_size = a.font_size.filter(|v| v.is_finite() && *v > 0.0);
if let Some(size) = font_size {
// Compensate for the visual gap between a `font-size` in
// the WKWebView (CSS pixels) and an NSFont of the "same"
// point size: NSFont's ascender/descender metrics pack
// glyphs noticeably tighter than WebKit's default
// line-box layout, so a 16 pt NSFont renders ~85% of the
// body text's apparent size. Bumping by ~1.15 brings the
// HUD label back in line with the underlying paragraph.
//
// We do NOT divide by `devicePixelRatio` here — both CSS
// pixels and NSView points are 1:1 on macOS regardless of
// Retina (Retina just means 1 point = 2 device pixels for
// both sides), so a /scale would actually *halve* the
// font on Retina hosts.
const NSFONT_TO_CSS_PX: f64 = 0.9;
let scaled = size * NSFONT_TO_CSS_PX;
let font: id = if let Some(name) = a.font_family.as_deref().and_then(first_family) {
let name_ns: id = NSString::alloc(nil).init_str(&name);
let font: id = msg_send![class!(NSFont), fontWithName: name_ns size: scaled];
let _: () = msg_send![name_ns, release];
if font.is_null() {
msg_send![class!(NSFont), systemFontOfSize: scaled]
} else {
font
}
} else {
msg_send![class!(NSFont), systemFontOfSize: scaled]
};
if !font.is_null() {
let key: id = NSString::alloc(nil).init_str("NSFont");
let _: () = msg_send![attributed, addAttribute: key value: font range: full_range];
let _: () = msg_send![key, release];
}
}
if let Some(color_hex) = a.color.as_deref().and_then(parse_css_color_to_rgba) {
let (r, g, b, alpha) = color_hex;
let color: id = msg_send![
class!(NSColor),
colorWithSRGBRed: r as f64
green: g as f64
blue: b as f64
alpha: alpha as f64
];
if !color.is_null() {
let key: id = NSString::alloc(nil).init_str("NSColor");
let _: () = msg_send![attributed, addAttribute: key value: color range: full_range];
let _: () = msg_send![key, release];
}
}
}
attributed
}
/// Pick the first font-family token from a CSS `font-family` stack
/// (e.g. `"Source Han Serif", "Noto Serif CJK SC", serif`) and strip
/// its surrounding quotes / whitespace. Returns `None` for the
/// generic-family-only case (`serif`, `sans-serif`, …) so we fall
/// back to the system font instead of asking AppKit to look up a
/// face by an alias it doesn't understand.
fn first_family(stack: &str) -> Option<String> {
for raw in stack.split(',') {
let trimmed = raw.trim().trim_matches('"').trim_matches('\'').trim();
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_ascii_lowercase();
if matches!(
lower.as_str(),
"serif"
| "sans-serif"
| "monospace"
| "cursive"
| "fantasy"
| "system-ui"
| "ui-serif"
| "ui-sans-serif"
| "ui-monospace"
| "ui-rounded"
| "math"
| "emoji"
| "fangsong"
) {
return None;
}
return Some(trimmed.to_owned());
}
None
}
/// Parse a subset of CSS color forms into normalized sRGB
/// `(r, g, b, a)` values in `[0, 1]`. Supports `rgb(r, g, b)` and
/// `rgba(r, g, b, a)` — the only forms `getComputedStyle` returns
/// for the `color` property in modern WebKit, so we don't need a
/// full CSS-color parser here.
fn parse_css_color_to_rgba(input: &str) -> Option<(f32, f32, f32, f32)> {
let trimmed = input.trim();
let inner = trimmed
.strip_prefix("rgba(")
.or_else(|| trimmed.strip_prefix("rgb("))
.and_then(|s| s.strip_suffix(')'))?;
let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
if parts.len() < 3 {
return None;
}
let r = parts[0].parse::<f32>().ok()? / 255.0;
let g = parts[1].parse::<f32>().ok()? / 255.0;
let b = parts[2].parse::<f32>().ok()? / 255.0;
let a = if parts.len() >= 4 {
parts[3].parse::<f32>().ok()?
} else {
1.0
};
Some((
r.clamp(0.0, 1.0),
g.clamp(0.0, 1.0),
b.clamp(0.0, 1.0),
a.clamp(0.0, 1.0),
))
}
/// Thread-safety wrapper for an `NSWindow*`. Tauri's command handler
/// thread isn't necessarily the main thread, so we hand the pointer
/// through to the main-thread block via this wrapper. Use only with
/// pointers obtained from `Window::ns_window()` while the window is
/// still alive.
struct ThreadSafeNSWindow(*mut Object);
unsafe impl Send for ThreadSafeNSWindow {}
unsafe impl Sync for ThreadSafeNSWindow {}
/// Run `f` on the main thread. Synchronously when already on the main
/// thread; otherwise enqueued on `NSOperationQueue mainQueue`. AppKit
/// requires NSView calls (and most NSWindow accessors) to run on the
/// main thread.
fn run_on_main_thread<F>(f: F)
where
F: FnOnce() + Send + 'static,
{
unsafe {
// The cocoa crate exposes `-[NSThread isMainThread]` as Rust
// `bool`, not the C BOOL — match that here.
let is_main: bool = msg_send![class!(NSThread), isMainThread];
if is_main {
f();
return;
}
// `ConcreteBlock` requires the closure to be `Fn`, but we
// need `FnOnce` semantics for the move-out. Wrap in a
// `Mutex<Option<F>>` so the block body can take it on first
// invocation; NSOperationQueue only runs the block once, so
// the unwrap-or-skip pattern is safe.
let once = Mutex::new(Some(f));
let block = ConcreteBlock::new(move || {
if let Ok(mut guard) = once.lock() {
if let Some(callable) = guard.take() {
callable();
}
}
});
let block = block.copy();
let queue: *mut Object = msg_send![class!(NSOperationQueue), mainQueue];
let _: () = msg_send![queue, addOperationWithBlock: &*block];
}
}