openpencil/crates/op-host-native/src/widget_host/keyboard_clipboard.rs
Kayshen-X 25eb2f0cc1 fix(editor): make the collab join field paste-replace, selectable and clearable
A pasted invite code now replaces the whole field instead of appending to
stale content, Cmd/Ctrl+A selects the field so Backspace/Delete clears it
in one stroke, and a clear button sits inside the input. IME commits keep
insert semantics; only clipboard paths replace. Every blur and navigation
arm drops the whole-field selection so no destructive replace-on-type
state survives a click.
2026-08-02 08:30:12 +08:00

117 lines
4.8 KiB
Rust

//! Copy / cut / paste against the focused chrome text input on
//! `WidgetHostNative`.
//!
//! Split out of `keyboard.rs` to keep every file under the repo's
//! 800-line cap.
use super::WidgetHostNative;
impl WidgetHostNative {
/// Paste `text` into the focused chat input — appended at the
/// caret (always the buffer end). Newlines are kept so a
/// multi-line clipboard paste survives; the input widget wraps
/// and honours `\n`. Returns `false` (no-op) when the chat input
/// is not focused or `text` is empty. The desktop host calls
/// this with the OS clipboard's contents on Cmd+V.
pub fn chat_input_paste(&mut self, text: &str) -> bool {
if !self.editor_state.chat.focused || text.is_empty() {
return false;
}
self.editor_state.chat.insert_input_text(text, self.now_ms);
self.mark_dirty();
true
}
/// Paste clipboard `text` into whichever text input currently owns
/// the keyboard — the clone-wizard URL / destination, the git commit
/// message, the remote / HTTPS draft, or a settings field. Each
/// character is routed through [`Self::apply_text`], so per-input
/// filtering (e.g. digits-only for the MCP port, the clone field's
/// `!cloning` lock) still applies; control characters / newlines are
/// dropped since these inputs are single-line. Returns `true` if
/// anything was inserted.
pub fn apply_input_paste(&mut self, text: &str) -> bool {
// The join field takes a pasted invite code as a whole-field
// replacement: char-by-char append silently concatenated a new code
// onto a stale one, producing an invalid join target.
if self.editor_state.editor_ui.collab_join_input_active() {
let changed = op_editor_ui::widgets::collab_ui::join_address_paste(
&mut self.editor_state.editor_ui,
text,
)
.unwrap_or(false);
if changed {
self.mark_dirty();
}
return true;
}
let mut inserted = false;
for c in text.chars() {
if c.is_control() {
continue;
}
if self.apply_text(c) {
inserted = true;
}
}
inserted
}
/// Cut the focused chat input — returns its text and empties the
/// buffer. `None` when the chat input is not focused or already
/// empty. The desktop host writes the returned text to the OS
/// clipboard on Cmd+X.
pub fn chat_input_cut(&mut self) -> Option<String> {
if !self.editor_state.chat.focused || self.editor_state.chat.input.text().is_empty() {
return None;
}
if let Some(selected) = self
.editor_state
.chat
.selected_input_text()
.map(str::to_string)
{
self.editor_state.chat.delete_input_selection(self.now_ms);
self.mark_dirty();
return Some(selected);
}
let taken = self.editor_state.chat.input.text().to_owned();
self.editor_state.chat.set_input_text("");
self.editor_state.chat.input.touch(self.now_ms);
self.mark_dirty();
Some(taken)
}
/// Highlighted slice of whichever `TextInputState`-backed input
/// currently owns the keyboard — settings / git (commit, remote,
/// HTTPS, branch, author, clone) / rename / property / variables /
/// model-picker / canvas text editor. `None` when no such input is
/// focused or it has no selection. The desktop host writes the
/// returned slice to the OS clipboard on Cmd+C; chat-input copy is
/// handled separately (its own whole-buffer path). Routes through
/// the shared `EditorState::active_text_input` resolver so every
/// focused field is covered with one priority order.
pub fn input_copy_text(&self) -> Option<String> {
let state = self.editor_state.active_text_input()?;
let (start, end) = state.highlight_range()?;
Some(state.text().get(start..end)?.to_string())
}
/// Cut the highlighted slice of the focused `TextInputState` input:
/// returns the slice and deletes it. `None` when no such input is
/// focused or it has no selection. The delete reuses
/// [`Self::apply_backspace`] so it follows each input's own backspace
/// routing (per-input dirty / hint bookkeeping included); with a live
/// selection `backspace` removes the whole highlighted range
/// (`TextInputState::consume_pending`). Backs Cmd+X for every editor
/// text field except the chat input (`chat_input_cut`).
pub fn input_cut_text(&mut self) -> Option<String> {
let text = self.input_copy_text()?;
if text.is_empty() {
return None;
}
self.apply_backspace();
Some(text)
}
}