From 0fbefd309f31395a2cb6d61e98b32c0cd4d7638b Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 23 Oct 2026 10:11:01 +0800 Subject: [PATCH] [fix] preserve remote mouse gestures across control overlays --- .../src/gui/application/gui_application.cpp | 1 + apps/desktop/src/gui/ui/stream_window.slint | 314 +++++++++++------- 2 files changed, 204 insertions(+), 111 deletions(-) diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 5b95f841..989e1ecf 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -4813,6 +4813,7 @@ void GuiApplication::ReleasePressedInput() { SendRemoteInput(action); } ui_->pressed_mouse_buttons.clear(); + if (ui_->stream) (*ui_->stream)->invoke_reset_input_state(); } void GuiApplication::ReleaseControl() { diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index ec8c62cd..9ece61f1 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -142,6 +142,8 @@ component ControlBarButton inherits Rectangle { } touch := TouchArea { + mouse-cursor: root.action-enabled ? pointer : default; + scroll-event(event) => { accept } // Keep disabled controls hit-testable so clicks cannot reach the peer. clicked => { if root.action-enabled { root.clicked(); } } } @@ -153,7 +155,6 @@ component VideoSettingRow inherits VerticalLayout { in property selected; in property enabled; callback choose(int); - callback pressed; spacing: 4px; Text { text: root.label; color: #5c626c; font-size: 11px; height: 18px; } HorizontalLayout { @@ -168,7 +169,7 @@ component VideoSettingRow inherits VerticalLayout { Text { text: option; color: root.enabled ? #30343b : #8b8f95; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; } option-touch := TouchArea { - pointer-event(event) => { if event.kind == PointerEventKind.down { root.pressed(); } } + mouse-cursor: root.enabled ? pointer : default; clicked => { if root.enabled { root.choose(index); } } scroll-event(event) => { accept } } @@ -288,14 +289,29 @@ export component StreamWindow inherits Window { private property video-menu-x: min(max(4px, control.x + video-button.x), video.width - root.video-menu-width - 4px); private property video-menu-y: control.y + root.control-toolbar-height + root.video-menu-height <= video.height ? control.y + root.control-toolbar-height - 1px : max(0px, control.y - root.video-menu-height); - changed selected-tab => { root.video-menu-open = false; } - changed control-expanded => { if !root.control-expanded { root.video-menu-open = false; } } - changed window-active => { if !root.window-active { root.video-menu-open = false; } } + private property control-menu-open: + root.display-menu-open || root.shortcut-menu-open || root.video-menu-open; + changed selected-tab => { + root.close-control-menus(); + root.cancel-remote-pointer(); + } + changed control-expanded => { if !root.control-expanded { root.close-control-menus(); } } + changed window-active => { + if !root.window-active { + root.close-control-menus(); + root.cancel-remote-pointer(); + root.control-drag-pending = false; + root.end-control-drag(); + } + } + changed mouse-control-enabled => { + if !root.mouse-control-enabled { root.cancel-remote-pointer(); } + } changed remote-update-visible => { if root.remote-update-visible { - root.video-menu-open = false; - root.display-menu-open = false; - root.shortcut-menu-open = false; + root.close-control-menus(); + root.control-drag-pending = false; + root.end-control-drag(); } // Changed handlers may run while Winit/AccessKit is building the // accessibility tree. Changing focus there re-enters its borrowed @@ -332,10 +348,16 @@ export component StreamWindow inherits Window { } return { text: "", anchor-x: 0px }; } - // A popup can remove itself from the scene in its clicked callback before - // the video input layer observes the same pointer release. Remember that - // the press belonged to local UI so that release cannot reach the peer. - private property suppress-next-remote-pointer-release: false; + // Own each remote button from down through up/cancel, independent of the + // pointer's current position or local overlays appearing during a drag. + private property remote-left-down: false; + private property remote-right-down: false; + private property remote-middle-down: false; + private property remote-pointer-active: + root.remote-left-down || root.remote-right-down || root.remote-middle-down; + private property control-drag-pending: false; + private property control-drag-start-x: 0px; + private property control-drag-start-y: 0px; private property control-docked-left: true; private property control-dragging: false; private property control-drag-x: 0px; @@ -353,6 +375,94 @@ export component StreamWindow inherits Window { callback end-control-drag; pure callback is-local-control-area(length, length) -> bool; + function close-control-menus() { + display-menu-open-timer.running = false; + root.display-menu-animation-enabled = false; + root.display-menu-progress = 0.0; + root.display-menu-open = false; + root.shortcut-menu-open = false; + root.video-menu-open = false; + } + + // Native focus/session changes release input in C++; clear the matching UI + // ownership too, including when replacing the only tab (same tab index). + public function reset-input-state() { + root.remote-left-down = false; + root.remote-right-down = false; + root.remote-middle-down = false; + root.close-control-menus(); + root.control-drag-pending = false; + root.end-control-drag(); + } + + function cancel-remote-pointer() { + if root.remote-pointer-active { + root.reset-input-state(); + root.pointer-input(PointerEventButton.other, PointerEventKind.cancel, + pointer.mouse-x, pointer.mouse-y); + } + } + + pure function remote-button-down(button: PointerEventButton) -> bool { + return button == PointerEventButton.left ? root.remote-left-down + : button == PointerEventButton.right ? root.remote-right-down + : button == PointerEventButton.middle ? root.remote-middle-down : false; + } + + function set-remote-button(button: PointerEventButton, down: bool) { + if button == PointerEventButton.left { root.remote-left-down = down; } + if button == PointerEventButton.right { root.remote-right-down = down; } + if button == PointerEventButton.middle { root.remote-middle-down = down; } + } + + function route-remote-pointer(button: PointerEventButton, kind: PointerEventKind, + x: length, y: length) { + // Releases and cancellation must not be gated on hit testing. A local + // popup removing itself on click cannot manufacture a remote release. + if kind == PointerEventKind.cancel { + root.cancel-remote-pointer(); + } else if kind == PointerEventKind.up { + if root.remote-button-down(button) { + root.set-remote-button(button, false); + root.pointer-input(button, kind, x, y); + } + } else if root.window-active && root.mouse-control-enabled && !root.remote-update-visible { + let remote-target = root.remote-pointer-active + || (!root.control-menu-open && !root.is-local-control-area(x, y)); + if kind == PointerEventKind.down && remote-target + && button != PointerEventButton.other { + input-focus.focus(); + root.set-remote-button(button, true); + root.pointer-input(button, kind, x, y); + } else if kind == PointerEventKind.move && remote-target { + root.pointer-input(button, kind, x, y); + } + } + } + + function route-remote-scroll(dx: length, dy: length, x: length, y: length) { + if root.window-active && root.mouse-control-enabled && !root.control-menu-open + && !root.is-local-control-area(x, y) { + root.scroll-input(dx, dy, x, y); + } + } + + function update-control-drag(button: PointerEventButton, kind: PointerEventKind, + x: length, y: length) { + if kind == PointerEventKind.cancel + || (kind == PointerEventKind.up && button == PointerEventButton.left) { + root.control-drag-pending = false; + root.end-control-drag(); + } else if kind == PointerEventKind.move && root.control-drag-pending { + if !root.control-dragging + && (abs(x - root.control-drag-start-x) >= 5px + || abs(y - root.control-drag-start-y) >= 5px) { + root.begin-control-drag(root.control-drag-start-x, root.control-drag-start-y); + } + root.move-control-drag(x, y); + } + } + animate display-menu-progress { duration: 120ms; easing: ease-out; @@ -426,12 +536,7 @@ export component StreamWindow inherits Window { // pointer positions remain relative to the stationary video surface. root.control-press-x = press-x - current-x; root.control-press-y = press-y - current-y; - display-menu-open-timer.running = false; - root.display-menu-animation-enabled = false; - root.display-menu-progress = 0.0; - root.display-menu-open = false; - root.shortcut-menu-open = false; - root.video-menu-open = false; + root.close-control-menus(); root.control-dragging = true; } move-control-drag(pointer-x, pointer-y) => { @@ -735,60 +840,38 @@ export component StreamWindow inherits Window { mouse-cursor: root.remote-cursor-active ? RemoteCursor.shape(root.remote-cursor-shape) : MouseCursor.default; pointer-event(event) => { - let over-control = self.mouse-x >= control.x - && self.mouse-x <= control.x + control.width - && self.mouse-y >= control.y - && self.mouse-y <= control.y + control.height; - let over-local-ui = root.is-local-control-area( - self.mouse-x, self.mouse-y); - let suppress-remote-release = - root.suppress-next-remote-pointer-release - && (event.kind == PointerEventKind.up - || event.kind == PointerEventKind.cancel); - if event.kind == PointerEventKind.down && !over-local-ui { - // Discard a stale marker if a popup consumed the - // whole previous gesture, including its release. - root.suppress-next-remote-pointer-release = false; - input-focus.focus(); - } - // A gesture started before the modal appeared keeps - // pointer capture. Let its release reach the peer. - if root.remote-update-visible && !root.control-dragging - && event.kind == PointerEventKind.up { - root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); - } else if event.kind == PointerEventKind.down - && event.button == PointerEventButton.left - && over-control { - root.begin-control-drag(self.mouse-x, self.mouse-y); - } else if root.control-dragging - && (event.kind == PointerEventKind.up - || event.kind == PointerEventKind.cancel) { - root.end-control-drag(); - // The control bar overlays the remote video. Events in - // it and its attached menus (including padding and gaps) - // are local UI input and must never reach the peer. - } else if !root.control-dragging && !over-local-ui - && !suppress-remote-release { - root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); - } - if suppress-remote-release { - root.suppress-next-remote-pointer-release = false; - } - } - moved => { - if root.control-dragging { - root.move-control-drag(self.mouse-x, self.mouse-y); - } + root.route-remote-pointer(event.button, event.kind, self.mouse-x, self.mouse-y); } scroll-event(event) => { - if !root.is-local-control-area(self.mouse-x, self.mouse-y) { - root.scroll-input(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); - } + root.route-remote-scroll(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); accept } } } + // Slint drops its implicit grab on any button-up. Keep the same + // owner above local UI until every remote button has been released. + // The handle also retains a stable owner if another button is used. + if root.remote-pointer-active || root.control-drag-pending: TouchArea { + z: 15; + mouse-cursor: root.control-drag-pending ? MouseCursor.grabbing + : root.remote-cursor-active ? RemoteCursor.shape(root.remote-cursor-shape) + : MouseCursor.default; + pointer-event(event) => { + if root.control-drag-pending { + root.update-control-drag(event.button, event.kind, self.mouse-x, self.mouse-y); + } else { + root.route-remote-pointer(event.button, event.kind, self.mouse-x, self.mouse-y); + } + } + scroll-event(event) => { + if !root.control-drag-pending { + root.route-remote-scroll(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); + } + accept + } + } + // Floating control bar, matching the original top-left overlay. control := Rectangle { x: root.control-dragging ? root.control-drag-x : root.control-docked-left ? 0px : parent.width - self.width; @@ -801,16 +884,18 @@ export component StreamWindow inherits Window { border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: FloatingBarStyle.corner-radius; - z: 8; + z: 10; clip: true; // Keep width changes atomic. When docked right, animating both // x and the clip width can leave newly revealed buttons outside // Slint's damage region until they receive a hover repaint. animate height { duration: 60ms; easing: ease-out; } - if root.control-expanded && !root.control-docked-left: Rectangle { - x: 31px; y: 12px; width: 2px; height: 15px; - background: ImGuiLineStyle.control-separator; + // All padding and statistics are local, non-draggable content. + TouchArea { + mouse-cursor: default; + clicked => { root.close-control-menus(); } + scroll-event(event) => { accept } } display-button := ControlBarButton { @@ -849,20 +934,20 @@ export component StreamWindow inherits Window { x: (root.control-docked-left ? 9px : 42px) + 2 * root.control-button-step; y: 7px; icon: FontAwesomeIcons.computer-mouse; slashed: !root.mouse-control-enabled; tooltip: root.mouse-control-enabled ? StreamStrings.release-mouse : StreamStrings.control-mouse; - clicked => { root.toggle-mouse-control(); } + clicked => { root.close-control-menus(); root.toggle-mouse-control(); } } audio-button := ControlBarButton { visible: root.control-expanded; x: (root.control-docked-left ? 9px : 42px) + 4 * root.control-button-step; y: 7px; icon: FontAwesomeIcons.volume-high; slashed: !root.audio-enabled; tooltip: root.audio-enabled ? StreamStrings.mute : StreamStrings.audio; - clicked => { root.toggle-audio(); } + clicked => { root.close-control-menus(); root.toggle-audio(); } } file-button := ControlBarButton { visible: root.control-expanded; x: (root.control-docked-left ? 9px : 42px) + 5 * root.control-button-step; y: 7px; icon: FontAwesomeIcons.folder-open; tooltip: StreamStrings.select-file; - clicked => { root.select-file(); } + clicked => { root.close-control-menus(); root.select-file(); } } video-button := ControlBarButton { visible: root.control-expanded; @@ -880,25 +965,51 @@ export component StreamWindow inherits Window { x: (root.control-docked-left ? 9px : 42px) + 7 * root.control-button-step; y: 7px; icon: FontAwesomeIcons.signal; selected: root.stats-visible; tooltip: root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-stats; - clicked => { root.toggle-stats(); } + clicked => { root.close-control-menus(); root.toggle-stats(); } } fullscreen-button := ControlBarButton { visible: root.control-expanded; x: (root.control-docked-left ? 9px : 42px) + 8 * root.control-button-step; y: 7px; icon: root.fullscreen-enabled ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen; - clicked => { root.toggle-fullscreen(); } + clicked => { root.close-control-menus(); root.toggle-fullscreen(); } } close-button := ControlBarButton { visible: root.control-expanded; x: (root.control-docked-left ? 9px : 42px) + 9 * root.control-button-step; y: 7px; icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect; - clicked => { root.disconnect(); } + clicked => { root.close-control-menus(); root.disconnect(); } } - if root.control-expanded && root.control-docked-left: Rectangle { - x: parent.width - 33px; y: 12px; width: 2px; height: 15px; - background: ImGuiLineStyle.control-separator; + // Reuse the separator gap as a visible grip; statistics and + // button gaps no longer move the bar accidentally. + if root.control-expanded: drag-handle := Rectangle { + x: root.control-docked-left ? parent.width - 40px : 27px; + y: 7px; width: 12px; height: 24px; + background: drag-touch.has-hover ? #eaf3ff : transparent; + border-radius: UiRadius.control; + for dot in 6: Rectangle { + x: 3px + mod(dot, 2) * 4px; + y: 6px + floor(dot / 2) * 5px; + width: 2px; height: 2px; border-radius: 1px; + background: #8b95a3; + } + drag-touch := TouchArea { + mouse-cursor: root.control-drag-pending ? grabbing : grab; + pointer-event(event) => { + let x = control.x + drag-handle.x + self.mouse-x; + let y = control.y + drag-handle.y + self.mouse-y; + if event.kind == PointerEventKind.down && event.button == PointerEventButton.left { + root.close-control-menus(); + root.control-drag-start-x = x; + root.control-drag-start-y = y; + root.control-drag-pending = true; + } else { + root.update-control-drag(event.button, event.kind, x, y); + } + } + scroll-event(event) => { accept } + } } collapse-button := ControlBarButton { x: root.control-expanded ? (root.control-docked-left ? parent.width - 25px : 10px) : 2px; @@ -908,7 +1019,7 @@ export component StreamWindow inherits Window { ? (root.control-docked-left ? FontAwesomeIcons.angle-left : FontAwesomeIcons.angle-right) : (root.control-docked-left ? FontAwesomeIcons.angle-right : FontAwesomeIcons.angle-left); tooltip: root.control-expanded ? StreamStrings.collapse-control : StreamStrings.expand-control; - clicked => { root.control-expanded = !root.control-expanded; } + clicked => { root.close-control-menus(); root.control-expanded = !root.control-expanded; } } privacy-button := ControlBarButton { @@ -921,7 +1032,7 @@ export component StreamWindow inherits Window { tooltip: root.privacy-status-text + (root.privacy-can-toggle ? "\n" + (root.privacy-active ? StreamStrings.privacy-disable : StreamStrings.privacy-enable) : ""); - clicked => { root.toggle-privacy(); } + clicked => { root.close-control-menus(); root.toggle-privacy(); } } if root.stats-visible && root.control-expanded: stats-table := Rectangle { @@ -1179,18 +1290,15 @@ export component StreamWindow inherits Window { } } - // While the menu is open, outside gestures dismiss it locally. - // Keep the backdrop through pointer-up; otherwise Slint's implicit - // pointer move or release can reach the remote input surface. - if root.video-menu-open: Rectangle { + // Dismiss outside gestures on release, keeping the entire click + // local. Toolbar controls remain above this shared menu backdrop. + if root.control-menu-open: Rectangle { width: parent.width; height: parent.height; z: 9; TouchArea { + mouse-cursor: default; pointer-event(event) => { - if event.kind == PointerEventKind.down { - root.suppress-next-remote-pointer-release = true; - } + if event.kind == PointerEventKind.up { root.close-control-menus(); } } - clicked => { root.video-menu-open = false; } scroll-event(event) => { accept } } } @@ -1200,7 +1308,7 @@ export component StreamWindow inherits Window { background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11; TouchArea { - pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } } + mouse-cursor: default; scroll-event(event) => { accept } } VerticalLayout { @@ -1209,20 +1317,17 @@ export component StreamWindow inherits Window { label: StreamStrings.video-quality; options: [StreamStrings.quality-low, StreamStrings.quality-medium, StreamStrings.quality-high]; selected: root.video-quality-index; enabled: root.video-settings-enabled; - pressed => { root.suppress-next-remote-pointer-release = true; } choose(index) => { root.change-video-settings(index, root.frame-rate-index, root.adaptation-policy-index); } } VideoSettingRow { label: StreamStrings.frame-rate; options: ["30 fps", "60 fps"]; selected: root.frame-rate-index; enabled: root.video-settings-enabled; - pressed => { root.suppress-next-remote-pointer-release = true; } choose(index) => { root.change-video-settings(root.video-quality-index, index, root.adaptation-policy-index); } } VideoSettingRow { label: StreamStrings.adaptation-policy; options: [StreamStrings.priority-frame-rate, StreamStrings.priority-quality, StreamStrings.priority-balanced]; selected: root.adaptation-policy-index; enabled: root.video-settings-enabled; - pressed => { root.suppress-next-remote-pointer-release = true; } choose(index) => { root.change-video-settings(root.video-quality-index, root.frame-rate-index, index); } } if !root.video-settings-enabled || root.video-settings-feedback != "": Text { @@ -1287,11 +1392,7 @@ export component StreamWindow inherits Window { // hover stable while still consuming padding, gaps and scroll // events locally instead of forwarding them to the peer. display-touch := TouchArea { - pointer-event(event) => { - if event.kind == PointerEventKind.down { - root.suppress-next-remote-pointer-release = true; - } - } + mouse-cursor: default; clicked => { let index = floor((self.mouse-y - 4px) / 30px); let row-y = self.mouse-y - 4px - index * 30px; @@ -1312,17 +1413,15 @@ export component StreamWindow inherits Window { if root.shortcut-menu-open: Rectangle { x: control.x + shortcut-button.x; y: control.y + 40px; width: 180px; height: root.shortcut-menu-height; background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.menu; z: 11; + TouchArea { mouse-cursor: default; scroll-event(event) => { accept } } VerticalLayout { padding: 4px; spacing: 1px; for shortcut in root.shortcut-items: Rectangle { height: 29px; background: shortcut-touch.has-hover ? #e8eef9 : transparent; Text { x: 8px; text: shortcut; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; } shortcut-touch := TouchArea { - pointer-event(event) => { - if event.kind == PointerEventKind.down { - root.suppress-next-remote-pointer-release = true; - } - } + mouse-cursor: MouseCursor.pointer; + scroll-event(event) => { accept } clicked => { root.send-shortcut(shortcut); root.shortcut-menu-open = false; } } } @@ -1376,11 +1475,6 @@ export component StreamWindow inherits Window { z: 14; TouchArea { mouse-cursor: default; - pointer-event(event) => { - if event.kind == PointerEventKind.down { - root.suppress-next-remote-pointer-release = true; - } - } scroll-event(event) => { accept } } remote-update-title := Text { @@ -1423,15 +1517,13 @@ export component StreamWindow inherits Window { background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: UiRadius.surface; z: 7; // Header/padding and drags leaving the panel belong to local UI. TouchArea { - pointer-event(event) => { - if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } - } + mouse-cursor: default; scroll-event(event) => { accept } } Text { x: 12px; y: 8px; text: StreamStrings.file-transfer; color: #2d3138; font-weight: 600; } IconButton { x: parent.width - 32px; y: 1px; icon: FontAwesomeIcons.xmark; icon-size: 13px; - clicked => { root.suppress-next-remote-pointer-release = true; root.close-file-transfer(); } + clicked => { root.close-file-transfer(); } } ScrollView { x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px;