refactor: rebuild desktop client with Slint

This commit is contained in:
dijunkun
2026-07-21 16:38:51 +08:00
parent eeb6a2a1ae
commit 59f77c2820
79 changed files with 7373 additions and 1009 deletions
+458
View File
@@ -0,0 +1,458 @@
// The legacy client uses ImGui::StyleColorsLight(). Keep the structural line
// colors in one place so the Slint renderer produces the same visual weight.
export global ImGuiLineStyle {
out property <color> border: #0000004d;
out property <color> separator: #6363639e;
out property <color> divider: #0000007a;
out property <color> control-separator: #b2b2b2;
}
// ImGui loads a 32px font atlas and applies per-window font scales. These
// logical sizes reproduce those visual tiers on Slint's HiDPI renderer.
export global ImGuiFontStyle {
out property <length> base: 12px;
out property <length> section-title: 18px;
out property <length> panel-label: 17px;
out property <length> field-value: 22px;
out property <length> prominent: 16px;
out property <length> body: 11px;
out property <length> small: 10px;
}
// Font Awesome 6 Free Solid glyphs used by the legacy ImGui client. The font
// data is registered for every Slint window by GuiApplication before show().
export global FontAwesomeIcons {
out property <string> angle-down: "\u{f107}";
out property <string> angle-left: "\u{f104}";
out property <string> angle-right: "\u{f105}";
out property <string> angle-up: "\u{f106}";
out property <string> arrow-right-long: "\u{f178}";
out property <string> bars: "\u{f0c9}";
out property <string> check: "\u{f00c}";
out property <string> circle-arrow-up: "\u{f0aa}";
out property <string> compress: "\u{f066}";
out property <string> computer-mouse: "\u{f8cc}";
out property <string> copy: "\u{f0c5}";
out property <string> display: "\u{e163}";
out property <string> expand: "\u{f065}";
out property <string> eye: "\u{f06e}";
out property <string> eye-slash: "\u{f070}";
out property <string> folder-open: "\u{f07c}";
out property <string> keyboard: "\u{f11c}";
out property <string> minus: "\u{f068}";
out property <string> pen: "\u{f304}";
out property <string> signal: "\u{f012}";
out property <string> shield-halved: "\u{f3ed}";
out property <string> square-full: "\u{f45c}";
out property <string> trash-can: "\u{f2ed}";
out property <string> volume-high: "\u{f028}";
out property <string> volume-xmark: "\u{f6a9}";
out property <string> xmark: "\u{f00d}";
}
export component IconButton inherits Rectangle {
in property <string> icon;
in property <string> tooltip;
in property <bool> danger: false;
in property <length> icon-size: 16px;
in property <color> icon-color: #24272d;
in property <color> normal-background: transparent;
in property <color> hover-background: #edf0f5;
in property <color> pressed-background: #d9dde5;
in property <bool> slashed: false;
callback clicked;
width: 30px;
height: 30px;
background: touch.pressed ? (danger ? #d92d20 : root.pressed-background)
: touch.has-hover ? (danger ? #f04438 : root.hover-background)
: root.normal-background;
border-radius: 4px;
Text {
text: root.icon;
color: touch.has-hover && root.danger ? white : root.icon-color;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: root.icon-size;
horizontal-alignment: center;
vertical-alignment: center;
}
if root.slashed: Text {
text: "";
color: touch.has-hover && root.danger ? white : root.icon-color;
font-size: root.icon-size + 7px;
font-weight: 700;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
clicked => { root.clicked(); }
}
if touch.has-hover && root.tooltip != "": Rectangle {
x: min(root.width - self.width, 0px);
y: root.height + 3px;
width: tip.preferred-width + 12px;
height: 24px;
background: #22252be8;
border-radius: 4px;
z: 20;
tip := Text {
text: root.tooltip;
color: white;
font-size: ImGuiFontStyle.small;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// Retained for non-font artwork. UI action icons use IconButton so the Slint
// and ImGui clients share Font Awesome's glyph geometry.
export component ImageButton inherits Rectangle {
in property <image> icon;
in property <string> tooltip;
in property <bool> danger: false;
in property <length> icon-width: 16px;
in property <length> icon-height: 16px;
callback clicked;
width: 30px;
height: 30px;
background: touch.pressed ? (danger ? #d92d20 : #d9dde5)
: touch.has-hover ? (danger ? #f04438 : #edf0f5)
: transparent;
border-radius: 4px;
Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: root.icon-width;
height: root.icon-height;
source: root.icon;
image-fit: contain;
}
touch := TouchArea {
clicked => { root.clicked(); }
}
if touch.has-hover && root.tooltip != "": Rectangle {
x: min(root.width - self.width, 0px);
y: root.height + 3px;
width: tip.preferred-width + 12px;
height: 24px;
background: #22252be8;
border-radius: 4px;
z: 20;
tip := Text {
text: root.tooltip;
color: white;
font-size: ImGuiFontStyle.small;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
export component CompactComboBox inherits Rectangle {
in property <[string]> model;
in-out property <int> current-index: 0;
in property <bool> enabled: true;
property <bool> popup-animation-enabled: false;
property <float> popup-progress: 0.0;
callback selected(int);
animate popup-progress {
duration: 160ms;
easing: ease-out;
enabled: root.popup-animation-enabled;
}
width: 73px;
height: 24px;
background: root.enabled ? white : #f1f1f1;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
Text {
x: 5px;
width: parent.width - 24px;
text: root.model[root.current-index];
color: root.enabled ? #202124 : #8b8f95;
font-size: ImGuiFontStyle.body;
overflow: elide;
vertical-alignment: center;
}
Rectangle {
x: parent.width - 21px;
width: 20px;
height: parent.height - 2px;
y: 1px;
background: root.enabled ? #9bc9f8 : #d7d9dc;
Text {
text: FontAwesomeIcons.angle-down;
color: #202124;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 10px;
horizontal-alignment: center;
vertical-alignment: center;
transform-rotation: combo-popup.is-open ? 180deg : 0deg;
animate transform-rotation {
duration: 140ms;
easing: ease-out;
enabled: combo-popup.is-open;
}
}
}
TouchArea {
enabled: root.enabled;
clicked => {
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
combo-popup.show();
popup-open-timer.running = true;
}
}
// Start on the next frame so the native popup first renders at zero
// height, making the downward expansion visible on every backend.
popup-open-timer := Timer {
interval: 16ms;
running: false;
triggered => {
self.running = false;
if combo-popup.is-open {
root.popup-animation-enabled = true;
root.popup-progress = 1.0;
}
}
}
combo-popup := PopupWindow {
x: 0px;
y: root.height;
width: root.width;
height: root.model.length * 23px;
close-policy: close-on-click-outside;
Rectangle {
// Keep the reveal anchored to the popup's top edge while the
// content settles downward into its final position.
y: (1.0 - root.popup-progress) * -8px;
width: parent.width;
height: parent.height * root.popup-progress;
opacity: root.popup-progress;
background: white;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
clip: true;
for choice[index] in root.model: Rectangle {
y: index * 23px;
width: parent.width;
height: 23px;
background: option-touch.has-hover ? #dbeafb : index == root.current-index ? #eef5fd : white;
Text { x: 5px; width: parent.width - 10px; text: choice; color: #202124; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
option-touch := TouchArea {
clicked => {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0.0;
root.current-index = index;
root.selected(index);
combo-popup.close();
}
}
}
Rectangle {
width: parent.width;
height: parent.height;
background: transparent;
border-width: 1px;
border-color: #bfc2c7;
border-radius: 3px;
z: 2;
}
}
}
}
export component ImGuiCheckBox inherits Rectangle {
in-out property <bool> checked;
in property <bool> enabled: true;
width: 21px;
height: 21px;
background: root.enabled ? white : #f1f1f1;
border-width: 1px;
border-color: check-touch.has-hover && root.enabled ? #8cbef2 : #b9bdc2;
border-radius: 4px;
if root.checked: Text {
text: FontAwesomeIcons.check;
color: #2e86de;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: 16px;
horizontal-alignment: center;
vertical-alignment: center;
}
check-touch := TouchArea {
enabled: root.enabled;
clicked => { root.checked = !root.checked; }
}
}
export component MiniToggleSwitch inherits Rectangle {
in-out property <bool> checked;
in property <bool> enabled: true;
callback toggled(bool);
width: 29px;
height: 16px;
background: !root.enabled ? (root.checked ? #6e9ddacc : #9a9a9acc)
: root.checked ? #2463c7 : #9a9a9a;
border-radius: 8px;
Rectangle {
x: root.checked ? parent.width - self.width - 2px : 2px;
y: 2px;
width: 12px;
height: 12px;
border-radius: 6px;
background: white;
}
TouchArea {
enabled: root.enabled;
clicked => { root.checked = !root.checked; root.toggled(root.checked); }
}
}
export component CompactButton inherits Rectangle {
in property <string> text;
in property <bool> primary: false;
in property <bool> enabled: true;
callback clicked;
width: 34px;
height: 24px;
background: !root.enabled ? #eceef0
: button-touch.pressed ? (root.primary ? #82b8f2 : #d8dadd)
: button-touch.has-hover ? (root.primary ? #b8d9fb : #f0f1f2)
: root.primary ? #a8d0fa : #e2e4e7;
border-width: 1px;
border-color: root.primary ? #9bc3ed : #c8cbd0;
border-radius: 3px;
Text {
text: root.text;
color: root.enabled ? #24272d : #8e9298;
font-size: ImGuiFontStyle.body;
overflow: elide;
horizontal-alignment: center;
vertical-alignment: center;
}
button-touch := TouchArea { enabled: root.enabled; clicked => { root.clicked(); } }
}
export component PrimaryButton inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
callback clicked;
min-width: 72px;
height: 30px;
background: !root.enabled ? #b8c1d1
: touch.pressed ? #1849a9
: touch.has-hover ? #2970d6
: #2463c7;
border-radius: 5px;
Text {
text: root.text;
color: white;
font-size: ImGuiFontStyle.body;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
enabled: root.enabled;
clicked => { root.clicked(); }
}
}
export component SecondaryButton inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
callback clicked;
min-width: 72px;
height: 30px;
background: !root.enabled ? #f1f2f4
: touch.pressed ? #dfe3e9
: touch.has-hover ? #f3f4f6 : white;
border-width: 1px;
border-color: #c9ced7;
border-radius: 5px;
clip: true;
Text {
text: root.text;
color: root.enabled ? #24272d : #949aa5;
font-size: ImGuiFontStyle.body;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
enabled: root.enabled;
clicked => { root.clicked(); }
}
}
export component ToggleSwitch inherits Rectangle {
in-out property <bool> checked;
in property <bool> enabled: true;
callback toggled(bool);
width: 36px;
height: 20px;
background: !root.enabled ? #d0d5dd : root.checked ? #2463c7 : #98a2b3;
border-radius: self.height / 2;
Rectangle {
x: root.checked ? parent.width - self.width - 3px : 3px;
y: 3px;
width: 14px;
height: 14px;
border-radius: 7px;
background: white;
animate x { duration: 100ms; easing: ease-in-out; }
}
TouchArea {
enabled: root.enabled;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
}
export component SectionTitle inherits Text {
color: #00000080;
font-size: ImGuiFontStyle.section-title;
font-weight: 500;
vertical-alignment: center;
}
export component DialogSurface inherits Rectangle {
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
border-radius: 8px;
}
+5
View File
@@ -0,0 +1,5 @@
// Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows.
export { MainWindow, RecentConnection, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ControllerEntry } from "server_window.slint";
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M2 9h13m-5-5 5 5-5 5" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 189 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 22"><path d="M3 11h25M20 3l8 8-8 8" fill="none" stroke="#050505" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 195 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m2 8 3.2 3.2L14 2.8" fill="none" stroke="#2389e8" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 193 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 8"><path d="M1 2l4 4 4-4" fill="none" stroke="#111" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 182 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 14"><path d="m8 1-6 6 6 6" fill="none" stroke="#202124" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 186 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 14"><path d="m2 1 6 6-6 6" fill="none" stroke="#202124" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 186 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 9"><path d="m1 7 5-5 5 5" fill="none" stroke="#202124" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 185 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" fill="none" stroke="#fff" stroke-width="1.7"/></svg>

After

Width:  |  Height:  |  Size: 144 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" fill="none" stroke="#000000" stroke-width="1"/></svg>

After

Width:  |  Height:  |  Size: 145 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M7 7H2m5 0V2m4 5h5m-5 0V2M7 11H2m5 0v5m4-5h5m-5 0v5" fill="none" stroke="#202124" stroke-width="1.5"/></svg>

After

Width:  |  Height:  |  Size: 178 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><rect x="6" y="3" width="9" height="11" rx="1.2" fill="#111"/><rect x="3" y="6" width="9" height="10" rx="1.2" fill="#111" stroke="#eff0f2" stroke-width="1.4"/></svg>

After

Width:  |  Height:  |  Size: 227 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M7 2H2v5M11 2h5v5M7 16H2v-5m9 5h5v-5" fill="none" stroke="#202124" stroke-width="1.5"/><path d="M2 2l5 5m9-5-5 5M2 16l5-5m9 5-5-5" stroke="#202124" stroke-width="1.3"/></svg>

After

Width:  |  Height:  |  Size: 244 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M1.5 9s2.7-4.5 7.5-4.5S16.5 9 16.5 9 13.8 13.5 9 13.5 1.5 9 1.5 9Z" fill="none" stroke="#111" stroke-width="1.6"/><path d="M2 2l14 14" stroke="#111" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 243 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M1.5 9s2.7-4.5 7.5-4.5S16.5 9 16.5 9 13.8 13.5 9 13.5 1.5 9 1.5 9Z" fill="none" stroke="#111" stroke-width="1.8"/><circle cx="9" cy="9" r="2.4" fill="#111"/></svg>

After

Width:  |  Height:  |  Size: 233 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M4 1h7l3 3v13H4V1Z" fill="none" stroke="#202124" stroke-width="1.4"/><path d="M11 1v4h4M9 14V8m-3 3 3-3 3 3" fill="none" stroke="#202124" stroke-width="1.4"/></svg>

After

Width:  |  Height:  |  Size: 234 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 16"><rect x="1" y="3" width="16" height="11" rx="1.5" fill="none" stroke="#202124" stroke-width="1.4"/><path d="M4 6h1m2 0h1m2 0h1m2 0h1M4 9h1m2 0h1m2 0h1m2 0h1M5 12h8" stroke="#202124" stroke-width="1.2" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 292 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" fill="none" stroke="#000000" stroke-width="1"/></svg>

After

Width:  |  Height:  |  Size: 155 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 4h12M2 8h12M2 12h12" fill="none" stroke="#000000" stroke-width="1" stroke-linecap="square"/></svg>

After

Width:  |  Height:  |  Size: 171 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 8h12" fill="none" stroke="#000000" stroke-width="1"/></svg>

After

Width:  |  Height:  |  Size: 132 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><rect x="2" y="3" width="14" height="10" rx="1" fill="none" stroke="#202124" stroke-width="1.5"/><path d="M6 16h6M9 13v3" stroke="#202124" stroke-width="1.5"/></svg>

After

Width:  |  Height:  |  Size: 226 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><rect x="4" y="1" width="10" height="16" rx="5" fill="none" stroke="#202124" stroke-width="1.4"/><path d="M2 2l14 14" stroke="#202124" stroke-width="1.7"/></svg>

After

Width:  |  Height:  |  Size: 222 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 18"><rect x="3" y="1" width="10" height="16" rx="5" fill="none" stroke="#202124" stroke-width="1.5"/><path d="M8 2v5" stroke="#202124" stroke-width="1.5"/></svg>

After

Width:  |  Height:  |  Size: 218 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M3 12.8 11.9 4l2.2 2.2-8.8 8.9-3.1.7.8-3Zm9.7-9.6 1-1a1.4 1.4 0 0 1 2 0l.2.2a1.4 1.4 0 0 1 0 2l-1 1-2.2-2.2Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 199 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M3 12.8 11.9 4l2.2 2.2-8.8 8.9-3.1.7.8-3Z" fill="#111"/><path d="m12.7 3.2 1-1a1.4 1.4 0 0 1 2 0l.2.2a1.4 1.4 0 0 1 0 2l-1 1-2.2-2.2Z" fill="#111"/></svg>

After

Width:  |  Height:  |  Size: 224 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M2 16V9h3v7H2Zm5 0V5h3v11H7Zm5 0V2h3v14h-3Z" fill="#202124"/></svg>

After

Width:  |  Height:  |  Size: 137 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M5 5h8l-.6 10H5.6L5 5Zm2-2h4l1 1H6l1-1ZM4 4h10v1H4V4Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 144 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M5 5h8l-.6 10H5.6L5 5Zm2-2h4l1 1H6l1-1ZM4 4h10v1H4V4Z" fill="#111"/></svg>

After

Width:  |  Height:  |  Size: 144 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M2 7h3l4-4v12l-4-4H2V7Z" fill="#202124"/><path d="M12 6l4 6m0-6-4 6" stroke="#202124" stroke-width="1.5"/></svg>

After

Width:  |  Height:  |  Size: 182 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><path d="M2 7h3l4-4v12l-4-4H2V7Z" fill="#202124"/><path d="M12 6a4 4 0 0 1 0 6m2-8a7 7 0 0 1 0 10" fill="none" stroke="#202124" stroke-width="1.4" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 238 B

File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
import { CompactComboBox, FontAwesomeIcons, IconButton, SecondaryButton, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
export struct ControllerEntry {
remote-id: string,
display-name: string,
}
export component ServerWindow inherits Window {
title: "CrossDesk";
preferred-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px;
preferred-height: 150px;
min-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px;
max-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px;
no-frame: true;
always-on-top: true;
background: transparent;
default-font-size: ImGuiFontStyle.base;
in property <[ControllerEntry]> controllers;
in property <[string]> controller-names;
in property <int> language-index: 0;
in-out property <int> selected-controller: 0;
in property <string> connection-label: "Connection status";
in property <string> connection-status: "";
in property <string> controller-label: "Controller";
in property <string> file-transfer-label: "File transfer";
in property <string> select-file-label: "Select file";
in property <bool> sending-file: false;
in property <bool> file-transfer-visible: false;
in property <float> file-progress: 0;
in property <string> file-progress-text: "";
in property <string> current-file-name: "";
in property <string> file-size-text: "";
callback title-drag(int, length, length);
callback toggle-collapsed(bool);
callback controller-selected(int);
callback select-file;
callback disconnect-controller;
private property <bool> collapsed: false;
private property <bool> transfer-hovered: false;
Rectangle {
width: root.width;
height: root.height;
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
border-radius: 8px;
clip: true;
Rectangle {
x: 0px;
y: 0px;
width: parent.width;
height: 30px;
background: #f8f9fb;
Rectangle { y: parent.height - 1px; height: 1px; background: ImGuiLineStyle.border; }
IconButton {
x: 0px;
y: 0px;
width: 30px; height: 30px;
icon: root.collapsed ? FontAwesomeIcons.angle-down : FontAwesomeIcons.angle-up;
icon-size: 12px;
clicked => { root.collapsed = !root.collapsed; root.toggle-collapsed(root.collapsed); }
}
drag := TouchArea {
x: 30px; width: parent.width - 30px;
property <int> phase: self.pressed ? 1 : 0;
changed phase => { root.title-drag(phase, self.mouse-x, self.mouse-y); }
moved => { if (self.pressed) { root.title-drag(2, self.mouse-x, self.mouse-y); } }
}
}
if !root.collapsed: Rectangle {
x: 12px; y: 38px; width: parent.width - 52px; height: 101px;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px;
VerticalLayout {
padding: 8px; spacing: 3px; alignment: start;
HorizontalLayout {
height: 24px; spacing: 5px;
Text { width: root.language-index == 0 ? 69px : 110px; text: root.controller-label; color: #434953; font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; }
CompactComboBox {
width: root.language-index == 0 ? 108px : root.language-index == 1 ? 147px : 247px;
model: root.controller-names;
current-index <=> root.selected-controller;
selected(index) => { root.controller-selected(index); }
}
}
Rectangle { height: 1px; background: ImGuiLineStyle.separator; }
HorizontalLayout {
height: 24px; spacing: 5px;
Text { width: root.language-index == 0 ? 69px : 110px; text: root.connection-label; color: #434953; font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; }
Text { text: root.connection-status; color: #2463c7; font-size: ImGuiFontStyle.body; horizontal-alignment: left; vertical-alignment: center; overflow: elide; }
}
Rectangle { height: 1px; background: ImGuiLineStyle.separator; }
Rectangle {
height: 24px;
HorizontalLayout {
y: 0px;
width: parent.width;
height: 24px;
spacing: 5px;
Text { width: root.language-index == 0 ? 69px : 110px; text: root.file-transfer-label; color: #434953; font-size: ImGuiFontStyle.body; vertical-alignment: center; overflow: elide; }
SecondaryButton { width: root.language-index == 0 ? 78px : root.language-index == 1 ? 140px : 205px; height: 24px; text: root.select-file-label; clicked => { root.select-file(); } }
Rectangle {
visible: root.file-transfer-visible;
width: 35px;
Text {
text: root.sending-file ? "↑ " + round(root.file-progress * 100) + "%" : "✓";
color: root.sending-file ? #2463c7 : #25874b;
font-size: ImGuiFontStyle.small;
horizontal-alignment: center;
vertical-alignment: center;
}
transfer-touch := TouchArea {
changed has-hover => { root.transfer-hovered = self.has-hover; }
}
}
}
}
}
}
if !root.collapsed: IconButton {
x: parent.width - 34px; y: 38px; width: 24px; height: 101px;
icon: FontAwesomeIcons.xmark;
icon-size: 13px;
icon-color: white;
normal-background: #ef2b2d;
hover-background: #d92d20;
pressed-background: #b42318;
border-radius: 5px;
clicked => { root.disconnect-controller(); }
}
if root.file-transfer-visible && root.transfer-hovered: Rectangle {
x: 15px; y: 67px; width: root.width - 30px; height: 70px;
background: #22252bf2;
border-radius: 5px;
z: 20;
VerticalLayout {
padding: 8px; spacing: 3px;
Text { text: root.current-file-name; color: white; font-size: ImGuiFontStyle.small; overflow: elide; }
Text { text: root.file-size-text; color: #e1e4e8; font-size: ImGuiFontStyle.small; }
Text { text: round(root.file-progress * 100) + "% " + root.file-progress-text; color: #e1e4e8; font-size: ImGuiFontStyle.small; }
}
}
}
}
+649
View File
@@ -0,0 +1,649 @@
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> 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";
}
// 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 <length> icon-size: 16px;
callback clicked;
width: 24px;
height: 24px;
background: touch.pressed ? #0f87fa
: touch.has-hover ? #4296fa
: root.selected ? #4296fa : #4296fa66;
border-radius: 3px;
Text {
text: root.icon;
color: #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 {
clicked => { root.clicked(); }
}
}
export component StreamWindow inherits Window {
title: "CrossDesk";
preferred-width: 1280px;
preferred-height: 720px;
min-width: 640px;
min-height: 360px;
no-frame: false;
background: black;
default-font-size: ImGuiFontStyle.base;
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> audio-enabled: true;
in property <bool> srtp-enabled: false;
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> 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-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 pointer-input(PointerEventButton, PointerEventKind, length, length);
callback scroll-input(length, length, length, length);
callback key-input(string, bool, bool, bool, bool, bool);
private property <bool> control-expanded: true;
private property <bool> display-menu-open: false;
private property <bool> shortcut-menu-open: 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;
callback begin-control-drag(length, length);
callback move-control-drag(length, length);
callback end-control-drag;
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.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;
}
}
Rectangle {
width: root.width;
height: root.height;
background: black;
// 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;
if root.srtp-enabled: Text {
x: 0px;
width: 12px;
height: parent.height;
text: FontAwesomeIcons.shield-halved;
color: #30343b;
font-family: "Font Awesome 6 Free";
font-weight: 900;
font-size: ImGuiFontStyle.base;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
x: root.srtp-enabled ? 16px : 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: black;
clip: true;
frame-image := Image {
width: parent.width;
height: parent.height;
source: root.frame;
image-fit: contain;
visible: root.has-frame;
}
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;
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 {
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;
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();
} else if !root.control-dragging {
root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y);
}
}
moved => {
if root.control-dragging {
root.move-control-drag(self.mouse-x, self.mouse-y);
}
}
scroll-event(event) => {
if self.mouse-x < control.x
|| self.mouse-x > control.x + control.width
|| self.mouse-y < control.y
|| self.mouse-y > control.y + control.height {
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(300px, parent.width) : 20px;
height: root.stats-visible && root.control-expanded ? min(180px, parent.height) : 38px;
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;
}
if root.control-expanded: display-button := ControlBarButton {
x: root.control-docked-left ? 9px : 42px; y: 7px;
icon: FontAwesomeIcons.display;
badge: root.selected-display + 1;
tooltip: StreamStrings.select-display;
clicked => { root.display-menu-open = !root.display-menu-open; root.shortcut-menu-open = false; }
}
if root.control-expanded: shortcut-button := ControlBarButton {
x: root.control-docked-left ? 41px : 74px; y: 7px;
icon: FontAwesomeIcons.keyboard; tooltip: StreamStrings.send-shortcut;
clicked => { root.shortcut-menu-open = !root.shortcut-menu-open; root.display-menu-open = false; }
}
if root.control-expanded: mouse-button := ControlBarButton {
x: root.control-docked-left ? 73px : 106px; 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(); }
}
if root.control-expanded: audio-button := ControlBarButton {
x: root.control-docked-left ? 105px : 138px; y: 7px;
icon: FontAwesomeIcons.volume-high; slashed: !root.audio-enabled;
tooltip: root.audio-enabled ? StreamStrings.mute : StreamStrings.audio;
clicked => { root.toggle-audio(); }
}
if root.control-expanded: file-button := ControlBarButton {
x: root.control-docked-left ? 137px : 170px; y: 7px;
icon: FontAwesomeIcons.folder-open; tooltip: StreamStrings.select-file;
clicked => { root.select-file(); }
}
if root.control-expanded: stats-button := ControlBarButton {
x: root.control-docked-left ? 169px : 202px; y: 7px;
icon: FontAwesomeIcons.signal; selected: root.stats-visible;
tooltip: root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-stats;
clicked => { root.toggle-stats(); }
}
if root.control-expanded: fullscreen-button := ControlBarButton {
x: root.control-docked-left ? 201px : 234px; y: 7px;
icon: root.fullscreen-enabled ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen;
clicked => { root.toggle-fullscreen(); }
}
if root.control-expanded: close-button := ControlBarButton {
x: root.control-docked-left ? 233px : 266px; y: 7px;
icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect;
clicked => { root.disconnect(); }
}
if root.control-expanded && root.control-docked-left: Rectangle {
x: 267px; y: 12px; width: 2px; height: 15px;
background: ImGuiLineStyle.control-separator;
}
collapse-button := ControlBarButton {
x: root.control-expanded ? (root.control-docked-left ? 275px : 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; }
}
if root.stats-visible && root.control-expanded: stats-table := Rectangle {
x: 14px;
y: 38px;
width: parent.width - 28px;
height: 128px;
background: transparent;
private property <length> row-height: 16px;
private property <length> label-column-width: 60px;
private property <length> loss-column-width: 66px;
private property <length> value-column-width:
(self.width - self.label-column-width - self.loss-column-width) / 2;
Rectangle {
y: 0px;
height: 1px;
background: ImGuiLineStyle.border;
}
Rectangle {
y: 0px;
height: stats-table.row-height;
Text {
x: stats-table.label-column-width + 4px;
width: stats-table.value-column-width - 8px;
text: StreamStrings.stats-in;
color: #30343b;
font-size: ImGuiFontStyle.small;
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: #30343b;
font-size: ImGuiFontStyle.small;
vertical-alignment: center;
}
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: #30343b;
font-size: ImGuiFontStyle.small;
vertical-alignment: center;
overflow: elide;
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: ImGuiLineStyle.border;
}
}
for row[index] in root.stats-rows: Rectangle {
y: (index + 1) * stats-table.row-height;
height: stats-table.row-height;
Text {
x: 4px;
width: stats-table.label-column-width - 8px;
text: row.label;
color: #30343b;
font-size: ImGuiFontStyle.small;
vertical-alignment: center;
}
Text {
x: stats-table.label-column-width + 4px;
width: stats-table.value-column-width - 8px;
text: row.inbound;
color: #30343b;
font-size: ImGuiFontStyle.small;
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: #30343b;
font-size: ImGuiFontStyle.small;
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: #30343b;
font-size: ImGuiFontStyle.small;
vertical-alignment: center;
overflow: elide;
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: ImGuiLineStyle.border;
}
}
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 },
]: Rectangle {
y: (index + 5) * stats-table.row-height;
height: stats-table.row-height;
Text {
x: 4px;
width: stats-table.label-column-width - 8px;
text: metric.label;
color: #30343b;
font-size: ImGuiFontStyle.small;
vertical-alignment: center;
}
Text {
x: stats-table.label-column-width + 4px;
width: stats-table.value-column-width - 8px;
text: metric.value;
color: #30343b;
font-size: ImGuiFontStyle.small;
vertical-alignment: center;
overflow: elide;
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: ImGuiLineStyle.border;
}
}
}
}
if root.display-menu-open: Rectangle {
x: control.x + (root.control-docked-left ? 9px : 42px); y: control.y + 40px; width: 180px; height: min(180px, root.displays.length * 30px + 8px);
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11;
VerticalLayout {
padding: 4px; spacing: 1px;
for display[index] in root.displays: Rectangle {
height: 29px;
background: display-touch.has-hover || index == root.selected-display ? #e8eef9 : transparent;
Text { x: 8px; text: display; color: #30343b; font-size: ImGuiFontStyle.body; vertical-alignment: center; }
display-touch := TouchArea { clicked => { root.selected-display = index; root.switch-display(index); root.display-menu-open = false; } }
}
}
}
if root.shortcut-menu-open: Rectangle {
x: control.x + (root.control-docked-left ? 41px : 74px); y: control.y + 40px; width: 150px; 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 { clicked => { root.send-shortcut(shortcut); root.shortcut-menu-open = false; } }
}
}
}
// 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; }
}
}
}
}
}
}
}
}