diff --git a/src/gui/application/gui_application.cpp b/src/gui/application/gui_application.cpp index f9c939d..e01a40b 100644 --- a/src/gui/application/gui_application.cpp +++ b/src/gui/application/gui_application.cpp @@ -28,12 +28,17 @@ #include "crossdesk_ui.h" #include "fa_solid_900.h" #include "localization.h" +#include "platform.h" #if _WIN32 #include "platform/tray/win_tray.h" #elif defined(__APPLE__) #include "platform/tray/mac_tray.h" #include "platform/window_drag.h" #elif defined(__linux__) +#include +#include +#include + #include "platform/tray/linux_tray.h" #endif #include "rd_log.h" @@ -44,6 +49,140 @@ namespace { using namespace std::chrono_literals; +#if defined(__linux__) && !defined(__APPLE__) +bool HasNonEmptyEnvironmentVariable(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] != '\0'; +} + +class ScopedUnsetEnvironmentVariable { +public: + explicit ScopedUnsetEnvironmentVariable(const char* name) : name_(name) { + if (const char* value = std::getenv(name_)) { + original_value_ = value; + } + unsetenv(name_); + } + + ~ScopedUnsetEnvironmentVariable() { + if (original_value_) { + setenv(name_, original_value_->c_str(), 1); + } else { + unsetenv(name_); + } + } + + ScopedUnsetEnvironmentVariable(const ScopedUnsetEnvironmentVariable&) = + delete; + ScopedUnsetEnvironmentVariable& operator=( + const ScopedUnsetEnvironmentVariable&) = delete; + +private: + const char* name_; + std::optional original_value_; +}; + +::Window X11GetActiveWindow(Display* display) { + if (!display) { + return 0; + } + + const ::Window root = DefaultRootWindow(display); + const Atom active_window_atom = + XInternAtom(display, "_NET_ACTIVE_WINDOW", True); + if (active_window_atom != None) { + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char* data = nullptr; + if (XGetWindowProperty(display, root, active_window_atom, 0, 1, False, + XA_WINDOW, &actual_type, &actual_format, &item_count, + &bytes_after, &data) == Success && + actual_type == XA_WINDOW && actual_format == 32 && item_count == 1 && + data) { + const ::Window active_window = + *reinterpret_cast(data); + XFree(data); + return active_window; + } + if (data) { + XFree(data); + } + } + + ::Window focused_window = 0; + int revert_to = RevertToNone; + XGetInputFocus(display, &focused_window, &revert_to); + return focused_window == None || focused_window == PointerRoot + ? 0 + : focused_window; +} + +std::optional X11GetWindowProcessId(Display* display, + ::Window x11_window) { + if (!display || !x11_window) { + return std::nullopt; + } + + const Atom process_id_atom = XInternAtom(display, "_NET_WM_PID", True); + if (process_id_atom == None) { + return std::nullopt; + } + + Atom actual_type = None; + int actual_format = 0; + unsigned long item_count = 0; + unsigned long bytes_after = 0; + unsigned char* data = nullptr; + const int status = XGetWindowProperty( + display, x11_window, process_id_atom, 0, 1, False, XA_CARDINAL, + &actual_type, &actual_format, &item_count, &bytes_after, &data); + if (status != Success || actual_type != XA_CARDINAL || actual_format != 32 || + item_count != 1 || !data) { + if (data) { + XFree(data); + } + return std::nullopt; + } + + const auto process_id = *reinterpret_cast(data); + XFree(data); + return process_id; +} + +bool X11WindowMatches(Display* display, ::Window x11_window, + const slint::Window& slint_window) { + if (!display || !x11_window) { + return false; + } + + XWindowAttributes attributes; + if (!XGetWindowAttributes(display, x11_window, &attributes)) { + return false; + } + + int root_x = 0; + int root_y = 0; + ::Window child = 0; + if (!XTranslateCoordinates(display, x11_window, + DefaultRootWindow(display), 0, 0, &root_x, + &root_y, &child)) { + return false; + } + + const auto position = slint_window.position(); + const auto size = slint_window.size(); + constexpr int kGeometryTolerance = 4; + return std::abs(root_x - position.x) <= kGeometryTolerance && + std::abs(root_y - position.y) <= kGeometryTolerance && + std::abs(attributes.width - static_cast(size.width)) <= + kGeometryTolerance && + std::abs(attributes.height - static_cast(size.height)) <= + kGeometryTolerance; +} +#endif + #if _WIN32 HICON LoadSlintTrayIcon() { HMODULE module = GetModuleHandleW(nullptr); @@ -496,6 +635,12 @@ float ServerWindowLogicalHeight(bool collapsed) { constexpr float kStreamWindowLogicalWidth = 1280.0f; constexpr float kStreamWindowLogicalHeight = 720.0f; +constexpr float kWaylandTitlebarLogicalHeight = 32.0f; + +float StreamWindowLogicalHeight(bool custom_titlebar) { + return kStreamWindowLogicalHeight + + (custom_titlebar ? kWaylandTitlebarLogicalHeight : 0.0f); +} SDL_DisplayID DisplayForSlintWindow(const slint::Window& window) { const float scale = std::max(window.scale_factor(), 0.01f); @@ -682,6 +827,7 @@ struct GuiApplication::SlintUi { std::string recent_model_signature; slint::Timer timer; WindowDragState main_drag; + WindowDragState stream_drag; WindowDragState server_drag; int main_native_titlebar_attempts = 30; int stream_live_resize_configuration_attempts = 0; @@ -697,7 +843,17 @@ struct GuiApplication::SlintUi { std::unique_ptr tray; #elif defined(__linux__) std::unique_ptr tray; + Display* x11_focus_display = nullptr; + std::chrono::steady_clock::time_point last_x11_activation_poll{}; #endif + + ~SlintUi() { +#if defined(__linux__) && !defined(__APPLE__) + if (x11_focus_display) { + XCloseDisplay(x11_focus_display); + } +#endif + } }; GuiApplication::GuiApplication() = default; @@ -792,6 +948,24 @@ bool GuiApplication::InitializeSDL() { if (!getenv("SDL_AUDIODRIVER")) { setenv("SDL_AUDIODRIVER", "pulseaudio", 0); } + + // Standard Wayland top-level windows cannot choose an absolute screen + // position. Use the session's XWayland compatibility server for the GUI so + // auxiliary windows such as the controlled-side status window can be + // placed deterministically. Capture and input still detect the surrounding + // Wayland session through XDG_SESSION_TYPE and continue to use the portal. + const char* requested_video_driver = std::getenv("SDL_VIDEODRIVER"); + const bool video_driver_allows_x11 = + requested_video_driver == nullptr || requested_video_driver[0] == '\0' || + std::strcmp(requested_video_driver, "x11") == 0; + use_xwayland_gui_ = + IsWaylandSession() && HasNonEmptyEnvironmentVariable("DISPLAY") && + video_driver_allows_x11; + if (use_xwayland_gui_) { + setenv("SDL_VIDEODRIVER", "x11", 1); + LOG_INFO("Wayland session detected; using XWayland for positioned GUI " + "windows while retaining Wayland portal capture and input"); + } #endif if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) { LOG_ERROR("SDL initialization failed: {}", SDL_GetError()); @@ -816,9 +990,25 @@ void GuiApplication::InitializeModules() { } void GuiApplication::InitializeUi() { +#if defined(__linux__) && !defined(__APPLE__) + // Winit prefers Wayland whenever WAYLAND_DISPLAY/WAYLAND_SOCKET is present. + // Hide them only while Slint selects its process-wide window backend, then + // restore the environment before the rest of the application starts. + std::optional wayland_display_guard; + std::optional wayland_socket_guard; + if (use_xwayland_gui_) { + wayland_display_guard.emplace("WAYLAND_DISPLAY"); + wayland_socket_guard.emplace("WAYLAND_SOCKET"); + } +#endif ui_ = std::make_unique(); #if _WIN32 ui_->main->set_custom_titlebar(true); +#elif defined(__linux__) + if (use_xwayland_gui_) { + ui_->main->set_custom_titlebar(true); + ui_->main->set_wayland_titlebar(true); + } #endif std::vector release_note_blocks; for (const auto& parsed : @@ -924,6 +1114,9 @@ void GuiApplication::InitializeUi() { if (ui_->capture_mode && ui_->capture_page == "stream") { ui_->stream.emplace(ui::StreamWindow::create()); +#if defined(__linux__) + (*ui_->stream)->set_custom_titlebar(use_xwayland_gui_); +#endif RegisterFontAwesome((*ui_->stream)->window()); (*ui_->stream)->set_tabs(ui_->tab_model); (*ui_->stream)->set_displays(ui_->display_model); @@ -968,7 +1161,8 @@ void GuiApplication::InitializeUi() { (*ui_->stream)->show(); PositionWindowAtCenter((*ui_->stream)->window(), ui_->main->window(), kStreamWindowLogicalWidth, - kStreamWindowLogicalHeight); + StreamWindowLogicalHeight( + (*ui_->stream)->get_custom_titlebar())); ui_->main->hide(); } else if (ui_->capture_mode && ui_->capture_page == "server") { ui_->server.emplace(ui::ServerWindow::create()); @@ -1362,7 +1556,7 @@ void GuiApplication::BindStreamCallbacks() { return; } auto& stream = *ui_->stream; - stream->window().on_close_requested([this] { + auto close_stream_sessions = [this] { std::vector ids; { std::shared_lock lock(remote_sessions_mutex_); @@ -1373,8 +1567,35 @@ void GuiApplication::BindStreamCallbacks() { for (const auto& id : ids) { CloseStreamTab(id); } + if (ui_ && ui_->stream) { + (*ui_->stream)->hide(); + } + }; + stream->window().on_close_requested([close_stream_sessions] { + close_stream_sessions(); return slint::CloseRequestResponse::HideWindow; }); + stream->on_stream_title_drag([this](int phase, float x, float y) { + if (ui_ && ui_->stream) { + DragWindow(*ui_->stream, phase, x, y, ui_->stream_drag); + } + }); + stream->on_minimize_stream_window([this] { + if (ui_ && ui_->stream) { + (*ui_->stream)->window().set_minimized(true); + } + }); + stream->on_toggle_maximize_stream_window([this] { + if (!ui_ || !ui_->stream) { + return; + } + auto& stream = *ui_->stream; + auto& window = stream->window(); + const bool maximize = !window.is_maximized(); + stream->set_window_maximized(maximize); + window.set_maximized(maximize); + }); + stream->on_close_stream_window(close_stream_sessions); stream->on_select_tab([this](int index) { SelectStreamTab(index); }); stream->on_reorder_tab([this](int from, float drop_x, float tab_width) { ReorderStreamTab(from, drop_x, tab_width); @@ -1622,6 +1843,9 @@ void GuiApplication::Tick() { SyncPlatformDialogs(); SyncStreamWindow(); SyncServerWindow(); +#if defined(__linux__) && !defined(__APPLE__) + SyncXWaylandWindowActivation(); +#endif } void GuiApplication::UpdateLocalization() { @@ -1985,6 +2209,47 @@ void GuiApplication::SyncPlatformDialogs() { #endif } +#if defined(__linux__) && !defined(__APPLE__) +void GuiApplication::SyncXWaylandWindowActivation() { + if (!ui_ || !use_xwayland_gui_) { + return; + } + + const auto now = std::chrono::steady_clock::now(); + if (now - ui_->last_x11_activation_poll < 100ms) { + return; + } + ui_->last_x11_activation_poll = now; + + if (!ui_->x11_focus_display) { + ui_->x11_focus_display = XOpenDisplay(nullptr); + } + Display* display = ui_->x11_focus_display; + const ::Window active_window = X11GetActiveWindow(display); + if (!active_window) { + return; + } + + if (const auto process_id = + X11GetWindowProcessId(display, active_window); + process_id && *process_id != static_cast(getpid())) { + ui_->main->set_window_active(false); + if (ui_->stream) { + (*ui_->stream)->set_window_active(false); + } + return; + } + + ui_->main->set_window_active( + X11WindowMatches(display, active_window, ui_->main->window())); + if (ui_->stream) { + (*ui_->stream) + ->set_window_active(X11WindowMatches( + display, active_window, (*ui_->stream)->window())); + } +} +#endif + void GuiApplication::SyncStreamWindow() { bool has_sessions = false; { @@ -1993,6 +2258,9 @@ void GuiApplication::SyncStreamWindow() { } if (need_to_create_stream_window_ && !ui_->stream) { ui_->stream.emplace(ui::StreamWindow::create()); +#if defined(__linux__) + (*ui_->stream)->set_custom_titlebar(use_xwayland_gui_); +#endif RegisterFontAwesome((*ui_->stream)->window()); // Slint globals belong to a component tree. Force the next localization // pass to initialize the newly-created, independent stream window tree. @@ -2016,6 +2284,8 @@ void GuiApplication::SyncStreamWindow() { if (!ui_->stream) { return; } + (*ui_->stream) + ->set_window_maximized((*ui_->stream)->window().is_maximized()); (*ui_->stream)->set_srtp_enabled(enable_srtp_); #if defined(__APPLE__) if (ui_->stream_live_resize_configuration_attempts > 0) { @@ -2029,7 +2299,8 @@ void GuiApplication::SyncStreamWindow() { if (ui_->stream_initial_position_attempts > 0) { if (PositionWindowAtCenter((*ui_->stream)->window(), ui_->main->window(), kStreamWindowLogicalWidth, - kStreamWindowLogicalHeight)) { + StreamWindowLogicalHeight( + (*ui_->stream)->get_custom_titlebar()))) { --ui_->stream_initial_position_attempts; } else { ui_->stream_initial_position_attempts = 0; @@ -2586,10 +2857,15 @@ void GuiApplication::SendPointerInput(int button, int kind, float x, float y) { const auto size = (*ui_->stream)->window().size(); const float scale = (*ui_->stream)->window().scale_factor(); const float available_width = size.width / scale; + const float titlebar_height = + !fullscreen_button_pressed_ && (*ui_->stream)->get_custom_titlebar() + ? kWaylandTitlebarLogicalHeight + : 0.0f; const float available_height = - size.height / scale - (fullscreen_button_pressed_ - ? 0.0f - : (ui_->tab_ids.size() > 1 ? 30.0f : 0.0f)); + size.height / scale - titlebar_height - + (fullscreen_button_pressed_ + ? 0.0f + : (ui_->tab_ids.size() > 1 ? 30.0f : 0.0f)); float render_width = available_width; float render_height = available_height; float offset_x = 0; @@ -2657,10 +2933,15 @@ void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x, const auto size = (*ui_->stream)->window().size(); const float scale = (*ui_->stream)->window().scale_factor(); const float width = size.width / scale; + const float titlebar_height = + !fullscreen_button_pressed_ && (*ui_->stream)->get_custom_titlebar() + ? kWaylandTitlebarLogicalHeight + : 0.0f; const float height = - size.height / scale - (fullscreen_button_pressed_ - ? 0.0f - : (ui_->tab_ids.size() > 1 ? 30.0f : 0.0f)); + size.height / scale - titlebar_height - + (fullscreen_button_pressed_ + ? 0.0f + : (ui_->tab_ids.size() > 1 ? 30.0f : 0.0f)); if (width <= 0 || height <= 0) { return; } diff --git a/src/gui/application/gui_application.h b/src/gui/application/gui_application.h index f39f96c..f23cb29 100644 --- a/src/gui/application/gui_application.h +++ b/src/gui/application/gui_application.h @@ -36,6 +36,9 @@ private: void SyncPlatformDialogs(); void SyncStreamWindow(); void SyncServerWindow(); +#if defined(__linux__) && !defined(__APPLE__) + void SyncXWaylandWindowActivation(); +#endif void UpdateLocalization(); void ResetSettingsUi(); void SaveSettingsFromUi(); @@ -59,6 +62,9 @@ private: bool OpenUrl(const std::string &url); std::unique_ptr ui_; +#if defined(__linux__) && !defined(__APPLE__) + bool use_xwayland_gui_ = false; +#endif }; } // namespace crossdesk diff --git a/src/gui/ui/main_window.slint b/src/gui/ui/main_window.slint index 969c824..52f31cf 100644 --- a/src/gui/ui/main_window.slint +++ b/src/gui/ui/main_window.slint @@ -184,16 +184,21 @@ component ConnectionDialogButton inherits Rectangle { export component MainWindow inherits Window { title: "CrossDesk"; preferred-width: 640px; - preferred-height: root.custom-titlebar ? 481px : 450px; + preferred-height: 450px + root.titlebar-height; min-width: 640px; max-width: 640px; - min-height: root.custom-titlebar ? 481px : 450px; - max-height: root.custom-titlebar ? 481px : 450px; + min-height: 450px + root.titlebar-height; + max-height: 450px + root.titlebar-height; no-frame: root.custom-titlebar; - background: white; + background: root.wayland-titlebar ? transparent : white; default-font-size: ImGuiFontStyle.base; in property custom-titlebar: false; + in property wayland-titlebar: false; + in property window-active: true; + private property titlebar-height: + root.custom-titlebar ? (root.wayland-titlebar ? 32px : 31px) : 0px; + private property wayland-corner-radius: 12px; in property local-id: ""; in property local-password: ""; in-out property remote-id-input: ""; @@ -310,7 +315,7 @@ export component MainWindow inherits Window { ? root.recent-tooltip-pointer-x - root.recent-tooltip-width - 12px : root.recent-tooltip-pointer-x + 12px)); private property recent-tooltip-y: min( - root.height - (root.custom-titlebar ? 31px : 0px) - root.recent-tooltip-height - 8px, + root.height - root.titlebar-height - root.recent-tooltip-height - 8px, max(8px, root.recent-tooltip-pointer-y - root.recent-tooltip-height - 8px)); reset-remote-id => { @@ -318,7 +323,108 @@ export component MainWindow inherits Window { remote-id-editor.focus(); } - if root.custom-titlebar: Rectangle { + window-surface := Rectangle { + width: root.width; + height: root.height; + background: root.wayland-titlebar ? #f6f5f4 : transparent; + border-radius: + root.wayland-titlebar ? root.wayland-corner-radius : 0px; + clip: root.wayland-titlebar; + + if root.custom-titlebar && root.wayland-titlebar: Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: root.titlebar-height; + background: #f6f5f4; + z: 100; + + Text { + x: 80px; + width: parent.width - 160px; + height: parent.height; + text: "CrossDesk"; + color: root.window-active ? #3d3d3d : #747474; + font-size: 12px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } + wayland-title-drag := TouchArea { + width: parent.width - 80px; + property phase: self.pressed ? 1 : 0; + changed phase => { + root.main-title-drag(phase, self.mouse-x, self.mouse-y); + } + moved => { + if (self.pressed) { + root.main-title-drag(2, self.mouse-x, self.mouse-y); + } + } + } + wayland-minimize-button := Rectangle { + x: parent.width - 74px; + y: 4px; + width: 24px; + height: 24px; + border-radius: 12px; + background: wayland-minimize-touch.pressed ? #c0bfbc + : wayland-minimize-touch.has-hover ? #deddda + : root.window-active ? #e6e6e6 + : transparent; + accessible-role: button; + accessible-label: "Minimize"; + accessible-action-default => { wayland-minimize-touch.clicked(); } + Rectangle { + x: 8px; + y: 13px; + width: 8px; + height: 0.75px; + background: root.window-active + || wayland-minimize-touch.has-hover + || wayland-minimize-touch.pressed ? #454545 : #898989; + } + wayland-minimize-touch := TouchArea { + clicked => { root.minimize-main-window(); } + } + } + wayland-close-button := Rectangle { + x: parent.width - 34px; + y: 4px; + width: 24px; + height: 24px; + border-radius: 12px; + background: wayland-close-touch.pressed ? #b2161d + : wayland-close-touch.has-hover ? #e01b24 + : root.window-active ? #e6e6e6 + : transparent; + accessible-role: button; + accessible-label: "Close"; + accessible-action-default => { wayland-close-touch.clicked(); } + Path { + width: parent.width; + height: parent.height; + viewbox-width: 24; + viewbox-height: 24; + commands: "M 14.75 14.75 L 8.25 8.25 M 14.75 8.25 L 8.25 14.75"; + stroke: wayland-close-touch.has-hover + || wayland-close-touch.pressed ? white + : root.window-active ? #454545 : #898989; + stroke-width: 1px; + } + wayland-close-touch := TouchArea { + clicked => { root.close-main-window(); } + } + } + Rectangle { + y: parent.height - 1px; + height: 1px; + background: #d5d3d0; + } + } + + if root.custom-titlebar && !root.wayland-titlebar: Rectangle { x: 0px; y: 0px; width: root.width; @@ -404,7 +510,7 @@ export component MainWindow inherits Window { content-layer := Rectangle { x: 0px; - y: root.custom-titlebar ? 31px : 0px; + y: root.titlebar-height; width: root.width; height: root.height - self.y; clip: true; @@ -1613,7 +1719,10 @@ export component MainWindow inherits Window { height: root.height; background: transparent; border-width: 1px; - border-color: #8b8b8b; + border-color: root.wayland-titlebar ? #aaa8a6 : #8b8b8b; + border-radius: + root.wayland-titlebar ? root.wayland-corner-radius : 0px; z: 200; } + } } diff --git a/src/gui/ui/stream_window.slint b/src/gui/ui/stream_window.slint index f372afa..444d6b8 100644 --- a/src/gui/ui/stream_window.slint +++ b/src/gui/ui/stream_window.slint @@ -112,13 +112,16 @@ component ControlBarButton inherits Rectangle { export component StreamWindow inherits Window { title: "CrossDesk"; preferred-width: 1280px; - preferred-height: 720px; + preferred-height: 720px + (root.custom-titlebar ? 32px : 0px); min-width: 640px; - min-height: 360px; - no-frame: false; - background: black; + min-height: 360px + (root.custom-titlebar ? 32px : 0px); + no-frame: root.custom-titlebar; + background: root.custom-titlebar ? transparent : black; default-font-size: ImGuiFontStyle.base; + in property custom-titlebar: false; + in property window-active: true; + in property window-maximized: false; in property <[StreamTab]> tabs; in-out property selected-tab: 0; in property frame; @@ -153,6 +156,10 @@ export component StreamWindow inherits Window { callback toggle-stats; callback toggle-fullscreen; callback disconnect; + callback stream-title-drag(int, length, length); + callback minimize-stream-window; + callback toggle-maximize-stream-window; + callback close-stream-window; callback pointer-input(PointerEventButton, PointerEventKind, length, length); callback scroll-input(length, length, length, length); callback key-input(string, bool, bool, bool, bool, bool); @@ -169,6 +176,9 @@ export component StreamWindow inherits Window { private property control-press-x: 0px; private property control-press-y: 0px; private property dragging-tab: -1; + private property titlebar-height: + root.custom-titlebar && !root.fullscreen-enabled ? 32px : 0px; + private property window-corner-radius: 12px; callback begin-control-drag(length, length); callback move-control-drag(length, length); callback end-control-drag; @@ -202,10 +212,167 @@ export component StreamWindow inherits Window { } } - Rectangle { + window-surface := Rectangle { width: root.width; height: root.height; background: black; + border-radius: + root.custom-titlebar && !root.fullscreen-enabled && !root.window-maximized + ? root.window-corner-radius : 0px; + clip: root.custom-titlebar && !root.fullscreen-enabled; + + if root.custom-titlebar && !root.fullscreen-enabled: Rectangle { + x: 0px; + y: 0px; + width: parent.width; + height: root.titlebar-height; + background: #f6f5f4; + z: 100; + + Text { + x: 120px; + width: parent.width - 240px; + height: parent.height; + text: "CrossDesk"; + color: root.window-active ? #3d3d3d : #747474; + font-size: 12px; + font-weight: 600; + horizontal-alignment: center; + vertical-alignment: center; + overflow: elide; + } + stream-title-drag-area := TouchArea { + width: parent.width - 120px; + property phase: self.pressed ? 1 : 0; + changed phase => { + root.stream-title-drag(phase, self.mouse-x, self.mouse-y); + } + moved => { + if (self.pressed) { + root.stream-title-drag(2, self.mouse-x, self.mouse-y); + } + } + double-clicked => { + root.toggle-maximize-stream-window(); + } + } + stream-minimize-button := Rectangle { + x: parent.width - 114px; + y: 4px; + width: 24px; + height: 24px; + border-radius: 12px; + background: stream-minimize-touch.pressed ? #c0bfbc + : stream-minimize-touch.has-hover ? #deddda + : root.window-active ? #e6e6e6 + : transparent; + accessible-role: button; + accessible-label: "Minimize"; + accessible-action-default => { stream-minimize-touch.clicked(); } + Rectangle { + x: 8px; + y: 13px; + width: 8px; + height: 0.75px; + background: root.window-active + || stream-minimize-touch.has-hover + || stream-minimize-touch.pressed ? #454545 : #898989; + } + stream-minimize-touch := TouchArea { + clicked => { root.minimize-stream-window(); } + } + } + stream-maximize-button := Rectangle { + x: parent.width - 74px; + y: 4px; + width: 24px; + height: 24px; + border-radius: 12px; + background: stream-maximize-touch.pressed ? #c0bfbc + : stream-maximize-touch.has-hover ? #deddda + : root.window-active ? #e6e6e6 + : transparent; + accessible-role: button; + accessible-label: root.window-maximized ? "Restore" : "Maximize"; + accessible-action-default => { stream-maximize-touch.clicked(); } + if !root.window-maximized: Rectangle { + x: 8px; + y: 8px; + width: 8px; + height: 8px; + background: transparent; + border-width: 1px; + border-color: root.window-active + || stream-maximize-touch.has-hover + || stream-maximize-touch.pressed ? #454545 : #898989; + } + if root.window-maximized: Rectangle { + x: 9px; + y: 7px; + width: 8px; + height: 8px; + background: transparent; + border-width: 1px; + border-color: root.window-active + || stream-maximize-touch.has-hover + || stream-maximize-touch.pressed ? #454545 : #898989; + } + if root.window-maximized: Rectangle { + x: 7px; + y: 9px; + width: 8px; + height: 8px; + background: stream-maximize-button.background; + border-width: 1px; + border-color: root.window-active + || stream-maximize-touch.has-hover + || stream-maximize-touch.pressed ? #454545 : #898989; + } + stream-maximize-touch := TouchArea { + clicked => { root.toggle-maximize-stream-window(); } + } + } + stream-close-button := Rectangle { + x: parent.width - 34px; + y: 4px; + width: 24px; + height: 24px; + border-radius: 12px; + background: stream-close-touch.pressed ? #b2161d + : stream-close-touch.has-hover ? #e01b24 + : root.window-active ? #e6e6e6 + : transparent; + accessible-role: button; + accessible-label: "Close"; + accessible-action-default => { stream-close-touch.clicked(); } + Path { + width: parent.width; + height: parent.height; + viewbox-width: 24; + viewbox-height: 24; + commands: "M 14.75 14.75 L 8.25 8.25 M 14.75 8.25 L 8.25 14.75"; + stroke: stream-close-touch.has-hover + || stream-close-touch.pressed ? white + : root.window-active ? #454545 : #898989; + stroke-width: 1px; + } + stream-close-touch := TouchArea { + clicked => { root.close-stream-window(); } + } + } + Rectangle { + y: parent.height - 1px; + height: 1px; + background: #d5d3d0; + } + } + + content-layer := Rectangle { + y: root.titlebar-height; + width: parent.width; + height: parent.height - self.y; + background: black; + clip: true; // Keep the ImGui tab strip behavior, including pointer-drag reordering. Rectangle { @@ -645,5 +812,17 @@ export component StreamWindow inherits Window { } } } + } + + if root.custom-titlebar && !root.fullscreen-enabled: Rectangle { + width: parent.width; + height: parent.height; + background: transparent; + border-width: 1px; + border-color: #aaa8a6; + border-radius: + root.window-maximized ? 0px : root.window-corner-radius; + z: 200; + } } } diff --git a/tests/slint_ui_smoke_test.cpp b/tests/slint_ui_smoke_test.cpp index 0b529a7..5a6485f 100644 --- a/tests/slint_ui_smoke_test.cpp +++ b/tests/slint_ui_smoke_test.cpp @@ -1,19 +1,58 @@ #include "crossdesk_ui.h" +#include "fa_solid_900.h" #include +#include +#include #include #include #include +namespace { + +// Set the CROSSDESK_*_UI_SNAPSHOT variables while using Slint's software +// renderer to export deterministic active and inactive visual-check images. +bool WriteWindowSnapshot(const slint::Window &window, const char *path) { + const auto snapshot = window.take_snapshot(); + if (!snapshot) { + return false; + } + + std::ofstream output(path, std::ios::binary); + output << "P6\n" + << snapshot->width() << ' ' << snapshot->height() << "\n255\n"; + for (const auto &pixel : *snapshot) { + output.put(static_cast(pixel.r)); + output.put(static_cast(pixel.g)); + output.put(static_cast(pixel.b)); + } + return output.good(); +} + +bool RegisterFontAwesome(slint::Window &window) { + return !window.window_handle() + .register_font_from_data(fa_solid_900_ttf, + fa_solid_900_ttf_len) + .has_value(); +} + +} // namespace + int main() { auto window = crossdesk::ui::MainWindow::create(); auto stream = crossdesk::ui::StreamWindow::create(); auto server = crossdesk::ui::ServerWindow::create(); + if (!RegisterFontAwesome(window->window()) || + !RegisterFontAwesome(stream->window()) || + !RegisterFontAwesome(server->window())) { + return 1; + } window->set_local_id("123 456 789"); window->set_local_password("123456"); window->set_connection_dialog_open(true); window->set_connection_password_required(true); window->set_custom_titlebar(true); + window->set_wayland_titlebar(true); window->set_settings_session_active(true); window->set_hardware_codec_available(false); window->set_portable_service_settings_visible(true); @@ -21,9 +60,32 @@ int main() { assert(window->get_connection_dialog_open()); assert(window->get_connection_password_required()); assert(window->get_custom_titlebar()); + assert(window->get_wayland_titlebar()); assert(window->get_settings_session_active()); assert(!window->get_hardware_codec_available()); assert(window->get_portable_service_settings_visible()); + if (const char *snapshot_path = + std::getenv("CROSSDESK_MAIN_UI_SNAPSHOT")) { + window->show(); + const bool snapshot_written = + WriteWindowSnapshot(window->window(), snapshot_path); + window->hide(); + if (!snapshot_written) { + return 2; + } + } + if (const char *snapshot_path = + std::getenv("CROSSDESK_MAIN_INACTIVE_UI_SNAPSHOT")) { + window->set_window_active(false); + window->show(); + const bool snapshot_written = + WriteWindowSnapshot(window->window(), snapshot_path); + window->hide(); + window->set_window_active(true); + if (!snapshot_written) { + return 3; + } + } window->set_settings_open(true); window->set_about_open(true); assert(window->get_settings_open()); @@ -66,7 +128,37 @@ int main() { stream->set_tabs( std::make_shared>( std::vector{tab})); + stream->set_custom_titlebar(true); + stream->set_window_maximized(true); assert(stream->get_tabs()->row_count() == 1); + assert(stream->get_custom_titlebar()); + assert(stream->get_window_maximized()); + if (const char *snapshot_path = + std::getenv("CROSSDESK_STREAM_UI_SNAPSHOT")) { + stream->set_window_maximized(false); + stream->show(); + const bool snapshot_written = + WriteWindowSnapshot(stream->window(), snapshot_path); + stream->hide(); + stream->set_window_maximized(true); + if (!snapshot_written) { + return 4; + } + } + if (const char *snapshot_path = + std::getenv("CROSSDESK_STREAM_INACTIVE_UI_SNAPSHOT")) { + stream->set_window_maximized(false); + stream->set_window_active(false); + stream->show(); + const bool snapshot_written = + WriteWindowSnapshot(stream->window(), snapshot_path); + stream->hide(); + stream->set_window_active(true); + stream->set_window_maximized(true); + if (!snapshot_written) { + return 5; + } + } bool tab_reordered = false; stream->on_reorder_tab([&](int index, float x, float width) { @@ -75,6 +167,12 @@ int main() { stream->invoke_reorder_tab(0, 120.0f, 110.0f); assert(tab_reordered); + bool maximize_requested = false; + stream->on_toggle_maximize_stream_window( + [&] { maximize_requested = true; }); + stream->invoke_toggle_maximize_stream_window(); + assert(maximize_requested); + crossdesk::ui::FileTransferEntry transfer; transfer.name = "archive.zip"; transfer.status = "Sending"; diff --git a/xmake/targets.lua b/xmake/targets.lua index d75d02a..9d4b225 100644 --- a/xmake/targets.lua +++ b/xmake/targets.lua @@ -107,6 +107,7 @@ function setup_targets() add_cxxflags("/bigobj") end add_packages("slint") + add_includedirs("src/gui/assets/fonts") add_rules("slint") add_files("src/gui/ui/crossdesk_ui.slint") add_files("tests/slint_ui_smoke_test.cpp")