feat(island): 参考 WinIsland 重写灵动岛(squircle + 三种底 + 字号系数排版 + 空闲细缝)
参考: 工作目录 WinIsland-master.zip(Rust/Direct2D 的 Windows 动态岛),
逐项提取的设计 token 归档在 docs/winisland-design-tokens.md。
视觉与造型:
- 连续圆角 squircle(新增 island/squircle.ts,超椭圆角,对齐 WinIsland utils/shape.rs),
胶囊半径 = min(圆角, h/2)(满圆角),展开卡 = min(48*圆角/20, w/2, h/2)(WinIsland expanded_island_radius)
- 三种底: 纯黑(WinIsland default)/ 毛玻璃(win.setBackgroundMaterial('acrylic'),等价 HostBackdropBrush)/
主题色渐变;描边 1px 白 alpha .118(纯黑)/.157(玻璃),展开投影 0 2px 3px rgba(0,0,0,.11)
- Apple 系统色: 强调蓝 #0a84ff、警告橙 #ff9f0a、危险红 #ff453a;白字 alpha 分级 .92/.72/.58
- 状态色只做"内层微染 + 描边 + 徽标",不外溢(保持外圈偏红 = 0)
排版(本次重点):
- 基础字号 11–20px,岛内**所有文本** = 基础字号 × 排版系数
(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),
与 WinIsland 的 font_size 模型一致;实测 13→19px 全部文本倍率 1.462(=19/13)
- 图标、按钮高度、间距也由字号推导,改字号不会溢出
形态与设置:
- 尺寸基准对齐 WinIsland 比例(胶囊 216x34 基准、展开 +156/+186 等),卡片 = 窗口 - 2*5px 且水平居中
- 6 个停靠位(顶/底 × 左/中/右,对齐 WinIsland DockPosition)
- 新增"空闲细缝"(WinIsland hidden_width):空闲留一条 6px 宽竖条,来消息自动展开
- 所有设置依旧"改即生效";顺带修掉 hide() 用 skipWindow 绕过 syncWindow 导致细缝不生效的问题
验证(verify:desktop 49 → 53 项,全绿):
- 字号即时生效: 13px→19px 五项文本倍率全部 1.462
- 风格即时生效: 纯黑 fill=rgb(0,0,0)/材质 none;毛玻璃 fill=rgba(10,10,14,.59)/材质 acrylic;主题色 fill=rgb(11,11,15)
- 空闲细缝: 16x36 → 来消息 268x44;关闭后完全隐藏
- 6 锚点、开合不震动(0.00px / 无过冲)、截图 6 张(比例偏差 0.000、外圈偏红 0)全部保持
- verify:e2e 133/133、verify:web 19/19 不受影响;截图归档到 docs/screenshots/island/
工程发现: Windows 对透明窗口有约 36px 最小高度(请求 32px 实际 36px),
空闲细缝高度据此设下限并写入注释与 README。
This commit is contained in:
1 parent
9c9ac69ac6
commit
3f2b135211
17 files changed
+1632
-588
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 连续圆角(squircle / Apple 连续曲率圆角)路径生成。
|
||||
*
|
||||
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表
|
||||
* 构造 `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡的半径取
|
||||
* `expanded_island_radius = min(48 * scale, w/2, h/2)`。
|
||||
*
|
||||
* 这里用等价的超椭圆角公式生成 SVG path:
|
||||
* 角点区域按 x = r * (1 - cos^p(t)), y = r * (1 - sin^p(t)) 采样,
|
||||
* 其中 p = 2/n、n 取 4.2 时最接近 Apple 的"连续曲率"观感(普通圆角相当于 n = 2)。
|
||||
* 直边部分保持完全笔直,因此与 WinIsland 的"直边 + 连续圆角"一致。
|
||||
*
|
||||
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),所以圆角半径恒定、不被拉伸。
|
||||
*/
|
||||
|
||||
/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */
|
||||
const SUPERELLIPSE_EXPONENT = 4.2;
|
||||
|
||||
/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */
|
||||
const CORNER_SEGMENTS = 14;
|
||||
|
||||
function cornerPoints(radius: number): { x: number; y: number }[] {
|
||||
const p = 2 / SUPERELLIPSE_EXPONENT;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
|
||||
const t = (index / CORNER_SEGMENTS) * (Math.PI / 2);
|
||||
const cos = Math.max(0, Math.cos(t));
|
||||
const sin = Math.max(0, Math.sin(t));
|
||||
// t=0 → 角外侧(贴直边),t=π/2 → 角内侧
|
||||
points.push({
|
||||
x: radius * (1 - Math.pow(cos, p)),
|
||||
y: radius * (1 - Math.pow(sin, p)),
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
/**
|
||||
* 生成连续圆角矩形路径。
|
||||
*
|
||||
* @param width 卡片宽度(px)
|
||||
* @param height 卡片高度(px)
|
||||
* @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2))
|
||||
*/
|
||||
export function squirclePath(width: number, height: number, radius: number): string {
|
||||
const w = Math.max(1, Math.round(width * 100) / 100);
|
||||
const h = Math.max(1, Math.round(height * 100) / 100);
|
||||
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
|
||||
|
||||
if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`;
|
||||
|
||||
const corner = cornerPoints(r);
|
||||
const parts: string[] = [];
|
||||
|
||||
// 从左上角的"上方直边起点"开始,顺时针绘制
|
||||
parts.push(`M${round(r)},0`);
|
||||
// 上边 → 右上角
|
||||
parts.push(`H${round(w - r)}`);
|
||||
for (const point of corner) parts.push(`L${round(w - r + point.x)},${round(point.y)}`);
|
||||
// 右边 → 右下角
|
||||
parts.push(`V${round(h - r)}`);
|
||||
for (const point of corner) parts.push(`L${round(w - point.y)},${round(h - r + point.x)}`);
|
||||
// 下边 → 左下角
|
||||
parts.push(`H${round(r)}`);
|
||||
for (const point of corner) parts.push(`L${round(r - point.x)},${round(h - point.y)}`);
|
||||
// 左边 → 左上角
|
||||
parts.push(`V${round(r)}`);
|
||||
for (const point of corner) parts.push(`L${round(point.y)},${round(r - point.x)}`);
|
||||
parts.push('Z');
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** 展开卡半径(与 WinIsland 的 expanded_island_radius 同构:min(48*scale, w/2, h/2)) */
|
||||
export function expandedRadius(width: number, height: number, radius: number): number {
|
||||
return Math.max(0, Math.min(Math.max(radius, 24), width / 2, height / 2));
|
||||
}
|
||||
@@ -28,7 +28,15 @@ function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandApp
|
||||
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100));
|
||||
};
|
||||
const positions: IslandAppearance['position'][] = ['top-center', 'top-left', 'top-right', 'bottom-center'];
|
||||
const positions: IslandAppearance['position'][] = [
|
||||
'top-center',
|
||||
'top-left',
|
||||
'top-right',
|
||||
'bottom-center',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
];
|
||||
const styles: IslandAppearance['style'][] = ['black', 'glass', 'tinted'];
|
||||
return {
|
||||
height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height),
|
||||
width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width),
|
||||
@@ -38,13 +46,17 @@ function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandApp
|
||||
typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent)
|
||||
? input.accent
|
||||
: DEFAULT_ISLAND_APPEARANCE.accent,
|
||||
fontSize: clamp(input?.fontSize, 11, 18, DEFAULT_ISLAND_APPEARANCE.fontSize),
|
||||
fontSize: clamp(input?.fontSize, 11, 20, DEFAULT_ISLAND_APPEARANCE.fontSize),
|
||||
animations: input?.animations !== false,
|
||||
speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed),
|
||||
position: positions.includes(input?.position as IslandAppearance['position'])
|
||||
? (input?.position as IslandAppearance['position'])
|
||||
: DEFAULT_ISLAND_APPEARANCE.position,
|
||||
alwaysOnTop: input?.alwaysOnTop !== false,
|
||||
style: styles.includes(input?.style as IslandAppearance['style'])
|
||||
? (input?.style as IslandAppearance['style'])
|
||||
: DEFAULT_ISLAND_APPEARANCE.style,
|
||||
idleSliver: input?.idleSliver === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import path from 'node:path';
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_POSITIONS,
|
||||
ISLAND_SLIVER_WIDTH,
|
||||
ISLAND_STYLES,
|
||||
type IslandAppearance,
|
||||
type IslandMode,
|
||||
type IslandNotification,
|
||||
@@ -31,12 +34,18 @@ import { getConfig } from './config.js';
|
||||
*/
|
||||
|
||||
/** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */
|
||||
/**
|
||||
* 基准尺寸(对齐 WinIsland 的 `base_width/base_height` 与 `expanded_width/expanded_height` 比例):
|
||||
* - 胶囊:小而饱满(WinIsland compact 为 120×27,我们是文字型胶囊,取 216×34,圆角 = h/2 满圆角)
|
||||
* - 展开卡:展开增量与渲染进程 IslandApp.vue 的 EXTRA 常量一一对应
|
||||
* (expanded +156×+218 / urgent +172×+238 / call +188×+254)
|
||||
*/
|
||||
const BASE = {
|
||||
pill: { width: 268, height: 44 },
|
||||
expanded: { width: 404, height: 316 },
|
||||
urgent: { width: 424, height: 344 },
|
||||
pill: { width: 216, height: 34 },
|
||||
expanded: { width: 372, height: 220 },
|
||||
urgent: { width: 388, height: 236 },
|
||||
/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */
|
||||
call: { width: 440, height: 360 },
|
||||
call: { width: 404, height: 252 },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,6 +77,12 @@ let SIZES: Record<IslandMode, { width: number; height: number }> = { ...DEFAULT_
|
||||
let URGENT_SIZE = { ...BASE.urgent };
|
||||
let CALL_SIZE = { ...BASE.call };
|
||||
|
||||
/**
|
||||
* 空闲"细缝"尺寸(参考 WinIsland 的 hidden_width:空闲态是一条很窄的圆角柱)。
|
||||
* 高度取胶囊高度的一部分,宽度固定(`ISLAND_SLIVER_WIDTH`)。
|
||||
*/
|
||||
let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
|
||||
|
||||
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
||||
const TIMEOUTS = {
|
||||
pill: 15_000,
|
||||
@@ -112,6 +127,8 @@ class IslandController {
|
||||
private boundsFrame: number | null = null;
|
||||
/** 外观设置(设置页可调,主进程持久化) */
|
||||
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
|
||||
/** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */
|
||||
private backgroundMaterial: string = 'none';
|
||||
|
||||
private state: IslandState = {
|
||||
mode: 'hidden',
|
||||
@@ -175,6 +192,9 @@ class IslandController {
|
||||
|
||||
win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
// 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质)
|
||||
this.backgroundMaterial = 'none';
|
||||
this.applyBackgroundMaterial();
|
||||
win.setIgnoreMouseEvents(false);
|
||||
win.on('closed', () => {
|
||||
this.win = null;
|
||||
@@ -392,6 +412,12 @@ class IslandController {
|
||||
hide(): void {
|
||||
this.clearTimers();
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
// 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏,
|
||||
// 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。
|
||||
if (this.appearance.idleSliver) {
|
||||
this.setState({ mode: 'hidden', reason: null });
|
||||
return;
|
||||
}
|
||||
this.fadeOut();
|
||||
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
||||
}
|
||||
@@ -497,26 +523,18 @@ class IslandController {
|
||||
const margin = ISLAND_MARGIN;
|
||||
const center = Math.round(area.x + area.width / 2);
|
||||
|
||||
switch (this.appearance.position) {
|
||||
case 'top-left':
|
||||
return { hMode: 'left', hValue: area.x + margin, vMode: 'top', vValue: area.y + margin };
|
||||
case 'top-right':
|
||||
return {
|
||||
hMode: 'right',
|
||||
hValue: area.x + area.width - margin,
|
||||
vMode: 'top',
|
||||
vValue: area.y + margin,
|
||||
};
|
||||
case 'bottom-center':
|
||||
return {
|
||||
hMode: 'center',
|
||||
hValue: center,
|
||||
vMode: 'bottom',
|
||||
vValue: area.y + area.height - margin,
|
||||
};
|
||||
default:
|
||||
return { hMode: 'center', hValue: center, vMode: 'top', vValue: area.y + margin };
|
||||
}
|
||||
// 6 个停靠位置(顶/底 × 左/中/右),与 WinIsland 的 DockPosition 一一对应
|
||||
const position = this.appearance.position;
|
||||
const vMode: IslandAnchor['vMode'] = position.startsWith('bottom') ? 'bottom' : 'top';
|
||||
const vValue = vMode === 'bottom' ? area.y + area.height - margin : area.y + margin;
|
||||
const hMode: IslandAnchor['hMode'] = position.endsWith('left')
|
||||
? 'left'
|
||||
: position.endsWith('right')
|
||||
? 'right'
|
||||
: 'center';
|
||||
const hValue =
|
||||
hMode === 'left' ? area.x + margin : hMode === 'right' ? area.x + area.width - margin : center;
|
||||
return { hMode, hValue, vMode, vValue };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -587,6 +605,15 @@ class IslandController {
|
||||
this.applyFocusable();
|
||||
|
||||
if (this.state.mode === 'hidden') {
|
||||
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
|
||||
// 关闭时保持原行为(完全淡出并隐藏)。
|
||||
if (this.appearance.idleSliver) {
|
||||
this.cancelFade();
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
if (!this.win.isVisible()) this.win.showInactive();
|
||||
this.animateBounds(SLIVER_SIZE);
|
||||
return;
|
||||
}
|
||||
this.fadeOut();
|
||||
return;
|
||||
}
|
||||
@@ -761,11 +788,13 @@ class IslandController {
|
||||
* 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。
|
||||
*/
|
||||
setAppearance(patch: Partial<IslandAppearance>): void {
|
||||
const before = this.appearance;
|
||||
this.appearance = normalizeAppearance({ ...this.appearance, ...patch });
|
||||
recomputeSizes(this.appearance);
|
||||
|
||||
if (this.win && !this.win.isDestroyed()) {
|
||||
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
|
||||
this.applyBackgroundMaterial();
|
||||
if (this.win.isVisible()) {
|
||||
// 显示中:立即应用透明度(隐藏时由 fadeIn 负责)
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
@@ -774,13 +803,42 @@ class IslandController {
|
||||
this.emitAppearance();
|
||||
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
|
||||
if (this.state.mode !== 'hidden') this.syncWindow();
|
||||
// 位置变化:即使当前不可见也先把窗口挪到新锚点,避免下次出现时"从旧位置飞过来"
|
||||
if (this.state.mode === 'hidden' && before.position !== this.appearance.position) {
|
||||
this.syncWindow();
|
||||
}
|
||||
logger.info(
|
||||
`灵动岛外观已更新:${this.appearance.width}x${this.appearance.height} 圆角=${this.appearance.radius} ` +
|
||||
`透明度=${this.appearance.opacity} 位置=${this.appearance.position} 动画=${this.appearance.animations} ` +
|
||||
`速度=${this.appearance.speed}`,
|
||||
`透明度=${this.appearance.opacity} 位置=${this.appearance.position} 风格=${this.appearance.style} ` +
|
||||
`字号=${this.appearance.fontSize} 动画=${this.appearance.animations} 速度=${this.appearance.speed} ` +
|
||||
`空闲细缝=${this.appearance.idleSliver}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用窗口背景材质(对应 WinIsland 的 `DWMWA_USE_HOSTBACKDROPBRUSH` / HostBackdropBrush):
|
||||
* - `glass` 风格:Windows 11 的 acrylic 亚克力(真正的桌面模糊)
|
||||
* - 其他风格:`none`(纯色由渲染进程绘制,避免多余的模糊开销)
|
||||
* 平台不支持时静默降级为半透明纯色。
|
||||
*/
|
||||
private applyBackgroundMaterial(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const material = this.appearance.style === 'glass' ? 'acrylic' : 'none';
|
||||
if (this.backgroundMaterial === material) return;
|
||||
try {
|
||||
this.win.setBackgroundMaterial(material);
|
||||
this.backgroundMaterial = material;
|
||||
} catch (error) {
|
||||
logger.warn(`设置窗口背景材质失败(降级为纯色):${(error as Error).message}`);
|
||||
this.backgroundMaterial = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前窗口背景材质(供冒烟验证与设置页展示) */
|
||||
getBackgroundMaterial(): string {
|
||||
return this.backgroundMaterial;
|
||||
}
|
||||
|
||||
getAppearance(): IslandAppearance {
|
||||
return { ...this.appearance };
|
||||
}
|
||||
@@ -807,6 +865,22 @@ export function recomputeSizes(appearance: IslandAppearance): void {
|
||||
};
|
||||
URGENT_SIZE = grow(BASE.urgent);
|
||||
CALL_SIZE = grow(BASE.call);
|
||||
// 空闲细缝:卡片宽固定 6px(WinIsland hidden_width 的思路),窗口 = 卡片 + 内边距。
|
||||
// 注意:Windows 对(透明无边框)窗口有约 36px 的**最小高度**限制——
|
||||
// 实测请求 32px 时实际得到 36px,因此这里直接以 36px 为下限,避免断言/预览与真实不一致。
|
||||
const sliverCardHeight = Math.max(16, Math.min(28, appearance.height - 22));
|
||||
SLIVER_SIZE = {
|
||||
width: Math.min(ISLAND_SLIVER_WIDTH + CARD_PAD_X * 2, appearance.width),
|
||||
height: Math.max(MIN_WINDOW_HEIGHT, sliverCardHeight + CARD_PAD_Y * 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 空闲细缝的**窗口**尺寸(`getBounds()` 返回值;卡片尺寸 = 再减去内边距)。
|
||||
* 供冒烟验证与设置页预览使用。
|
||||
*/
|
||||
export function getSliverSize(): { width: number; height: number } {
|
||||
return { ...SLIVER_SIZE };
|
||||
}
|
||||
|
||||
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
||||
@@ -819,13 +893,13 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||
radius: clamp(input.radius, 8, 32),
|
||||
opacity: clamp(input.opacity, 0.4, 1),
|
||||
accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent,
|
||||
fontSize: clamp(input.fontSize, 11, 18),
|
||||
fontSize: clamp(input.fontSize, 11, 20),
|
||||
animations: input.animations !== false,
|
||||
speed: clamp(input.speed, 0.5, 2),
|
||||
position: ['top-center', 'top-left', 'top-right', 'bottom-center'].includes(input.position)
|
||||
? input.position
|
||||
: 'top-center',
|
||||
position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center',
|
||||
alwaysOnTop: input.alwaysOnTop !== false,
|
||||
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
|
||||
idleSliver: input.idleSliver === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -835,11 +909,19 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||
* 现在所有形变统一走单调的 easeOutCubic(见 animateBounds),不再有任何回弹。
|
||||
*/
|
||||
|
||||
/** 卡片相对窗口的内边距(必须与渲染进程 CSS 的 4px / 3px 一致) */
|
||||
const CARD_PAD_X = 4;
|
||||
const CARD_PAD_Y = 3;
|
||||
/**
|
||||
* 卡片相对窗口的内边距(必须与渲染进程 IslandApp.vue 的 CARD_PAD_X/CARD_PAD_Y 一致)。
|
||||
* 取 5px 是为了给展开态的投影(WinIsland: y+2、σ=3)留出不被窗口裁掉的余量。
|
||||
*/
|
||||
const CARD_PAD_X = 5;
|
||||
const CARD_PAD_Y = 5;
|
||||
/** 灵动岛与屏幕工作区边缘的留白 */
|
||||
const ISLAND_MARGIN = 8;
|
||||
/**
|
||||
* Windows 对窗口最小高度的实际限制(实测:请求 32px 会得到 36px)。
|
||||
* 空闲细缝等"极小形态"必须以此作为高度下限,否则断言与真实窗口会不一致。
|
||||
*/
|
||||
const MIN_WINDOW_HEIGHT = 36;
|
||||
|
||||
interface Bounds {
|
||||
x: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { BrowserWindow, NativeImage } from 'electron';
|
||||
import type { IslandAppearance, IslandNotification } from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { getDiagnostics } from './ipc.js';
|
||||
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||
import { getSliverSize, island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
|
||||
|
||||
interface SmokeResult {
|
||||
@@ -381,7 +381,15 @@ async function runIslandChecks(
|
||||
await sleep(700);
|
||||
clearInterval(shrinkSampler);
|
||||
await sleep(120);
|
||||
const allowedSizes = new Set(['396x308', '416x336', '432x352', '260x38']);
|
||||
// 允许的卡片尺寸由当前外观与各形态窗口尺寸推导(卡片 = 窗口 - 2*5px 内边距)
|
||||
const cardLabel = (size: { width: number; height: number }): string =>
|
||||
`${size.width - 10}x${size.height - 10}`;
|
||||
const allowedSizes = new Set([
|
||||
cardLabel(ISLAND_SIZES.pill),
|
||||
cardLabel(ISLAND_SIZES.expanded),
|
||||
cardLabel(ISLAND_URGENT_SIZE),
|
||||
cardLabel(ISLAND_CALL_SIZE),
|
||||
]);
|
||||
const stretched = [...new Set(shrinkSamples)].filter((size) => !allowedSizes.has(size));
|
||||
record(
|
||||
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)',
|
||||
@@ -626,7 +634,7 @@ async function runIslandChecks(
|
||||
const cardTops = jitterSamples.map((item) => item.cardTop);
|
||||
const topSpread = cardTops.length > 0 ? Math.max(...cardTops) - Math.min(...cardTops) : 99;
|
||||
const cardWidths = [...new Set(jitterSamples.map((item) => item.cardWidth))];
|
||||
const expectedWidths = [ISLAND_SIZES.expanded.width - 8, ISLAND_SIZES.pill.width - 8];
|
||||
const expectedWidths = [ISLAND_SIZES.expanded.width - 10, ISLAND_SIZES.pill.width - 10];
|
||||
const widthOk = cardWidths.every((width) => expectedWidths.includes(width));
|
||||
record(
|
||||
'收回过程不抖动(窗口宽度单调收缩 + 卡片布局尺寸不拉伸)',
|
||||
@@ -704,8 +712,8 @@ async function runIslandChecks(
|
||||
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
|
||||
await sleep(350);
|
||||
|
||||
const expandedCard = { width: ISLAND_SIZES.expanded.width - 8, height: ISLAND_SIZES.expanded.height - 6 };
|
||||
const pillCard = { width: ISLAND_SIZES.pill.width - 8, height: ISLAND_SIZES.pill.height - 6 };
|
||||
const expandedCard = { width: ISLAND_SIZES.expanded.width - 10, height: ISLAND_SIZES.expanded.height - 10 };
|
||||
const pillCard = { width: ISLAND_SIZES.pill.width - 10, height: ISLAND_SIZES.pill.height - 10 };
|
||||
|
||||
await runMotionProbe('expand');
|
||||
const expandSamples = [...motionSamples];
|
||||
@@ -771,6 +779,168 @@ async function runIslandChecks(
|
||||
|
||||
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
|
||||
await sleep(250);
|
||||
/** 排空灵动岛队列:连续收起直到没有活动通知与排队通知(否则"空闲态"断言会被下一条顶掉) */
|
||||
const drainIsland = async (): Promise<void> => {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const current = island.getState();
|
||||
if (!current.active && current.queued.length === 0) return;
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(120);
|
||||
}
|
||||
};
|
||||
|
||||
/** 灵动岛状态摘要(失败时用于定位是"没排空队列"还是"几何没生效") */
|
||||
const islandStateSummary = (): string => {
|
||||
const current = island.getState();
|
||||
return `mode=${current.mode} active=${current.active?.id ?? 'null'} queued=${current.queued.length} visible=${
|
||||
islandWindow?.isVisible() ?? '-'
|
||||
}`;
|
||||
};
|
||||
|
||||
/** 等待灵动岛渲染出指定选择器(最多约 2s) */
|
||||
const waitForIslandDom = async (selector: string, timeoutMs = 2000): Promise<boolean> => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const found = await islandWindow?.webContents
|
||||
.executeJavaScript(`Boolean(document.querySelector(${JSON.stringify(selector)}))`)
|
||||
.catch(() => false);
|
||||
if (found) return true;
|
||||
await sleep(60);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 10.6) 字号:所有文本都由基础字号推导,改设置必须"立刻看到"(WinIsland 的 font_size 模型)
|
||||
const readFontSizes = async (): Promise<Record<string, number>> =>
|
||||
(await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const pick = (selector) => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return 0;
|
||||
return Math.round(parseFloat(getComputedStyle(node).fontSize) * 100) / 100;
|
||||
};
|
||||
return {
|
||||
'pill-title': pick('.pill-title'),
|
||||
'pill-sub': pick('.pill-sub'),
|
||||
title: pick('.title'),
|
||||
content: pick('.content-text'),
|
||||
ghost: pick('.ghost-btn'),
|
||||
};
|
||||
})()`,
|
||||
)
|
||||
.catch(() => ({}))) ?? {};
|
||||
|
||||
const probeFontScale = async (fontSize: number): Promise<Record<string, number>> => {
|
||||
island.setAppearance({ ...appearanceBefore, fontSize });
|
||||
await drainIsland();
|
||||
island.pushNotification(makeNotification('smoke-font', 'NORMAL', '字号实时生效校验'), {
|
||||
inClass: false,
|
||||
});
|
||||
await waitForIslandDom('.island-card.pill .pill-title');
|
||||
await sleep(120);
|
||||
const pillSizes = await readFontSizes();
|
||||
island.handleAction({ action: 'expand' });
|
||||
await waitForIslandDom('.island-card.expanded .title');
|
||||
await sleep(160);
|
||||
const expandedSizes = await readFontSizes();
|
||||
// 两次读取各有一半元素不存在(返回 0),合并时只采纳 >0 的有效值
|
||||
const merged: Record<string, number> = { ...pillSizes };
|
||||
for (const [key, value] of Object.entries(expandedSizes)) {
|
||||
if (value > 0) merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
await drainIsland();
|
||||
const fontSmall = await probeFontScale(13);
|
||||
const fontLarge = await probeFontScale(19);
|
||||
const fontKeys = Object.keys(fontSmall).filter((key) => (fontSmall[key] ?? 0) > 0);
|
||||
const expectedRatio = 19 / 13;
|
||||
const fontRatios = fontKeys.map((key) => ({
|
||||
key,
|
||||
ratio: (fontLarge[key] ?? 0) / (fontSmall[key] ?? 1),
|
||||
}));
|
||||
const fontUniform = fontRatios.every((item) => Math.abs(item.ratio - expectedRatio) <= 0.03);
|
||||
record(
|
||||
'字号设置立刻生效(岛内所有文本按基础字号等比缩放)',
|
||||
fontKeys.length >= 5 && fontUniform,
|
||||
`13px→[${fontKeys.map((key) => `${key}=${fontSmall[key]}`).join(' ')}] ` +
|
||||
`19px→[${fontKeys.map((key) => `${key}=${fontLarge[key]}`).join(' ')}] ` +
|
||||
`期望倍率=${expectedRatio.toFixed(3)} 实测=[${fontRatios.map((item) => `${item.key}:${item.ratio.toFixed(3)}`).join(' ')}] ` +
|
||||
`${islandStateSummary()}`,
|
||||
);
|
||||
|
||||
// 10.7) 视觉风格:纯黑 / 毛玻璃(亚克力)/ 主题色渐变,切换立即生效
|
||||
const probeStyle = async (
|
||||
style: IslandAppearance['style'],
|
||||
): Promise<{ fill: string; stroke: string; material: string }> => {
|
||||
island.setAppearance({ ...appearanceBefore, style });
|
||||
await sleep(260);
|
||||
const measured = await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const path = document.querySelector('.island-card .shape path');
|
||||
if (!path) return null;
|
||||
const style = getComputedStyle(path);
|
||||
return { fill: style.fill, stroke: style.stroke };
|
||||
})()`,
|
||||
)
|
||||
.catch(() => null);
|
||||
return {
|
||||
fill: String(measured?.fill ?? '-'),
|
||||
stroke: String(measured?.stroke ?? '-'),
|
||||
material: island.getBackgroundMaterial(),
|
||||
};
|
||||
};
|
||||
|
||||
const blackStyle = await probeStyle('black');
|
||||
const glassStyle = await probeStyle('glass');
|
||||
const tintedStyle = await probeStyle('tinted');
|
||||
record(
|
||||
'视觉风格切换立刻生效(纯黑 / 毛玻璃亚克力 / 主题色)',
|
||||
blackStyle.fill === 'rgb(0, 0, 0)' &&
|
||||
blackStyle.material === 'none' &&
|
||||
glassStyle.fill.startsWith('rgba(10, 10, 14') &&
|
||||
glassStyle.material === 'acrylic' &&
|
||||
tintedStyle.fill === 'rgb(11, 11, 15)',
|
||||
`纯黑 fill=${blackStyle.fill} 材质=${blackStyle.material};` +
|
||||
`毛玻璃 fill=${glassStyle.fill} 材质=${glassStyle.material};` +
|
||||
`主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.material}`,
|
||||
);
|
||||
island.setAppearance({ ...appearanceBefore, style: 'black' });
|
||||
await sleep(200);
|
||||
|
||||
// 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开
|
||||
const sliver = getSliverSize();
|
||||
island.setAppearance({ ...appearanceBefore, idleSliver: true });
|
||||
await drainIsland();
|
||||
await sleep(420);
|
||||
const sliverBounds = islandWindow?.getBounds();
|
||||
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
|
||||
await sleep(500);
|
||||
const afterSliverNotification = islandWindow?.getBounds();
|
||||
record(
|
||||
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
|
||||
sliverBounds?.width === sliver.width &&
|
||||
sliverBounds?.height === sliver.height &&
|
||||
afterSliverNotification?.width === island.getAppearance().width,
|
||||
`细缝=${sliverBounds?.width ?? '-'}x${sliverBounds?.height ?? '-'}(期望 ${sliver.width}x${sliver.height})` +
|
||||
` 来消息后=${afterSliverNotification?.width ?? '-'}x${afterSliverNotification?.height ?? '-'} ${islandStateSummary()}`,
|
||||
);
|
||||
|
||||
island.setAppearance({ ...appearanceBefore, idleSliver: false });
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(450);
|
||||
record(
|
||||
'关闭空闲细缝后空闲再次完全隐藏(保持原行为)',
|
||||
islandWindow?.isVisible() === false,
|
||||
`isVisible=${islandWindow?.isVisible() ?? '-'}`,
|
||||
);
|
||||
island.pushNotification(makeNotification('smoke-after-sliver', 'NORMAL', '细缝关闭后恢复'), {
|
||||
inClass: false,
|
||||
});
|
||||
await sleep(320);
|
||||
// 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
|
||||
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
|
||||
const workArea = screen.getPrimaryDisplay().workArea;
|
||||
@@ -830,7 +1000,7 @@ async function runIslandChecks(
|
||||
`${item.position}=${bounds ? `${bounds.x},${bounds.y}` : '-'}${matched ? '✓' : '✗'}`,
|
||||
);
|
||||
}
|
||||
record('个性设置:停靠位置生效(左上/右上/底部居中/顶部居中)', positionOk, positionDetails.join(' '));
|
||||
record('个性设置:停靠位置生效(6 个锚点)', positionOk, positionDetails.join(' '));
|
||||
island.setAppearance(appearanceBefore);
|
||||
|
||||
// 11.4 置顶开关 → 窗口 alwaysOnTop
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
ISLAND_STYLE_LABELS,
|
||||
ISLAND_STYLES,
|
||||
ISLAND_POSITIONS,
|
||||
formatDate,
|
||||
type IslandAppearance,
|
||||
type IslandPosition,
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
@@ -120,6 +123,11 @@ const positionOptions = ISLAND_POSITIONS.map((value) => ({
|
||||
label: ISLAND_POSITION_LABELS[value],
|
||||
}));
|
||||
|
||||
const styleOptions = ISLAND_STYLES.map((value) => ({
|
||||
value,
|
||||
label: ISLAND_STYLE_LABELS[value],
|
||||
}));
|
||||
|
||||
/** 读取当前生效的外观(主进程为单一事实来源) */
|
||||
async function loadIslandAppearance(): Promise<void> {
|
||||
if (!window.desktop?.islandGetAppearance) return;
|
||||
@@ -279,10 +287,25 @@ onMounted(async () => {
|
||||
<el-form-item label="主题色">
|
||||
<el-color-picker
|
||||
:model-value="island.accent"
|
||||
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#6cc4ff' })"
|
||||
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
|
||||
/>
|
||||
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="视觉风格">
|
||||
<el-select
|
||||
:model-value="island.style"
|
||||
style="width: 180px"
|
||||
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in styleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted ml-8">纯黑 / 毛玻璃(亚克力)/ 主题色渐变</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示位置">
|
||||
<el-select
|
||||
:model-value="island.position"
|
||||
@@ -314,6 +337,15 @@ onMounted(async () => {
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="空闲细缝">
|
||||
<el-switch
|
||||
:model-value="island.idleSliver"
|
||||
@change="
|
||||
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
|
||||
"
|
||||
/>
|
||||
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
|
||||
|
||||
@@ -72,12 +72,17 @@ export const DEFAULT_ISLAND_APPEARANCE = {
|
||||
width: 268,
|
||||
radius: 20,
|
||||
opacity: 1,
|
||||
accent: '#6cc4ff',
|
||||
/** 默认强调色采用 Apple 系统蓝(与 WinIsland 的系统色体系一致) */
|
||||
accent: '#0a84ff',
|
||||
fontSize: 13,
|
||||
animations: true,
|
||||
speed: 1,
|
||||
position: 'top-center',
|
||||
alwaysOnTop: true,
|
||||
/** 纯黑底(WinIsland default 风格) */
|
||||
style: 'black',
|
||||
/** 空闲时完全隐藏(与原行为一致;打开后空闲会留一条细缝) */
|
||||
idleSliver: false,
|
||||
} as const;
|
||||
|
||||
/** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */
|
||||
@@ -86,19 +91,59 @@ export const ISLAND_APPEARANCE_RANGES = {
|
||||
width: { min: 220, max: 420 },
|
||||
radius: { min: 8, max: 32 },
|
||||
opacity: { min: 0.4, max: 1 },
|
||||
fontSize: { min: 11, max: 18 },
|
||||
fontSize: { min: 11, max: 20 },
|
||||
speed: { min: 0.5, max: 2 },
|
||||
} as const;
|
||||
|
||||
/** 停靠位置可选值 */
|
||||
export const ISLAND_POSITIONS = ['top-center', 'top-left', 'top-right', 'bottom-center'] as const;
|
||||
/** 停靠位置可选值(与 WinIsland 的 DockPosition 一致:顶/底 × 左/中/右) */
|
||||
export const ISLAND_POSITIONS = [
|
||||
'top-center',
|
||||
'top-left',
|
||||
'top-right',
|
||||
'bottom-center',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
] as const;
|
||||
|
||||
export const ISLAND_POSITION_LABELS: Record<(typeof ISLAND_POSITIONS)[number], string> = {
|
||||
'top-center': '顶部居中',
|
||||
'top-left': '左上角',
|
||||
'top-right': '右上角',
|
||||
'bottom-center': '底部居中',
|
||||
'bottom-left': '左下角',
|
||||
'bottom-right': '右下角',
|
||||
};
|
||||
|
||||
/** 视觉风格可选值(参考 WinIsland 的 island_style) */
|
||||
export const ISLAND_STYLES = ['black', 'glass', 'tinted'] as const;
|
||||
|
||||
export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string> = {
|
||||
black: '纯黑(灵动岛默认)',
|
||||
glass: '毛玻璃(半透明模糊)',
|
||||
tinted: '主题色渐变',
|
||||
};
|
||||
|
||||
/**
|
||||
* 空闲细缝尺寸(参考 WinIsland hidden_width = 5:空闲态是一条很窄的圆角柱)。
|
||||
* 宽度固定 6px,高度取胶囊高度的一部分,视觉上就是一条"小黑条"。
|
||||
*/
|
||||
export const ISLAND_SLIVER_WIDTH = 6;
|
||||
|
||||
/** 岛内文本的排版系数(相对基础字号,集中在这里方便统一调整) */
|
||||
export const ISLAND_TYPE_SCALE = {
|
||||
/** 展开卡标题 */
|
||||
title: 1.08,
|
||||
/** 正文 */
|
||||
body: 0.95,
|
||||
/** 次要说明 / 元信息 */
|
||||
meta: 0.78,
|
||||
/** 徽标 / 按钮 */
|
||||
badge: 0.76,
|
||||
/** 胶囊主标题 */
|
||||
pillTitle: 0.92,
|
||||
/** 胶囊副标题 */
|
||||
pillSub: 0.74,
|
||||
} as const;
|
||||
/** 默认学期周次上限 */
|
||||
export const DEFAULT_WEEK_COUNT = 20;
|
||||
|
||||
|
||||
@@ -415,26 +415,45 @@ export interface IslandAppearance {
|
||||
height: number;
|
||||
/** 胶囊宽度(px,220~420) */
|
||||
width: number;
|
||||
/** 卡片圆角(px,8~32) */
|
||||
/** 卡片圆角(px,8~32):采用连续圆角(squircle),此值为四角的基准半径 */
|
||||
radius: number;
|
||||
/** 整体不透明度(0.4~1) */
|
||||
opacity: number;
|
||||
/** 主题色(强调色,用于高亮与按钮) */
|
||||
accent: string;
|
||||
/** 基础字号(px,11~18) */
|
||||
/**
|
||||
* 基础字号(px,11~20)——参考 WinIsland 的 font_size:
|
||||
* 岛内**所有文本**都由它乘以各自的排版系数得出,因此改字号会整体等比缩放。
|
||||
*/
|
||||
fontSize: number;
|
||||
/** 是否启用动画(关闭后展开/收起为瞬时) */
|
||||
animations: boolean;
|
||||
/** 动画速度倍率(0.5 慢 ~ 2 快) */
|
||||
speed: number;
|
||||
/** 显示位置 */
|
||||
/** 显示位置(顶/底 × 左/中/右,与 WinIsland 的 DockPosition 对齐) */
|
||||
position: IslandPosition;
|
||||
/** 是否始终置顶 */
|
||||
alwaysOnTop: boolean;
|
||||
/**
|
||||
* 视觉风格(参考 WinIsland 的 island_style):
|
||||
* - `black`:纯黑底(默认,WinIsland `default` 风格)
|
||||
* - `glass`:半透明 + 背景模糊(WinIsland `glass` 风格)
|
||||
* - `tinted`:强调色渐变(本产品特色,保持与原主题色一致)
|
||||
*/
|
||||
style: IslandStyle;
|
||||
/**
|
||||
* 空闲时是否保留一条"细缝"(参考 WinIsland 的 hidden_width:空闲态是一条很窄的圆角柱)。
|
||||
* 关闭时(默认)空闲即完全隐藏,与原行为一致。
|
||||
*/
|
||||
idleSliver: boolean;
|
||||
}
|
||||
|
||||
/** 灵动岛停靠位置 */
|
||||
export type IslandPosition = 'top-center' | 'top-left' | 'top-right' | 'bottom-center';
|
||||
/** 灵动岛停靠位置(6 个锚点,与 WinIsland 一致) */
|
||||
export type IslandPosition =
|
||||
'top-center' | 'top-left' | 'top-right' | 'bottom-center' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
/** 灵动岛视觉风格 */
|
||||
export type IslandStyle = 'black' | 'glass' | 'tinted';
|
||||
|
||||
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
|
||||
export interface IslandState {
|
||||
|
||||
Reference in new issue
Block a user