From af49d5ba2857dbd2fd45df2b0878c3a6fd7aa675 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 28 Oct 2026 11:03:50 +0800 Subject: [PATCH] [ui] center privacy unlock hints and update corner branding --- .../macos/privacy/privacy_cover_view.mm | 106 ++++++++------ .../band_module/privacy_cover_renderer.cpp | 130 +++++++++++------- 2 files changed, 147 insertions(+), 89 deletions(-) diff --git a/apps/desktop/src/platform/macos/privacy/privacy_cover_view.mm b/apps/desktop/src/platform/macos/privacy/privacy_cover_view.mm index 8e680260..91efe103 100644 --- a/apps/desktop/src/platform/macos/privacy/privacy_cover_view.mm +++ b/apps/desktop/src/platform/macos/privacy/privacy_cover_view.mm @@ -3,6 +3,7 @@ #include "privacy_cover_view.h" #include +#include #include namespace { @@ -32,9 +33,11 @@ NSImage* LoadPrivacyLogo() { return nil; } -NSAttributedString* Text(NSString* value, CGFloat size, NSFontWeight weight, NSColor* color) { +NSAttributedString* Text(NSString* value, CGFloat size, NSFontWeight weight, NSColor* color, + NSTextAlignment alignment = NSTextAlignmentLeft) { NSMutableParagraphStyle* paragraph = [[NSMutableParagraphStyle alloc] init]; paragraph.lineBreakMode = NSLineBreakByWordWrapping; + paragraph.alignment = alignment; return [[NSAttributedString alloc] initWithString:value attributes:@{ NSFontAttributeName: [NSFont systemFontOfSize:size weight:weight], NSForegroundColorAttributeName: color, @@ -48,21 +51,58 @@ CGFloat TextHeight(NSAttributedString* text, CGFloat width) { options:kTextOptions].size.height); } -NSAttributedString* BrandText(CGFloat scale) { - NSFont* font = [NSFont fontWithName:@"Arial-BoldMT" size:29 * scale]; - if (!font) font = [NSFont boldSystemFontOfSize:29 * scale]; - auto* brand = [[NSMutableAttributedString alloc] initWithString:@"CrossDesk" - attributes:@{NSFontAttributeName: font, - NSForegroundColorAttributeName: - [NSColor colorWithSRGBRed:69.0 / 255 green:135.0 / 255 - blue:219.0 / 255 alpha:1]}]; - [brand addAttribute:NSForegroundColorAttributeName - value:[NSColor colorWithSRGBRed:37.0 / 255 green:83.0 / 255 - blue:151.0 / 255 alpha:1] - range:NSMakeRange(5, 4)]; - [brand addAttribute:NSKernAttributeName value:@(scale) range:NSMakeRange(4, 1)]; - return brand; +void DrawUnlockInstructions(NSString* value, NSSize size, CGFloat scale, + CGFloat brand_top) { + NSColor* foreground = [NSColor colorWithSRGBRed:229.0 / 255 green:233.0 / 255 + blue:240.0 / 255 alpha:1]; + NSColor* muted = [NSColor colorWithSRGBRed:166.0 / 255 green:174.0 / 255 + blue:186.0 / 255 alpha:1]; + NSAttributedString* hint = Text(value, 17 * scale, NSFontWeightRegular, muted, + NSTextAlignmentCenter); + NSAttributedString* plus = Text(@"+", 16 * scale, NSFontWeightRegular, muted); + const CGFloat text_width = 420 * scale; + const CGFloat hint_height = TextHeight(hint, text_width); + const CGFloat key_height = 44 * scale, key_gap = 28 * scale; + const CGFloat line_gap = 16 * scale; + NSArray* keys = @[@"Control", @"Option", @"Shift", @"Esc"]; + NSMutableArray* labels = [NSMutableArray array]; + std::array widths{}; + CGFloat row_width = key_gap * (keys.count - 1); + for (NSUInteger i = 0; i < keys.count; ++i) { + NSAttributedString* label = Text(keys[i], 16 * scale, NSFontWeightSemibold, foreground); + [labels addObject:label]; + widths[i] = std::max(48 * scale, std::ceil(label.size.width) + 28 * scale); + row_width += widths[i]; + } + const CGFloat content_height = hint_height + line_gap + key_height; + // Center the instructions independently of the footer, leaving room on short displays. + const CGFloat top = std::max(32 * scale, std::min((size.height - content_height) / 2, + brand_top - 32 * scale - content_height)); + [hint drawWithRect:NSMakeRect((size.width - text_width) / 2, top, text_width, hint_height) + options:kTextOptions]; + const CGFloat key_top = top + hint_height + line_gap; + CGFloat left = (size.width - row_width) / 2; + for (NSUInteger i = 0; i < keys.count; ++i) { + NSRect bounds = NSMakeRect(left, key_top, widths[i], key_height); + NSBezierPath* key = [NSBezierPath bezierPathWithRoundedRect:NSInsetRect(bounds, scale / 2, scale / 2) + xRadius:8 * scale yRadius:8 * scale]; + [[NSColor colorWithSRGBRed:16.0 / 255 green:19.0 / 255 blue:24.0 / 255 alpha:1] setFill]; + [key fill]; + [[NSColor colorWithSRGBRed:52.0 / 255 green:58.0 / 255 blue:68.0 / 255 alpha:1] setStroke]; + key.lineWidth = scale; + [key stroke]; + NSAttributedString* label = labels[i]; + [label drawAtPoint:NSMakePoint(left + (widths[i] - label.size.width) / 2, + key_top + (key_height - label.size.height) / 2)]; + left += widths[i]; + if (i + 1 < keys.count) { + [plus drawAtPoint:NSMakePoint(left + (key_gap - plus.size.width) / 2, + key_top + (key_height - plus.size.height) / 2)]; + left += key_gap; + } + } } + } @interface CrossDeskPrivacyCoverView : NSView @@ -81,38 +121,26 @@ NSAttributedString* BrandText(CGFloat scale) { if (width <= 0 || height <= 0) return; // Match the Windows cover's sizes, colors and bottom-left alignment. // AppKit coordinates are points; Retina backing scale is applied by AppKit. - const CGFloat scale = std::min({CGFloat(1), width / 768, height / 200}); - const CGFloat margin = 32 * scale, text_width = 398 * scale; - NSColor* foreground = [NSColor colorWithSRGBRed:245.0 / 255 green:247.0 / 255 - blue:250.0 / 255 alpha:1]; - // Match the horizontal, two-tone wordmark in docs/images/crossdesk-banner.svg. - NSAttributedString* brand = BrandText(scale); - const CGFloat logo_size = 48 * scale; + const CGFloat scale = std::min({CGFloat(1), width / 960, height / 280}); + const CGFloat margin = 32 * scale, brand_gap = 8 * scale; + NSAttributedString* brand = Text(@"CrossDesk远程控制中", 29 * scale, + NSFontWeightSemibold, NSColor.whiteColor); + const CGFloat logo_size = 60 * scale; const CGFloat brand_text_width = std::ceil(brand.size.width); const CGFloat brand_text_height = std::ceil(brand.size.height); - const CGFloat brand_width = logo_size + 8 * scale + brand_text_width; - const CGFloat bottom = height - margin; - const CGFloat logo_top = bottom - logo_size; + // Keep the corner branding fixed when local unlocking is toggled. + const CGFloat brand_bottom = height - margin; + const CGFloat logo_top = brand_bottom - logo_size; [self.logo drawInRect:NSMakeRect(margin, logo_top, logo_size, logo_size) fromRect:NSZeroRect operation:NSCompositingOperationSourceOver fraction:1 respectFlipped:YES hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; - [brand drawWithRect:NSMakeRect(margin + logo_size + 8 * scale, - bottom - brand_text_height, + const CGFloat text_left = margin + logo_size + brand_gap; + [brand drawWithRect:NSMakeRect(text_left, brand_bottom - brand_text_height, brand_text_width, brand_text_height) options:kTextOptions]; if (!self.allowLocalUnlock) return; - NSColor* muted = [NSColor colorWithSRGBRed:181.0 / 255 green:190.0 / 255 - blue:204.0 / 255 alpha:1]; - NSAttributedString* hint = Text(self.unlockHint, 16 * scale, NSFontWeightRegular, muted); - NSAttributedString* shortcut = Text(@"Control + Option + Shift + Esc", 24 * scale, - NSFontWeightSemibold, foreground); - const CGFloat hint_height = TextHeight(hint, text_width); - const CGFloat shortcut_height = TextHeight(shortcut, text_width); - const CGFloat text_left = margin + brand_width + 24 * scale; - [hint drawWithRect:NSMakeRect(text_left, bottom - shortcut_height - 12 * scale - hint_height, - text_width, hint_height) options:kTextOptions]; - [shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height, - text_width, shortcut_height) options:kTextOptions]; + DrawUnlockInstructions(self.unlockHint, self.bounds.size, scale, + brand_bottom - std::max(logo_size, brand_text_height)); } @end diff --git a/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp b/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp index a1cc21d7..c1f82b53 100644 --- a/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp +++ b/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp @@ -13,21 +13,23 @@ namespace crossdesk { namespace { constexpr size_t kInstructionLines = 2; -constexpr size_t kBrandSegments = 2; -constexpr size_t kCrossLine = kInstructionLines; -constexpr size_t kDeskLine = kCrossLine + 1; +constexpr size_t kBrandLine = kInstructionLines; struct PaintResources { HDC dc; int saved; HICON logo = nullptr; - std::array fonts{}; + HBRUSH key_fill = nullptr; + HPEN key_border = nullptr; + std::array fonts{}; ~PaintResources() { // Deselect every owned GDI object before releasing it. if (saved) RestoreDC(dc, saved); for (auto font : fonts) if (font) DeleteObject(font); if (logo) DestroyIcon(logo); + if (key_fill) DeleteObject(key_fill); + if (key_border) DeleteObject(key_border); } }; @@ -47,12 +49,13 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module, if (width <= 0 || height <= 0) return false; // Scale with the monitor DPI, shrinking the whole group on small displays. // All coordinates remain in the owning window's physical-pixel context. - const double scale = (std::min)({dpi / 96.0, width / 768.0, height / 200.0}); + const double scale = (std::min)({dpi / 96.0, width / 960.0, height / 280.0}); const auto px = [scale](int value) { return (std::max)(1, static_cast(value * scale + 0.5)); }; const int margin = px(32); - const int text_width = px(398); + const int text_width = px(420); + const int line_gap = px(16); PaintResources resources{dc, SaveDC(dc)}; if (!resources.saved || !SetBkMode(dc, TRANSPARENT)) return false; @@ -63,77 +66,104 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module, COLORREF color; RECT bounds{}; }; - std::array lines{{ - {unlock_hint, 16, FW_NORMAL, RGB(181, 190, 204)}, - {L"Ctrl + Alt + Shift + F12", 24, FW_SEMIBOLD, RGB(245, 247, 250)}, - {L"Cross", 29, FW_BOLD, RGB(69, 135, 219)}, - {L"Desk", 29, FW_BOLD, RGB(37, 83, 151)}, + std::array lines{{ + {unlock_hint, 17, FW_NORMAL, RGB(166, 174, 186)}, + {L"+", 16, FW_SEMIBOLD, RGB(229, 233, 240)}, + {L"CrossDesk远程控制中", 29, FW_SEMIBOLD, RGB(255, 255, 255)}, }}; - constexpr UINT flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX; + constexpr UINT flags = DT_CENTER | DT_WORDBREAK | DT_NOPREFIX; constexpr UINT brand_flags = DT_LEFT | DT_SINGLELINE | DT_NOPREFIX; for (size_t i = 0; i < lines.size(); ++i) { if (i < kInstructionLines && !allow_local_unlock) continue; auto& line = lines[i]; - const bool is_brand = i >= kInstructionLines; resources.fonts[i] = CreateFontW(-px(line.size), 0, 0, 0, line.weight, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, - ANTIALIASED_QUALITY, DEFAULT_PITCH, - is_brand ? L"Arial" : L"Microsoft YaHei UI"); + ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI"); if (!resources.fonts[i] || !Select(dc, resources.fonts[i])) return false; line.bounds = {0, 0, text_width, 0}; if (!DrawTextW(dc, line.text, -1, &line.bounds, - (is_brand ? brand_flags : flags) | DT_CALCRECT) || + (i == 0 ? flags : brand_flags) | DT_CALCRECT) || line.bounds.right > text_width) return false; } - const int content_height = - static_cast(lines[0].bounds.bottom + lines[1].bounds.bottom) + - (allow_local_unlock ? px(12) : 0); - // Match the horizontal, two-tone wordmark in docs/images/crossdesk-banner.svg. - const int logo_size = px(48); + // Keep the logo and status text side by side, aligned along their bottom edge. + const int logo_size = px(60); const int brand_gap = px(8); - const int segment_gap = px(1); - const int brand_width = logo_size + brand_gap + segment_gap + - static_cast(lines[kCrossLine].bounds.right + lines[kDeskLine].bounds.right); - const int brand_height = (std::max)({logo_size, - static_cast(lines[kCrossLine].bounds.bottom), - static_cast(lines[kDeskLine].bounds.bottom)}); + const int brand_width = logo_size + brand_gap + + static_cast(lines[kBrandLine].bounds.right); + const int brand_height = (std::max)(logo_size, + static_cast(lines[kBrandLine].bounds.bottom)); if (logo_size <= 0 || - (std::max)(brand_height, content_height) + 2 * margin > height || - brand_width + (allow_local_unlock ? px(24) + text_width : 0) + 2 * margin > width) + brand_height + 2 * margin > height || + brand_width + 2 * margin > width) return false; resources.logo = static_cast(LoadImageW( module, L"IDI_CROSSDESK_LOGO", IMAGE_ICON, logo_size, logo_size, 0)); if (!resources.logo) return false; const int brand_left = rect.left + margin; - const int left = brand_left + brand_width + px(24); + // Keep the corner branding fixed when local unlocking is toggled. const int brand_bottom = rect.bottom - margin; - int top = rect.bottom - margin - content_height; if (!DrawIconEx(dc, brand_left, brand_bottom - logo_size, resources.logo, logo_size, logo_size, 0, nullptr, DI_NORMAL)) return false; - int brand_text_left = brand_left + logo_size + brand_gap; - for (size_t i = kCrossLine; i <= kDeskLine; ++i) { - const auto& line = lines[i]; - if (!Select(dc, resources.fonts[i])) return false; - const int brand_text_top = brand_bottom - line.bounds.bottom; - RECT bounds{brand_text_left, brand_text_top, - brand_text_left + line.bounds.right, - brand_text_top + line.bounds.bottom}; - SetTextColor(dc, line.color); - if (!DrawTextW(dc, line.text, -1, &bounds, brand_flags)) return false; - brand_text_left += line.bounds.right + segment_gap; - } + const int brand_text_left = brand_left + logo_size + brand_gap; + const auto& brand = lines[kBrandLine]; + if (!Select(dc, resources.fonts[kBrandLine])) return false; + RECT brand_bounds{brand_text_left, brand_bottom - brand.bounds.bottom, + brand_text_left + brand.bounds.right, brand_bottom}; + SetTextColor(dc, brand.color); + if (!DrawTextW(dc, brand.text, -1, &brand_bounds, brand_flags)) return false; if (!allow_local_unlock) return true; - for (size_t i = 0; i < kInstructionLines; ++i) { - const auto& line = lines[i]; - if (!Select(dc, resources.fonts[i])) return false; - RECT bounds{left, top, left + text_width, top + line.bounds.bottom}; - SetTextColor(dc, line.color); - if (!DrawTextW(dc, line.text, -1, &bounds, flags)) return false; - top += line.bounds.bottom + px(12); + + constexpr std::array keys{L"Ctrl", L"Alt", L"Shift", L"F12"}; + std::array key_widths{}; + const int key_gap = px(28); + const int key_height = (std::max)(px(44), + static_cast(lines[1].bounds.bottom) + 2 * px(10)); + int row_width = key_gap * static_cast(keys.size() - 1); + if (!Select(dc, resources.fonts[1])) return false; + for (size_t i = 0; i < keys.size(); ++i) { + SIZE extent{}; + if (!GetTextExtentPoint32W(dc, keys[i], lstrlenW(keys[i]), &extent)) return false; + key_widths[i] = (std::max)(px(48), static_cast(extent.cx) + 2 * px(14)); + row_width += key_widths[i]; + } + const int content_height = static_cast(lines[0].bounds.bottom) + line_gap + key_height; + // Center the instructions independently of the footer, leaving room on short displays. + const int top = (std::min)(rect.top + (height - content_height) / 2, + brand_bottom - brand_height - px(32) - content_height); + if (top < rect.top + margin || (std::max)(row_width, text_width) + 2 * margin > width) + return false; + const int hint_left = rect.left + (width - text_width) / 2; + RECT hint_bounds{hint_left, top, hint_left + text_width, top + lines[0].bounds.bottom}; + if (!Select(dc, resources.fonts[0])) return false; + SetTextColor(dc, lines[0].color); + if (!DrawTextW(dc, unlock_hint, -1, &hint_bounds, flags)) return false; + + resources.key_fill = CreateSolidBrush(RGB(16, 19, 24)); + resources.key_border = CreatePen(PS_SOLID, px(1), RGB(52, 58, 68)); + if (!resources.key_fill || !resources.key_border || + !Select(dc, resources.key_fill) || !Select(dc, resources.key_border) || + !Select(dc, resources.fonts[1])) + return false; + const int key_top = top + static_cast(lines[0].bounds.bottom) + line_gap; + int left = rect.left + (width - row_width) / 2; + constexpr UINT key_flags = DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX; + for (size_t i = 0; i < keys.size(); ++i) { + RECT bounds{left, key_top, left + key_widths[i], key_top + key_height}; + if (!RoundRect(dc, bounds.left, bounds.top, bounds.right, bounds.bottom, px(16), px(16))) + return false; + SetTextColor(dc, lines[1].color); + if (!DrawTextW(dc, keys[i], -1, &bounds, key_flags)) return false; + left += key_widths[i]; + if (i + 1 < keys.size()) { + RECT plus_bounds{left, key_top, left + key_gap, key_top + key_height}; + SetTextColor(dc, lines[0].color); + if (!DrawTextW(dc, L"+", -1, &plus_bounds, key_flags)) return false; + left += key_gap; + } } return true; }