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

1184 lines
58 KiB
Plaintext

import { ComboBox, ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, 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> 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> 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-connection-mode: "Mode";
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,
}
// 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 <string> icon;
in property <string> badge: "";
in property <string> tooltip: "";
in property <bool> selected: 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.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: 3px;
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;
}
// Match the two offset diagonal strokes used by the ImGui mouse/audio
// disabled state instead of covering the glyph with a full-width slash.
if root.slashed: Rectangle {
x: 11px;
y: 0px;
width: 2px;
height: 24px;
background: black;
transform-rotation: -45deg;
}
if root.slashed: Rectangle {
x: 9.5px;
y: 1.5px;
width: 2px;
height: 24px;
background: touch.has-hover ? #4296fa : #b3d5fd;
transform-rotation: -45deg;
}
touch := TouchArea {
// Keep disabled controls hit-testable so clicks cannot reach the peer.
clicked => { if root.action-enabled { root.clicked(); } }
}
}
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;
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 <[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> 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-connection-mode: "";
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);
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: true;
private property <length> control-toolbar-height: 38px;
private property <length> control-button-step: 32px;
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 + 44px;
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 <bool> shortcut-menu-open: false;
private property <bool> control-tooltip-visible: false;
private property <ControlBarTooltip> hovered-control: {
if root.window-active && !root.control-dragging
&& !root.display-menu-open && !root.shortcut-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 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 <bool> suppress-next-remote-pointer-release: false;
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;
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) => {
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 + control.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 + 68px;
return over-control || over-display-menu || over-shortcut-menu;
}
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.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.can-drop-file(event.data) ? DragAction.copy : DragAction.none;
}
dropped(event) => {
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) => {
root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
accept
}
key-released(event) => {
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 ? MouseCursor.default
: root.remote-cursor-shape == 1 ? MouseCursor.none
: root.remote-cursor-shape == 2 ? MouseCursor.help
: root.remote-cursor-shape == 3 ? MouseCursor.pointer
: root.remote-cursor-shape == 4 ? MouseCursor.progress
: root.remote-cursor-shape == 5 ? MouseCursor.wait
: root.remote-cursor-shape == 6 ? MouseCursor.crosshair
: root.remote-cursor-shape == 7 ? MouseCursor.text
: root.remote-cursor-shape == 8 ? MouseCursor.alias
: root.remote-cursor-shape == 9 ? MouseCursor.copy
: root.remote-cursor-shape == 10 ? MouseCursor.move
: root.remote-cursor-shape == 11 ? MouseCursor.no-drop
: root.remote-cursor-shape == 12 ? MouseCursor.not-allowed
: root.remote-cursor-shape == 13 ? MouseCursor.grab
: root.remote-cursor-shape == 14 ? MouseCursor.grabbing
: root.remote-cursor-shape == 15 ? MouseCursor.col-resize
: root.remote-cursor-shape == 16 ? MouseCursor.row-resize
: root.remote-cursor-shape == 17 ? MouseCursor.n-resize
: root.remote-cursor-shape == 18 ? MouseCursor.e-resize
: root.remote-cursor-shape == 19 ? MouseCursor.s-resize
: root.remote-cursor-shape == 20 ? MouseCursor.w-resize
: root.remote-cursor-shape == 21 ? MouseCursor.ne-resize
: root.remote-cursor-shape == 22 ? MouseCursor.nw-resize
: root.remote-cursor-shape == 23 ? MouseCursor.se-resize
: root.remote-cursor-shape == 24 ? MouseCursor.sw-resize
: root.remote-cursor-shape == 25 ? MouseCursor.ew-resize
: root.remote-cursor-shape == 26 ? MouseCursor.ns-resize
: root.remote-cursor-shape == 27 ? MouseCursor.nesw-resize
: root.remote-cursor-shape == 28 ? MouseCursor.nwse-resize
: 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();
}
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 + 8 * 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: 6px;
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;
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;
}
}
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(); }
}
stats-button := ControlBarButton {
visible: root.control-expanded;
x: (root.control-docked-left ? 9px : 42px) + 6 * 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) + 7 * 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) + 8 * 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 <length> row-height: root.stats-row-height;
private property <length> label-column-width: 50px;
private property <length> loss-column-width:
max((self.width - self.label-column-width) / 3,
stats-loss-label.preferred-width + 8px);
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,
encryption-width-probe.preferred-width
+ (root.stats-encryption-enabled ? 14px : 0px));
private property <length> details-right-x: self.width / 2;
left-label-width-probe := Text {
visible: false;
text: "FPS\n" + StreamStrings.stats-connection-mode;
font-size: ImGuiFontStyle.small;
}
right-label-width-probe := Text {
visible: false;
text: StreamStrings.stats-resolution + "\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;
}
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: 4px;
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: 4px;
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-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 == 3 && 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 == 3 && root.stats-encryption-enabled ? #18794e : #253247;
font-size: ImGuiFontStyle.body;
font-weight: 600;
horizontal-alignment: left;
vertical-alignment: center;
overflow: elide;
}
if index == 3 && 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;
}
}
}
}
if root.display-menu-open: display-menu := Rectangle {
private property <length> expanded-height: min(180px, root.displays.length * 30px + 8px);
private property <int> 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);
// Overlap the control border by one pixel so the menu reads as
// an attached panel growing downward from the display button.
y: control.y + control.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: 5px;
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: 3px;
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: 68px;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11;
VerticalLayout {
padding: 4px; spacing: 1px;
for shortcut in ["Ctrl+Alt+Del", "Win+L"]: 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: 5px; 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;
}
}
// Transfer progress overlay at bottom-left.
if root.file-transfer-visible: Rectangle {
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
background: #fffffff0; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 6px; z: 7;
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;
}
}
}