[ui] replace host session window with compact edge controls

This commit is contained in:
dijunkun
2026-10-09 05:44:10 +08:00
parent 9d613a5a13
commit 1d47256cbc
10 changed files with 552 additions and 306 deletions
@@ -665,7 +665,7 @@ struct WindowDragState {
slint::Timer pointer_timer;
#endif
bool ReadGlobalPointer(float* x, float* y) {
bool ReadGlobalPointer(float* x, float* y, bool* left_pressed = nullptr) {
#if defined(__linux__) && !defined(__APPLE__)
const char* driver = SDL_GetCurrentVideoDriver();
if (driver && std::strcmp(driver, "x11") == 0) {
@@ -686,12 +686,14 @@ struct WindowDragState {
&buttons)) {
return false;
}
if (left_pressed) *left_pressed = (buttons & Button1Mask) != 0;
*x = static_cast<float>(root_x);
*y = static_cast<float>(root_y);
return true;
}
#endif
SDL_GetGlobalMouseState(x, y);
const auto buttons = SDL_GetGlobalMouseState(x, y);
if (left_pressed) *left_pressed = (buttons & SDL_BUTTON_LMASK) != 0;
return true;
}
};
@@ -710,14 +712,6 @@ bool CanUseGlobalPointerPosition() {
#endif
}
float ServerWindowLogicalWidth(int language) {
return language == 0 ? 250.0f : language == 1 ? 330.0f : 430.0f;
}
float ServerWindowLogicalHeight(bool collapsed) {
return collapsed ? 30.0f : 150.0f;
}
constexpr float kStreamWindowLogicalWidth = 1280.0f;
constexpr float kStreamWindowLogicalHeight = 720.0f;
constexpr float kWaylandTitlebarLogicalHeight = 32.0f;
@@ -798,57 +792,6 @@ bool PositionWindowAtCenter(slint::Window& window, const slint::Window& anchor,
return true;
}
bool PositionWindowAtBottomRight(slint::Window& window, float logical_width,
float logical_height) {
if (!CanUseGlobalPointerPosition()) {
return false;
}
const auto position = window.position();
const auto size = window.size();
#if defined(__APPLE__)
const float scale = std::max(window.scale_factor(), 0.01f);
const SDL_Point center{
static_cast<int>(
std::lround(position.x / scale + size.width / (2.0f * scale))),
static_cast<int>(
std::lround(position.y / scale + size.height / (2.0f * scale)))};
#else
const SDL_Point center{
position.x + static_cast<int>(size.width / 2),
position.y + static_cast<int>(size.height / 2)};
#endif
SDL_DisplayID display = SDL_GetDisplayForPoint(&center);
if (display == 0) {
display = SDL_GetPrimaryDisplay();
}
SDL_Rect usable_bounds{};
if (display == 0 || !SDL_GetDisplayUsableBounds(display, &usable_bounds)) {
LOG_WARN("Unable to obtain usable display bounds for server window: {}",
SDL_GetError());
return false;
}
#if defined(__APPLE__)
const float target_x =
static_cast<float>(usable_bounds.x) +
std::max(0.0f, static_cast<float>(usable_bounds.w) - logical_width);
const float target_y =
static_cast<float>(usable_bounds.y) +
std::max(0.0f, static_cast<float>(usable_bounds.h) - logical_height);
window.set_position(
slint::LogicalPosition(slint::Point<float>{target_x, target_y}));
#else
const auto target = window_geometry::BottomRightPosition(
PhysicalDisplayBounds(usable_bounds),
PhysicalWindowSize(window, logical_width, logical_height));
window.set_position(slint::PhysicalPosition(
slint::Point<int32_t>{target.x, target.y}));
#endif
return true;
}
#if _WIN32
bool ConfigureWindowsServerWindow(slint::Window& window) {
const HWND hwnd = window.win32_hwnd();
@@ -977,6 +920,7 @@ struct GuiApplication::SlintUi {
std::make_shared<slint::VectorModel<ui::ReleaseNoteBlock>>();
std::optional<slint::ComponentHandle<ui::StreamWindow>> stream;
std::optional<slint::ComponentHandle<ui::ServerWindow>> server;
std::optional<slint::ComponentHandle<ui::ServerStatusBar>> server_bar;
std::shared_ptr<slint::VectorModel<ui::RecentConnection>> recent_model =
std::make_shared<slint::VectorModel<ui::RecentConnection>>();
std::shared_ptr<slint::VectorModel<ui::StreamTab>> tab_model =
@@ -989,26 +933,32 @@ struct GuiApplication::SlintUi {
std::make_shared<slint::VectorModel<ui::NetworkStatsRow>>();
std::shared_ptr<slint::VectorModel<ui::ControllerEntry>> controller_model =
std::make_shared<slint::VectorModel<ui::ControllerEntry>>();
std::shared_ptr<slint::VectorModel<slint::SharedString>>
controller_name_model =
std::make_shared<slint::VectorModel<slint::SharedString>>();
std::unique_ptr<SlintVideoPresenter> video_presenter;
std::vector<std::string> tab_order;
std::vector<std::string> tab_ids;
std::string tab_model_signature;
std::vector<std::string> controller_ids;
std::string controller_model_signature;
std::string connection_dialog_remote_id;
bool connection_password_initialized = false;
bool portable_service_dialog_initialized = false;
std::string recent_model_signature;
slint::Timer timer;
slint::Timer server_file_dialog_timer;
WindowDragState main_drag;
WindowDragState stream_drag;
WindowDragState server_drag;
int main_native_titlebar_attempts = 30;
int stream_live_resize_configuration_attempts = 0;
int stream_initial_position_attempts = 0;
int server_initial_position_attempts = 0;
SDL_DisplayID server_display = 0;
float server_anchor = 0.3f;
float server_drag_anchor = 0.3f;
bool server_drag_moved = false;
bool server_pointer_pressed = false;
bool server_file_dialog_open = false;
bool server_suspended_for_dialog = false;
server_window_state::EdgeLayout server_layout{};
#if _WIN32
int server_native_window_attempts = 0;
#endif
@@ -1859,18 +1809,37 @@ void GuiApplication::BindServerCallbacks() {
return;
}
auto& server = *ui_->server;
server->on_title_drag([this](int phase, float x, float y) {
auto& bar = *ui_->server_bar;
bar->on_title_drag([this](int phase, float, float) {
auto& drag = ui_->server_drag;
DragWindow(*ui_->server, phase, x, y, drag);
if (phase == 1) {
ui_->server_drag_moved = false;
(*ui_->server_bar)->set_dragged(false);
drag.active = CanUseGlobalPointerPosition() &&
drag.ReadGlobalPointer(&drag.pointer_start_x, &drag.pointer_start_y);
ui_->server_drag_anchor = ui_->server_anchor;
} else if (phase == 0) {
PositionServerWindows();
drag.active = false;
}
});
server->on_toggle_collapsed([this](bool collapsed) {
server_window_collapsed_ = collapsed;
const int language = (*ui_->server)->get_language_index();
const float width = ServerWindowLogicalWidth(language);
(*ui_->server)
->window()
.set_size(slint::LogicalSize(
slint::Size<float>{width, ServerWindowLogicalHeight(collapsed)}));
bar->on_toggle_panel([this] {
SetServerPanelExpanded(server_window_collapsed_);
});
bar->on_move_anchor([this](float delta) {
ui_->server_anchor = std::clamp(ui_->server_anchor + delta, 0.0f, 1.0f);
PositionServerWindows();
});
const auto dismiss = [this] { SetServerPanelExpanded(false); };
bar->on_dismiss_panel(dismiss);
server->on_dismiss_panel(dismiss);
server->window().on_close_requested([dismiss] {
dismiss();
return slint::CloseRequestResponse::KeepWindowShown;
});
bar->window().on_close_requested([dismiss] {
dismiss();
return slint::CloseRequestResponse::KeepWindowShown;
});
server->on_controller_selected([this](int index) {
if (index < 0 || index >= static_cast<int>(ui_->controller_ids.size())) {
@@ -1879,17 +1848,43 @@ void GuiApplication::BindServerCallbacks() {
selected_server_remote_id_ = ui_->controller_ids[index];
});
server->on_select_file([this] {
if (selected_server_remote_id_.empty()) {
if (selected_server_remote_id_.empty() || ui_->server_file_dialog_open) {
return;
}
transfers_.ProcessSelectedFile(
OpenFileDialog(localization::select_file[localization_language_index_]),
nullptr, file_label_, selected_server_remote_id_);
// A native file picker runs a nested event loop. Keep the recipient stable
// if another controller connects or leaves while it is open.
const std::string recipient = selected_server_remote_id_;
ui_->server_file_dialog_open = true;
(*ui_->server)->window().request_redraw();
// TouchArea clears pressed only after its clicked callback returns. Allow
// that release and a repaint before entering the blocking native picker.
ui_->server_file_dialog_timer.start(
slint::TimerMode::SingleShot, std::chrono::milliseconds(32),
[this, recipient] {
const auto connected = [this, &recipient] {
std::shared_lock lock(connection_status_mutex_);
const auto found = connection_status_.find(recipient);
return found != connection_status_.end() &&
found->second == ConnectionStatus::Connected;
};
if (!connected()) {
ui_->server_file_dialog_open = false;
return;
}
const auto path = OpenFileDialog(
localization::select_file[localization_language_index_]);
ui_->server_file_dialog_open = false;
if (connected() && !path.empty())
transfers_.ProcessSelectedFile(path, nullptr, file_label_, recipient);
});
});
server->on_disconnect_controller([this] {
if (peer_ && !selected_server_remote_id_.empty()) {
CloseServerController(selected_server_remote_id_);
}
server->on_disconnect_controller([this](slint::SharedString id) {
CloseServerController(std::string(id));
});
server->on_disconnect_all([this] {
// Work from the confirmed UI snapshot; a later arrival is a new session.
const auto ids = ui_->controller_ids;
for (const auto& id : ids) CloseServerController(id);
});
}
@@ -3253,6 +3248,96 @@ void GuiApplication::ScheduleNextVideoFrame() {
[this] { ScheduleNextVideoFrame(); });
}
void GuiApplication::SetServerPanelExpanded(bool expanded) {
if (!ui_->server || !ui_->server_bar) return;
server_window_collapsed_ = !expanded;
(*ui_->server_bar)->set_expanded(expanded);
if (expanded) {
PositionServerWindows();
(*ui_->server)->show();
#if _WIN32
ConfigureWindowsServerWindow((*ui_->server)->window());
#endif
PositionServerWindows();
} else {
(*ui_->server)->set_confirming_disconnect(false);
(*ui_->server)->hide();
}
}
void GuiApplication::PositionServerWindows() {
if (!ui_->server || !ui_->server_bar) return;
auto& bar = *ui_->server_bar;
auto& panel = *ui_->server;
const float scale = std::max(bar->window().scale_factor(), 0.01f);
SDL_Rect bounds{};
if (!SDL_GetDisplayUsableBounds(ui_->server_display, &bounds)) {
ui_->server_display = SDL_GetPrimaryDisplay();
if (!SDL_GetDisplayUsableBounds(ui_->server_display, &bounds)) return;
}
// Cocoa/SDL uses points; Windows and X11 report desktop pixels.
#if defined(__APPLE__)
const float desktop_scale = 1;
#else
const float desktop_scale = scale;
#endif
const server_window_state::Rect screen{
bounds.x / desktop_scale, bounds.y / desktop_scale,
bounds.w / desktop_scale, bounds.h / desktop_scale};
float pointer_x = 0, pointer_y = 0;
bool pressed = false;
const bool pointer_valid = CanUseGlobalPointerPosition() &&
ui_->server_drag.ReadGlobalPointer(&pointer_x, &pointer_y, &pressed);
if (pointer_valid && ui_->server_drag.active) {
const float delta = (pointer_y - ui_->server_drag.pointer_start_y) / desktop_scale;
if (std::abs(delta) >= 4) {
ui_->server_drag_moved = true;
bar->set_dragged(true);
}
if (ui_->server_drag_moved) {
const float travel = std::max(1.0f, screen.height - 52.0f);
ui_->server_anchor = std::clamp(ui_->server_drag_anchor + delta / travel, 0.0f, 1.0f);
}
if (!pressed) ui_->server_drag.active = false;
}
ui_->server_layout = server_window_state::PlacePanel(
screen, ui_->server_anchor, bar->get_bar_width(), panel->get_content_height());
const auto& layout = ui_->server_layout;
bar->set_opens_above(layout.above);
const auto apply = [scale](slint::Window& window, const server_window_state::Rect& rect) {
const auto size = window.size();
const float own_scale = std::max(window.scale_factor(), 0.01f);
if (std::abs(size.width / own_scale - rect.width) > 0.5f ||
std::abs(size.height / own_scale - rect.height) > 0.5f) {
window.set_size(slint::LogicalSize(slint::Size<float>{rect.width, rect.height}));
}
if (!CanUseGlobalPointerPosition()) return;
const auto position = window.position();
if (std::abs(position.x / scale - rect.x) <= 0.5f &&
std::abs(position.y / scale - rect.y) <= 0.5f) return;
#if defined(__APPLE__)
window.set_position(slint::LogicalPosition(slint::Point<float>{rect.x, rect.y}));
#else
window.set_position(slint::PhysicalPosition(slint::Point<int32_t>{
static_cast<int32_t>(std::lround(rect.x * scale)),
static_cast<int32_t>(std::lround(rect.y * scale))}));
#endif
};
apply(bar->window(), layout.bar);
apply(panel->window(), layout.panel);
if (pointer_valid) {
const float x = pointer_x / desktop_scale;
const float y = pointer_y / desktop_scale;
if (pressed && !ui_->server_pointer_pressed && !server_window_collapsed_ &&
!ui_->server_file_dialog_open && !ui_->server_drag.active &&
!server_window_state::Contains(layout.bar, x, y) &&
!server_window_state::Contains(layout.panel, x, y)) {
SetServerPanelExpanded(false);
}
ui_->server_pointer_pressed = pressed;
}
}
void GuiApplication::SyncServerWindow() {
// The connection map is authoritative. Lifecycle flags record callback
// intent, but callbacks for different controllers can cross each other, so
@@ -3283,22 +3368,21 @@ void GuiApplication::SyncServerWindow() {
#endif
if (show_controller_window && !ui_->server) {
ui_->server.emplace(ui::ServerWindow::create());
ui_->server_bar.emplace(ui::ServerStatusBar::create());
RegisterFontAwesome((*ui_->server)->window());
RegisterFontAwesome((*ui_->server_bar)->window());
(*ui_->server)->set_controllers(ui_->controller_model);
(*ui_->server)->set_controller_names(ui_->controller_name_model);
(*ui_->server)->set_language_index(localization_language_index_);
server_window_collapsed_ = false;
(*ui_->server)
->window()
.set_size(slint::LogicalSize(slint::Size<float>{
ServerWindowLogicalWidth(localization_language_index_),
ServerWindowLogicalHeight(false)}));
server_window_collapsed_ = true;
ui_->server_display = DisplayForSlintWindow(ui_->main->window());
ui_->server_anchor = 0.3f;
ui_->server_drag.active = false;
ui_->server_drag_moved = false;
ui_->server_pointer_pressed = false;
ui_->server_suspended_for_dialog = false;
BindServerCallbacks();
(*ui_->server)->show();
// Apply once immediately and once after the next layout pass. This keeps
// the right and bottom edges correct when the backend resolves DPI and
// language-dependent preferred width asynchronously on first show.
ui_->server_initial_position_attempts = 2;
ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_);
ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_);
(*ui_->server_bar)->show();
#if _WIN32
ui_->server_native_window_attempts = 30;
#endif
@@ -3307,98 +3391,75 @@ void GuiApplication::SyncServerWindow() {
}
if (!show_controller_window && ui_->server) {
(*ui_->server)->hide();
(*ui_->server_bar)->hide();
// Keep handles alive through a native file dialog's nested event loop.
if (ui_->server_file_dialog_open) {
server_window_collapsed_ = true;
(*ui_->server_bar)->set_expanded(false);
(*ui_->server)->set_confirming_disconnect(false);
ui_->server_suspended_for_dialog = true;
return;
}
ui_->server.reset();
ui_->server_bar.reset();
ui_->server_drag.active = false;
server_window_created_ = false;
server_window_inited_ = false;
ui_->server_initial_position_attempts = 0;
#if _WIN32
ui_->server_native_window_attempts = 0;
#endif
}
if (!ui_->server) {
return;
if (!ui_->server) return;
if (ui_->server_suspended_for_dialog) {
(*ui_->server_bar)->show();
ui_->server_suspended_for_dialog = false;
}
#if _WIN32
ConfigureWindowsWindowIcons((*ui_->server)->window().win32_hwnd());
#endif
std::vector<ui::ControllerEntry> controllers;
std::vector<slint::SharedString> names;
ui_->controller_ids.clear();
{
std::shared_lock lock(connection_status_mutex_);
controllers.reserve(connection_status_.size());
for (const auto& [id, _] : connection_status_) {
for (const auto& [id, status] : connection_status_) {
if (status != ConnectionStatus::Connected) continue;
const auto host = connection_host_names_.find(id);
const std::string name =
host != connection_host_names_.end() && !host->second.empty()
? host->second
: id;
ui::ControllerEntry entry;
entry.remote_id = UiText(id);
entry.display_name = UiText(name);
controllers.push_back(entry);
names.emplace_back(UiText(name));
ui_->controller_ids.push_back(id);
? host->second : id;
controllers.push_back({UiText(id), UiText(name)});
}
}
ui_->controller_model->set_vector(std::move(controllers));
ui_->controller_name_model->set_vector(std::move(names));
// Stable ordering and model identity preserve radio selection, hover and
// scrolling while the 16 ms UI timer reconciles asynchronous peer events.
std::sort(controllers.begin(), controllers.end(), [](const auto& a, const auto& b) {
return std::string(a.remote_id) < std::string(b.remote_id);
});
std::vector<std::string> ids;
std::string signature;
for (const auto& controller : controllers) {
ids.emplace_back(controller.remote_id);
signature += std::string(controller.remote_id) + '\0' +
std::string(controller.display_name) + '\0';
}
if (ids != ui_->controller_ids) (*ui_->server)->set_confirming_disconnect(false);
ui_->controller_ids = std::move(ids);
if (signature != ui_->controller_model_signature) {
ui_->controller_model->set_vector(std::move(controllers));
ui_->controller_model_signature = std::move(signature);
}
const int selected = server_window_state::ReconcileSelectedController(
ui_->controller_ids, &selected_server_remote_id_);
(*ui_->server)->set_selected_controller(selected);
(*ui_->server)->set_language_index(localization_language_index_);
(*ui_->server_bar)->set_controller_count(static_cast<int>(ui_->controller_ids.size()));
ui_localization::ApplyServerStrings(*ui_->server, localization_language_index_);
ui_localization::ApplyServerStrings(*ui_->server_bar, localization_language_index_);
#if _WIN32
if (ui_->server_native_window_attempts > 0) {
if (ConfigureWindowsServerWindow((*ui_->server)->window())) {
if (ConfigureWindowsServerWindow((*ui_->server_bar)->window()))
ui_->server_native_window_attempts = 0;
} else {
else
--ui_->server_native_window_attempts;
}
}
#endif
if (ui_->server_initial_position_attempts > 0) {
const float server_width =
ServerWindowLogicalWidth(localization_language_index_);
const float server_height =
ServerWindowLogicalHeight(server_window_collapsed_);
(*ui_->server)
->window()
.set_size(slint::LogicalSize(
slint::Size<float>{server_width, server_height}));
if (PositionWindowAtBottomRight((*ui_->server)->window(), server_width,
server_height)) {
--ui_->server_initial_position_attempts;
} else {
ui_->server_initial_position_attempts = 0;
}
}
(*ui_->server)
->set_controller_label(
UiText(localization::controller[localization_language_index_]));
(*ui_->server)
->set_connection_label(UiText(
localization::connection_status[localization_language_index_]));
(*ui_->server)
->set_file_transfer_label(
UiText(localization::file_transfer[localization_language_index_]));
(*ui_->server)
->set_select_file_label(
UiText(localization::select_file[localization_language_index_]));
ConnectionStatus status = ConnectionStatus::Closed;
{
std::shared_lock lock(connection_status_mutex_);
if (const auto found = connection_status_.find(selected_server_remote_id_);
found != connection_status_.end()) {
status = found->second;
}
}
(*ui_->server)
->set_connection_status(
UiText(ConnectionStatusText(status, localization_language_index_)));
auto& transfer = transfers_.global_state();
const auto sent = transfer.file_sent_bytes_.load();
const auto total = transfer.file_total_bytes_.load();
@@ -3429,6 +3490,7 @@ void GuiApplication::SyncServerWindow() {
->set_file_size_text(total == 0 ? slint::SharedString{}
: UiText(FormatFileSize(sent) + " / " +
FormatFileSize(total)));
PositionServerWindows();
}
void GuiApplication::SaveSettingsFromUi() {
@@ -3820,6 +3882,7 @@ void GuiApplication::Cleanup() {
return;
}
ui_->timer.stop();
ui_->server_file_dialog_timer.stop();
ui_->video_timer.stop();
#if _WIN32 && CROSSDESK_PORTABLE
JoinPortableWindowsServiceInstallThread();
@@ -3848,6 +3911,7 @@ void GuiApplication::Cleanup() {
}
if (ui_->server) {
(*ui_->server)->hide();
(*ui_->server_bar)->hide();
}
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
if (ui_->tray) {
@@ -69,6 +69,8 @@ private:
void SyncStreamKeyboardFocus();
void SetStreamKeyboardFocus(bool focused);
void SyncServerWindow();
void SetServerPanelExpanded(bool expanded);
void PositionServerWindows();
#if defined(__linux__) && !defined(__APPLE__)
void SyncXWaylandWindowActivation();
#endif
@@ -14,8 +14,47 @@
namespace crossdesk::server_window_state {
inline int ReconcileSelectedController(const std::vector<std::string>& ids,
std::string* selected_id) {
// All values use logical desktop coordinates (including negative monitor
// origins). The bar's anchor is independent of the panel's changing height.
struct Rect {
float x, y, width, height;
};
struct EdgeLayout {
Rect bar;
Rect panel;
bool above;
};
inline EdgeLayout PlacePanel(const Rect &screen, float position,
float bar_width, float panel_height) {
constexpr float bar_height = 36;
constexpr float gap = 6;
constexpr float margin = 8;
const float travel = std::max(0.0f, screen.height - bar_height - 2 * margin);
const float top =
screen.y + margin + travel * std::clamp(position, 0.0f, 1.0f);
const float above = std::max(0.0f, top - screen.y - margin - gap);
const float below = std::max(0.0f, screen.y + screen.height - margin - top -
bar_height - gap);
const bool opens_above =
below < panel_height && (above >= panel_height || above > below);
const float height = std::min(panel_height, opens_above ? above : below);
const float width = std::min(225.0f, screen.width);
return {{screen.x + screen.width - bar_width, top, bar_width, bar_height},
{screen.x + screen.width - width,
opens_above ? top - gap - height : top + bar_height + gap, width,
height},
opens_above};
}
inline bool Contains(const Rect &rect, float x, float y) {
return x >= rect.x && y >= rect.y && x < rect.x + rect.width &&
y < rect.y + rect.height;
}
inline int ReconcileSelectedController(const std::vector<std::string> &ids,
std::string *selected_id) {
if (!selected_id) {
return 0;
}
@@ -33,4 +72,4 @@ inline int ReconcileSelectedController(const std::vector<std::string>& ids,
}
} // namespace crossdesk::server_window_state
#endif
#endif
@@ -316,6 +316,13 @@ struct TranslationRow {
X(completed, u8"已完成", "Completed", u8"Завершено") \
X(failed, u8"失败", "Failed", u8"Ошибка") \
X(controller, u8"控制端:", "Controller:", u8"Контроллер:") \
X(server_controlled, u8"被控中", "Controlled", u8"Под управлением") \
X(server_heading, u8"此设备被控中", "This device is controlled", u8"Устройство под управлением") \
X(server_connected, u8"已连接", "Connected", u8"Подключено") \
X(server_send_file, u8"发送文件", "Send file", u8"Отправить файл") \
X(server_disconnect, u8"断开", "Disconnect", u8"Отключить") \
X(server_disconnect_all, u8"全部断开", "Disconnect all", u8"Отключить всех") \
X(server_confirm_disconnect, u8"断开所有控制端的连接?", "Disconnect all controllers?", u8"Отключить все контроллеры?") \
X(file_transfer, u8"文件传输:", "File Transfer:", u8"Передача файлов:") \
X(connection_status, u8"连接状态:", \
"Connection Status:", u8"Состояние соединения:") \
+4
View File
@@ -7,6 +7,10 @@ export global ImGuiLineStyle {
out property <color> control-separator: #b2b2b2;
}
export global FloatingBarStyle {
out property <length> corner-radius: 6px;
}
// 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 {
+1 -1
View File
@@ -3,4 +3,4 @@
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, ControllerEntry } from "server_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
+251 -122
View File
@@ -1,150 +1,279 @@
import { CompactComboBox, FontAwesomeIcons, IconButton, SecondaryButton, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
import { ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, ImGuiLineStyle, FloatingBarStyle } from "common.slint";
export struct ControllerEntry {
remote-id: string,
display-name: string,
}
export component ServerWindow inherits Window {
export global ServerStrings {
in property <string> controlled: "被控中";
in property <string> heading: "此设备被控中";
in property <string> connected: "已连接";
in property <string> send-file: "发送文件";
in property <string> disconnect: "断开";
in property <string> disconnect-one: "断开连接";
in property <string> disconnect-all: "全部断开";
in property <string> confirm-disconnect: "断开所有控制端的连接?";
in property <string> cancel: "取消";
}
component SessionButton inherits Rectangle {
in property <string> text;
in property <bool> danger: false;
in property <bool> enabled: true;
private property <bool> keyboard-focus: false;
private property <bool> pointer-feedback: true;
callback clicked;
width: label.preferred-width + 20px;
height: 28px;
border-radius: 5px;
border-width: 1px;
border-color: root.danger ? #d92d20 : #c9ced7;
background: root.danger ? (root.pointer-feedback && touch.pressed ? #b42318 : root.pointer-feedback && touch.has-hover ? #f04438 : #d92d20)
: (root.pointer-feedback && touch.pressed ? #dfe3e9 : root.pointer-feedback && touch.has-hover ? #f3f4f6 : white);
opacity: root.enabled ? 1 : 0.5;
accessible-role: button;
accessible-label: root.text;
accessible-action-default => { if root.enabled { root.pointer-feedback = false; root.clicked(); } }
label := Text {
text: root.text; color: root.danger ? white : #24272d;
font-size: 12px; horizontal-alignment: center; vertical-alignment: center;
}
forward-focus: keyboard;
keyboard := FocusScope {
enabled: root.enabled;
focus-gained(reason) => {
if reason == FocusReason.tab-navigation {
root.keyboard-focus = true;
} else if reason == FocusReason.pointer-click {
root.keyboard-focus = false;
}
// Preserve the input method when returning from the file picker.
}
key-pressed(event) => {
root.keyboard-focus = true;
if root.enabled && (event.text == Key.Return || event.text == " ") {
root.pointer-feedback = false;
root.clicked(); return accept;
}
return reject;
}
}
touch := TouchArea {
enabled: root.enabled;
pointer-event(event) => {
if event.kind == PointerEventKind.down || event.kind == PointerEventKind.move {
root.pointer-feedback = true;
}
}
// A native dialog may not deliver a pointer exit to the owner window.
// Restore hover feedback only after a fresh pointer move or press.
clicked => { root.pointer-feedback = false; root.keyboard-focus = false; keyboard.focus(); root.clicked(); }
}
if root.enabled && keyboard.has-focus && root.keyboard-focus: Rectangle {
x: 2px; y: 2px; width: parent.width - 4px; height: parent.height - 4px;
border-radius: 3px; border-width: 1px; border-color: root.danger ? white : #2463c7;
}
}
// A separate native window keeps the collapsed footprint as small as the bar.
export component ServerStatusBar inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
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 <int> controller-count: 1;
in property <bool> expanded: false;
in property <bool> opens-above: false;
in property <bool> dragged: false;
out property <length> bar-width: label.preferred-width + 48px;
preferred-width: root.bar-width;
preferred-height: 36px;
min-width: root.bar-width; max-width: root.bar-width;
min-height: 36px; max-height: 36px;
callback toggle-panel;
callback dismiss-panel;
callback title-drag(int, length, length);
callback move-anchor(float);
forward-focus: keyboard;
keyboard := FocusScope {
key-pressed(event) => {
if event.text == Key.Return || event.text == " " { root.toggle-panel(); return accept; }
if event.text == Key.Escape { root.dismiss-panel(); return accept; }
if event.text == Key.UpArrow { root.move-anchor(-0.05); return accept; }
if event.text == Key.DownArrow { root.move-anchor(0.05); return accept; }
return reject;
}
}
Rectangle {
x: 0px; y: 0px; width: parent.width; height: parent.height;
background: touch.pressed ? #dfe3e9 : touch.has-hover ? #f3f4f6 : white;
border-width: 1px; border-color: ImGuiLineStyle.border;
border-radius: FloatingBarStyle.corner-radius;
}
label := Text {
x: 10px; width: root.width - 38px; height: root.height;
text: ServerStrings.controlled + (root.controller-count > 1 ? " · " + root.controller-count : "");
color: #24272d; font-size: 14px; vertical-alignment: center;
}
Text {
x: root.width - 26px; width: 18px; height: root.height;
text: root.opens-above != root.expanded ? FontAwesomeIcons.angle-up : FontAwesomeIcons.angle-down;
color: #24272d; font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 12px; horizontal-alignment: center; vertical-alignment: center;
}
touch := TouchArea {
property <int> phase: self.pressed ? 1 : 0;
changed phase => { root.title-drag(self.phase, self.mouse-x, self.mouse-y); }
moved => { if self.pressed { root.title-drag(2, self.mouse-x, self.mouse-y); } }
clicked => { keyboard.focus(); if !root.dragged { root.toggle-panel(); } }
}
accessible-role: button;
accessible-label: label.text;
accessible-action-default => { root.toggle-panel(); }
}
export component ServerWindow inherits Window {
title: "CrossDesk";
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
preferred-width: 225px; min-width: 225px; max-width: 225px;
preferred-height: root.content-height;
no-frame: true;
always-on-top: true;
background: transparent;
default-font-size: 12px;
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);
in property <string> file-progress-text;
in property <string> current-file-name;
in property <string> file-size-text;
in-out property <bool> confirming-disconnect: false;
private property <length> footer-height: send-file.width + disconnect-all.width + 6px > 205px ? 62px : 28px;
private property <string> hovered-name;
private property <length> tooltip-y;
out property <length> content-height: 10px + 24px + (root.controllers.length > 1 ? min(root.controllers.length, 6) * 34px : 28px) + 8px + root.footer-height + 10px + (root.confirming-disconnect ? 36px : 0px) + (root.file-transfer-visible ? 58px : 0px);
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); } }
}
callback disconnect-controller(string);
callback disconnect-all;
callback dismiss-panel;
forward-focus: keyboard;
keyboard := FocusScope {
key-pressed(event) => {
if event.text == Key.Escape { root.dismiss-panel(); return accept; }
return reject;
}
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; }
Rectangle {
width: root.width; height: root.height;
background: white; border-width: 1px; border-color: ImGuiLineStyle.border;
border-radius: 8px;
Text {
x: 10px; y: 10px; width: parent.width - 20px; height: 24px;
text: ServerStrings.heading + (root.controllers.length > 1 ? " · " + root.controllers.length : "");
color: #24272d; font-size: 13px; font-weight: 500; vertical-alignment: center; overflow: elide;
}
list := ScrollView {
x: 10px; y: 34px; width: parent.width - 20px;
height: max(0px, root.height - 34px - root.footer-height - 18px - (root.confirming-disconnect ? 36px : 0px) - (root.file-transfer-visible ? 58px : 0px));
viewport-width: self.visible-width;
viewport-height: root.controllers.length * (root.controllers.length > 1 ? 34px : 28px);
for controller[index] in root.controllers: row := Rectangle {
x: 0px; y: index * (root.controllers.length > 1 ? 34px : 28px);
width: list.visible-width; height: root.controllers.length > 1 ? 32px : 28px;
select := Rectangle {
x: 0px; y: 0px;
width: parent.width - (root.controllers.length > 1 ? disconnect.width + 4px : status.preferred-width + 8px);
height: parent.height; border-radius: 4px;
background: root.controllers.length > 1 && index == root.selected-controller ? #eef5fd : select-touch.has-hover ? #f3f4f6 : transparent;
if root.controllers.length > 1: Rectangle {
x: 4px; y: (parent.height - 13px) / 2; width: 13px; height: 13px;
border-radius: 7px; border-width: 1px; border-color: index == root.selected-controller ? #2463c7 : #8c939e;
if index == root.selected-controller: Rectangle { x: 3px; y: 3px; width: 7px; height: 7px; border-radius: 4px; background: #2463c7; }
}
name := Text {
x: root.controllers.length > 1 ? 23px : 0px; width: parent.width - self.x - 4px;
height: parent.height; text: controller.display-name; color: #24272d;
font-size: 13px; vertical-alignment: center; overflow: elide;
}
forward-focus: selection-keyboard;
selection-keyboard := FocusScope {
key-pressed(event) => {
if event.text == Key.Return || event.text == " " {
root.selected-controller = index; root.controller-selected(index); return accept;
}
if event.text == Key.UpArrow || event.text == Key.DownArrow {
root.selected-controller = max(0, min(root.controllers.length - 1, root.selected-controller + (event.text == Key.UpArrow ? -1 : 1)));
root.controller-selected(root.selected-controller); return accept;
}
return reject;
}
}
select-touch := TouchArea {
clicked => { selection-keyboard.focus(); root.selected-controller = index; root.controller-selected(index); }
changed has-hover => {
root.hovered-name = self.has-hover && name.preferred-width > name.width ? controller.display-name : "";
root.tooltip-y = max(0px, min(root.height - 52px, list.y + row.y + list.viewport-y + row.height));
}
}
accessible-role: radio-button;
accessible-label: controller.display-name;
accessible-checked: index == root.selected-controller;
accessible-action-default => { root.selected-controller = index; root.controller-selected(index); }
}
status := Text {
visible: root.controllers.length == 1;
x: parent.width - self.preferred-width; height: parent.height;
text: ServerStrings.connected; color: #16a34a; font-size: 12px; vertical-alignment: center;
}
disconnect := SessionButton {
visible: root.controllers.length > 1;
x: parent.width - self.width; y: 2px;
text: ServerStrings.disconnect; danger: true;
clicked => { root.confirming-disconnect = false; root.disconnect-controller(controller.remote-id); }
}
}
}
}
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; }
footer := Rectangle {
x: 10px; y: list.y + list.height + 8px; width: parent.width - 20px; height: root.footer-height;
send-file := SessionButton {
x: root.footer-height > 28px ? parent.width - self.width : disconnect-all.x - self.width - 6px;
y: 0px;
text: root.confirming-disconnect ? ServerStrings.cancel : ServerStrings.send-file;
enabled: root.confirming-disconnect || !root.sending-file;
clicked => { if root.confirming-disconnect { root.confirming-disconnect = false; } else { root.select-file(); } }
}
disconnect-all := SessionButton {
x: parent.width - self.width; y: root.footer-height - 28px;
text: root.controllers.length > 1 ? ServerStrings.disconnect-all : ServerStrings.disconnect-one;
danger: true;
clicked => {
if root.controllers.length == 1 { root.disconnect-controller(root.controllers[0].remote-id); }
else if root.confirming-disconnect { root.confirming-disconnect = false; root.disconnect-all(); }
else { root.confirming-disconnect = true; }
}
}
}
if root.confirming-disconnect: Text {
x: 10px; y: footer.y + footer.height + 6px; width: parent.width - 20px; height: 30px;
text: ServerStrings.confirm-disconnect; color: #d92d20; font-size: 12px; wrap: word-wrap;
}
if root.file-transfer-visible: VerticalLayout {
x: 10px; y: root.height - 62px; width: parent.width - 20px; height: 52px; spacing: 2px;
Text { text: root.current-file-name; color: #596575; font-size: 11px; overflow: elide; }
Text { text: root.file-size-text; color: #596575; font-size: 11px; overflow: elide; }
Text { text: round(root.file-progress * 100) + "% " + root.file-progress-text; color: #2463c7; font-size: 11px; overflow: elide; }
}
}
// Draw outside the scrolling list so its last row can reveal its name.
if root.hovered-name != "": Rectangle {
x: 10px; y: root.tooltip-y; width: 205px; height: tip.preferred-height + 12px; z: 20;
background: #24272d; border-radius: 5px;
tip := Text { x: 6px; y: 6px; width: parent.width - 12px; text: root.hovered-name; color: white; font-size: 12px; wrap: word-wrap; }
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { ComboBox, ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle, FloatingBarStyle } from "common.slint";
export struct StreamTab {
remote-id: string,
@@ -810,7 +810,7 @@ export component StreamWindow inherits Window {
background: white;
border-width: 1px;
border-color: ImGuiLineStyle.border;
border-radius: 6px;
border-radius: FloatingBarStyle.corner-radius;
z: 8;
clip: true;
// Keep width changes atomic. When docked right, animating both
+15
View File
@@ -20,6 +20,21 @@ inline slint::SharedString Text(const std::string& value) {
return slint::SharedString(value);
}
template <typename Component>
void ApplyServerStrings(const Component& component, int language_index) {
const int language = localization::detail::ClampLanguageIndex(language_index);
auto& strings = component->template global<ui::ServerStrings>();
strings.set_controlled(Text(localization::server_controlled[language]));
strings.set_heading(Text(localization::server_heading[language]));
strings.set_connected(Text(localization::server_connected[language]));
strings.set_send_file(Text(localization::server_send_file[language]));
strings.set_disconnect(Text(localization::server_disconnect[language]));
strings.set_disconnect_one(Text(localization::disconnect[language]));
strings.set_disconnect_all(Text(localization::server_disconnect_all[language]));
strings.set_confirm_disconnect(Text(localization::server_confirm_disconnect[language]));
strings.set_cancel(Text(localization::cancel[language]));
}
inline int ApplyMainWindowStrings(
const slint::ComponentHandle<ui::MainWindow>& window,
int language_index) {
+3 -17
View File
@@ -351,25 +351,11 @@ void ConfigureServerCapturePage(
server->set_controllers(
std::make_shared<slint::VectorModel<crossdesk::ui::ControllerEntry>>(
std::vector{controller}));
server->set_controller_names(
std::make_shared<slint::VectorModel<slint::SharedString>>(
std::vector{slint::SharedString("Mac")}));
server->set_language_index(language);
server->set_controller_label(crossdesk::ui_localization::Text(
crossdesk::localization::controller[language]));
server->set_connection_label(crossdesk::ui_localization::Text(
crossdesk::localization::connection_status[language]));
server->set_connection_status(crossdesk::ui_localization::Text(
crossdesk::localization::p2p_connected[language]));
server->set_file_transfer_label(crossdesk::ui_localization::Text(
crossdesk::localization::file_transfer[language]));
server->set_select_file_label(crossdesk::ui_localization::Text(
crossdesk::localization::select_file[language]));
crossdesk::ui_localization::ApplyServerStrings(server, language);
server->set_file_transfer_visible(false);
server->set_sending_file(false);
const float width = language == 0 ? 250.0f : language == 1 ? 330.0f : 430.0f;
server->window().set_size(
slint::LogicalSize(slint::Size<float>{width, 150.0f}));
server->window().set_size(slint::LogicalSize(
slint::Size<float>{225.0f, server->get_content_height()}));
}
int RunCaptureMode(