mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-29 01:02:44 +08:00
[ui] center privacy unlock hints and update corner branding
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
#include "privacy_cover_view.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
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<NSString*>* keys = @[@"Control", @"Option", @"Shift", @"Esc"];
|
||||
NSMutableArray<NSAttributedString*>* labels = [NSMutableArray array];
|
||||
std::array<CGFloat, 4> 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
|
||||
|
||||
|
||||
@@ -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<HFONT, kInstructionLines + kBrandSegments> fonts{};
|
||||
HBRUSH key_fill = nullptr;
|
||||
HPEN key_border = nullptr;
|
||||
std::array<HFONT, kInstructionLines + 1> 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<int>(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<Line, kInstructionLines + kBrandSegments> 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<Line, kInstructionLines + 1> 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<int>(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<int>(lines[kCrossLine].bounds.right + lines[kDeskLine].bounds.right);
|
||||
const int brand_height = (std::max)({logo_size,
|
||||
static_cast<int>(lines[kCrossLine].bounds.bottom),
|
||||
static_cast<int>(lines[kDeskLine].bounds.bottom)});
|
||||
const int brand_width = logo_size + brand_gap +
|
||||
static_cast<int>(lines[kBrandLine].bounds.right);
|
||||
const int brand_height = (std::max)(logo_size,
|
||||
static_cast<int>(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<HICON>(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<const wchar_t*, 4> keys{L"Ctrl", L"Alt", L"Shift", L"F12"};
|
||||
std::array<int, keys.size()> key_widths{};
|
||||
const int key_gap = px(28);
|
||||
const int key_height = (std::max)(px(44),
|
||||
static_cast<int>(lines[1].bounds.bottom) + 2 * px(10));
|
||||
int row_width = key_gap * static_cast<int>(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<int>(extent.cx) + 2 * px(14));
|
||||
row_width += key_widths[i];
|
||||
}
|
||||
const int content_height = static_cast<int>(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<int>(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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user