From 3a825cbfe1bfd75471e2e9d6d3000460cafbcceb Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 12 Jul 2026 09:07:49 +0800 Subject: [PATCH] feat(canvas): paste clipboard images as nodes --- crates/op-editor-core/src/host_support.rs | 45 +++- .../op-editor-core/src/host_support_tests.rs | 86 +++++++ crates/op-editor-core/src/lib.rs | 2 + crates/op-host-desktop/src/clipboard.rs | 46 +++- crates/op-host-desktop/src/keyboard_input.rs | 86 +++++-- .../src/keyboard_input_tests.rs | 233 ++++++++++++++++++ .../src/widget_host/shortcuts.rs | 42 ++++ 7 files changed, 507 insertions(+), 33 deletions(-) create mode 100644 crates/op-editor-core/src/host_support_tests.rs create mode 100644 crates/op-host-desktop/src/keyboard_input_tests.rs diff --git a/crates/op-editor-core/src/host_support.rs b/crates/op-editor-core/src/host_support.rs index 0b78f64e8..fe117baf6 100644 --- a/crates/op-editor-core/src/host_support.rs +++ b/crates/op-editor-core/src/host_support.rs @@ -214,11 +214,41 @@ impl EditorState { /// Returns the new node id on success; `None` when the id allocator is /// exhausted. pub fn insert_image_node_at_viewport(&mut self, name: &str, src: &str) -> Option { + self.insert_image_node_at_viewport_with_dimensions(name, src, 300.0, 200.0) + } + + /// Insert an Image node centred on the current viewport, preserving the + /// source bitmap's aspect ratio. The largest side is capped at 300 + /// document pixels and smaller bitmaps are not enlarged. + pub fn insert_image_node_at_viewport_sized( + &mut self, + name: &str, + src: &str, + pixel_width: u32, + pixel_height: u32, + ) -> Option { + if pixel_width == 0 || pixel_height == 0 { + return None; + } + let scale = (300.0 / f64::from(pixel_width.max(pixel_height))).min(1.0); + self.insert_image_node_at_viewport_with_dimensions( + name, + src, + f64::from(pixel_width) * scale, + f64::from(pixel_height) * scale, + ) + } + + fn insert_image_node_at_viewport_with_dimensions( + &mut self, + name: &str, + src: &str, + width: f64, + height: f64, + ) -> Option { use jian_ops_schema::node::image::ImageNode; use jian_ops_schema::node::PenNode; use jian_ops_schema::sizing::SizingBehavior; - const W: f64 = 300.0; - const H: f64 = 200.0; let pan_x = self.viewport.pan_x as f64; let pan_y = self.viewport.pan_y as f64; let zoom = self.viewport.zoom.max(0.001) as f64; @@ -226,21 +256,20 @@ impl EditorState { let centre_y = -pan_y / zoom; let safe = self.max_node_id().checked_add(1)?; let id = NodeId::new(format!("n{}", safe)); - let mut next_id = safe.checked_add(1)?; - let _ = &mut next_id; + let _next_id = safe.checked_add(1)?; self.commit_history(); let node = PenNode::Image(ImageNode { base: jian_ops_schema::node::base::PenNodeBase { id: id.as_str().to_string(), name: Some(name.to_string()), - x: Some(centre_x - W / 2.0), - y: Some(centre_y - H / 2.0), + x: Some(centre_x - width / 2.0), + y: Some(centre_y - height / 2.0), ..Default::default() }, src: src.into(), object_fit: None, - width: Some(SizingBehavior::Number(W)), - height: Some(SizingBehavior::Number(H)), + width: Some(SizingBehavior::Number(width)), + height: Some(SizingBehavior::Number(height)), corner_radius: None, effects: None, exposure: None, diff --git a/crates/op-editor-core/src/host_support_tests.rs b/crates/op-editor-core/src/host_support_tests.rs new file mode 100644 index 000000000..1b841089f --- /dev/null +++ b/crates/op-editor-core/src/host_support_tests.rs @@ -0,0 +1,86 @@ +use crate::{EditorState, NodeId, Viewport}; +use jian_ops_schema::node::PenNode; +use jian_ops_schema::sizing::SizingBehavior; + +#[test] +fn sized_image_preserves_aspect_ratio_centers_selects_and_undoes() { + let mut state = EditorState::sample(); + state.viewport = Viewport { + pan_x: -200.0, + pan_y: -100.0, + zoom: 2.0, + }; + + let id = state + .insert_image_node_at_viewport_sized( + "Clipboard image", + "data:image/png;base64,AAAA", + 400, + 200, + ) + .expect("image inserted"); + + assert_eq!(id, NodeId::new("n15")); + assert_eq!(state.selection.anchor, id); + assert_eq!(state.active_children().len(), 2); + let PenNode::Image(image) = &state.active_children()[0] else { + panic!("inserted node should be an Image"); + }; + assert_eq!(image.base.name.as_deref(), Some("Clipboard image")); + assert_eq!(image.base.x, Some(-50.0)); + assert_eq!(image.base.y, Some(-25.0)); + assert_eq!(image.width, Some(SizingBehavior::Number(300.0))); + assert_eq!(image.height, Some(SizingBehavior::Number(150.0))); + assert_eq!(image.src, "data:image/png;base64,AAAA"); + + assert!(state.undo()); + assert_eq!(state.active_children().len(), 1); + assert_eq!(state.selection.anchor, NodeId::new("n11")); +} + +#[test] +fn sized_image_rejects_zero_pixel_dimensions_without_history() { + for (pixel_width, pixel_height) in [(0, 100), (100, 0), (0, 0)] { + let mut state = EditorState::sample(); + + assert!(state + .insert_image_node_at_viewport_sized( + "Invalid image", + "data:image/png;base64,AAAA", + pixel_width, + pixel_height, + ) + .is_none()); + assert_eq!(state.active_children().len(), 1); + assert_eq!(state.selection.anchor, NodeId::new("n11")); + assert!(!state.history.can_undo()); + } +} + +#[test] +fn sized_image_keeps_small_bitmap_at_original_size_and_centers_it() { + let mut state = EditorState::new(); + state.viewport = Viewport { + pan_x: -200.0, + pan_y: -100.0, + zoom: 2.0, + }; + + let id = state + .insert_image_node_at_viewport_sized( + "Small clipboard image", + "data:image/png;base64,AAAA", + 64, + 32, + ) + .expect("small image inserted"); + + let PenNode::Image(image) = state.selected_node().expect("inserted image selected") else { + panic!("inserted node should be an Image"); + }; + assert_eq!(state.selection.anchor, id); + assert_eq!(image.width, Some(SizingBehavior::Number(64.0))); + assert_eq!(image.height, Some(SizingBehavior::Number(32.0))); + assert_eq!(image.base.x, Some(68.0)); + assert_eq!(image.base.y, Some(34.0)); +} diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index a90f206d3..f99491b3c 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -55,6 +55,8 @@ pub mod history; pub mod history_snapshot; pub mod hoist_app_state; pub mod host_support; +#[cfg(test)] +mod host_support_tests; pub mod icon_picker_state; pub mod image_node_props; pub mod image_panel_state; diff --git a/crates/op-host-desktop/src/clipboard.rs b/crates/op-host-desktop/src/clipboard.rs index 59724cbd7..bcee0550b 100644 --- a/crates/op-host-desktop/src/clipboard.rs +++ b/crates/op-host-desktop/src/clipboard.rs @@ -6,8 +6,17 @@ //! a clipboard that fails to initialise simply yields `None` / a //! no-op rather than surfacing an error to the user. +/// PNG-encoded clipboard bitmap plus its original pixel dimensions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClipboardImage { + pub png: Vec, + pub width: u32, + pub height: u32, +} + /// Read the system clipboard's text. `None` when the clipboard holds /// no text or could not be opened. +#[allow(dead_code)] // Retained single-flavour API; paste uses one shared OS handle. pub fn get_text() -> Option { arboard::Clipboard::new().ok()?.get_text().ok() } @@ -18,8 +27,26 @@ pub fn get_text() -> Option { /// wrapped / encoded. Backs paste-image-into-chat — the desktop /// equivalent of the TS chat input's clipboard *files* surface /// (`ai-chat-input.tsx:85-94`). -pub fn get_image() -> Option> { +#[allow(dead_code)] // Required typed single-image API; paste uses the snapshot API below. +pub fn get_image() -> Option { let image = arboard::Clipboard::new().ok()?.get_image().ok()?; + encode_image(image) +} + +/// Snapshot all paste-relevant clipboard flavours through one OS +/// clipboard handle. The keyboard router turns this tuple into its +/// injectable `ClipboardPayload` seam. +pub(crate) fn read_paste_flavours() -> (Option, Option, Option) { + let Ok(mut clipboard) = arboard::Clipboard::new() else { + return (None, None, None); + }; + let text = clipboard.get_text().ok(); + let html = clipboard.get().html().ok(); + let image = clipboard.get_image().ok().and_then(encode_image); + (text, html, image) +} + +fn encode_image(image: arboard::ImageData<'_>) -> Option { let (width, height) = (image.width, image.height); if width == 0 || height == 0 { return None; @@ -27,25 +54,34 @@ pub fn get_image() -> Option> { // arboard hands back tightly-packed RGBA8; wrap it as a raster // skia image and run it through the same PNG encoder the export // path uses (no extra image-codec dependency). - if image.bytes.len() < width * height * 4 { + let row_bytes = width.checked_mul(4)?; + let rgba_len = row_bytes.checked_mul(height)?; + if image.bytes.len() < rgba_len { return None; } + let pixel_width = u32::try_from(width).ok()?; + let pixel_height = u32::try_from(height).ok()?; let info = skia_safe::ImageInfo::new( - (width as i32, height as i32), + (i32::try_from(width).ok()?, i32::try_from(height).ok()?), skia_safe::ColorType::RGBA8888, skia_safe::AlphaType::Unpremul, None, ); let data = skia_safe::Data::new_copy(&image.bytes); - let raster = skia_safe::images::raster_from_data(&info, data, width * 4)?; + let raster = skia_safe::images::raster_from_data(&info, data, row_bytes)?; let png = raster.encode(None, skia_safe::EncodedImageFormat::PNG, 100)?; - Some(png.as_bytes().to_vec()) + Some(ClipboardImage { + png: png.as_bytes().to_vec(), + width: pixel_width, + height: pixel_height, + }) } /// Read the system clipboard's HTML flavour (NSPasteboard /// `public.html` / CF_HTML / text/html). `None` when the clipboard /// holds no HTML — the Figma-paste path probes this before falling /// back to the internal node clipboard. +#[allow(dead_code)] // Retained single-flavour API; paste uses one shared OS handle. pub fn get_html() -> Option { arboard::Clipboard::new().ok()?.get().html().ok() } diff --git a/crates/op-host-desktop/src/keyboard_input.rs b/crates/op-host-desktop/src/keyboard_input.rs index 68e7cdec7..809035bf2 100644 --- a/crates/op-host-desktop/src/keyboard_input.rs +++ b/crates/op-host-desktop/src/keyboard_input.rs @@ -3,8 +3,26 @@ //! under the repo's 800-line-per-file cap. use crate::{chat_session, persistence, DesktopApp}; +use base64::Engine as _; use winit::keyboard::{Key, NamedKey}; +/// Snapshot of every system-clipboard flavour relevant to Cmd/Ctrl+V. +/// Tests inject this directly so paste precedence is deterministic and +/// never depends on the developer machine's clipboard contents. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct ClipboardPayload { + pub(crate) text: Option, + pub(crate) html: Option, + pub(crate) image: Option, +} + +impl ClipboardPayload { + fn read_system() -> Self { + let (text, html, image) = crate::clipboard::read_paste_flavours(); + Self { text, html, image } + } +} + impl DesktopApp { /// Dispatch a pressed key (`logical_key` + its `text`) — the /// editor's keyboard-shortcut table. Called from the winit @@ -382,28 +400,40 @@ impl DesktopApp { /// pastes Figma clipboard HTML or the document node clipboard onto /// the canvas. `pub(crate)` for the Edit-menu path. pub(crate) fn handle_cmd_paste(&mut self) -> bool { + self.handle_paste_payload(ClipboardPayload::read_system()) + } + + /// Input-aware paste router over an already-read clipboard snapshot. + /// Keeping OS access outside this seam makes precedence directly + /// testable and guarantees one clipboard handle per paste gesture. + pub(crate) fn handle_paste_payload(&mut self, mut payload: ClipboardPayload) -> bool { // Non-chat text inputs (settings, git, rename, etc.) are // checked first so that opening a modal (e.g. agent settings // via Cmd+,) while the chat input is focused routes the paste // to the modal's field instead of the chat. - if self.host.input_active_pub() && !self.host.editor_state().chat.focused { - if let Some(text) = crate::clipboard::get_text() { - self.host.apply_input_paste(&text); + if self.host.non_chat_input_owns_keyboard_pub() { + if let Some(text) = payload.text.as_deref() { + self.host.apply_input_paste(text); } return true; } - if self.host.editor_state().chat.focused { + if self.host.chat_input_owns_keyboard_pub() { // Clipboard image data wins over text when pasting into the // chat input; the paste is consumed either way. - if !self.try_paste_image_into_chat() { - if let Some(text) = crate::clipboard::get_text() { - self.host.chat_input_paste(&text); - } + if let Some(image) = payload.image.take() { + self.paste_image_into_chat(image); + } else if let Some(text) = payload.text.as_deref() { + self.host.chat_input_paste(text); } return true; } - if let Some(result) = self.try_figma_clipboard_paste() { - return result; + if let Some(html) = payload.html.take() { + if let Some(result) = self.try_figma_clipboard_paste(html) { + return result; + } + } + if let Some(image) = payload.image { + return self.paste_image_to_canvas(image); } self.host.apply_paste() } @@ -414,12 +444,7 @@ impl DesktopApp { /// and the paste is consumed even when nothing stages — TS /// filters oversized files after `preventDefault()`, and /// `add_attachment` enforces the same 4 × 5 MB caps here. - /// Returns false when the clipboard holds no image (caller falls - /// through to the text paste). - fn try_paste_image_into_chat(&mut self) -> bool { - let Some(png) = crate::clipboard::get_image() else { - return false; - }; + fn paste_image_into_chat(&mut self, image: crate::clipboard::ClipboardImage) { // TS names pasted clipboard images "pasted-image.png". self.host .editor_state_mut() @@ -427,9 +452,27 @@ impl DesktopApp { .add_attachment(op_editor_core::chat::ChatAttachment { name: "pasted-image.png".to_string(), media_type: "image/png".to_string(), - data: png, + data: image.png, }); - true + } + + fn paste_image_to_canvas(&mut self, image: crate::clipboard::ClipboardImage) -> bool { + let encoded = base64::engine::general_purpose::STANDARD.encode(&image.png); + let src = format!("data:image/png;base64,{encoded}"); + let inserted = self + .host + .editor_state_mut() + .insert_image_node_at_viewport_sized( + "pasted-image.png", + &src, + image.width, + image.height, + ) + .is_some(); + if inserted { + self.host.mark_editor_state_dirty(); + } + inserted } /// Probe the system clipboard for Figma HTML (Cmd+C in Figma) and @@ -439,8 +482,7 @@ impl DesktopApp { /// marker; `pump_figma_clipboard_paste` applies the parsed nodes /// on a later frame. `None` when the clipboard holds no Figma /// payload (caller falls back to the internal node clipboard). - fn try_figma_clipboard_paste(&mut self) -> Option { - let html = crate::clipboard::get_html()?; + fn try_figma_clipboard_paste(&mut self, html: String) -> Option { if !op_figma::is_figma_clipboard_html(&html) { return None; } @@ -590,3 +632,7 @@ impl DesktopApp { self.host.mark_editor_state_dirty(); } } + +#[cfg(test)] +#[path = "keyboard_input_tests.rs"] +mod tests; diff --git a/crates/op-host-desktop/src/keyboard_input_tests.rs b/crates/op-host-desktop/src/keyboard_input_tests.rs new file mode 100644 index 000000000..f62bda00e --- /dev/null +++ b/crates/op-host-desktop/src/keyboard_input_tests.rs @@ -0,0 +1,233 @@ +use super::*; +use crate::clipboard::ClipboardImage; +use crate::keyboard_input::ClipboardPayload; +use jian_ops_schema::node::PenNode; +use jian_ops_schema::sizing::SizingBehavior; +use op_editor_core::agent_settings::{AcpAgentField, SettingsFocus}; + +const FIGMA_HTML: &str = + "eyJ2IjoxfQ==T1A="; + +fn image(width: u32, height: u32) -> ClipboardImage { + ClipboardImage { + png: vec![1, 2, 3], + width, + height, + } +} + +fn payload( + text: Option<&str>, + html: Option<&str>, + image: Option, +) -> ClipboardPayload { + ClipboardPayload { + text: text.map(str::to_string), + html: html.map(str::to_string), + image, + } +} + +fn seed_internal_clipboard(app: &mut DesktopApp) { + let state = app.host.editor_state_mut(); + state.set_single_selection(op_editor_core::NodeId::new("n10")); + assert!(state.copy_selected()); + state.clear_selection(); +} + +fn focus_settings_input(app: &mut DesktopApp) { + let ui = &mut app.host.editor_state_mut().editor_ui; + ui.agent_settings_open = true; + ui.agent_settings.add_acp_agent(); + ui.agent_settings.focus = Some(SettingsFocus::AcpAgent { + index: 0, + field: AcpAgentField::Command, + }); + ui.settings_input.set_text(""); +} + +#[test] +fn canvas_image_paste_preserves_size_selects_and_beats_internal_nodes() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + app.host.editor_state_mut().viewport = op_editor_core::Viewport::IDENTITY; + + assert!(app.handle_paste_payload(payload(None, None, Some(image(400, 200))))); + + let state = app.host.editor_state(); + assert_eq!(state.active_children().len(), 2); + let id = state.selection.anchor.clone(); + let PenNode::Image(node) = op_editor_core::walkers::find_node(state.active_children(), &id) + .expect("selected pasted node exists") + else { + panic!("selected pasted node should be an Image"); + }; + assert_eq!(node.width, Some(SizingBehavior::Number(300.0))); + assert_eq!(node.height, Some(SizingBehavior::Number(150.0))); + assert_eq!(node.base.x, Some(-150.0)); + assert_eq!(node.base.y, Some(-75.0)); + assert_eq!(node.src, "data:image/png;base64,AQID"); + assert!(state.history.can_undo()); + + assert!(app.host.editor_state_mut().undo()); + assert_eq!(app.host.editor_state().active_children().len(), 1); +} + +#[test] +fn focused_non_chat_input_pastes_text_before_every_canvas_flavour() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + focus_settings_input(&mut app); + + assert!(app.handle_paste_payload(payload( + Some("codex\n"), + Some(FIGMA_HTML), + Some(image(400, 200)), + ))); + + assert_eq!( + app.host.editor_state().editor_ui.settings_input.text(), + "codex" + ); + assert_eq!(app.host.editor_state().active_children().len(), 1); + assert!(app.host.editor_state().chat.pending_attachments.is_empty()); + assert!(app.pending_figma_paste.is_none()); +} + +#[test] +fn focused_chat_prefers_image_attachment_then_falls_back_to_text() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + app.host.editor_state_mut().chat.focused = true; + + assert!(app.handle_paste_payload(payload( + Some("ignored text"), + Some(FIGMA_HTML), + Some(image(640, 480)), + ))); + + let state = app.host.editor_state(); + assert!(state.chat.input.text().is_empty()); + assert_eq!(state.chat.pending_attachments.len(), 1); + assert_eq!(state.chat.pending_attachments[0].data, vec![1, 2, 3]); + assert_eq!(state.active_children().len(), 1); + assert!(app.pending_figma_paste.is_none()); + + app.host.editor_state_mut().chat.pending_attachments.clear(); + assert!(app.handle_paste_payload(payload(Some("chat text"), None, None))); + assert_eq!(app.host.editor_state().chat.input.text(), "chat text"); + assert!(app.host.editor_state().chat.pending_attachments.is_empty()); +} + +#[test] +fn chat_model_picker_paste_owns_keyboard_over_stale_chat_focus() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + let state = app.host.editor_state_mut(); + state.editor_ui.chat_model_picker.open = true; + state.chat.focused = true; + + assert!(app.handle_paste_payload(payload(Some("gp"), Some(FIGMA_HTML), Some(image(640, 480)),))); + + let state = app.host.editor_state(); + assert_eq!(state.editor_ui.chat_model_picker_input.text(), "gp"); + assert!(state.chat.input.text().is_empty()); + assert!(state.chat.pending_attachments.is_empty()); + assert_eq!(state.active_children().len(), 1); + assert!(state.selection.is_empty()); + assert_eq!(state.clipboard.len(), 1); + assert!(app.pending_figma_paste.is_none()); +} + +#[test] +fn font_picker_paste_owns_keyboard_over_stale_chat_focus() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + let state = app.host.editor_state_mut(); + state.editor_ui.font_picker.open = true; + state.chat.focused = true; + + assert!(app.handle_paste_payload(payload( + Some("Inter"), + Some(FIGMA_HTML), + Some(image(640, 480)), + ))); + + let state = app.host.editor_state(); + assert_eq!(state.editor_ui.font_picker_search, "Inter"); + assert!(state.chat.input.text().is_empty()); + assert!(state.chat.pending_attachments.is_empty()); + assert_eq!(state.active_children().len(), 1); + assert!(state.selection.is_empty()); + assert_eq!(state.clipboard.len(), 1); + assert!(app.pending_figma_paste.is_none()); +} + +#[test] +fn image_search_paste_owns_keyboard_over_stale_chat_focus() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + let state = app.host.editor_state_mut(); + state.editor_ui.image_panel.search_open = true; + state.chat.focused = true; + + assert!(app.handle_paste_payload(payload( + Some("sunset"), + Some(FIGMA_HTML), + Some(image(640, 480)), + ))); + + let state = app.host.editor_state(); + assert_eq!(state.editor_ui.image_panel.search_query, "sunset"); + assert!(state.chat.input.text().is_empty()); + assert!(state.chat.pending_attachments.is_empty()); + assert_eq!(state.active_children().len(), 1); + assert!(state.selection.is_empty()); + assert_eq!(state.clipboard.len(), 1); + assert!(app.pending_figma_paste.is_none()); +} + +#[test] +fn hidden_git_focus_does_not_swallow_canvas_image_paste() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + app.host.editor_state_mut().viewport = op_editor_core::Viewport::IDENTITY; + let git = &mut app.host.editor_state_mut().editor_ui.git_panel; + assert!(!git.open); + git.commit_focused = true; + + assert!(app.handle_paste_payload(payload(Some("stale git text"), None, Some(image(400, 200)),))); + + let state = app.host.editor_state(); + assert!(state.editor_ui.git_panel.commit_input.text().is_empty()); + assert_eq!(state.active_children().len(), 2); + assert!(matches!(state.selected_node(), Some(PenNode::Image(_)))); + assert_eq!(state.clipboard.len(), 1); +} + +#[test] +fn figma_html_beats_canvas_image_and_internal_nodes() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + + assert!(app.handle_paste_payload(payload(None, Some(FIGMA_HTML), Some(image(400, 200)),))); + + assert!(app.pending_figma_paste.is_some()); + assert_eq!(app.host.editor_state().active_children().len(), 1); + assert!(app.host.editor_state().selection.is_empty()); +} + +#[test] +fn internal_node_clipboard_remains_the_canvas_fallback() { + let mut app = DesktopApp::new(None); + seed_internal_clipboard(&mut app); + + assert!(app.handle_paste_payload(ClipboardPayload::default())); + + assert_eq!(app.host.editor_state().active_children().len(), 2); + assert!(app.host.editor_state().selection.anchor.is_real()); + assert!(!matches!( + app.host.editor_state().selected_node(), + Some(PenNode::Image(_)) + )); +} diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index 8c65e0243..ba3a74695 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -5,6 +5,48 @@ use super::WidgetHostNative; use op_editor_core::ReorderDirection; impl WidgetHostNative { + /// True when a non-chat text surface owns keyboard input. Plain-string + /// host inputs are checked explicitly; `TextInputState` owners share the + /// canonical editor-core resolver. + pub fn non_chat_input_owns_keyboard_pub(&self) -> bool { + let editor_ui = &self.editor_state.editor_ui; + if self.preview.is_some() + || editor_ui.font_picker.open + || editor_ui.image_panel.search_open + || editor_ui.image_panel.generate_open + || self.variables_search_active() + || editor_ui.preset_name_input_active() + || editor_ui.icon_picker.open + || editor_ui.component_browser_open + // `active_text_input()` resolves chat before Git. Visible Git / + // clone inputs must therefore claim ownership before the pointer + // comparison below when a stale chat-focus bit coexists. + || self.git_commit_focus_active() + || self.git_remote_focus_active() + || self.git_https_focus_active() + || self.git_branch_create_focus_active() + || self.git_author_focus_active() + || self.git_clone_input_active() + { + return true; + } + if self.editor_state.chat.focused { + self.editor_state + .active_text_input() + .is_some_and(|active| !std::ptr::eq(active, &self.editor_state.chat.input)) + } else { + // The host predicate applies the visibility gates for Git / + // clone inputs; the plain-string omissions are covered above. + self.input_active() + } + } + + /// True only when the chat input, rather than a higher-priority + /// non-chat surface, owns keyboard input. + pub fn chat_input_owns_keyboard_pub(&self) -> bool { + self.editor_state.chat.focused && !self.non_chat_input_owns_keyboard_pub() + } + /// Cmd-C — copy selection to clipboard. pub fn apply_copy(&mut self) -> bool { if self.editor_state.chat.focused {