Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
# Changelog
All notable changes to this project are documented in this file.

## Unreleased

- Rust: Added `Window::copy_focused_text_selection()`, `Window::cut_focused_text_selection()`, and
`Window::paste_into_focused_text()` so custom backends can drive clipboard operations on the focused
text input from their own event handlers (e.g. web `ClipboardEvent`s); the winit web backend now uses them.
- Rust: Added `Window::has_focused_text_input()` so custom backends can tell when a text input holds the
focus (e.g. to keep a hidden editable element focused for web clipboard/IME events).

## [1.17.1] - 2026-07-07

- Fixed a panic/crash on startup when a global reads `Palette.color-scheme` (or accent-color) during its initialization.
Expand Down
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

188 changes: 188 additions & 0 deletions api/rs/slint/tests/window_focused_text_clipboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0

//! Tests for the `Window` focused-text clipboard API:
//! [`Window::copy_focused_text_selection`], [`Window::cut_focused_text_selection`], and
//! [`Window::paste_into_focused_text`] — the seam that lets custom backends service
//! copy/cut/paste from their own event handlers (e.g. web `ClipboardEvent`s).

mod common;

use slint::platform::{Key, WindowEvent};

const WIDTH: u32 = 200;
const HEIGHT: u32 = 32;

slint::slint! {
export component TestCase inherits Window {
width: 200px;
height: 32px;
in property <bool> ti-enabled: true;
in property <bool> ti-read-only: false;
in-out property <string> ti-text <=> ti.text;
public function focus-ti() { ti.focus(); }
ti := TextInput {
enabled: root.ti-enabled;
read-only: root.ti-read-only;
font-size: 12px;
}
}
}

/// Extend the selection by `n` graphemes to the right, from the current cursor position.
fn select_right(window: &slint::Window, n: usize) {
window.dispatch_event(WindowEvent::KeyPressed { text: Key::Shift.into() });
for _ in 0..n {
window.dispatch_event(WindowEvent::KeyPressed { text: Key::RightArrow.into() });
window.dispatch_event(WindowEvent::KeyReleased { text: Key::RightArrow.into() });
}
window.dispatch_event(WindowEvent::KeyReleased { text: Key::Shift.into() });
}

/// Move the cursor `n` graphemes to the right without selecting.
fn move_right(window: &slint::Window, n: usize) {
for _ in 0..n {
window.dispatch_event(WindowEvent::KeyPressed { text: Key::RightArrow.into() });
window.dispatch_event(WindowEvent::KeyReleased { text: Key::RightArrow.into() });
}
}

#[test]
fn copy_returns_selection_and_cut_deletes_it() {
common::setup(WIDTH, HEIGHT);
let ui = TestCase::new().unwrap();
ui.show().unwrap();
ui.set_ti_text("Hello World".into());
ui.invoke_focus_ti();

// No selection yet: copy and cut return None, and cut deletes nothing.
assert_eq!(ui.window().copy_focused_text_selection(), None);
assert_eq!(ui.window().cut_focused_text_selection(), None);
assert_eq!(ui.get_ti_text(), "Hello World");

// Select "Hello" (cursor starts at 0).
select_right(ui.window(), 5);

// Copy returns the selection and leaves the text untouched.
assert_eq!(ui.window().copy_focused_text_selection().as_deref(), Some("Hello"));
assert_eq!(ui.get_ti_text(), "Hello World");

// Cut returns the same selection and deletes it.
assert_eq!(ui.window().cut_focused_text_selection().as_deref(), Some("Hello"));
assert_eq!(ui.get_ti_text(), " World");
}

#[test]
fn paste_inserts_at_cursor_and_replaces_selection() {
common::setup(WIDTH, HEIGHT);
let ui = TestCase::new().unwrap();
ui.show().unwrap();
ui.set_ti_text("AB".into());
ui.invoke_focus_ti();

// Insert between "A" and "B"; the cursor ends up after the inserted text.
move_right(ui.window(), 1);
assert!(ui.window().paste_into_focused_text("X"));
assert_eq!(ui.get_ti_text(), "AXB");

// Select the trailing "B" and paste over it.
select_right(ui.window(), 1);
assert!(ui.window().paste_into_focused_text("Y"));
assert_eq!(ui.get_ti_text(), "AXY");
}

#[test]
fn no_focused_text_input_refuses_everything() {
common::setup(WIDTH, HEIGHT);
let ui = TestCase::new().unwrap();
ui.show().unwrap();
ui.set_ti_text("text".into());
// Nothing focused.
assert_eq!(ui.window().copy_focused_text_selection(), None);
assert_eq!(ui.window().cut_focused_text_selection(), None);
assert!(!ui.window().paste_into_focused_text("nope"));
assert_eq!(ui.get_ti_text(), "text");
}

#[test]
fn read_only_gates_cut_and_paste_but_not_copy() {
common::setup(WIDTH, HEIGHT);
let ui = TestCase::new().unwrap();
ui.show().unwrap();
ui.set_ti_text("Secret".into());
ui.set_ti_read_only(true);
ui.invoke_focus_ti();
select_right(ui.window(), 6);

// Copy is allowed on a read-only input, matching the Copy keyboard shortcut.
assert_eq!(ui.window().copy_focused_text_selection().as_deref(), Some("Secret"));
// Cut and paste refuse, matching the shortcuts' `!read-only` gate; nothing changes.
assert_eq!(ui.window().cut_focused_text_selection(), None);
assert!(!ui.window().paste_into_focused_text("overwrite"));
assert_eq!(ui.get_ti_text(), "Secret");
}

#[test]
fn disabled_gates_cut_and_paste() {
common::setup(WIDTH, HEIGHT);
let ui = TestCase::new().unwrap();
ui.show().unwrap();
ui.set_ti_text("Frozen".into());
ui.invoke_focus_ti();
select_right(ui.window(), 6);
// Disable after focusing and selecting.
ui.set_ti_enabled(false);

assert_eq!(ui.window().cut_focused_text_selection(), None);
assert!(!ui.window().paste_into_focused_text("thaw"));
assert_eq!(ui.get_ti_text(), "Frozen");
}

#[test]
fn has_focused_text_input_tracks_text_focus() {
common::setup(WIDTH, HEIGHT);
let ui = TestCase::new().unwrap();
ui.show().unwrap();
// Nothing focused yet.
assert!(!ui.window().has_focused_text_input());
// Focusing the TextInput reports true.
ui.invoke_focus_ti();
assert!(ui.window().has_focused_text_input());
}

#[test]
fn has_focused_text_input_false_for_non_text_focus() {
slint::slint! {
export component FocusCase inherits Window {
width: 200px;
height: 32px;
public function focus-fs() { fs.focus(); }
fs := FocusScope {}
}
}
common::setup(WIDTH, HEIGHT);
let ui = FocusCase::new().unwrap();
ui.show().unwrap();
// A focused non-text item (a FocusScope) is not a text input.
ui.invoke_focus_fs();
assert!(!ui.window().has_focused_text_input());
}

#[test]
fn multi_byte_selection_boundaries() {
common::setup(WIDTH, HEIGHT);
let ui = TestCase::new().unwrap();
ui.show().unwrap();
ui.set_ti_text("héllo".into()); // cspell:disable-line
ui.invoke_focus_ti();

// Select "hé" — the selection edge falls after a two-byte character.
select_right(ui.window(), 2);
assert_eq!(ui.window().copy_focused_text_selection().as_deref(), Some("hé"));
assert_eq!(ui.window().cut_focused_text_selection().as_deref(), Some("hé"));
assert_eq!(ui.get_ti_text(), "llo");

// Paste multi-byte text back at the cursor.
assert!(ui.window().paste_into_focused_text("→"));
assert_eq!(ui.get_ti_text(), "→llo");
}
1 change: 0 additions & 1 deletion demos/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion examples/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion internal/backends/winit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ cfg-if = "1"
derive_more = { workspace = true }
lyon_path = { workspace = true }
pin-weak = "1"
scoped-tls-hkt = "0.1"
strum = { workspace = true }
winit = { version = "0.30.2", default-features = false, features = ["rwh_06"] }
raw-window-handle = { version = "0.6", features = ["alloc"] }
Expand Down
15 changes: 5 additions & 10 deletions internal/backends/winit/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -874,11 +874,11 @@ impl i_slint_core::platform::Platform for Backend {
)))
}

#[cfg(target_arch = "wasm32")]
fn set_clipboard_text(&self, text: &str, clipboard: i_slint_core::platform::Clipboard) {
crate::wasm_input_helper::set_clipboard_text(text.into(), clipboard);
}

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

#[cfg(target_arch = "wasm32")]
fn clipboard_text(&self, clipboard: i_slint_core::platform::Clipboard) -> Option<String> {
crate::wasm_input_helper::get_clipboard_text(clipboard)
}

#[cfg(not(target_arch = "wasm32"))]
fn clipboard_text(&self, clipboard: i_slint_core::platform::Clipboard) -> Option<String> {
let mut pair = self.shared_data.clipboard.borrow_mut();
Expand Down
84 changes: 11 additions & 73 deletions internal/backends/winit/wasm_input_helper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,73 +77,31 @@ impl WasmInputHelper {
else {
return;
};
e.prevent_default();
let synthetic_clipboard_data = RefCell::new(text);
CURRENT_WASM_CLIPBOARD_DATA.set(&synthetic_clipboard_data, || {
if let Some(focus_item) = WindowInner::from_pub(&window_adapter.window())
.focus_item
.borrow()
.upgrade()
{
if let Some(text_input) =
focus_item.downcast::<i_slint_core::items::TextInput>()
{
text_input.as_pin_ref().paste(&window_adapter, &focus_item);
}
}
})
if window_adapter.window().paste_into_focused_text(&text) {
e.prevent_default();
}
}
});
let win = window_adapter.clone();
h.add_event_listener("copy", move |e: web_sys::ClipboardEvent| {
if let Some(window_adapter) = win.upgrade() {
e.prevent_default();

let synthetic_clipboard_data = RefCell::new(String::default());
CURRENT_WASM_CLIPBOARD_DATA.set(&synthetic_clipboard_data, || {
if let Some(focus_item) = WindowInner::from_pub(&window_adapter.window())
.focus_item
.borrow()
.upgrade()
{
if let Some(text_input) =
focus_item.downcast::<i_slint_core::items::TextInput>()
{
let text = text_input.as_pin_ref().copy(&window_adapter, &focus_item);
}
if let Some(text) = window_adapter.window().copy_focused_text_selection() {
if let Some(data) = e.clipboard_data() {
data.set_data("text", &text).ok();
}
});
if let Some(data) = e.clipboard_data() {
data.set_data("text", &synthetic_clipboard_data.into_inner()).ok();
e.prevent_default();
}
}
});

let win = window_adapter.clone();
h.add_event_listener("cut", move |e: web_sys::ClipboardEvent| {
if let Some(window_adapter) = win.upgrade() {
e.prevent_default();
if let Some(focus_item) =
WindowInner::from_pub(&window_adapter.window()).focus_item.borrow().upgrade()
{
if let Some(text_input) =
focus_item.downcast::<i_slint_core::items::TextInput>()
{
let (anchor, cursor) =
text_input.as_pin_ref().selection_anchor_and_cursor();
if anchor == cursor {
return;
}
let text = text_input.as_pin_ref().text();
if let Some(data) = e.clipboard_data() {
data.set_data("text", &text[anchor..cursor]).ok();
}
text_input.as_pin_ref().delete_selection(
&window_adapter,
&focus_item,
i_slint_core::items::TextChangeNotify::TriggerCallbacks,
);
if let Some(text) = window_adapter.window().cut_focused_text_selection() {
if let Some(data) = e.clipboard_data() {
data.set_data("text", &text).ok();
}
e.prevent_default();
}
}
});
Expand Down Expand Up @@ -325,23 +283,3 @@ fn event_text(e: &web_sys::KeyboardEvent, is_apple: bool) -> Option<SharedString
_ => None,
}
}

scoped_tls_hkt::scoped_thread_local!(static CURRENT_WASM_CLIPBOARD_DATA : for<'a> &'a RefCell<String>);

pub(crate) fn set_clipboard_text(data: String, clipboard: i_slint_core::platform::Clipboard) {
if CURRENT_WASM_CLIPBOARD_DATA.is_set()
&& matches!(clipboard, i_slint_core::platform::Clipboard::DefaultClipboard)
{
CURRENT_WASM_CLIPBOARD_DATA.with(|current_data| *current_data.borrow_mut() = data)
}
}

pub(crate) fn get_clipboard_text(clipboard: i_slint_core::platform::Clipboard) -> Option<String> {
if CURRENT_WASM_CLIPBOARD_DATA.is_set()
&& matches!(clipboard, i_slint_core::platform::Clipboard::DefaultClipboard)
{
Some(CURRENT_WASM_CLIPBOARD_DATA.with(|current_data| current_data.borrow().clone()))
} else {
None
}
}
Loading
Loading