Skip to main content

cbf_chrome/platform/macos/
browser_view.rs

1//! macOS `NSView` implementation used to host Chromium rendering surfaces.
2//!
3//! This module defines `BrowserViewMac` and related delegate/event types for
4//! translating native macOS input, IME, drag-and-drop, and context menu events.
5
6#![allow(non_snake_case)]
7
8use std::{
9    cell::{Cell, RefCell},
10    ffi::c_void,
11    ptr::NonNull,
12};
13
14use objc2::{
15    AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send,
16    rc::Retained,
17    runtime::{AnyObject, NSObject, ProtocolObject},
18    sel,
19};
20use objc2_app_kit::{
21    NSApplication, NSControlStateValueOff, NSControlStateValueOn, NSDragOperation,
22    NSDraggingContext, NSDraggingItem, NSDraggingSession, NSDraggingSource, NSEvent,
23    NSEventModifierFlags, NSEventType, NSImage, NSMenu, NSMenuItem, NSMenuItemBadge,
24    NSPasteboardWriting, NSResponder, NSTextInputClient, NSView,
25};
26use objc2_core_foundation::{CGPoint, CGRect, CGSize};
27use objc2_foundation::{
28    NSArray, NSAttributedString, NSAttributedStringKey, NSData, NSNotFound, NSObjectProtocol,
29    NSPoint, NSRange, NSRangePointer, NSRect, NSString, NSUInteger,
30};
31use objc2_quartz_core::CATransaction;
32
33use cbf::data::{
34    context_menu::{ContextMenu, ContextMenuIcon, ContextMenuItem, ContextMenuItemType},
35    drag::DragStartRequest,
36    edit::EditAction,
37    ime::{ImeBoundsUpdate, ImeCompositionBounds, ImeRect, ImeTextRange, TextSelectionBounds},
38    key::{KeyEvent, KeyEventType},
39    mouse::{MouseEvent, MouseWheelEvent, PointerType},
40};
41
42use super::bindings::{CALayerHost, ContextId};
43use super::choice_menu::ChromeChoiceMenuPresenter;
44#[cfg(feature = "macos-choice-menu-default")]
45use super::choice_menu::MacChoiceMenuPresenter;
46use crate::data::choice_menu::{ChromeChoiceMenu, ChromeChoiceMenuSelectionMode};
47use crate::ffi::{
48    convert_nsevent_to_key_event, convert_nsevent_to_mouse_event,
49    convert_nsevent_to_mouse_wheel_event,
50};
51
52/// Callback interface for BrowserViewMac input and menu events.
53pub trait BrowserViewMacDelegate {
54    /// Called when a key event is translated from macOS input.
55    fn on_key_event(&self, view: &BrowserViewMac, event: KeyEvent, commands: Vec<String>);
56    /// Called when a browser-generic edit action is requested by AppKit.
57    fn on_edit_action(&self, view: &BrowserViewMac, action: EditAction);
58    /// Called when an IME event is produced by the view.
59    fn on_ime_event(&self, view: &BrowserViewMac, event: BrowserViewMacImeEvent);
60    /// Called when plain character input is received.
61    fn on_char_event(&self, view: &BrowserViewMac, event: KeyEvent);
62    /// Called when a mouse event is translated from macOS input.
63    fn on_mouse_event(&self, view: &BrowserViewMac, event: MouseEvent);
64    /// Called when a mouse wheel event is translated from macOS input.
65    fn on_mouse_wheel_event(&self, view: &BrowserViewMac, event: MouseWheelEvent);
66    /// Called when a context menu command is selected.
67    fn on_context_menu_command(&self, view: &BrowserViewMac, menu_id: u64, command_id: i32);
68    /// Called when a context menu is dismissed.
69    fn on_context_menu_dismissed(&self, view: &BrowserViewMac, menu_id: u64);
70    /// Called when a choice menu selection is accepted.
71    fn on_choice_menu_selected(&self, view: &BrowserViewMac, request_id: u64, indices: Vec<i32>);
72    /// Called when a choice menu is dismissed.
73    fn on_choice_menu_dismissed(&self, view: &BrowserViewMac, request_id: u64);
74    /// Called when NSResponder focus state for BrowserViewMac changed.
75    fn on_focus_changed(&self, view: &BrowserViewMac, focused: bool);
76    /// Called when native drag session moves.
77    fn on_native_drag_update(
78        &self,
79        _view: &BrowserViewMac,
80        _event: BrowserViewMacNativeDragUpdate,
81    ) {
82    }
83    /// Called when native drag session ends with drop.
84    fn on_native_drag_drop(&self, _view: &BrowserViewMac, _event: BrowserViewMacNativeDragDrop) {}
85    /// Called when native drag session is cancelled.
86    fn on_native_drag_cancel(&self, _view: &BrowserViewMac, _session_id: u64) {}
87}
88
89/// Configuration for constructing a BrowserViewMac instance.
90pub struct BrowserViewMacConfig {
91    pub frame: CGRect,
92    pub delegate: Box<dyn BrowserViewMacDelegate>,
93}
94
95const NO_MENU_ID: u64 = 0;
96const NO_COMMAND_ID: i32 = i32::MIN;
97const NO_CHOICE_MENU_REQUEST_ID: u64 = 0;
98const NO_CHOICE_MENU_ACTION: i32 = i32::MIN;
99
100/// IME events emitted by BrowserViewMac.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum BrowserViewMacImeEvent {
103    /// Update IME composition text and selection.
104    SetComposition {
105        text: String,
106        selection: Option<ImeTextRange>,
107        replacement: Option<ImeTextRange>,
108    },
109    /// Commit IME composition text.
110    CommitText {
111        text: String,
112        replacement: Option<ImeTextRange>,
113        relative_caret_position: i32,
114    },
115    /// Finish IME composing with optional selection retention.
116    FinishComposingText { keep_selection: bool },
117}
118
119#[derive(Debug, Clone, PartialEq)]
120pub struct BrowserViewMacNativeDragUpdate {
121    pub session_id: u64,
122    pub modifiers: u32,
123    pub position_in_widget_x: f32,
124    pub position_in_widget_y: f32,
125    pub position_in_screen_x: f32,
126    pub position_in_screen_y: f32,
127}
128
129#[derive(Debug, Clone, PartialEq)]
130pub struct BrowserViewMacNativeDragDrop {
131    pub session_id: u64,
132    pub modifiers: u32,
133    pub position_in_widget_x: f32,
134    pub position_in_widget_y: f32,
135    pub position_in_screen_x: f32,
136    pub position_in_screen_y: f32,
137}
138
139/// Internal state stored alongside the macOS view instance.
140pub struct BrowserViewMacIvars {
141    delegate: Box<dyn BrowserViewMacDelegate>,
142    has_marked_text: Cell<bool>,
143    marked_range: Cell<NSRange>,
144    selected_range: Cell<NSRange>,
145    ime_handled: Cell<bool>,
146    suppress_key_up: Cell<bool>,
147    ime_insert_expected: Cell<bool>,
148    saw_insert_command: Cell<bool>,
149    sent_char_event: Cell<bool>,
150    ime_bounds: RefCell<Option<ImeBoundsUpdate>>,
151    browser_layer: Retained<CALayerHost>,
152    browser_layer_frame: Cell<CGRect>,
153    edit_commands: RefCell<Vec<String>>,
154    pending_char_event: RefCell<Option<KeyEvent>>,
155    context_menu_id: Cell<u64>,
156    context_menu_selected_command_id: Cell<i32>,
157    choice_menu_request_id: Cell<u64>,
158    choice_menu_selected_action: Cell<i32>,
159    active_drag_source: RefCell<Option<Retained<AnyObject>>>,
160}
161
162define_class!(
163    /// macOS NSView that hosts the Chromium rendering surface.
164    #[unsafe(super(NSView, NSResponder, NSObject))]
165    #[thread_kind = objc2::MainThreadOnly]
166    #[name = "BrowserViewMac"]
167    #[ivars = BrowserViewMacIvars]
168    pub struct BrowserViewMac;
169
170    impl BrowserViewMac {
171        #[unsafe(method(keyDown:))]
172        fn key_down(&self, event: &NSEvent) {
173            let had_marked_text = self.ivars().has_marked_text.get();
174            self.ivars().ime_handled.set(false);
175            self.ivars().suppress_key_up.set(false);
176            self.ivars().saw_insert_command.set(false);
177            self.ivars().sent_char_event.set(false);
178            self.ivars().edit_commands.borrow_mut().clear();
179            self.ivars().pending_char_event.borrow_mut().take();
180
181            let nsevent_ptr = NonNull::from(event).cast::<c_void>();
182            self.ivars()
183                .pending_char_event
184                .replace(Some(convert_nsevent_to_key_event(0, nsevent_ptr)));
185
186            let events = NSArray::arrayWithObject(event);
187            self.interpretKeyEvents(&events);
188
189            if self.ivars().ime_handled.get() {
190                self.ivars().pending_char_event.borrow_mut().take();
191                self.ivars().suppress_key_up.set(true);
192                return;
193            }
194
195            if had_marked_text && should_ignore_accelerator_with_marked_text(event) {
196                // Match Chromium's macOS text input handling here: while marked
197                // text is active, AppKit can consume keys such as Return
198                // directly inside the IME candidate UI without invoking our
199                // NSTextInputClient callbacks. If we forward that key as a
200                // normal RawKeyDown/KeyDown anyway, the page can observe it as
201                // an accelerator and submit a form even though the IME already
202                // consumed it to confirm or navigate candidates.
203                self.ivars().pending_char_event.borrow_mut().take();
204                self.ivars().suppress_key_up.set(true);
205                return;
206            }
207
208            self.forward_key_event(event);
209            if self.ivars().saw_insert_command.get() && !self.ivars().sent_char_event.get() {
210                let pending_char_event = self.ivars().pending_char_event.borrow().clone();
211                if let Some(text) = synthesized_char_text(pending_char_event.as_ref())
212                    && let Some(event) = build_char_event(pending_char_event, text)
213                {
214                    self.send_mac_char_event(event);
215                }
216            }
217            self.ivars().pending_char_event.borrow_mut().take();
218        }
219
220        #[unsafe(method(keyUp:))]
221        fn key_up(&self, event: &NSEvent) {
222            if self.ivars().suppress_key_up.replace(false) {
223                return;
224            }
225            // KeyUp doesn't carry commands from interpretKeyEvents in the same way,
226            // or at least we don't call interpretKeyEvents for KeyUp usually.
227            self.forward_key_event(event);
228        }
229
230        #[unsafe(method(flagsChanged:))]
231        fn flags_changed(&self, event: &NSEvent) {
232            self.forward_key_event(event);
233        }
234
235        #[unsafe(method(isFlipped))]
236        fn is_flipped(&self) -> bool {
237            false
238        }
239
240        #[unsafe(method(acceptsFirstResponder))]
241        fn accepts_first_responder(&self) -> bool {
242            true
243        }
244
245        #[unsafe(method(becomeFirstResponder))]
246        fn become_first_responder(&self) -> bool {
247            self.ivars().delegate.on_focus_changed(self, true);
248            true
249        }
250
251        #[unsafe(method(resignFirstResponder))]
252        fn resign_first_responder(&self) -> bool {
253            self.ivars().delegate.on_focus_changed(self, false);
254            true
255        }
256
257        #[unsafe(method(undo:))]
258        fn undo(&self, _sender: &AnyObject) {
259            self.send_edit_action(EditAction::Undo);
260        }
261
262        #[unsafe(method(redo:))]
263        fn redo(&self, _sender: &AnyObject) {
264            self.send_edit_action(EditAction::Redo);
265        }
266
267        #[unsafe(method(cut:))]
268        fn cut(&self, _sender: &AnyObject) {
269            self.send_edit_action(EditAction::Cut);
270        }
271
272        #[unsafe(method(copy:))]
273        fn copy(&self, _sender: &AnyObject) {
274            self.send_edit_action(EditAction::Copy);
275        }
276
277        #[unsafe(method(paste:))]
278        fn paste(&self, _sender: &AnyObject) {
279            self.send_edit_action(EditAction::Paste);
280        }
281
282        #[unsafe(method(selectAll:))]
283        fn select_all(&self, _sender: &AnyObject) {
284            self.send_edit_action(EditAction::SelectAll);
285        }
286
287        #[unsafe(method(mouseDown:))]
288        fn mouse_down(&self, event: &NSEvent) {
289            self.forward_mouse_event(event);
290        }
291
292        #[unsafe(method(rightMouseDown:))]
293        fn right_mouse_down(&self, event: &NSEvent) {
294            self.forward_mouse_event(event);
295        }
296
297        #[unsafe(method(otherMouseDown:))]
298        fn other_mouse_down(&self, event: &NSEvent) {
299            self.forward_mouse_event(event);
300        }
301
302        #[unsafe(method(mouseUp:))]
303        fn mouse_up(&self, event: &NSEvent) {
304            self.forward_mouse_event(event);
305        }
306
307        #[unsafe(method(rightMouseUp:))]
308        fn right_mouse_up(&self, event: &NSEvent) {
309            self.forward_mouse_event(event);
310        }
311
312        #[unsafe(method(otherMouseUp:))]
313        fn other_mouse_up(&self, event: &NSEvent) {
314            self.forward_mouse_event(event);
315        }
316
317        #[unsafe(method(mouseMoved:))]
318        fn mouse_moved(&self, event: &NSEvent) {
319            self.forward_mouse_event(event);
320        }
321
322        #[unsafe(method(mouseDragged:))]
323        fn mouse_dragged(&self, event: &NSEvent) {
324            self.forward_mouse_event(event);
325        }
326
327        #[unsafe(method(rightMouseDragged:))]
328        fn right_mouse_dragged(&self, event: &NSEvent) {
329            self.forward_mouse_event(event);
330        }
331
332        #[unsafe(method(otherMouseDragged:))]
333        fn other_mouse_dragged(&self, event: &NSEvent) {
334            self.forward_mouse_event(event);
335        }
336
337        #[unsafe(method(mouseEntered:))]
338        fn mouse_entered(&self, event: &NSEvent) {
339            self.forward_mouse_event(event);
340        }
341
342        #[unsafe(method(mouseExited:))]
343        fn mouse_exited(&self, event: &NSEvent) {
344            self.forward_mouse_event(event);
345        }
346
347        #[unsafe(method(scrollWheel:))]
348        fn scroll_wheel(&self, event: &NSEvent) {
349            self.forward_mouse_wheel_event(event);
350        }
351
352        #[unsafe(method(contextMenuItemSelected:))]
353        fn context_menu_item_selected(&self, sender: &AnyObject) {
354            let tag: isize = unsafe { msg_send![sender, tag] };
355            self.ivars()
356                .context_menu_selected_command_id
357                .set(tag as i32);
358        }
359
360        #[unsafe(method(choiceMenuItemSelected:))]
361        fn choice_menu_item_selected(&self, sender: &AnyObject) {
362            let tag: isize = unsafe { msg_send![sender, tag] };
363            self.ivars().choice_menu_selected_action.set(tag as i32);
364        }
365    }
366
367    unsafe impl NSTextInputClient for BrowserViewMac {
368        #[unsafe(method(insertText:replacementRange:))]
369        unsafe fn insertText_replacementRange(&self, string: &AnyObject, replacement_range: NSRange) {
370            let Some(text) = extract_insert_text(string) else { return };
371
372            if self.ivars().ime_insert_expected.get() {
373                self.mark_ime_handled();
374                self.ivars().ime_insert_expected.set(false);
375                self.update_marked_state(false, ns_not_found_range(), ns_not_found_range());
376                self.send_mac_ime_event(BrowserViewMacImeEvent::CommitText {
377                    text,
378                    replacement: nsrange_to_text_range(replacement_range),
379                    relative_caret_position: 0,
380                });
381            } else {
382                // Normal text input (not via IME composition).
383                // We send a Char event. We do NOT mark IME handled, so that the
384                // corresponding KeyDown event (if any) is also sent by key_down.
385                let pending_char_event = self.ivars().pending_char_event.borrow().clone();
386                if let Some(event) = build_char_event(pending_char_event, text) {
387                    self.ivars().sent_char_event.set(true);
388                    self.send_mac_char_event(event);
389                }
390            }
391        }
392
393        #[unsafe(method(doCommandBySelector:))]
394        unsafe fn doCommandBySelector(&self, selector: objc2::runtime::Sel) {
395            let mut command = selector.name().to_str().unwrap().to_string();
396            if let Some(stripped) = command.strip_suffix(':') {
397                command = stripped.to_string();
398            }
399            if command.to_ascii_lowercase().starts_with("insert") {
400                // Chromium ignores insert* commands during key down to avoid
401                // tab inserting text instead of moving focus.
402                self.ivars().saw_insert_command.set(true);
403                return;
404            }
405            self.ivars().edit_commands.borrow_mut().push(command);
406        }
407
408        #[unsafe(method(setMarkedText:selectedRange:replacementRange:))]
409        unsafe fn setMarkedText_selectedRange_replacementRange(
410            &self,
411            string: &AnyObject,
412            selected_range: NSRange,
413            replacement_range: NSRange,
414        ) {
415            if let Some(text) = extract_insert_text(string) {
416                self.mark_ime_handled();
417                self.ivars().ime_insert_expected.set(true);
418                let marked_range = composition_range_for_text(&text);
419                self.update_marked_state(true, marked_range, selected_range);
420                self.send_mac_ime_event(BrowserViewMacImeEvent::SetComposition {
421                    text,
422                    selection: nsrange_to_text_range(selected_range),
423                    replacement: nsrange_to_text_range(replacement_range),
424                });
425            }
426        }
427
428        #[unsafe(method(unmarkText))]
429        fn unmarkText(&self) {
430            self.mark_ime_handled();
431            let had_marked = self.ivars().has_marked_text.get();
432            self.ivars().ime_insert_expected.set(had_marked);
433            self.update_marked_state(false, ns_not_found_range(), ns_not_found_range());
434            self.send_mac_ime_event(BrowserViewMacImeEvent::FinishComposingText {
435                keep_selection: false,
436            });
437        }
438
439        #[unsafe(method(selectedRange))]
440        fn selectedRange(&self) -> NSRange {
441            self.ivars().selected_range.get()
442        }
443
444        #[unsafe(method(markedRange))]
445        fn markedRange(&self) -> NSRange {
446            if self.ivars().has_marked_text.get() {
447                self.ivars().marked_range.get()
448            } else {
449                ns_not_found_range()
450            }
451        }
452
453        #[unsafe(method(hasMarkedText))]
454        fn hasMarkedText(&self) -> bool {
455            self.ivars().has_marked_text.get()
456        }
457
458        // NOTE: The intended return type is `Option<Retained<NSAttributedString>>`,
459        //   not `*const NSObject`, but since it does not satisfy the trait boundary,
460        //   it returns a raw pointer.
461        #[unsafe(method(attributedSubstringForProposedRange:actualRange:))]
462        unsafe fn attributedSubstringForProposedRange_actualRange(
463            &self,
464            range: NSRange,
465            actual_range: NSRangePointer,
466        ) -> *const AnyObject {
467            if !actual_range.is_null() {
468                unsafe { actual_range.write(range) };
469            }
470
471            std::ptr::null()
472        }
473
474        // NOTE: The intended return type is `Retained<NSArray<NSAttributedStringKey>>`,
475        //   not `*const NSObject`, but since it does not satisfy the trait boundary,
476        //   it returns a raw pointer.
477        #[unsafe(method(validAttributesForMarkedText))]
478        fn validAttributesForMarkedText(&self) -> *const AnyObject {
479            let array: Retained<NSArray<NSAttributedStringKey>> = NSArray::new();
480            Retained::autorelease_return(array) as _
481        }
482
483        #[unsafe(method(firstRectForCharacterRange:actualRange:))]
484        unsafe fn firstRectForCharacterRange_actualRange(
485            &self,
486            range: NSRange,
487            actual_range: NSRangePointer,
488        ) -> NSRect {
489            if !actual_range.is_null() {
490                unsafe { actual_range.write(range) };
491            }
492
493            self.ime_candidate_rect(range)
494        }
495
496        #[unsafe(method(characterIndexForPoint:))]
497        fn characterIndexForPoint(&self, _point: NSPoint) -> NSUInteger {
498            NSNotFound as NSUInteger
499        }
500    }
501);
502
503struct HostDragSourceIvars {
504    view: Retained<BrowserViewMac>,
505    session_id: u64,
506    operation_mask: NSDragOperation,
507}
508
509define_class!(
510    #[unsafe(super(NSObject))]
511    #[thread_kind = objc2::MainThreadOnly]
512    #[ivars = HostDragSourceIvars]
513    struct HostDragSource;
514
515    unsafe impl NSObjectProtocol for HostDragSource {}
516
517    unsafe impl NSDraggingSource for HostDragSource {
518        #[unsafe(method(draggingSession:sourceOperationMaskForDraggingContext:))]
519        fn draggingSession_sourceOperationMaskForDraggingContext(
520            &self,
521            _session: &NSDraggingSession,
522            _context: NSDraggingContext,
523        ) -> NSDragOperation {
524            self.ivars().operation_mask
525        }
526
527        #[unsafe(method(draggingSession:movedToPoint:))]
528        fn draggingSession_movedToPoint(
529            &self,
530            _session: &NSDraggingSession,
531            screen_point: NSPoint,
532        ) {
533            self.ivars()
534                .view
535                .emit_native_drag_update(self.ivars().session_id, screen_point);
536        }
537
538        #[unsafe(method(draggingSession:endedAtPoint:operation:))]
539        fn draggingSession_endedAtPoint_operation(
540            &self,
541            _session: &NSDraggingSession,
542            screen_point: NSPoint,
543            operation: NSDragOperation,
544        ) {
545            let should_treat_as_drop =
546                !operation.is_empty() || self.ivars().view.contains_screen_point(screen_point);
547            if should_treat_as_drop {
548                self.ivars()
549                    .view
550                    .emit_native_drag_drop(self.ivars().session_id, screen_point);
551            } else {
552                self.ivars()
553                    .view
554                    .emit_native_drag_cancel(self.ivars().session_id);
555            }
556            self.ivars().view.ivars().active_drag_source.replace(None);
557        }
558    }
559);
560
561impl HostDragSource {
562    fn new(
563        mtm: MainThreadMarker,
564        view: Retained<BrowserViewMac>,
565        session_id: u64,
566        operation_mask: NSDragOperation,
567    ) -> Retained<Self> {
568        let this = Self::alloc(mtm).set_ivars(HostDragSourceIvars {
569            view,
570            session_id,
571            operation_mask,
572        });
573        unsafe { msg_send![super(this), init] }
574    }
575}
576
577impl BrowserViewMac {
578    /// Create a new BrowserViewMac on the main thread.
579    pub fn new(mtm: MainThreadMarker, config: BrowserViewMacConfig) -> Retained<Self> {
580        let browser_layer = CALayerHost::init(CALayerHost::alloc());
581        browser_layer.setFrame(config.frame);
582        browser_layer.setGeometryFlipped(true);
583
584        let this = Self::alloc(mtm).set_ivars(BrowserViewMacIvars {
585            delegate: config.delegate,
586            has_marked_text: Cell::new(false),
587            marked_range: Cell::new(ns_not_found_range()),
588            selected_range: Cell::new(ns_not_found_range()),
589            ime_handled: Cell::new(false),
590            suppress_key_up: Cell::new(false),
591            ime_insert_expected: Cell::new(false),
592            saw_insert_command: Cell::new(false),
593            sent_char_event: Cell::new(false),
594            ime_bounds: RefCell::new(None),
595            browser_layer,
596            browser_layer_frame: Cell::new(config.frame),
597            edit_commands: RefCell::new(Vec::new()),
598            pending_char_event: RefCell::new(None),
599            context_menu_id: Cell::new(NO_MENU_ID),
600            context_menu_selected_command_id: Cell::new(NO_COMMAND_ID),
601            choice_menu_request_id: Cell::new(NO_CHOICE_MENU_REQUEST_ID),
602            choice_menu_selected_action: Cell::new(NO_CHOICE_MENU_ACTION),
603            active_drag_source: RefCell::new(None),
604        });
605        let this: Retained<Self> = unsafe { msg_send![super(this), init] };
606
607        this.setFrame(config.frame);
608        this.setWantsLayer(true);
609        this.layer()
610            .expect("BrowserViewMac must have a layer")
611            .addSublayer(&this.ivars().browser_layer);
612
613        this
614    }
615
616    /// Set the CALayerHost context id used to display Chromium content.
617    pub fn set_context_id(&self, context_id: ContextId) {
618        unsafe {
619            self.ivars().browser_layer.setContextId(context_id);
620        }
621    }
622
623    /// Update the layer frame without implicit animations.
624    pub fn set_layer_frame(&self, frame: CGRect) {
625        // Disable implicit animations for layer frame origin update.
626        CATransaction::begin();
627        CATransaction::setDisableActions(true);
628        self.ivars().browser_layer.setFrame(frame);
629        CATransaction::commit();
630
631        self.ivars().browser_layer_frame.set(frame);
632    }
633
634    /// Update IME bounds so macOS can place candidate windows correctly.
635    pub fn set_ime_bounds(&self, update: ImeBoundsUpdate) {
636        self.ivars().ime_bounds.replace(Some(update));
637    }
638
639    /// Access the underlying CALayerHost used for rendering.
640    pub fn browser_layer(&self) -> &CALayerHost {
641        &self.ivars().browser_layer
642    }
643
644    /// Show a context menu built from backend menu data.
645    pub fn show_context_menu(&self, menu: ContextMenu) {
646        if self.window().is_none() {
647            return;
648        }
649
650        self.ivars().context_menu_id.set(menu.menu_id);
651        self.ivars()
652            .context_menu_selected_command_id
653            .set(NO_COMMAND_ID);
654
655        let mtm = MainThreadMarker::new().expect("BrowserViewMac must be on main thread");
656        let ns_menu = build_ns_menu(mtm, &menu.items, self);
657
658        let bounds = self.bounds();
659        let x = menu.x as f64;
660        let y = if self.isFlipped() {
661            menu.y as f64
662        } else {
663            (bounds.size.height - menu.y as f64).max(0.0)
664        };
665        let location = NSPoint::new(x, y);
666
667        _ = ns_menu.popUpMenuPositioningItem_atLocation_inView(None, location, Some(self));
668
669        let menu_id = self.ivars().context_menu_id.replace(NO_MENU_ID);
670        let command_id = self
671            .ivars()
672            .context_menu_selected_command_id
673            .replace(NO_COMMAND_ID);
674
675        if menu_id == NO_MENU_ID {
676            return;
677        }
678
679        if command_id == NO_COMMAND_ID {
680            self.ivars()
681                .delegate
682                .on_context_menu_dismissed(self, menu_id);
683        } else {
684            self.ivars()
685                .delegate
686                .on_context_menu_command(self, menu_id, command_id);
687        }
688    }
689
690    /// Show a host-owned `<select>` choice menu.
691    pub fn show_choice_menu(&self, menu: ChromeChoiceMenu) {
692        #[cfg(feature = "macos-choice-menu-default")]
693        return self.show_choice_menu_with_presenter(menu, &MacChoiceMenuPresenter);
694        #[cfg(not(feature = "macos-choice-menu-default"))]
695        {
696            self.ivars()
697                .delegate
698                .on_choice_menu_dismissed(self, menu.request_id);
699        }
700    }
701
702    /// Show a host-owned `<select>` choice menu with a custom presenter.
703    pub fn show_choice_menu_with_presenter<P: ChromeChoiceMenuPresenter>(
704        &self,
705        menu: ChromeChoiceMenu,
706        presenter: &P,
707    ) {
708        if self.window().is_none() {
709            self.ivars()
710                .delegate
711                .on_choice_menu_dismissed(self, menu.request_id);
712            return;
713        }
714
715        if matches!(menu.selection_mode, ChromeChoiceMenuSelectionMode::Multiple) {
716            self.ivars()
717                .delegate
718                .on_choice_menu_dismissed(self, menu.request_id);
719            return;
720        }
721
722        self.ivars().choice_menu_request_id.set(menu.request_id);
723        self.ivars()
724            .choice_menu_selected_action
725            .set(NO_CHOICE_MENU_ACTION);
726
727        let result = presenter.show_choice_menu(self, &menu);
728
729        let request_id = self
730            .ivars()
731            .choice_menu_request_id
732            .replace(NO_CHOICE_MENU_REQUEST_ID);
733        self.ivars()
734            .choice_menu_selected_action
735            .set(NO_CHOICE_MENU_ACTION);
736
737        if request_id == NO_CHOICE_MENU_REQUEST_ID {
738            return;
739        }
740
741        match result {
742            Some(indices) if !indices.is_empty() => {
743                self.ivars()
744                    .delegate
745                    .on_choice_menu_selected(self, request_id, indices);
746            }
747            _ => {
748                self.ivars()
749                    .delegate
750                    .on_choice_menu_dismissed(self, request_id);
751            }
752        }
753    }
754
755    /// Start a native macOS drag session for host-owned drag lifecycle.
756    pub fn start_native_drag_session(&self, request: &DragStartRequest) -> bool {
757        let Some(window) = self.window() else {
758            return false;
759        };
760        let Some(image_data) = request.image.as_ref() else {
761            return false;
762        };
763        if image_data.png_bytes.is_empty() {
764            return false;
765        }
766
767        let data = unsafe {
768            NSData::initWithBytes_length(
769                NSData::alloc(),
770                image_data.png_bytes.as_ptr().cast(),
771                image_data.png_bytes.len() as NSUInteger,
772            )
773        };
774        let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) else {
775            return false;
776        };
777        let image_scale = if image_data.scale > 0.0 {
778            image_data.scale as f64
779        } else {
780            1.0
781        };
782        image.setSize(CGSize::new(
783            image_data.pixel_width as f64 / image_scale,
784            image_data.pixel_height as f64 / image_scale,
785        ));
786
787        let writer_string = if !request.data.text.is_empty() {
788            request.data.text.as_str()
789        } else if let Some(url_info) = request.data.url_infos.first() {
790            url_info.url.as_str()
791        } else if !request.source_origin.is_empty() {
792            request.source_origin.as_str()
793        } else {
794            "Atelier"
795        };
796        let writer = NSString::from_str(writer_string);
797        let writer_ref: &ProtocolObject<dyn NSPasteboardWriting> =
798            ProtocolObject::from_ref(&*writer);
799        let dragging_item =
800            NSDraggingItem::initWithPasteboardWriter(NSDraggingItem::alloc(), writer_ref);
801
802        let mut mouse_location = window.mouseLocationOutsideOfEventStream();
803        mouse_location = self.convertPoint_fromView(mouse_location, None);
804        let image_size = image.size();
805        let drag_frame = NSRect::new(
806            NSPoint::new(
807                mouse_location.x - image_data.cursor_offset_x as f64,
808                mouse_location.y - image_size.height + image_data.cursor_offset_y as f64,
809            ),
810            image_size,
811        );
812        unsafe {
813            dragging_item.setDraggingFrame_contents(drag_frame, Some(&*image));
814        }
815
816        let mtm = MainThreadMarker::new().expect("BrowserViewMac must be on main thread");
817        let drag_event = NSApplication::sharedApplication(mtm)
818            .currentEvent()
819            .or_else(|| {
820                let screen_mouse = window.mouseLocationOutsideOfEventStream();
821                NSEvent::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure(
822                    NSEventType::LeftMouseDragged,
823                    screen_mouse,
824                    NSEventModifierFlags::empty(),
825                    0.0,
826                    window.windowNumber(),
827                    None,
828                    0,
829                    1,
830                    1.0,
831                )
832            });
833        let Some(drag_event) = drag_event else {
834            return false;
835        };
836
837        let operation_mask =
838            NSDragOperation::from_bits_truncate(request.allowed_operations.bits() as _);
839        let source = HostDragSource::new(
840            mtm,
841            Retained::from(self),
842            request.session_id,
843            operation_mask,
844        );
845        let source_ref: &ProtocolObject<dyn NSDraggingSource> = ProtocolObject::from_ref(&*source);
846        let items = NSArray::arrayWithObject(&*dragging_item);
847
848        self.beginDraggingSessionWithItems_event_source(&items, &drag_event, source_ref);
849
850        self.ivars()
851            .active_drag_source
852            .replace(Some(source.into_super().into()));
853
854        true
855    }
856
857    fn forward_key_event(&self, event: &NSEvent) {
858        let nsevent_ptr = NonNull::from(event).cast::<c_void>();
859        // Key conversion only needs keyboard fields here; routing target is resolved later.
860        let key_event = convert_nsevent_to_key_event(0, nsevent_ptr);
861
862        // Capture commands from doCommandBySelector
863        let commands = std::mem::take(&mut *self.ivars().edit_commands.borrow_mut());
864
865        self.ivars()
866            .delegate
867            .on_key_event(self, key_event, commands);
868    }
869
870    fn send_edit_action(&self, action: EditAction) {
871        self.ivars().delegate.on_edit_action(self, action);
872    }
873
874    fn forward_mouse_event(&self, event: &NSEvent) {
875        let nsevent_ptr = NonNull::from(event).cast::<c_void>();
876        let nsview_ptr = NonNull::from(self).cast::<c_void>();
877        let mouse_event =
878            convert_nsevent_to_mouse_event(0, nsevent_ptr, nsview_ptr, PointerType::Mouse, false);
879        self.ivars().delegate.on_mouse_event(self, mouse_event);
880    }
881
882    fn forward_mouse_wheel_event(&self, event: &NSEvent) {
883        let nsevent_ptr = NonNull::from(event).cast::<c_void>();
884        let nsview_ptr = NonNull::from(self).cast::<c_void>();
885        let wheel_event = convert_nsevent_to_mouse_wheel_event(0, nsevent_ptr, nsview_ptr);
886        self.ivars()
887            .delegate
888            .on_mouse_wheel_event(self, wheel_event);
889    }
890
891    fn send_mac_ime_event(&self, event: BrowserViewMacImeEvent) {
892        self.ivars().delegate.on_ime_event(self, event);
893    }
894
895    pub(crate) fn take_choice_menu_result(&self) -> Option<i32> {
896        let action = self
897            .ivars()
898            .choice_menu_selected_action
899            .replace(NO_CHOICE_MENU_ACTION);
900        (action != NO_CHOICE_MENU_ACTION).then_some(action)
901    }
902
903    fn send_mac_char_event(&self, event: KeyEvent) {
904        self.ivars().delegate.on_char_event(self, event);
905    }
906
907    fn update_marked_state(
908        &self,
909        has_marked_text: bool,
910        marked_range: NSRange,
911        selected_range: NSRange,
912    ) {
913        let ivars = self.ivars();
914        ivars.has_marked_text.set(has_marked_text);
915        ivars.marked_range.set(marked_range);
916        ivars.selected_range.set(selected_range);
917    }
918
919    fn mark_ime_handled(&self) {
920        self.ivars().ime_handled.set(true);
921    }
922
923    fn ime_candidate_rect(&self, range: NSRange) -> CGRect {
924        let fallback = if let Some(window) = self.window() {
925            window.frame()
926        } else {
927            CGRect::new(CGPoint::new(0.0, 0.0), CGSize::new(0.0, 0.0))
928        };
929
930        let bounds = self.ivars().ime_bounds.borrow();
931        let Some(bounds) = bounds.as_ref() else {
932            return fallback;
933        };
934
935        let layer_frame = self.ivars().browser_layer_frame.get();
936
937        if let Some(composition) = bounds.composition.as_ref()
938            && let Some(rect) = rect_for_composition_range(range, composition)
939        {
940            let rect = flip_rect_in_layer(rect, layer_frame.size.height);
941            return self.to_screen_rect(offset_rect(
942                rect,
943                layer_frame.origin.x,
944                layer_frame.origin.y,
945            ));
946        }
947
948        if let Some(selection) = bounds.selection.as_ref() {
949            let rect = flip_rect_in_layer(rect_from_selection(selection), layer_frame.size.height);
950            return self.to_screen_rect(offset_rect(
951                rect,
952                layer_frame.origin.x,
953                layer_frame.origin.y,
954            ));
955        }
956
957        fallback
958    }
959
960    fn to_screen_rect(&self, rect: CGRect) -> CGRect {
961        let window_rect = self.convertRect_toView(rect, None);
962
963        if let Some(window) = self.window() {
964            window.convertRectToScreen(window_rect)
965        } else {
966            window_rect
967        }
968    }
969
970    fn emit_native_drag_update(&self, session_id: u64, screen_point: NSPoint) {
971        let (widget_x, widget_y, screen_x, screen_y) = self.drag_points(screen_point);
972        let modifiers = self
973            .window()
974            .and_then(|_| MainThreadMarker::new())
975            .and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent())
976            .map(|event| event.modifierFlags().bits() as u32)
977            .unwrap_or(0);
978        self.ivars().delegate.on_native_drag_update(
979            self,
980            BrowserViewMacNativeDragUpdate {
981                session_id,
982                modifiers,
983                position_in_widget_x: widget_x,
984                position_in_widget_y: widget_y,
985                position_in_screen_x: screen_x,
986                position_in_screen_y: screen_y,
987            },
988        );
989    }
990
991    fn emit_native_drag_drop(&self, session_id: u64, screen_point: NSPoint) {
992        let (widget_x, widget_y, screen_x, screen_y) = self.drag_points(screen_point);
993        let modifiers = self
994            .window()
995            .and_then(|_| MainThreadMarker::new())
996            .and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent())
997            .map(|event| event.modifierFlags().bits() as u32)
998            .unwrap_or(0);
999        self.ivars().delegate.on_native_drag_drop(
1000            self,
1001            BrowserViewMacNativeDragDrop {
1002                session_id,
1003                modifiers,
1004                position_in_widget_x: widget_x,
1005                position_in_widget_y: widget_y,
1006                position_in_screen_x: screen_x,
1007                position_in_screen_y: screen_y,
1008            },
1009        );
1010    }
1011
1012    fn emit_native_drag_cancel(&self, session_id: u64) {
1013        self.ivars()
1014            .delegate
1015            .on_native_drag_cancel(self, session_id);
1016    }
1017
1018    fn drag_points(&self, screen_point: NSPoint) -> (f32, f32, f32, f32) {
1019        let mut local_point = NSPoint::new(0.0, 0.0);
1020        if let Some(window) = self.window() {
1021            let base_point = window.convertPointFromScreen(screen_point);
1022            local_point = self.convertPoint_fromView(base_point, None);
1023        }
1024        let bounds = self.bounds();
1025        let widget_x = local_point.x as f32;
1026        let widget_y = (bounds.size.height - local_point.y).max(0.0) as f32;
1027        (
1028            widget_x,
1029            widget_y,
1030            screen_point.x as f32,
1031            screen_point.y as f32,
1032        )
1033    }
1034
1035    fn contains_screen_point(&self, screen_point: NSPoint) -> bool {
1036        let Some(window) = self.window() else {
1037            return false;
1038        };
1039        let base_point = window.convertPointFromScreen(screen_point);
1040        let local_point = self.convertPoint_fromView(base_point, None);
1041        let bounds = self.bounds();
1042        local_point.x >= bounds.origin.x
1043            && local_point.x <= bounds.origin.x + bounds.size.width
1044            && local_point.y >= bounds.origin.y
1045            && local_point.y <= bounds.origin.y + bounds.size.height
1046    }
1047}
1048
1049#[inline]
1050fn ns_not_found_range() -> NSRange {
1051    NSRange::new(NSNotFound as usize, 0)
1052}
1053
1054fn build_ns_menu(
1055    mtm: MainThreadMarker,
1056    items: &[ContextMenuItem],
1057    target: &BrowserViewMac,
1058) -> Retained<NSMenu> {
1059    let title = NSString::from_str("");
1060    let menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), &title);
1061
1062    for item in items {
1063        if !item.visible {
1064            continue;
1065        }
1066
1067        if let Some(menu_item) = build_ns_menu_item(mtm, item, target) {
1068            menu.addItem(&menu_item);
1069        }
1070    }
1071
1072    menu
1073}
1074
1075fn build_ns_menu_item(
1076    mtm: MainThreadMarker,
1077    item: &ContextMenuItem,
1078    target: &BrowserViewMac,
1079) -> Option<Retained<NSMenuItem>> {
1080    let title_text = menu_item_title(item);
1081
1082    let menu_item = match item.r#type {
1083        ContextMenuItemType::Separator => {
1084            return Some(NSMenuItem::separatorItem(mtm));
1085        }
1086        ContextMenuItemType::Title => {
1087            let title = NSString::from_str(&title_text);
1088            return Some(NSMenuItem::sectionHeaderWithTitle(&title, mtm));
1089        }
1090        _ => {
1091            let title = NSString::from_str(&title_text);
1092            let key_equivalent = item
1093                .accelerator
1094                .as_ref()
1095                .map(|accel| accel.key_equivalent.as_str())
1096                .unwrap_or("");
1097            let key_equivalent = NSString::from_str(key_equivalent);
1098
1099            let action = if matches!(
1100                item.r#type,
1101                ContextMenuItemType::Submenu | ContextMenuItemType::ActionableSubmenu
1102            ) {
1103                None
1104            } else {
1105                Some(sel!(contextMenuItemSelected:))
1106            };
1107
1108            unsafe {
1109                NSMenuItem::initWithTitle_action_keyEquivalent(
1110                    NSMenuItem::alloc(mtm),
1111                    &title,
1112                    action,
1113                    &key_equivalent,
1114                )
1115            }
1116        }
1117    };
1118
1119    if matches!(
1120        item.r#type,
1121        ContextMenuItemType::Submenu | ContextMenuItemType::ActionableSubmenu
1122    ) {
1123        let submenu = build_ns_menu(mtm, &item.submenu, target);
1124        menu_item.setSubmenu(Some(&submenu));
1125    } else {
1126        unsafe {
1127            menu_item.setTarget(Some(target));
1128        }
1129    }
1130
1131    menu_item.setEnabled(item.enabled);
1132    menu_item.setHidden(!item.visible);
1133    menu_item.setTag(item.command_id as isize);
1134
1135    if let Some(subtitle) = menu_item_subtitle(item) {
1136        let subtitle = NSString::from_str(&subtitle);
1137        menu_item.setSubtitle(Some(&subtitle));
1138    }
1139
1140    if !item.minor_text.is_empty() {
1141        let tooltip = NSString::from_str(&item.minor_text);
1142        menu_item.setToolTip(Some(&tooltip));
1143    }
1144
1145    if let Some(icon) = build_ns_image(item.icon.as_ref().or(item.minor_icon.as_ref())) {
1146        menu_item.setImage(Some(&icon));
1147    }
1148
1149    if item.is_alerted {
1150        let badge = NSMenuItemBadge::alertsWithCount(1);
1151        menu_item.setBadge(Some(&badge));
1152    } else if item.is_new_feature {
1153        let badge = NSMenuItemBadge::newItemsWithCount(1);
1154        menu_item.setBadge(Some(&badge));
1155    }
1156
1157    if let Some(accel) = item.accelerator.as_ref() {
1158        let modifier_mask = NSEventModifierFlags::from_bits_truncate(accel.modifier_mask as _);
1159        menu_item.setKeyEquivalentModifierMask(modifier_mask);
1160    }
1161
1162    if matches!(
1163        item.r#type,
1164        ContextMenuItemType::Check | ContextMenuItemType::Radio
1165    ) {
1166        if item.checked {
1167            menu_item.setState(NSControlStateValueOn);
1168        } else {
1169            menu_item.setState(NSControlStateValueOff);
1170        }
1171    }
1172
1173    Some(menu_item)
1174}
1175
1176fn menu_item_title(item: &ContextMenuItem) -> String {
1177    let mut title = if item.label.is_empty() {
1178        item.accessible_name.clone()
1179    } else {
1180        item.label.clone()
1181    };
1182
1183    if item.may_have_mnemonics {
1184        title = strip_mnemonic(&title);
1185    }
1186
1187    title
1188}
1189
1190fn menu_item_subtitle(item: &ContextMenuItem) -> Option<String> {
1191    if !item.secondary_label.is_empty() {
1192        Some(item.secondary_label.clone())
1193    } else if !item.minor_text.is_empty() {
1194        Some(item.minor_text.clone())
1195    } else {
1196        None
1197    }
1198}
1199
1200fn strip_mnemonic(input: &str) -> String {
1201    let mut output = String::with_capacity(input.len());
1202    let mut chars = input.chars().peekable();
1203
1204    while let Some(ch) = chars.next() {
1205        if ch == '&' {
1206            if matches!(chars.peek(), Some('&')) {
1207                output.push('&');
1208                chars.next();
1209            }
1210            continue;
1211        }
1212        output.push(ch);
1213    }
1214
1215    output
1216}
1217
1218fn build_ns_image(icon: Option<&ContextMenuIcon>) -> Option<Retained<NSImage>> {
1219    let icon = icon?;
1220    if icon.png_bytes.is_empty() {
1221        return None;
1222    }
1223
1224    let data = unsafe {
1225        NSData::initWithBytes_length(
1226            NSData::alloc(),
1227            icon.png_bytes.as_ptr().cast(),
1228            icon.png_bytes.len() as NSUInteger,
1229        )
1230    };
1231    let image = NSImage::initWithData(NSImage::alloc(), &data)?;
1232    image.setSize(CGSize::new(icon.width as f64, icon.height as f64));
1233    Some(image)
1234}
1235
1236fn nsrange_to_text_range(range: NSRange) -> Option<ImeTextRange> {
1237    if range.location == NSNotFound as usize {
1238        return None;
1239    }
1240
1241    let start = range.location.min(i32::MAX as usize) as i32;
1242    let end = range.end().min(i32::MAX as usize).max(start as usize) as i32;
1243
1244    Some(ImeTextRange { start, end })
1245}
1246
1247fn composition_range_for_text(text: &str) -> NSRange {
1248    let length = text.encode_utf16().count();
1249    NSRange::new(0, length)
1250}
1251
1252fn build_char_event(template: Option<KeyEvent>, text: String) -> Option<KeyEvent> {
1253    if text.is_empty() {
1254        return None;
1255    }
1256
1257    let mut event =
1258        template.unwrap_or_else(|| KeyEvent::char_input(0, 0, 0, text.clone(), text.clone()));
1259    event.type_ = KeyEventType::Char;
1260    event.text = Some(text.clone());
1261
1262    if event
1263        .unmodified_text
1264        .as_deref()
1265        .map(str::is_empty)
1266        .unwrap_or(true)
1267    {
1268        event.unmodified_text = Some(text);
1269    }
1270
1271    Some(event)
1272}
1273
1274fn synthesized_char_text(template: Option<&KeyEvent>) -> Option<String> {
1275    let event = template?;
1276    event
1277        .text
1278        .as_ref()
1279        .filter(|text| !text.is_empty())
1280        .cloned()
1281        .or_else(|| {
1282            event
1283                .unmodified_text
1284                .as_ref()
1285                .filter(|text| !text.is_empty())
1286                .cloned()
1287        })
1288}
1289
1290fn extract_insert_text(value: &AnyObject) -> Option<String> {
1291    let mut text = if let Some(attributed) = value.downcast_ref::<NSAttributedString>() {
1292        Some(attributed.string().to_string())
1293    } else {
1294        value
1295            .downcast_ref::<NSString>()
1296            .map(|ns_string| ns_string.to_string())
1297    }?;
1298
1299    // Keep return/newline so Enter still emits a Char event, but drop other
1300    // control characters that should not be treated as text insertion.
1301    text = text
1302        .chars()
1303        .filter(|c| matches!(c, '\r' | '\n') || !c.is_control())
1304        .collect::<String>();
1305
1306    if text.is_empty() {
1307        return None;
1308    }
1309
1310    Some(text)
1311}
1312
1313// Chromium-derived VKEY constants mirrored for the marked-text accelerator
1314// guard. These are Chromium-compatible Windows virtual-key codes, not raw
1315// macOS `NSEvent.keyCode` values. `convert_nsevent_to_key_event` populates
1316// `KeyEvent.key_code` from Chromium's `windows_key_code`, so this guard must
1317// compare against the same VKEY values used by Chromium's
1318// `components/remote_cocoa/app_shim/bridged_content_view.mm`
1319// `ShouldIgnoreAcceleratorWithMarkedText`.
1320const VKEY_TAB: i32 = 0x09;
1321const VKEY_RETURN: i32 = 0x0D;
1322const VKEY_ESCAPE: i32 = 0x1B;
1323const VKEY_PRIOR: i32 = 0x21;
1324const VKEY_NEXT: i32 = 0x22;
1325const VKEY_LEFT: i32 = 0x25;
1326const VKEY_UP: i32 = 0x26;
1327const VKEY_RIGHT: i32 = 0x27;
1328const VKEY_DOWN: i32 = 0x28;
1329
1330fn should_ignore_accelerator_with_marked_text(event: &NSEvent) -> bool {
1331    let nsevent_ptr = NonNull::from(event).cast::<c_void>();
1332    let key_event = convert_nsevent_to_key_event(0, nsevent_ptr);
1333    should_ignore_accelerator_with_marked_text_key_event(&key_event)
1334}
1335
1336// Mirrors Chromium's macOS IME guard in
1337// components/remote_cocoa/app_shim/bridged_content_view.mm
1338// `ShouldIgnoreAcceleratorWithMarkedText`. While marked text is active,
1339// AppKit may consume these navigation/confirmation keys inside IME handling
1340// without calling our NSTextInputClient callbacks, so they must not be
1341// forwarded as page accelerators.
1342fn should_ignore_accelerator_with_marked_text_key_event(event: &KeyEvent) -> bool {
1343    matches!(
1344        event.key_code,
1345        VKEY_RETURN
1346            | VKEY_TAB
1347            | VKEY_ESCAPE
1348            | VKEY_LEFT
1349            | VKEY_UP
1350            | VKEY_RIGHT
1351            | VKEY_DOWN
1352            | VKEY_PRIOR
1353            | VKEY_NEXT
1354    )
1355}
1356
1357fn rect_for_composition_range(
1358    range: NSRange,
1359    composition: &ImeCompositionBounds,
1360) -> Option<CGRect> {
1361    if composition.range_start < 0 || composition.range_end < composition.range_start {
1362        return None;
1363    }
1364    if composition.character_bounds.is_empty() {
1365        return None;
1366    }
1367    if range.location == NSNotFound as usize {
1368        return None;
1369    }
1370
1371    let start = range.location.min(i32::MAX as usize) as i32;
1372    let end = range.end().min(i32::MAX as usize).max(range.location) as i32;
1373
1374    if start < composition.range_start || end > composition.range_end {
1375        return None;
1376    }
1377
1378    let local_start = (start - composition.range_start) as usize;
1379    if local_start >= composition.character_bounds.len() {
1380        return None;
1381    }
1382
1383    if range.length == 0 {
1384        return Some(rect_from_ime(&composition.character_bounds[local_start]));
1385    }
1386
1387    let local_end = (end - composition.range_start) as usize;
1388    let clamped_end = local_end.min(composition.character_bounds.len());
1389    if clamped_end <= local_start {
1390        return Some(rect_from_ime(&composition.character_bounds[local_start]));
1391    }
1392
1393    let mut rect = rect_from_ime(&composition.character_bounds[local_start]);
1394    for bounds in &composition.character_bounds[local_start + 1..clamped_end] {
1395        rect = union_rect(rect, rect_from_ime(bounds));
1396    }
1397
1398    Some(rect)
1399}
1400
1401fn rect_from_selection(selection: &TextSelectionBounds) -> CGRect {
1402    rect_from_ime(&selection.caret_rect)
1403}
1404
1405fn rect_from_ime(rect: &ImeRect) -> CGRect {
1406    CGRect::new(
1407        CGPoint::new(rect.x as f64, rect.y as f64),
1408        CGSize::new(rect.width as f64, rect.height as f64),
1409    )
1410}
1411
1412fn union_rect(a: CGRect, b: CGRect) -> CGRect {
1413    let min_x = a.origin.x.min(b.origin.x);
1414    let min_y = a.origin.y.min(b.origin.y);
1415    let max_x = (a.origin.x + a.size.width).max(b.origin.x + b.size.width);
1416    let max_y = (a.origin.y + a.size.height).max(b.origin.y + b.size.height);
1417
1418    CGRect::new(
1419        CGPoint::new(min_x, min_y),
1420        CGSize::new((max_x - min_x).max(0.0), (max_y - min_y).max(0.0)),
1421    )
1422}
1423
1424fn offset_rect(rect: CGRect, offset_x: f64, offset_y: f64) -> CGRect {
1425    CGRect::new(
1426        CGPoint::new(rect.origin.x + offset_x, rect.origin.y + offset_y),
1427        rect.size,
1428    )
1429}
1430
1431fn flip_rect_in_layer(rect: CGRect, layer_height: f64) -> CGRect {
1432    let flipped_y = (layer_height - (rect.origin.y + rect.size.height)).max(0.0);
1433    CGRect::new(CGPoint::new(rect.origin.x, flipped_y), rect.size)
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use cbf::data::key::{KeyEvent, KeyEventType};
1439    use objc2::rc::Retained;
1440    use objc2_foundation::NSString;
1441
1442    use super::{
1443        VKEY_DOWN, VKEY_ESCAPE, VKEY_LEFT, VKEY_NEXT, VKEY_PRIOR, VKEY_RETURN, VKEY_RIGHT,
1444        VKEY_TAB, VKEY_UP, build_char_event, extract_insert_text,
1445        should_ignore_accelerator_with_marked_text_key_event, synthesized_char_text,
1446    };
1447
1448    #[test]
1449    fn marked_text_guard_matches_chromium_ime_navigation_keys() {
1450        for key_code in [
1451            VKEY_RETURN,
1452            VKEY_TAB,
1453            VKEY_ESCAPE,
1454            VKEY_PRIOR,
1455            VKEY_NEXT,
1456            VKEY_LEFT,
1457            VKEY_UP,
1458            VKEY_RIGHT,
1459            VKEY_DOWN,
1460        ] {
1461            let event = KeyEvent::raw_key_down(0, key_code, 0);
1462            assert!(should_ignore_accelerator_with_marked_text_key_event(&event));
1463        }
1464    }
1465
1466    #[test]
1467    fn marked_text_guard_leaves_regular_typing_keys_alone() {
1468        let event = KeyEvent::raw_key_down(0, 0x41, 0);
1469        assert!(!should_ignore_accelerator_with_marked_text_key_event(
1470            &event
1471        ));
1472    }
1473
1474    #[test]
1475    fn char_event_preserves_key_metadata_from_pending_keydown() {
1476        let mut template = KeyEvent::raw_key_down(42, 191, 4);
1477        template.dom_code = Some("Slash".to_string());
1478        template.dom_key = Some("/".to_string());
1479        template.unmodified_text = Some("/".to_string());
1480
1481        let event = build_char_event(Some(template), "/".to_string()).unwrap();
1482
1483        assert_eq!(event.type_, KeyEventType::Char);
1484        assert_eq!(event.key_code, 191);
1485        assert_eq!(event.platform_key_code, 42);
1486        assert_eq!(event.modifiers, 4);
1487        assert_eq!(event.dom_code.as_deref(), Some("Slash"));
1488        assert_eq!(event.dom_key.as_deref(), Some("/"));
1489        assert_eq!(event.text.as_deref(), Some("/"));
1490        assert_eq!(event.unmodified_text.as_deref(), Some("/"));
1491    }
1492
1493    #[test]
1494    fn extract_insert_text_keeps_enter_but_drops_other_controls() {
1495        let text: Retained<NSString> = NSString::from_str("a\r\n\t\u{0}b");
1496        let extracted = extract_insert_text(text.as_ref()).unwrap();
1497        assert_eq!(extracted, "a\r\nb");
1498    }
1499
1500    #[test]
1501    fn synthesized_char_text_uses_enter_from_key_event() {
1502        let mut event = KeyEvent::raw_key_down(0, VKEY_RETURN, 0);
1503        event.text = Some("\r".to_string());
1504        event.unmodified_text = Some("\r".to_string());
1505
1506        assert_eq!(synthesized_char_text(Some(&event)).as_deref(), Some("\r"));
1507    }
1508}