[feat] add DeskHub device wall and shared group actions

This commit is contained in:
dijunkun
2026-10-12 03:08:04 +08:00
parent 54dbd04a8d
commit 73b4d0f645
29 changed files with 3344 additions and 136 deletions
+3 -1
View File
@@ -49,6 +49,7 @@ export global FontAwesomeIcons {
out property <string> signal: "\u{f012}";
out property <string> shield-halved: "\u{f3ed}";
out property <string> square-full: "\u{f45c}";
out property <string> tags: "\u{f02c}";
out property <string> trash-can: "\u{f2ed}";
out property <string> user-secret: "\u{f21b}";
out property <string> volume-high: "\u{f028}";
@@ -59,6 +60,7 @@ export global FontAwesomeIcons {
export component IconButton inherits Rectangle {
in property <string> icon;
in property <string> tooltip;
in property <bool> tooltip-above: false;
in property <bool> enabled: true;
in property <bool> danger: false;
in property <length> icon-size: 16px;
@@ -103,7 +105,7 @@ export component IconButton inherits Rectangle {
if touch.has-hover && root.tooltip != "": Rectangle {
x: min(root.width - self.width, 0px);
y: root.height + 3px;
y: root.tooltip-above ? -self.height - 3px : root.height + 3px;
width: tip.preferred-width + 12px;
height: 24px;
background: #22252be8;
@@ -4,3 +4,7 @@ export { AnnouncementItem, MessageStrings } from "message_panel.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
export { GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
export { GalleryStrings } from "device_strings.slint";
export { DeviceGroupOption } from "device_group_dialog.slint";
@@ -0,0 +1,54 @@
import { IconButton, FontAwesomeIcons } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export global DeviceCardStyle {
out property <length> toolbar-width: 104px;
}
component CardAction inherits IconButton {
width: 26px; height: 23px;
icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
tooltip-above: true;
}
// Shared by recent connections and DeskHub; callers supply only behavior.
export component DeviceCardActions inherits Rectangle {
in property <string> remove-label;
in property <bool> connected: false;
callback edit-alias();
callback edit-groups();
callback remove-device();
callback connect-device();
callback disconnect-device();
width: DeviceCardStyle.toolbar-width; height: 23px;
background: #4a4e55dc; border-radius: 5px;
HorizontalLayout {
spacing: 0px;
CardAction {
icon: FontAwesomeIcons.pen;
tooltip: GalleryStrings.tr("修改名称", "Rename", "Переименовать");
clicked => { root.edit-alias(); }
}
CardAction {
icon: FontAwesomeIcons.tags;
tooltip: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
clicked => { root.edit-groups(); }
}
CardAction {
icon: FontAwesomeIcons.trash-can;
tooltip: root.remove-label;
clicked => { root.remove-device(); }
}
CardAction {
icon: root.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long;
icon-size: 13px;
tooltip: root.connected ? GalleryStrings.tr("断开", "Disconnect", "Отключить") : GalleryStrings.tr("连接", "Connect", "Подключить");
clicked => {
if root.connected { root.disconnect-device(); }
else { root.connect-device(); }
}
}
}
}
@@ -0,0 +1,87 @@
import { ScrollView, LineEdit } from "std-widgets.slint";
import { CompactButton, ImGuiCheckBox } from "common.slint";
import { GalleryStrings } from "device_strings.slint";
export struct DeviceGroupOption {
name: string,
selected: bool,
}
export component DeviceGroupDialog inherits Rectangle {
in property <string> device-name;
in property <string> device-id;
in property <[DeviceGroupOption]> options;
in property <string> error;
in-out property <string> new-group;
callback toggle-group(string);
callback save(string);
callback cancel();
background: #00000040;
TouchArea {}
dialog := Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 40px);
height: 208px + group-list.height + (root.error == "" ? 0px : 32px);
background: white; border-radius: 8px;
border-width: 1px; border-color: #b2b2b2;
TouchArea {}
Text {
x: 18px; y: 16px; height: 24px;
text: GalleryStrings.tr("修改分组", "Edit groups", "Изменить группы");
color: #24272d; font-size: 17px; font-weight: 600;
}
Text {
x: 18px; y: 47px; width: parent.width - 36px; height: 18px;
text: root.device-name; color: #30343b; font-size: 12px; overflow: elide;
}
Text {
x: 18px; y: 67px; width: parent.width - 36px; height: 16px;
text: "ID " + root.device-id; color: #858c97; font-size: 11px;
}
group-list := Rectangle {
x: 18px; y: 95px; width: parent.width - 36px;
height: min(5, max(1, root.options.length)) * 32px + 2px;
background: #fbfcfd; border-width: 1px; border-color: #e1e5eb; border-radius: 5px;
ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width;
VerticalLayout {
spacing: 0px;
for option[index] in root.options: Rectangle {
height: 32px;
background: row-touch.has-hover ? #eef4fc : transparent;
ImGuiCheckBox {
x: 9px; y: 6px; width: 20px; height: 20px;
checked: option.selected && (option.name != "" || root.new-group == "");
}
Text {
x: 39px; width: parent.width - 48px; height: parent.height;
text: option.name == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : option.name;
color: #30343b; font-size: 12px; overflow: elide; vertical-alignment: center;
}
row-touch := TouchArea { clicked => { root.toggle-group(option.name); } }
}
}
}
}
LineEdit {
x: 18px; y: group-list.y + group-list.height + 12px;
width: parent.width - 36px; height: 30px;
text <=> root.new-group;
placeholder-text: GalleryStrings.tr("新建分组(可选)", "New group (optional)", "Новая группа (необязательно)");
}
if root.error != "": Text {
x: 18px; y: group-list.y + group-list.height + 48px;
width: parent.width - 36px; height: 32px;
text: root.error; color: #c53e3e; font-size: 11px; wrap: word-wrap;
}
HorizontalLayout {
x: 18px; y: parent.height - 46px; width: parent.width - 36px; height: 28px;
spacing: 10px;
Rectangle { horizontal-stretch: 1; }
CompactButton { width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.cancel(); } }
CompactButton { width: 88px; height: 28px; primary: true; text: GalleryStrings.tr("保存", "Save", "Сохранить"); clicked => { root.save(root.new-group); } }
}
}
}
@@ -0,0 +1,6 @@
export global GalleryStrings {
in-out property <int> language: 0;
public pure function tr(zh: string, en: string, ru: string) -> string {
return self.language == 0 ? zh : self.language == 2 ? ru : en;
}
}
+454
View File
@@ -0,0 +1,454 @@
import { GalleryStrings } from "device_strings.slint";
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
import { GroupFilter } from "group_filter.slint";
import { ScrollView, LineEdit } from "std-widgets.slint";
import { CompactButton, FontAwesomeIcons, ImGuiCheckBox } from "common.slint";
export struct GalleryTile {
id: string,
name: string,
status: string,
connected: bool,
online: bool,
presence-known: bool,
stale: bool,
selected: bool,
has-frame: bool,
frame: image,
}
export struct GalleryRecentDevice {
id: string,
name: string,
selected: bool,
online: bool,
}
export struct GalleryActionDevice {
id: string,
name: string,
connected: bool,
}
export component GalleryView inherits Rectangle {
in property <bool> controlling: false;
// Pointer mapping must use the exact image viewport, including its inset.
out property <float> video-width: video.width / 1px;
out property <float> video-height: video.height / 1px;
private property <bool> vertical-rail: focus-view.width > 840px;
private property <int> rail-current-index: 0;
changed rail-current-index => { root.reveal-current(); }
changed width => { root.reveal-current(); }
changed height => { root.reveal-current(); }
function reveal-current() {
if root.vertical-rail {
rail.viewport-x = 0px;
let top = root.rail-current-index * 100px;
rail.viewport-y = clamp(rail.viewport-y, -top, rail.height - top - 92px);
rail.viewport-y = clamp(rail.viewport-y, min(0px, rail.height - rail.viewport-height), 0px);
} else {
rail.viewport-y = 0px;
let left = root.rail-current-index * 144px;
rail.viewport-x = clamp(rail.viewport-x, -left, rail.width - left - 136px);
rail.viewport-x = clamp(rail.viewport-x, min(0px, rail.width - rail.viewport-width), 0px);
}
}
callback pointer-input(PointerEventButton, PointerEventKind, length, length);
callback scroll-input(length, length, length, length);
callback key-input(string, bool, bool, bool, bool, bool);
in property <[GalleryTile]> tiles;
in property <[string]> groups;
in-out property <int> group-index: 0;
in property <int> columns: 3;
in property <int> rows: 2;
in property <int> page: 0;
in property <int> pages: 1;
in property <int> count: 0;
in property <int> connected-count: 0;
in property <int> selected-count: 0;
in property <int> hidden-selected: 0;
in property <string> notice;
in property <GalleryTile> focused;
in-out property <bool> batch: false;
in-out property <bool> adding: false;
in-out property <bool> importing: false;
in property <[GalleryRecentDevice]> recent-devices;
in property <int> recent-selected: 0;
in property <string> import-group;
private property <bool> confirming: false;
private property <string> action-name;
private property <string> action-code;
in property <[GalleryActionDevice]> selected-devices;
callback action(string, string);
callback add-device(string, string, string, bool) -> bool;
callback import-recent(string) -> bool;
callback edit-alias(string, string);
callback edit-groups(string, string);
callback remove-device(string);
background: white;
changed adding => { if root.adding { root.action("release", ""); } }
changed importing => { root.action(root.importing ? "release" : "recent-close", ""); }
public function reset-transient() { root.batch = false; root.adding = false; root.importing = false; root.confirming = false; }
function confirm-action(code: string, name: string) {
root.action-code = code; root.action-name = name; root.confirming = true;
}
toolbar := HorizontalLayout {
x: 29px; y: 12px; width: parent.width - 58px; height: 24px;
visible: root.focused.id == "";
spacing: 8px;
GroupFilter { width: 155px; height: 24px; system-count: 1; model: root.groups; current-index <=> root.group-index;
selected(index) => { root.action("group", index); }
}
Rectangle { horizontal-stretch: 1; }
CompactButton { width: 82px; primary: root.batch; text: root.batch ? GalleryStrings.tr("完成", "Done", "Готово") : GalleryStrings.tr("批量选择", "Select", "Выбрать"); clicked => { root.batch = !root.batch; root.action("selection", ""); } }
CompactButton { width: 116px; text: GalleryStrings.tr("从最近连接添加", "Add from recent", "Из недавних"); clicked => { root.action("recent-open", ""); root.importing = true; } }
CompactButton { width: 90px; primary: true; text: GalleryStrings.tr("添加设备", "Add device", "Добавить"); clicked => { root.action("add-open", ""); root.adding = true; } }
}
grid := Rectangle {
x: toolbar.x; y: toolbar.y + toolbar.height + 12px; width: toolbar.width;
height: footer.y - 12px - self.y;
visible: root.focused.id == "";
for tile[index] in root.tiles: Rectangle {
x: mod(index, root.columns) * (grid.width + 12px) / root.columns;
y: floor(index / root.columns) * (grid.height + 12px) / root.rows;
width: (grid.width - (root.columns - 1) * 12px) / root.columns;
height: (grid.height - (root.rows - 1) * 12px) / root.rows;
border-radius: 6px; background: tile.selected ? #eaf3ff : #eff0f2;
border-width: tile.selected ? 2px : 1px;
border-color: tile.selected ? #76aeea : root.batch && tile-touch.has-hover ? #94bdeb : #c9cdd2;
Rectangle {
x: 6px; y: 6px; width: parent.width - 12px; height: parent.height - 36px;
background: #24272d;
if tile.has-frame: Image { x: 0px; y: 0px; width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale ? 0.45 : 1; }
if !tile.has-frame || !tile.connected || tile.stale: Text {
text: tile.status; color: #ffffff; font-size: 12px;
horizontal-alignment: center; vertical-alignment: center;
wrap: word-wrap;
}
}
Text { x: 9px; y: parent.height - 28px; width: (tile-touch.has-hover && !root.batch ? parent.width - DeviceCardStyle.toolbar-width - 6px : presence-badge.x) - 17px; height: 28px; text: tile.name; color: #24272d; font-size: 12px; overflow: elide; vertical-alignment: center; }
presence-badge := Rectangle {
visible: root.batch || !tile-touch.has-hover;
x: parent.width - (root.batch ? 35px : 9px) - self.width; y: parent.height - 23px;
width: presence-label.preferred-width + 12px; height: 18px;
border-radius: 9px; background: tile.online ? #eaf6ee : #e2e5e9;
presence-label := Text {
x: 6px; height: parent.height;
text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
color: tile.online ? #17854c : #62666d; font-size: 10px; vertical-alignment: center;
}
}
if root.batch: Rectangle {
x: parent.width - 27px; y: parent.height - 23px;
width: 18px; height: 18px; border-radius: 5px; border-width: 1px;
background: tile.selected ? (tile-touch.pressed ? #5c9fdf : tile-touch.has-hover ? #659fe0 : #6dabea) : tile-touch.has-hover ? #f3f8fe : white;
border-color: tile.selected ? #6dabea : tile-touch.has-hover ? #76aeea : #b7bec8;
animate background, border-color { duration: 120ms; }
if tile.selected: Text {
text: FontAwesomeIcons.check; color: white;
font-family: "Font Awesome 6 Free"; font-weight: 900; font-size: 11px;
horizontal-alignment: center; vertical-alignment: center;
}
}
tile-touch := TouchArea {
clicked => { root.action(root.batch ? "select" : "focus", tile.id); }
if tile-touch.has-hover && !root.batch: DeviceCardActions {
x: parent.width - self.width - 6px; y: parent.height - 26px;
remove-label: GalleryStrings.tr("从桌面墙移除", "Remove from DeskHub", "Убрать из DeskHub");
connected: tile.connected;
edit-alias => { root.action("release", ""); root.edit-alias(tile.id, tile.name); }
edit-groups => { root.edit-groups(tile.id, tile.name); }
remove-device => { root.action("release", ""); root.remove-device(tile.id); }
connect-device => { root.action("connect", tile.id); }
disconnect-device => { root.action("disconnect", tile.id); }
}
}
}
if root.count == 0: Text {
text: GalleryStrings.tr("添加设备,建立你的桌面墙", "Add devices to build your DeskHub", "Добавьте устройства в DeskHub");
color: #808080; horizontal-alignment: center; vertical-alignment: center;
}
}
focus-view := Rectangle {
visible: root.focused.id != "";
x: 29px; y: 7px; width: parent.width - 58px;
height: parent.height - 51px;
HorizontalLayout {
x: 0px; y: 0px; width: parent.width; height: 28px; spacing: 8px;
CompactButton { width: 52px; text: GalleryStrings.tr("返回", "Back", "Назад"); clicked => { root.action("focus", ""); } }
Rectangle {
horizontal-stretch: 1; min-width: 0px;
Text { x: 0px; width: parent.width; height: parent.height; text: root.focused.name; color: #303943; font-size: 12px; font-weight: 600; overflow: elide; horizontal-alignment: left; vertical-alignment: center; }
}
Rectangle {
width: min(focus-state.preferred-width + 16px, 110px); height: 22px;
border-radius: 5px;
background: root.controlling ? #e7f1fd : #f0f2f5;
focus-state := Text {
x: 8px; width: parent.width - 16px; height: parent.height;
text: root.controlling ? GalleryStrings.tr("正在操控", "Controlling", "Управление")
: root.focused.connected && root.focused.has-frame && !root.focused.stale
? GalleryStrings.tr("只读预览", "View only", "Просмотр") : root.focused.status;
font-size: 10px; color: root.controlling ? #3e77b3 : #6b7480; vertical-alignment: center; overflow: elide;
}
}
CompactButton { width: 100px; primary: true; enabled: !root.focused.connected || root.controlling || (root.focused.has-frame && !root.focused.stale); text: root.focused.connected ? (root.controlling ? GalleryStrings.tr("释放操控", "Release", "Отпустить") : GalleryStrings.tr("接管操控", "Control", "Управлять")) : GalleryStrings.tr("连接", "Connect", "Подключить"); clicked => { root.action(root.focused.connected ? (root.controlling ? "release" : "control") : "connect", root.focused.id); } }
}
Rectangle {
x: 0px; y: 36px; width: root.vertical-rail ? parent.width - 158px : parent.width;
height: root.vertical-rail ? parent.height - 36px : parent.height - 152px;
background: #eff1f4; border-radius: 6px;
border-width: root.controlling ? 2px : 1px; border-color: root.controlling ? #76aeea : #d5dbe3;
video := Rectangle {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
background: #24272d;
if root.focused.has-frame: Image {
width: parent.width; height: parent.height; source: root.focused.frame; image-fit: contain;
opacity: root.focused.stale || !root.focused.connected ? 0.4 : 1;
}
video-focus := FocusScope {
property <bool> active-control: root.controlling;
changed active-control => { if self.active-control { self.focus(); } }
width: parent.width; height: parent.height;
focus-on-click: true;
focus-lost => { if root.controlling { root.action("release", ""); } }
key-pressed(event) => {
if root.controlling {
if event.text == Key.Escape && event.modifiers.shift && (event.modifiers.control || event.modifiers.meta) {
root.action("release", "");
} else { root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); }
}
accept
}
key-released(event) => {
if root.controlling { root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta); }
accept
}
TouchArea {
pointer-event(event) => {
if event.kind == PointerEventKind.down { video-focus.focus(); }
if root.controlling { root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y); }
}
scroll-event(event) => {
if root.controlling { root.scroll-input(event.delta-x, event.delta-y, self.mouse-x, self.mouse-y); }
accept
}
}
}
if !root.focused.has-frame || root.focused.stale || !root.focused.connected: Text { text: root.focused.status; color: white; horizontal-alignment: center; vertical-alignment: center; }
}
}
rail := ScrollView {
x: root.vertical-rail ? parent.width - 148px : 0px;
y: root.vertical-rail ? 36px : parent.height - 106px;
width: root.vertical-rail ? 148px : parent.width;
height: root.vertical-rail ? parent.height - 36px : 106px;
// Reserve 14px below or 12px beside cards for the scrollbar.
viewport-width: root.vertical-rail ? self.width : max(self.width, root.tiles.length * 144px - 8px);
viewport-height: root.vertical-rail ? max(self.height, root.tiles.length * 100px - 8px) : self.height;
horizontal-scrollbar-policy: root.vertical-rail ? always-off : as-needed;
vertical-scrollbar-policy: root.vertical-rail ? as-needed : always-off;
for tile[index] in root.tiles: Rectangle {
private property <bool> current: tile.id == root.focused.id;
init => { if self.current { root.rail-current-index = index; root.reveal-current(); } }
changed current => { if self.current { root.rail-current-index = index; root.reveal-current(); } }
x: root.vertical-rail ? 0px : index * 144px;
y: root.vertical-rail ? index * 100px : 0px;
width: 136px; height: 92px; border-radius: 5px;
background: tile.id == root.focused.id ? #edf5ff : #f5f6f8;
border-width: tile.id == root.focused.id ? 2px : 1px;
border-color: tile.id == root.focused.id ? #76aeea : #d5dbe3;
Rectangle {
x: 5px; y: 5px; width: 126px; height: 52px; background: #24272d;
if tile.has-frame: Image { width: parent.width; height: parent.height; source: tile.frame; image-fit: contain; opacity: tile.stale || !tile.connected ? 0.4 : 1; }
if !tile.has-frame || tile.stale || !tile.connected: Text {
text: tile.status; color: #e2e6ed; font-size: 10px; overflow: elide;
horizontal-alignment: center; vertical-alignment: center;
}
}
Text { x: 7px; y: 61px; width: 122px; height: 16px; text: tile.name; color: #303943; font-size: 11px; overflow: elide; }
Text {
x: 7px; y: 78px; width: 86px; height: 12px; font-size: 9px;
text: tile.online ? GalleryStrings.tr("在线", "Online", "В сети") : tile.presence-known ? GalleryStrings.tr("离线", "Offline", "Не в сети") : GalleryStrings.tr("未知", "Unknown", "Неизвестно");
color: tile.online ? #17854c : #7a828d; overflow: elide;
}
if tile.id == root.focused.id: Text {
x: 99px; y: 78px; width: 30px; height: 12px;
text: GalleryStrings.tr("当前", "Current", "Выбрано"); color: #4d83bd; font-size: 9px; horizontal-alignment: right; overflow: elide;
}
TouchArea { clicked => { root.action("focus", tile.id); } }
}
}
}
// Reuse the footer for selection actions so entering batch mode never
// changes card geometry or moves a device beneath the pointer.
footer := HorizontalLayout {
x: toolbar.x; y: parent.height - 36px; width: toolbar.width; height: 24px; spacing: 12px;
if root.batch: CompactButton {
width: 90px; enabled: root.count > 0;
text: root.count > 0 && root.selected-count == root.count
? GalleryStrings.tr("取消全选", "Deselect all", "Снять выбор")
: GalleryStrings.tr("全选", "Select all", "Выбрать все");
clicked => { root.action("all-select", ""); }
}
Rectangle {
horizontal-stretch: 1; min-width: 0px;
Text {
y: 0px; width: parent.width; height: root.batch && root.hidden-selected > 0 ? 13px : parent.height;
text: root.batch ? GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.selected-count : root.connected-count + " / " + root.count + GalleryStrings.tr(" 台已连接", " connected", " подключено");
color: #62666d; vertical-alignment: center; font-size: 12px; overflow: elide;
}
if root.batch && root.hidden-selected > 0: Text {
y: 14px; width: parent.width; height: 10px;
text: GalleryStrings.tr("跨页 ", "Other pages ", "Другие страницы ") + root.hidden-selected;
color: #80858d; font-size: 10px; overflow: elide;
}
}
if root.batch: HorizontalLayout {
width: 214px; spacing: 8px;
CompactButton { width: 58px; enabled: root.selected-count > 0; text: GalleryStrings.tr("连接", "Connect", "Связь"); clicked => { root.action("connect", ""); } }
CompactButton { width: 62px; enabled: root.selected-count > 0; text: GalleryStrings.tr("断开", "Close", "Отключить"); clicked => { root.confirm-action("disconnect", self.text); } }
CompactButton { width: 78px; enabled: root.selected-count > 0; text: GalleryStrings.tr("分发文件", "Send file", "Файл"); clicked => { root.confirm-action("file", self.text); } }
}
HorizontalLayout {
width: 112px; height: 24px; spacing: 6px;
CompactButton { width: 24px; text: "‹"; text-size: 16px; enabled: root.page > 0; clicked => { root.action("page", "previous"); } }
Text { text: (root.page + 1) + " / " + root.pages; min-width: 0px; overflow: elide; horizontal-alignment: center; vertical-alignment: center; }
CompactButton { width: 24px; text: "›"; text-size: 16px; enabled: root.page + 1 < root.pages; clicked => { root.action("page", "next"); } }
}
}
if root.adding: Rectangle {
background: #00000042;
TouchArea {}
add-dialog := Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 400px; height: 306px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
function submit() {
if root.add-device(device-id.text, password.text, group.text, remember.checked) {
password.text = "";
root.adding = false;
}
}
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("添加设备", "Add device", "Добавить устройство"); font-size: 16px; }
Text { x: 18px; y: 56px; width: 72px; height: 30px; text: GalleryStrings.tr("设备 ID", "Device ID", "ID"); vertical-alignment: center; font-size: 12px; }
device-id := LineEdit {
x: 96px; y: 56px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("9 位设备 ID", "9-digit device ID", "ID из 9 цифр");
input-type: InputType.number;
accepted => { password.focus(); }
}
Text { x: 18px; y: 100px; width: 72px; height: 30px; text: GalleryStrings.tr("密码", "Password", "Пароль"); vertical-alignment: center; font-size: 12px; }
password := LineEdit {
x: 96px; y: 100px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("设备连接密码", "Device password", "Пароль устройства");
input-type: InputType.password;
accepted => { group.focus(); }
}
Text { x: 18px; y: 144px; width: 72px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); vertical-alignment: center; font-size: 12px; }
group := LineEdit {
x: 96px; y: 144px; width: 286px; height: 30px;
placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)");
accepted => { add-dialog.submit(); }
}
remember := ImGuiCheckBox { x: 96px; y: 188px; }
Text { x: 123px; y: 188px; height: 21px; text: GalleryStrings.tr("记住密码", "Remember password", "Запомнить пароль"); font-size: 12px; vertical-alignment: center;
TouchArea { clicked => { remember.checked = !remember.checked; } }
}
Text { x: 18px; y: 216px; width: 364px; height: 28px; text: root.notice; color: #8e4740; font-size: 11px; wrap: word-wrap; overflow: elide; }
CompactButton { x: 172px; y: 260px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { password.text = ""; root.adding = false; } }
CompactButton {
x: 254px; y: 260px; width: 128px; height: 28px; primary: true;
text: GalleryStrings.tr("添加并连接", "Add and connect", "Подключить");
enabled: device-id.text != "" && password.text != "";
clicked => { add-dialog.submit(); }
}
}
}
if root.importing: Rectangle {
background: #00000042;
TouchArea {}
Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 440px; height: 378px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
Text { x: 18px; y: 16px; height: 24px; text: GalleryStrings.tr("从最近连接添加", "Add from recent connections", "Добавить из недавних"); font-size: 16px; }
Text { x: 18px; y: 44px; width: 404px; height: 18px; text: GalleryStrings.tr("加入桌面墙后,可通过批量选择连接", "Add devices, then use Select to connect them.", "После добавления подключайтесь через выбор."); color: #62666d; font-size: 11px; }
ImGuiCheckBox { x: 18px; y: 74px; enabled: root.recent-devices.length > 0; checked: root.recent-devices.length > 0 && root.recent-selected == root.recent-devices.length; }
Text { x: 45px; y: 74px; height: 21px; text: GalleryStrings.tr("全选", "Select all", "Выбрать все"); font-size: 12px; vertical-alignment: center; }
TouchArea { x: 18px; y: 74px; width: 144px; height: 21px; enabled: root.recent-devices.length > 0; clicked => { root.action("recent-all", ""); } }
Text { x: 224px; y: 74px; width: 198px; height: 21px; text: GalleryStrings.tr("已选 ", "Selected ", "Выбрано ") + root.recent-selected + " / " + root.recent-devices.length; horizontal-alignment: right; vertical-alignment: center; font-size: 12px; color: #62666d; }
Rectangle {
x: 18px; y: 102px; width: 404px; height: 160px;
border-width: 1px; border-color: #d8dadd; border-radius: 4px; clip: true;
recent-list := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
viewport-height: max(self.height, root.recent-devices.length * 44px);
for device[index] in root.recent-devices: Rectangle {
y: index * 44px; width: recent-list.viewport-width; height: 44px;
background: device.selected ? #edf5fe : row-touch.has-hover ? #f4f5f6 : white;
ImGuiCheckBox { x: 8px; y: 11px; checked: device.selected; }
Text { x: 36px; y: 3px; width: parent.width - 126px; height: 20px; text: device.name; font-size: 12px; overflow: elide; vertical-alignment: center; }
Text { x: 36px; y: 23px; width: parent.width - 126px; height: 16px; text: device.id; font-size: 11px; color: #808080; }
Rectangle { x: parent.width - 74px; y: 19px; width: 6px; height: 6px; border-radius: 3px; background: device.online ? #21b567 : #92969e; }
Text {
x: parent.width - 61px; y: 12px; width: 57px; height: 20px;
text: device.online ? GalleryStrings.tr("在线", "Online", "В сети") : GalleryStrings.tr("离线", "Offline", "Не в сети");
color: device.online ? #17854c : #808080; font-size: 11px; vertical-alignment: center;
}
row-touch := TouchArea { clicked => { root.action("recent-select", device.id); } }
}
}
if root.recent-devices.length == 0: Text { text: GalleryStrings.tr("暂无最近连接", "No recent connections", "Нет недавних подключений"); color: #808080; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; }
}
Text { x: 18px; y: 274px; width: 60px; height: 30px; text: GalleryStrings.tr("分组", "Group", "Группа"); font-size: 12px; vertical-alignment: center; }
import-group := LineEdit { x: 84px; y: 274px; width: 338px; height: 30px; text: root.import-group; placeholder-text: GalleryStrings.tr("分组名称(可选)", "Group name (optional)", "Группа (необязательно)"); }
Text { x: 18px; y: 309px; width: 404px; height: 20px; text: root.notice; font-size: 11px; color: #8e4740; overflow: elide; }
CompactButton { x: 192px; y: 336px; width: 72px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.importing = false; } }
CompactButton { x: 274px; y: 336px; width: 148px; primary: true; text: GalleryStrings.tr("加入桌面墙", "Add selected", "Добавить"); enabled: root.recent-selected > 0; clicked => { if root.import-recent(import-group.text) { root.importing = false; } } }
}
}
if root.confirming: Rectangle {
background: #00000042; TouchArea {}
Rectangle {
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
width: 360px; height: confirmation-list.height + 118px;
background: white; border-radius: 8px; border-width: 1px; border-color: #b2b2b2;
Text {
x: 20px; y: 18px; width: parent.width - 132px; height: 24px;
text: root.action-code == "disconnect" ? GalleryStrings.tr("断开连接", "Disconnect devices", "Отключение") : root.action-name;
color: #24272d; font-size: 16px; font-weight: 600; vertical-alignment: center;
}
Rectangle {
x: parent.width - 108px; y: 20px; width: 88px; height: 22px;
background: #eff3f8; border-radius: 11px;
Text { text: root.selected-count + GalleryStrings.tr(" 台设备", " devices", " устр."); color: #626e7d; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
}
confirmation-list := Rectangle {
x: 20px; y: 58px; width: parent.width - 40px;
height: clamp(root.selected-devices.length * 52px + 2px, 54px, 210px);
background: #fcfdfe; border-width: 1px; border-color: #e2e6eb; border-radius: 6px; clip: true;
confirmation-scroll := ScrollView {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
viewport-width: self.width - 12px;
viewport-height: max(self.height, root.selected-devices.length * 52px);
for device[index] in root.selected-devices: Rectangle {
y: index * 52px; width: confirmation-scroll.viewport-width; height: 52px;
Text { x: 12px; y: 8px; width: parent.width - 32px - connection-badge.width; height: 18px; text: device.name; color: #303740; font-size: 12px; font-weight: 600; overflow: elide; vertical-alignment: center; }
Text { x: 12px; y: 29px; width: parent.width - 32px - connection-badge.width; height: 16px; text: "ID " + device.id; color: #8a929e; font-size: 11px; vertical-alignment: center; }
connection-badge := Rectangle {
x: parent.width - self.width - 12px; y: 16px;
width: GalleryStrings.language == 0 ? 68px : 104px; height: 20px; border-radius: 10px;
background: device.connected ? #eaf6ee : #f0f2f5;
Rectangle { x: 9px; y: 7px; width: 6px; height: 6px; border-radius: 3px; background: device.connected ? #27a762 : #a0a6b0; }
Text { x: 21px; width: parent.width - 27px; height: parent.height; text: device.connected ? GalleryStrings.tr("已连接", "Connected", "Подключено") : GalleryStrings.tr("未连接", "Not connected", "Нет связи"); color: device.connected ? #348259 : #858d98; font-size: 10px; vertical-alignment: center; }
}
if index + 1 < root.selected-devices.length: Rectangle { x: 12px; y: parent.height - 1px; width: parent.width - 24px; height: 1px; background: #edf0f3; }
}
}
}
CompactButton { x: parent.width - 208px; y: parent.height - 46px; width: 72px; height: 28px; text: GalleryStrings.tr("取消", "Cancel", "Отмена"); clicked => { root.confirming = false; } }
CompactButton {
x: parent.width - 126px; y: parent.height - 46px; width: 108px; height: 28px; primary: true;
text: root.action-code == "disconnect" ? GalleryStrings.tr("确认断开", "Disconnect", "Отключить") : GalleryStrings.tr("确认执行", "Continue", "Продолжить");
clicked => { root.confirming = false; root.action(root.action-code, ""); }
}
}
}
}
+161
View File
@@ -0,0 +1,161 @@
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons } from "common.slint";
// Shared by history and DeskHub; system choices precede the named groups.
export component GroupFilter inherits Rectangle {
in property <[string]> model;
in property <int> system-count: 2;
in property <bool> enabled: true;
in-out property <int> current-index: 0;
callback selected(int);
private property <bool> has-divider: root.system-count > 0 && root.model.length > root.system-count;
private property <length> list-height: root.model.length * 26px + (root.has-divider ? 6px : 0px);
private property <length> visible-height: min(root.list-height, 162px);
private property <length> scroll-y: 0px;
private property <bool> popup-animation-enabled: false;
private property <float> popup-progress: 0;
animate popup-progress {
duration: 140ms; easing: ease-out;
enabled: root.popup-animation-enabled;
}
width: 155px; height: 28px;
background: !root.enabled ? #f3f4f5 : trigger-touch.has-hover ? #f8f9fb : white;
border-width: 1px;
border-color: popup.is-open || trigger-focus.has-focus ? #8db5df : #c9cdd2;
border-radius: 4px;
animate background, border-color { duration: 100ms; easing: ease-out; }
forward-focus: trigger-focus;
accessible-role: combobox;
accessible-enabled: root.enabled;
accessible-expandable: true;
accessible-expanded: popup.is-open;
accessible-value: root.model[root.current-index];
function reveal-selection() {
let top = root.current-index * 26px + (root.has-divider && root.current-index >= root.system-count ? 6px : 0px);
if top < -root.scroll-y { root.scroll-y = -top; }
if top + 26px > -root.scroll-y + root.visible-height {
root.scroll-y = root.visible-height - top - 26px;
}
root.scroll-y = clamp(root.scroll-y, min(0px, root.visible-height - root.list-height), 0px);
}
function choose(index: int) {
if !root.enabled || root.model.length == 0 { return; }
root.current-index = clamp(index, 0, root.model.length - 1);
root.reveal-selection();
root.selected(root.current-index);
}
function open-menu() {
if root.enabled && root.model.length > 0 {
popup-open-timer.running = false;
root.popup-animation-enabled = false;
root.popup-progress = 0;
root.reveal-selection();
popup.show();
popup-open-timer.running = true;
}
}
// Defer until the popup has its initial frame; animate its contents only,
// keeping the native popup bounds and scrolling geometry stable.
popup-open-timer := Timer {
interval: 16ms;
running: false;
triggered => {
self.running = false;
if popup.is-open {
root.popup-animation-enabled = true;
root.popup-progress = 1;
}
}
}
function handle-key(event: KeyEvent) -> EventResult {
if !root.enabled { return reject; }
if event.text == Key.UpArrow { root.choose(root.current-index - 1); return accept; }
if event.text == Key.DownArrow { root.choose(root.current-index + 1); return accept; }
if event.text == Key.Return || event.text == " " {
if popup.is-open { popup.close(); } else { root.open-menu(); }
return accept;
}
if event.text == Key.Escape && popup.is-open { popup.close(); return accept; }
return reject;
}
Text {
x: 10px; width: parent.width - 36px; height: parent.height;
text: root.model[root.current-index];
color: root.enabled ? #30343b : #92979f;
font-size: 12px; overflow: elide; vertical-alignment: center;
accessible-role: none;
}
Text {
x: parent.width - 25px; width: 16px; height: parent.height;
text: FontAwesomeIcons.angle-down; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #858c96;
horizontal-alignment: center; vertical-alignment: center;
transform-rotation: popup.is-open ? 180deg : 0deg;
animate transform-rotation { duration: 140ms; easing: ease-out; }
accessible-role: none;
}
trigger-focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => { return root.handle-key(event); }
trigger-touch := TouchArea {
enabled: root.enabled;
clicked => { trigger-focus.focus(); root.open-menu(); }
}
}
popup := PopupWindow {
x: 0px; y: root.height + 4px;
width: root.width; height: root.visible-height + 8px;
close-policy: close-on-click-outside;
forward-focus: popup-focus;
popup-focus := FocusScope {
key-pressed(event) => { return root.handle-key(event); }
Rectangle {
y: (1 - root.popup-progress) * -4px;
width: parent.width; height: parent.height;
opacity: root.popup-progress;
background: white; border-width: 1px;
border-color: #c9cdd2; border-radius: 5px;
ScrollView {
x: 4px; y: 4px; width: parent.width - 8px; height: parent.height - 8px;
viewport-width: self.width;
viewport-height: root.list-height;
viewport-y <=> root.scroll-y;
Rectangle {
width: parent.width; height: root.list-height;
if root.has-divider: Rectangle {
x: 6px; y: root.system-count * 26px + 3px;
width: parent.width - 12px; height: 1px; background: #e9ebee;
}
for choice[index] in root.model: Rectangle {
y: index * 26px + (root.has-divider && index >= root.system-count ? 6px : 0px);
width: parent.width; height: 26px; border-radius: 3px;
background: index == root.current-index ? #edf4fc : option-touch.has-hover ? #f1f3f5 : transparent;
animate background { duration: 100ms; easing: ease-out; }
accessible-role: list-item;
accessible-label: choice;
Text {
x: 8px; width: parent.width - 34px; height: parent.height;
text: choice; color: #30343b; font-size: 12px;
overflow: elide; vertical-alignment: center;
accessible-role: none;
}
if index == root.current-index: Text {
x: parent.width - 24px; width: 16px; height: parent.height;
text: FontAwesomeIcons.check; font-family: "Font Awesome 6 Free";
font-weight: 900; font-size: 10px; color: #4e87c0;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
option-touch := TouchArea {
clicked => { root.choose(index); popup.close(); }
}
}
}
}
}
}
}
}
+269 -72
View File
@@ -1,3 +1,8 @@
import { GalleryStrings } from "device_strings.slint";
import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint";
import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint";
import { GalleryView, GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint";
import { GroupFilter } from "group_filter.slint";
import { LineEdit, ScrollView } from "std-widgets.slint";
import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint";
import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
@@ -6,7 +11,9 @@ export struct RecentConnection {
remote-id: string,
display-name: string,
host-name: string,
group-names: string,
online: bool,
connected: bool,
thumbnail: image,
has-thumbnail: bool,
platform: string,
@@ -286,20 +293,86 @@ export component MainWindow inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 640px;
preferred-height: 450px + root.titlebar-height;
preferred-height: 486px + root.titlebar-height;
min-width: 640px;
max-width: 640px;
min-height: 450px + root.titlebar-height;
max-height: 450px + root.titlebar-height;
max-width: root.gallery-mode ? 16384px : 640px;
min-height: 486px + root.titlebar-height;
max-height: root.gallery-mode ? 16384px : 486px + root.titlebar-height;
no-frame: root.custom-titlebar;
background: root.wayland-titlebar ? transparent : white;
default-font-size: ImGuiFontStyle.base;
in-out property <bool> gallery-mode: false;
in property <bool> gallery-fullscreen: false;
in property <bool> gallery-transitioning: false;
in property <bool> gallery-content-visible: true;
changed gallery-transitioning => {
if root.gallery-transitioning {
focus-sink.focus();
root.menu-open = false;
root.recent-tooltip-visible = false;
}
}
in-out property <string> connection-input-error;
in property <bool> gallery-controlling: false;
out property <float> gallery-video-width: gallery.video-width;
out property <float> gallery-video-height: gallery.video-height;
callback gallery-pointer(PointerEventButton, PointerEventKind, length, length);
callback gallery-scroll(length, length, length, length);
callback gallery-key(string, bool, bool, bool, bool, bool);
in property <[GalleryTile]> gallery-tiles;
in property <[string]> gallery-groups;
in-out property <int> gallery-group-index: 0;
in property <int> gallery-columns: 3;
in property <int> gallery-rows: 2;
in property <int> gallery-page: 0;
in property <int> gallery-pages: 1;
in property <int> gallery-count: 0;
in property <int> gallery-connected: 0;
in property <int> gallery-selected: 0;
in property <int> gallery-hidden-selected: 0;
in property <string> gallery-notice;
in property <[GalleryActionDevice]> gallery-selected-devices;
in property <GalleryTile> gallery-focused;
in-out property <bool> gallery-batch: false;
in-out property <bool> gallery-adding: false;
in-out property <bool> gallery-importing: false;
in property <[GalleryRecentDevice]> gallery-recent-devices;
in property <int> gallery-recent-selected: 0;
in property <string> gallery-import-group;
out property <float> gallery-width: root.width / 1px;
out property <float> gallery-height: gallery.height / 1px;
callback change-mode(bool);
callback toggle-gallery-fullscreen();
callback gallery-action(string, string);
callback gallery-add-device(string, string, string, bool) -> bool;
callback gallery-import-recent(string) -> bool;
in-out property <bool> device-group-open: false;
private property <string> device-group-id;
private property <string> device-group-name;
in property <[DeviceGroupOption]> device-group-options;
in property <string> device-group-error;
in-out property <string> device-group-new-name;
callback device-group-action(string, string);
callback device-group-save(string) -> bool;
function open-device-groups(id: string, name: string) {
root.gallery-action("release", "");
focus-sink.focus();
root.recent-tooltip-visible = false;
root.device-group-id = id;
root.device-group-name = name;
root.device-group-new-name = "";
root.device-group-action("open", id);
root.device-group-open = true;
}
public function reset-gallery-transient() { gallery.reset-transient(); root.menu-open = false; }
in property <bool> custom-titlebar: false;
in property <bool> wayland-titlebar: false;
in property <bool> window-active: true;
private property <length> titlebar-height:
root.custom-titlebar ? (root.wayland-titlebar ? 32px : 31px) : 0px;
root.custom-titlebar && !root.gallery-fullscreen ? (root.wayland-titlebar ? 32px : 31px) : 0px;
private property <length> content-header-height: 38px;
private property <length> wayland-corner-radius: 12px;
in property <string> local-id: "";
in property <string> local-password: "";
@@ -310,6 +383,13 @@ export component MainWindow inherits Window {
in property <bool> signal-reconnecting: false;
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
in property <[string]> recent-groups: [GalleryStrings.tr("全部设备", "All devices", "Все устройства"), GalleryStrings.tr("未分组", "Ungrouped", "Без группы")];
in-out property <int> recent-group-index: 0;
callback recent-group-selected(int);
changed recent-group-index => {
root.recent-tooltip-visible = false;
recent-scroll.viewport-x = 0px;
}
in property <[AnnouncementItem]> announcements;
in property <int> announcement-unread;
private property <string> announcement-unread-label:
@@ -389,13 +469,14 @@ export component MainWindow inherits Window {
// as disabled so the fixed settings layout never turns into a blank row.
in property <bool> hardware-codec-available: true;
callback copy-local-id;
callback copy-local-id() -> bool;
callback toggle-password-visibility;
callback refresh-password;
callback reset-password(string) -> bool;
callback format-remote-id(string) -> string;
callback connect-requested(string);
callback recent-connect(string);
callback recent-disconnect(string);
callback recent-edit-alias(string, string);
callback recent-delete(string);
callback save-settings() -> bool;
@@ -439,29 +520,28 @@ export component MainWindow inherits Window {
in-out property <string> alias-input: "";
in-out property <bool> delete-open: false;
private property <string> delete-remote-id: "";
private property <bool> delete-from-gallery: false;
private property <bool> copy-toast: false;
private property <bool> compact-language: root.language-index == 0;
private property <bool> recent-tooltip-visible: false;
private property <string> recent-tooltip-name: "";
private property <string> recent-tooltip-host: "";
private property <string> recent-tooltip-id: "";
private property <string> recent-tooltip-groups: "";
private property <bool> recent-tooltip-online: false;
private property <length> recent-tooltip-pointer-x: 0px;
private property <length> recent-tooltip-pointer-y: 0px;
private property <length> recent-tooltip-width: 260px;
private property <length> recent-tooltip-height:
root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name
? 80px : 64px;
private property <length> recent-tooltip-x: min(
root.width - root.recent-tooltip-width - 8px,
max(8px,
root.recent-tooltip-pointer-x > root.width * 0.7
? root.recent-tooltip-pointer-x - root.recent-tooltip-width - 12px
: root.recent-tooltip-pointer-x + 12px));
private property <length> recent-tooltip-y: min(
root.height - root.titlebar-height - root.recent-tooltip-height - 8px,
max(8px, root.recent-tooltip-pointer-y - root.recent-tooltip-height - 8px));
changed menu-open => { if root.menu-open { root.gallery-action("release", ""); } }
changed connection-dialog-open => { if root.connection-dialog-open { root.gallery-action("release", ""); } }
changed permission-dialog-open => { if root.permission-dialog-open { root.gallery-action("release", ""); } }
changed settings-open => {
if root.settings-open {
root.settings-scroll-y = 0px;
@@ -490,7 +570,7 @@ export component MainWindow inherits Window {
root.wayland-titlebar ? root.wayland-corner-radius : 0px;
clip: root.wayland-titlebar;
if root.custom-titlebar && root.wayland-titlebar: Rectangle {
if root.custom-titlebar && root.wayland-titlebar && !root.gallery-fullscreen: Rectangle {
x: 0px;
y: 0px;
width: parent.width;
@@ -583,7 +663,7 @@ export component MainWindow inherits Window {
}
}
if root.custom-titlebar && !root.wayland-titlebar: Rectangle {
if root.custom-titlebar && !root.wayland-titlebar && !root.gallery-fullscreen: Rectangle {
x: 0px;
y: 0px;
width: root.width;
@@ -673,6 +753,12 @@ export component MainWindow inherits Window {
width: root.width;
height: root.height - self.y;
clip: true;
opacity: root.gallery-content-visible || !root.gallery-mode ? 1 : 0;
animate opacity {
duration: 60ms;
easing: ease-in-out;
enabled: root.gallery-mode;
}
Rectangle {
width: parent.width;
@@ -714,10 +800,10 @@ export component MainWindow inherits Window {
// App actions live in the content header; window controls and dragging
// remain in the native or Windows-specific title bar above it.
menu-button := IconButton {
x: parent.width - 40px;
y: 4px;
width: 32px;
height: 32px;
x: parent.width - mode-switch.x - self.width;
y: mode-switch.y;
width: mode-switch.height;
height: mode-switch.height;
icon: FontAwesomeIcons.bars;
icon-size: 14px;
hover-background: #e1e5eb;
@@ -726,7 +812,7 @@ export component MainWindow inherits Window {
? MessageStrings.unread + " " + root.announcement-unread + (root.update-available ? " · " + UiStrings.update-available : "")
: root.update-available ? UiStrings.update-available : "";
z: 8;
clicked => { root.menu-open = !root.menu-open; }
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
// Unread messages take precedence so the menu has only one indicator.
if root.update-available && root.announcement-unread <= 0: Rectangle {
@@ -767,7 +853,7 @@ export component MainWindow inherits Window {
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.menu-open = !root.menu-open; }
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
}
if root.announcement-unread > 0: Rectangle {
@@ -794,10 +880,51 @@ export component MainWindow inherits Window {
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.menu-open = !root.menu-open; }
clicked => { root.gallery-action("release", ""); root.menu-open = !root.menu-open; }
}
}
mode-switch := Rectangle {
x: 29px; y: root.content-header-height - self.height; width: 164px; height: 24px;
background: #eef1f5;
border-width: 0.5px; border-color: #d5dbe3; border-radius: 6px;
Rectangle {
x: root.gallery-mode ? 82px : 2px;
y: 2px; width: 80px; height: parent.height - 4px;
background: @linear-gradient(180deg, #c4e0fd, #abd0f8);
border-width: 0.5px; border-color: #97bde5; border-radius: 4px;
drop-shadow-color: #315b8b20;
drop-shadow-blur: 2px; drop-shadow-offset-y: 1px;
Rectangle {
x: 1px; y: 1px; width: parent.width - 2px; height: parent.height - 2px;
border-width: 0.5px; border-color: #ffffff70; border-radius: 3px;
}
animate x { duration: 160ms; easing: ease-out; }
}
for label[index] in [GalleryStrings.tr("连接", "Connect", "Связь"), GalleryStrings.tr("桌面墙", "DeskHub", "DeskHub")]: Rectangle {
x: 2px + index * 80px; y: 2px; width: 80px; height: parent.height - 4px;
Text {
text: label;
color: (index == 1) == root.gallery-mode ? #234b75 : #697483;
font-weight: (index == 1) == root.gallery-mode ? 600 : 400;
horizontal-alignment: center; vertical-alignment: center; font-size: 12px;
animate color { duration: 120ms; easing: ease-out; }
}
TouchArea {
clicked => { root.change-mode(index == 1); }
}
}
}
if root.gallery-mode: CompactButton {
x: menu-button.x - self.width - 8px;
y: mode-switch.y;
width: 87px;
height: mode-switch.height;
text: root.gallery-fullscreen ? GalleryStrings.tr("退出全屏", "Exit full", "Выйти") : GalleryStrings.tr("全屏", "Fullscreen", "На весь экран");
clicked => { root.toggle-gallery-fullscreen(); }
}
Rectangle {
y: root.content-header-height; height: parent.height - self.y - 24px; visible: !root.gallery-mode;
// Local and remote desktop panels.
Rectangle {
y: 0px;
@@ -856,13 +983,14 @@ export component MainWindow inherits Window {
y: 46px;
width: 29px;
height: 30px;
icon: FontAwesomeIcons.copy;
icon: root.copy-toast ? FontAwesomeIcons.check : FontAwesomeIcons.copy;
icon-size: 16px;
tooltip: UiStrings.copied;
clicked => {
root.copy-local-id();
root.copy-toast = true;
copy-timer.restart();
if root.copy-local-id() {
root.copy-toast = true;
copy-timer.restart();
}
}
}
Rectangle { x: 8px; y: 88px; width: 240px; height: 1px; background: ImGuiLineStyle.separator; }
@@ -921,7 +1049,7 @@ export component MainWindow inherits Window {
}
}
Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.divider; }
Rectangle { x: parent.width / 2; y: 18px; width: 1px; height: 206px; background: ImGuiLineStyle.border; }
SectionTitle { x: 356px; y: 9px; width: 256px; height: 31px; text: UiStrings.remote-desktop; }
Rectangle {
@@ -959,10 +1087,11 @@ export component MainWindow inherits Window {
// Run formatting after the input event has
// finished so the caret state isn't reset by
// a synchronous two-way binding rewrite.
edited => { remote-id-format-timer.restart(); }
edited => { root.connection-input-error = ""; remote-id-format-timer.restart(); }
accepted => { root.connect-requested(root.remote-id-input); }
}
}
Text { x: 8px; y: 92px; width: 240px; height: 52px; text: root.connection-input-error; font-size: 12px; color: #a33832; wrap: word-wrap; }
IconButton {
x: 175px;
y: 42px;
@@ -982,7 +1111,7 @@ export component MainWindow inherits Window {
// Recent connections: horizontal cards with thumbnail, state and actions.
recent-panel := Rectangle {
y: 234px;
height: 192px;
height: parent.height - self.y;
background: white;
border-width: 0px;
border-color: ImGuiLineStyle.border;
@@ -990,6 +1119,13 @@ export component MainWindow inherits Window {
Rectangle { y: 0px; height: 1px; background: ImGuiLineStyle.border; }
SectionTitle { x: 29px; y: 7px; height: 28px; text: UiStrings.recent-connections; }
GroupFilter {
x: parent.width - self.width - 29px; y: 8px;
width: 155px; height: 28px;
model: root.recent-groups;
current-index <=> root.recent-group-index;
selected(index) => { root.recent-group-selected(index); }
}
recent-list := Rectangle {
x: 29px;
@@ -1002,6 +1138,13 @@ export component MainWindow inherits Window {
border-radius: 10px;
clip: true;
if root.recent-connections.length == 0: Text {
text: root.recent-group-index == 0
? GalleryStrings.tr("暂无近期连接", "No recent connections", "Нет недавних подключений")
: GalleryStrings.tr("该分组暂无近期连接", "No recent connections in this group", "В этой группе нет недавних подключений");
color: #808080; font-size: ImGuiFontStyle.base;
horizontal-alignment: center; vertical-alignment: center;
}
recent-scroll := ScrollView {
width: parent.width;
height: parent.height;
@@ -1018,7 +1161,7 @@ export component MainWindow inherits Window {
height: 117px;
background: transparent;
private property <length> content-width: self.width - 30px;
private property <length> toolbar-width: 79px;
private property <length> toolbar-width: DeviceCardStyle.toolbar-width;
private property <length> toolbar-right-padding: 3px;
Rectangle {
@@ -1113,12 +1256,19 @@ export component MainWindow inherits Window {
image-touch := TouchArea {
width: parent.width;
height: 84px;
private property <string> group-names: connection.group-names;
changed group-names => {
if self.has-hover && root.recent-tooltip-id == connection.remote-id {
root.recent-tooltip-groups = self.group-names;
}
}
clicked => { root.recent-connect(connection.remote-id); }
changed has-hover => {
if self.has-hover {
root.recent-tooltip-name = connection.display-name;
root.recent-tooltip-host = connection.host-name;
root.recent-tooltip-id = connection.remote-id;
root.recent-tooltip-groups = self.group-names;
root.recent-tooltip-online = connection.online;
root.recent-tooltip-pointer-x = recent-panel.x + recent-list.x
+ recent-scroll.x + recent-scroll.viewport-x
@@ -1142,41 +1292,24 @@ export component MainWindow inherits Window {
}
}
}
if card-touch.has-hover: Rectangle {
if card-touch.has-hover: DeviceCardActions {
x: parent.width - self.width - connection-card.toolbar-right-padding;
y: 85px;
width: connection-card.toolbar-width;
height: 23px;
background: #4a4e55dc;
border-radius: 5px;
HorizontalLayout {
spacing: 0px;
IconButton {
width: 26px; height: 23px; icon: FontAwesomeIcons.pen; icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
clicked => {
root.alias-remote-id = connection.remote-id;
root.alias-input = connection.display-name;
root.alias-open = true;
}
}
IconButton {
width: 26px; height: 23px; icon: FontAwesomeIcons.trash-can; icon-size: 12px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
clicked => {
root.delete-remote-id = connection.remote-id;
root.delete-open = true;
}
}
IconButton {
width: 27px; height: 23px; icon: FontAwesomeIcons.arrow-right-long; icon-size: 13px; icon-color: white;
hover-background: #ffffff2e;
pressed-background: #598cf273;
clicked => { root.recent-connect(connection.remote-id); }
}
remove-label: GalleryStrings.tr("删除记录", "Delete record", "Удалить запись");
connected: connection.connected;
edit-alias => {
root.alias-remote-id = connection.remote-id;
root.alias-input = connection.display-name;
root.alias-open = true;
}
edit-groups => { root.open-device-groups(connection.remote-id, connection.display-name); }
remove-device => {
root.delete-remote-id = connection.remote-id;
root.delete-from-gallery = false;
root.delete-open = true;
}
connect-device => { root.recent-connect(connection.remote-id); }
disconnect-device => { root.recent-disconnect(connection.remote-id); }
}
}
if index < root.recent-connections.length - 1: Rectangle {
@@ -1192,9 +1325,38 @@ export component MainWindow inherits Window {
}
}
}
gallery := GalleryView {
y: root.content-header-height; width: parent.width; height: parent.height - self.y - 24px;
visible: root.gallery-mode;
controlling: root.gallery-controlling;
pointer-input(button, kind, x, y) => { root.gallery-pointer(button, kind, x, y); }
scroll-input(dx, dy, x, y) => { root.gallery-scroll(dx, dy, x, y); }
key-input(text, pressed, ctrl, alt, shift, meta) => { root.gallery-key(text, pressed, ctrl, alt, shift, meta); }
tiles: root.gallery-tiles; groups: root.gallery-groups;
group-index <=> root.gallery-group-index;
columns: root.gallery-columns; rows: root.gallery-rows;
page: root.gallery-page; pages: root.gallery-pages; count: root.gallery-count;
connected-count: root.gallery-connected; selected-count: root.gallery-selected;
hidden-selected: root.gallery-hidden-selected; notice: root.gallery-notice;
selected-devices: root.gallery-selected-devices;
focused: root.gallery-focused; batch <=> root.gallery-batch; adding <=> root.gallery-adding;
importing <=> root.gallery-importing; recent-devices: root.gallery-recent-devices;
recent-selected: root.gallery-recent-selected; import-group: root.gallery-import-group;
action(code, value) => { root.gallery-action(code, value); }
add-device(id, password, group, remember) => { root.gallery-add-device(id, password, group, remember) }
import-recent(group) => { root.gallery-import-recent(group) }
edit-alias(id, name) => {
root.alias-remote-id = id; root.alias-input = name; root.alias-open = true;
}
edit-groups(id, name) => { root.open-device-groups(id, name); }
remove-device(id) => {
root.delete-remote-id = id; root.delete-from-gallery = true; root.delete-open = true;
}
}
// Status bar.
Rectangle {
y: 426px;
y: parent.height - 24px;
height: 24px;
background: #fafbfc;
Rectangle {
@@ -1255,9 +1417,11 @@ export component MainWindow inherits Window {
// pointer can otherwise cover a dialog button and hide it from the user.
if root.recent-tooltip-visible && !root.connection-dialog-open: Rectangle {
x: root.recent-tooltip-x;
y: root.recent-tooltip-y;
y: min(root.height - root.titlebar-height - self.height - 8px,
max(8px, root.recent-tooltip-pointer-y - self.height - 8px));
width: root.recent-tooltip-width;
height: root.recent-tooltip-height;
height: (root.recent-tooltip-host != "" && root.recent-tooltip-host != root.recent-tooltip-name
? 80px : 64px) + tooltip-groups.preferred-height + 2px;
background: #22252bf2;
border-width: 1px;
border-color: #ffffff38;
@@ -1285,6 +1449,14 @@ export component MainWindow inherits Window {
font-size: ImGuiFontStyle.small;
overflow: elide;
}
tooltip-groups := Text {
text: GalleryStrings.tr("分组", "Groups", "Группы") + ": " +
(root.recent-tooltip-groups == "" ? GalleryStrings.tr("未分组", "Ungrouped", "Без группы") : root.recent-tooltip-groups);
color: #e1e4e8;
font-size: ImGuiFontStyle.small;
wrap: word-wrap;
vertical-stretch: 0;
}
Text {
text: root.recent-tooltip-online ? UiStrings.online : UiStrings.offline;
color: root.recent-tooltip-online ? #62d98b : #d1d5db;
@@ -1536,14 +1708,32 @@ export component MainWindow inherits Window {
}
}
// One editor and one callback path for recent connections and DeskHub.
if root.device-group-open: DeviceGroupDialog {
z: 40;
device-id: root.device-group-id;
device-name: root.device-group-name;
options: root.device-group-options;
error: root.device-group-error;
new-group <=> root.device-group-new-name;
toggle-group(name) => { root.device-group-action("toggle", name); }
save(name) => {
if root.device-group-save(name) {
root.device-group-action("close", "");
root.device-group-open = false;
}
}
cancel => { root.device-group-action("close", ""); root.device-group-open = false; }
}
// Recent connection alias modal.
if root.alias-open: Rectangle {
background: #00000042;
z: 40;
TouchArea { clicked => { focus-sink.focus(); } }
DialogSurface {
x: root.compact-language ? 205px : 160px;
y: 145px;
x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px;
y: root.gallery-mode ? (parent.height - self.height) / 2 : 145px;
width: root.compact-language ? 230px : 320px;
height: 160px;
VerticalLayout {
@@ -1588,16 +1778,16 @@ export component MainWindow inherits Window {
z: 40;
TouchArea { clicked => { focus-sink.focus(); } }
DialogSurface {
x: root.compact-language ? 205px : 160px;
y: 155px;
x: root.gallery-mode ? (parent.width - self.width) / 2 : root.compact-language ? 205px : 160px;
y: root.gallery-mode ? (parent.height - self.height) / 2 : 155px;
width: root.compact-language ? 230px : 320px;
height: 140px;
VerticalLayout {
padding: 20px; spacing: 18px;
DialogPromptText { prompt: UiStrings.confirm-delete; }
DialogPromptText { prompt: root.delete-from-gallery ? (root.gallery-group-index > 0 ? GalleryStrings.tr("从当前分组移除此设备?", "Remove from this group?", "Убрать из этой группы?") : GalleryStrings.tr("从桌面墙移除此设备?", "Remove from DeskHub?", "Убрать из DeskHub?")) : UiStrings.confirm-delete; }
HorizontalLayout {
alignment: center; spacing: 8px;
PrimaryButton { text: UiStrings.ok; clicked => { root.recent-delete(root.delete-remote-id); root.delete-open = false; } }
PrimaryButton { text: UiStrings.ok; clicked => { if root.delete-from-gallery { root.gallery-action("remove", root.delete-remote-id); } else { root.recent-delete(root.delete-remote-id); } root.delete-open = false; } }
SecondaryButton { text: UiStrings.cancel; clicked => { root.delete-open = false; } }
}
}
@@ -1672,8 +1862,8 @@ export component MainWindow inherits Window {
text: UiStrings.ok;
clicked => { root.connection-submit-password(root.connection-password, root.connection-remember-password); }
}
if !root.connection-validating: ConnectionDialogButton {
x: 117px;
ConnectionDialogButton {
x: root.connection-validating ? (parent.width - self.width) / 2 : 117px;
y: 118px;
text: UiStrings.cancel;
clicked => { root.connection-cancel(); }
@@ -2265,6 +2455,13 @@ export component MainWindow inherits Window {
}
}
// Ignore clicks against moving cards and duplicate fullscreen requests.
if root.gallery-transitioning: TouchArea {
x: 0px; y: root.titlebar-height;
width: root.width; height: root.height - self.y;
z: 250;
}
if root.custom-titlebar: Rectangle {
x: 0px;
y: 0px;
@@ -40,6 +40,7 @@ inline int ApplyMainWindowStrings(
int language_index) {
const int language =
localization::detail::ClampLanguageIndex(language_index);
window->global<ui::GalleryStrings>().set_language(language);
auto& strings = window->global<ui::UiStrings>();
auto& messages = window->global<ui::MessageStrings>();
messages.set_messages(Text(localization::messages[language]));