use std::borrow::Cow;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::ffi::{CStr, CString, c_void};
use std::fmt::{self, Write as _};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::ptr::{self, NonNull};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use objc2::encode::{Encode, EncodeArguments, EncodeReturn};
use objc2::rc::Retained;
use objc2::runtime::{
AnyClass, AnyObject, Bool, Imp, Method, MethodImplementation, Sel, VerificationError,
};
use objc2::{MainThreadMarker, msg_send, sel};
use objc2_app_kit::{
NSApplication, NSEvent, NSEventType, NSResponder, NSTextAlternativesAttributeName,
NSTextInputContextKeyboardSelectionDidChangeNotification, NSView,
};
use objc2_core_foundation::{CFArray, CFDictionary, CFRetained, CFString, CFType};
use objc2_foundation::{
NSArray, NSAttributedString, NSNotification, NSNotificationCenter, NSPoint, NSRange, NSRect,
NSSize, NSString,
};
use raw_window_handle::RawWindowHandle;
use teksilo_canvas::Point as WindowPoint;
use teksilo_canvas::Rect as WindowRect;
use teksilo_core::raw_handle::ParentHandle;
use teksilo_core::text_input_document::TextInputDocument;
use teksilo_core::text_surface::TextSurfaces;
use teksilo_core::window::TeksiloWindowId;
use teksilo_core::{AppEventPoster, ImeOrigin};
use super::answers::{self, NOT_FOUND, Substring, Utf16Range};
use super::composition::MarkedReplacement;
use super::dictation::{DictationEvent, DictationSession};
use super::key_press::KeyPress;
use super::left_behind::{self, Delivery, LeftBehind};
use super::winit_ime::{self, InKeyInsertion, WinitIme};
use super::{CommitSignals, ImeCommitPayload, TakenKey};
struct AttachedView {
window_id: TeksiloWindowId,
poster: Arc<dyn AppEventPoster>,
surfaces: TextSurfaces,
writing_tools: Cell<bool>,
dictation: Cell<DictationSession>,
marked_replacement: RefCell<MarkedReplacement>,
winit_ime: RefCell<WinitIme>,
}
thread_local! {
static VIEWS: RefCell<HashMap<usize, AttachedView>> = RefCell::new(HashMap::new());
static KEY_DOWN_DEPTH: Cell<u32> = const { Cell::new(0) };
static FINISHING: Cell<bool> = const { Cell::new(false) };
static LEFT_BEHIND: RefCell<LeftBehind> = RefCell::new(LeftBehind::default());
static SOURCE_CHANGED_IN_KEY: Cell<bool> = const { Cell::new(false) };
static KEY_PRESSES: RefCell<Vec<(usize, KeyPress)>> = const { RefCell::new(Vec::new()) };
static PROBED: RefCell<HashSet<Sel>> = RefCell::new(HashSet::new());
static LAST_LINE: RefCell<(String, u32)> = const { RefCell::new((String::new(), 0)) };
}
pub(super) fn attach(
window_id: TeksiloWindowId,
parent: &ParentHandle,
poster: Arc<dyn AppEventPoster>,
surfaces: TextSurfaces,
) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let RawWindowHandle::AppKit(handle) = parent.raw_window_handle() else {
return;
};
let view = handle.ns_view.as_ptr() as usize;
VIEWS.with_borrow_mut(|views| {
views.insert(
view,
AttachedView {
window_id,
poster,
surfaces,
writing_tools: Cell::new(false),
dictation: Cell::new(DictationSession::Closed),
marked_replacement: RefCell::new(MarkedReplacement::default()),
winit_ime: RefCell::new(WinitIme::default()),
},
);
});
install(mtm);
}
pub(super) fn detach(window_id: TeksiloWindowId) {
let _ = VIEWS.try_with(|views| {
if let Ok(mut views) = views.try_borrow_mut() {
views.retain(|_, view| view.window_id != window_id);
}
});
}
fn with_attached<R>(view: *const AnyObject, f: impl FnOnce(&AttachedView) -> R) -> Option<R> {
VIEWS
.try_with(|views| {
let views = views.try_borrow().ok()?;
views.get(&(view as usize)).map(f)
})
.ok()
.flatten()
}
fn attached_window(view: *const AnyObject) -> Option<TeksiloWindowId> {
with_attached(view, |view| view.window_id)
}
fn ask_document<R>(
view: *mut AnyObject,
ask: impl FnOnce(&dyn TextInputDocument) -> Option<R>,
) -> Option<R> {
let surfaces = with_attached(view, |attached| attached.surfaces.clone())?;
catch_unwind(AssertUnwindSafe(|| {
surfaces.with_focused_input_document(ask).flatten()
}))
.ok()
.flatten()
}
fn guarded<R>(answer: impl FnOnce() -> R, fallback: impl FnOnce() -> R) -> R {
catch_unwind(AssertUnwindSafe(answer)).unwrap_or_else(|_| fallback())
}
#[derive(Clone, Copy)]
enum Source {
Document,
Winit,
NoDocument,
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Document => "from the document",
Self::Winit => "from winit",
Self::NoDocument => "no document",
})
}
}
fn ns_range(range: Utf16Range) -> NSRange {
NSRange::new(range.location, range.length)
}
fn utf16_range(range: NSRange) -> Utf16Range {
Utf16Range {
location: range.location,
length: range.length,
}
}
fn as_view<'a>(view: *mut AnyObject) -> Option<&'a NSView> {
unsafe { view.as_ref() }?.downcast_ref::<NSView>()
}
fn window_rect_to_screen(view: *mut AnyObject, rect: WindowRect) -> Option<NSRect> {
let view = as_view(view)?;
let window = view.window()?;
let rect = NSRect::new(
NSPoint::new(f64::from(rect.x), f64::from(rect.y)),
NSSize::new(f64::from(rect.width), f64::from(rect.height)),
);
Some(window.convertRectToScreen(view.convertRect_toView(rect, None)))
}
fn screen_point_to_window(view: *mut AnyObject, point: NSPoint) -> Option<WindowPoint> {
let view = as_view(view)?;
let window = view.window()?;
let point = view.convertPoint_fromView(window.convertPointFromScreen(point), None);
Some(WindowPoint::new(point.x as f32, point.y as f32))
}
#[derive(Clone, Copy)]
enum Slot {
KeyDown,
InsertText,
SetMarkedText,
UnmarkText,
HasMarkedText,
MarkedRange,
SelectedRange,
AttributedSubstring,
CharacterIndex,
FirstRect,
ValidAttributes,
DoCommand,
RespondsToSelector,
InterpretKeyEvents,
ViewShowWritingTools,
ViewStartDictation,
ViewCharacterPalette,
AppShowWritingTools,
AppStartDictation,
AppCharacterPalette,
AppStopDictation,
ContextDictationDidStart,
ContextDictationDidEnd,
ContextStopDictation,
ContextShowWritingTools,
ContextHandleEvent,
ContextSelectedKeyboardInputSource,
ContextActivate,
ContextDeactivate,
ContextDiscardMarkedText,
ContextInvalidateCharacterCoordinates,
}
impl Slot {
const fn index(self) -> usize {
self as usize
}
}
const SLOTS: usize = Slot::ContextInvalidateCharacterCoordinates.index() + 1;
static ORIGINALS: [OnceLock<Imp>; SLOTS] = [const { OnceLock::new() }; SLOTS];
static WINIT_VIEW: OnceLock<&'static AnyClass> = OnceLock::new();
static DICTATION_STARTS_REPORTED: AtomicBool = AtomicBool::new(false);
#[derive(Clone, Copy)]
enum Own {
HasMarkedText,
UnmarkText,
}
impl Own {
const fn index(self) -> usize {
self as usize
}
}
static OWN: [OnceLock<Imp>; 2] = [const { OnceLock::new() }; 2];
type ObjectArgFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject);
type VoidFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel);
type TwoObjectArgFn =
unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, *mut AnyObject);
type BoolFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> Bool;
type RangeFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> NSRange;
type ObjectFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> *mut AnyObject;
type InsertTextFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, NSRange);
type SetMarkedTextFn =
unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, NSRange, NSRange);
type SubstringFn =
unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRange, *mut NSRange) -> *mut AnyObject;
type IndexForPointFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSPoint) -> usize;
type FirstRectFn =
unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRange, *mut NSRange) -> NSRect;
type SelectorArgFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, Option<Sel>);
type RespondsFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, Option<Sel>) -> Bool;
type RectFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> NSRect;
type IntegerFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> isize;
type UnsignedFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel) -> usize;
type ObjectArgBoolFn = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> Bool;
macro_rules! call_original {
($slot:expr, $fn_type:ty, $fallback:expr, ($($arg:expr),* $(,)?)) => {
match ORIGINALS[$slot].get() {
Some(&original) => unsafe {
std::mem::transmute::<Imp, $fn_type>(original)($($arg),*)
},
None => $fallback,
}
};
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Install {
Always,
WhenTracing,
}
struct Hook {
install: Install,
selector: &'static CStr,
slot: usize,
replacement: Imp,
verify: fn(&AnyClass, Sel) -> Result<(), VerificationError>,
}
fn verifier<F: MethodImplementation>(_: F) -> fn(&AnyClass, Sel) -> Result<(), VerificationError> {
|class, selector| class.verify_sel::<F::Arguments, F::Return>(selector)
}
macro_rules! hook {
($install:ident, $selector:expr, $slot:expr, $replacement:expr, $fn_type:ty) => {{
let replacement: $fn_type = $replacement;
Hook {
install: Install::$install,
selector: $selector,
slot: $slot.index(),
replacement: unsafe { std::mem::transmute::<$fn_type, Imp>(replacement) },
verify: verifier(replacement),
}
}};
}
macro_rules! action_hook {
($selector:expr, $slot:ident, $wrapper:ident) => {
hook!(
WhenTracing,
$selector,
Slot::$slot,
$wrapper::<{ Slot::$slot.index() }>,
ObjectArgFn
)
};
}
macro_rules! context_hook {
($selector:expr, $slot:ident) => {
hook!(
WhenTracing,
$selector,
Slot::$slot,
context_call::<{ Slot::$slot.index() }>,
VoidFn
)
};
}
fn view_hooks() -> [Hook; 17] {
[
hook!(Always, c"keyDown:", Slot::KeyDown, key_down, ObjectArgFn),
hook!(
Always,
c"interpretKeyEvents:",
Slot::InterpretKeyEvents,
interpret_key_events,
ObjectArgFn
),
hook!(
Always,
c"insertText:replacementRange:",
Slot::InsertText,
insert_text,
InsertTextFn
),
hook!(
Always,
c"setMarkedText:selectedRange:replacementRange:",
Slot::SetMarkedText,
set_marked_text,
SetMarkedTextFn
),
hook!(Always, c"unmarkText", Slot::UnmarkText, unmark_text, VoidFn),
hook!(
WhenTracing,
c"hasMarkedText",
Slot::HasMarkedText,
has_marked_text,
BoolFn
),
hook!(
Always,
c"markedRange",
Slot::MarkedRange,
marked_range,
RangeFn
),
hook!(
Always,
c"selectedRange",
Slot::SelectedRange,
selected_range,
RangeFn
),
hook!(
Always,
c"attributedSubstringForProposedRange:actualRange:",
Slot::AttributedSubstring,
attributed_substring,
SubstringFn
),
hook!(
Always,
c"characterIndexForPoint:",
Slot::CharacterIndex,
character_index_for_point,
IndexForPointFn
),
hook!(
Always,
c"firstRectForCharacterRange:actualRange:",
Slot::FirstRect,
first_rect,
FirstRectFn
),
hook!(
WhenTracing,
c"validAttributesForMarkedText",
Slot::ValidAttributes,
valid_attributes,
ObjectFn
),
hook!(
Always,
c"doCommandBySelector:",
Slot::DoCommand,
do_command_by_selector,
SelectorArgFn
),
hook!(
WhenTracing,
c"respondsToSelector:",
Slot::RespondsToSelector,
responds_to_selector,
RespondsFn
),
action_hook!(c"showWritingTools:", ViewShowWritingTools, view_action),
action_hook!(c"startDictation:", ViewStartDictation, view_action),
action_hook!(
c"orderFrontCharacterPalette:",
ViewCharacterPalette,
view_action
),
]
}
fn app_hooks() -> [Hook; 4] {
[
hook!(
Always,
c"showWritingTools:",
Slot::AppShowWritingTools,
app_show_writing_tools,
ObjectArgFn
),
hook!(
Always,
c"startDictation:",
Slot::AppStartDictation,
app_start_dictation,
ObjectArgFn
),
action_hook!(
c"orderFrontCharacterPalette:",
AppCharacterPalette,
app_action
),
hook!(
Always,
c"stopDictation:",
Slot::AppStopDictation,
app_stop_dictation,
ObjectArgFn
),
]
}
struct Addition {
selector: &'static CStr,
implementation: Imp,
types: CString,
verify: fn(&AnyClass, Sel) -> Result<(), VerificationError>,
}
fn type_encoding_of<F: MethodImplementation>(_: F) -> CString {
let mut types = format!(
"{}{}{}",
<F::Return as EncodeReturn>::ENCODING_RETURN,
<*mut AnyObject as Encode>::ENCODING,
<Sel as Encode>::ENCODING
);
for encoding in <F::Arguments as EncodeArguments>::ENCODINGS {
let _ = write!(types, "{encoding}");
}
CString::new(types).unwrap_or_default()
}
macro_rules! addition {
($selector:expr, $implementation:expr, $fn_type:ty) => {{
let implementation: $fn_type = $implementation;
Addition {
selector: $selector,
implementation: unsafe { std::mem::transmute::<$fn_type, Imp>(implementation) },
types: type_encoding_of(implementation),
verify: verifier(implementation),
}
}};
}
fn view_additions() -> [Addition; 6] {
[
addition!(c"documentVisibleRect", document_visible_rect, RectFn),
addition!(
c"unionRectInVisibleSelectedRange",
union_rect_in_visible_selected_range,
RectFn
),
addition!(c"isEditable", is_editable, BoolFn),
addition!(c"writingToolsBehavior", writing_tools_behavior, IntegerFn),
addition!(
c"allowsWritingToolsAffordance",
allows_writing_tools_affordance,
BoolFn
),
addition!(
c"allowedWritingToolsResultOptions",
allowed_writing_tools_result_options,
UnsignedFn
),
]
}
fn add_method(class: &AnyClass, addition: Addition) {
let selector = Sel::register(addition.selector);
let class_name = class.name().to_string_lossy();
if let Some(existing) = class.instance_method(selector) {
if existing.implementation() as usize != addition.implementation as usize {
let encoding = type_encoding(existing);
trace_args(format_args!(
"install: {class_name} already has {selector} ({encoding}), not added"
));
}
return;
}
let types: &'static CStr = Box::leak(addition.types.into_boxed_c_str());
let added = unsafe {
objc2::ffi::class_addMethod(
class as *const AnyClass as *mut AnyClass,
selector,
addition.implementation,
types.as_ptr(),
)
};
if !added.as_bool() {
trace_args(format_args!(
"install: {class_name} {selector}: class_addMethod refused, not added"
));
return;
}
match (addition.verify)(class, selector) {
Ok(()) => trace_args(format_args!(
"install: {class_name} {selector} added ({})",
types.to_string_lossy()
)),
Err(error) => trace_args(format_args!(
"install: {class_name} {selector} added, but its signature does not check ({error})"
)),
}
}
fn context_hooks() -> [Hook; 10] {
[
hook!(
Always,
c"_dictationDidStart:",
Slot::ContextDictationDidStart,
context_dictation_did_start,
ObjectArgFn
),
hook!(
Always,
c"_dictationDidEnd:",
Slot::ContextDictationDidEnd,
context_dictation_did_end,
ObjectArgFn
),
action_hook!(c"_stopDictation:", ContextStopDictation, context_action),
hook!(
WhenTracing,
c"showWritingTools:forDelegate:",
Slot::ContextShowWritingTools,
context_show_writing_tools,
TwoObjectArgFn
),
hook!(
WhenTracing,
c"handleEvent:",
Slot::ContextHandleEvent,
context_handle_event,
ObjectArgBoolFn
),
hook!(
WhenTracing,
c"selectedKeyboardInputSource",
Slot::ContextSelectedKeyboardInputSource,
context_selected_keyboard_input_source,
ObjectFn
),
context_hook!(c"activate", ContextActivate),
context_hook!(c"deactivate", ContextDeactivate),
context_hook!(c"discardMarkedText", ContextDiscardMarkedText),
context_hook!(
c"invalidateCharacterCoordinates",
ContextInvalidateCharacterCoordinates
),
]
}
fn install(mtm: MainThreadMarker) {
static INSTALLED: AtomicBool = AtomicBool::new(false);
if INSTALLED.load(Ordering::Relaxed) {
return;
}
let Some(view_class) = AnyClass::get(c"WinitView") else {
trace("install: no WinitView class yet, nothing hooked");
return;
};
INSTALLED.store(true, Ordering::Relaxed);
let _ = WINIT_VIEW.set(view_class);
record_own(
view_class,
Own::HasMarkedText,
c"hasMarkedText",
|class, selector| class.verify_sel::<(), Bool>(selector),
);
record_own(
view_class,
Own::UnmarkText,
c"unmarkText",
|class, selector| class.verify_sel::<(), ()>(selector),
);
let tracing = super::trace_enabled();
let wanted = |hook: &&Hook| tracing || hook.install == Install::Always;
for hook in view_hooks().iter().filter(wanted) {
install_hook(view_class, hook);
}
for addition in view_additions() {
add_method(view_class, addition);
}
match AnyClass::get(c"NSApplication") {
Some(class) => app_hooks()
.iter()
.filter(wanted)
.for_each(|hook| install_hook(class, hook)),
None => trace("install: no NSApplication class"),
}
match AnyClass::get(c"NSTextInputContext") {
Some(class) => context_hooks()
.iter()
.filter(wanted)
.for_each(|hook| install_hook(class, hook)),
None => trace("install: no NSTextInputContext class"),
}
let did_start: ObjectArgFn = context_dictation_did_start;
let starts_reported = AnyClass::get(c"NSTextInputContext")
.and_then(|class| class.instance_method(sel!(_dictationDidStart:)))
.is_some_and(|method| method.implementation() as usize == did_start as usize);
DICTATION_STARTS_REPORTED.store(starts_reported, Ordering::Relaxed);
if !starts_reported {
trace("install: dictation starts are not reported; startDictation: opens the session");
}
observe_keyboard_selection(mtm);
if tracing {
trace_related_methods();
}
}
fn record_own(
class: &AnyClass,
own: Own,
selector: &CStr,
verify: fn(&AnyClass, Sel) -> Result<(), VerificationError>,
) {
let selector = Sel::register(selector);
let class_name = class.name().to_string_lossy();
let Some(method) = class.instance_method(selector) else {
trace_args(format_args!(
"install: {class_name} has no {selector}, not recorded"
));
return;
};
if let Err(error) = verify(class, selector) {
let encoding = type_encoding(method);
trace_args(format_args!(
"install: {class_name} {selector} is {encoding} ({error}), not recorded"
));
return;
}
let _ = OWN[own.index()].set(method.implementation());
}
fn install_hook(class: &AnyClass, hook: &Hook) {
let selector = Sel::register(hook.selector);
let class_name = class.name().to_string_lossy();
let Some(method) = class.instance_method(selector) else {
trace_args(format_args!(
"install: {class_name} has no {selector}, not hooked"
));
return;
};
let current = method.implementation();
if current as usize == hook.replacement as usize {
return;
}
if let Err(error) = (hook.verify)(class, selector) {
let encoding = type_encoding(method);
trace_args(format_args!(
"install: {class_name} {selector} is {encoding} ({error}), not hooked"
));
return;
}
let inherited = class
.superclass()
.and_then(|superclass| superclass.instance_method(selector))
.is_some_and(|inherited| ptr::eq(inherited, method));
if ORIGINALS[hook.slot].set(current).is_err() {
trace_args(format_args!(
"install: {class_name} {selector}: its slot is taken, not hooked"
));
return;
}
if inherited {
let added = unsafe {
objc2::ffi::class_addMethod(
class as *const AnyClass as *mut AnyClass,
selector,
hook.replacement,
objc2::ffi::method_getTypeEncoding(method),
)
};
let outcome = if added.as_bool() {
"hooked (an override of the inherited method)"
} else {
"not hooked (class_addMethod refused)"
};
trace_args(format_args!("install: {class_name} {selector} {outcome}"));
} else {
let replaced = unsafe { method.set_implementation(hook.replacement) };
let outcome = if replaced as usize == current as usize {
"hooked"
} else {
"hooked, but its implementation changed while being replaced"
};
trace_args(format_args!("install: {class_name} {selector} {outcome}"));
}
}
fn type_encoding(method: &Method) -> Cow<'_, str> {
let encoding = unsafe { objc2::ffi::method_getTypeEncoding(method) };
if encoding.is_null() {
return Cow::Borrowed("?");
}
unsafe { CStr::from_ptr(encoding) }.to_string_lossy()
}
fn trace_related_methods() {
const CLASSES: [&CStr; 4] = [
c"NSTextInputContext",
c"NSApplication",
c"NSResponder",
c"NSView",
];
const WORDS: [&str; 8] = [
"dictation",
"writingtools",
"characterpalette",
"autofill",
"inputsource",
"keyboardselection",
"handleevent",
"inputmode",
];
for name in CLASSES {
let Some(class) = AnyClass::get(name) else {
continue;
};
for method in class.instance_methods().iter() {
let selector = method.name();
let lowered = selector.name().to_string_lossy().to_ascii_lowercase();
if !WORDS.iter().any(|word| lowered.contains(word)) {
continue;
}
trace_args(format_args!(
"related: {} {selector} {}",
name.to_string_lossy(),
type_encoding(method)
));
}
}
}
fn trace(message: &str) {
super::trace(format_args!("{message}"));
}
fn trace_args(args: fmt::Arguments<'_>) {
super::trace(args);
}
fn log(build: impl FnOnce(&mut String)) {
if !super::trace_enabled() {
return;
}
let _ = catch_unwind(AssertUnwindSafe(|| {
let mut line = String::new();
build(&mut line);
let repeated = LAST_LINE
.try_with(|last| {
let Ok(mut last) = last.try_borrow_mut() else {
return Some(0);
};
let (last_line, repeats) = &mut *last;
if *last_line == line {
*repeats += 1;
return None;
}
let earlier = *repeats;
*last_line = line.clone();
*repeats = 0;
Some(earlier)
})
.unwrap_or(Some(0));
match repeated {
None => {}
Some(0) => super::trace(format_args!("{line}")),
Some(earlier) => {
super::trace(format_args!("(the line before, {earlier} more times)"));
super::trace(format_args!("{line}"));
}
}
}));
}
struct KeyDownScope {
pushed: bool,
}
impl KeyDownScope {
fn enter(view: *mut AnyObject) -> Self {
let _ = KEY_DOWN_DEPTH.try_with(|depth| depth.set(depth.get().saturating_add(1)));
let pushed = KEY_PRESSES
.try_with(|presses| {
presses
.try_borrow_mut()
.map(|mut presses| presses.push((view as usize, KeyPress::default())))
.is_ok()
})
.unwrap_or(false);
Self { pushed }
}
}
impl Drop for KeyDownScope {
fn drop(&mut self) {
let _ = KEY_DOWN_DEPTH.try_with(|depth| depth.set(depth.get().saturating_sub(1)));
if self.pushed {
let _ = KEY_PRESSES.try_with(|presses| {
if let Ok(mut presses) = presses.try_borrow_mut() {
presses.pop();
}
});
}
}
}
fn key_down_depth() -> u32 {
KEY_DOWN_DEPTH.try_with(Cell::get).unwrap_or(0)
}
fn with_key_press<R>(f: impl FnOnce(usize, &mut KeyPress) -> R) -> Option<R> {
KEY_PRESSES
.try_with(|presses| {
let mut presses = presses.try_borrow_mut().ok()?;
let (view, press) = presses.last_mut()?;
Some(f(*view, press))
})
.ok()
.flatten()
}
fn note_input_method_composed() {
let _ = with_key_press(|_, press| press.note_composed());
}
fn note_input_method_commanded() {
let _ = with_key_press(|_, press| press.note_commanded());
}
fn in_key_insertion(
view: *mut AnyObject,
text: *mut AnyObject,
) -> Option<(InKeyInsertion, String)> {
if key_down_depth() == 0 {
return None;
}
let marked = view_has_marked_text(view) == Some(true);
let string = read_inserted(text)
.map(|(text, _)| text)
.unwrap_or_default();
let insertion = with_attached(view, |attached| {
attached
.winit_ime
.try_borrow()
.ok()
.map(|ime| ime.insertion(marked, &string))
})
.flatten()
.unwrap_or(WinitIme::default().insertion(marked, &string));
Some((insertion, string))
}
fn note_input_method_inserted(insertion: Option<&(InKeyInsertion, String)>) {
let Some((insertion, string)) = insertion else {
return;
};
let control = string.chars().next().is_some_and(char::is_control);
let _ = with_key_press(|_, press| match insertion {
InKeyInsertion::WinitCommits | InKeyInsertion::HookCommits => press.note_committed(),
InKeyInsertion::Types if control => press.note_typed(""),
InKeyInsertion::Types => press.note_typed(string),
});
}
fn selected_source(view: *mut AnyObject) -> Option<String> {
let context: Option<Retained<AnyObject>> =
unsafe { view.as_ref() }.and_then(|view| unsafe { msg_send![view, inputContext] });
let source = selected_source_untraced(object_ptr(context.as_deref()));
unsafe { source.cast::<NSString>().as_ref() }.map(|source| source.to_string())
}
fn key_event_characters(events: *mut AnyObject) -> Option<String> {
let events = unsafe { events.as_ref() }?.downcast_ref::<NSArray>()?;
let first = events.iter().next()?;
let event = first.downcast_ref::<NSEvent>()?;
let kind = event.r#type();
if kind != NSEventType::KeyDown && kind != NSEventType::KeyUp {
return None;
}
event.characters().map(|text| text.to_string())
}
pub(super) fn key_taken_by_input_method(window_id: TeksiloWindowId) -> Option<TakenKey> {
let (view, taken) = with_key_press(|view, press| (view, press.taken()))?;
if attached_window(view as *const AnyObject) != Some(window_id) {
return None;
}
let taken = taken?;
log(|line| {
let what = match &taken {
TakenKey::Swallowed => {
Cow::Borrowed("swallowed by the input method, its text is not typed")
}
TakenKey::Committed => {
Cow::Borrowed("answered by the input method's commit, it reaches no widget")
}
TakenKey::Substituted(text) => {
let mut what = String::from("typed by the input method as ");
write_escaped(&mut what, text, TEXT_LIMIT);
what.push_str(", in place of its characters");
Cow::Owned(what)
}
};
let _ = write!(
line,
"keyDown: {what} {}",
Context::of(view as *mut AnyObject)
);
});
Some(taken)
}
fn is_kind_of(object: &AnyObject, class: &AnyClass) -> bool {
unsafe { msg_send![object, isKindOfClass: class] }
}
fn view_has_marked_text(view: *mut AnyObject) -> Option<bool> {
let marked = original_has_marked_text(view)?;
if !marked {
return Some(false);
}
let left_over = with_key_press(|_, press| press.marked_text_is_left_over()).unwrap_or(false);
if !left_over {
return Some(true);
}
let composing = ask_document(view, |document| {
Some(document.composition_range().is_some())
});
Some(composing.unwrap_or(true))
}
fn original_has_marked_text(view: *mut AnyObject) -> Option<bool> {
attached_window(view)?;
let winit_view = WINIT_VIEW.get()?;
let object = unsafe { view.as_ref() }?;
if !is_kind_of(object, winit_view) {
return None;
}
let own = *OWN[Own::HasMarkedText.index()].get()?;
let marked = unsafe { std::mem::transmute::<Imp, BoolFn>(own)(view, sel!(hasMarkedText)) };
Some(marked.as_bool())
}
fn winit_unmark_text(view: *mut AnyObject) -> bool {
let Some(&own) = OWN[Own::UnmarkText.index()].get() else {
return false;
};
unsafe { std::mem::transmute::<Imp, VoidFn>(own)(view, sel!(unmarkText)) };
true
}
struct Context {
view: *mut AnyObject,
window: Option<TeksiloWindowId>,
depth: u32,
marked: Option<bool>,
event: Option<Option<NSEventType>>,
}
impl Context {
fn of(view: *mut AnyObject) -> Self {
Self::with_marked(view, original_has_marked_text(view))
}
fn with_marked(view: *mut AnyObject, marked: Option<bool>) -> Self {
let event = MainThreadMarker::new().map(|mtm| {
NSApplication::sharedApplication(mtm)
.currentEvent()
.map(|event| event.r#type())
});
Self {
view,
window: attached_window(view),
depth: key_down_depth(),
marked,
event,
}
}
}
impl fmt::Display for Context {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.view.is_null() {
f.write_str("view=nil")?;
} else {
write!(f, "view={:p}", self.view)?;
}
match self.window {
Some(window) => write!(f, " window={}", window.raw())?,
None => f.write_str(" window=unregistered")?,
}
write!(f, " depth={}", self.depth)?;
match self.marked {
Some(marked) => write!(f, " marked={marked}")?,
None => f.write_str(" marked=?")?,
}
match self.event {
None => f.write_str(" event=?"),
Some(None) => f.write_str(" event=none"),
Some(Some(kind)) => write!(f, " event={}", EventType(kind)),
}
}
}
struct EventType(NSEventType);
impl fmt::Display for EventType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self.0 {
NSEventType::LeftMouseDown => "LeftMouseDown",
NSEventType::LeftMouseUp => "LeftMouseUp",
NSEventType::RightMouseDown => "RightMouseDown",
NSEventType::RightMouseUp => "RightMouseUp",
NSEventType::MouseMoved => "MouseMoved",
NSEventType::LeftMouseDragged => "LeftMouseDragged",
NSEventType::RightMouseDragged => "RightMouseDragged",
NSEventType::MouseEntered => "MouseEntered",
NSEventType::MouseExited => "MouseExited",
NSEventType::KeyDown => "KeyDown",
NSEventType::KeyUp => "KeyUp",
NSEventType::FlagsChanged => "FlagsChanged",
NSEventType::AppKitDefined => "AppKitDefined",
NSEventType::SystemDefined => "SystemDefined",
NSEventType::ApplicationDefined => "ApplicationDefined",
NSEventType::Periodic => "Periodic",
NSEventType::CursorUpdate => "CursorUpdate",
NSEventType::ScrollWheel => "ScrollWheel",
NSEventType::OtherMouseDown => "OtherMouseDown",
NSEventType::OtherMouseUp => "OtherMouseUp",
NSEventType::OtherMouseDragged => "OtherMouseDragged",
NSEventType::Gesture => "Gesture",
NSEventType::Magnify => "Magnify",
NSEventType::Swipe => "Swipe",
NSEventType::Rotate => "Rotate",
NSEventType::BeginGesture => "BeginGesture",
NSEventType::EndGesture => "EndGesture",
NSEventType::Pressure => "Pressure",
other => return write!(f, "type{}", other.0),
};
f.write_str(name)
}
}
struct Range(NSRange);
impl fmt::Display for Range {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let NSRange { location, length } = self.0;
if location == NOT_FOUND {
if length == 0 {
f.write_str("none")
} else {
write!(f, "none+{length}")
}
} else {
write!(f, "{location}..{}", location.saturating_add(length))
}
}
}
struct OutRange(*mut NSRange);
impl fmt::Display for OutRange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match unsafe { self.0.as_ref() } {
Some(range) => write!(f, "{}", Range(*range)),
None => f.write_str("null"),
}
}
}
struct Index(usize);
impl fmt::Display for Index {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0 == NOT_FOUND {
f.write_str("none")
} else {
write!(f, "{}", self.0)
}
}
}
struct Point(NSPoint);
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({:.1}, {:.1})", self.0.x, self.0.y)
}
}
struct Rect(NSRect);
impl fmt::Display for Rect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let NSRect { origin, size } = self.0;
write!(
f,
"({:.1}, {:.1}) {:.1}x{:.1}",
origin.x, origin.y, size.width, size.height
)
}
}
const TEXT_LIMIT: usize = 400;
const VALUE_LIMIT: usize = 80;
const RUN_LIMIT: usize = 32;
fn write_escaped(out: &mut String, text: &str, limit: usize) {
if text.chars().count() > limit {
let cut: String = text.chars().take(limit).collect();
let _ = write!(out, "{cut:?}...");
} else {
let _ = write!(out, "{text:?}");
}
}
fn write_ns_string(out: &mut String, text: &NSString) {
write_escaped(out, &text.to_string(), TEXT_LIMIT);
let _ = write!(out, " len16={}", text.length());
}
fn class_name(object: &AnyObject) -> String {
object.class().name().to_string_lossy().into_owned()
}
fn write_text(out: &mut String, object: *mut AnyObject) {
let Some(object) = (unsafe { object.as_ref() }) else {
out.push_str("nil");
return;
};
if let Some(attributed) = object.downcast_ref::<NSAttributedString>() {
out.push_str("NSAttributedString ");
write_ns_string(out, &attributed.string());
out.push_str(" attrs=[");
write_attribute_runs(out, attributed);
out.push(']');
} else if let Some(text) = object.downcast_ref::<NSString>() {
out.push_str("NSString ");
write_ns_string(out, text);
} else {
out.push_str(&class_name(object));
}
}
fn write_attribute_runs(out: &mut String, text: &NSAttributedString) {
let length = text.length();
let mut index = 0;
let mut runs = 0;
while index < length {
if runs == RUN_LIMIT {
out.push_str(", ...");
break;
}
let mut run = NSRange::new(0, 0);
let attributes = unsafe { text.attributesAtIndex_effectiveRange(index, &mut run) };
if runs > 0 {
out.push_str(", ");
}
let _ = write!(out, "{}", Range(run));
let keys = attributes.allKeys();
if keys.is_empty() {
out.push_str(" (none)");
}
for key in keys.iter() {
let _ = write!(out, " {key}");
if let Some(value) = attributes.objectForKey(&key) {
write_attribute_value(out, &value);
}
}
runs += 1;
let end = run.location.saturating_add(run.length);
if end <= index {
break;
}
index = end;
}
}
fn write_attribute_value(out: &mut String, value: &AnyObject) {
let alternatives_class = AnyClass::get(c"NSTextAlternatives");
if alternatives_class.is_some_and(|class| is_kind_of(value, class)) {
let (primary, alternatives): (
Option<Retained<NSString>>,
Option<Retained<NSArray<NSString>>>,
) = unsafe {
(
msg_send![value, primaryString],
msg_send![value, alternativeStrings],
)
};
out.push_str("(primary=");
match primary {
Some(primary) => write_escaped(out, &primary.to_string(), TEXT_LIMIT),
None => out.push_str("nil"),
}
out.push_str(" alternatives=[");
for (n, alternative) in alternatives
.iter()
.flat_map(|array| array.iter())
.enumerate()
{
if n > 0 {
out.push_str(", ");
}
write_escaped(out, &alternative.to_string(), TEXT_LIMIT);
}
out.push_str("])");
} else {
let description: Option<Retained<NSString>> = unsafe { msg_send![value, description] };
let _ = write!(out, "<{}>=", class_name(value));
match description {
Some(description) => write_escaped(out, &description.to_string(), VALUE_LIMIT),
None => out.push_str("nil"),
}
}
}
fn write_string_array(out: &mut String, array: *mut AnyObject) {
let Some(object) = (unsafe { array.as_ref() }) else {
out.push_str("nil");
return;
};
let Some(array) = object.downcast_ref::<NSArray>() else {
out.push_str(&class_name(object));
return;
};
out.push('[');
for (n, item) in array.iter().enumerate() {
if n > 0 {
out.push_str(", ");
}
match item.downcast_ref::<NSString>() {
Some(text) => {
let _ = write!(out, "{text}");
}
None => out.push_str(&class_name(&item)),
}
}
out.push(']');
}
fn write_sender(out: &mut String, sender: *mut AnyObject) {
let Some(sender) = (unsafe { sender.as_ref() }) else {
out.push_str("nil");
return;
};
out.push_str(&class_name(sender));
let titled = AnyClass::get(c"NSMenuItem").is_some_and(|class| is_kind_of(sender, class));
let named = AnyClass::get(c"NSNotification").is_some_and(|class| is_kind_of(sender, class));
let label: Option<Retained<NSString>> = if titled {
unsafe { msg_send![sender, title] }
} else if named {
unsafe { msg_send![sender, name] }
} else {
None
};
if let Some(label) = label {
out.push(' ');
write_escaped(out, &label.to_string(), TEXT_LIMIT);
}
}
fn write_key_event(out: &mut String, event: *mut AnyObject) {
let Some(object) = (unsafe { event.as_ref() }) else {
out.push_str(" event=nil");
return;
};
let Some(event) = object.downcast_ref::<NSEvent>() else {
let _ = write!(out, " event={}", class_name(object));
return;
};
let kind = event.r#type();
if kind != NSEventType::KeyDown && kind != NSEventType::KeyUp {
let _ = write!(out, " event={}", EventType(kind));
return;
}
out.push_str(" chars=");
match event.characters() {
Some(text) => write_escaped(out, &text.to_string(), TEXT_LIMIT),
None => out.push_str("nil"),
}
out.push_str(" unmodified=");
match event.charactersIgnoringModifiers() {
Some(text) => write_escaped(out, &text.to_string(), TEXT_LIMIT),
None => out.push_str("nil"),
}
let _ = write!(
out,
" keyCode={} repeat={} flags={:#x}",
event.keyCode(),
event.isARepeat(),
event.modifierFlags().bits()
);
}
extern "C-unwind" fn key_down(this: *mut AnyObject, cmd: Sel, event: *mut AnyObject) {
{
let _scope = KeyDownScope::enter(this);
log(|line| {
let _ = write!(line, "keyDown: {} |", Context::of(this));
write_key_event(line, event);
});
let ended = with_attached(this, |view| view.writing_tools.replace(false)).unwrap_or(false);
if ended {
log(|line| {
let _ = write!(
line,
"keyDown: the Writing Tools session ends {}",
Context::of(this)
);
});
}
composition_over(this, "keyDown:");
guarded(|| follow_winit_source_check(this), || ());
call_original!(Slot::KeyDown.index(), ObjectArgFn, (), (this, cmd, event));
log(|line| {
let _ = write!(line, "keyDown: end {}", Context::of(this));
});
}
let source_changed = key_down_depth() == 0
&& SOURCE_CHANGED_IN_KEY
.try_with(|changed| changed.replace(false))
.unwrap_or(false);
if source_changed {
guarded(|| input_source_changed("keyDown: end"), || ());
}
}
fn follow_winit_source_check(view: *mut AnyObject) {
let Some(marked) = original_has_marked_text(view) else {
return;
};
let source = if marked { selected_source(view) } else { None };
let reset = with_attached(view, |attached| {
attached
.winit_ime
.try_borrow_mut()
.ok()
.map(|mut ime| ime.key_down(source.as_deref(), marked))
})
.flatten()
.unwrap_or(false);
if reset {
log(|line| {
let _ = write!(
line,
"keyDown: the input source changed while winit holds marked text: \
winit disables its IME and keeps the marked text {}",
Context::of(view)
);
});
}
}
extern "C-unwind" fn interpret_key_events(this: *mut AnyObject, cmd: Sel, events: *mut AnyObject) {
struct Interpreting;
impl Drop for Interpreting {
fn drop(&mut self) {
let _ = with_key_press(|_, press| press.end_interpreting());
}
}
let characters = guarded(|| key_event_characters(events), || None);
let _ = with_key_press(|_, press| {
press.note_characters(characters.as_deref());
press.begin_interpreting();
});
let interpreting = Interpreting;
log(|line| {
let _ = write!(line, "interpretKeyEvents: {}", Context::of(this));
});
call_original!(
Slot::InterpretKeyEvents.index(),
ObjectArgFn,
(),
(this, cmd, events)
);
drop(interpreting);
log(|line| {
let verdict = with_key_press(|_, press| press.taken());
let _ = write!(
line,
"interpretKeyEvents: end {} | {}",
Context::of(this),
match verdict {
Some(Some(TakenKey::Swallowed)) => {
"swallowed: the input method did nothing with the key"
}
Some(Some(TakenKey::Committed)) => {
"the input method acted: it committed, and handed nothing back"
}
Some(Some(TakenKey::Substituted(_))) => {
"the input method acted: it typed the key as other characters"
}
Some(None) => "the input method acted",
None => "outside a keyDown:",
}
);
});
}
extern "C-unwind" fn insert_text(
this: *mut AnyObject,
cmd: Sel,
text: *mut AnyObject,
replacement: NSRange,
) {
let insertion = guarded(|| in_key_insertion(this, text), || None);
note_input_method_inserted(insertion.as_ref());
if insertion.is_some() {
guarded(|| note_left_behind_acted(this), || ());
}
log(|line| {
let _ = write!(
line,
"insertText:replacementRange: {} | text=",
Context::of(this)
);
write_text(line, text);
let _ = write!(line, " replacement={}", Range(replacement));
write_stack(line);
});
let hook_commits = matches!(insertion, Some((InKeyInsertion::HookCommits, _)));
let taken = catch_unwind(AssertUnwindSafe(|| {
insert_out_of_band(this, text, replacement, hook_commits)
}))
.unwrap_or(Inserted::ByWinit);
if taken == Inserted::ByWinit {
call_original!(
Slot::InsertText.index(),
InsertTextFn,
(),
(this, cmd, text, replacement)
);
} else {
log(|line| {
let _ = write!(line, "insertText:replacementRange: -> {taken}");
});
}
if matches!(
insertion,
Some((
InKeyInsertion::WinitCommits | InKeyInsertion::HookCommits,
_
))
) {
let _ = with_key_press(|_, press| press.note_commit_through());
}
}
#[derive(Clone, PartialEq, Eq)]
enum Inserted {
ByWinit,
Control,
Empty,
Finished,
Again(Delivery),
Posted(ImeOrigin, Option<std::ops::Range<usize>>),
Written(ImeOrigin, std::ops::Range<usize>),
}
impl fmt::Display for Inserted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ByWinit => f.write_str("winit's own"),
Self::Control => f.write_str("dropped (a control character)"),
Self::Empty => f.write_str("nothing to post (empty)"),
Self::Finished => f.write_str("dropped (the field already kept the composition)"),
Self::Again(Delivery::MarkedAgain) => f.write_str(
"dropped (a composition kept at an input-source switch, marked again and \
delivered again; winit's unmarkText ended the mark)",
),
Self::Again(_) => f.write_str(
"dropped (a composition kept at an input-source switch, delivered again)",
),
Self::Posted(origin, None) => write!(f, "posted as {origin:?}"),
Self::Posted(origin, Some(chars)) => write!(
f,
"posted as {origin:?}, replacing characters {}..{}",
chars.start, chars.end
),
Self::Written(origin, chars) => write!(
f,
"written into the field as {origin:?}, characters {}..{}",
chars.start, chars.end
),
}
}
}
fn insert_out_of_band(
view: *mut AnyObject,
text: *mut AnyObject,
replacement: NSRange,
winit_would_drop: bool,
) -> Inserted {
if key_down_depth() > 0 && winit_would_drop {
log(|line| {
let _ = write!(
line,
"insertText:replacementRange: winit disabled its IME for an input-source change \
and would drop this commit: the hook commits it {}",
Context::of(view)
);
});
} else if key_down_depth() > 0 {
if replacement.location != NOT_FOUND {
log(|line| {
let _ = write!(
line,
"insertText:replacementRange: in-key replacement not handled: \
replacement={} left to winit, which ignores it",
Range(replacement)
);
});
}
return Inserted::ByWinit;
}
if FINISHING.try_with(Cell::get).unwrap_or(false) {
return Inserted::Finished;
}
let Some((window_id, poster, writing_tools, dictation)) = with_attached(view, |attached| {
(
attached.window_id,
Arc::clone(&attached.poster),
attached.writing_tools.get(),
attached.dictation.get().is_dictating(),
)
}) else {
return Inserted::ByWinit;
};
let Some((string, alternatives)) = read_inserted(text) else {
return Inserted::ByWinit;
};
if string.chars().next().is_some_and(char::is_control) {
return Inserted::Control;
}
let Some(had_marked_text) = original_has_marked_text(view) else {
return Inserted::ByWinit;
};
if key_down_depth() == 0 {
let delivery = LEFT_BEHIND
.try_with(|left| {
left.try_borrow_mut()
.map(|mut left| left.inserted_outside_key(&string, had_marked_text))
.unwrap_or(Delivery::New)
})
.unwrap_or(Delivery::New);
match delivery {
Delivery::New => {}
Delivery::Again => {
composition_over(view, "insertText:replacementRange:");
return Inserted::Again(delivery);
}
Delivery::MarkedAgain => {
if unmark_finishing(view) {
composition_over(view, "insertText:replacementRange:");
return Inserted::Again(delivery);
}
return Inserted::ByWinit;
}
}
}
if had_marked_text {
if !winit_unmark_text(view) {
return Inserted::ByWinit;
}
log(|line| {
let _ = write!(
line,
"insertText:replacementRange: ended the composition with winit's unmarkText {}",
Context::of(view)
);
});
}
let replacement = utf16_range(replacement);
let replacement_chars = replacement.to_range().and_then(|_| {
let chars = ask_document(view, |document| {
answers::replacement_chars(document, replacement)
});
if chars.is_none() {
log(|line| {
let _ = write!(
line,
"insertText:replacementRange: replacement={} not mapped: no document \
answered, the text replaces the selection",
Range(ns_range(replacement))
);
});
}
chars
});
if string.is_empty()
&& replacement_chars
.as_ref()
.is_none_or(|range| range.is_empty())
{
composition_over(view, "insertText:replacementRange:");
return Inserted::Empty;
}
let origin = super::classify(CommitSignals {
had_marked_text,
carries_text_alternatives: alternatives,
dictation_session: dictation,
writing_tools_session: writing_tools,
});
if !had_marked_text && key_down_depth() == 0 {
let surfaces = with_attached(view, |attached| attached.surfaces.clone());
let written = surfaces.map(|surfaces| {
catch_unwind(AssertUnwindSafe(|| {
super::write_half::write(&surfaces, &string, replacement_chars.clone(), origin)
}))
.unwrap_or(super::write_half::Written::No("a panic in the field"))
});
match written {
Some(super::write_half::Written::Yes(range)) => {
poster.post_external(Box::new(super::ImeWrittenPayload {
window_id_owner: window_id,
}));
composition_over(view, "insertText:replacementRange:");
return Inserted::Written(origin, range);
}
Some(super::write_half::Written::No(why)) => log(|line| {
let _ = write!(
line,
"insertText:replacementRange: not written ({why}), posted"
);
}),
None => {}
}
}
poster.post_external(Box::new(ImeCommitPayload {
window_id_owner: window_id,
text: string,
origin,
replacement: replacement_chars.clone(),
}));
composition_over(view, "insertText:replacementRange:");
Inserted::Posted(origin, replacement_chars)
}
fn record_marked_replacement(view: *mut AnyObject, replacement: NSRange) {
let replacement = utf16_range(replacement);
let chars = replacement.to_range().and_then(|_| {
let chars = ask_document(view, |document| {
answers::replacement_chars(document, replacement)
});
log(|line| {
let _ = write!(
line,
"setMarkedText:selectedRange:replacementRange: marked replacement={} ",
Range(ns_range(replacement))
);
match &chars {
Some(chars) => {
let _ = write!(line, "-> chars {}..{}", chars.start, chars.end);
}
None => line.push_str("not mapped: no document answered"),
}
});
chars
});
let _ = with_attached(view, |attached| {
if let Ok(mut slot) = attached.marked_replacement.try_borrow_mut() {
slot.record(chars);
}
});
}
fn view_of_window(window_id: TeksiloWindowId) -> Option<usize> {
VIEWS
.try_with(|views| {
let views = views.try_borrow().ok()?;
views
.iter()
.find(|(_, attached)| attached.window_id == window_id)
.map(|(&view, _)| view)
})
.ok()
.flatten()
}
pub(super) fn take_marked_replacement(
window_id: TeksiloWindowId,
) -> Option<std::ops::Range<usize>> {
let view = view_of_window(window_id)?;
let chars = with_attached(view as *const AnyObject, |attached| {
attached
.marked_replacement
.try_borrow_mut()
.ok()
.and_then(|mut slot| slot.take())
})
.flatten();
if let Some(chars) = &chars {
log(|line| {
let _ = write!(
line,
"take_marked_replacement: chars {}..{} go with the preedit {}",
chars.start,
chars.end,
Context::of(view as *mut AnyObject)
);
});
}
chars
}
pub(super) fn finish_composition(window_id: TeksiloWindowId) -> bool {
let Some(view) = view_of_window(window_id) else {
return false;
};
let view = view as *mut AnyObject;
let finished = guarded(
|| {
let had_marked_text = original_has_marked_text(view)?;
let left = had_marked_text
.then(|| composition_left_behind(view))
.flatten();
let unmarked = unmark_finishing(view);
if unmarked && let Some((text, source)) = left {
record_left_behind(view, &source, &text, "finish_composition");
}
Some((had_marked_text, unmarked))
},
|| None,
);
let Some((had_marked_text, true)) = finished else {
log(|line| {
let _ = write!(
line,
"finish_composition: not finished here, winit's IME is reset instead {}",
Context::of(view)
);
});
return false;
};
composition_over(view, "finish_composition");
log(|line| {
let _ = write!(
line,
"finish_composition: winit's unmarkText ended the composition \
(had marked text: {had_marked_text}) and the input method discarded its own {}",
Context::of(view)
);
});
true
}
fn unmark_finishing(view: *mut AnyObject) -> bool {
struct Finishing;
impl Drop for Finishing {
fn drop(&mut self) {
let _ = FINISHING.try_with(|finishing| finishing.set(false));
}
}
let _ = FINISHING.try_with(|finishing| finishing.set(true));
let _finishing = Finishing;
winit_unmark_text(view)
}
fn composition_left_behind(view: *mut AnyObject) -> Option<(String, String)> {
let marked = original_has_marked_text(view)?;
let selected = selected_source(view);
let ime = with_attached(view, |attached| {
attached.winit_ime.try_borrow().ok().map(|ime| ime.clone())
})
.flatten()?;
ime.left_behind(selected.as_deref(), marked, same_input_method_described)
.map(|(text, source)| (text.to_owned(), source.to_owned()))
}
fn same_input_method_described(composed_under: &str, selected: &str) -> bool {
let composed_method = input_method_of(composed_under);
let selected_method = input_method_of(selected);
let same = winit_ime::same_input_method(
composed_under,
composed_method.as_deref(),
selected,
selected_method.as_deref(),
);
log(|line| {
let _ = write!(
line,
"input source {selected:?} (a mode of {selected_method:?}) after {composed_under:?} \
(a mode of {composed_method:?}), which composed the marked text: {}",
if same {
"the same input method"
} else {
"another input method"
}
);
});
same
}
fn record_left_behind(view: *mut AnyObject, source: &str, text: &str, by: &str) {
let _ = LEFT_BEHIND.try_with(|left| {
if let Ok(mut left) = left.try_borrow_mut() {
left.kept(source, text);
}
});
log(|line| {
let _ = write!(
line,
"{by}: the composition was made with {source:?}, no longer selected, which may \
deliver it again: "
);
write_escaped(line, text, TEXT_LIMIT);
let _ = write!(line, " is dropped then {}", Context::of(view));
});
}
fn note_left_behind_acted(view: *mut AnyObject) {
let recorded = LEFT_BEHIND
.try_with(|left| left.try_borrow().is_ok_and(|left| !left.is_empty()))
.unwrap_or(false);
if !recorded {
return;
}
let source = selected_source(view);
let _ = LEFT_BEHIND.try_with(|left| {
if let Ok(mut left) = left.try_borrow_mut() {
left.input_method_acted(source.as_deref());
}
});
}
fn note_left_behind_marked(view: *mut AnyObject, text: &str) {
let held_before = original_has_marked_text(view).unwrap_or(false);
let again = LEFT_BEHIND
.try_with(|left| {
left.try_borrow_mut()
.is_ok_and(|mut left| left.marked_outside_key(text, held_before))
})
.unwrap_or(false);
if again {
log(|line| {
let _ = write!(
line,
"setMarkedText:selectedRange:replacementRange: marks again a composition kept at \
an input-source switch; its insertText: is dropped {}",
Context::of(view)
);
});
}
}
fn input_source_changed(by: &str) {
if key_down_depth() > 0 {
let _ = SOURCE_CHANGED_IN_KEY.try_with(|changed| changed.set(true));
log(|line| {
let _ = write!(
line,
"{by}: the input source changed in a key press, looked at once it is over"
);
});
return;
}
let responder = key_first_responder();
let view = responder_ptr(responder.as_deref());
if attached_window(view).is_some() {
keep_left_behind(view, by);
}
}
fn keep_left_behind(view: *mut AnyObject, by: &str) {
if original_has_marked_text(view) != Some(true) {
return;
}
let Some((marked, source)) = composition_left_behind(view) else {
log(|line| {
let _ = write!(
line,
"{by}: the composition's input method is still selected (selected {:?}), \
it goes on {}",
selected_source(view),
Context::of(view)
);
});
return;
};
let Some((window_id, poster, writing_tools, dictation)) = with_attached(view, |attached| {
(
attached.window_id,
Arc::clone(&attached.poster),
attached.writing_tools.get(),
attached.dictation.get().is_dictating(),
)
}) else {
return;
};
if !unmark_finishing(view) {
log(|line| {
let _ = write!(
line,
"{by}: winit's unmarkText is not recorded, the composition stays marked {}",
Context::of(view)
);
});
return;
}
let origin = super::classify(CommitSignals {
had_marked_text: true,
carries_text_alternatives: false,
dictation_session: dictation,
writing_tools_session: writing_tools,
});
let text = left_behind::as_committed(&marked, dictation);
poster.post_external(Box::new(ImeCommitPayload {
window_id_owner: window_id,
text: text.clone(),
origin,
replacement: None,
}));
log(|line| {
let _ = write!(
line,
"{by}: the input method left its composition marked; winit's unmarkText ended it \
and it is posted as {origin:?}: "
);
write_escaped(line, &text, TEXT_LIMIT);
if text != marked {
line.push_str(", shown as ");
write_escaped(line, &marked, TEXT_LIMIT);
}
let _ = write!(line, " {}", Context::of(view));
});
composition_over(view, by);
record_left_behind(view, &source, &text, by);
}
fn composition_over(view: *mut AnyObject, by: &str) {
set_dictation(view, DictationEvent::CompositionOver, by);
let _ = with_attached(view, |attached| {
if let Ok(mut slot) = attached.marked_replacement.try_borrow_mut() {
slot.clear();
}
});
}
fn read_inserted(text: *mut AnyObject) -> Option<(String, bool)> {
let object = unsafe { text.as_ref() }?;
if let Some(attributed) = object.downcast_ref::<NSAttributedString>() {
Some((
attributed.string().to_string(),
carries_text_alternatives(attributed),
))
} else {
object
.downcast_ref::<NSString>()
.map(|text| (text.to_string(), false))
}
}
fn carries_text_alternatives(text: &NSAttributedString) -> bool {
let key = unsafe { NSTextAlternativesAttributeName };
let length = text.length();
let mut index = 0;
while index < length {
let mut run = NSRange::new(0, 0);
let value = unsafe { text.attribute_atIndex_effectiveRange(key, index, &mut run) };
if value.is_some() {
return true;
}
let end = run.location.saturating_add(run.length);
index = if end > index { end } else { index + 1 };
}
false
}
extern "C-unwind" fn set_marked_text(
this: *mut AnyObject,
cmd: Sel,
text: *mut AnyObject,
selected: NSRange,
replacement: NSRange,
) {
note_input_method_composed();
log(|line| {
let _ = write!(
line,
"setMarkedText:selectedRange:replacementRange: {} | text=",
Context::of(this)
);
write_text(line, text);
let _ = write!(
line,
" selected={} replacement={}",
Range(selected),
Range(replacement)
);
write_stack(line);
});
guarded(|| record_marked_replacement(this, replacement), || ());
guarded(
|| {
let source = selected_source(this);
let string = read_inserted(text)
.map(|(text, _)| text)
.unwrap_or_default();
let _ = with_attached(this, |attached| {
if let Ok(mut ime) = attached.winit_ime.try_borrow_mut() {
ime.marked(source.as_deref(), &string);
}
});
if key_down_depth() > 0 {
note_left_behind_acted(this);
} else {
note_left_behind_marked(this, &string);
}
},
|| (),
);
call_original!(
Slot::SetMarkedText.index(),
SetMarkedTextFn,
(),
(this, cmd, text, selected, replacement)
);
let _ = catch_unwind(AssertUnwindSafe(|| {
if read_inserted(text).is_some_and(|(text, _)| text.is_empty()) {
composition_over(this, "setMarkedText:selectedRange:replacementRange:");
}
}));
}
extern "C-unwind" fn unmark_text(this: *mut AnyObject, cmd: Sel) {
note_input_method_composed();
log(|line| {
let _ = write!(line, "unmarkText {}", Context::of(this));
});
call_original!(Slot::UnmarkText.index(), VoidFn, (), (this, cmd));
let _ = catch_unwind(AssertUnwindSafe(|| composition_over(this, "unmarkText")));
}
extern "C-unwind" fn has_marked_text(this: *mut AnyObject, cmd: Sel) -> Bool {
let original = call_original!(Slot::HasMarkedText.index(), BoolFn, Bool::NO, (this, cmd));
let marked = match guarded(|| view_has_marked_text(this), || None) {
Some(false) if original.as_bool() => Bool::NO,
_ => original,
};
log(|line| {
let known = attached_window(this).map(|_| marked.as_bool());
let _ = write!(
line,
"hasMarkedText {} | -> {}",
Context::with_marked(this, known),
marked.as_bool()
);
});
marked
}
extern "C-unwind" fn marked_range(this: *mut AnyObject, cmd: Sel) -> NSRange {
let from_document = guarded(
|| {
let view_has_marked_text = view_has_marked_text(this)?;
ask_document(this, |document| {
answers::marked_range(document, view_has_marked_text)
})
},
|| None,
);
let (range, source) = match from_document {
Some(range) => (ns_range(range), Source::Document),
None => (
call_original!(
Slot::MarkedRange.index(),
RangeFn,
NSRange::new(NOT_FOUND, 0),
(this, cmd)
),
Source::Winit,
),
};
log(|line| {
let _ = write!(
line,
"markedRange {} | -> {} ({source})",
Context::of(this),
Range(range)
);
write_stack(line);
});
range
}
extern "C-unwind" fn selected_range(this: *mut AnyObject, cmd: Sel) -> NSRange {
let from_document = guarded(|| ask_document(this, answers::selected_range), || None);
let (range, source) = match from_document {
Some(range) => (ns_range(range), Source::Document),
None => (
call_original!(
Slot::SelectedRange.index(),
RangeFn,
NSRange::new(NOT_FOUND, 0),
(this, cmd)
),
Source::Winit,
),
};
log(|line| {
let _ = write!(
line,
"selectedRange {} | -> {} ({source})",
Context::of(this),
Range(range)
);
write_stack(line);
});
range
}
extern "C-unwind" fn attributed_substring(
this: *mut AnyObject,
cmd: Sel,
range: NSRange,
actual: *mut NSRange,
) -> *mut AnyObject {
let from_document = guarded(
|| {
ask_document(this, |document| {
answers::substring(document, utf16_range(range))
})
},
|| None,
);
let (substring, source) = match from_document {
Some(Substring::Nil) => (ptr::null_mut(), Source::Document),
Some(Substring::Text {
text,
actual: range,
}) => {
if let Some(actual) = unsafe { actual.as_mut() } {
*actual = ns_range(range);
}
let text = NSAttributedString::from_nsstring(&NSString::from_str(&text));
let text: *mut NSAttributedString = Retained::autorelease_return(text);
(text.cast::<AnyObject>(), Source::Document)
}
None => (
call_original!(
Slot::AttributedSubstring.index(),
SubstringFn,
ptr::null_mut(),
(this, cmd, range, actual)
),
Source::Winit,
),
};
log(|line| {
let _ = write!(
line,
"attributedSubstringForProposedRange:actualRange: {} | range={} actual={} -> ",
Context::of(this),
Range(range),
OutRange(actual)
);
write_text(line, substring);
let _ = write!(line, " ({source})");
});
substring
}
extern "C-unwind" fn character_index_for_point(
this: *mut AnyObject,
cmd: Sel,
point: NSPoint,
) -> usize {
let from_document = guarded(
|| {
let window_point = screen_point_to_window(this, point)?;
ask_document(this, |document| {
answers::character_index(document, window_point)
})
},
|| None,
);
let (index, source) = match from_document {
Some(index) => (index, Source::Document),
None => (
call_original!(
Slot::CharacterIndex.index(),
IndexForPointFn,
NOT_FOUND,
(this, cmd, point)
),
Source::Winit,
),
};
log(|line| {
let _ = write!(
line,
"characterIndexForPoint: {} | point={} -> {} ({source})",
Context::of(this),
Point(point),
Index(index)
);
});
index
}
extern "C-unwind" fn first_rect(
this: *mut AnyObject,
cmd: Sel,
range: NSRange,
actual: *mut NSRange,
) -> NSRect {
let from_document = guarded(
|| {
let (rect, covered) = ask_document(this, |document| {
answers::first_rect(document, utf16_range(range))
})?;
Some((window_rect_to_screen(this, rect)?, covered))
},
|| None,
);
let (rect, source) = match from_document {
Some((rect, covered)) => {
if let Some(actual) = unsafe { actual.as_mut() } {
*actual = ns_range(covered);
}
(rect, Source::Document)
}
None => (
call_original!(
Slot::FirstRect.index(),
FirstRectFn,
NSRect::ZERO,
(this, cmd, range, actual)
),
Source::Winit,
),
};
log(|line| {
let _ = write!(
line,
"firstRectForCharacterRange:actualRange: {} | range={} actual={} -> {} ({source})",
Context::of(this),
Range(range),
OutRange(actual),
Rect(rect)
);
});
rect
}
extern "C-unwind" fn valid_attributes(this: *mut AnyObject, cmd: Sel) -> *mut AnyObject {
let attributes = call_original!(
Slot::ValidAttributes.index(),
ObjectFn,
ptr::null_mut(),
(this, cmd)
);
log(|line| {
let _ = write!(
line,
"validAttributesForMarkedText {} | -> ",
Context::of(this)
);
write_string_array(line, attributes);
});
attributes
}
extern "C-unwind" fn do_command_by_selector(this: *mut AnyObject, cmd: Sel, command: Option<Sel>) {
note_input_method_commanded();
log(|line| {
let _ = write!(
line,
"doCommandBySelector: {} | command=",
Context::of(this)
);
match command {
Some(command) => {
let _ = write!(line, "{command}");
}
None => line.push_str("nil"),
}
});
call_original!(
Slot::DoCommand.index(),
SelectorArgFn,
(),
(this, cmd, command)
);
}
extern "C-unwind" fn document_visible_rect(this: *mut AnyObject, _cmd: Sel) -> NSRect {
let from_document = guarded(
|| window_rect_to_screen(this, ask_document(this, answers::visible_rect)?),
|| None,
);
let (rect, source) = match from_document {
Some(rect) => (rect, Source::Document),
None => (NSRect::ZERO, Source::NoDocument),
};
log(|line| {
let _ = write!(
line,
"documentVisibleRect {} | -> {} ({source})",
Context::of(this),
Rect(rect)
);
});
rect
}
extern "C-unwind" fn union_rect_in_visible_selected_range(
this: *mut AnyObject,
_cmd: Sel,
) -> NSRect {
let from_document = guarded(
|| {
let rect = ask_document(this, answers::union_rect_in_visible_selection)?;
window_rect_to_screen(this, rect)
},
|| None,
);
let (rect, source) = match from_document {
Some(rect) => (rect, Source::Document),
None => (NSRect::ZERO, Source::NoDocument),
};
log(|line| {
let _ = write!(
line,
"unionRectInVisibleSelectedRange {} | -> {} ({source})",
Context::of(this),
Rect(rect)
);
});
rect
}
extern "C-unwind" fn is_editable(this: *mut AnyObject, _cmd: Sel) -> Bool {
let editable = answers::is_editable();
log(|line| {
let focused = guarded(
|| ask_document(this, |document| Some(document.is_editable())),
|| None,
);
let _ = write!(
line,
"isEditable {} | -> {editable} (the focused widget takes text: {focused:?})",
Context::of(this)
);
});
Bool::new(editable)
}
extern "C-unwind" fn writing_tools_behavior(this: *mut AnyObject, _cmd: Sel) -> isize {
let (behavior, source) = guarded(
|| match ask_document(this, |document| {
Some(answers::writing_tools_behavior(Some(document)))
}) {
Some(behavior) => (behavior, Source::Document),
None => (answers::writing_tools_behavior(None), Source::NoDocument),
},
|| (answers::writing_tools_behavior(None), Source::NoDocument),
);
log(|line| {
let _ = write!(
line,
"writingToolsBehavior {} | -> {behavior} ({source})",
Context::of(this)
);
});
behavior
}
extern "C-unwind" fn allows_writing_tools_affordance(this: *mut AnyObject, _cmd: Sel) -> Bool {
log(|line| {
let _ = write!(
line,
"allowsWritingToolsAffordance {} | -> false",
Context::of(this)
);
});
Bool::NO
}
extern "C-unwind" fn allowed_writing_tools_result_options(
this: *mut AnyObject,
_cmd: Sel,
) -> usize {
log(|line| {
let _ = write!(
line,
"allowedWritingToolsResultOptions {} | -> {} (plain text)",
Context::of(this),
answers::WRITING_TOOLS_PLAIN_TEXT
);
});
answers::WRITING_TOOLS_PLAIN_TEXT
}
extern "C-unwind" fn responds_to_selector(
this: *mut AnyObject,
cmd: Sel,
selector: Option<Sel>,
) -> Bool {
let answer = call_original!(
Slot::RespondsToSelector.index(),
RespondsFn,
Bool::NO,
(this, cmd, selector)
);
if let Some(selector) = selector
&& super::trace_enabled()
{
let first = first_probe(selector);
let in_key = key_down_depth() > 0 && super::trace_stacks_enabled();
if first || in_key {
log(|line| {
let _ = write!(
line,
"respondsToSelector: {} | selector={selector} -> {}{}",
Context::with_marked(this, None),
answer.as_bool(),
if first {
" (first time asked)"
} else {
" (in a key press)"
}
);
write_stack(line);
});
}
}
answer
}
fn first_probe(selector: Sel) -> bool {
PROBED
.try_with(|probed| {
probed
.try_borrow_mut()
.is_ok_and(|mut probed| probed.insert(selector))
})
.unwrap_or(false)
}
extern "C-unwind" fn view_action<const SLOT: usize>(
this: *mut AnyObject,
cmd: Sel,
sender: *mut AnyObject,
) {
log(|line| {
let _ = write!(line, "{cmd} (view) {} | sender=", Context::of(this));
write_sender(line, sender);
});
call_original!(SLOT, ObjectArgFn, (), (this, cmd, sender));
}
fn key_first_responder() -> Option<Retained<NSResponder>> {
let mtm = MainThreadMarker::new()?;
NSApplication::sharedApplication(mtm)
.keyWindow()
.and_then(|window| window.firstResponder())
}
fn responder_ptr(responder: Option<&NSResponder>) -> *mut AnyObject {
object_ptr(responder.map(|responder| {
let responder: &AnyObject = responder.as_ref();
responder
}))
}
fn trace_app_action(cmd: Sel, sender: *mut AnyObject) {
log(|line| {
let responder = key_first_responder();
let _ = write!(
line,
"{cmd} (NSApplication, first responder) {} | sender=",
Context::of(responder_ptr(responder.as_deref()))
);
write_sender(line, sender);
});
}
extern "C-unwind" fn app_action<const SLOT: usize>(
this: *mut AnyObject,
cmd: Sel,
sender: *mut AnyObject,
) {
trace_app_action(cmd, sender);
call_original!(SLOT, ObjectArgFn, (), (this, cmd, sender));
}
extern "C-unwind" fn app_show_writing_tools(
this: *mut AnyObject,
cmd: Sel,
sender: *mut AnyObject,
) {
trace_app_action(cmd, sender);
let _ = catch_unwind(AssertUnwindSafe(|| {
let responder = key_first_responder();
let view = responder_ptr(responder.as_deref());
let began = with_attached(view, |attached| attached.writing_tools.set(true)).is_some();
if began {
log(|line| {
let _ = write!(
line,
"showWritingTools: a Writing Tools session begins {}",
Context::of(view)
);
});
}
}));
call_original!(
Slot::AppShowWritingTools.index(),
ObjectArgFn,
(),
(this, cmd, sender)
);
}
extern "C-unwind" fn app_start_dictation(this: *mut AnyObject, cmd: Sel, sender: *mut AnyObject) {
trace_app_action(cmd, sender);
let _ = catch_unwind(AssertUnwindSafe(|| {
let starts_reported = DICTATION_STARTS_REPORTED.load(Ordering::Relaxed);
let responder = key_first_responder();
let view = responder_ptr(responder.as_deref());
set_dictation(
view,
DictationEvent::StartRequested { starts_reported },
"startDictation:",
);
if starts_reported {
log(|line| {
let _ = write!(
line,
"startDictation: the session waits for _dictationDidStart: {}",
Context::of(view)
);
});
}
}));
call_original!(
Slot::AppStartDictation.index(),
ObjectArgFn,
(),
(this, cmd, sender)
);
}
extern "C-unwind" fn app_stop_dictation(this: *mut AnyObject, cmd: Sel, sender: *mut AnyObject) {
trace_app_action(cmd, sender);
let _ = catch_unwind(AssertUnwindSafe(|| {
let views: Vec<usize> = VIEWS
.try_with(|views| {
views
.try_borrow()
.map(|views| views.keys().copied().collect())
.unwrap_or_default()
})
.unwrap_or_default();
for view in views {
let view = view as *mut AnyObject;
let marked = original_has_marked_text(view).unwrap_or(false);
set_dictation(
view,
DictationEvent::StopRequested { marked },
"stopDictation:",
);
}
}));
call_original!(
Slot::AppStopDictation.index(),
ObjectArgFn,
(),
(this, cmd, sender)
);
}
extern "C-unwind" fn context_dictation_did_start(
this: *mut AnyObject,
cmd: Sel,
argument: *mut AnyObject,
) {
trace_context_action(this, cmd, argument);
let _ = catch_unwind(AssertUnwindSafe(|| {
let client = input_context_client(this);
set_dictation(
object_ptr(client.as_deref()),
DictationEvent::Started,
"_dictationDidStart:",
);
}));
call_original!(
Slot::ContextDictationDidStart.index(),
ObjectArgFn,
(),
(this, cmd, argument)
);
}
extern "C-unwind" fn context_dictation_did_end(
this: *mut AnyObject,
cmd: Sel,
argument: *mut AnyObject,
) {
trace_context_action(this, cmd, argument);
let _ = catch_unwind(AssertUnwindSafe(|| {
let view = object_ptr(input_context_client(this).as_deref());
let marked = original_has_marked_text(view).unwrap_or(false);
set_dictation(view, DictationEvent::Ended { marked }, "_dictationDidEnd:");
}));
call_original!(
Slot::ContextDictationDidEnd.index(),
ObjectArgFn,
(),
(this, cmd, argument)
);
}
fn set_dictation(view: *mut AnyObject, event: DictationEvent, by: &str) {
let change = with_attached(view, |attached| {
let before = attached.dictation.get();
let after = before.after(event);
attached.dictation.set(after);
(before, after)
});
if let Some((before, after)) = change
&& before != after
{
log(|line| {
let _ = write!(
line,
"{by} the dictation session {before:?} -> {after:?} {}",
Context::of(view)
);
});
}
}
fn input_context_client(context: *mut AnyObject) -> Option<Retained<AnyObject>> {
unsafe { context.as_ref() }.and_then(|context| unsafe { msg_send![context, client] })
}
fn object_ptr(object: Option<&AnyObject>) -> *mut AnyObject {
object.map_or(ptr::null_mut(), |object| ptr::from_ref(object).cast_mut())
}
extern "C-unwind" fn context_show_writing_tools(
this: *mut AnyObject,
cmd: Sel,
sender: *mut AnyObject,
delegate: *mut AnyObject,
) {
log(|line| {
let client = input_context_client(this);
let _ = write!(
line,
"{cmd} (NSTextInputContext, client) {} | sender=",
Context::of(object_ptr(client.as_deref()))
);
write_sender(line, sender);
line.push_str(" delegate=");
write_sender(line, delegate);
});
call_original!(
Slot::ContextShowWritingTools.index(),
TwoObjectArgFn,
(),
(this, cmd, sender, delegate)
);
}
fn trace_context_action(this: *mut AnyObject, cmd: Sel, argument: *mut AnyObject) {
log(|line| {
let client = input_context_client(this);
let _ = write!(
line,
"{cmd} (NSTextInputContext, client) {} | argument=",
Context::of(object_ptr(client.as_deref()))
);
write_sender(line, argument);
});
}
extern "C-unwind" fn context_action<const SLOT: usize>(
this: *mut AnyObject,
cmd: Sel,
argument: *mut AnyObject,
) {
trace_context_action(this, cmd, argument);
call_original!(SLOT, ObjectArgFn, (), (this, cmd, argument));
}
#[link(name = "Carbon", kind = "framework")]
unsafe extern "C" {
static kTISPropertyInputSourceID: Option<&'static CFString>;
static kTISPropertyInputSourceType: Option<&'static CFString>;
static kTISTypeKeyboardInputMode: Option<&'static CFString>;
static kTISPropertyBundleID: Option<&'static CFString>;
fn TISCreateInputSourceList(
properties: &CFDictionary,
include_all_installed: u8,
) -> Option<NonNull<CFArray>>;
fn TISGetInputSourceProperty(source: NonNull<c_void>, key: &CFString) -> *const c_void;
}
fn input_method_of(source: &str) -> Option<String> {
let (id_key, type_key, input_mode, bundle_key) = unsafe {
(
kTISPropertyInputSourceID?,
kTISPropertyInputSourceType?,
kTISTypeKeyboardInputMode?,
kTISPropertyBundleID?,
)
};
let id = CFString::from_str(source);
let (key, value): (&CFType, &CFType) = (id_key, &id);
let filter = CFDictionary::<CFType, CFType>::from_slices(&[key], &[value]);
let list = unsafe { TISCreateInputSourceList(filter.as_opaque(), 1) }?;
let list: CFRetained<CFArray> = unsafe { CFRetained::from_raw(list) };
if list.count() < 1 {
return None;
}
let found = NonNull::new(unsafe { list.value_at_index(0) }.cast_mut())?;
let property = |key: &CFString| -> Option<String> {
let value = unsafe { TISGetInputSourceProperty(found, key) };
let value = unsafe { value.cast::<CFType>().as_ref() }?;
value.downcast_ref::<CFString>().map(ToString::to_string)
};
if property(type_key)? != input_mode.to_string() {
return None;
}
property(bundle_key)
}
const STACK_FRAMES: usize = 25;
fn write_stack(out: &mut String) {
if key_down_depth() == 0 || !super::trace_stacks_enabled() {
return;
}
let Some(thread) = AnyClass::get(c"NSThread") else {
return;
};
let symbols: Option<Retained<NSArray<NSString>>> =
unsafe { msg_send![thread, callStackSymbols] };
let Some(symbols) = symbols else {
return;
};
static OWN_IMAGE: OnceLock<String> = OnceLock::new();
let own = OWN_IMAGE.get_or_init(|| {
std::env::current_exe()
.ok()
.and_then(|path| {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.unwrap_or_default()
});
out.push_str(" | stack:");
let frames = symbols
.iter()
.map(|frame| {
let frame = frame.to_string();
let mut parts = frame.split_whitespace().skip(1);
let image = parts.next().unwrap_or_default().to_string();
let symbol: Vec<&str> = parts.skip(1).collect();
let end = match symbol.as_slice() {
[.., "+", _] => symbol.len() - 2,
_ => symbol.len(),
};
(image, symbol[..end].join(" "))
})
.skip_while(|(image, _)| image == own)
.take(STACK_FRAMES);
for (n, (image, symbol)) in frames.enumerate() {
out.push_str(if n == 0 { " " } else { " < " });
let _ = write!(out, "{image} ");
write_escaped_plain(out, &symbol, 120);
}
}
fn write_escaped_plain(out: &mut String, text: &str, limit: usize) {
if text.chars().count() > limit {
out.extend(text.chars().take(limit));
out.push_str("...");
} else {
out.push_str(text);
}
}
fn write_client(out: &mut String, context: *mut AnyObject) {
let client = input_context_client(context);
let client_ptr = object_ptr(client.as_deref());
let _ = write!(out, "{}", Context::of(client_ptr));
if attached_window(client_ptr).is_none()
&& let Some(client) = client.as_deref()
{
let _ = write!(out, " client={}", class_name(client));
}
}
extern "C-unwind" fn context_handle_event(
this: *mut AnyObject,
cmd: Sel,
event: *mut AnyObject,
) -> Bool {
log(|line| {
let _ = write!(line, "{cmd} (NSTextInputContext {this:p}, client) ");
write_client(line, this);
line.push_str(" | begin");
write_key_event(line, event);
});
let handled = call_original!(
Slot::ContextHandleEvent.index(),
ObjectArgBoolFn,
Bool::NO,
(this, cmd, event)
);
log(|line| {
let _ = write!(line, "{cmd} end (NSTextInputContext {this:p}, client) ");
write_client(line, this);
let _ = write!(line, " | -> {}", handled.as_bool());
});
handled
}
extern "C-unwind" fn context_selected_keyboard_input_source(
this: *mut AnyObject,
cmd: Sel,
) -> *mut AnyObject {
let source = call_original!(
Slot::ContextSelectedKeyboardInputSource.index(),
ObjectFn,
ptr::null_mut(),
(this, cmd)
);
log(|line| {
let _ = write!(line, "{cmd} (NSTextInputContext {this:p}, client) ");
write_client(line, this);
line.push_str(" | -> ");
write_text(line, source);
});
source
}
extern "C-unwind" fn context_call<const SLOT: usize>(this: *mut AnyObject, cmd: Sel) {
log(|line| {
let _ = write!(line, "{cmd} (NSTextInputContext {this:p}, client) ");
write_client(line, this);
});
call_original!(SLOT, VoidFn, (), (this, cmd));
}
objc2::define_class!(
#[unsafe(super(objc2::runtime::NSObject))]
#[name = "TeksiloTextInputSourceObserver"]
struct KeyboardSelectionObserver;
impl KeyboardSelectionObserver {
#[unsafe(method(keyboardSelectionDidChange:))]
fn keyboard_selection_did_change(&self, notification: &NSNotification) {
trace_keyboard_selection_change(notification);
guarded(
|| input_source_changed("NSTextInputContextKeyboardSelectionDidChangeNotification"),
|| (),
);
}
}
);
fn trace_keyboard_selection_change(notification: &NSNotification) {
log(|line| {
let context = notification.object();
let context_ptr = object_ptr(context.as_deref());
let _ = write!(
line,
"NSTextInputContextKeyboardSelectionDidChangeNotification (NSTextInputContext {context_ptr:p}, client) "
);
write_client(line, context_ptr);
line.push_str(" | selected=");
write_text(line, selected_source_untraced(context_ptr));
});
}
fn selected_source_untraced(context: *mut AnyObject) -> *mut AnyObject {
if context.is_null() {
return ptr::null_mut();
}
match ORIGINALS[Slot::ContextSelectedKeyboardInputSource.index()].get() {
Some(&original) => unsafe {
std::mem::transmute::<Imp, ObjectFn>(original)(
context,
sel!(selectedKeyboardInputSource),
)
},
None => unsafe { msg_send![context, selectedKeyboardInputSource] },
}
}
fn observe_keyboard_selection(mtm: MainThreadMarker) {
let observer: Retained<KeyboardSelectionObserver> =
unsafe { msg_send![mtm.alloc::<KeyboardSelectionObserver>(), init] };
unsafe {
NSNotificationCenter::defaultCenter().addObserver_selector_name_object(
&observer,
sel!(keyboardSelectionDidChange:),
Some(NSTextInputContextKeyboardSelectionDidChangeNotification),
None,
);
}
std::mem::forget(observer);
trace("install: observing NSTextInputContextKeyboardSelectionDidChangeNotification");
}
#[cfg(test)]
mod tests {
use objc2::runtime::NSObject;
use objc2::{ClassType, define_class};
use super::*;
define_class!(
#[unsafe(super(NSObject))]
#[name = "TeksiloTextInputHookTestBase"]
struct TestBase;
impl TestBase {
#[unsafe(method(hasMarkedText))]
fn has_marked_text(&self) -> bool {
true
}
#[unsafe(method(unmarkText))]
fn unmark_text(&self) -> bool {
true
}
}
);
define_class!(
#[unsafe(super(TestBase, NSObject))]
#[name = "TeksiloTextInputHookTestSub"]
struct TestSub;
);
fn implementation(class: &AnyClass, selector: Sel) -> usize {
class
.instance_method(selector)
.map_or(0, |method| method.implementation() as usize)
}
#[test]
fn a_declared_method_is_replaced_and_still_answers_through_the_original() {
let class = TestBase::class();
let hook = hook!(
WhenTracing,
c"hasMarkedText",
Slot::HasMarkedText,
has_marked_text,
BoolFn
);
let before = implementation(class, sel!(hasMarkedText));
install_hook(class, &hook);
assert_eq!(
implementation(class, sel!(hasMarkedText)),
hook.replacement as usize
);
assert_eq!(
ORIGINALS[Slot::HasMarkedText.index()]
.get()
.map(|imp| *imp as usize),
Some(before)
);
let object: Retained<TestBase> = unsafe { msg_send![TestBase::class(), new] };
let marked: bool = unsafe { msg_send![&*object, hasMarkedText] };
assert!(marked, "the replacement returns what the original answers");
install_hook(class, &hook);
assert_eq!(
implementation(class, sel!(hasMarkedText)),
hook.replacement as usize
);
}
#[test]
fn an_inherited_method_gets_an_override_and_the_superclass_keeps_its_own() {
let class = TestSub::class();
let hook = hook!(
WhenTracing,
c"respondsToSelector:",
Slot::RespondsToSelector,
responds_to_selector,
RespondsFn
);
let inherited = implementation(NSObject::class(), sel!(respondsToSelector:));
install_hook(class, &hook);
assert_eq!(
implementation(class, sel!(respondsToSelector:)),
hook.replacement as usize
);
assert_eq!(
implementation(TestBase::class(), sel!(respondsToSelector:)),
inherited
);
assert_eq!(
implementation(NSObject::class(), sel!(respondsToSelector:)),
inherited
);
let object: Retained<TestSub> = unsafe { msg_send![TestSub::class(), new] };
let yes: bool = unsafe { msg_send![&*object, respondsToSelector: sel!(hasMarkedText)] };
let no: bool = unsafe { msg_send![&*object, respondsToSelector: sel!(selectedRange)] };
assert!(yes && !no, "the replacement answers as NSObject does");
install_hook(class, &hook);
assert_eq!(
implementation(class, sel!(respondsToSelector:)),
hook.replacement as usize
);
}
#[test]
fn text_input_sources_name_the_input_method_of_a_mode() {
assert_eq!(input_method_of("com.apple.keylayout.ABC"), None);
assert_eq!(input_method_of("com.example.no.such.source"), None);
let hiragana = input_method_of("com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese");
if let Some(hiragana) = &hiragana {
assert_eq!(
input_method_of("com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese.Katakana")
.as_ref(),
Some(hiragana),
"two modes of one input method"
);
assert_ne!(
input_method_of("com.apple.inputmethod.Kotoeri.KanaTyping.Japanese").as_ref(),
Some(hiragana),
"Romaji and Kana typing are two input methods"
);
}
}
#[test]
fn a_method_with_another_signature_or_none_is_left_alone() {
let class = TestBase::class();
let before = implementation(class, sel!(unmarkText));
install_hook(
class,
&hook!(
WhenTracing,
c"unmarkText",
Slot::UnmarkText,
unmark_text,
VoidFn
),
);
install_hook(
class,
&hook!(
WhenTracing,
c"markedRange",
Slot::MarkedRange,
marked_range,
RangeFn
),
);
assert_eq!(implementation(class, sel!(unmarkText)), before);
assert!(ORIGINALS[Slot::UnmarkText.index()].get().is_none());
assert!(class.instance_method(sel!(markedRange)).is_none());
assert!(ORIGINALS[Slot::MarkedRange.index()].get().is_none());
}
}