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"; export struct StreamTab { remote-id: string, title: string, connected: bool, } export struct FileTransferEntry { name: string, status: string, progress: float, speed: string, size: string, } export struct NetworkStatsRow { label: string, inbound: string, outbound: string, loss-rate: string, } export global StreamStrings { in-out property remote-update-title: "Remote device needs an update"; in-out property remote-update: "Please update to the latest version."; in-out property remote-update-dismiss: "Got it"; in-out property video-settings: "Video settings"; in-out property video-settings-unavailable: "The remote device does not support live video settings"; in-out property video-quality: "Video quality"; in-out property frame-rate: "Capture frame rate"; in-out property adaptation-policy: "Video preference"; in-out property quality-low: "Low"; in-out property quality-medium: "Medium"; in-out property quality-high: "High"; in-out property priority-frame-rate: "Frame rate"; in-out property priority-quality: "Quality"; in-out property priority-balanced: "Balanced"; in-out property select-display: "Select Display"; in-out property send-shortcut: "Send Shortcut"; in-out property privacy-enable: "Enable privacy screen"; in-out property privacy-disable: "Turn privacy off / recover"; in-out property control-mouse: "Control Mouse"; in-out property release-mouse: "Release Mouse"; in-out property audio: "Audio Capture"; in-out property mute: "Mute"; in-out property select-file: "Select File to Send"; in-out property show-stats: "Show Net Traffic Stats"; in-out property hide-stats: "Hide Net Traffic Stats"; in-out property fullscreen: "Fullscreen"; in-out property exit-fullscreen: "Exit fullscreen"; in-out property disconnect: "Disconnect"; in-out property file-transfer: "File Transfer Progress"; in-out property expand-control: "Expand Control Bar"; in-out property collapse-control: "Collapse Control Bar"; in-out property stats-in: "In"; in-out property stats-out: "Out"; in-out property stats-loss-rate: "Loss Rate"; in-out property stats-resolution: "Res"; in-out property stats-video-latency: "Video delay"; in-out property stats-connection-latency: "Connection RTT"; in-out property stats-connection-mode: "Mode"; in-out property stats-encryption: "Encryption"; in-out property stats-encryption-enabled: "Enabled"; in-out property stats-encryption-disabled: "Disabled"; } struct ControlBarTooltip { text: string, 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. component ControlBarButton inherits Rectangle { in property icon; in property badge: ""; in property tooltip: ""; in property selected: false; in property slashed: false; in property action-enabled: true; // Capability and temporary command availability have different visuals. in property muted: false; 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 : "", anchor-x: root.x + root.width / 2, }; callback clicked; 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; Text { text: root.icon; color: root.muted ? #8a8f96 : #24272d; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: root.icon-size; horizontal-alignment: center; vertical-alignment: center; } // 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 // exact horizontal and vertical center of the button. x: (parent.width - self.preferred-width) / 2 + 0.5px; y: (parent.height - self.preferred-height) / 2 - 0.5px; text: root.badge; color: black; font-size: 11px; } // Draw the two offset disabled-state strokes as paths. Rotated rectangles // stay vertical in the software renderer used for fallback and UI capture. if root.slashed: Path { width: 24px; height: 24px; viewbox-width: 24; viewbox-height: 24; commands: "M 3.5 3.5 L 20.5 20.5"; stroke: black; stroke-width: 2px; } if root.slashed: Path { width: 24px; height: 24px; viewbox-width: 24; viewbox-height: 24; commands: "M 2 5 L 19 22"; stroke: touch.has-hover ? #4296fa : #b3d5fd; stroke-width: 2px; } touch := TouchArea { // Keep disabled controls hit-testable so clicks cannot reach the peer. clicked => { if root.action-enabled { root.clicked(); } } } } component VideoSettingRow inherits VerticalLayout { in property label; in property <[string]> options; 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 { spacing: 4px; for option[index] in root.options: Rectangle { height: 27px; background: !root.enabled ? #f1f1f1 : root.selected == index ? #d7eaff : option-touch.has-hover ? #eaf3ff : #f5f6f8; border-radius: UiRadius.control; border-width: 1px; border-color: root.enabled && root.selected == index ? #4296fa : #dce0e6; 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(); } } clicked => { if root.enabled { root.choose(index); } } scroll-event(event) => { accept } } } } } export component StreamWindow inherits Window { title: "CrossDesk"; icon: @image-url("../../../resources/linux/crossdesk_256x256.png"); preferred-width: 1280px; preferred-height: 720px + (root.custom-titlebar ? 32px : 0px); min-width: 640px; min-height: 360px + (root.custom-titlebar ? 32px : 0px); no-frame: root.custom-titlebar; background: root.native-video-enabled ? transparent : root.custom-titlebar ? transparent : black; default-font-size: ImGuiFontStyle.base; in property custom-titlebar: false; in property native-video-enabled: false; // Export the actual input surface in logical pixels. Native rendering and // input mapping must follow the same layout, including title and tab bars. out property video-area-top: content-layer.y + video.y; out property video-area-width: video.width; out property video-area-height: video.height; in property window-active: true; in property window-maximized: false; in property <[StreamTab]> tabs; in-out property selected-tab: 0; in property frame; in property has-frame: false; in property status-text: ""; in property receiving-text: ""; in property remote-update-visible: false; callback dismiss-remote-update; in property <[string]> displays; in-out property selected-display: 0; in property mouse-control-enabled: true; in property privacy-can-toggle: false; in property privacy-active: false; in property privacy-off: false; in property privacy-unsupported: false; // Privacy feedback stays on its toolbar button and hover tooltip. in property privacy-status-text: "Privacy screen"; // Numeric values mirror crossdesk::RemoteCursorShape and Slint's MouseCursor. in property remote-cursor-active: false; in property remote-cursor-shape: 0; in property audio-enabled: true; in property video-settings-enabled: false; in property video-settings-feedback: ""; in-out property video-quality-index: 2; in-out property frame-rate-index: 1; in-out property adaptation-policy-index: 1; callback change-video-settings(int, int, int); in property fullscreen-enabled: false; in property stats-visible: false; in property <[NetworkStatsRow]> stats-rows; in property stats-fps: "0"; in property stats-resolution: ""; in property stats-video-latency: "—"; in property stats-connection-latency: "—"; in property stats-connection-mode: ""; in property stats-encryption-enabled: false; in property file-transfer-visible: false; in property <[FileTransferEntry]> file-transfers; callback select-tab(int); callback reorder-tab(int, length, length); callback close-tab(string); callback switch-display(int); in property secure-attention-supported: true; 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; callback send-shortcut(string); callback toggle-mouse-control; callback toggle-privacy; callback toggle-audio; callback select-file; callback close-file-transfer; pure callback can-drop-file(data-transfer) -> bool; callback file-dropped(data-transfer); callback toggle-stats; callback toggle-fullscreen; callback disconnect; callback stream-title-drag(int, length, length); callback minimize-stream-window; callback toggle-maximize-stream-window; callback close-stream-window; callback pointer-input(PointerEventButton, PointerEventKind, length, length); callback scroll-input(length, length, length, length); callback key-input(string, bool, bool, bool, bool, bool); callback keyboard-focus-changed(bool); private property control-expanded: true; private property control-toolbar-height: 38px; private property control-button-step: 32px; 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 + 65px; 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); private property file-panel-height: min(190px, video.height * 0.32); private property display-menu-open: false; private property display-menu-animation-enabled: false; private property display-menu-progress: 0.0; private property shortcut-menu-open: false; 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); 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; } } changed remote-update-visible => { if root.remote-update-visible { root.video-menu-open = false; root.display-menu-open = false; root.shortcut-menu-open = false; } // Changed handlers may run while Winit/AccessKit is building the // accessibility tree. Changing focus there re-enters its borrowed // adapter and aborts the process; defer focus to the event loop. input-focus-timer.running = true; input-focus-timer.restart(); root.keyboard-focus-changed(!root.remote-update-visible); } input-focus-timer := Timer { interval: 1ms; running: false; triggered => { self.running = false; input-focus.focus(); } } 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 { 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; } if privacy-button.has-hover { return privacy-button.tooltip-info; } if video-button.has-hover { return video-button.tooltip-info; } if audio-button.has-hover { return audio-button.tooltip-info; } if file-button.has-hover { return file-button.tooltip-info; } if stats-button.has-hover { return stats-button.tooltip-info; } if fullscreen-button.has-hover { return fullscreen-button.tooltip-info; } if close-button.has-hover { return close-button.tooltip-info; } } } 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; private property control-docked-left: true; private property control-dragging: false; private property control-drag-x: 0px; // Start flush against the title/tab strip. This remains mutable so a // manually dragged control bar keeps its chosen vertical position. private property control-pos-y: 0px; private property control-press-x: 0px; private property control-press-y: 0px; private property dragging-tab: -1; private property titlebar-height: root.custom-titlebar && !root.fullscreen-enabled ? 32px : 0px; private property window-corner-radius: 12px; callback begin-control-drag(length, length); callback move-control-drag(length, length); callback end-control-drag; pure callback is-local-control-area(length, length) -> bool; animate display-menu-progress { duration: 120ms; easing: ease-out; enabled: root.display-menu-animation-enabled; } display-menu-open-timer := Timer { interval: 16ms; running: false; triggered => { self.running = false; if root.display-menu-open { root.display-menu-animation-enabled = true; root.display-menu-progress = 1.0; } } } changed hovered-control => { root.control-tooltip-visible = false; control-tooltip-timer.running = root.hovered-control.text != ""; if root.hovered-control.text != "" { control-tooltip-timer.restart(); } } control-tooltip-timer := Timer { interval: 450ms; running: false; triggered => { self.running = false; root.control-tooltip-visible = true; } } 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 && 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-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; 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; 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-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; } begin-control-drag(press-x, press-y) => { // Capture the snapped position before switching x to control-drag-x. // Otherwise a second press briefly restores the previous drag target // and makes a plain click move the control bar. let current-x = control.x; let current-y = control.y; root.control-drag-x = current-x; // Store the pointer offset in the control bar, while all following // 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.control-dragging = true; } move-control-drag(pointer-x, pointer-y) => { if root.control-dragging { root.control-drag-x = min(max(0px, pointer-x - root.control-press-x), video.width - control.width); root.control-pos-y = min(max(0px, pointer-y - root.control-press-y), video.height - control.height); } } end-control-drag() => { if root.control-dragging { root.control-docked-left = root.control-drag-x + control.width / 2 < video.width / 2; root.control-pos-y = min(max(0px, control.y), video.height - control.height); root.control-dragging = false; } } window-surface := Rectangle { width: root.width; height: root.height; background: root.native-video-enabled ? transparent : black; border-radius: root.custom-titlebar && !root.fullscreen-enabled && !root.window-maximized ? root.window-corner-radius : 0px; clip: root.custom-titlebar && !root.fullscreen-enabled; if root.custom-titlebar && !root.fullscreen-enabled: Rectangle { x: 0px; y: 0px; width: parent.width; height: root.titlebar-height; background: #f6f5f4; z: 100; Text { x: 120px; width: parent.width - 240px; height: parent.height; text: "CrossDesk"; color: root.window-active ? #3d3d3d : #747474; font-size: 12px; font-weight: 600; horizontal-alignment: center; vertical-alignment: center; overflow: elide; } stream-title-drag-area := TouchArea { width: parent.width - 120px; property phase: self.pressed ? 1 : 0; changed phase => { root.stream-title-drag(phase, self.mouse-x, self.mouse-y); } moved => { if (self.pressed) { root.stream-title-drag(2, self.mouse-x, self.mouse-y); } } double-clicked => { root.toggle-maximize-stream-window(); } } stream-minimize-button := Rectangle { x: parent.width - 114px; y: 4px; width: 24px; height: 24px; border-radius: 12px; background: stream-minimize-touch.pressed ? #c0bfbc : stream-minimize-touch.has-hover ? #deddda : root.window-active ? #e6e6e6 : transparent; accessible-role: button; accessible-label: "Minimize"; accessible-action-default => { stream-minimize-touch.clicked(); } Rectangle { x: 8px; y: 13px; width: 8px; height: 0.75px; background: root.window-active || stream-minimize-touch.has-hover || stream-minimize-touch.pressed ? #454545 : #898989; } stream-minimize-touch := TouchArea { clicked => { root.minimize-stream-window(); } } } stream-maximize-button := Rectangle { x: parent.width - 74px; y: 4px; width: 24px; height: 24px; border-radius: 12px; background: stream-maximize-touch.pressed ? #c0bfbc : stream-maximize-touch.has-hover ? #deddda : root.window-active ? #e6e6e6 : transparent; accessible-role: button; accessible-label: root.window-maximized ? "Restore" : "Maximize"; accessible-action-default => { stream-maximize-touch.clicked(); } if !root.window-maximized: Rectangle { x: 8px; y: 8px; width: 8px; height: 8px; background: transparent; border-width: 1px; border-color: root.window-active || stream-maximize-touch.has-hover || stream-maximize-touch.pressed ? #454545 : #898989; } if root.window-maximized: Rectangle { x: 9px; y: 7px; width: 8px; height: 8px; background: transparent; border-width: 1px; border-color: root.window-active || stream-maximize-touch.has-hover || stream-maximize-touch.pressed ? #454545 : #898989; } if root.window-maximized: Rectangle { x: 7px; y: 9px; width: 8px; height: 8px; background: stream-maximize-button.background; border-width: 1px; border-color: root.window-active || stream-maximize-touch.has-hover || stream-maximize-touch.pressed ? #454545 : #898989; } stream-maximize-touch := TouchArea { clicked => { root.toggle-maximize-stream-window(); } } } stream-close-button := Rectangle { x: parent.width - 34px; y: 4px; width: 24px; height: 24px; border-radius: 12px; background: stream-close-touch.pressed ? #b2161d : stream-close-touch.has-hover ? #e01b24 : root.window-active ? #e6e6e6 : transparent; accessible-role: button; accessible-label: "Close"; accessible-action-default => { stream-close-touch.clicked(); } Path { width: parent.width; height: parent.height; viewbox-width: 24; viewbox-height: 24; commands: "M 14.75 14.75 L 8.25 8.25 M 14.75 8.25 L 8.25 14.75"; stroke: stream-close-touch.has-hover || stream-close-touch.pressed ? white : root.window-active ? #454545 : #898989; stroke-width: 1px; } stream-close-touch := TouchArea { clicked => { root.close-stream-window(); } } } Rectangle { y: parent.height - 1px; height: 1px; background: #d5d3d0; } } content-layer := Rectangle { y: root.titlebar-height; width: parent.width; height: parent.height - self.y; background: root.native-video-enabled ? transparent : black; clip: true; // Keep the ImGui tab strip behavior, including pointer-drag reordering. Rectangle { y: 0px; height: root.tabs.length > 1 ? 30px : 0px; visible: !root.fullscreen-enabled && root.tabs.length > 1; background: #f1f2f4; z: 10; HorizontalLayout { spacing: 1px; for tab[index] in root.tabs: tab-item := Rectangle { width: min(190px, max(110px, (parent.width - 12px) / root.tabs.length)); background: index == root.selected-tab ? white : tab-touch.has-hover || root.dragging-tab == index ? #e5e7eb : #d8dbe0; border-width: root.dragging-tab == index ? 1px : 0px; border-color: #2463c7; Rectangle { width: parent.width - 27px; height: parent.height; background: transparent; Text { x: 0px; width: parent.width - self.x; height: parent.height; text: tab.title; color: #30343b; font-size: ImGuiFontStyle.base; overflow: elide; vertical-alignment: center; } } Rectangle { y: parent.height - 2px; height: 2px; background: index == root.selected-tab ? #3b82f6 : transparent; } tab-touch := TouchArea { width: parent.width - 27px; changed pressed => { if (self.pressed) { root.dragging-tab = index; } else if (root.dragging-tab == index) { root.reorder-tab(index, tab-item.x + self.mouse-x, tab-item.width); root.dragging-tab = -1; } } clicked => { root.selected-tab = index; root.select-tab(index); } } IconButton { x: parent.width - 27px; y: 1px; width: 27px; height: 27px; z: 2; icon: FontAwesomeIcons.xmark; icon-size: 11px; icon-color: #555c66; danger: true; clicked => { root.close-tab(tab.remote-id); } } } } } video := Rectangle { y: root.fullscreen-enabled ? 0px : (root.tabs.length > 1 ? 30px : 0px); height: parent.height - self.y; background: root.native-video-enabled ? transparent : black; clip: true; frame-image := Image { width: parent.width; height: parent.height; source: root.frame; image-fit: contain; visible: root.has-frame && !root.native-video-enabled; } Text { // A connection state and the frame-waiting state may change in // the same UI tick. Render them through one text item so two // independent labels can never occupy the center together. visible: !root.has-frame && (root.receiving-text != "" || root.status-text != ""); text: root.receiving-text != "" ? root.receiving-text : root.status-text; color: root.receiving-text != "" ? #ffffffea : white; font-size: root.receiving-text != "" ? 14px : ImGuiFontStyle.prominent; horizontal-alignment: center; vertical-alignment: center; } drop-zone := DropArea { can-drop(event) => { return !root.remote-update-visible && root.can-drop-file(event.data) ? DragAction.copy : DragAction.none; } dropped(event) => { if root.remote-update-visible { return DragAction.none; } root.file-dropped(event.data); return DragAction.copy; } } input-focus := FocusScope { focus-on-click: true; focus-gained => { root.keyboard-focus-changed(true); } focus-lost => { root.keyboard-focus-changed(false); } key-pressed(event) => { if !root.remote-update-visible { root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); } accept } key-released(event) => { if root.remote-update-visible { // Dismiss on release so the same key cannot reach the peer. if event.text == Key.Escape || event.text == Key.Return { root.dismiss-remote-update(); } } else { root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); } accept } pointer := TouchArea { 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); } } 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); } 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; 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; z: 8; 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; } display-button := ControlBarButton { visible: root.control-expanded; x: root.control-docked-left ? 9px : 42px; y: 7px; icon: FontAwesomeIcons.display; badge: root.selected-display + 1; tooltip: StreamStrings.select-display; clicked => { display-menu-open-timer.running = false; root.display-menu-animation-enabled = false; root.display-menu-progress = 0.0; root.display-menu-open = !root.display-menu-open; root.shortcut-menu-open = false; root.video-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; icon: FontAwesomeIcons.keyboard; tooltip: StreamStrings.send-shortcut; clicked => { display-menu-open-timer.running = false; root.display-menu-animation-enabled = false; root.display-menu-progress = 0.0; root.shortcut-menu-open = !root.shortcut-menu-open; root.display-menu-open = false; root.video-menu-open = false; } } mouse-button := ControlBarButton { visible: root.control-expanded; 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(); } } 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(); } } 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(); } } video-button := ControlBarButton { visible: root.control-expanded; x: (root.control-docked-left ? 9px : 42px) + 6 * root.control-button-step; y: 7px; 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; } } stats-button := ControlBarButton { visible: root.control-expanded; 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(); } } 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(); } } 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(); } } if root.control-expanded && root.control-docked-left: Rectangle { x: parent.width - 33px; y: 12px; width: 2px; height: 15px; background: ImGuiLineStyle.control-separator; } 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); tooltip: root.control-expanded ? StreamStrings.collapse-control : StreamStrings.expand-control; clicked => { root.control-expanded = !root.control-expanded; } } privacy-button := ControlBarButton { visible: root.control-expanded; x: (root.control-docked-left ? 9px : 42px) + 3 * root.control-button-step; y: 7px; icon: FontAwesomeIcons.user-secret; slashed: root.privacy-off && !root.privacy-unsupported; muted: root.privacy-unsupported; action-enabled: 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.toggle-privacy(); } } if root.stats-visible && root.control-expanded: stats-table := Rectangle { x: 8px; y: root.control-toolbar-height + 2px; width: parent.width - 16px; height: root.stats-content-height; background: transparent; private property row-height: root.stats-row-height; private property label-column-width: 50px; private property loss-column-width: max((self.width - self.label-column-width) / 3, stats-loss-label.preferred-width + 8px); private property value-column-width: (self.width - self.label-column-width - self.loss-column-width) / 2; private property details-right-width: right-label-width-probe.preferred-width + 6px + max(resolution-width-probe.preferred-width, connection-latency-width-probe.preferred-width, encryption-width-probe.preferred-width + (root.stats-encryption-enabled ? 14px : 0px)); private property details-right-x: min(self.width / 2, self.width - self.details-right-width - 6px); left-label-width-probe := Text { visible: false; text: "FPS\n" + StreamStrings.stats-video-latency + "\n" + StreamStrings.stats-connection-mode; font-size: ImGuiFontStyle.small; } right-label-width-probe := Text { visible: false; text: StreamStrings.stats-resolution + "\n" + StreamStrings.stats-connection-latency + "\n" + StreamStrings.stats-encryption; font-size: ImGuiFontStyle.small; } resolution-width-probe := Text { visible: false; text: root.stats-resolution; font-size: ImGuiFontStyle.body; font-weight: 600; } connection-latency-width-probe := Text { visible: false; text: root.stats-connection-latency; font-size: ImGuiFontStyle.body; font-weight: 600; } encryption-width-probe := Text { visible: false; text: root.stats-encryption-enabled ? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled; font-size: ImGuiFontStyle.body; font-weight: 600; } Rectangle { x: 0px; y: 0px; width: parent.width; height: root.stats-header-height; background: #f3f5f8; border-radius: UiRadius.control; Text { x: stats-table.label-column-width + 4px; width: stats-table.value-column-width - 8px; text: StreamStrings.stats-in; color: #667085; font-size: ImGuiFontStyle.small; horizontal-alignment: right; vertical-alignment: center; } Text { x: stats-table.label-column-width + stats-table.value-column-width + 4px; width: stats-table.value-column-width - 8px; text: StreamStrings.stats-out; color: #667085; font-size: ImGuiFontStyle.small; horizontal-alignment: right; vertical-alignment: center; } stats-loss-label := Text { x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px; width: stats-table.loss-column-width - 8px; text: StreamStrings.stats-loss-rate; color: #667085; font-size: ImGuiFontStyle.small; horizontal-alignment: right; vertical-alignment: center; overflow: elide; } } for row[index] in root.stats-rows: Rectangle { x: 0px; y: root.stats-header-height + index * stats-table.row-height; width: parent.width; height: stats-table.row-height; background: index == root.stats-rows.length - 1 ? #edf3fc : transparent; border-radius: UiRadius.control; Text { x: 4px; width: stats-table.label-column-width - 8px; text: row.label; color: #475467; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; vertical-alignment: center; } Text { x: stats-table.label-column-width + 4px; width: stats-table.value-column-width - 8px; text: row.inbound; color: #253247; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; horizontal-alignment: right; vertical-alignment: center; overflow: elide; } Text { x: stats-table.label-column-width + stats-table.value-column-width + 4px; width: stats-table.value-column-width - 8px; text: row.outbound; color: #253247; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; horizontal-alignment: right; vertical-alignment: center; overflow: elide; } Text { x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px; width: stats-table.loss-column-width - 8px; text: row.loss-rate; color: #475467; font-size: ImGuiFontStyle.body; font-weight: index == root.stats-rows.length - 1 ? 600 : 400; horizontal-alignment: right; vertical-alignment: center; overflow: elide; } if index < root.stats-rows.length - 2: Rectangle { x: 4px; y: parent.height - 1px; width: parent.width - 8px; height: 1px; background: #edf0f4; } } for metric[index] in [ { label: "FPS", value: root.stats-fps }, { label: StreamStrings.stats-resolution, value: root.stats-resolution }, { label: StreamStrings.stats-video-latency, value: root.stats-video-latency }, { label: StreamStrings.stats-connection-latency, value: root.stats-connection-latency }, { label: StreamStrings.stats-connection-mode, value: root.stats-connection-mode }, { label: StreamStrings.stats-encryption, value: root.stats-encryption-enabled ? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled }, ]: Rectangle { private property encryption-icon-width: index == 5 && root.stats-encryption-enabled ? 14px : 0px; x: mod(index, 2) == 1 ? stats-table.details-right-x : 6px; y: root.stats-header-height + root.stats-rows.length * stats-table.row-height + 2px + floor(index / 2) * 21px; width: mod(index, 2) == 1 ? stats-table.details-right-width : stats-table.details-right-x - 14px; height: 21px; metric-label := Text { x: 0px; y: 0px; width: mod(index, 2) == 1 ? right-label-width-probe.preferred-width : left-label-width-probe.preferred-width; height: parent.height; text: metric.label; color: #667085; font-size: ImGuiFontStyle.small; vertical-alignment: center; } metric-value := Text { x: metric-label.width + 6px; y: 0px; height: parent.height; width: parent.width - self.x - parent.encryption-icon-width; text: metric.value; color: index == 5 && root.stats-encryption-enabled ? #18794e : #253247; font-size: ImGuiFontStyle.body; font-weight: 600; horizontal-alignment: left; vertical-alignment: center; overflow: elide; } if index == 5 && root.stats-encryption-enabled: Text { x: metric-value.x + min(metric-value.preferred-width, metric-value.width) + 4px; y: metric-value.y + (metric-value.height - self.height) / 2; width: 10px; height: 12px; text: FontAwesomeIcons.shield-halved; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px; color: #238653; horizontal-alignment: center; vertical-alignment: center; } } } } // 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 { width: parent.width; height: parent.height; z: 9; TouchArea { pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } } clicked => { root.video-menu-open = false; } scroll-event(event) => { accept } } } if root.video-menu-open: Rectangle { 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; TouchArea { pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } } scroll-event(event) => { accept } } VerticalLayout { padding: 10px; spacing: 8px; VideoSettingRow { 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 { text: root.video-settings-enabled ? root.video-settings-feedback : StreamStrings.video-settings-unavailable; wrap: word-wrap; color: #8b8f95; font-size: 11px; } } } if root.display-menu-open: display-menu := Rectangle { private property expanded-height: min(180px, root.displays.length * 30px + 8px); 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; 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; for display[index] in root.displays: Rectangle { height: 29px; background: index == display-menu.hovered-index && display-touch.pressed ? #bfd4f2 : index == display-menu.hovered-index ? #d8e6f8 : index == root.selected-display ? #e8eef9 : transparent; border-radius: UiRadius.control; Text { x: 8px; width: parent.width - 34px; text: display; color: #30343b; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } if index == root.selected-display: Text { x: parent.width - 24px; width: 16px; text: FontAwesomeIcons.check; color: #2463c7; font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; } } } // Use one hit target for the whole popup. A single owner keeps // 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; } } 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 && index >= 0 && index < root.displays.length { display-menu-open-timer.running = false; root.display-menu-animation-enabled = false; root.display-menu-progress = 0.0; root.selected-display = index; root.switch-display(index); root.display-menu-open = false; } } scroll-event(event) => { accept } } } 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; 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; } } clicked => { root.send-shortcut(shortcut); root.shortcut-menu-open = false; } } } } } // Measure unwrapped text so short hints stay compact. Render outside // the clipped control bar, capping longer hints before wrapping. control-tooltip-width-probe := Text { visible: false; text: root.hovered-control.text; font-size: ImGuiFontStyle.body; } if root.hovered-control.text != "" && root.control-tooltip-visible: Rectangle { width: min(240px, video.width - 8px, control-tooltip-width-probe.preferred-width + 16px); x: min(max(4px, control.x + root.hovered-control.anchor-x - self.width / 2), video.width - self.width - 4px); height: control-tip.preferred-height + 12px; // Anchor to the button row so expanded stats stay beneath the tooltip. y: control.y + root.control-toolbar-height + self.height + 8px <= video.height ? control.y + root.control-toolbar-height + 4px : max(4px, control.y - self.height - 4px); background: #30343bf5; border-radius: UiRadius.control; z: 12; control-tip := Text { x: 8px; y: 6px; width: parent.width - 16px; text: root.hovered-control.text; color: white; font-size: ImGuiFontStyle.body; wrap: word-wrap; } } if root.remote-update-visible: Rectangle { width: parent.width; height: parent.height; background: #0c162c52; z: 13; TouchArea { mouse-cursor: default; scroll-event(event) => { accept } } } remote-update-banner := DialogSurface { visible: root.remote-update-visible; x: (parent.width - self.width) / 2; y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px)); width: min(520px, parent.width - 48px); height: max(208px, remote-update-message.y + remote-update-message.height + 92px); border-color: #e5e9f0; border-radius: UiRadius.surface; drop-shadow-color: #07142b30; drop-shadow-blur: 32px; drop-shadow-offset-y: 12px; 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 } } Rectangle { x: 32px; y: 32px; width: 48px; height: 48px; background: #edf4ff; border-radius: UiRadius.surface; Path { x: 12px; y: 12px; width: 24px; height: 24px; viewbox-width: 24; viewbox-height: 24; commands: "M 12 16 L 12 3 M 6.5 8.5 L 12 3 L 17.5 8.5 M 4 15 L 4 20 Q 4 21 5 21 L 19 21 Q 20 21 20 20 L 20 15"; fill: transparent; stroke: #2463c7; stroke-width: 1.8px; } } remote-update-title := Text { x: 100px; y: 34px; width: parent.width - 144px; height: max(28px, self.preferred-height); text: StreamStrings.remote-update-title; color: #1b2433; font-size: 20px; font-weight: 600; wrap: word-wrap; } remote-update-message := Text { x: remote-update-title.x; y: remote-update-title.y + remote-update-title.height + 8px; width: remote-update-title.width; height: max(22px, self.preferred-height); text: StreamStrings.remote-update; color: #667085; font-size: 15px; wrap: word-wrap; } IconButton { x: parent.width - 44px; y: 12px; width: 32px; height: 32px; icon: FontAwesomeIcons.xmark; icon-size: 14px; icon-color: #8a93a3; hover-background: #f2f4f7; clicked => { root.dismiss-remote-update(); } } PrimaryButton { x: parent.width - 136px; y: parent.height - 68px; width: 104px; height: 36px; border-radius: UiRadius.control; text: StreamStrings.remote-update-dismiss; text-size: 14px; clicked => { root.dismiss-remote-update(); } } } // Transfer progress overlay at bottom-left. if root.file-transfer-visible: Rectangle { x: root.file-panel-x; y: root.file-panel-y; width: root.file-panel-width; height: root.file-panel-height; 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; } } 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(); } } ScrollView { x: 10px; y: 32px; width: parent.width - 20px; height: parent.height - 42px; VerticalLayout { // Slint's ScrollView draws its vertical scrollbar over // the viewport. Reserve a gutter so transfer content // never extends underneath it. padding-right: 16px; spacing: 8px; for entry in root.file-transfers: VerticalLayout { spacing: 3px; HorizontalLayout { height: 18px; Text { text: entry.name; color: #30343b; font-size: ImGuiFontStyle.body; overflow: elide; } Text { width: 80px; text: entry.status; color: #5c6470; font-size: ImGuiFontStyle.body; horizontal-alignment: right; } } Rectangle { height: 7px; background: #dfe3e8; border-radius: 3px; Rectangle { x: 0px; y: 0px; width: parent.width * max(0, min(1, entry.progress)); height: parent.height; background: #2463c7; border-radius: 3px; } } HorizontalLayout { height: 14px; Text { text: round(entry.progress * 100) + "% " + entry.speed; color: #6a717c; font-size: ImGuiFontStyle.small; } Text { text: entry.size; color: #6a717c; font-size: ImGuiFontStyle.small; horizontal-alignment: right; } } } } } } } } if root.custom-titlebar && !root.fullscreen-enabled: Rectangle { width: parent.width; height: parent.height; background: transparent; border-width: 1px; border-color: #aaa8a6; border-radius: root.window-maximized ? 0px : root.window-corner-radius; z: 200; } } }