1use std::sync::Arc;
13
14use cef::{ImplBrowser, ImplBrowserHost};
15
16use crate::events;
17use crate::state::{AppState, DragPayload, DragSession, DragType};
18
19const TEAROFF_MIN_DIM: i32 = 200;
23const TEAROFF_MAX_DIM: i32 = 8192;
24
25pub fn start_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
27 let drag_type: DragType = serde_json::from_value(
28 args.get("dragType").cloned().unwrap_or_default()
29 ).map_err(|e| format!("Invalid dragType: {}", e))?;
30 let source_window = args.get("sourceWindow").and_then(|v| v.as_str()).unwrap_or("main").to_string();
31 let source_workspace_id = args.get("sourceWorkspaceId").and_then(|v| v.as_str()).unwrap_or("").to_string();
32 let source_tab_id = args.get("sourceTabId").and_then(|v| v.as_str()).unwrap_or("").to_string();
33 let payload: DragPayload = serde_json::from_value(
34 args.get("payload").cloned().unwrap_or_default()
35 ).unwrap_or(DragPayload { block_id: None, tab_id: None });
36
37 let drag_id = uuid::Uuid::new_v4().to_string();
38 let now = std::time::SystemTime::now()
39 .duration_since(std::time::UNIX_EPOCH)
40 .unwrap_or_default()
41 .as_millis() as u64;
42
43 tracing::info!(drag_id = %drag_id, drag_type = ?drag_type, source_window = %source_window, "[dnd:cef] start_cross_drag");
44
45 let session = DragSession {
46 drag_id: drag_id.clone(),
47 drag_type,
48 source_window,
49 source_workspace_id,
50 source_tab_id,
51 payload,
52 started_at: now,
53 };
54
55 let dispatch = state.host_dispatch(
63 crate::reducer::HostCommand::StartDrag { session: session.clone() },
64 );
65 if dispatch.events.iter().any(|e| matches!(e, crate::reducer::HostEvent::Error { .. })) {
66 return Err("a drag session is already active".to_string());
67 }
68 events::emit_event_all_windows(state, "cross-drag-start", &serde_json::to_value(&session).unwrap());
69
70 Ok(serde_json::json!(drag_id))
71}
72
73pub fn update_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
75 let drag_id = args.get("dragId").and_then(|v| v.as_str()).unwrap_or("").to_string();
76 let screen_x = args.get("screenX").and_then(|v| v.as_f64()).unwrap_or(0.0);
77 let screen_y = args.get("screenY").and_then(|v| v.as_f64()).unwrap_or(0.0);
78
79 let session = state
81 .get_drag_session(&drag_id)
82 .ok_or_else(|| "no active drag session or drag_id mismatch".to_string())?;
83
84 let target_window = hit_test_windows(state, screen_x, screen_y);
85
86 events::emit_event_all_windows(state, "cross-drag-update", &serde_json::json!({
87 "dragId": drag_id,
88 "dragType": session.drag_type,
89 "payload": session.payload,
90 "targetWindow": target_window,
91 "sourceWindow": session.source_window,
92 "screenX": screen_x,
93 "screenY": screen_y,
94 }));
95
96 Ok(serde_json::json!(target_window))
97}
98
99pub fn complete_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
101 let drag_id = args.get("dragId").and_then(|v| v.as_str()).unwrap_or("").to_string();
102 let target_window = args.get("targetWindow").and_then(|v| v.as_str()).map(|s| s.to_string());
103 let screen_x = args.get("screenX").and_then(|v| v.as_f64()).unwrap_or(0.0);
104 let screen_y = args.get("screenY").and_then(|v| v.as_f64()).unwrap_or(0.0);
105
106 let outcome = match &target_window {
111 Some(t) => crate::reducer::DragOutcome::Dropped { target_label: t.clone() },
112 None => crate::reducer::DragOutcome::TornOff { new_label: String::new() },
113 };
114 let dispatch = state.host_dispatch(
115 crate::reducer::HostCommand::EndDrag { drag_id: drag_id.clone(), outcome },
116 );
117 let session = dispatch
118 .ended_drag_session
119 .ok_or_else(|| "no active drag session or drag_id mismatch".to_string())?;
120
121 let result = if target_window.is_some() { "drop" } else { "tearoff" };
122 tracing::info!(drag_id = %drag_id, result = %result, "[dnd:cef] complete_cross_drag");
123
124 events::emit_event_all_windows(state, "cross-drag-end", &serde_json::json!({
125 "dragId": drag_id,
126 "result": result,
127 "targetWindow": target_window,
128 "screenX": screen_x,
129 "screenY": screen_y,
130 "payload": session.payload,
131 "dragType": session.drag_type,
132 "sourceWindow": session.source_window,
133 "sourceWorkspaceId": session.source_workspace_id,
134 "sourceTabId": session.source_tab_id,
135 }));
136
137 Ok(serde_json::Value::Null)
138}
139
140pub fn cancel_cross_drag(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
142 let drag_id = args.get("dragId").and_then(|v| v.as_str()).unwrap_or("").to_string();
143
144 let dispatch = state.host_dispatch(
148 crate::reducer::HostCommand::EndDrag {
149 drag_id: drag_id.clone(),
150 outcome: crate::reducer::DragOutcome::Cancelled,
151 },
152 );
153 if dispatch.ended_drag_session.is_none() {
154 return Err("no active drag session or drag_id mismatch".to_string());
155 }
156
157 events::emit_event_all_windows(state, "cross-drag-end", &serde_json::json!({
158 "dragId": drag_id,
159 "result": "cancel",
160 }));
161
162 tracing::info!(drag_id = %drag_id, "[dnd:cef] cancel_cross_drag");
163 Ok(serde_json::Value::Null)
164}
165
166#[cfg(target_os = "windows")]
168fn hit_test_windows(state: &Arc<AppState>, screen_x: f64, screen_y: f64) -> Option<String> {
169 use cef::ImplBrowserHost;
170 use windows_sys::Win32::Foundation::RECT;
171 use windows_sys::Win32::UI::WindowsAndMessaging::GetWindowRect;
172
173 for (label, browser) in state.list_browsers() {
175 if let Some(host) = browser.host() {
176 let hwnd = host.window_handle();
177 if hwnd.0.is_null() { continue; }
178 unsafe {
179 let mut rect: RECT = std::mem::zeroed();
180 GetWindowRect(hwnd.0 as *mut std::ffi::c_void, &mut rect);
181 let x = rect.left as f64;
182 let y = rect.top as f64;
183 let w = (rect.right - rect.left) as f64;
184 let h = (rect.bottom - rect.top) as f64;
185 if screen_x >= x && screen_x <= x + w && screen_y >= y && screen_y <= y + h {
186 return Some(label.clone());
187 }
188 }
189 }
190 }
191 None
192}
193
194#[cfg(not(target_os = "windows"))]
195fn hit_test_windows(_state: &Arc<AppState>, _screen_x: f64, _screen_y: f64) -> Option<String> {
196 None
197}
198
199pub fn get_cursor_point() -> Result<serde_json::Value, String> {
201 #[cfg(target_os = "windows")]
202 {
203 use windows_sys::Win32::Foundation::POINT;
204 use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos;
205 unsafe {
206 let mut pt: POINT = std::mem::zeroed();
207 GetCursorPos(&mut pt);
208 return Ok(serde_json::json!({ "x": pt.x, "y": pt.y }));
209 }
210 }
211 #[allow(unreachable_code)]
212 Ok(serde_json::json!({ "x": 0, "y": 0 }))
213}
214
215pub fn get_mouse_button_state() -> Result<serde_json::Value, String> {
217 #[cfg(target_os = "windows")]
218 {
219 use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
220 let state = unsafe { GetAsyncKeyState(0x01) }; return Ok(serde_json::json!((state as u16 & 0x8000) != 0));
222 }
223 #[allow(unreachable_code)]
224 Ok(serde_json::json!(false))
225}
226
227pub fn set_drag_cursor() -> Result<serde_json::Value, String> {
229 #[cfg(target_os = "windows")]
230 {
231 use windows_sys::Win32::UI::WindowsAndMessaging::{
232 CopyIcon, LoadCursorW, SetSystemCursor, IDC_CROSS, OCR_NO,
233 };
234 unsafe {
235 let cross = LoadCursorW(std::ptr::null_mut(), IDC_CROSS);
236 if !cross.is_null() {
237 let copy = CopyIcon(cross);
238 if !copy.is_null() {
239 SetSystemCursor(copy, OCR_NO);
240 }
241 }
242 }
243 }
244 Ok(serde_json::Value::Null)
245}
246
247pub fn restore_drag_cursor() -> Result<serde_json::Value, String> {
249 #[cfg(target_os = "windows")]
250 {
251 use windows_sys::Win32::UI::WindowsAndMessaging::{SystemParametersInfoW, SPI_SETCURSORS};
252 unsafe {
253 SystemParametersInfoW(SPI_SETCURSORS, 0, std::ptr::null_mut(), 0);
254 }
255 }
256 Ok(serde_json::Value::Null)
257}
258
259pub fn release_drag_capture(state: &Arc<AppState>) -> Result<serde_json::Value, String> {
261 #[cfg(target_os = "windows")]
262 {
263 use windows_sys::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
264 use windows_sys::Win32::UI::WindowsAndMessaging::{
265 EnumChildWindows, PostMessageW, WM_CANCELMODE,
266 };
267 use windows_sys::Win32::Foundation::{BOOL, LPARAM};
268
269 let hwnd = state
272 .get_browser("main")
273 .and_then(|b| b.host())
274 .map(|h| h.window_handle().0 as *mut std::ffi::c_void)
275 .unwrap_or_else(|| unsafe { super::window::find_own_top_level_window() });
276
277 if !hwnd.is_null() {
278 unsafe {
279 ReleaseCapture();
280 PostMessageW(hwnd, WM_CANCELMODE, 0, 0);
281 unsafe extern "system" fn cancel_child(child: *mut std::ffi::c_void, _: LPARAM) -> BOOL {
282 PostMessageW(child, WM_CANCELMODE, 0, 0);
283 1
284 }
285 EnumChildWindows(hwnd, Some(cancel_child), 0);
286 }
287 }
288 }
289 let _ = state;
290 Ok(serde_json::Value::Null)
291}
292
293pub fn pool_window_ready(
299 state: &Arc<AppState>,
300 args: &serde_json::Value,
301) -> Result<serde_json::Value, String> {
302 let label = args
303 .get("label")
304 .and_then(|v| v.as_str())
305 .ok_or_else(|| "missing label".to_string())?;
306 super::window_pool::mark_pool_window_renderer_ready(state, label);
307 Ok(serde_json::Value::Null)
308}
309
310pub fn tear_off_pool_promote(
315 state: &Arc<AppState>,
316 args: &serde_json::Value,
317) -> Result<serde_json::Value, String> {
318 let workspace_id = args
319 .get("workspaceId")
320 .and_then(|v| v.as_str())
321 .ok_or_else(|| "missing workspaceId".to_string())?;
322 let screen_x = args
323 .get("screenX")
324 .and_then(|v| v.as_f64())
325 .ok_or_else(|| "missing screenX".to_string())? as i32;
326 let screen_y = args
327 .get("screenY")
328 .and_then(|v| v.as_f64())
329 .ok_or_else(|| "missing screenY".to_string())? as i32;
330 let width = args
332 .get("width")
333 .and_then(|v| v.as_f64())
334 .map(|w| (w as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM));
335 let height = args
336 .get("height")
337 .and_then(|v| v.as_f64())
338 .map(|h| (h as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM));
339 let tab_anchor_x = args.get("tabAnchorX").and_then(|v| v.as_f64()).map(|n| n as i32);
344 let tab_anchor_y = args.get("tabAnchorY").and_then(|v| v.as_f64()).map(|n| n as i32);
345
346 match super::window_pool::promote_pool_window(
347 state,
348 workspace_id,
349 screen_x,
350 screen_y,
351 width,
352 height,
353 tab_anchor_x,
354 tab_anchor_y,
355 ) {
356 Some(label) => Ok(serde_json::json!(label)),
357 None => {
358 tracing::warn!(
363 target: "dnd:tearoff:pool",
364 workspace_id = %workspace_id,
365 "[pool] pool exhausted on tear-off — frontend will cold-path"
366 );
367 Err("pool_exhausted".to_string())
368 }
369 }
370}
371
372pub fn open_window_at_position(state: &Arc<AppState>, args: &serde_json::Value) -> Result<serde_json::Value, String> {
375 if state.any_browser_pane_closing() {
378 tracing::warn!(
379 target: "wfr:gate",
380 "[wfr:gate] open_window_at_position refused — pane is mid-close (H.7 invariant)"
381 );
382 return Err("a pane is currently closing; retry shortly".to_string());
383 }
384
385 let screen_x = args.get("screenX").and_then(|v| v.as_f64()).unwrap_or(0.0);
386 let screen_y = args.get("screenY").and_then(|v| v.as_f64()).unwrap_or(0.0);
387 let workspace_id = args.get("workspaceId").and_then(|v| v.as_str()).unwrap_or("").to_string();
388
389 let window_id = uuid::Uuid::new_v4();
390 let label = format!("window-{}", window_id.simple());
391
392 let win_w = args
397 .get("width")
398 .and_then(|v| v.as_f64())
399 .map(|w| (w as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM))
400 .unwrap_or(1200);
401 let win_h = args
402 .get("height")
403 .and_then(|v| v.as_f64())
404 .map(|h| (h as i32).clamp(TEAROFF_MIN_DIM, TEAROFF_MAX_DIM))
405 .unwrap_or(800);
406
407 let tab_anchor_x = args.get("tabAnchorX").and_then(|v| v.as_f64()).map(|n| n as i32);
409 let tab_anchor_y = args.get("tabAnchorY").and_then(|v| v.as_f64()).map(|n| n as i32);
410
411 let (pos_x, pos_y) = match (tab_anchor_x, tab_anchor_y) {
415 (Some(ax), Some(ay)) => (ax, ay),
416 _ => (
417 ((screen_x - win_w as f64 / 2.0).max(0.0)) as i32,
418 ((screen_y - 16.0).max(0.0)) as i32,
419 ),
420 };
421
422 tracing::info!(
423 label = %label, pos_x = %pos_x, pos_y = %pos_y,
424 workspace_id = %workspace_id,
425 "[dnd:cef] open_window_at_position"
426 );
427
428 let ipc_port = *state.ipc_port.lock();
430 let ipc_token = &state.ipc_token;
431 let base_url = super::window::resolve_frontend_base_url(ipc_port);
432 let separator = if base_url.contains('?') { "&" } else { "?" };
433 let mut url = format!(
434 "{}{}ipc_port={}&ipc_token={}&windowLabel={}",
435 base_url, separator, ipc_port, ipc_token, label
436 );
437 if !workspace_id.is_empty() {
438 url.push_str(&format!("&workspaceId={}", workspace_id));
439 }
440
441 state.host_dispatch(
448 crate::reducer::HostCommand::EnqueuePendingWindowCreation {
449 entry: crate::state::PendingWindowCreation {
450 label: label.clone(),
451 kind: crate::state::WindowKind::FullInstance,
452 parent_instance_id: None,
453 },
454 },
455 );
456
457 crate::ui_tasks::post_create_window(
460 state, &url, &label, pos_x, pos_y, win_w, win_h,
461 true,
462 );
463
464 Ok(serde_json::json!(label))
470}
471
472pub fn set_js_drag_active(_args: &serde_json::Value) -> Result<serde_json::Value, String> {
474 Ok(serde_json::Value::Null)
476}
477
478pub fn tear_off_sc_move_handshake(
498 state: &Arc<AppState>,
499 args: &serde_json::Value,
500) -> Result<serde_json::Value, String> {
501 let t_start = std::time::Instant::now();
502
503 let source_label = args
504 .get("sourceWindowLabel")
505 .and_then(|v| v.as_str())
506 .ok_or_else(|| "missing sourceWindowLabel".to_string())?
507 .to_string();
508 let dest_label = args
509 .get("destWindowLabel")
510 .and_then(|v| v.as_str())
511 .ok_or_else(|| "missing destWindowLabel".to_string())?
512 .to_string();
513 let cursor_x = args
518 .get("cursorX")
519 .and_then(|v| v.as_f64())
520 .ok_or_else(|| "missing or invalid cursorX".to_string())? as i32;
521 let cursor_y = args
522 .get("cursorY")
523 .and_then(|v| v.as_f64())
524 .ok_or_else(|| "missing or invalid cursorY".to_string())? as i32;
525
526 let tab_id = args
531 .get("tabId")
532 .and_then(|v| v.as_str())
533 .unwrap_or("")
534 .to_string();
535 let source_ws_id = args
536 .get("sourceWsId")
537 .and_then(|v| v.as_str())
538 .unwrap_or("")
539 .to_string();
540 let dest_ws_id = args
541 .get("destWsId")
542 .and_then(|v| v.as_str())
543 .unwrap_or("")
544 .to_string();
545 let original_tab_index = args
550 .get("originalTabIndex")
551 .and_then(|v| v.as_u64())
552 .unwrap_or(0) as usize;
553 let was_pinned = args
558 .get("wasPinned")
559 .and_then(|v| v.as_bool())
560 .unwrap_or(false);
561
562 #[cfg(target_os = "windows")]
568 let handshake_ms: f64 = {
569 let dest_hwnd = wait_for_browser_hwnd(state, &dest_label, std::time::Duration::from_millis(2000))
577 .ok_or_else(|| format!("dest window not registered within 2s: {}", dest_label))?;
578
579 if !tab_id.is_empty() && !source_ws_id.is_empty() && !dest_ws_id.is_empty() {
585 crate::commands::tear_off_hook::start_tear_off_tracking(
586 state.clone(),
587 source_label.clone(),
588 dest_label.clone(),
589 tab_id.clone(),
590 source_ws_id.clone(),
591 dest_ws_id.clone(),
592 original_tab_index,
593 was_pinned,
594 )?;
595 }
596
597 let t_handshake = std::time::Instant::now();
598
599 unsafe {
600 use windows_sys::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
601 use windows_sys::Win32::UI::WindowsAndMessaging::{
602 PostMessageW, SetForegroundWindow, HTCAPTION, SC_MOVE, WM_SYSCOMMAND,
603 };
604
605 ReleaseCapture();
610
611 SetForegroundWindow(dest_hwnd);
620
621 let lparam = ((cursor_y as i32 as u32) << 16) | (cursor_x as i32 as u32 & 0xFFFF);
622 let post_ok = PostMessageW(
628 dest_hwnd,
629 WM_SYSCOMMAND,
630 (SC_MOVE as usize) | (HTCAPTION as usize),
631 lparam as isize,
632 );
633 if post_ok == 0 {
634 let last_err = windows_sys::Win32::Foundation::GetLastError();
635 return Err(format!(
636 "PostMessageW(SC_MOVE) failed: GetLastError={}",
637 last_err
638 ));
639 }
640 }
641
642 t_handshake.elapsed().as_micros() as f64 / 1000.0
643 };
644
645 #[cfg(not(target_os = "windows"))]
646 let handshake_ms: f64 = {
647 let _ = (state, &dest_label);
652 0.0
653 };
654
655 let total_ms = t_start.elapsed().as_micros() as f64 / 1000.0;
656
657 tracing::info!(
658 target: "dnd:tearoff",
659 source = %source_label,
660 dest = %dest_label,
661 cursor_x = %cursor_x,
662 cursor_y = %cursor_y,
663 handshake_ms = %handshake_ms,
664 total_ms = %total_ms,
665 "[dnd:tearoff] SC_MOVE handshake complete"
666 );
667
668 Ok(serde_json::json!({
669 "handshakeMs": handshake_ms,
670 "totalMs": total_ms,
671 }))
672}
673
674#[cfg(target_os = "windows")]
679fn wait_for_browser_hwnd(
680 state: &Arc<AppState>,
681 label: &str,
682 timeout: std::time::Duration,
683) -> Option<*mut std::ffi::c_void> {
684 let deadline = std::time::Instant::now() + timeout;
685 while std::time::Instant::now() < deadline {
686 if let Some(browser) = state.get_browser(label) {
688 if let Some(host) = browser.host() {
689 let h = host.window_handle();
690 if !h.0.is_null() {
691 return Some(h.0 as *mut std::ffi::c_void);
692 }
693 }
694 }
695 std::thread::sleep(std::time::Duration::from_millis(10));
696 }
697 None
698}