bevy_cef 0.14.0

Bevy CEF integration for web rendering
use crate::common::WebviewSource;
use crate::focus::FocusedWebview;
use bevy::input::keyboard::KeyboardInput;
use bevy::prelude::*;
use bevy_cef_core::prelude::{Browsers, EditCommand, create_cef_key_events, keyboard_modifiers};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;

/// A keyboard modifier snapshot used by [`CefKeyboardFilter`] entries.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
pub struct ModifiersState {
    pub alt: bool,
    pub ctrl: bool,
    pub shift: bool,
    pub logo: bool,
}

/// Embedder-provided filter: `(focused webview, physical key, modifiers)` triples
/// that must NOT be delivered to CEF this frame (the embedder routes them
/// elsewhere, e.g. to a PTY). The embedder fills this each frame before the
/// keyboard-delivery systems run (see [`KeyboardDeliverSet`]).
#[derive(Resource, Default)]
pub struct CefKeyboardFilter {
    suppressed: HashSet<(Entity, KeyCode, ModifiersState)>,
}

impl CefKeyboardFilter {
    /// Replaces the suppressed set.
    pub fn set(&mut self, entries: impl IntoIterator<Item = (Entity, KeyCode, ModifiersState)>) {
        self.suppressed = entries.into_iter().collect();
    }

    /// Whether `(webview, code, mods)` is withheld from CEF this frame.
    pub fn contains(&self, webview: Entity, code: KeyCode, mods: ModifiersState) -> bool {
        self.suppressed.contains(&(webview, code, mods))
    }
}

/// System set covering the keyboard-delivery systems, so an embedder can order
/// its `CefKeyboardFilter` population `.before(KeyboardDeliverSet)`.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct KeyboardDeliverSet;

/// The plugin to handle keyboard inputs.
///
/// Keyboard and IME input is delivered to the webview that currently holds
/// focus ([`FocusedWebview`]), which is set when a webview is clicked. A webview
/// therefore receives keyboard input only after it has been clicked at least
/// once; while no webview is focused, input is dropped.
///
/// To use IME, you need to set [`Window::ime_enabled`](bevy::prelude::Window) to `true`.
pub(super) struct KeyboardPlugin;

impl Plugin for KeyboardPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<IsImeCommiting>()
            .init_resource::<IsImeComposing>()
            .init_resource::<CefKeyboardFilter>();

        app.add_systems(
            Update,
            (
                // Workaround for bevy_winit not calling `set_ime_allowed` on initial window
                // creation when `Window::ime_enabled` is `true` from the start.
                activate_ime,
                ime_event.run_if(on_message::<Ime>),
                send_key_event.run_if(on_message::<KeyboardInput>),
            )
                .chain()
                .in_set(KeyboardDeliverSet),
        );
    }
}

/// Workaround: bevy_winit does not call `winit::Window::set_ime_allowed()` during initial window
/// creation when `Window::ime_enabled` is `true`. This means `Ime` events are never generated.
///
/// To trigger bevy_winit's own `changed_windows` system, we temporarily toggle `ime_enabled` off
/// then back on over two frames, which causes the change detection to fire and call
/// `set_ime_allowed(true)` internally.
fn activate_ime(mut windows: Query<&mut Window>, mut state: Local<ImeActivationState>) {
    match *state {
        ImeActivationState::Pending => {
            for mut window in windows.iter_mut() {
                if window.ime_enabled {
                    window.ime_enabled = false;
                    *state = ImeActivationState::Toggled;
                }
            }
        }
        ImeActivationState::Toggled => {
            for mut window in windows.iter_mut() {
                if !window.ime_enabled {
                    window.ime_enabled = true;
                    *state = ImeActivationState::Done;
                }
            }
        }
        ImeActivationState::Done => {}
    }
}

#[derive(Default)]
enum ImeActivationState {
    #[default]
    Pending,
    Toggled,
    Done,
}

#[derive(Resource, Default, Serialize, Deserialize, Reflect)]
#[reflect(Default, Serialize, Deserialize)]
struct IsImeCommiting(bool);

/// Tracks whether CEF has an active IME composition.
///
/// Set to `true` when `ImeSetComposition(non-empty)` is called, cleared only on
/// `ImeCancelComposition()` or `ImeCommitText()`. Critically, empty `Preedit` does NOT clear this
/// flag — this avoids the same-frame ordering problem where `ime_event` processes `Preedit { "" }`
/// before `send_key_event` processes the BackSpace that caused it.
#[derive(Resource, Default, Serialize, Deserialize, Reflect)]
#[reflect(Default, Serialize, Deserialize)]
struct IsImeComposing(bool);

#[allow(clippy::too_many_arguments)]
fn send_key_event(
    mut er: MessageReader<KeyboardInput>,
    mut is_ime_commiting: ResMut<IsImeCommiting>,
    mut is_ime_composing: ResMut<IsImeComposing>,
    input: Res<ButtonInput<KeyCode>>,
    browsers: NonSend<Browsers>,
    focused: Res<FocusedWebview>,
    filter: Res<CefKeyboardFilter>,
    webviews: Query<Entity, With<WebviewSource>>,
) {
    let modifiers = keyboard_modifiers(&input);
    let target = focused.0.filter(|e| webviews.get(*e).is_ok());
    for event in er.read() {
        if (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Backspace)
            && is_ime_commiting.0
        {
            is_ime_commiting.0 = false;
            continue;
        }
        if event.key_code == KeyCode::Backspace && is_ime_composing.0 {
            is_ime_composing.0 = false;
            continue;
        }
        // Deliver only to the explicitly-focused webview. When nothing is
        // focused — before the first click, or focus is on a non-webview surface
        // (e.g. a terminal pane in an embedder) — no webview receives keys.
        // Broadcasting to all webviews here leaked keystrokes to a
        // previously-focused webview: `send_key` is gated on CEF's
        // `focused_frame()`, which survives `set_focus(false)`, so the blurred
        // webview kept receiving input.
        let Some(webview) = target else {
            continue;
        };
        let ms = ModifiersState {
            alt: input.pressed(KeyCode::AltLeft) || input.pressed(KeyCode::AltRight),
            ctrl: input.pressed(KeyCode::ControlLeft) || input.pressed(KeyCode::ControlRight),
            shift: input.pressed(KeyCode::ShiftLeft) || input.pressed(KeyCode::ShiftRight),
            logo: input.pressed(KeyCode::SuperLeft) || input.pressed(KeyCode::SuperRight),
        };
        if filter.contains(webview, event.key_code, ms) {
            // NOTE: the embedder routes this key elsewhere (e.g. a PTY); skipping this
            // `continue` would leak the key to CEF despite the filter.
            continue;
        }
        for key_event in create_cef_key_events(modifiers, event) {
            browsers.send_key(&webview, key_event);
        }

        // macOS windowless (OSR) has no real NSView, so CEF never translates
        // keyboard shortcuts into editor commands (copy/cut/paste/…). Detect the
        // ⌘ shortcuts and invoke the command explicitly, in addition to the key
        // event above (which still drives the DOM `keydown`). This block sits
        // after the `filter.contains` `continue`, so suppressed keys never reach
        // it; the `repeat` / IME guards avoid multi-fire and composition clashes.
        #[cfg(target_os = "macos")]
        if event.state == bevy::input::ButtonState::Pressed
            && !event.repeat
            && !is_ime_composing.0
            && let Some(cmd) = edit_command_for(event.key_code, ms)
        {
            browsers.exec_edit_command(&webview, cmd);
        }
    }
}

fn ime_event(
    mut er: MessageReader<Ime>,
    mut is_ime_commiting: ResMut<IsImeCommiting>,
    mut is_ime_composing: ResMut<IsImeComposing>,
    browsers: NonSend<Browsers>,
    focused: Res<FocusedWebview>,
    webviews: Query<Entity, With<WebviewSource>>,
) {
    let has_target = focused.0.filter(|e| webviews.get(*e).is_ok()).is_some();
    if !has_target {
        // No webview is focused (focus is on a non-webview surface, e.g. a
        // terminal pane in an embedder). Cancel any composition still live on
        // the previously-focused webview — CEF keeps its focused frame after
        // `set_focus(false)`, so the cancel reaches it — and clear the shared
        // IME flags so a later keystroke on a re-focused webview is not wrongly
        // suppressed by stale composition state.
        if is_ime_composing.0 {
            browsers.ime_cancel_composition();
        }
        is_ime_composing.0 = false;
        is_ime_commiting.0 = false;
    }
    for event in er.read() {
        // Drive CEF IME only when a webview is focused. With focus on a
        // non-webview surface (e.g. a terminal pane in an embedder), routing IME
        // here would leak composition to a previously-focused webview whose CEF
        // `focused_frame()` survives `set_focus(false)` — the same leak as keys.
        if !has_target {
            continue;
        }
        match event {
            Ime::Preedit { value, cursor, .. } => {
                if value.is_empty() {
                    browsers.ime_cancel_composition();
                } else {
                    browsers.set_ime_composition(value, cursor.map(|(_, e)| e as u32));
                    is_ime_composing.0 = true;
                }
            }
            Ime::Commit { value, .. } => {
                browsers.set_ime_commit_text(value);
                is_ime_commiting.0 = true;
                is_ime_composing.0 = false;
            }
            Ime::Disabled { .. } => {
                browsers.ime_cancel_composition();
                is_ime_composing.0 = false;
            }
            _ => {}
        }
    }
}

/// Maps a macOS clipboard/editing keyboard shortcut to its [`EditCommand`].
///
/// Matches on the physical [`KeyCode`] (layout-independent, consistent with
/// [`CefKeyboardFilter`]). Only a plain ⌘ combination qualifies — ⌃/⌥ held
/// alongside denotes a different shortcut and yields `None`. ⇧ selects redo
/// vs. undo for `KeyZ`.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn edit_command_for(code: KeyCode, ms: ModifiersState) -> Option<EditCommand> {
    if !ms.logo || ms.ctrl || ms.alt {
        return None;
    }
    Some(match (code, ms.shift) {
        (KeyCode::KeyC, false) => EditCommand::Copy,
        (KeyCode::KeyX, false) => EditCommand::Cut,
        (KeyCode::KeyV, false) => EditCommand::Paste,
        (KeyCode::KeyA, false) => EditCommand::SelectAll,
        (KeyCode::KeyZ, false) => EditCommand::Undo,
        (KeyCode::KeyZ, true) => EditCommand::Redo,
        _ => return None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filter_contains_matches_only_exact_triple() {
        let mut f = CefKeyboardFilter::default();
        let e = Entity::from_raw_u32(7).unwrap();
        let alt = ModifiersState {
            alt: true,
            ..Default::default()
        };
        f.set([(e, KeyCode::KeyH, alt)]);
        assert!(f.contains(e, KeyCode::KeyH, alt));
        assert!(!f.contains(e, KeyCode::KeyH, ModifiersState::default()));
        assert!(!f.contains(Entity::from_raw_u32(8).unwrap(), KeyCode::KeyH, alt));
    }

    fn cmd(shift: bool, ctrl: bool, alt: bool) -> ModifiersState {
        ModifiersState {
            logo: true,
            shift,
            ctrl,
            alt,
        }
    }

    #[test]
    fn edit_command_for_maps_plain_cmd_shortcuts() {
        let m = cmd(false, false, false);
        assert_eq!(edit_command_for(KeyCode::KeyC, m), Some(EditCommand::Copy));
        assert_eq!(edit_command_for(KeyCode::KeyX, m), Some(EditCommand::Cut));
        assert_eq!(edit_command_for(KeyCode::KeyV, m), Some(EditCommand::Paste));
        assert_eq!(
            edit_command_for(KeyCode::KeyA, m),
            Some(EditCommand::SelectAll)
        );
        assert_eq!(edit_command_for(KeyCode::KeyZ, m), Some(EditCommand::Undo));
    }

    #[test]
    fn edit_command_for_shift_cmd_z_is_redo() {
        assert_eq!(
            edit_command_for(KeyCode::KeyZ, cmd(true, false, false)),
            Some(EditCommand::Redo)
        );
    }

    #[test]
    fn edit_command_for_rejects_extra_modifiers_and_bare_keys() {
        // ⌃/⌥ held alongside ⌘ → different shortcut.
        assert_eq!(
            edit_command_for(KeyCode::KeyC, cmd(false, true, false)),
            None
        );
        assert_eq!(
            edit_command_for(KeyCode::KeyC, cmd(false, false, true)),
            None
        );
        // ⇧⌘C is not a copy shortcut.
        assert_eq!(
            edit_command_for(KeyCode::KeyC, cmd(true, false, false)),
            None
        );
        // No ⌘ at all.
        assert_eq!(
            edit_command_for(KeyCode::KeyC, ModifiersState::default()),
            None
        );
        // Unmapped key.
        assert_eq!(
            edit_command_for(KeyCode::KeyB, cmd(false, false, false)),
            None
        );
    }
}