From 55bab3579ddf560fcc52d64c83cecdfe1bab5096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Thu, 5 Nov 2026 17:22:56 +0100 Subject: [PATCH] fix: let taps reach sidebar rows on Android --- lang/flectar-mail.pot | 58 ++++----- tests/sidebar_row_input.rs | 141 ++++++++++++++++++++ ui/components/sidebar-controls.slint | 186 +++++++++++++++------------ 3 files changed, 269 insertions(+), 116 deletions(-) create mode 100644 tests/sidebar_row_input.rs diff --git a/lang/flectar-mail.pot b/lang/flectar-mail.pot index c22bdc62..95d113fd 100644 --- a/lang/flectar-mail.pot +++ b/lang/flectar-mail.pot @@ -3090,64 +3090,60 @@ msgstr "" msgid "{}, {}" msgstr "" -#: ui/components/sidebar-controls.slint:171 -msgid "Collapse label" -msgstr "" - -#: ui/components/sidebar-controls.slint:171 -msgid "Expand label" -msgstr "" - -#: ui/components/sidebar-controls.slint:172 -msgid "Collapse folder" -msgstr "" - -#: ui/components/sidebar-controls.slint:172 -msgid "Expand folder" -msgstr "" - -#: ui/components/sidebar-controls.slint:187 +#: ui/components/sidebar-controls.slint:211 msgid "New subfolder" msgstr "" -#: ui/components/sidebar-controls.slint:193 ui/dialogs/folder-dialog.slint:29 -#: ui/components/sidebar-controls.slint:193 ui/dialogs/folder-dialog.slint:29 +#: ui/components/sidebar-controls.slint:216 ui/dialogs/folder-dialog.slint:29 +#: ui/components/sidebar-controls.slint:216 ui/dialogs/folder-dialog.slint:29 msgid "Rename folder" msgstr "" -#: ui/components/sidebar-controls.slint:199 +#: ui/components/sidebar-controls.slint:221 msgid "Delete folder" msgstr "" -#: ui/components/sidebar-controls.slint:212 -#: ui/components/sidebar-controls.slint:212 +#: ui/components/sidebar-controls.slint:226 +#: ui/components/sidebar-controls.slint:226 msgid "Empty Trash…" msgstr "" -#: ui/components/sidebar-controls.slint:224 +#: ui/components/sidebar-controls.slint:230 msgid "New nested label" msgstr "" -#: ui/components/sidebar-controls.slint:230 -#: ui/components/sidebar-controls.slint:230 +#: ui/components/sidebar-controls.slint:234 msgid "Rename global label" msgstr "" -#: ui/components/sidebar-controls.slint:230 -#: ui/components/sidebar-controls.slint:230 +#: ui/components/sidebar-controls.slint:234 msgid "Rename label in {}" msgstr "" -#: ui/components/sidebar-controls.slint:235 -#: ui/components/sidebar-controls.slint:235 +#: ui/components/sidebar-controls.slint:238 msgid "Delete global label" msgstr "" -#: ui/components/sidebar-controls.slint:235 -#: ui/components/sidebar-controls.slint:235 +#: ui/components/sidebar-controls.slint:238 msgid "Delete label from {}" msgstr "" +#: ui/components/sidebar-controls.slint:264 +msgid "Collapse label" +msgstr "" + +#: ui/components/sidebar-controls.slint:264 +msgid "Expand label" +msgstr "" + +#: ui/components/sidebar-controls.slint:265 +msgid "Collapse folder" +msgstr "" + +#: ui/components/sidebar-controls.slint:265 +msgid "Expand folder" +msgstr "" + #: ui/components/sidebar.slint:117 ui/views/mail-list-pane.slint:1430 msgid "New email" msgstr "" diff --git a/tests/sidebar_row_input.rs b/tests/sidebar_row_input.rs new file mode 100644 index 00000000..5a5e720f --- /dev/null +++ b/tests/sidebar_row_input.rs @@ -0,0 +1,141 @@ +//! Taps, right-clicks and long presses on sidebar rows, which also have menus. +use i_slint_core::input::TouchPhase; +use i_slint_core::lengths::LogicalPoint; +use i_slint_core::platform::InternalEvent; +use slint::ComponentHandle; +use slint::platform::{ + Key, Platform, PointerEventButton, WindowAdapter, WindowEvent, + software_renderer::{MinimalSoftwareWindow, RepaintBufferType}, +}; +use std::{cell::Cell, rc::Rc, time::Duration}; + +slint::slint! { + import { SidebarItem, SidebarSectionHeader } from "../ui/components/sidebar-controls.slint"; + + export component SidebarRowHarness inherits Window { + width: 260px; + height: 300px; + in-out property selected-scope; + in-out property folder-toggles; + in-out property new-subfolders; + in-out property account-toggles; + SidebarItem { + y: 0px; + width: parent.width; + label: "Projects"; + scope: "Folder:5"; + account-id: 1; + folder-id: 5; + has-children: true; + folder-menu-enabled: true; + can-create-child: true; + clicked(scope) => { root.selected-scope = scope; } + toggled(id, open) => { root.folder-toggles += 1; } + new-subfolder(account, folder, name) => { root.new-subfolders += 1; } + } + SidebarSectionHeader { + y: 40px; + width: parent.width; + label: "Flectar"; + detail: "alex@flectar.com"; + toggled(open) => { root.account-toggles += 1; } + } + } +} + +struct Headless { + window: Rc, + clock: Rc>, +} + +impl Platform for Headless { + fn create_window_adapter(&self) -> Result, slint::PlatformError> { + Ok(self.window.clone()) + } + + fn duration_since_start(&self) -> Duration { + self.clock.get() + } +} + +fn click(window: &slint::Window, x: f32, y: f32, button: PointerEventButton) { + let position = slint::LogicalPosition::new(x, y); + window.dispatch_event(WindowEvent::PointerMoved { position }); + window.dispatch_event(WindowEvent::PointerPressed { position, button }); + window.dispatch_event(WindowEvent::PointerReleased { position, button }); +} + +fn touch(window: &slint::Window, x: f32, y: f32, phase: TouchPhase) { + window.dispatch_event(WindowEvent::internal(InternalEvent::Touch { + id: 0, + position: LogicalPoint::new(x, y), + phase, + })); +} + +/// Runs the open menu's first item, if a menu is open. +fn run_first_menu_item(window: &slint::Window) { + for key in [Key::DownArrow, Key::Return] { + window.dispatch_event(WindowEvent::KeyPressed { text: key.into() }); + window.dispatch_event(WindowEvent::KeyReleased { text: key.into() }); + } +} + +// Slint's Android build turns a press on a menu's own area into a wait for a +// long press, so rows keep their touch area inside it: taps reach the row on +// every platform, and the row opens the menu itself. +#[test] +fn rows_take_taps_and_open_their_menus_on_right_click_or_hold() { + let clock = Rc::new(Cell::new(Duration::ZERO)); + let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer); + slint::platform::set_platform(Box::new(Headless { + window: window.clone(), + clock: clock.clone(), + })) + .unwrap(); + let ui = SidebarRowHarness::new().unwrap(); + ui.show().unwrap(); + let advance = |ms: u64| { + clock.set(clock.get() + Duration::from_millis(ms)); + slint::platform::update_timers_and_animations(); + }; + + click(ui.window(), 10.0, 14.0, PointerEventButton::Left); + assert_eq!( + ui.get_folder_toggles(), + 1, + "the disclosure folds the folder" + ); + click(ui.window(), 150.0, 14.0, PointerEventButton::Left); + assert_eq!(ui.get_selected_scope(), "Folder:5", "a click selects it"); + click(ui.window(), 150.0, 60.0, PointerEventButton::Left); + assert_eq!(ui.get_account_toggles(), 1, "a click folds the account"); + + ui.set_selected_scope("".into()); + touch(ui.window(), 150.0, 14.0, TouchPhase::Started); + advance(100); + touch(ui.window(), 150.0, 14.0, TouchPhase::Ended); + advance(600); + assert_eq!(ui.get_selected_scope(), "Folder:5", "a tap selects it"); + run_first_menu_item(ui.window()); + assert_eq!(ui.get_new_subfolders(), 0, "a tap opens no menu"); + + // Holding a finger still for half a second opens the menu, as on Android + // and iOS, and lifting it afterwards is not a tap. + ui.set_selected_scope("".into()); + touch(ui.window(), 150.0, 14.0, TouchPhase::Started); + advance(600); + touch(ui.window(), 150.0, 14.0, TouchPhase::Ended); + assert_eq!(ui.get_selected_scope(), ""); + run_first_menu_item(ui.window()); + assert_eq!(ui.get_new_subfolders(), 1, "the hold opened the menu"); + + // A finger that moves is scrolling the sidebar. + touch(ui.window(), 150.0, 14.0, TouchPhase::Started); + advance(100); + touch(ui.window(), 150.0, 30.0, TouchPhase::Moved); + advance(600); + touch(ui.window(), 150.0, 30.0, TouchPhase::Ended); + run_first_menu_item(ui.window()); + assert_eq!(ui.get_new_subfolders(), 1, "scrolling opens no menu"); +} diff --git a/ui/components/sidebar-controls.slint b/ui/components/sidebar-controls.slint index 6370bc83..f32bcd25 100644 --- a/ui/components/sidebar-controls.slint +++ b/ui/components/sidebar-controls.slint @@ -50,6 +50,57 @@ component SidebarCountBadge inherits Rectangle { } } +// A row's pointer area, which sits inside the row's ContextMenuArea so taps +// reach it first: on Android, Slint holds every press on a menu's own area to +// wait for a long press, which swallowed taps on the rows above it. A +// right-click opens the menu, and so does holding a finger still for half a +// second, as on Android and iOS. +component SidebarRowHit inherits TouchArea { + in property menu-enabled; + callback activated(); + callback open-menu(Point); + private property holding: false; + private property held: false; + private property hold-x; + private property hold-y; + + pointer-event(event) => { + if event.kind == PointerEventKind.down { + self.held = false; + if event.button == PointerEventButton.right && root.menu-enabled { + root.open-menu({ x: self.mouse-x, y: self.mouse-y }); + } else if event.button == PointerEventButton.left && event.touch-finger-id != 0 && root.menu-enabled { + self.hold-x = self.mouse-x; + self.hold-y = self.mouse-y; + self.holding = true; + } + } else if event.kind == PointerEventKind.move { + if self.holding && (abs(self.mouse-x - self.hold-x) > 8px || abs(self.mouse-y - self.hold-y) > 8px) { + self.holding = false; + } + } else { + self.holding = false; + } + } + clicked => { + // The finger that opened the menu lifts here; it was not a tap. + if !self.held { + root.activated(); + } + self.held = false; + } + + Timer { + interval: 500ms; + running: root.holding; + triggered => { + root.holding = false; + root.held = true; + root.open-menu({ x: root.hold-x, y: root.hold-y }); + } + } +} + // A folder row is shared by unified and account sections so indentation, // selection, hover, and counts remain consistent throughout the tree. export component SidebarItem inherits SidebarRowSurface { @@ -150,14 +201,56 @@ export component SidebarItem inherits SidebarRowSurface { muted-color: root.muted-color; } - row-hit := TouchArea { - enabled: root.interactive; - accessible-role: button; - accessible-label: root.accessible-name; - accessible-checked: root.selected; - accessible-action-default => { root.clicked(root.scope); } - clicked => { root.clicked(root.scope); } - mouse-cursor: pointer; + // Folder and label actions. Rows without any keep the area disabled. + row-menu := ContextMenuArea { + enabled: root.interactive && (root.folder-menu-enabled || root.can-empty-trash || root.label-manageable); + width: parent.width; + height: parent.height; + Menu { + if root.folder-menu-enabled: MenuItem { + title: @tr("New subfolder"); + enabled: root.can-create-child; + activated => { root.new-subfolder(root.account-id, root.folder-id, root.label); } + } + if root.folder-menu-enabled: MenuItem { + title: @tr("Rename folder"); + enabled: root.can-rename; + activated => { root.rename-folder(root.folder-id, root.label); } + } + if root.folder-menu-enabled: MenuItem { + title: @tr("Delete folder"); + enabled: root.can-delete; + activated => { root.delete-folder(root.folder-id, root.label); } + } + if root.can-empty-trash: MenuItem { + title: @tr("Empty Trash…"); + activated => { root.empty-trash(root.account-id); } + } + if root.label-manageable && root.label-can-create-child: MenuItem { + title: @tr("New nested label"); + activated => { root.new-child-label(root.account-id, root.label-id, root.label-path, root.label-account-name); } + } + if root.label-manageable: MenuItem { + title: root.label-is-global ? @tr("Rename global label") : @tr("Rename label in {}", root.label-account-name); + activated => { root.rename-label(root.label-id, root.label-path, root.editable-color, root.label-account-name, root.label-is-global); } + } + if root.label-manageable: MenuItem { + title: root.label-is-global ? @tr("Delete global label") : @tr("Delete label from {}", root.label-account-name); + activated => { root.delete-label(root.label-id, root.label-path, root.label-account-name, root.label-is-global, root.has-children); } + } + } + + row-hit := SidebarRowHit { + enabled: root.interactive; + menu-enabled: row-menu.enabled; + accessible-role: button; + accessible-label: root.accessible-name; + accessible-checked: root.selected; + accessible-action-default => { root.clicked(root.scope); } + activated => { root.clicked(root.scope); } + open-menu(position) => { row-menu.show(position); } + mouse-cursor: pointer; + } } if root.has-children: TouchArea { @@ -178,83 +271,6 @@ export component SidebarItem inherits SidebarRowSurface { mouse-cursor: pointer; } - if root.interactive && root.folder-menu-enabled: ContextMenuArea { - x: 22px; - width: parent.width - 22px; - height: parent.height; - Menu { - MenuItem { - title: @tr("New subfolder"); - enabled: root.can-create-child; - activated => { root.new-subfolder(root.account-id, root.folder-id, root.label); } - } - - MenuItem { - title: @tr("Rename folder"); - enabled: root.can-rename; - activated => { root.rename-folder(root.folder-id, root.label); } - } - - MenuItem { - title: @tr("Delete folder"); - enabled: root.can-delete; - activated => { root.delete-folder(root.folder-id, root.label); } - } - } - } - - if root.interactive && root.can-empty-trash: ContextMenuArea { - x: 22px; - width: parent.width - 22px; - height: parent.height; - Menu { - MenuItem { - title: @tr("Empty Trash…"); - activated => { root.empty-trash(root.account-id); } - } - } - } - - if root.interactive && root.label-manageable && root.label-can-create-child: ContextMenuArea { - x: 22px; - width: parent.width - 22px; - height: parent.height; - Menu { - MenuItem { - title: @tr("New nested label"); - enabled: root.label-can-create-child; - activated => { root.new-child-label(root.account-id, root.label-id, root.label-path, root.label-account-name); } - } - - MenuItem { - title: root.label-is-global ? @tr("Rename global label") : @tr("Rename label in {}", root.label-account-name); - activated => { root.rename-label(root.label-id, root.label-path, root.editable-color, root.label-account-name, root.label-is-global); } - } - - MenuItem { - title: root.label-is-global ? @tr("Delete global label") : @tr("Delete label from {}", root.label-account-name); - activated => { root.delete-label(root.label-id, root.label-path, root.label-account-name, root.label-is-global, root.has-children); } - } - } - } - - if root.interactive && root.label-manageable && !root.label-can-create-child: ContextMenuArea { - x: 22px; - width: parent.width - 22px; - height: parent.height; - Menu { - MenuItem { - title: root.label-is-global ? @tr("Rename global label") : @tr("Rename label in {}", root.label-account-name); - activated => { root.rename-label(root.label-id, root.label-path, root.editable-color, root.label-account-name, root.label-is-global); } - } - - MenuItem { - title: root.label-is-global ? @tr("Delete global label") : @tr("Delete label from {}", root.label-account-name); - activated => { root.delete-label(root.label-id, root.label-path, root.label-account-name, root.label-is-global, root.has-children); } - } - } - } - mail-drop-target := DropArea { width: parent.width; height: parent.height;