Files
crossdesk/apps/desktop/src/gui/ui/stream_window.slint
T

1855 lines
91 KiB
Plaintext

import { UiRadius } from "ui_radius.slint";
import { ComboBox, ScrollView } from "std-widgets.slint";
import { RemoteCursor } from "remote_cursor.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } 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 <string> remote-update-title: "Remote update recommended";
in-out property <string> remote-update: "Update the remote device to the latest version for improved features and performance.";
in-out property <string> remote-update-dismiss: "Got it";
in-out property <string> video-settings: "Video settings";
in-out property <string> video-settings-unavailable: "The remote device does not support live video settings";
in-out property <string> video-quality: "Video quality";
in-out property <string> frame-rate: "Capture frame rate";
in-out property <string> adaptation-policy: "Video preference";
in-out property <string> quality-low: "Low";
in-out property <string> quality-medium: "Medium";
in-out property <string> quality-high: "High";
in-out property <string> priority-frame-rate: "Frame rate";
in-out property <string> priority-quality: "Quality";
in-out property <string> priority-balanced: "Balanced";
in-out property <string> select-display: "Select Display";
in-out property <string> send-shortcut: "Send Shortcut";
in-out property <string> privacy-enable: "Enable privacy screen";
in-out property <string> privacy-disable: "Turn privacy off / recover";
in-out property <string> control-mouse: "Control Mouse";
in-out property <string> release-mouse: "Release Mouse";
in-out property <string> audio: "Audio Capture";
in-out property <string> mute: "Mute";
in-out property <string> select-file: "Select File to Send";
in-out property <string> show-stats: "Show Net Traffic Stats";
in-out property <string> hide-stats: "Hide Net Traffic Stats";
in-out property <string> fullscreen: "Fullscreen";
in-out property <string> exit-fullscreen: "Exit fullscreen";
in-out property <string> disconnect: "Disconnect";
in-out property <string> confirm-disconnect: "Disconnect this session?";
in-out property <string> disconnect-description: "The current remote session will end.";
in-out property <string> cancel: "Cancel";
in-out property <string> file-transfer: "File Transfer Progress";
in-out property <string> expand-control: "Expand Control Bar";
in-out property <string> collapse-control: "Collapse Control Bar";
in-out property <string> stats-in: "In";
in-out property <string> stats-out: "Out";
in-out property <string> stats-loss-rate: "Loss Rate";
in-out property <string> stats-resolution: "Res";
in-out property <string> stats-video-latency: "Video delay";
in-out property <string> stats-connection-latency: "Connection RTT";
in-out property <string> stats-connection-mode: "Mode";
in-out property <string> stats-encoder: "Remote encoder";
in-out property <string> stats-decoder: "Local decoder";
in-out property <string> stats-encryption: "Encryption";
in-out property <string> stats-encryption-enabled: "Enabled";
in-out property <string> stats-encryption-disabled: "Disabled";
}
struct ControlBarTooltip {
text: string,
anchor-x: length,
}
// 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 <string> icon;
in property <bool> show-logo: false;
in property <bool> logo-pressed: false;
in property <bool> logo-dragging: false;
in property <float> reveal-progress: 1;
in property <string> badge: "";
in property <string> tooltip: "";
in property <bool> selected: false;
in property <bool> danger: false;
in property <bool> slashed: false;
in property <bool> action-enabled: true;
// Capability and temporary command availability have different visuals.
in property <bool> muted: false;
in property <length> icon-size: 16px;
out property <bool> has-hover: touch.has-hover;
out property <ControlBarTooltip> tooltip-info: {
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);
private property <bool> button-hovered:
!root.show-logo && root.action-enabled && !root.muted && touch.has-hover;
private property <bool> 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 <color> 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 <color> 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 <float> 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.button-foreground;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: root.icon-size;
horizontal-alignment: center;
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
// 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: root.button-foreground;
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;
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: 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: root.button-fill;
stroke-width: 2px;
}
touch := TouchArea {
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(); } }
}
}
component VideoSettingRow inherits VerticalLayout {
in property <string> label;
in property <[string]> options;
in property <int> selected;
in property <bool> enabled;
callback choose(int);
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 {
mouse-cursor: root.enabled ? pointer : default;
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 <bool> custom-titlebar: false;
in property <bool> 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 <length> video-area-top: content-layer.y + video.y;
out property <length> video-area-width: video.width;
out property <length> video-area-height: video.height;
in property <bool> window-active: true;
in property <bool> window-maximized: false;
in property <[StreamTab]> tabs;
in-out property <int> selected-tab: 0;
in property <image> frame;
in property <bool> has-frame: false;
in property <string> status-text: "";
in property <string> receiving-text: "";
in property <bool> remote-update-visible: false;
callback dismiss-remote-update;
in property <[string]> displays;
in-out property <int> selected-display: 0;
in property <bool> mouse-control-enabled: true;
in property <bool> privacy-can-toggle: false;
in property <bool> privacy-active: false;
in property <bool> privacy-off: false;
in property <bool> privacy-unsupported: false;
// Privacy feedback stays on its toolbar button and hover tooltip.
in property <string> privacy-status-text: "Privacy screen";
// Numeric values mirror crossdesk::RemoteCursorShape and Slint's MouseCursor.
in property <bool> remote-cursor-active: false;
in property <int> remote-cursor-shape: 0;
in property <bool> audio-enabled: true;
in property <bool> video-settings-enabled: false;
in property <string> video-settings-feedback: "";
in-out property <int> video-quality-index: 2;
in-out property <int> frame-rate-index: 1;
in-out property <int> adaptation-policy-index: 1;
callback change-video-settings(int, int, int);
in property <bool> fullscreen-enabled: false;
in property <bool> stats-visible: false;
in property <[NetworkStatsRow]> stats-rows;
in property <string> stats-fps: "0";
in property <string> stats-resolution: "";
in property <string> stats-video-latency: "—";
in property <string> stats-connection-latency: "—";
in property <string> stats-connection-mode: "";
in property <string> stats-encoder: "—";
in property <string> stats-decoder: "—";
in property <bool> stats-encryption-enabled: false;
in property <bool> 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 <bool> 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 <length> shortcut-menu-height: root.shortcut-items.length * 30px + 16px;
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 <bool> control-expanded: false;
private property <bool> control-visual-expanded: false;
private property <length> control-toolbar-height: 38px;
private property <length> control-button-step: 32px;
private property <length> control-disconnect-gap: 8px;
private property <length> control-buttons-x: root.control-docked-left ? 43px : 7px;
private property <length> control-panel-gap: 8px;
private property <float> control-reveal-progress: 0;
private property <float> control-surface-progress: min(1, root.control-reveal-progress / 0.65);
private property <float> control-content-progress: max(0, (root.control-reveal-progress - 0.65) / 0.35);
private property <bool> control-content-visible:
root.control-visual-expanded && root.control-content-progress > 0;
private property <length> control-surface-width: root.control-toolbar-height
+ (control.width - root.control-toolbar-height) * root.control-surface-progress;
private property <length> control-surface-x: control.x
+ (root.control-docked-left ? 0px : control.width - root.control-surface-width);
private property <duration> control-collapse-duration: 180ms;
private property <bool> control-animation-enabled: false;
private property <length> stats-row-height: 18px;
private property <length> stats-header-height: 18px;
private property <length> stats-content-height:
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 114px;
private property <length> stats-panel-height:
min(root.stats-content-height + 16px, max(0px, video.height - root.control-toolbar-height - root.control-panel-gap));
private property <length> stats-panel-y: root.control-panel-y(root.stats-panel-height);
private property <length> file-panel-x: 18px;
private property <length> file-panel-y: video.height - min(210px, video.height * 0.35);
private property <length> file-panel-width: min(430px, video.width * 0.6);
private property <length> file-panel-height: min(190px, video.height * 0.32);
private property <bool> display-menu-open: false;
private property <bool> display-menu-animation-enabled: false;
private property <float> display-menu-progress: 0.0;
private property <length> display-menu-height: min(180px, root.displays.length * 30px + 16px);
private property <length> display-menu-y: root.control-panel-y(root.display-menu-height);
private property <bool> shortcut-menu-open: false;
private property <length> shortcut-menu-y: root.control-panel-y(root.shortcut-menu-height);
in-out property <bool> video-menu-open: false;
private property <length> video-menu-width: min(320px, video.width - 8px);
private property <length> video-menu-height: root.video-settings-enabled && root.video-settings-feedback == "" ? 182px : 220px;
private property <length> video-menu-x: min(max(4px, control.x + video-button.x), video.width - root.video-menu-width - 4px);
private property <length> video-menu-y: root.control-panel-y(root.video-menu-height);
private property <bool> disconnect-menu-open: false;
private property <length> disconnect-menu-width: min(220px, video.width - 8px);
private property <length> disconnect-menu-height:
disconnect-actions.y + disconnect-actions.height + 12px;
private property <length> 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 <length> disconnect-menu-y: root.control-panel-y(root.disconnect-menu-height);
private property <bool> control-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 => {
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();
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.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
// 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 <bool> control-tooltip-visible: false;
private property <ControlBarTooltip> hovered-control: {
if root.window-active && !root.control-dragging
&& !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; }
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 };
}
// 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 <bool> remote-left-down: false;
private property <bool> remote-right-down: false;
private property <bool> remote-middle-down: false;
private property <bool> remote-pointer-active:
root.remote-left-down || root.remote-right-down || root.remote-middle-down;
private property <bool> control-drag-pending: false;
private property <length> control-drag-start-x: 0px;
private property <length> control-drag-start-y: 0px;
private property <bool> control-docked-left: true;
private property <bool> control-dragging: false;
private property <length> 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 <length> control-pos-y: 0px;
private property <length> control-press-x: 0px;
private property <length> control-press-y: 0px;
private property <int> dragging-tab: -1;
private property <length> titlebar-height:
root.custom-titlebar && !root.fullscreen-enabled ? 32px : 0px;
private property <length> 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;
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;
root.display-menu-progress = 0.0;
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
// 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 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
|| 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;
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 >= 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 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 >= root.display-menu-y
&& pointer-y <= root.display-menu-y + root.display-menu-height;
let shortcut-menu-x = control.x + shortcut-button.x;
let over-shortcut-menu = root.shortcut-menu-open
&& pointer-x >= shortcut-menu-x
&& pointer-x <= shortcut-menu-x + 180px
&& 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-stats || over-display-menu || over-shortcut-menu
|| over-video-menu || over-disconnect-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;
root.close-control-menus();
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 <int> 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) => {
root.route-remote-pointer(event.button, event.kind, self.mouse-x, self.mouse-y);
}
scroll-event(event) => {
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 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
: 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
}
}
// 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-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;
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 {
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;
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;
root.disconnect-menu-open = false;
if root.display-menu-open {
display-menu-open-timer.running = true;
}
}
}
shortcut-button := ControlBarButton {
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;
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;
root.disconnect-menu-open = false;
}
}
mouse-button := ControlBarButton {
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 {
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 {
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 {
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 {
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 {
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 {
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;
danger: true;
selected: root.disconnect-menu-open;
clicked => {
let open = !root.disconnect-menu-open;
root.close-control-menus();
root.disconnect-menu-open = open;
}
}
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;
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 {
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.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-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: 8px;
width: parent.width - 16px;
height: root.stats-content-height;
background: transparent;
private property <length> row-height: root.stats-row-height;
private property <length> label-column-width: 50px;
private property <length> loss-column-width:
max(56px, stats-loss-label.preferred-width + 12px);
private property <length> value-column-width:
(self.width - self.label-column-width - self.loss-column-width) / 2;
private property <length> 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 + 12px
+ (root.stats-encryption-enabled ? 14px : 0px));
private property <length> details-right-x:
min(self.width / 2 + 4px, self.width - self.details-right-width - 8px);
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.small;
font-weight: 600;
}
Rectangle {
x: 0px;
y: 0px;
width: parent.width;
height: root.stats-header-height;
background: #f5f7fa;
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 ? #edf4ff : transparent;
border-radius: UiRadius.control;
Text {
x: 4px;
width: stats-table.label-column-width - 8px;
text: row.label;
color: index == root.stats-rows.length - 1 ? #275188 : #667085;
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: index == root.stats-rows.length - 1 ? #275188 : #344054;
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: index == root.stats-rows.length - 1 ? #275188 : #344054;
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: index == root.stats-rows.length - 1 ? #275188 : #667085;
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;
}
}
codec-label-width-probe := Text {
visible: false;
text: StreamStrings.stats-encoder + "\n" + StreamStrings.stats-decoder;
font-size: ImGuiFontStyle.small;
}
Rectangle {
x: 0px;
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
+ 70px;
width: parent.width; height: 44px;
background: #f7f9fc; border-radius: UiRadius.control;
border-width: 1px; border-color: #e8edf4;
Rectangle {
x: 8px; y: 22px; width: parent.width - 16px; height: 1px;
background: #e8edf4;
}
for codec[index] in [
{ label: StreamStrings.stats-encoder, value: root.stats-encoder },
{ label: StreamStrings.stats-decoder, value: root.stats-decoder },
]: Rectangle {
x: 8px; y: 2px + index * 21px;
width: parent.width - 16px; height: 19px;
Text {
x: 0px; y: 0px;
width: codec-label-width-probe.preferred-width;
height: parent.height; text: codec.label;
color: #667085; font-size: ImGuiFontStyle.small;
vertical-alignment: center;
}
Text {
x: codec-label-width-probe.preferred-width + 8px; y: 0px;
width: parent.width - self.x; height: parent.height;
text: codec.value; color: #344054;
font-size: ImGuiFontStyle.body; font-weight: 500;
horizontal-alignment: right;
vertical-alignment: center; overflow: elide;
}
}
}
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 <length> encryption-icon-width:
index == 5 && root.stats-encryption-enabled ? 14px : 0px;
x: mod(index, 2) == 1 ? stats-table.details-right-x : 8px;
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
+ 4px + floor(index / 2) * 20px;
width: mod(index, 2) == 1 ? stats-table.width - stats-table.details-right-x - 8px
: stats-table.details-right-x - 18px;
height: 20px;
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;
}
value-width-probe := Text {
visible: false; text: metric.value;
font-size: index >= 4 ? ImGuiFontStyle.small : ImGuiFontStyle.body;
font-weight: 600;
}
metric-value := Rectangle {
private property <length> badge-padding: index >= 4 ? 6px : 0px;
private property <length> icon-width: parent.encryption-icon-width;
width: index >= 4
? min(max(0px, parent.width - metric-label.width - 6px),
value-width-probe.preferred-width + self.badge-padding * 2 + self.icon-width)
: max(0px, parent.width - metric-label.width - 6px);
x: parent.width - self.width; y: 2px; height: 16px;
border-radius: 4px;
background: index == 4 ? #edf3fc : index == 5
? (root.stats-encryption-enabled ? #eaf6ee : #fff3e3) : transparent;
Text {
x: parent.badge-padding; y: 0px; height: parent.height;
width: max(0px, parent.width - parent.badge-padding * 2 - parent.icon-width);
text: metric.value;
color: index == 4 ? #275188 : index == 5
? (root.stats-encryption-enabled ? #18794e : #946015) : #253247;
font-size: index >= 4 ? ImGuiFontStyle.small : ImGuiFontStyle.body;
font-weight: 600; horizontal-alignment: right;
vertical-alignment: center; overflow: elide;
}
if index == 5 && root.stats-encryption-enabled: Text {
x: parent.width - parent.badge-padding - 10px; y: 2px;
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;
}
}
}
}
}
// 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.up { root.close-control-menus(); }
}
scroll-event(event) => { accept }
}
}
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;
z: 11;
TouchArea {
mouse-cursor: default;
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;
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;
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;
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 := StreamControlSurface {
private property <length> expanded-height: root.display-menu-height;
private property <int> hovered-index:
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;
clip: true;
z: 11;
VerticalLayout {
padding: 8px; 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 {
mouse-cursor: default;
clicked => {
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;
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: 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: 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; }
shortcut-touch := TouchArea {
mouse-cursor: MouseCursor.pointer;
scroll-event(event) => { accept }
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(400px, parent.width - 48px);
height: remote-update-message.y + remote-update-message.height + 84px;
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;
scroll-event(event) => { accept }
}
remote-update-title := Text {
x: 24px; y: 24px; width: parent.width - 80px;
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: parent.width - 48px;
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 - 128px; y: parent.height - 60px;
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 {
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.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;
}
}
}