From 1d47256cbcf98083d79f65a604b4a40c179438f3 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 9 Oct 2026 05:44:10 +0800 Subject: [PATCH] [ui] replace host session window with compact edge controls --- .../src/gui/application/gui_application.cpp | 386 ++++++++++-------- .../src/gui/application/gui_application.h | 2 + .../src/gui/application/server_window_state.h | 45 +- .../assets/localization/localization_data.h | 7 + apps/desktop/src/gui/ui/common.slint | 4 + apps/desktop/src/gui/ui/crossdesk_ui.slint | 2 +- apps/desktop/src/gui/ui/server_window.slint | 373 +++++++++++------ apps/desktop/src/gui/ui/stream_window.slint | 4 +- apps/desktop/src/gui/ui/ui_localization.h | 15 + apps/desktop/tests/slint_ui_smoke_test.cpp | 20 +- 10 files changed, 552 insertions(+), 306 deletions(-) diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index fce85f37..35b0b0fa 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -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(root_x); *y = static_cast(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( - std::lround(position.x / scale + size.width / (2.0f * scale))), - static_cast( - std::lround(position.y / scale + size.height / (2.0f * scale)))}; -#else - const SDL_Point center{ - position.x + static_cast(size.width / 2), - position.y + static_cast(size.height / 2)}; -#endif - SDL_DisplayID display = SDL_GetDisplayForPoint(¢er); - 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(usable_bounds.x) + - std::max(0.0f, static_cast(usable_bounds.w) - logical_width); - const float target_y = - static_cast(usable_bounds.y) + - std::max(0.0f, static_cast(usable_bounds.h) - logical_height); - window.set_position( - slint::LogicalPosition(slint::Point{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{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>(); std::optional> stream; std::optional> server; + std::optional> server_bar; std::shared_ptr> recent_model = std::make_shared>(); std::shared_ptr> tab_model = @@ -989,26 +933,32 @@ struct GuiApplication::SlintUi { std::make_shared>(); std::shared_ptr> controller_model = std::make_shared>(); - std::shared_ptr> - controller_name_model = - std::make_shared>(); std::unique_ptr video_presenter; std::vector tab_order; std::vector tab_ids; std::string tab_model_signature; std::vector 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{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(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{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{rect.x, rect.y})); +#else + window.set_position(slint::PhysicalPosition(slint::Point{ + static_cast(std::lround(rect.x * scale)), + static_cast(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{ - 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 controllers; - std::vector 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 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(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{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) { diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index cb70cd53..c1dc9075 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -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 diff --git a/apps/desktop/src/gui/application/server_window_state.h b/apps/desktop/src/gui/application/server_window_state.h index 369cd5f3..082d7c5c 100644 --- a/apps/desktop/src/gui/application/server_window_state.h +++ b/apps/desktop/src/gui/application/server_window_state.h @@ -14,8 +14,47 @@ namespace crossdesk::server_window_state { -inline int ReconcileSelectedController(const std::vector& 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 &ids, + std::string *selected_id) { if (!selected_id) { return 0; } @@ -33,4 +72,4 @@ inline int ReconcileSelectedController(const std::vector& ids, } } // namespace crossdesk::server_window_state -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 89ee83b0..aad6f00d 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -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"Состояние соединения:") \ diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index b54c4cd8..85c846aa 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -7,6 +7,10 @@ export global ImGuiLineStyle { out property control-separator: #b2b2b2; } +export global FloatingBarStyle { + out property 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 { diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index 23f0a0b2..d5faac40 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -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"; diff --git a/apps/desktop/src/gui/ui/server_window.slint b/apps/desktop/src/gui/ui/server_window.slint index 8367297b..021ced92 100644 --- a/apps/desktop/src/gui/ui/server_window.slint +++ b/apps/desktop/src/gui/ui/server_window.slint @@ -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 controlled: "被控中"; + in property heading: "此设备被控中"; + in property connected: "已连接"; + in property send-file: "发送文件"; + in property disconnect: "断开"; + in property disconnect-one: "断开连接"; + in property disconnect-all: "全部断开"; + in property confirm-disconnect: "断开所有控制端的连接?"; + in property cancel: "取消"; +} + +component SessionButton inherits Rectangle { + in property text; + in property danger: false; + in property enabled: true; + private property keyboard-focus: false; + private property 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 controller-count: 1; + in property expanded: false; + in property opens-above: false; + in property dragged: false; + out property 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 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 language-index: 0; in-out property selected-controller: 0; - in property connection-label: "Connection status"; - in property connection-status: ""; - in property controller-label: "Controller"; - in property file-transfer-label: "File transfer"; - in property select-file-label: "Select file"; in property sending-file: false; in property file-transfer-visible: false; in property file-progress: 0; - in property file-progress-text: ""; - in property current-file-name: ""; - in property file-size-text: ""; - - callback title-drag(int, length, length); - callback toggle-collapsed(bool); + in property file-progress-text; + in property current-file-name; + in property file-size-text; + in-out property confirming-disconnect: false; + private property footer-height: send-file.width + disconnect-all.width + 6px > 205px ? 62px : 28px; + private property hovered-name; + private property tooltip-y; + out property 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 collapsed: false; - private property 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 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; } } } } diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index bc6137a8..bd4b24d7 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -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 diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 0d46ad33..d5b6c955 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -20,6 +20,21 @@ inline slint::SharedString Text(const std::string& value) { return slint::SharedString(value); } +template +void ApplyServerStrings(const Component& component, int language_index) { + const int language = localization::detail::ClampLanguageIndex(language_index); + auto& strings = component->template global(); + 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& window, int language_index) { diff --git a/apps/desktop/tests/slint_ui_smoke_test.cpp b/apps/desktop/tests/slint_ui_smoke_test.cpp index 05d326f9..fe6bd154 100644 --- a/apps/desktop/tests/slint_ui_smoke_test.cpp +++ b/apps/desktop/tests/slint_ui_smoke_test.cpp @@ -351,25 +351,11 @@ void ConfigureServerCapturePage( server->set_controllers( std::make_shared>( std::vector{controller})); - server->set_controller_names( - std::make_shared>( - 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{width, 150.0f})); + server->window().set_size(slint::LogicalSize( + slint::Size{225.0f, server->get_content_height()})); } int RunCaptureMode(