fix: let taps reach sidebar rows on Android

This commit is contained in:
Tomás Romero committed 2026-11-05 17:22:56 +01:00
1 parent 51c6aed425
commit 55bab3579d
3 files changed
+269 -116

No files matched your search

+27 -31
View File
@@ -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 ""
+141
View File
@@ -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 <string> selected-scope;
in-out property <int> folder-toggles;
in-out property <int> new-subfolders;
in-out property <int> 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<MinimalSoftwareWindow>,
clock: Rc<Cell<Duration>>,
}
impl Platform for Headless {
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, 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");
}
+101 -85
View File
@@ -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 <bool> menu-enabled;
callback activated();
callback open-menu(Point);
private property <bool> holding: false;
private property <bool> held: false;
private property <length> hold-x;
private property <length> 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;