mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 00:53:37 +00:00
Send the cursor position with drag and drop events
This commit is contained in:
parent
c7b42bee96
commit
513f86d45c
@ -1,7 +1,7 @@
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use async_stream::stream;
|
||||
use axum::body::Body;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
|
||||
use tauri::path::PathResolver;
|
||||
use tauri::WebviewWindow;
|
||||
use tauri::PhysicalPosition;
|
||||
use tauri_plugin_updater::{UpdaterExt, Update};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio::sync::broadcast;
|
||||
@ -50,6 +51,16 @@ static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::
|
||||
/// The event broadcast sender for Tauri events.
|
||||
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
/// The shortest interval between two drag-over events.
|
||||
///
|
||||
/// A native drag emits one such event per mouse move. Every one of them travels into the app, where
|
||||
/// it decides which drop zone lights up, so an unthrottled drag would render the whole page dozens
|
||||
/// of times per second. A tenth of a second still follows the cursor closely enough.
|
||||
const DRAG_OVER_EVENT_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// When we sent the last drag-over event, used to protect Blazor from render storms.
|
||||
static LAST_DRAG_OVER_SENT: Lazy<Mutex<Option<Instant>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
/// Stores the localhost origin of the Blazor app after the .NET server is ready.
|
||||
static APPROVED_APP_URL: Lazy<Mutex<Option<tauri::Url>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
@ -141,9 +152,31 @@ pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
|
||||
// Register a callback for window events, such as file drops. We have to use
|
||||
// this handler in addition to the app event handler, because file drop events
|
||||
// are only available in the window event handler (is a bug, cf. https://github.com/tauri-apps/tauri/issues/14338):
|
||||
//
|
||||
// Turning a drag and drop position into CSS pixels needs the scale factor of the
|
||||
// window. We read it from this clone rather than from MAIN_WINDOW: window events are
|
||||
// delivered synchronously on the main thread on macOS, so locking MAIN_WINDOW in here
|
||||
// would deadlock as soon as anybody else holds that lock.
|
||||
//
|
||||
let event_window = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
|
||||
//
|
||||
// Only a drag and drop event carries a position, and only that position needs the
|
||||
// scale factor. Asking the window on every window event would be needless work.
|
||||
// Asking it anew for every drag is what keeps a display change covered: we hold no
|
||||
// factor of our own which a moved window could leave behind.
|
||||
//
|
||||
let scale_factor = match event {
|
||||
WindowEvent::DragDrop(_) => event_window.scale_factor().unwrap_or(1.0),
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
let Some(event_to_send) = Event::from_window_event(event, scale_factor) else {
|
||||
return;
|
||||
};
|
||||
|
||||
debug!(Source = "Tauri"; "Tauri event received: location=window event handler, event={event:?}");
|
||||
let event_to_send = Event::from_window_event(event);
|
||||
let sender = event_sender.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match sender.send(event_to_send) {
|
||||
@ -406,11 +439,69 @@ pub async fn get_event_stream(_token: APIToken) -> Response {
|
||||
([(CONTENT_TYPE, "application/jsonl")], Body::from_stream(stream)).into_response()
|
||||
}
|
||||
|
||||
/// The cursor position of a drag and drop event, in CSS pixels relative to the viewport.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct CursorPosition {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// Converts the cursor position of a drag and drop event into CSS pixels.
|
||||
///
|
||||
/// Tauri names the type PhysicalPosition, but only Windows fills it with device pixels: there, wry
|
||||
/// converts the screen coordinate with ScreenToClient. macOS hands over the NSView point of the
|
||||
/// drag and GTK the logical widget coordinate, and both of those already are what CSS calls a
|
||||
/// pixel. tauri-runtime-wry relabels all three without touching them, which is why the scale factor
|
||||
/// belongs to the Windows branch alone: applying it everywhere would halve every coordinate on a
|
||||
/// display with a scale factor of two.
|
||||
/// Changing the display or its scaling at runtime needs no attention here. On Windows the caller
|
||||
/// reads the factor anew for every drag and drop event, and Tauri keeps its own value current
|
||||
/// through WM_DPICHANGED, so nothing of ours can go stale. On macOS and Linux no factor takes part
|
||||
/// in the first place: a point stays a point when the window moves to a display with a different
|
||||
/// pixel density, and only the number of device pixels behind it changes.
|
||||
///
|
||||
/// What the equality of a logical point and a CSS pixel does depend on is that nobody zooms the
|
||||
/// webview: neither through WebviewWindow::set_zoom nor through zoomHotkeysEnabled, which our
|
||||
/// tauri.conf.json leaves off. Should AI Studio ever offer a zoom, say for accessibility, the
|
||||
/// position has to be divided by it as well -- on every platform, this time.
|
||||
///
|
||||
/// The decision is written with cfg! rather than #[cfg], so that both branches are compiled and
|
||||
/// type-checked on every platform instead of only on the one they apply to.
|
||||
fn cursor_position_in_css_pixels(position: PhysicalPosition<f64>, scale_factor: f64) -> CursorPosition {
|
||||
scale_cursor_position(position, if cfg!(target_os = "windows") { scale_factor } else { 1.0 })
|
||||
}
|
||||
|
||||
/// Divides a cursor position by a scale factor.
|
||||
fn scale_cursor_position(position: PhysicalPosition<f64>, scale_factor: f64) -> CursorPosition {
|
||||
// Zero or less cannot be a scale. Treating such a value as 1.0 keeps it from turning the
|
||||
// position into infinity:
|
||||
let scale_factor = if scale_factor > 0.0 { scale_factor } else { 1.0 };
|
||||
CursorPosition { x: position.x / scale_factor, y: position.y / scale_factor }
|
||||
}
|
||||
|
||||
/// Decides whether a drag-over event is due, given when we sent the last one.
|
||||
fn drag_over_is_due(last_sent: Option<Instant>, now: Instant) -> bool {
|
||||
!last_sent.is_some_and(|last_at| now.duration_since(last_at) < DRAG_OVER_EVENT_INTERVAL)
|
||||
}
|
||||
|
||||
/// Forgets when we sent the last drag-over event, so the next drag starts with a fresh interval.
|
||||
///
|
||||
/// Every drag which begins, ends, or is abandoned calls this. Without it, a drag starting within
|
||||
/// the interval of the previous one would have its first drag-over event swallowed, and the
|
||||
/// highlight would stay behind until the pointer moves again.
|
||||
fn reset_drag_over_throttle() {
|
||||
*LAST_DRAG_OVER_SENT.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Data structure representing a Tauri event for our event API.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Event {
|
||||
pub event_type: TauriEventType,
|
||||
pub payload: Vec<String>,
|
||||
|
||||
/// Where the cursor was, for the drag and drop events which know it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<CursorPosition>,
|
||||
}
|
||||
|
||||
/// Implementation of the Event struct.
|
||||
@ -421,43 +512,86 @@ impl Event {
|
||||
Event {
|
||||
payload,
|
||||
event_type,
|
||||
position: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an Event instance from a Tauri WindowEvent.
|
||||
pub fn from_window_event(window_event: &WindowEvent) -> Self {
|
||||
/// Creates a new Event instance which carries the cursor position as well.
|
||||
pub fn with_position(event_type: TauriEventType, payload: Vec<String>, position: CursorPosition) -> Self {
|
||||
Event {
|
||||
payload,
|
||||
event_type,
|
||||
position: Some(position),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an Event instance from a Tauri WindowEvent, unless the event is none of our business.
|
||||
pub fn from_window_event(window_event: &WindowEvent, scale_factor: f64) -> Option<Self> {
|
||||
match window_event {
|
||||
WindowEvent::DragDrop(drop_event) => {
|
||||
match drop_event {
|
||||
DragDropEvent::Enter { paths, .. } => Event::new(
|
||||
TauriEventType::FileDropHovered,
|
||||
paths.iter().map(|p| p.display().to_string()).collect(),
|
||||
),
|
||||
DragDropEvent::Enter { paths, position } => {
|
||||
reset_drag_over_throttle();
|
||||
Some(Event::with_position(
|
||||
TauriEventType::FileDropHovered,
|
||||
paths.iter().map(|p| p.display().to_string()).collect(),
|
||||
cursor_position_in_css_pixels(*position, scale_factor),
|
||||
))
|
||||
},
|
||||
|
||||
DragDropEvent::Drop { paths, .. } => Event::new(
|
||||
TauriEventType::FileDropDropped,
|
||||
paths.iter().map(|p| p.display().to_string()).collect(),
|
||||
),
|
||||
DragDropEvent::Over { position } => {
|
||||
let now = Instant::now();
|
||||
let mut last_sent = LAST_DRAG_OVER_SENT.lock().unwrap();
|
||||
if !drag_over_is_due(*last_sent, now) {
|
||||
return None;
|
||||
}
|
||||
|
||||
DragDropEvent::Leave => Event::new(TauriEventType::FileDropCanceled, Vec::new()),
|
||||
*last_sent = Some(now);
|
||||
drop(last_sent);
|
||||
|
||||
_ => Event::new(TauriEventType::Unknown, Vec::new()),
|
||||
Some(Event::with_position(
|
||||
TauriEventType::FileDropOver,
|
||||
Vec::new(),
|
||||
cursor_position_in_css_pixels(*position, scale_factor),
|
||||
))
|
||||
},
|
||||
|
||||
DragDropEvent::Drop { paths, position } => {
|
||||
reset_drag_over_throttle();
|
||||
Some(Event::with_position(
|
||||
TauriEventType::FileDropDropped,
|
||||
paths.iter().map(|p| p.display().to_string()).collect(),
|
||||
cursor_position_in_css_pixels(*position, scale_factor),
|
||||
))
|
||||
},
|
||||
|
||||
DragDropEvent::Leave => {
|
||||
reset_drag_over_throttle();
|
||||
Some(Event::new(TauriEventType::FileDropCanceled, Vec::new()))
|
||||
},
|
||||
|
||||
// The event is marked as non-exhaustive, so a variant added later lands here:
|
||||
_ => None,
|
||||
}
|
||||
},
|
||||
|
||||
WindowEvent::Focused(state) => if *state {
|
||||
Event::new(TauriEventType::WindowFocused,
|
||||
Vec::new(),
|
||||
)
|
||||
Some(Event::new(TauriEventType::WindowFocused,
|
||||
Vec::new(),
|
||||
))
|
||||
} else {
|
||||
Event::new(TauriEventType::WindowNotFocused,
|
||||
Vec::new(),
|
||||
)
|
||||
Some(Event::new(TauriEventType::WindowNotFocused,
|
||||
Vec::new(),
|
||||
))
|
||||
},
|
||||
|
||||
_ => Event::new(TauriEventType::Unknown,
|
||||
Vec::new(),
|
||||
),
|
||||
//
|
||||
// Everything else is none of our business. Saying so keeps it out of the broadcast
|
||||
// channel, which matters during a drag: the app discarded these events at the far end
|
||||
// of the stream, but a single drag pushed hundreds of them through a channel of 100
|
||||
// beforehand, which is what made its receiver lag.
|
||||
//
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -473,6 +607,7 @@ pub enum TauriEventType {
|
||||
WindowNotFocused,
|
||||
|
||||
FileDropHovered,
|
||||
FileDropOver,
|
||||
FileDropDropped,
|
||||
FileDropCanceled,
|
||||
|
||||
@ -948,6 +1083,56 @@ mod tests {
|
||||
assert!(self_update_blocked_reason(false, InstallationKind::User).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_drag_over_event_of_a_drag_is_due() {
|
||||
assert!(drag_over_is_due(None, Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_over_event_within_the_interval_is_not_due() {
|
||||
let now = Instant::now();
|
||||
assert!(!drag_over_is_due(Some(now - DRAG_OVER_EVENT_INTERVAL / 2), now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_over_event_after_the_interval_is_due() {
|
||||
let now = Instant::now();
|
||||
assert!(drag_over_is_due(Some(now - DRAG_OVER_EVENT_INTERVAL), now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scale_factor_of_two_halves_the_cursor_position() {
|
||||
let position = scale_cursor_position(PhysicalPosition::new(200.0, 100.0), 2.0);
|
||||
assert_eq!((position.x, position.y), (100.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_impossible_scale_factor_leaves_the_cursor_position_alone() {
|
||||
let position = scale_cursor_position(PhysicalPosition::new(200.0, 100.0), 0.0);
|
||||
assert_eq!((position.x, position.y), (200.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_position_is_scaled_on_windows_only() {
|
||||
let position = cursor_position_in_css_pixels(PhysicalPosition::new(200.0, 100.0), 2.0);
|
||||
let expected = if cfg!(target_os = "windows") { (100.0, 50.0) } else { (200.0, 100.0) };
|
||||
|
||||
assert_eq!((position.x, position.y), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_event_we_do_not_care_about_is_not_channeled() {
|
||||
assert!(Event::from_window_event(&WindowEvent::Destroyed, 1.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_the_window_focus_is_channeled_without_a_position() {
|
||||
let event = Event::from_window_event(&WindowEvent::Focused(false), 1.0).unwrap();
|
||||
|
||||
assert!(matches!(event.event_type, TauriEventType::WindowNotFocused));
|
||||
assert!(event.position.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfium_library_directory_prefers_resources_libraries() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user