use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use crate::window::LiveBackground;
use crate::Rgba;
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq, Hash)]
#[serde(transparent)]
pub struct WindowId(pub u64);
impl WindowId {
pub const PRIMARY: Self = Self(1);
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum DesktopEvent {
Ready,
WindowResized {
window_id: WindowId,
width: u32,
height: u32,
},
WindowMoved { window_id: WindowId, x: i32, y: i32 },
WindowMaximized { window_id: WindowId },
WindowUnmaximized { window_id: WindowId },
WindowMinimized { window_id: WindowId },
WindowRestored { window_id: WindowId },
WindowEnteredFullscreen { window_id: WindowId },
WindowLeftFullscreen { window_id: WindowId },
WindowFocused { window_id: WindowId },
WindowBlurred { window_id: WindowId },
WindowCloseRequested { window_id: WindowId },
WindowClosed { window_id: WindowId },
ThemeChanged { dark: bool },
ScaleFactorChanged { scale: f64 },
NavigationRequested { window_id: WindowId, url: String },
NavigationCompleted { window_id: WindowId, url: String },
Exiting,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum EventResponse {
#[default]
Continue,
PreventDefault,
}
pub type EventHandler = dyn Fn(&DesktopEvent) -> EventResponse + Send + Sync + 'static;
pub const MAX_EVENT_HANDLERS: usize = 256;
#[derive(Debug, Error, Eq, PartialEq)]
pub enum EventRegistrationError {
#[error("desktop event handler registry is full (max {MAX_EVENT_HANDLERS}); help: drop unused subscriptions before registering more handlers")]
Capacity,
#[error("desktop event handler registry is closed; help: register handlers before the desktop frame shuts down")]
Closed,
#[error("desktop event handler registry is unavailable because a callback panicked; help: restart the application")]
Unavailable,
}
struct EventRegistration {
id: u64,
handler: Arc<EventHandler>,
}
#[derive(Default)]
struct EventRegistryState {
handlers: Option<Arc<[EventRegistration]>>,
next_id: u64,
closed: bool,
}
#[derive(Clone, Default)]
pub struct EventRegistry {
inner: Arc<Mutex<EventRegistryState>>,
}
#[must_use = "dropping the subscription immediately removes the event handler"]
pub struct EventSubscription {
owner: std::sync::Weak<Mutex<EventRegistryState>>,
id: u64,
}
impl Drop for EventSubscription {
fn drop(&mut self) {
let Some(owner) = self.owner.upgrade() else {
return;
};
remove_event_handler(&owner, self.id);
}
}
impl EventRegistry {
pub fn on_event<F>(&self, handler: F) -> Result<(), EventRegistrationError>
where
F: Fn(&DesktopEvent) -> EventResponse + Send + Sync + 'static,
{
self.register(Arc::new(handler)).map(|_| ())
}
pub fn subscribe<F>(&self, handler: F) -> Result<EventSubscription, EventRegistrationError>
where
F: Fn(&DesktopEvent) -> EventResponse + Send + Sync + 'static,
{
let id = self.register(Arc::new(handler))?;
Ok(EventSubscription {
owner: Arc::downgrade(&self.inner),
id,
})
}
fn register(&self, handler: Arc<EventHandler>) -> Result<u64, EventRegistrationError> {
let mut state = self
.inner
.lock()
.map_err(|_| EventRegistrationError::Unavailable)?;
if state.closed {
return Err(EventRegistrationError::Closed);
}
let len = state.handlers.as_ref().map_or(0, |handlers| handlers.len());
if len >= MAX_EVENT_HANDLERS {
return Err(EventRegistrationError::Capacity);
}
let id = state.next_id;
state.next_id = state.next_id.wrapping_add(1);
let mut next = Vec::with_capacity(len + 1);
if let Some(handlers) = state.handlers.as_ref() {
next.extend(handlers.iter().map(|entry| EventRegistration {
id: entry.id,
handler: Arc::clone(&entry.handler),
}));
}
next.push(EventRegistration { id, handler });
let previous = state.handlers.replace(next.into());
drop(state);
drop(previous);
Ok(id)
}
#[must_use]
pub fn dispatch(&self, event: &DesktopEvent) -> EventResponse {
let handlers = {
let Ok(state) = self.inner.lock() else {
return EventResponse::Continue;
};
if state.closed {
return EventResponse::Continue;
}
state.handlers.as_ref().map(Arc::clone)
};
let Some(handlers) = handlers else {
return EventResponse::Continue;
};
let mut response = EventResponse::Continue;
for registration in handlers.iter() {
if (registration.handler)(event) == EventResponse::PreventDefault {
response = EventResponse::PreventDefault;
}
}
response
}
pub(crate) fn close(&self) {
let handlers = {
let Ok(mut state) = self.inner.lock() else {
return;
};
state.closed = true;
state.handlers.take()
};
drop(handlers);
}
}
fn remove_event_handler(owner: &Mutex<EventRegistryState>, id: u64) {
let previous = {
let Ok(mut state) = owner.lock() else {
return;
};
let Some(handlers) = state.handlers.as_ref() else {
return;
};
let Some(index) = handlers.iter().position(|entry| entry.id == id) else {
return;
};
if handlers.len() == 1 {
state.handlers.take()
} else {
let mut next = Vec::with_capacity(handlers.len() - 1);
next.extend(
handlers
.iter()
.enumerate()
.filter(|(entry_index, _)| *entry_index != index)
.map(|(_, entry)| EventRegistration {
id: entry.id,
handler: Arc::clone(&entry.handler),
}),
);
state.handlers.replace(next.into())
}
};
drop(previous);
}
#[derive(Debug, Error)]
pub enum EventJavascriptError {
#[error("failed to serialize desktop event for JavaScript: {0}")]
Serialization(serde_json::Error),
}
impl DesktopEvent {
#[must_use]
pub const fn javascript_name(&self) -> &'static str {
match self {
Self::Ready => "webui:ready",
Self::WindowResized { .. } => "webui:window-resized",
Self::WindowMoved { .. } => "webui:window-moved",
Self::WindowMaximized { .. } => "webui:window-maximized",
Self::WindowUnmaximized { .. } => "webui:window-unmaximized",
Self::WindowMinimized { .. } => "webui:window-minimized",
Self::WindowRestored { .. } => "webui:window-restored",
Self::WindowEnteredFullscreen { .. } => "webui:window-entered-fullscreen",
Self::WindowLeftFullscreen { .. } => "webui:window-left-fullscreen",
Self::WindowFocused { .. } => "webui:window-focused",
Self::WindowBlurred { .. } => "webui:window-blurred",
Self::WindowCloseRequested { .. } => "webui:window-close-requested",
Self::WindowClosed { .. } => "webui:window-closed",
Self::ThemeChanged { .. } => "webui:theme-changed",
Self::ScaleFactorChanged { .. } => "webui:scale-factor-changed",
Self::NavigationRequested { .. } => "webui:navigation-requested",
Self::NavigationCompleted { .. } => "webui:navigation-completed",
Self::Exiting => "webui:exiting",
}
}
pub fn to_javascript(&self) -> Result<String, EventJavascriptError> {
let detail = serde_json::to_string(self).map_err(EventJavascriptError::Serialization)?;
let name = self.javascript_name();
debug_assert!(
name.bytes()
.all(|byte| byte.is_ascii_graphic() && byte != b'"' && byte != b'\\'),
"event name must be escape-free ASCII"
);
let mut script = String::with_capacity(detail.len() + name.len() + 54);
script.push_str("window.dispatchEvent(new CustomEvent(\"");
script.push_str(name);
script.push_str("\",{detail:");
script.push_str(&detail);
script.push_str("}));");
Ok(script)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum WindowCommand {
SetTitle(String),
SetBackground(Rgba),
SetSize { width: u32, height: u32 },
Minimize,
Maximize,
Unmaximize,
SetFullscreen(bool),
Center,
Focus,
Close,
StartDrag,
SetAlwaysOnTop(bool),
}
pub const MAX_QUEUED_WINDOW_COMMANDS: usize = 256;
pub const MAX_WINDOW_TITLE_BYTES: usize = 16 * 1024;
pub const MAX_QUEUED_WINDOW_TITLE_BYTES: usize = 64 * 1024;
#[derive(Debug, Error, Eq, PartialEq)]
pub enum WindowCommandError {
#[error(
"desktop window command queue is full; help: wait for the UI thread to drain commands"
)]
QueueFull,
#[error("desktop window title is too large: {size} bytes (max {MAX_WINDOW_TITLE_BYTES}); help: shorten the title before sending it")]
TitleTooLarge { size: usize },
#[error("desktop window title queue is full: {queued} of {MAX_QUEUED_WINDOW_TITLE_BYTES} bytes queued, {requested} more requested; help: wait for the UI thread to drain title updates")]
TitleQueueFull { queued: usize, requested: usize },
#[error("desktop window command queue is closed; help: stop sending commands after the desktop frame shuts down")]
Closed,
#[error(
"desktop window command queue is unavailable because the native UI thread panicked; help: restart the application"
)]
Unavailable,
}
#[derive(Clone, Default)]
pub struct WindowHandle {
inner: Arc<Mutex<WindowHandleState>>,
}
#[derive(Default)]
struct WindowHandleState {
queue: Option<VecDeque<WindowCommand>>,
background: Option<Arc<LiveBackground>>,
wakeup: Option<Arc<dyn Fn() + Send + Sync>>,
queued_title_bytes: usize,
wake_scheduled: bool,
closed: bool,
}
impl WindowHandle {
pub(crate) fn with_background(background: Arc<LiveBackground>) -> Self {
Self {
inner: Arc::new(Mutex::new(WindowHandleState {
background: Some(background),
..WindowHandleState::default()
})),
}
}
pub fn set_wakeup<F>(&self, wakeup: F)
where
F: Fn() + Send + Sync + 'static,
{
let wakeup: Arc<dyn Fn() + Send + Sync> = Arc::new(wakeup);
let (previous, notify) = {
let Ok(mut state) = self.inner.lock() else {
return;
};
if state.closed {
return;
}
let previous = state.wakeup.replace(Arc::clone(&wakeup));
let has_backlog = state.queue.as_ref().is_some_and(|queue| !queue.is_empty());
let notify = has_backlog.then(|| {
state.wake_scheduled = true;
wakeup
});
(previous, notify)
};
drop(previous);
if let Some(notify) = notify {
notify();
}
}
#[must_use]
pub fn drain_commands(&self) -> Vec<WindowCommand> {
let queue = {
let Ok(mut state) = self.inner.lock() else {
return Vec::new();
};
state.wake_scheduled = false;
state.queued_title_bytes = 0;
state.queue.take()
};
queue.map_or_else(Vec::new, VecDeque::into)
}
pub fn send(&self, command: WindowCommand) -> Result<(), WindowCommandError> {
let command = normalize_command(command);
let notify = {
let mut state = self
.inner
.lock()
.map_err(|_| WindowCommandError::Unavailable)?;
if state.closed {
return Err(WindowCommandError::Closed);
}
let (command, title_bytes) = command?;
let queue_len = state.queue.as_ref().map_or(0, VecDeque::len);
if queue_len >= MAX_QUEUED_WINDOW_COMMANDS {
return Err(WindowCommandError::QueueFull);
}
if title_bytes > MAX_QUEUED_WINDOW_TITLE_BYTES - state.queued_title_bytes {
return Err(WindowCommandError::TitleQueueFull {
queued: state.queued_title_bytes,
requested: title_bytes,
});
}
state.queued_title_bytes += title_bytes;
if let WindowCommand::SetBackground(color) = command {
if let Some(background) = &state.background {
background.set(color);
}
}
state
.queue
.get_or_insert_with(VecDeque::new)
.push_back(command);
if state.wake_scheduled {
None
} else if let Some(wakeup) = state.wakeup.as_ref().map(Arc::clone) {
state.wake_scheduled = true;
Some(wakeup)
} else {
None
}
};
if let Some(notify) = notify {
notify();
}
Ok(())
}
pub(crate) fn close(&self) {
let (wakeup, queue) = {
let Ok(mut state) = self.inner.lock() else {
return;
};
state.closed = true;
state.wake_scheduled = false;
state.queued_title_bytes = 0;
(state.wakeup.take(), state.queue.take())
};
drop(wakeup);
drop(queue);
}
pub fn set_title(&self, title: impl Into<String>) -> Result<(), WindowCommandError> {
self.send(WindowCommand::SetTitle(title.into()))
}
pub fn set_background(&self, color: Rgba) -> Result<(), WindowCommandError> {
self.send(WindowCommand::SetBackground(color))
}
pub fn set_size(&self, width: u32, height: u32) -> Result<(), WindowCommandError> {
self.send(WindowCommand::SetSize { width, height })
}
pub fn minimize(&self) -> Result<(), WindowCommandError> {
self.send(WindowCommand::Minimize)
}
pub fn maximize(&self) -> Result<(), WindowCommandError> {
self.send(WindowCommand::Maximize)
}
pub fn unmaximize(&self) -> Result<(), WindowCommandError> {
self.send(WindowCommand::Unmaximize)
}
pub fn fullscreen(&self, value: bool) -> Result<(), WindowCommandError> {
self.send(WindowCommand::SetFullscreen(value))
}
pub fn center(&self) -> Result<(), WindowCommandError> {
self.send(WindowCommand::Center)
}
pub fn focus(&self) -> Result<(), WindowCommandError> {
self.send(WindowCommand::Focus)
}
pub fn request_close(&self) -> Result<(), WindowCommandError> {
self.send(WindowCommand::Close)
}
pub fn start_drag(&self) -> Result<(), WindowCommandError> {
self.send(WindowCommand::StartDrag)
}
pub fn set_always_on_top(&self, value: bool) -> Result<(), WindowCommandError> {
self.send(WindowCommand::SetAlwaysOnTop(value))
}
}
fn normalize_command(command: WindowCommand) -> Result<(WindowCommand, usize), WindowCommandError> {
let WindowCommand::SetTitle(title) = command else {
return Ok((command, 0));
};
let size = title.len();
if size > MAX_WINDOW_TITLE_BYTES {
return Err(WindowCommandError::TitleTooLarge { size });
}
let title = if title.capacity() == size {
title
} else {
title.into_boxed_str().into_string()
};
Ok((WindowCommand::SetTitle(title), size))
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum DesktopHostMessage {
StartDrag,
Minimize,
ToggleMaximize,
Close,
}
pub const MAX_HOST_MESSAGE_BYTES: usize = 256;
#[derive(Debug, Error)]
pub enum DesktopHostMessageError {
#[error("desktop host message is too large: {size} bytes (max {MAX_HOST_MESSAGE_BYTES}); help: send only a supported command")]
TooLarge { size: usize },
#[error("invalid desktop host message; help: use start-drag, minimize, toggle-maximize, or close: {0}")]
Invalid(serde_json::Error),
}
impl DesktopHostMessage {
pub fn from_json(input: &str) -> Result<Self, DesktopHostMessageError> {
if input.len() > MAX_HOST_MESSAGE_BYTES {
return Err(DesktopHostMessageError::TooLarge { size: input.len() });
}
serde_json::from_str(input).map_err(DesktopHostMessageError::Invalid)
}
}
pub const DRAG_REGION_SCRIPT: &str = concat!(
"(()=>{const p=m=>window.webuiHostPostMessage&&window.webuiHostPostMessage(JSON.stringify(m));",
"const d=e=>{const q=typeof e.composedPath==='function'?e.composedPath():[];",
"for(let i=0;i<q.length;i++){const n=q[i];if(!n||n.nodeType!==1)continue;",
"if(n.hasAttribute('webui-no-drag'))return false;if(n.hasAttribute('webui-drag'))return true}return false};",
"let a=null,w=!!window.chrome?.webview;document.addEventListener('pointerdown',e=>{a=null;",
"if(e.button!==0||!d(e))return;if(!w){p('start-drag');return}",
"a=[e.pointerId,e.screenX,e.screenY]});",
"if(w){document.addEventListener('pointermove',e=>{",
"if(!a||e.pointerId!==a[0])return;if(!(e.buttons&1)){a=null;return}",
"if(Math.abs(e.screenX-a[1])<4&&Math.abs(e.screenY-a[2])<4)return;",
"a=null;p('start-drag')});",
"const end=e=>{if(a&&e.pointerId===a[0])a=null};",
"document.addEventListener('pointerup',end);document.addEventListener('pointercancel',end);",
"window.addEventListener('blur',()=>{a=null})}",
"document.addEventListener('dblclick',e=>{if(e.button===0&&d(e))p('toggle-maximize')})})();"
);
#[cfg(test)]
#[path = "event_tests.rs"]
mod tests;