Skip to content

Commit 505709a

Browse files
committed
Add public API to drive copy/cut/paste of the focused text input
`slint::Window` gains four methods that operate on the currently focused `TextInput`: - `copy_focused_text_selection() -> Option<SharedString>` - `cut_focused_text_selection() -> Option<SharedString>` - `paste_into_focused_text(&str) -> bool` - `has_focused_text_input() -> bool` Copy and cut return the selected text to the caller instead of writing it through `Platform::set_clipboard_text`, and paste takes the text as an argument instead of reading `Platform::clipboard_text`. This lets a custom backend service a clipboard operation from inside its own event handler. The motivating case is the web platform: the browser clipboard is asynchronous and reachable only from a `copy`/`cut`/`paste` `ClipboardEvent` fired during a user gesture, which the synchronous `Platform` clipboard hooks cannot satisfy — so the data has to flow through the event, not the `Platform` trait. `has_focused_text_input` reports whether a text input currently holds the focus, letting such a backend keep its clipboard-event target in sync with Slint — on the web, that means keeping a hidden editable element focused exactly while a text input is edited, since browsers only deliver clipboard (and IME) events to an editable context. Cut and paste honor the same read-only/disabled gate as the Cut/Paste keyboard shortcuts; copy stays ungated like the Copy shortcut. `TextInput::insert_text` is extracted from `paste_clipboard` so the paste path can insert externally-supplied text, and the winit web backend's `copy`/`cut`/`paste` handlers now drive these `Window` methods. That retires the `CURRENT_WASM_CLIPBOARD_DATA` thread-local shuttle: the wasm `Platform` clipboard hooks fall back to their default no-op implementations (nothing could reach them outside a clipboard event anyway) and the winit backend's `scoped-tls-hkt` dependency is dropped.
1 parent aaba2f8 commit 505709a

8 files changed

Lines changed: 331 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
# Changelog
33
All notable changes to this project are documented in this file.
44

5+
## Unreleased
6+
7+
- Rust: Added `Window::copy_focused_text_selection()`, `Window::cut_focused_text_selection()`, and
8+
`Window::paste_into_focused_text()` so custom backends can drive clipboard operations on the focused
9+
text input from their own event handlers (e.g. web `ClipboardEvent`s); the winit web backend now uses them.
10+
- Rust: Added `Window::has_focused_text_input()` so custom backends can tell when a text input holds the
11+
focus (e.g. to keep a hidden editable element focused for web clipboard/IME events).
12+
513
## [1.17.1] - 2026-07-07
614

715
- Fixed a panic/crash on startup when a global reads `Palette.color-scheme` (or accent-color) during its initialization.

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright © SixtyFPS GmbH <info@slint.dev>
2+
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3+
4+
//! Tests for the `Window` focused-text clipboard API:
5+
//! [`Window::copy_focused_text_selection`], [`Window::cut_focused_text_selection`], and
6+
//! [`Window::paste_into_focused_text`] — the seam that lets custom backends service
7+
//! copy/cut/paste from their own event handlers (e.g. web `ClipboardEvent`s).
8+
9+
mod common;
10+
11+
use slint::platform::{Key, WindowEvent};
12+
13+
const WIDTH: u32 = 200;
14+
const HEIGHT: u32 = 32;
15+
16+
slint::slint! {
17+
export component TestCase inherits Window {
18+
width: 200px;
19+
height: 32px;
20+
in property <bool> ti-enabled: true;
21+
in property <bool> ti-read-only: false;
22+
in-out property <string> ti-text <=> ti.text;
23+
public function focus-ti() { ti.focus(); }
24+
ti := TextInput {
25+
enabled: root.ti-enabled;
26+
read-only: root.ti-read-only;
27+
font-size: 12px;
28+
}
29+
}
30+
}
31+
32+
/// Extend the selection by `n` graphemes to the right, from the current cursor position.
33+
fn select_right(window: &slint::Window, n: usize) {
34+
window.dispatch_event(WindowEvent::KeyPressed { text: Key::Shift.into() });
35+
for _ in 0..n {
36+
window.dispatch_event(WindowEvent::KeyPressed { text: Key::RightArrow.into() });
37+
window.dispatch_event(WindowEvent::KeyReleased { text: Key::RightArrow.into() });
38+
}
39+
window.dispatch_event(WindowEvent::KeyReleased { text: Key::Shift.into() });
40+
}
41+
42+
/// Move the cursor `n` graphemes to the right without selecting.
43+
fn move_right(window: &slint::Window, n: usize) {
44+
for _ in 0..n {
45+
window.dispatch_event(WindowEvent::KeyPressed { text: Key::RightArrow.into() });
46+
window.dispatch_event(WindowEvent::KeyReleased { text: Key::RightArrow.into() });
47+
}
48+
}
49+
50+
#[test]
51+
fn copy_returns_selection_and_cut_deletes_it() {
52+
common::setup(WIDTH, HEIGHT);
53+
let ui = TestCase::new().unwrap();
54+
ui.show().unwrap();
55+
ui.set_ti_text("Hello World".into());
56+
ui.invoke_focus_ti();
57+
58+
// No selection yet: copy and cut return None, and cut deletes nothing.
59+
assert_eq!(ui.window().copy_focused_text_selection(), None);
60+
assert_eq!(ui.window().cut_focused_text_selection(), None);
61+
assert_eq!(ui.get_ti_text(), "Hello World");
62+
63+
// Select "Hello" (cursor starts at 0).
64+
select_right(ui.window(), 5);
65+
66+
// Copy returns the selection and leaves the text untouched.
67+
assert_eq!(ui.window().copy_focused_text_selection().as_deref(), Some("Hello"));
68+
assert_eq!(ui.get_ti_text(), "Hello World");
69+
70+
// Cut returns the same selection and deletes it.
71+
assert_eq!(ui.window().cut_focused_text_selection().as_deref(), Some("Hello"));
72+
assert_eq!(ui.get_ti_text(), " World");
73+
}
74+
75+
#[test]
76+
fn paste_inserts_at_cursor_and_replaces_selection() {
77+
common::setup(WIDTH, HEIGHT);
78+
let ui = TestCase::new().unwrap();
79+
ui.show().unwrap();
80+
ui.set_ti_text("AB".into());
81+
ui.invoke_focus_ti();
82+
83+
// Insert between "A" and "B"; the cursor ends up after the inserted text.
84+
move_right(ui.window(), 1);
85+
assert!(ui.window().paste_into_focused_text("X"));
86+
assert_eq!(ui.get_ti_text(), "AXB");
87+
88+
// Select the trailing "B" and paste over it.
89+
select_right(ui.window(), 1);
90+
assert!(ui.window().paste_into_focused_text("Y"));
91+
assert_eq!(ui.get_ti_text(), "AXY");
92+
}
93+
94+
#[test]
95+
fn no_focused_text_input_refuses_everything() {
96+
common::setup(WIDTH, HEIGHT);
97+
let ui = TestCase::new().unwrap();
98+
ui.show().unwrap();
99+
ui.set_ti_text("text".into());
100+
// Nothing focused.
101+
assert_eq!(ui.window().copy_focused_text_selection(), None);
102+
assert_eq!(ui.window().cut_focused_text_selection(), None);
103+
assert!(!ui.window().paste_into_focused_text("nope"));
104+
assert_eq!(ui.get_ti_text(), "text");
105+
}
106+
107+
#[test]
108+
fn read_only_gates_cut_and_paste_but_not_copy() {
109+
common::setup(WIDTH, HEIGHT);
110+
let ui = TestCase::new().unwrap();
111+
ui.show().unwrap();
112+
ui.set_ti_text("Secret".into());
113+
ui.set_ti_read_only(true);
114+
ui.invoke_focus_ti();
115+
select_right(ui.window(), 6);
116+
117+
// Copy is allowed on a read-only input, matching the Copy keyboard shortcut.
118+
assert_eq!(ui.window().copy_focused_text_selection().as_deref(), Some("Secret"));
119+
// Cut and paste refuse, matching the shortcuts' `!read-only` gate; nothing changes.
120+
assert_eq!(ui.window().cut_focused_text_selection(), None);
121+
assert!(!ui.window().paste_into_focused_text("overwrite"));
122+
assert_eq!(ui.get_ti_text(), "Secret");
123+
}
124+
125+
#[test]
126+
fn disabled_gates_cut_and_paste() {
127+
common::setup(WIDTH, HEIGHT);
128+
let ui = TestCase::new().unwrap();
129+
ui.show().unwrap();
130+
ui.set_ti_text("Frozen".into());
131+
ui.invoke_focus_ti();
132+
select_right(ui.window(), 6);
133+
// Disable after focusing and selecting.
134+
ui.set_ti_enabled(false);
135+
136+
assert_eq!(ui.window().cut_focused_text_selection(), None);
137+
assert!(!ui.window().paste_into_focused_text("thaw"));
138+
assert_eq!(ui.get_ti_text(), "Frozen");
139+
}
140+
141+
#[test]
142+
fn has_focused_text_input_tracks_text_focus() {
143+
common::setup(WIDTH, HEIGHT);
144+
let ui = TestCase::new().unwrap();
145+
ui.show().unwrap();
146+
// Nothing focused yet.
147+
assert!(!ui.window().has_focused_text_input());
148+
// Focusing the TextInput reports true.
149+
ui.invoke_focus_ti();
150+
assert!(ui.window().has_focused_text_input());
151+
}
152+
153+
#[test]
154+
fn has_focused_text_input_false_for_non_text_focus() {
155+
slint::slint! {
156+
export component FocusCase inherits Window {
157+
width: 200px;
158+
height: 32px;
159+
public function focus-fs() { fs.focus(); }
160+
fs := FocusScope {}
161+
}
162+
}
163+
common::setup(WIDTH, HEIGHT);
164+
let ui = FocusCase::new().unwrap();
165+
ui.show().unwrap();
166+
// A focused non-text item (a FocusScope) is not a text input.
167+
ui.invoke_focus_fs();
168+
assert!(!ui.window().has_focused_text_input());
169+
}
170+
171+
#[test]
172+
fn multi_byte_selection_boundaries() {
173+
common::setup(WIDTH, HEIGHT);
174+
let ui = TestCase::new().unwrap();
175+
ui.show().unwrap();
176+
ui.set_ti_text("héllo".into());
177+
ui.invoke_focus_ti();
178+
179+
// Select "hé" — the selection edge falls after a two-byte character.
180+
select_right(ui.window(), 2);
181+
assert_eq!(ui.window().copy_focused_text_selection().as_deref(), Some("hé"));
182+
assert_eq!(ui.window().cut_focused_text_selection().as_deref(), Some("hé"));
183+
assert_eq!(ui.get_ti_text(), "llo");
184+
185+
// Paste multi-byte text back at the cursor.
186+
assert!(ui.window().paste_into_focused_text("→"));
187+
assert_eq!(ui.get_ti_text(), "→llo");
188+
}

internal/backends/winit/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ cfg-if = "1"
7373
derive_more = { workspace = true }
7474
lyon_path = { workspace = true }
7575
pin-weak = "1"
76-
scoped-tls-hkt = "0.1"
7776
strum = { workspace = true }
7877
winit = { version = "0.30.2", default-features = false, features = ["rwh_06"] }
7978
raw-window-handle = { version = "0.6", features = ["alloc"] }

internal/backends/winit/lib.rs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -874,11 +874,11 @@ impl i_slint_core::platform::Platform for Backend {
874874
)))
875875
}
876876

877-
#[cfg(target_arch = "wasm32")]
878-
fn set_clipboard_text(&self, text: &str, clipboard: i_slint_core::platform::Clipboard) {
879-
crate::wasm_input_helper::set_clipboard_text(text.into(), clipboard);
880-
}
881-
877+
// On wasm, the clipboard hooks keep their default no-op implementations: the browser
878+
// clipboard is asynchronous and gesture-gated, so copy/cut/paste are serviced entirely from
879+
// the `ClipboardEvent` handlers in `wasm_input_helper`, via
880+
// `Window::copy_focused_text_selection` / `cut_focused_text_selection` /
881+
// `paste_into_focused_text`.
882882
#[cfg(not(target_arch = "wasm32"))]
883883
fn set_clipboard_text(&self, text: &str, clipboard: i_slint_core::platform::Clipboard) {
884884
let mut pair = self.shared_data.clipboard.borrow_mut();
@@ -887,11 +887,6 @@ impl i_slint_core::platform::Platform for Backend {
887887
}
888888
}
889889

890-
#[cfg(target_arch = "wasm32")]
891-
fn clipboard_text(&self, clipboard: i_slint_core::platform::Clipboard) -> Option<String> {
892-
crate::wasm_input_helper::get_clipboard_text(clipboard)
893-
}
894-
895890
#[cfg(not(target_arch = "wasm32"))]
896891
fn clipboard_text(&self, clipboard: i_slint_core::platform::Clipboard) -> Option<String> {
897892
let mut pair = self.shared_data.clipboard.borrow_mut();

internal/backends/winit/wasm_input_helper.rs

Lines changed: 11 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -77,73 +77,31 @@ impl WasmInputHelper {
7777
else {
7878
return;
7979
};
80-
e.prevent_default();
81-
let synthetic_clipboard_data = RefCell::new(text);
82-
CURRENT_WASM_CLIPBOARD_DATA.set(&synthetic_clipboard_data, || {
83-
if let Some(focus_item) = WindowInner::from_pub(&window_adapter.window())
84-
.focus_item
85-
.borrow()
86-
.upgrade()
87-
{
88-
if let Some(text_input) =
89-
focus_item.downcast::<i_slint_core::items::TextInput>()
90-
{
91-
text_input.as_pin_ref().paste(&window_adapter, &focus_item);
92-
}
93-
}
94-
})
80+
if window_adapter.window().paste_into_focused_text(&text) {
81+
e.prevent_default();
82+
}
9583
}
9684
});
9785
let win = window_adapter.clone();
9886
h.add_event_listener("copy", move |e: web_sys::ClipboardEvent| {
9987
if let Some(window_adapter) = win.upgrade() {
100-
e.prevent_default();
101-
102-
let synthetic_clipboard_data = RefCell::new(String::default());
103-
CURRENT_WASM_CLIPBOARD_DATA.set(&synthetic_clipboard_data, || {
104-
if let Some(focus_item) = WindowInner::from_pub(&window_adapter.window())
105-
.focus_item
106-
.borrow()
107-
.upgrade()
108-
{
109-
if let Some(text_input) =
110-
focus_item.downcast::<i_slint_core::items::TextInput>()
111-
{
112-
let text = text_input.as_pin_ref().copy(&window_adapter, &focus_item);
113-
}
88+
if let Some(text) = window_adapter.window().copy_focused_text_selection() {
89+
if let Some(data) = e.clipboard_data() {
90+
data.set_data("text", &text).ok();
11491
}
115-
});
116-
if let Some(data) = e.clipboard_data() {
117-
data.set_data("text", &synthetic_clipboard_data.into_inner()).ok();
92+
e.prevent_default();
11893
}
11994
}
12095
});
12196

12297
let win = window_adapter.clone();
12398
h.add_event_listener("cut", move |e: web_sys::ClipboardEvent| {
12499
if let Some(window_adapter) = win.upgrade() {
125-
e.prevent_default();
126-
if let Some(focus_item) =
127-
WindowInner::from_pub(&window_adapter.window()).focus_item.borrow().upgrade()
128-
{
129-
if let Some(text_input) =
130-
focus_item.downcast::<i_slint_core::items::TextInput>()
131-
{
132-
let (anchor, cursor) =
133-
text_input.as_pin_ref().selection_anchor_and_cursor();
134-
if anchor == cursor {
135-
return;
136-
}
137-
let text = text_input.as_pin_ref().text();
138-
if let Some(data) = e.clipboard_data() {
139-
data.set_data("text", &text[anchor..cursor]).ok();
140-
}
141-
text_input.as_pin_ref().delete_selection(
142-
&window_adapter,
143-
&focus_item,
144-
i_slint_core::items::TextChangeNotify::TriggerCallbacks,
145-
);
100+
if let Some(text) = window_adapter.window().cut_focused_text_selection() {
101+
if let Some(data) = e.clipboard_data() {
102+
data.set_data("text", &text).ok();
146103
}
104+
e.prevent_default();
147105
}
148106
}
149107
});
@@ -325,23 +283,3 @@ fn event_text(e: &web_sys::KeyboardEvent, is_apple: bool) -> Option<SharedString
325283
_ => None,
326284
}
327285
}
328-
329-
scoped_tls_hkt::scoped_thread_local!(static CURRENT_WASM_CLIPBOARD_DATA : for<'a> &'a RefCell<String>);
330-
331-
pub(crate) fn set_clipboard_text(data: String, clipboard: i_slint_core::platform::Clipboard) {
332-
if CURRENT_WASM_CLIPBOARD_DATA.is_set()
333-
&& matches!(clipboard, i_slint_core::platform::Clipboard::DefaultClipboard)
334-
{
335-
CURRENT_WASM_CLIPBOARD_DATA.with(|current_data| *current_data.borrow_mut() = data)
336-
}
337-
}
338-
339-
pub(crate) fn get_clipboard_text(clipboard: i_slint_core::platform::Clipboard) -> Option<String> {
340-
if CURRENT_WASM_CLIPBOARD_DATA.is_set()
341-
&& matches!(clipboard, i_slint_core::platform::Clipboard::DefaultClipboard)
342-
{
343-
Some(CURRENT_WASM_CLIPBOARD_DATA.with(|current_data| current_data.borrow().clone()))
344-
} else {
345-
None
346-
}
347-
}

0 commit comments

Comments
 (0)