ContextMenu on the LineEdit and TextEdit

With usual copy/paste entries

We need to make sure that showing the popup window don't clear the
selection in the TextInput which happens if the TextInput gets a
FocusOut event.
This commit is contained in:
Olivier Goffart 2025-02-10 17:36:40 +01:00
parent 9195f3e265
commit c85b20d431
7 changed files with 223 additions and 139 deletions

View file

@ -67,38 +67,57 @@ export component LineEditBase inherits Rectangle {
accessible-role: none;
}
text-input := TextInput {
property <length> computed-x;
ContextMenu {
MenuItem {
title: @tr("Cut");
activated => { text-input.cut(); }
}
MenuItem {
title: @tr("Copy");
activated => { text-input.copy(); }
}
MenuItem {
title: @tr("Paste");
activated => { text-input.paste(); }
}
MenuItem {
title: @tr("Select All");
activated => { text-input.select-all(); }
}
x: min(0px, max(parent.width - self.width - self.text-cursor-width, self.computed-x));
width: max(parent.width - self.text-cursor-width, self.preferred-width);
height: 100%;
vertical-alignment: center;
single-line: true;
color: root.text-color;
text-input := TextInput {
property <length> computed-x;
cursor-position-changed(cursor-position) => {
if cursor-position.x + self.computed_x < root.margin {
self.computed_x = - cursor-position.x + root.margin;
} else if cursor-position.x + self.computed_x > parent.width - root.margin - self.text-cursor-width {
self.computed_x = parent.width - cursor-position.x - root.margin - self.text-cursor-width;
x: min(0px, max(parent.width - self.width - self.text-cursor-width, self.computed-x));
width: max(parent.width - self.text-cursor-width, self.preferred-width);
height: 100%;
vertical-alignment: center;
single-line: true;
color: root.text-color;
cursor-position-changed(cursor-position) => {
if cursor-position.x + self.computed_x < root.margin {
self.computed_x = - cursor-position.x + root.margin;
} else if cursor-position.x + self.computed_x > parent.width - root.margin - self.text-cursor-width {
self.computed_x = parent.width - cursor-position.x - root.margin - self.text-cursor-width;
}
}
}
accepted => {
root.accepted(self.text);
}
accepted => {
root.accepted(self.text);
}
edited => {
root.edited(self.text);
}
edited => {
root.edited(self.text);
}
key-pressed(event) => {
root.key-pressed(event)
}
key-pressed(event) => {
root.key-pressed(event)
}
key-released(event) => {
root.key-released(event)
key-released(event) => {
root.key-released(event)
}
}
}
}

View file

@ -58,48 +58,68 @@ export component TextEditBase inherits Rectangle {
forward-focus: text-input;
scroll-view := ScrollView {
x: root.scroll-view-padding;
y: root.scroll-view-padding;
width: parent.width - 2 * root.scroll-view-padding;
height: parent.height - 2 * root.scroll-view-padding;
viewport-width: root.wrap == TextWrap.no-wrap ? max(self.visible-width, text-input.preferred-width) : self.visible-width;
viewport-height: max(self.visible-height, text-input.preferred-height);
ContextMenu {
MenuItem {
title: @tr("Cut");
activated => { text-input.cut(); }
}
MenuItem {
title: @tr("Copy");
activated => { text-input.copy(); }
}
MenuItem {
title: @tr("Paste");
activated => { text-input.paste(); }
}
MenuItem {
title: @tr("Select All");
activated => { text-input.select-all(); }
}
text-input := TextInput {
enabled: true;
single-line: false;
wrap: word-wrap;
selection-background-color: root.selection-background-color;
selection-foreground-color: root.selection-foreground-color;
page-height: scroll-view.visible-height;
scroll-view := ScrollView {
x: root.scroll-view-padding;
y: root.scroll-view-padding;
width: parent.width - 2 * root.scroll-view-padding;
height: parent.height - 2 * root.scroll-view-padding;
viewport-width: root.wrap == TextWrap.no-wrap ? max(self.visible-width, text-input.preferred-width) : self.visible-width;
viewport-height: max(self.visible-height, text-input.preferred-height);
edited => {
root.edited(self.text);
}
text-input := TextInput {
enabled: true;
single-line: false;
wrap: word-wrap;
selection-background-color: root.selection-background-color;
selection-foreground-color: root.selection-foreground-color;
page-height: scroll-view.visible-height;
key-pressed(event) => {
root.key-pressed(event)
}
key-released(event) => {
root.key-released(event)
}
cursor-position-changed(cpos) => {
if (cpos.x + root.viewport-x < 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, - cpos.x + 12px));
} else if (cpos.x + root.viewport-x > parent.visible-width - 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, parent.visible-width - cpos.x - 12px));
edited => {
root.edited(self.text);
}
if (cpos.y + root.viewport-y < 12px) {
root.viewport-y = min(0px, max(parent.visible-height - self.height, - cpos.y + 12px));
} else if (cpos.y + root.viewport-y > parent.visible-height - 12px - 20px) {
// FIXME: font-height hardcoded to 20px
root.viewport-y = min(0px, max(parent.visible-height - self.height, parent.visible-height - cpos.y - 12px - 20px));
key-pressed(event) => {
root.key-pressed(event)
}
key-released(event) => {
root.key-released(event)
}
cursor-position-changed(cpos) => {
if (cpos.x + root.viewport-x < 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, - cpos.x + 12px));
} else if (cpos.x + root.viewport-x > parent.visible-width - 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, parent.visible-width - cpos.x - 12px));
}
if (cpos.y + root.viewport-y < 12px) {
root.viewport-y = min(0px, max(parent.visible-height - self.height, - cpos.y + 12px));
} else if (cpos.y + root.viewport-y > parent.visible-height - 12px - 20px) {
// FIXME: font-height hardcoded to 20px
root.viewport-y = min(0px, max(parent.visible-height - self.height, parent.visible-height - cpos.y - 12px - 20px));
}
}
}
}
}
placeholder := Text {

View file

@ -138,48 +138,67 @@ export component TextEdit {
border-width: 1px;
}
scroll-view := ScrollView {
x: 8px;
y: 8px;
width: parent.width - 16px;
height: parent.height - 16px;
viewport-width: root.wrap == TextWrap.no-wrap ? max(self.visible-width, text-input.preferred-width) : self.visible-width;
viewport-height: max(self.visible-height, text-input.preferred-height);
ContextMenu {
MenuItem {
title: @tr("Cut");
activated => { text-input.cut(); }
}
MenuItem {
title: @tr("Copy");
activated => { text-input.copy(); }
}
MenuItem {
title: @tr("Paste");
activated => { text-input.paste(); }
}
MenuItem {
title: @tr("Select All");
activated => { text-input.select-all(); }
}
text-input := TextInput {
enabled: true;
color: CupertinoPalette.foreground;
font-size: CupertinoFontSettings.body.font-size;
font-weight: CupertinoFontSettings.body.font-weight;
selection-background-color: CupertinoPalette.selection-background;
selection-foreground-color: self.color;
single-line: false;
wrap: word-wrap;
page-height: scroll-view.visible-height;
scroll-view := ScrollView {
x: 8px;
y: 8px;
width: parent.width - 16px;
height: parent.height - 16px;
viewport-width: root.wrap == TextWrap.no-wrap ? max(self.visible-width, text-input.preferred-width) : self.visible-width;
viewport-height: max(self.visible-height, text-input.preferred-height);
edited => {
root.edited(self.text);
}
text-input := TextInput {
enabled: true;
color: CupertinoPalette.foreground;
font-size: CupertinoFontSettings.body.font-size;
font-weight: CupertinoFontSettings.body.font-weight;
selection-background-color: CupertinoPalette.selection-background;
selection-foreground-color: self.color;
single-line: false;
wrap: word-wrap;
page-height: scroll-view.visible-height;
key-pressed(event) => {
root.key-pressed(event)
}
key-released(event) => {
root.key-released(event)
}
cursor-position-changed(cpos) => {
if (cpos.x + root.viewport-x < 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, - cpos.x + 12px));
} else if (cpos.x + root.viewport-x > parent.visible-width - 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, parent.visible-width - cpos.x - 12px));
edited => {
root.edited(self.text);
}
if (cpos.y + root.viewport-y < 12px) {
root.viewport-y = min(0px, max(parent.visible-height - self.height, - cpos.y + 12px));
} else if (cpos.y + root.viewport-y > parent.visible-height - 12px - 20px) {
// FIXME: font-height hardcoded to 20px
root.viewport-y = min(0px, max(parent.visible-height - self.height, parent.visible-height - cpos.y - 12px - 20px));
key-pressed(event) => {
root.key-pressed(event)
}
key-released(event) => {
root.key-released(event)
}
cursor-position-changed(cpos) => {
if (cpos.x + root.viewport-x < 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, - cpos.x + 12px));
} else if (cpos.x + root.viewport-x > parent.visible-width - 12px) {
root.viewport-x = min(0px, max(parent.visible-width - self.width, parent.visible-width - cpos.x - 12px));
}
if (cpos.y + root.viewport-y < 12px) {
root.viewport-y = min(0px, max(parent.visible-height - self.height, - cpos.y + 12px));
} else if (cpos.y + root.viewport-y > parent.visible-height - 12px - 20px) {
// FIXME: font-height hardcoded to 20px
root.viewport-y = min(0px, max(parent.visible-height - self.height, parent.visible-height - cpos.y - 12px - 20px));
}
}
}
}

View file

@ -466,7 +466,7 @@ pub enum FocusEvent {
FocusOut,
/// This event is sent when the window receives the keyboard focus.
WindowReceivedFocus,
/// This event is sent when the window looses the keyboard focus.
/// This event is sent when the window looses the keyboard focus. (including if this is because of a popup)
WindowLostFocus,
}

View file

@ -640,7 +640,7 @@ impl Item for TextInput {
return InputEventResult::GrabMouse;
}
MouseEvent::Pressed { .. } => {
MouseEvent::Pressed { button: PointerEventButton::Middle, .. } => {
#[cfg(not(target_os = "android"))]
self.ensure_focus_and_ime(window_adapter, self_rc);
}
@ -663,8 +663,6 @@ impl Item for TextInput {
);
self.paste_clipboard(window_adapter, self_rc, Clipboard::SelectionClipboard);
}
// Other mouse buttons should still be accepted even if we don't handle them
MouseEvent::Released { .. } => {}
MouseEvent::Exit => {
if let Some(x) = window_adapter.internal(crate::InternalToken) {
x.set_mouse_cursor(super::MouseCursor::Default);

View file

@ -11,8 +11,8 @@ use crate::api::{
WindowPosition, WindowSize,
};
use crate::input::{
key_codes, ClickState, InternalKeyboardModifierState, KeyEvent, KeyEventType, MouseEvent,
MouseInputState, TextCursorBlinker,
key_codes, ClickState, FocusEvent, InternalKeyboardModifierState, KeyEvent, KeyEventType,
MouseEvent, MouseInputState, TextCursorBlinker,
};
use crate::item_tree::{ItemRc, ItemTreeRc, ItemTreeRef, ItemTreeVTable, ItemTreeWeak, ItemWeak};
use crate::items::{ColorScheme, InputType, ItemRef, MouseCursor, PopupClosePolicy};
@ -719,7 +719,7 @@ impl WindowInner {
if item.as_ref().is_some_and(|i| !i.is_visible()) {
// Reset the focus... not great, but better than keeping it.
self.take_focus_item();
self.take_focus_item(&FocusEvent::FocusOut);
item = None;
}
@ -820,7 +820,7 @@ impl WindowInner {
}
}
let old = self.take_focus_item();
let old = self.take_focus_item(&FocusEvent::FocusOut);
let new =
if set_focus { self.move_focus(new_focus_item.clone(), next_focus_item) } else { None };
let window_adapter = self.window_adapter();
@ -831,13 +831,14 @@ impl WindowInner {
/// Take the focus_item out of this Window
///
/// This sends the FocusOut event!
fn take_focus_item(&self) -> Option<ItemRc> {
/// This sends the event whiwh must be either FocusOut or WindowLostFocus for popups
fn take_focus_item(&self, event: &FocusEvent) -> Option<ItemRc> {
let focus_item = self.focus_item.take();
assert!(matches!(event, FocusEvent::FocusOut | FocusEvent::WindowLostFocus));
if let Some(focus_item_rc) = focus_item.upgrade() {
focus_item_rc.borrow().as_ref().focus_event(
&crate::input::FocusEvent::FocusOut,
event,
&self.window_adapter(),
&focus_item_rc,
);
@ -855,7 +856,7 @@ impl WindowInner {
Some(item) => {
*self.focus_item.borrow_mut() = item.downgrade();
item.borrow().as_ref().focus_event(
&crate::input::FocusEvent::FocusIn,
&FocusEvent::FocusIn,
&self.window_adapter(),
item,
)
@ -889,15 +890,16 @@ impl WindowInner {
/// Move keyboard focus to the next item
pub fn focus_next_item(&self) {
let start_item = self.take_focus_item().map(next_focus_item).unwrap_or_else(|| {
ItemRc::new(
self.active_popups
.borrow()
.last()
.map_or_else(|| self.component(), |p| p.component.clone()),
0,
)
});
let start_item =
self.take_focus_item(&FocusEvent::FocusOut).map(next_focus_item).unwrap_or_else(|| {
ItemRc::new(
self.active_popups
.borrow()
.last()
.map_or_else(|| self.component(), |p| p.component.clone()),
0,
)
});
let end_item = self.move_focus(start_item.clone(), next_focus_item);
let window_adapter = self.window_adapter();
if let Some(window_adapter) = window_adapter.internal(crate::InternalToken) {
@ -907,15 +909,16 @@ impl WindowInner {
/// Move keyboard focus to the previous item.
pub fn focus_previous_item(&self) {
let start_item = previous_focus_item(self.take_focus_item().unwrap_or_else(|| {
ItemRc::new(
self.active_popups
.borrow()
.last()
.map_or_else(|| self.component(), |p| p.component.clone()),
0,
)
}));
let start_item =
previous_focus_item(self.take_focus_item(&FocusEvent::FocusOut).unwrap_or_else(|| {
ItemRc::new(
self.active_popups
.borrow()
.last()
.map_or_else(|| self.component(), |p| p.component.clone()),
0,
)
}));
let end_item = self.move_focus(start_item.clone(), previous_focus_item);
let window_adapter = self.window_adapter();
if let Some(window_adapter) = window_adapter.internal(crate::InternalToken) {
@ -931,11 +934,8 @@ impl WindowInner {
pub fn set_active(&self, have_focus: bool) {
self.pinned_fields.as_ref().project_ref().active.set(have_focus);
let event = if have_focus {
crate::input::FocusEvent::WindowReceivedFocus
} else {
crate::input::FocusEvent::WindowLostFocus
};
let event =
if have_focus { FocusEvent::WindowReceivedFocus } else { FocusEvent::WindowLostFocus };
if let Some(focus_item) = self.focus_item.borrow().upgrade() {
focus_item.borrow().as_ref().focus_event(&event, &self.window_adapter(), &focus_item);
@ -1163,7 +1163,10 @@ impl WindowInner {
}
};
let focus_item = self.take_focus_item().map(|item| item.downgrade()).unwrap_or_default();
let focus_item = self
.take_focus_item(&FocusEvent::WindowLostFocus)
.map(|item| item.downgrade())
.unwrap_or_default();
self.active_popups.borrow_mut().push(PopupWindow {
popup_id,

View file

@ -14,7 +14,10 @@ export component TestCase inherits Window {
forward-focus: edit;
out property <bool> textedit-focused <=> edit.has_focus;
callback edited <=> edit.edited;
out property <string> text <=> edit.text;
in-out property <string> text <=> edit.text;
public function paste() {
edit.paste();
}
}
/*
@ -46,10 +49,10 @@ assert_eq!(edits.borrow().clone(), vec!["h", "he", "hel", "hell", "hello"]);
use slint::{LogicalPosition, platform::{WindowEvent, PointerEventButton}};
use slint::private_unstable_api::re_exports::MouseCursor;
assert_eq!(slint_testing::access_testing_window(instance.window(), |window| window.mouse_cursor.get()), MouseCursor::Text, "after previous click");
instance.window().dispatch_event(WindowEvent::PointerPressed { position: LogicalPosition::new(50.0, 50.0), button: PointerEventButton::Right });
assert_eq!(slint_testing::access_testing_window(instance.window(), |window| window.mouse_cursor.get()), MouseCursor::Text, "right button pressed");
instance.window().dispatch_event(WindowEvent::PointerReleased { position: LogicalPosition::new(50.0, 50.0), button: PointerEventButton::Right });
assert_eq!(slint_testing::access_testing_window(instance.window(), |window| window.mouse_cursor.get()), MouseCursor::Text, "right button released");
instance.window().dispatch_event(WindowEvent::PointerPressed { position: LogicalPosition::new(50.0, 50.0), button: PointerEventButton::Middle });
assert_eq!(slint_testing::access_testing_window(instance.window(), |window| window.mouse_cursor.get()), MouseCursor::Text, "Middle button pressed");
instance.window().dispatch_event(WindowEvent::PointerReleased { position: LogicalPosition::new(50.0, 50.0), button: PointerEventButton::Middle });
assert_eq!(slint_testing::access_testing_window(instance.window(), |window| window.mouse_cursor.get()), MouseCursor::Text, "Middle button released");
instance.window().dispatch_event(WindowEvent::PointerExited { });
assert_eq!(slint_testing::access_testing_window(instance.window(), |window| window.mouse_cursor.get()), MouseCursor::Default);
@ -68,6 +71,28 @@ slint_testing::send_keyboard_string_sequence(&instance, "XX");
let test = instance.get_text();
// The exact position depends on the size of the content which depends on the style, but it should be in the middle
assert!(test.contains("\nThis iXXs line"));
// use the menu key to pen the menu and choose select call
instance.set_text("Hello👋".into());
assert_eq!(instance.get_text(), "Hello👋");
// select all
slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(slint::platform::Key::Menu));
slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(slint::platform::Key::UpArrow));
slint_testing::send_keyboard_string_sequence(&instance, "\n");
assert_eq!(instance.get_text(), "Hello👋");
// copy
slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(slint::platform::Key::Menu));
slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(slint::platform::Key::DownArrow));
slint_testing::send_keyboard_string_sequence(&instance, &SharedString::from(slint::platform::Key::DownArrow));
slint_testing::send_keyboard_string_sequence(&instance, "\n");
assert_eq!(instance.get_text(), "Hello👋");
slint_testing::send_keyboard_string_sequence(&instance, "Xxx");
assert_eq!(instance.get_text(), "Xxx");
instance.invoke_paste();
assert_eq!(instance.get_text(), "XxxHello👋");
```
*/