use serde::{Deserialize, Serialize};
use std::any::Any;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use crate::backend::SystemTheme;
use crate::types::{Modifiers, WidgetId};
use crate::{
ActivityBarEvent, ChartEvent, DataTableEvent, FormEvent, ListViewEvent, PaletteEvent,
StatusBarEvent, TabBarEvent, TerminalEvent, TextDisplayEvent, TreeEvent,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Key {
Char(char),
Named(NamedKey),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NamedKey {
Escape,
Tab,
BackTab,
Enter,
Backspace,
Delete,
Insert,
Home,
End,
PageUp,
PageDown,
Up,
Down,
Left,
Right,
F(u8),
CapsLock,
NumLock,
ScrollLock,
Menu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum MouseButton {
#[default]
Left,
Right,
Middle,
X1,
X2,
Other(u8),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ButtonMask {
#[serde(default)]
pub left: bool,
#[serde(default)]
pub right: bool,
#[serde(default)]
pub middle: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Point {
pub x: f32,
pub y: f32,
}
impl Point {
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct ScrollDelta {
pub x: f32,
pub y: f32,
}
impl ScrollDelta {
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Rect {
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
x,
y,
width,
height,
}
}
pub fn contains(&self, p: Point) -> bool {
p.x >= self.x && p.x < self.x + self.width && p.y >= self.y && p.y < self.y + self.height
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Viewport {
pub width: f32,
pub height: f32,
pub scale: f32,
}
impl Viewport {
pub const fn new(width: f32, height: f32, scale: f32) -> Self {
Self {
width,
height,
scale,
}
}
}
impl Default for Viewport {
fn default() -> Self {
Self::new(80.0, 24.0, 1.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BackendNativeEvent {
pub backend: String,
pub kind: String,
pub payload: String,
}
pub struct UserPayload(Arc<dyn Any + Send + Sync>);
impl UserPayload {
pub fn new<T: Any + Send + Sync + 'static>(value: T) -> Self {
Self(Arc::new(value))
}
pub fn downcast_ref<T: Any + Send + Sync + 'static>(&self) -> Option<&T> {
self.0.downcast_ref::<T>()
}
}
#[cfg(any(
feature = "tui",
feature = "gtk",
all(feature = "macos", target_os = "macos"),
feature = "win"
))]
impl UserPayload {
pub(crate) fn from_arc(payload: Arc<dyn Any + Send + Sync>) -> Self {
Self(payload)
}
pub(crate) fn into_arc(self) -> Arc<dyn Any + Send + Sync> {
self.0
}
}
impl fmt::Debug for UserPayload {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("UserPayload").field(&"<opaque>").finish()
}
}
impl Clone for UserPayload {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
impl PartialEq for UserPayload {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Serialize for UserPayload {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Err(serde::ser::Error::custom(
"UiEvent::User carries an opaque Arc<dyn Any> payload and cannot be serialised",
))
}
}
impl<'de> Deserialize<'de> for UserPayload {
fn deserialize<D>(_deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Err(serde::de::Error::custom(
"UiEvent::User cannot be deserialised — it is an in-process-only \
background-thread wake payload, never a wire value",
))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum UiEvent {
Accelerator(crate::AcceleratorId, Modifiers),
KeyPressed {
key: Key,
modifiers: Modifiers,
repeat: bool,
},
CharTyped(char),
MouseDown {
widget: Option<WidgetId>,
button: MouseButton,
position: Point,
modifiers: Modifiers,
},
MouseUp {
widget: Option<WidgetId>,
button: MouseButton,
position: Point,
},
MouseMoved {
position: Point,
buttons: ButtonMask,
},
MouseEntered {
widget: WidgetId,
},
MouseLeft {
widget: WidgetId,
},
DoubleClick {
widget: Option<WidgetId>,
position: Point,
},
Scroll {
widget: Option<WidgetId>,
delta: ScrollDelta,
position: Point,
},
WindowResized {
viewport: Viewport,
},
WindowClose,
WindowFocused(bool),
DpiChanged(f32),
WindowStateChanged {
maximized: bool,
fullscreen: bool,
minimized: bool,
},
FilesDropped {
paths: Vec<PathBuf>,
position: Point,
},
ClipboardPaste(String),
OpenRequested {
urls: Vec<String>,
files: Vec<PathBuf>,
},
TextCopied(String),
FocusChanged(Option<WidgetId>),
SystemThemeChanged(SystemTheme),
DisplaysChanged,
ScrollOffsetChanged {
widget: WidgetId,
new_offset: usize,
},
TextSelectionChanged {
region: WidgetId,
anchor: Point,
focus: Point,
},
SplitDividerDragged {
tree: WidgetId,
split_index: usize,
new_ratio: f32,
},
MenuActivated(WidgetId),
ContextMenuItemActivated(WidgetId),
ContextMenuDismissed,
TrayClicked {
button: MouseButton,
},
NotificationActivated {
tag: Option<String>,
action: Option<WidgetId>,
},
Tree(WidgetId, TreeEvent),
List(WidgetId, ListViewEvent),
Form(WidgetId, FormEvent),
Palette(WidgetId, PaletteEvent),
TabBar(WidgetId, TabBarEvent),
StatusBar(WidgetId, StatusBarEvent),
ActivityBar(WidgetId, ActivityBarEvent),
Terminal(WidgetId, TerminalEvent),
TextDisplay(WidgetId, TextDisplayEvent),
Chart(WidgetId, ChartEvent),
DataTable(WidgetId, DataTableEvent),
BackendNative(BackendNativeEvent),
User(UserPayload),
}
pub fn mouse_down(button: MouseButton, x: f32, y: f32, modifiers: Modifiers) -> UiEvent {
UiEvent::MouseDown {
widget: None,
button,
position: Point::new(x, y),
modifiers,
}
}
pub fn mouse_up(button: MouseButton, x: f32, y: f32) -> UiEvent {
UiEvent::MouseUp {
widget: None,
button,
position: Point::new(x, y),
}
}
pub fn mouse_moved(x: f32, y: f32, buttons: ButtonMask) -> UiEvent {
UiEvent::MouseMoved {
position: Point::new(x, y),
buttons,
}
}
pub fn scroll(dx: f32, dy_native_down_positive: f32, x: f32, y: f32) -> UiEvent {
UiEvent::Scroll {
widget: None,
delta: ScrollDelta::new(dx, -dy_native_down_positive),
position: Point::new(x, y),
}
}
pub fn window_resized(width: f32, height: f32, scale: f32) -> UiEvent {
UiEvent::WindowResized {
viewport: Viewport::new(width, height, scale),
}
}
pub fn files_dropped(paths: Vec<PathBuf>, x: f32, y: f32) -> UiEvent {
UiEvent::FilesDropped {
paths,
position: Point::new(x, y),
}
}
pub fn classify_open_args(args: impl IntoIterator<Item = String>) -> UiEvent {
let mut urls = Vec::new();
let mut files = Vec::new();
for arg in args {
if arg.contains("://") {
urls.push(arg);
} else {
files.push(PathBuf::from(arg));
}
}
UiEvent::OpenRequested { urls, files }
}
#[cfg(test)]
mod user_payload_tests {
use super::*;
#[test]
fn downcast_ref_recovers_the_original_type() {
let payload = UserPayload::new(42_i32);
assert_eq!(payload.downcast_ref::<i32>(), Some(&42));
assert_eq!(payload.downcast_ref::<String>(), None);
}
#[test]
fn clone_is_a_shallow_arc_clone_not_a_deep_copy() {
let payload = UserPayload::new(String::from("hello"));
let cloned = payload.clone();
assert_eq!(
cloned.downcast_ref::<String>(),
payload.downcast_ref::<String>()
);
assert_eq!(payload, cloned, "a clone must be `==` to its source");
}
#[test]
fn partial_eq_is_pointer_identity_not_structural() {
let a = UserPayload::new(1_i32);
let b = UserPayload::new(1_i32);
assert_ne!(
a, b,
"two payloads with equal contents but distinct allocations must not be equal — \
there is no generic structural equality for an erased Any"
);
let a_clone = a.clone();
assert_eq!(a, a_clone, "a clone of the same allocation must be equal");
}
#[test]
fn debug_never_panics_and_never_exposes_raw_content() {
let payload = UserPayload::new("do not leak me".to_string());
let rendered = format!("{payload:?}");
assert!(
!rendered.contains("do not leak me"),
"Debug must print a placeholder, not the erased payload's contents"
);
}
#[test]
fn serialize_fails_with_a_descriptive_error_instead_of_panicking() {
let payload = UserPayload::new(1_i32);
let err = serde_json::to_string(&payload)
.expect_err("UiEvent::User payloads must not be serialisable");
assert!(err.to_string().contains("cannot be serialised"));
}
#[test]
fn deserialize_fails_with_a_descriptive_error_instead_of_panicking() {
let err = serde_json::from_str::<UserPayload>("null")
.expect_err("UiEvent::User must never be constructible from wire data");
assert!(err.to_string().contains("cannot be deserialised"));
}
#[test]
fn user_event_clones_and_compares_via_the_wrapped_payload() {
let ev = UiEvent::User(UserPayload::new(7_i32));
let cloned = ev.clone();
assert_eq!(ev, cloned);
match &cloned {
UiEvent::User(payload) => assert_eq!(payload.downcast_ref::<i32>(), Some(&7)),
other => panic!("expected UiEvent::User, got {other:?}"),
}
}
}
#[cfg(test)]
mod shared_constructor_tests {
use super::*;
#[test]
fn mouse_down_builds_expected_shape() {
let mods = Modifiers {
ctrl: true,
..Default::default()
};
let ev = mouse_down(MouseButton::Left, 1.0, 2.0, mods);
match ev {
UiEvent::MouseDown {
widget,
button,
position,
modifiers,
} => {
assert!(widget.is_none());
assert_eq!(button, MouseButton::Left);
assert_eq!(position, Point::new(1.0, 2.0));
assert!(modifiers.ctrl);
}
other => panic!("expected MouseDown, got {other:?}"),
}
}
#[test]
fn mouse_up_builds_expected_shape() {
let ev = mouse_up(MouseButton::Right, 3.0, 4.0);
assert_eq!(
ev,
UiEvent::MouseUp {
widget: None,
button: MouseButton::Right,
position: Point::new(3.0, 4.0),
}
);
}
#[test]
fn mouse_moved_builds_expected_shape() {
let buttons = ButtonMask {
left: true,
..Default::default()
};
let ev = mouse_moved(5.0, 6.0, buttons);
assert_eq!(
ev,
UiEvent::MouseMoved {
position: Point::new(5.0, 6.0),
buttons,
}
);
}
#[test]
fn scroll_negates_native_down_positive_dy() {
let ev = scroll(0.0, 1.0, 10.0, 20.0);
assert_eq!(
ev,
UiEvent::Scroll {
widget: None,
delta: ScrollDelta::new(0.0, -1.0),
position: Point::new(10.0, 20.0),
}
);
}
#[test]
fn scroll_leaves_dx_unflipped() {
let ev = scroll(2.5, 0.0, 0.0, 0.0);
match ev {
UiEvent::Scroll { delta, .. } => {
assert_eq!(delta.x, 2.5);
assert_eq!(delta.y, 0.0);
}
other => panic!("expected Scroll, got {other:?}"),
}
}
#[test]
fn window_resized_builds_expected_shape() {
let ev = window_resized(1920.0, 1080.0, 2.0);
assert_eq!(
ev,
UiEvent::WindowResized {
viewport: Viewport::new(1920.0, 1080.0, 2.0),
}
);
}
#[test]
fn classify_open_args_splits_urls_from_files() {
let ev = classify_open_args(vec![
"myapp://callback?code=abc".to_string(),
"/home/user/report.pdf".to_string(),
"https://example.com/oauth".to_string(),
"relative/path.txt".to_string(),
]);
assert_eq!(
ev,
UiEvent::OpenRequested {
urls: vec![
"myapp://callback?code=abc".to_string(),
"https://example.com/oauth".to_string(),
],
files: vec![
PathBuf::from("/home/user/report.pdf"),
PathBuf::from("relative/path.txt"),
],
}
);
}
#[test]
fn classify_open_args_handles_empty_input() {
let ev = classify_open_args(Vec::<String>::new());
assert_eq!(
ev,
UiEvent::OpenRequested {
urls: vec![],
files: vec![],
}
);
}
}