From ff7b954949de0c00ce7b93d518013c4efaf1dd4b Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 23 Oct 2026 13:56:25 +0800 Subject: [PATCH] [ui] add animated logo toolbar and disconnect confirmation --- .../assets/localization/localization_data.h | 5 + .../desktop/src/gui/ui/icons/circle-white.svg | 9 + apps/desktop/src/gui/ui/stream_window.slint | 554 +++++++++++++----- apps/desktop/src/gui/ui/ui_localization.h | 5 + 4 files changed, 435 insertions(+), 138 deletions(-) create mode 100644 apps/desktop/src/gui/ui/icons/circle-white.svg diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index f5214ff3..fbc228e3 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -115,6 +115,11 @@ struct TranslationRow { X(recent_connections, u8"近期连接", "Recent Connections", \ u8"Недавние подключения") \ X(disconnect, u8"断开连接", "Disconnect", u8"Отключить") \ + X(stream_confirm_disconnect, u8"断开当前连接?", "Disconnect this session?", \ + u8"Отключить текущий сеанс?") \ + X(stream_disconnect_description, u8"当前远程控制会话将结束。", \ + "The current remote session will end.", \ + u8"Текущий сеанс удалённого управления будет завершён.") \ X(select_display, u8"选择显示器", "Select Display", u8"Выбрать дисплей") \ X(display_screen, u8"显示屏", "Display", u8"Экран") \ X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \ diff --git a/apps/desktop/src/gui/ui/icons/circle-white.svg b/apps/desktop/src/gui/ui/icons/circle-white.svg new file mode 100644 index 00000000..099460ca --- /dev/null +++ b/apps/desktop/src/gui/ui/icons/circle-white.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index 9ece61f1..4b6b5fa2 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -1,7 +1,7 @@ import { UiRadius } from "ui_radius.slint"; import { ComboBox, ScrollView } from "std-widgets.slint"; import { RemoteCursor } from "remote_cursor.slint"; -import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint"; +import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; export struct StreamTab { remote-id: string, @@ -54,6 +54,9 @@ export global StreamStrings { in-out property fullscreen: "Fullscreen"; in-out property exit-fullscreen: "Exit fullscreen"; in-out property disconnect: "Disconnect"; + in-out property confirm-disconnect: "Disconnect this session?"; + in-out property disconnect-description: "The current remote session will end."; + in-out property cancel: "Cancel"; in-out property file-transfer: "File Transfer Progress"; in-out property expand-control: "Expand Control Bar"; in-out property collapse-control: "Collapse Control Bar"; @@ -76,14 +79,29 @@ struct ControlBarTooltip { anchor-x: length, } -// The legacy ImGui control bar uses 24px buttons with Font Awesome rendered -// at half of the 32px atlas size. Keep this local to the stream window: the -// shared IconButton is intentionally larger and is used by the other windows. +// Shared surface treatment for the capsule and its detached detail cards. +component StreamControlSurface inherits Rectangle { + background: white; + border-width: 1px; + border-color: #dbe5f1; + border-radius: 16px; + drop-shadow-color: #1d35571a; + drop-shadow-blur: 10px; + drop-shadow-offset-y: 2px; +} + +// Keep the compact 16px icons inside roomier circular targets. This remains +// local to the stream window; other windows use the larger shared IconButton. component ControlBarButton inherits Rectangle { in property icon; + in property show-logo: false; + in property logo-pressed: false; + in property logo-dragging: false; + in property reveal-progress: 1; in property badge: ""; in property tooltip: ""; in property selected: false; + in property danger: false; in property slashed: false; in property action-enabled: true; // Capability and temporary command availability have different visuals. @@ -91,21 +109,52 @@ component ControlBarButton inherits Rectangle { in property icon-size: 16px; out property has-hover: touch.has-hover; out property tooltip-info: { - text: root.visible && touch.has-hover && !touch.pressed ? root.tooltip : "", + text: root.visible && touch.has-hover && !touch.pressed && !root.logo-pressed ? root.tooltip : "", anchor-x: root.x + root.width / 2, }; callback clicked; + callback pointer-input(PointerEventButton, PointerEventKind, length, length); - width: 24px; - height: 24px; - background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa - : touch.has-hover ? #4296fa - : root.selected ? #4296fa : #4296fa66; - border-radius: UiRadius.control; + private property button-hovered: + !root.show-logo && root.action-enabled && !root.muted && touch.has-hover; + private property button-pressed: + !root.show-logo && root.action-enabled && !root.muted && touch.pressed; + // Share the fill with the slash cutout so every icon stays legible while + // transitioning between idle, hover and pressed states. + private property button-fill: root.muted ? #eef0f3 + : root.button-pressed ? (root.danger ? #7a271a : #65a5f2) + : root.button-hovered ? (root.danger ? (root.selected ? #912018 : #b42318) + : root.selected ? #86baff : #a7ceff) + : root.danger ? (root.selected ? #b42318 : #d92d20) + : root.selected ? #b3d5fd : #deecff; + private property button-foreground: root.muted ? #8a8f96 + : root.danger ? white : #24272d; + animate button-fill, button-foreground { + duration: 90ms; + easing: ease-out; + enabled: !root.show-logo; + } + + // Animate the artwork, never its hit area. Use the owning gesture state + // so feedback survives pointer capture and clears on cancellation. + private property logo-scale: !root.show-logo || root.logo-dragging ? 1 + : root.logo-pressed ? 0.94 : touch.has-hover ? 1.06 : 1; + animate logo-scale { + duration: root.logo-pressed && !root.logo-dragging ? 70ms : 140ms; + easing: ease-out; + enabled: root.show-logo; + } + + width: 30px; + height: 30px; + opacity: root.show-logo ? 1 : root.reveal-progress; + background: root.show-logo ? transparent : root.button-fill; + border-radius: min(self.width, self.height) / 2; Text { + visible: !root.show-logo; text: root.icon; - color: root.muted ? #8a8f96 : #24272d; + color: root.button-foreground; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: root.icon-size; @@ -113,6 +162,33 @@ component ControlBarButton inherits Rectangle { vertical-alignment: center; } + // Scale the antialiased SVG plate and mark together for subtle feedback. + if root.show-logo: Rectangle { + width: (min(parent.width, parent.height) - 2px) * root.logo-scale; + height: self.width; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + Image { + width: parent.width; height: parent.height; + source: @image-url("icons/circle-white.svg"); + image-fit: contain; + } + // Keep the mark inside the circular plate without rounded clipping, + // which is unsupported by the software renderer. The source app icon + // includes a square white plate around this 104 x 76 px mark area. + Image { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + // Keep the mark at 24px while giving the white plate more room. + width: 24px * root.logo-scale; + height: self.width * 76 / 104; + source: @image-url("../../../resources/linux/crossdesk_128x128.png"); + source-clip-x: 12; source-clip-y: 26; + source-clip-width: 104; source-clip-height: 76; + image-fit: contain; + } + } + // Draw the selected display number over the center of the monitor glyph. if root.badge != "": Text { // Use the rendered badge size so one- and multi-digit IDs share the @@ -120,7 +196,7 @@ component ControlBarButton inherits Rectangle { x: (parent.width - self.preferred-width) / 2 + 0.5px; y: (parent.height - self.preferred-height) / 2 - 0.5px; text: root.badge; - color: black; + color: root.button-foreground; font-size: 11px; } @@ -128,22 +204,29 @@ component ControlBarButton inherits Rectangle { // stay vertical in the software renderer used for fallback and UI capture. if root.slashed: Path { width: 24px; height: 24px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; viewbox-width: 24; viewbox-height: 24; commands: "M 3.5 3.5 L 20.5 20.5"; - stroke: black; + stroke: root.button-foreground; stroke-width: 2px; } if root.slashed: Path { width: 24px; height: 24px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; viewbox-width: 24; viewbox-height: 24; commands: "M 2 5 L 19 22"; - stroke: touch.has-hover ? #4296fa : #b3d5fd; + stroke: root.button-fill; stroke-width: 2px; } touch := TouchArea { - mouse-cursor: root.action-enabled ? pointer : default; + mouse-cursor: !root.action-enabled ? default : root.show-logo ? (root.logo-pressed ? grabbing : grab) : pointer; scroll-event(event) => { accept } + pointer-event(event) => { + root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); + } // Keep disabled controls hit-testable so clicks cannot reach the peer. clicked => { if root.action-enabled { root.clicked(); } } } @@ -247,7 +330,7 @@ export component StreamWindow inherits Window { private property <[string]> secure-shortcut-items: ["Ctrl+Alt+Del", "Win+L"]; private property <[string]> ordinary-shortcut-items: ["Win+L"]; private property <[string]> shortcut-items: root.secure-attention-supported ? root.secure-shortcut-items : root.ordinary-shortcut-items; - private property shortcut-menu-height: root.shortcut-items.length * 30px + 8px; + private property shortcut-menu-height: root.shortcut-items.length * 30px + 16px; callback send-shortcut(string); callback toggle-mouse-control; callback toggle-privacy; @@ -268,13 +351,31 @@ export component StreamWindow inherits Window { callback key-input(string, bool, bool, bool, bool, bool); callback keyboard-focus-changed(bool); - private property control-expanded: true; + private property control-expanded: false; + private property control-visual-expanded: false; private property control-toolbar-height: 38px; private property control-button-step: 32px; + private property control-disconnect-gap: 8px; + private property control-buttons-x: root.control-docked-left ? 43px : 7px; + private property control-panel-gap: 8px; + private property control-reveal-progress: 0; + private property control-surface-progress: min(1, root.control-reveal-progress / 0.65); + private property control-content-progress: max(0, (root.control-reveal-progress - 0.65) / 0.35); + private property control-content-visible: + root.control-visual-expanded && root.control-content-progress > 0; + private property control-surface-width: root.control-toolbar-height + + (control.width - root.control-toolbar-height) * root.control-surface-progress; + private property control-surface-x: control.x + + (root.control-docked-left ? 0px : control.width - root.control-surface-width); + private property control-collapse-duration: 180ms; + private property control-animation-enabled: false; private property stats-row-height: 18px; private property stats-header-height: 18px; private property stats-content-height: root.stats-header-height + root.stats-rows.length * root.stats-row-height + 114px; + private property stats-panel-height: + min(root.stats-content-height + 16px, max(0px, video.height - root.control-toolbar-height - root.control-panel-gap)); + private property stats-panel-y: root.control-panel-y(root.stats-panel-height); private property file-panel-x: 18px; private property file-panel-y: video.height - min(210px, video.height * 0.35); private property file-panel-width: min(430px, video.width * 0.6); @@ -282,20 +383,91 @@ export component StreamWindow inherits Window { private property display-menu-open: false; private property display-menu-animation-enabled: false; private property display-menu-progress: 0.0; + private property display-menu-height: min(180px, root.displays.length * 30px + 16px); + private property display-menu-y: root.control-panel-y(root.display-menu-height); private property shortcut-menu-open: false; + private property shortcut-menu-y: root.control-panel-y(root.shortcut-menu-height); in-out property video-menu-open: false; private property video-menu-width: min(320px, video.width - 8px); private property video-menu-height: root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px; 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); + private property video-menu-y: root.control-panel-y(root.video-menu-height); + private property disconnect-menu-open: false; + private property disconnect-menu-width: min(220px, video.width - 8px); + private property disconnect-menu-height: + disconnect-actions.y + disconnect-actions.height + 12px; + private property disconnect-menu-x: min(max(4px, + control.x + close-button.x + close-button.width - root.disconnect-menu-width), + video.width - root.disconnect-menu-width - 4px); + private property disconnect-menu-y: root.control-panel-y(root.disconnect-menu-height); private property control-menu-open: - root.display-menu-open || root.shortcut-menu-open || root.video-menu-open; + root.display-menu-open || root.shortcut-menu-open || root.video-menu-open || root.disconnect-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 control-expanded => { + control-reveal-timer.running = false; + control-collapse-timer.running = false; + if root.control-expanded { + if !root.control-visual-expanded { + root.control-animation-enabled = false; + root.control-reveal-progress = 0; + root.control-visual-expanded = true; + control-reveal-timer.running = true; + } else { + // Reverse an unfinished collapse from its current frame. + root.control-animation-enabled = true; + root.control-reveal-progress = 1; + } + } else { + root.close-control-menus(); + if root.control-reveal-progress > 0 { + root.control-animation-enabled = true; + root.control-reveal-progress = 0; + control-collapse-timer.running = true; + } else { + root.finish-control-collapse(); + } + } + } + animate control-reveal-progress { + duration: root.control-expanded ? 160ms : root.control-collapse-duration; + easing: root.control-expanded ? ease-out : ease-in-out; + enabled: root.control-animation-enabled; + } + control-reveal-timer := Timer { + interval: 16ms; + running: false; + triggered => { + self.running = false; + if root.control-expanded { + root.control-animation-enabled = true; + root.control-reveal-progress = 1; + } + } + } + control-collapse-timer := Timer { + // Retain the layout through the final animation frame. + interval: root.control-collapse-duration + 16ms; + running: false; + triggered => { + self.running = false; + if !root.control-expanded { root.finish-control-collapse(); } + } + } + function finish-control-collapse() { + if root.control-dragging { + // A drag can begin while the capsule is shrinking. Preserve the + // logo's position and pointer offset when its container contracts. + let logo-offset = logo-button.x; + root.control-drag-x = control.x + logo-offset; + root.control-press-x = root.control-press-x - logo-offset; + } + root.control-animation-enabled = false; + root.control-reveal-progress = 0; + root.control-visual-expanded = false; + } changed window-active => { if !root.window-active { root.close-control-menus(); @@ -331,9 +503,9 @@ export component StreamWindow inherits Window { private property control-tooltip-visible: false; private property hovered-control: { if root.window-active && !root.control-dragging - && !root.display-menu-open && !root.shortcut-menu-open && !root.video-menu-open { - if collapse-button.has-hover { return collapse-button.tooltip-info; } - if root.control-expanded { + && !root.control-menu-open { + if logo-button.has-hover { return logo-button.tooltip-info; } + if root.control-expanded && root.control-content-visible { if display-button.has-hover { return display-button.tooltip-info; } if shortcut-button.has-hover { return shortcut-button.tooltip-info; } if mouse-button.has-hover { return mouse-button.tooltip-info; } @@ -375,6 +547,12 @@ export component StreamWindow inherits Window { callback end-control-drag; pure callback is-local-control-area(length, length) -> bool; + pure function control-panel-y(panel-height: length) -> length { + let below = control.y + root.control-toolbar-height + root.control-panel-gap; + return below + panel-height <= video.height ? below + : max(0px, control.y - root.control-panel-gap - panel-height); + } + function close-control-menus() { display-menu-open-timer.running = false; root.display-menu-animation-enabled = false; @@ -382,6 +560,7 @@ export component StreamWindow inherits Window { root.display-menu-open = false; root.shortcut-menu-open = false; root.video-menu-open = false; + root.disconnect-menu-open = false; } // Native focus/session changes release input in C++; clear the matching UI @@ -447,12 +626,27 @@ export component StreamWindow inherits Window { } } + function arm-control-drag(x: length, y: length) { + root.close-control-menus(); + root.control-drag-start-x = x; + root.control-drag-start-y = y; + root.control-drag-pending = true; + } + function update-control-drag(button: PointerEventButton, kind: PointerEventKind, x: length, y: length) { if kind == PointerEventKind.cancel || (kind == PointerEventKind.up && button == PointerEventButton.left) { + // Slint emits clicked before pointer-up. Resolve logo clicks here + // so a captured drag can never also toggle the toolbar. + let toggle-expanded = kind == PointerEventKind.up && root.control-drag-pending + && !root.control-dragging + && root.window-active && !root.remote-update-visible + && x >= control.x + logo-button.x && x < control.x + logo-button.x + logo-button.width + && y >= control.y + logo-button.y && y < control.y + logo-button.y + logo-button.height; root.control-drag-pending = false; root.end-control-drag(); + if toggle-expanded { root.control-expanded = !root.control-expanded; } } else if kind == PointerEventKind.move && root.control-drag-pending { if !root.control-dragging && (abs(x - root.control-drag-start-x) >= 5px @@ -497,32 +691,36 @@ export component StreamWindow inherits Window { is-local-control-area(pointer-x, pointer-y) => { if root.remote-update-visible { return true; } - let over-control = pointer-x >= control.x - && pointer-x <= control.x + control.width + let over-control = pointer-x >= root.control-surface-x + && pointer-x <= root.control-surface-x + root.control-surface-width && pointer-y >= control.y && pointer-y <= control.y + control.height; - let display-menu-x = control.x + (root.control-docked-left ? 9px : 42px); - let display-menu-y = control.y + root.control-toolbar-height - 1px; - let display-menu-height = min(180px, root.displays.length * 30px + 8px); + let over-stats = root.stats-visible && root.control-content-visible + && pointer-x >= control.x && pointer-x <= control.x + control.width + && pointer-y >= root.stats-panel-y && pointer-y <= root.stats-panel-y + root.stats-panel-height; + let display-menu-x = control.x + display-button.x; let over-display-menu = root.display-menu-open && pointer-x >= display-menu-x && pointer-x <= display-menu-x + 180px - && pointer-y >= display-menu-y - && pointer-y <= display-menu-y + display-menu-height; + && pointer-y >= root.display-menu-y + && pointer-y <= root.display-menu-y + root.display-menu-height; let shortcut-menu-x = control.x + shortcut-button.x; - let shortcut-menu-y = control.y + 40px; let over-shortcut-menu = root.shortcut-menu-open && pointer-x >= shortcut-menu-x && pointer-x <= shortcut-menu-x + 180px - && pointer-y >= shortcut-menu-y - && pointer-y <= shortcut-menu-y + root.shortcut-menu-height; + && pointer-y >= root.shortcut-menu-y + && pointer-y <= root.shortcut-menu-y + root.shortcut-menu-height; let over-video-menu = root.video-menu-open && pointer-x >= root.video-menu-x && pointer-x <= root.video-menu-x + root.video-menu-width && pointer-y >= root.video-menu-y && pointer-y <= root.video-menu-y + root.video-menu-height; + let over-disconnect-menu = root.disconnect-menu-open + && pointer-x >= root.disconnect-menu-x && pointer-x <= root.disconnect-menu-x + root.disconnect-menu-width + && pointer-y >= root.disconnect-menu-y && pointer-y <= root.disconnect-menu-y + root.disconnect-menu-height; let over-file-panel = root.file-transfer-visible && pointer-x >= root.file-panel-x && pointer-x <= root.file-panel-x + root.file-panel-width && pointer-y >= root.file-panel-y && pointer-y <= root.file-panel-y + root.file-panel-height; - return over-control || over-display-menu || over-shortcut-menu || over-video-menu || over-file-panel; + return over-control || over-stats || over-display-menu || over-shortcut-menu + || over-video-menu || over-disconnect-menu || over-file-panel; } begin-control-drag(press-x, press-y) => { @@ -851,7 +1049,7 @@ export component StreamWindow inherits Window { // 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. + // The logo 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 @@ -872,35 +1070,38 @@ export component StreamWindow inherits Window { } } - // Floating control bar, matching the original top-left overlay. + // Keep the toolbar anchor and hit area stable while the visual + // capsule grows from the logo's docked edge. control := Rectangle { x: root.control-dragging ? root.control-drag-x : root.control-docked-left ? 0px : parent.width - self.width; y: min(max(0px, root.control-pos-y), parent.height - self.height); - width: root.control-expanded ? min(76px + 9 * root.control-button-step, parent.width) : 20px; - height: root.stats-visible && root.control-expanded - ? min(root.control-toolbar-height + root.stats-content-height + 8px, parent.height) - : root.control-toolbar-height; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: FloatingBarStyle.corner-radius; + width: root.control-visual-expanded ? min(80px + 9 * root.control-button-step + root.control-disconnect-gap, parent.width) : root.control-toolbar-height; + height: root.control-toolbar-height; 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; } - // All padding and statistics are local, non-draggable content. + if root.control-visual-expanded: StreamControlSurface { + width: root.control-surface-width; + height: root.control-toolbar-height; + x: root.control-docked-left ? 0px : parent.width - self.width; + border-radius: root.control-toolbar-height / 2; + opacity: min(1, root.control-reveal-progress / 0.15); + } + + // Padding stays local; only the logo starts dragging. TouchArea { + x: root.control-surface-x - control.x; + width: root.control-surface-width; mouse-cursor: default; clicked => { root.close-control-menus(); } scroll-event(event) => { accept } } display-button := ControlBarButton { - visible: root.control-expanded; - x: root.control-docked-left ? 9px : 42px; y: 7px; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.display; badge: root.selected-display + 1; tooltip: StreamStrings.select-display; @@ -911,14 +1112,18 @@ export component StreamWindow inherits Window { root.display-menu-open = !root.display-menu-open; root.shortcut-menu-open = false; root.video-menu-open = false; + root.disconnect-menu-open = false; if root.display-menu-open { display-menu-open-timer.running = true; } } } shortcut-button := ControlBarButton { - visible: root.control-expanded; - x: (root.control-docked-left ? 9px : 42px) + root.control-button-step; y: 7px; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + root.control-button-step; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.keyboard; tooltip: StreamStrings.send-shortcut; clicked => { display-menu-open-timer.running = false; @@ -927,117 +1132,146 @@ export component StreamWindow inherits Window { root.shortcut-menu-open = !root.shortcut-menu-open; root.display-menu-open = false; root.video-menu-open = false; + root.disconnect-menu-open = false; } } mouse-button := ControlBarButton { - visible: root.control-expanded; - x: (root.control-docked-left ? 9px : 42px) + 2 * root.control-button-step; y: 7px; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + 2 * root.control-button-step; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.computer-mouse; slashed: !root.mouse-control-enabled; tooltip: root.mouse-control-enabled ? StreamStrings.release-mouse : StreamStrings.control-mouse; 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; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + 4 * root.control-button-step; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.volume-high; slashed: !root.audio-enabled; tooltip: root.audio-enabled ? StreamStrings.mute : StreamStrings.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; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + 5 * root.control-button-step; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.folder-open; tooltip: StreamStrings.select-file; clicked => { root.close-control-menus(); root.select-file(); } } video-button := ControlBarButton { - visible: root.control-expanded; - x: (root.control-docked-left ? 9px : 42px) + 6 * root.control-button-step; y: 7px; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + 6 * root.control-button-step; + y: (parent.height - self.height) / 2; icon: "\u{f1de}"; selected: root.video-menu-open; tooltip: StreamStrings.video-settings; clicked => { root.video-menu-open = !root.video-menu-open; root.display-menu-open = false; root.shortcut-menu-open = false; + root.disconnect-menu-open = false; } } stats-button := ControlBarButton { - visible: root.control-expanded; - x: (root.control-docked-left ? 9px : 42px) + 7 * root.control-button-step; y: 7px; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + 7 * root.control-button-step; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.signal; selected: root.stats-visible; tooltip: root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-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; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + 8 * root.control-button-step; + y: (parent.height - self.height) / 2; icon: root.fullscreen-enabled ? FontAwesomeIcons.compress : FontAwesomeIcons.expand; tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen; clicked => { root.close-control-menus(); root.toggle-fullscreen(); } } + if root.control-content-visible: Rectangle { + opacity: root.control-content-progress; + x: close-button.x - 6px; y: 12px; + width: 1px; height: 14px; + background: #dbe5f1; + } close-button := ControlBarButton { - visible: root.control-expanded; - x: (root.control-docked-left ? 9px : 42px) + 9 * root.control-button-step; y: 7px; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + action-enabled: root.control-expanded; + x: root.control-buttons-x + 9 * root.control-button-step + root.control-disconnect-gap; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect; - clicked => { root.close-control-menus(); root.disconnect(); } + danger: true; + selected: root.disconnect-menu-open; + clicked => { + let open = !root.disconnect-menu-open; + root.close-control-menus(); + root.disconnect-menu-open = open; + } } - // 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; - y: 7px; - width: 15px; - icon: root.control-expanded - ? (root.control-docked-left ? FontAwesomeIcons.angle-left : FontAwesomeIcons.angle-right) - : (root.control-docked-left ? FontAwesomeIcons.angle-right : FontAwesomeIcons.angle-left); + logo-button := ControlBarButton { + // Stay under the pointer across expansion and collapse. + x: root.control-docked-left ? 0px : parent.width - self.width; + y: 0px; + width: root.control-toolbar-height; + height: root.control-toolbar-height; + show-logo: true; + logo-pressed: root.control-drag-pending; + logo-dragging: self.logo-pressed && root.control-dragging; tooltip: root.control-expanded ? StreamStrings.collapse-control : StreamStrings.expand-control; - clicked => { root.close-control-menus(); root.control-expanded = !root.control-expanded; } + pointer-input(button, kind, pointer-x, pointer-y) => { + let x = control.x + logo-button.x + pointer-x; + let y = control.y + logo-button.y + pointer-y; + if kind == PointerEventKind.down && button == PointerEventButton.left { + root.arm-control-drag(x, y); + } else { + root.update-control-drag(button, kind, x, y); + } + } } privacy-button := ControlBarButton { - visible: root.control-expanded; - x: (root.control-docked-left ? 9px : 42px) + 3 * root.control-button-step; y: 7px; + reveal-progress: root.control-content-progress; + visible: root.control-content-visible; + x: root.control-buttons-x + 3 * root.control-button-step; + y: (parent.height - self.height) / 2; icon: FontAwesomeIcons.user-secret; slashed: root.privacy-off && !root.privacy-unsupported; muted: root.privacy-unsupported; - action-enabled: root.privacy-can-toggle; + action-enabled: root.control-expanded && root.privacy-can-toggle; tooltip: root.privacy-status-text + (root.privacy-can-toggle ? "\n" + (root.privacy-active ? StreamStrings.privacy-disable : StreamStrings.privacy-enable) : ""); clicked => { root.close-control-menus(); root.toggle-privacy(); } } + } - if root.stats-visible && root.control-expanded: stats-table := Rectangle { + if root.stats-visible && root.control-content-visible: stats-panel := StreamControlSurface { + x: control.x; y: root.stats-panel-y; + width: control.width; height: root.stats-panel-height; + opacity: root.control-content-progress; + z: 10; + clip: true; + TouchArea { + mouse-cursor: default; + clicked => { root.close-control-menus(); } + scroll-event(event) => { accept } + } + stats-table := Rectangle { x: 8px; - y: root.control-toolbar-height + 2px; + y: 8px; width: parent.width - 16px; height: root.stats-content-height; background: transparent; @@ -1302,11 +1536,61 @@ export component StreamWindow inherits Window { scroll-event(event) => { accept } } } - if root.video-menu-open: Rectangle { + disconnect-panel := StreamControlSurface { + visible: root.disconnect-menu-open; + x: root.disconnect-menu-x; y: root.disconnect-menu-y; + width: root.disconnect-menu-width; height: root.disconnect-menu-height; + z: 11; + TouchArea { mouse-cursor: default; scroll-event(event) => { accept } } + disconnect-title := Text { + x: 12px; y: 12px; width: parent.width - 24px; + height: max(20px, self.preferred-height); + text: StreamStrings.confirm-disconnect; + color: #30343b; font-size: 14px; font-weight: 600; + vertical-alignment: center; wrap: word-wrap; + } + disconnect-message := Text { + x: 12px; y: disconnect-title.y + disconnect-title.height + 4px; + width: parent.width - 24px; height: max(18px, self.preferred-height); + text: StreamStrings.disconnect-description; + color: #667085; font-size: 12px; wrap: word-wrap; + } + disconnect-actions := HorizontalLayout { + x: parent.width - self.width - 12px; + y: disconnect-message.y + disconnect-message.height + 12px; + width: 168px; height: 28px; + spacing: 8px; + SecondaryButton { + width: 72px; + height: 28px; + text: StreamStrings.cancel; + clicked => { root.close-control-menus(); } + } + Rectangle { + width: 88px; + background: confirm-disconnect-touch.pressed ? #912018 + : confirm-disconnect-touch.has-hover ? #b42318 : #d92d20; + border-radius: UiRadius.control; + Text { + text: StreamStrings.disconnect; + color: white; font-size: ImGuiFontStyle.body; + horizontal-alignment: center; vertical-alignment: center; + } + confirm-disconnect-touch := TouchArea { + mouse-cursor: MouseCursor.pointer; + scroll-event(event) => { accept } + clicked => { + root.close-control-menus(); + root.disconnect(); + } + } + } + } + } + if root.video-menu-open: StreamControlSurface { x: root.video-menu-x; y: root.video-menu-y; width: root.video-menu-width; height: root.video-menu-height; - background: white; border-width: 1px; border-color: ImGuiLineStyle.border; - border-radius: UiRadius.menu; z: 11; + z: 11; TouchArea { mouse-cursor: default; scroll-event(event) => { accept } @@ -1337,28 +1621,22 @@ export component StreamWindow inherits Window { } } - if root.display-menu-open: display-menu := Rectangle { - private property expanded-height: min(180px, root.displays.length * 30px + 8px); + if root.display-menu-open: display-menu := StreamControlSurface { + private property expanded-height: root.display-menu-height; private property hovered-index: - display-touch.has-hover && display-touch.mouse-y >= 4px - && floor((display-touch.mouse-y - 4px) / 30px) < root.displays.length - ? floor((display-touch.mouse-y - 4px) / 30px) : -1; - x: control.x + (root.control-docked-left ? 9px : 42px); - // Anchor to the button row, independent of the expanded stats. - // Overlap its bottom edge by one pixel to keep the menu attached. - y: control.y + root.control-toolbar-height - 1px; + display-touch.has-hover && display-touch.mouse-y >= 8px + && floor((display-touch.mouse-y - 8px) / 30px) < root.displays.length + ? floor((display-touch.mouse-y - 8px) / 30px) : -1; + x: control.x + display-button.x; + y: root.display-menu-y; width: 180px; height: self.expanded-height * root.display-menu-progress; opacity: root.display-menu-progress; - background: white; - border-width: 1px; - border-color: ImGuiLineStyle.border; - border-radius: UiRadius.menu; clip: true; z: 11; VerticalLayout { - padding: 4px; spacing: 1px; + padding: 8px; spacing: 1px; for display[index] in root.displays: Rectangle { height: 29px; background: index == display-menu.hovered-index && display-touch.pressed ? #bfd4f2 @@ -1394,9 +1672,9 @@ export component StreamWindow inherits Window { display-touch := TouchArea { mouse-cursor: default; clicked => { - let index = floor((self.mouse-y - 4px) / 30px); - let row-y = self.mouse-y - 4px - index * 30px; - if self.mouse-y >= 4px && row-y < 29px + let index = floor((self.mouse-y - 8px) / 30px); + let row-y = self.mouse-y - 8px - index * 30px; + if self.mouse-y >= 8px && row-y < 29px && index >= 0 && index < root.displays.length { display-menu-open-timer.running = false; root.display-menu-animation-enabled = false; @@ -1410,12 +1688,12 @@ 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; + if root.shortcut-menu-open: StreamControlSurface { + x: control.x + shortcut-button.x; y: root.shortcut-menu-y; + width: 180px; height: root.shortcut-menu-height; z: 11; TouchArea { mouse-cursor: default; scroll-event(event) => { accept } } VerticalLayout { - padding: 4px; spacing: 1px; + padding: 8px; 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; } diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index f5907fb5..f6792573 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -228,6 +228,11 @@ inline int ApplyStreamWindowStrings( strings.set_fullscreen(Text(localization::fullscreen[language])); strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language])); strings.set_disconnect(Text(localization::disconnect[language])); + strings.set_confirm_disconnect( + Text(localization::stream_confirm_disconnect[language])); + strings.set_disconnect_description( + Text(localization::stream_disconnect_description[language])); + strings.set_cancel(Text(localization::cancel[language])); strings.set_file_transfer( Text(localization::file_transfer_progress[language])); strings.set_expand_control(Text(localization::expand_control_bar[language]));