bevy_cef 0.11.0

Bevy CEF integration for web rendering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
use crate::common::WebviewSource;
use crate::focus::FocusedWebview;
use bevy::input::keyboard::KeyboardInput;
use bevy::prelude::*;
#[cfg(not(target_os = "windows"))]
use bevy_cef_core::prelude::Browsers;
#[cfg(target_os = "windows")]
use bevy_cef_core::prelude::BrowsersProxy;
use bevy_cef_core::prelude::{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>();

        #[cfg(not(target_os = "windows"))]
        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),
        );

        #[cfg(target_os = "windows")]
        app.add_systems(
            Update,
            (
                activate_ime,
                ime_event_win.run_if(on_message::<Ime>),
                send_key_event_win.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);

#[cfg(not(target_os = "windows"))]
#[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);
        }
    }
}

#[cfg(not(target_os = "windows"))]
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;
            }
            _ => {}
        }
    }
}

#[cfg(target_os = "windows")]
#[allow(clippy::too_many_arguments)]
fn send_key_event_win(
    mut er: MessageReader<KeyboardInput>,
    mut is_ime_commiting: ResMut<IsImeCommiting>,
    mut is_ime_composing: ResMut<IsImeComposing>,
    input: Res<ButtonInput<KeyCode>>,
    proxy: Res<BrowsersProxy>,
    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. See the non-Windows
        // variant for why broadcasting on `None` leaks keys to a blurred webview,
        // and why a webview receives keys only after it is first clicked.
        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) {
            proxy.send_key(&webview, key_event);
        }
    }
}

#[cfg(target_os = "windows")]
fn ime_event_win(
    mut er: MessageReader<Ime>,
    mut is_ime_commiting: ResMut<IsImeCommiting>,
    mut is_ime_composing: ResMut<IsImeComposing>,
    proxy: Res<BrowsersProxy>,
    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 {
        // See `ime_event`: finalize any composition on the now-blurred webview
        // and clear the shared IME flags when no webview is focused.
        if is_ime_composing.0 {
            proxy.ime_cancel_composition();
        }
        is_ime_composing.0 = false;
        is_ime_commiting.0 = false;
    }
    for event in er.read() {
        // See `ime_event`: drive CEF IME only when a webview is focused.
        if !has_target {
            continue;
        }
        match event {
            Ime::Preedit { value, cursor, .. } => {
                if value.is_empty() {
                    proxy.ime_cancel_composition();
                } else {
                    proxy.set_ime_composition(value, cursor.map(|(_, e)| e as u32));
                    is_ime_composing.0 = true;
                }
            }
            Ime::Commit { value, .. } => {
                proxy.set_ime_commit_text(value);
                is_ime_commiting.0 = true;
                is_ime_composing.0 = false;
            }
            Ime::Disabled { .. } => {
                proxy.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
        );
    }
}