#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::str::FromStr as _;
use waterui_core::Environment;
use waterui_core::layout::{ProposalSize, StretchAxis, ViewDimensions};
use waterui_graphics::gpu_surface::{GpuContext, GpuFrame, GpuView};
use waterui_graphics::input::{
Code, Key, Modifiers, NamedKey, ScrollUnit, SurfaceInputEvent, SurfacePointerButton,
};
use crate::page::{CefInputModifiers, CefKeyInput, CefPageHandle, CefPointerButton};
const CEF_WHEEL_DELTA: f64 = 120.0;
#[cfg(any(target_os = "macos", target_os = "linux"))]
const KEYCODE_UNMAPPED: u16 = 0xffff;
#[derive(Debug)]
pub struct CefSurfaceInput {
page: CefPageHandle,
modifiers: CefInputModifiers,
wheel_remainder: (f64, f64),
pending_text: Option<String>,
}
impl CefSurfaceInput {
#[must_use]
pub fn new(page: CefPageHandle) -> Self {
Self {
page,
modifiers: CefInputModifiers::default(),
wheel_remainder: (0.0, 0.0),
pending_text: None,
}
}
#[must_use]
pub const fn page(&self) -> &CefPageHandle {
&self.page
}
pub fn handle(&mut self, event: &SurfaceInputEvent) {
match event {
SurfaceInputEvent::Focus(focused) => self.page.set_focus(*focused),
SurfaceInputEvent::Modifiers(modifiers) => self.set_modifiers(*modifiers),
SurfaceInputEvent::PointerMove { position } => {
self.page
.pointer_move(position.x, position.y, self.modifiers);
}
SurfaceInputEvent::PointerButton {
pressed,
button,
position,
} => self.pointer_button(*pressed, *button, position.x, position.y),
SurfaceInputEvent::Scroll {
position,
delta_x,
delta_y,
unit,
..
} => self.scroll(position.x, position.y, *delta_x, *delta_y, *unit),
SurfaceInputEvent::Key {
pressed,
key,
code,
modifiers,
..
} => {
self.set_modifiers(*modifiers);
self.key(*pressed, key, *code);
}
SurfaceInputEvent::TextInput(text) => {
let previous = self.pending_text.replace(text.to_string());
assert!(
previous.is_none(),
"CEF received consecutive text input without the corresponding key event"
);
}
SurfaceInputEvent::CompositionStart => {}
SurfaceInputEvent::CompositionUpdate { text, caret } => {
let selection = composition_selection(text, *caret);
self.page.set_composition(text, selection, selection, None);
}
SurfaceInputEvent::CompositionCommit(text) => self.page.commit_text(text, None),
SurfaceInputEvent::CompositionCancel => self.page.cancel_composition(),
}
}
const fn set_modifiers(&mut self, modifiers: Modifiers) {
self.modifiers = CefInputModifiers {
shift: modifiers.contains(Modifiers::SHIFT),
control: modifiers.contains(Modifiers::CONTROL),
alt: modifiers.contains(Modifiers::ALT),
command: modifiers.contains(Modifiers::META),
..self.modifiers
};
}
fn pointer_button(&mut self, pressed: bool, button: SurfacePointerButton, x: f64, y: f64) {
let Some(button) = cef_pointer_button(button) else {
if pressed {
match button {
SurfacePointerButton::Back => self.page.go_back(),
SurfacePointerButton::Forward => self.page.go_forward(),
_ => unreachable!("only navigation buttons omit a CEF pointer button"),
}
}
return;
};
match button {
CefPointerButton::Primary => self.modifiers.primary_button = pressed,
CefPointerButton::Middle => self.modifiers.middle_button = pressed,
CefPointerButton::Secondary => self.modifiers.secondary_button = pressed,
}
self.page
.pointer_button(pressed, button, x, y, self.modifiers);
}
fn scroll(&mut self, x: f64, y: f64, delta_x: f64, delta_y: f64, unit: ScrollUnit) {
if delta_x == 0.0 && delta_y == 0.0 {
return;
}
let multiplier = match unit {
ScrollUnit::Line => CEF_WHEEL_DELTA,
ScrollUnit::Pixel => 1.0,
};
let delta_x = delta_x.mul_add(multiplier, self.wheel_remainder.0);
let delta_y = delta_y.mul_add(multiplier, self.wheel_remainder.1);
let integral_x = delta_x.round();
let integral_y = delta_y.round();
self.wheel_remainder = (delta_x - integral_x, delta_y - integral_y);
self.page
.scroll(x, y, integral_x, integral_y, self.modifiers);
}
fn key(&mut self, pressed: bool, key: &Key, code: Code) {
let text = if pressed {
self.pending_text.take()
} else {
None
};
let text_character = text.as_deref().and_then(single_cef_character);
let input = CefKeyInput {
native_keycode: native_key_code(code),
keyval: windows_virtual_key(key),
character: text_character.or_else(|| key_character(key)),
};
self.page.key(pressed, input, self.modifiers);
#[cfg(target_os = "macos")]
if pressed && let Some(command) = MacEditShortcut::from_input(key, self.modifiers) {
command.execute(&self.page);
}
if text_character.is_none()
&& let Some(text) = text
{
self.page.commit_text(&text, None);
}
}
}
pub struct CefInputGpuView<V> {
view: V,
input: CefSurfaceInput,
}
impl<V> CefInputGpuView<V> {
pub const fn new(view: V, input: CefSurfaceInput) -> Self {
Self { view, input }
}
}
impl<V: GpuView> GpuView for CefInputGpuView<V> {
#[expect(
clippy::future_not_send,
reason = "CEF and WaterUI view state are confined to the UI thread"
)]
async fn setup(&mut self, ctx: &GpuContext<'_>, env: &mut Environment) {
self.view.setup(ctx, env).await;
}
fn render(&mut self, frame: &mut GpuFrame) {
self.view.render(frame);
}
fn preferred_surface_hdr(&self) -> Option<bool> {
self.view.preferred_surface_hdr()
}
fn wants_input_events(&self) -> bool {
true
}
fn input(&mut self, event: &SurfaceInputEvent) {
self.input.handle(event);
}
fn ime_caret(&self) -> Option<kurbo::Rect> {
self.view.ime_caret()
}
fn measure(&self, proposal: ProposalSize) -> ViewDimensions {
self.view.measure(proposal)
}
fn stretch_axis(&self) -> StretchAxis {
self.view.stretch_axis()
}
fn priority(&self) -> i32 {
self.view.priority()
}
}
const fn cef_pointer_button(button: SurfacePointerButton) -> Option<CefPointerButton> {
match button {
SurfacePointerButton::Primary => Some(CefPointerButton::Primary),
SurfacePointerButton::Middle => Some(CefPointerButton::Middle),
SurfacePointerButton::Secondary => Some(CefPointerButton::Secondary),
SurfacePointerButton::Back | SurfacePointerButton::Forward => None,
}
}
fn composition_selection(text: &str, caret: Option<usize>) -> u32 {
let caret = caret.unwrap_or(text.len());
assert!(
text.is_char_boundary(caret),
"CEF composition caret {caret} is not a character boundary of {text:?}"
);
u32::try_from(text[..caret].encode_utf16().count())
.expect("CEF composition caret exceeds u32 UTF-16 code units")
}
fn single_cef_character(text: &str) -> Option<char> {
let mut characters = text.chars();
let character = characters.next()?;
(characters.next().is_none() && character.len_utf16() == 1).then_some(character)
}
fn key_character(key: &Key) -> Option<char> {
match key {
Key::Character(value) => single_cef_character(value),
Key::Named(NamedKey::Backspace) => Some('\u{7f}'),
Key::Named(NamedKey::Tab) => Some('\t'),
Key::Named(NamedKey::Enter) => Some('\r'),
Key::Named(NamedKey::Escape) => Some('\u{1b}'),
Key::Named(_) => None,
}
}
fn windows_virtual_key(key: &Key) -> u32 {
match key {
Key::Character(value) => value
.chars()
.next()
.map_or(0, |character| character.to_ascii_uppercase().into()),
Key::Named(named) => named_virtual_key(*named),
}
}
const fn named_virtual_key(key: NamedKey) -> u32 {
match key {
NamedKey::Backspace => 0x08,
NamedKey::Tab => 0x09,
NamedKey::Enter => 0x0d,
NamedKey::Shift => 0x10,
NamedKey::Control => 0x11,
NamedKey::Alt => 0x12,
NamedKey::Escape => 0x1b,
NamedKey::PageUp => 0x21,
NamedKey::PageDown => 0x22,
NamedKey::End => 0x23,
NamedKey::Home => 0x24,
NamedKey::ArrowLeft => 0x25,
NamedKey::ArrowUp => 0x26,
NamedKey::ArrowRight => 0x27,
NamedKey::ArrowDown => 0x28,
NamedKey::Insert => 0x2d,
NamedKey::Delete => 0x2e,
NamedKey::F1 => 0x70,
NamedKey::F2 => 0x71,
NamedKey::F3 => 0x72,
NamedKey::F4 => 0x73,
NamedKey::F5 => 0x74,
NamedKey::F6 => 0x75,
NamedKey::F7 => 0x76,
NamedKey::F8 => 0x77,
NamedKey::F9 => 0x78,
NamedKey::F10 => 0x79,
NamedKey::F11 => 0x7a,
NamedKey::F12 => 0x7b,
_ => 0,
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn native_key_code(code: Code) -> u32 {
let Ok(mapping) = keycode::KeyMappingCode::from_str(&code.to_string()) else {
return 0;
};
let map = keycode::KeyMap::from(mapping);
#[cfg(target_os = "macos")]
let native = map.mac;
#[cfg(target_os = "linux")]
let native = map.xkb;
if native == KEYCODE_UNMAPPED {
0
} else {
u32::from(native)
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
const fn native_key_code(_code: Code) -> u32 {
0
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MacEditShortcut {
Undo,
Redo,
Cut,
Copy,
Paste,
SelectAll,
}
#[cfg(target_os = "macos")]
impl MacEditShortcut {
fn from_input(key: &Key, modifiers: CefInputModifiers) -> Option<Self> {
if !modifiers.command || modifiers.control || modifiers.alt {
return None;
}
let Key::Character(value) = key else {
return None;
};
let character = single_cef_character(value)?.to_ascii_lowercase();
Some(match (character, modifiers.shift) {
('z', false) => Self::Undo,
('z', true) => Self::Redo,
('x', false) => Self::Cut,
('c', false) => Self::Copy,
('v', false) => Self::Paste,
('a', false) => Self::SelectAll,
_ => return None,
})
}
fn execute(self, page: &CefPageHandle) {
match self {
Self::Undo => page.undo(),
Self::Redo => page.redo(),
Self::Cut => page.cut(),
Self::Copy => page.copy(),
Self::Paste => page.paste(),
Self::SelectAll => page.select_all(),
}
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "macos")]
use super::{CefInputModifiers, MacEditShortcut};
use super::{
Code, Key, NamedKey, composition_selection, key_character, native_key_code,
single_cef_character, windows_virtual_key,
};
#[test]
fn character_keys_identify_themselves_by_their_uppercase_virtual_key() {
assert_eq!(
windows_virtual_key(&Key::Character("a".into())),
u32::from('A')
);
assert_eq!(
windows_virtual_key(&Key::Character(" ".into())),
u32::from(' ')
);
assert_eq!(windows_virtual_key(&Key::Named(NamedKey::ArrowLeft)), 0x25);
assert_eq!(windows_virtual_key(&Key::Named(NamedKey::BrowserSearch)), 0);
}
#[test]
fn only_one_bmp_character_uses_the_key_character_path() {
assert_eq!(single_cef_character("W"), Some('W'));
assert_eq!(single_cef_character(""), None);
assert_eq!(single_cef_character("UI"), None);
assert_eq!(single_cef_character("🚀"), None);
}
#[test]
fn editing_keys_preserve_their_character_payloads() {
assert_eq!(key_character(&Key::Character("a".into())), Some('a'));
assert_eq!(
key_character(&Key::Named(NamedKey::Backspace)),
Some('\u{7f}')
);
assert_eq!(key_character(&Key::Named(NamedKey::ArrowLeft)), None);
}
#[test]
fn physical_codes_resolve_to_chromium_hardware_codes() {
#[cfg(target_os = "macos")]
{
assert_eq!(native_key_code(Code::KeyA), 0x00);
assert_eq!(native_key_code(Code::Escape), 0x35);
assert_eq!(native_key_code(Code::ArrowLeft), 0x7b);
assert_eq!(native_key_code(Code::Lang1), 0);
}
#[cfg(target_os = "linux")]
{
assert_eq!(native_key_code(Code::KeyA), 0x26);
assert_eq!(native_key_code(Code::Escape), 0x09);
assert_eq!(native_key_code(Code::ArrowLeft), 0x71);
}
assert_eq!(native_key_code(Code::Unidentified), 0);
}
#[test]
fn composition_carets_convert_from_bytes_to_utf16_code_units() {
assert_eq!(composition_selection("日本🚀", Some(6)), 2);
assert_eq!(composition_selection("日本🚀", None), 4);
assert_eq!(composition_selection("abc", Some(1)), 1);
}
#[test]
#[should_panic(expected = "not a character boundary")]
fn a_composition_caret_inside_a_character_is_a_bug_in_the_backend() {
let _ = composition_selection("日本", Some(1));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_standard_edit_shortcuts_map_to_cef_frame_commands() {
let command = CefInputModifiers {
command: true,
..Default::default()
};
let shifted_command = CefInputModifiers {
shift: true,
..command
};
assert_eq!(
MacEditShortcut::from_input(&Key::Character("a".into()), command),
Some(MacEditShortcut::SelectAll)
);
assert_eq!(
MacEditShortcut::from_input(&Key::Character("c".into()), command),
Some(MacEditShortcut::Copy)
);
assert_eq!(
MacEditShortcut::from_input(&Key::Character("x".into()), command),
Some(MacEditShortcut::Cut)
);
assert_eq!(
MacEditShortcut::from_input(&Key::Character("v".into()), command),
Some(MacEditShortcut::Paste)
);
assert_eq!(
MacEditShortcut::from_input(&Key::Character("z".into()), command),
Some(MacEditShortcut::Undo)
);
assert_eq!(
MacEditShortcut::from_input(&Key::Character("Z".into()), shifted_command),
Some(MacEditShortcut::Redo)
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_edit_shortcuts_reject_nonstandard_modifier_combinations() {
let command_control = CefInputModifiers {
command: true,
control: true,
..Default::default()
};
let shifted_command = CefInputModifiers {
command: true,
shift: true,
..Default::default()
};
assert_eq!(
MacEditShortcut::from_input(&Key::Character("a".into()), command_control),
None
);
assert_eq!(
MacEditShortcut::from_input(&Key::Character("a".into()), shifted_command),
None
);
assert_eq!(
MacEditShortcut::from_input(&Key::Character("a".into()), CefInputModifiers::default()),
None
);
}
}