feat(island): 彻底照搬 WinIsland 架构重写灵动岛(固定包围盒窗口 + 窗口内弹簧形变)
背景:上一版仍沿用"每帧移动/缩放透明窗口"的做法,实机上会出现形变抖动与残影
(用户反馈"越改越烂、开合震动还在")。本次按 WinIsland 的真实做法重做架构。
主进程(packages/desktop-client/src/main/island.ts):
- 窗口一次创建为**固定包围盒**(最大形态 + ISLAND_SHADOW_PAD 阴影留白),
开合过程中不再调用 setBounds;只在"尺寸/位置设置变化"时重排一次窗口
- 新增 setInteractive():默认 setIgnoreMouseEvents(true,{forward:true}) 整块穿透,
由渲染层命中测试回调切换(照搬 WinIsland set_cursor_hittest),保证不吞桌面点击
- 移除逐帧几何代码(animateBounds/boundsFor/computeBounds/resolveAnchor 用于窗口定位的简化版保留)
- 空闲细缝不再受 Windows 36px 最小窗口高度限制(窗口固定,岛可真正缩到 6px 宽)
渲染层(IslandApp.vue + 新增 spring.ts):
- 岛是**单一元素**,宽/高/圆角由 WinIsland 弹簧逐帧逼近:
force=(target-value)*0.10*dt → velocity=(velocity+force)*0.68^dt → value+=velocity*dt
(dt 以帧计;换向保留 35% 动量、单帧位移 ≤ 剩余距离 20%),并加零过冲钳制
- 胶囊层与展开层常驻并交叉淡入(展开 progress²、胶囊 1-1.5×progress),形变全程无缩放回弹
- 形状层改为显式像素宽高 + preserveAspectRatio="none"(避免 viewBox 缩放导致形状错位/残留角)
- 修两个实机可见缺陷:① 展开层漏了 flex-direction: column(展开卡内容横排、元素散落);
② 隐藏且无细缝时不再把岛缩成细缝(否则下次弹出会"从细缝长大")
- 尺寸增量 ISLAND_SIZE_DELTA / 阴影留白移入 @classhelper/shared,主/渲染两端共用一份
验证(verify:desktop 53 → 54 项,全绿):
- 新增「开合过程中窗口全程恒定」(逐帧窗口签名恒为 476x282@482,-2,岛 386x230→…→268x44 单调收缩,
SVG 形状 bbox 与卡片框一致 ±2px)
- 新增「岛外鼠标穿透」(岛内 interactive=true / 岛外 false)
- 开合不震动改为按"岛"判定:窗口冻结 + 岛锚点 0.00px + 无过冲;截图按岛矩形裁剪并统计岛外红像素=0
- 其余能力(通知/作业/叫人/紧急、标为已读、上课隐藏、6 锚点、字号可调、设置立即生效、空闲细缝)全部保留
This commit is contained in:
1 parent
93447922dc
commit
7a4668cb1b
8 files changed
+954
-410
No files matched your search
@@ -3,6 +3,8 @@ import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||
import {
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_POSITIONS,
|
||||
ISLAND_SHADOW_PAD,
|
||||
ISLAND_SIZE_DELTA,
|
||||
ISLAND_SLIVER_WIDTH,
|
||||
ISLAND_STYLES,
|
||||
type IslandAppearance,
|
||||
@@ -35,37 +37,23 @@ 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)
|
||||
* 形态尺寸(**岛本身的尺寸**,不是窗口尺寸):
|
||||
* - 胶囊 = 用户设置的 width/height(WinIsland compact 是 120×27,我们是文字型胶囊,默认 268×44)
|
||||
* - 展开/紧急/叫人 = 胶囊 + `ISLAND_SIZE_DELTA`(与渲染进程共用同一常量表)
|
||||
*
|
||||
* 窗口尺寸 = 最大形态 + `ISLAND_SHADOW_PAD`,**一次算好、开合过程中不变**(照搬 WinIsland:
|
||||
* 它的窗口也是一次创建成最大包围盒,形变全部发生在窗口内的画布上)。
|
||||
*/
|
||||
const BASE = {
|
||||
pill: { width: 216, height: 34 },
|
||||
expanded: { width: 372, height: 220 },
|
||||
urgent: { width: 388, height: 236 },
|
||||
expanded: { width: 216 + ISLAND_SIZE_DELTA.expanded.width, height: 34 + ISLAND_SIZE_DELTA.expanded.height },
|
||||
urgent: { width: 216 + ISLAND_SIZE_DELTA.urgent.width, height: 34 + ISLAND_SIZE_DELTA.urgent.height },
|
||||
/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */
|
||||
call: { width: 404, height: 252 },
|
||||
call: { width: 216 + ISLAND_SIZE_DELTA.call.width, height: 34 + ISLAND_SIZE_DELTA.call.height },
|
||||
};
|
||||
|
||||
/**
|
||||
* 帧调度:优先 requestAnimationFrame(有则最平滑),主进程没有该全局时退化为 ~60fps 定时器。
|
||||
* 关键点不是用哪个 API,而是**始终只有一条动画循环**:每次新动画都会取消上一条并作废令牌,
|
||||
* 结束时再对齐一次最终尺寸,避免累积误差导致边缘"抖一下"。
|
||||
*/
|
||||
type FrameHandle = number;
|
||||
declare const requestAnimationFrame: ((callback: (time: number) => void) => number) | undefined;
|
||||
declare const cancelAnimationFrame: ((handle: number) => void) | undefined;
|
||||
|
||||
function scheduleFrame(callback: () => void): FrameHandle {
|
||||
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(() => callback());
|
||||
return setTimeout(callback, 16) as unknown as FrameHandle;
|
||||
}
|
||||
|
||||
function cancelFrame(handle: FrameHandle): void {
|
||||
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);
|
||||
else clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
|
||||
}
|
||||
// 说明:几何动画已全部移交渲染进程(渲染层用 WinIsland 的弹簧逐帧逼近岛的宽高/圆角),
|
||||
// 主进程不再做窗口帧动画,因此原 scheduleFrame / cancelFrame / requestAnimationFrame 声明已移除。
|
||||
|
||||
const DEFAULT_SIZES: Record<IslandMode, { width: number; height: number }> = {
|
||||
hidden: BASE.pill,
|
||||
@@ -83,6 +71,12 @@ let CALL_SIZE = { ...BASE.call };
|
||||
*/
|
||||
let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
|
||||
|
||||
/**
|
||||
* 固定包围盒窗口的尺寸:取所有形态里最大的一个,再加上阴影留白。
|
||||
* **只在设置变化时重算一次**,开合过程中窗口尺寸恒定(照搬 WinIsland 的"一次建大窗口")。
|
||||
*/
|
||||
let BBOX_SIZE = { width: 0, height: 0 };
|
||||
|
||||
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
||||
const TIMEOUTS = {
|
||||
pill: 15_000,
|
||||
@@ -129,6 +123,8 @@ class IslandController {
|
||||
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
|
||||
/** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */
|
||||
private backgroundMaterial: string = 'none';
|
||||
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
|
||||
private interactive = false;
|
||||
|
||||
private state: IslandState = {
|
||||
mode: 'hidden',
|
||||
@@ -166,7 +162,8 @@ class IslandController {
|
||||
if (this.win && !this.win.isDestroyed()) return;
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...this.computeBounds(SIZES.hidden),
|
||||
// 固定包围盒(最大形态 + 阴影留白):开合过程中**窗口不再移动/缩放**
|
||||
...this.windowBounds(),
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
@@ -195,7 +192,9 @@ class IslandController {
|
||||
// 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质)
|
||||
this.backgroundMaterial = 'none';
|
||||
this.applyBackgroundMaterial();
|
||||
win.setIgnoreMouseEvents(false);
|
||||
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
|
||||
// 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。
|
||||
win.setIgnoreMouseEvents(true, { forward: true });
|
||||
win.on('closed', () => {
|
||||
this.win = null;
|
||||
this.ready = false;
|
||||
@@ -545,39 +544,6 @@ class IslandController {
|
||||
return { hMode, hValue, vMode, vValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* 由锚点 + 目标尺寸推出窗口矩形。
|
||||
*
|
||||
* 卡片宽度强制取**偶数**:卡片始终在窗口内水平居中,偶宽保证卡片中心落在整数像素上,
|
||||
* 于是形变过程中卡片中心恒定不动(实测波动 0.00px),不会出现"左右轻微振动"。
|
||||
*/
|
||||
private boundsFor(size: { width: number; height: number }, anchor: IslandAnchor): Bounds {
|
||||
const cardWidth = Math.max(2, evenWidth(Math.round(size.width) - CARD_PAD_X * 2));
|
||||
const cardHeight = Math.max(2, Math.round(size.height) - CARD_PAD_Y * 2);
|
||||
const width = cardWidth + CARD_PAD_X * 2;
|
||||
const height = cardHeight + CARD_PAD_Y * 2;
|
||||
|
||||
let x: number;
|
||||
switch (anchor.hMode) {
|
||||
case 'left':
|
||||
x = anchor.hValue;
|
||||
break;
|
||||
case 'right':
|
||||
x = anchor.hValue - width;
|
||||
break;
|
||||
default:
|
||||
x = anchor.hValue - cardWidth / 2 - CARD_PAD_X;
|
||||
break;
|
||||
}
|
||||
|
||||
const y = anchor.vMode === 'bottom' ? anchor.vValue - height : anchor.vValue;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
private computeBounds(size: { width: number; height: number }): Bounds {
|
||||
return this.boundsFor(size, this.resolveAnchor());
|
||||
}
|
||||
|
||||
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
|
||||
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
|
||||
this.state = { ...this.state, ...patch, updatedAt: Date.now() };
|
||||
@@ -598,6 +564,12 @@ class IslandController {
|
||||
this.win.webContents.send('island:state', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵动岛窗口是**固定包围盒**(照搬 WinIsland):开合过程中只改渲染层里岛的宽高/圆角,
|
||||
* 窗口本身**一帧都不动**,因此不会有"逐帧移动+缩放透明窗口"带来的抖动与残影。
|
||||
*
|
||||
* 这里只负责三件事:可见性、焦点、透明度;几何交给渲染进程的弹簧形变。
|
||||
*/
|
||||
private syncWindow(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
|
||||
@@ -617,47 +589,68 @@ class IslandController {
|
||||
// 关闭时保持原行为(完全淡出并隐藏)。
|
||||
if (this.appearance.idleSliver) {
|
||||
this.cancelFade();
|
||||
this.setInteractive(false);
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
if (!this.win.isVisible()) this.win.showInactive();
|
||||
this.animateBounds(SLIVER_SIZE);
|
||||
return;
|
||||
}
|
||||
this.fadeOut();
|
||||
return;
|
||||
}
|
||||
|
||||
const size =
|
||||
this.state.mode === 'expanded'
|
||||
? this.state.active
|
||||
? this.isImmediate(this.state.active)
|
||||
? this.state.active.kind === 'call'
|
||||
? CALL_SIZE
|
||||
: URGENT_SIZE
|
||||
: SIZES.expanded
|
||||
: SIZES.expanded
|
||||
: SIZES.pill;
|
||||
|
||||
this.setInteractive(true);
|
||||
if (!this.win.isVisible()) {
|
||||
const pillBounds = this.computeBounds(SIZES.pill);
|
||||
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画
|
||||
if (size === SIZES.pill) {
|
||||
this.cancelFade();
|
||||
// 必须使用个性化透明度:这里以前硬编码 1,导致"隐藏后再弹出胶囊"会忽略透明度设置
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.setBounds(pillBounds);
|
||||
this.win.showInactive();
|
||||
return;
|
||||
}
|
||||
// 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见
|
||||
// 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度
|
||||
this.cancelFade();
|
||||
this.win.setOpacity(0);
|
||||
this.win.setBounds(pillBounds);
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.showInactive();
|
||||
this.animateBounds(size, { spring: true });
|
||||
this.fadeIn();
|
||||
return;
|
||||
}
|
||||
this.animateBounds(size, { spring: size !== SIZES.pill });
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前外观把窗口放到锚点上(**只在尺寸/位置设置变化时调用**,开合时不调用)。
|
||||
* 窗口尺寸 = 最大形态 + 2×阴影留白,因此任何形态的岛都能完整容纳。
|
||||
*/
|
||||
private applyWindowLayout(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const next = this.windowBounds();
|
||||
const current = this.win.getBounds();
|
||||
if (
|
||||
current.x === next.x &&
|
||||
current.y === next.y &&
|
||||
current.width === next.width &&
|
||||
current.height === next.height
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.win.setBounds(next);
|
||||
}
|
||||
|
||||
/** 固定包围盒窗口的矩形:以锚点定位,尺寸恒为"最大形态 + 阴影留白" */
|
||||
private windowBounds(): Bounds {
|
||||
const width = Math.round(BBOX_SIZE.width);
|
||||
const height = Math.round(BBOX_SIZE.height);
|
||||
const anchor = this.resolveAnchor();
|
||||
|
||||
// 窗口比岛大 ISLAND_SHADOW_PAD(给投影留白),因此窗口要再偏移一个 pad,
|
||||
// 保证**岛**的边/中心精确落在锚点上(而不是让窗口落在锚点上)。
|
||||
const pad = ISLAND_SHADOW_PAD;
|
||||
let x: number;
|
||||
switch (anchor.hMode) {
|
||||
case 'left':
|
||||
x = anchor.hValue - pad;
|
||||
break;
|
||||
case 'right':
|
||||
x = anchor.hValue + pad - width;
|
||||
break;
|
||||
default:
|
||||
x = anchor.hValue - Math.round(width / 2);
|
||||
break;
|
||||
}
|
||||
const y = anchor.vMode === 'bottom' ? anchor.vValue + pad - height : anchor.vValue - pad;
|
||||
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
/** 作废进行中的淡入淡出动画 */
|
||||
@@ -717,86 +710,19 @@ class IslandController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果。
|
||||
*
|
||||
* 用**单一 requestAnimationFrame 循环**驱动(需求 4):
|
||||
* - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动
|
||||
* - 锚点固定:锚点在动画开始前算一次(整数),过程中只让"对侧边"移动
|
||||
* - 缓动**单调不过冲**:早期版本用 easeOutBack 弹簧缓动,窗口尺寸会超过目标再弹回来,
|
||||
* 观感就是"开合时震一下";现在统一用 easeOutCubic,尺寸/位置只朝目标单向变化
|
||||
* - 每帧只用整数像素、卡片宽度取偶数,卡片中心与不动的那条边全程恒定
|
||||
* - 关闭动画(appearance.animations = false)时直接落到目标尺寸
|
||||
* 几何动画已完全移交渲染进程(固定窗口内做弹簧形变),这里只保留一个"取消"入口,
|
||||
* 供 `hideImmediately()` 等在打断场景下调用(现在无需真正取消任何窗口动画)。
|
||||
*/
|
||||
private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const from = this.win.getBounds();
|
||||
const anchor = this.resolveAnchor();
|
||||
const to = this.boundsFor(size, anchor);
|
||||
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
|
||||
|
||||
this.cancelBoundsAnimation();
|
||||
const token = ++this.boundsToken;
|
||||
|
||||
if (!this.appearance.animations) {
|
||||
this.win.setBounds(to);
|
||||
return;
|
||||
}
|
||||
|
||||
// 形变时长:展开稍长一点(视觉上更"从容"),收回更快;倍速来自个性化设置
|
||||
const duration = Math.round(
|
||||
(options.spring === true ? 300 : 220) / Math.max(0.25, this.appearance.speed),
|
||||
);
|
||||
const fromCardWidth = Math.max(2, evenWidth(from.width - CARD_PAD_X * 2));
|
||||
const fromCardHeight = Math.max(2, from.height - CARD_PAD_Y * 2);
|
||||
const toCardWidth = Math.max(2, evenWidth(to.width - CARD_PAD_X * 2));
|
||||
const toCardHeight = Math.max(2, to.height - CARD_PAD_Y * 2);
|
||||
const startedAt = Date.now();
|
||||
|
||||
const frame = (): void => {
|
||||
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
|
||||
const progress = Math.min(1, (Date.now() - startedAt) / duration);
|
||||
// easeOutCubic:单调递减斜率,永不越过 1(因此不会有回弹)
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const cardWidth = Math.max(
|
||||
2,
|
||||
evenWidth(Math.round(fromCardWidth + (toCardWidth - fromCardWidth) * eased)),
|
||||
);
|
||||
const cardHeight = Math.max(2, Math.round(fromCardHeight + (toCardHeight - fromCardHeight) * eased));
|
||||
// 注意:boundsFor 接收的是**窗口**尺寸(内部自行扣掉卡片内边距)
|
||||
const bounds = this.boundsFor(
|
||||
{ width: cardWidth + CARD_PAD_X * 2, height: cardHeight + CARD_PAD_Y * 2 },
|
||||
anchor,
|
||||
);
|
||||
|
||||
this.win.setBounds(bounds);
|
||||
if (progress < 1) {
|
||||
this.boundsFrame = scheduleFrame(frame);
|
||||
return;
|
||||
}
|
||||
// 收尾对齐到最终尺寸,避免四舍五入留下误差(会让卡片边缘"抖一下")
|
||||
this.win.setBounds(to);
|
||||
this.boundsFrame = null;
|
||||
this.applyFocusable();
|
||||
};
|
||||
this.boundsFrame = scheduleFrame(frame);
|
||||
}
|
||||
|
||||
/** 取消进行中的尺寸动画(帧循环) */
|
||||
private cancelBoundsAnimation(): void {
|
||||
if (this.boundsFrame !== null) {
|
||||
cancelFrame(this.boundsFrame);
|
||||
this.boundsFrame = null;
|
||||
}
|
||||
this.boundsFrame = null;
|
||||
this.boundsToken += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。
|
||||
* 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。
|
||||
* 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。
|
||||
*/
|
||||
setAppearance(patch: Partial<IslandAppearance>): void {
|
||||
const before = this.appearance;
|
||||
this.appearance = normalizeAppearance({ ...this.appearance, ...patch });
|
||||
recomputeSizes(this.appearance);
|
||||
|
||||
@@ -809,12 +735,10 @@ class IslandController {
|
||||
}
|
||||
}
|
||||
this.emitAppearance();
|
||||
// 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口)
|
||||
this.applyWindowLayout();
|
||||
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
|
||||
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.style} ` +
|
||||
@@ -847,6 +771,31 @@ class IslandController {
|
||||
return this.backgroundMaterial;
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`):
|
||||
* 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。
|
||||
* 渲染进程做命中测试(指针是否在"岛"的可见区域/可交互控件内),再回调这里切换。
|
||||
*/
|
||||
setInteractive(interactive: boolean): void {
|
||||
if (this.interactive === interactive) return;
|
||||
this.interactive = interactive;
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
// forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程,
|
||||
// 这样渲染进程才能发现指针进入岛体并重新打开命中。
|
||||
this.win.setIgnoreMouseEvents(!interactive, { forward: true });
|
||||
}
|
||||
|
||||
/** 当前是否接收鼠标(供冒烟验证) */
|
||||
getInteractive(): boolean {
|
||||
return this.interactive;
|
||||
}
|
||||
|
||||
/** 固定包围盒窗口的矩形(供冒烟验证"窗口全程不变") */
|
||||
getWindowBounds(): Bounds {
|
||||
if (!this.win || this.win.isDestroyed()) return { x: 0, y: 0, width: 0, height: 0 };
|
||||
return this.win.getBounds();
|
||||
}
|
||||
|
||||
getAppearance(): IslandAppearance {
|
||||
return { ...this.appearance };
|
||||
}
|
||||
@@ -873,18 +822,24 @@ 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));
|
||||
// 空闲细缝:宽固定 6px(WinIsland hidden_width 的思路),高取胶囊高度的一部分。
|
||||
// 岛在固定窗口内形变,因此这里就是**岛的尺寸**,不受窗口最小高度限制。
|
||||
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),
|
||||
width: ISLAND_SLIVER_WIDTH,
|
||||
height: Math.max(12, Math.min(28, appearance.height - 22)),
|
||||
};
|
||||
|
||||
// 固定窗口包围盒 = 最大形态 + 2×阴影留白(岛在其中居中/贴边形变)
|
||||
const maxWidth = Math.max(SIZES.pill.width, SIZES.expanded.width, URGENT_SIZE.width, CALL_SIZE.width);
|
||||
const maxHeight = Math.max(SIZES.pill.height, SIZES.expanded.height, URGENT_SIZE.height, CALL_SIZE.height);
|
||||
BBOX_SIZE = {
|
||||
width: maxWidth + ISLAND_SHADOW_PAD * 2,
|
||||
height: maxHeight + ISLAND_SHADOW_PAD * 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 空闲细缝的**窗口**尺寸(`getBounds()` 返回值;卡片尺寸 = 再减去内边距)。
|
||||
* 空闲细缝的**岛**尺寸(渲染进程里 `.island-card` 的宽高;窗口是固定包围盒,不代表当前形态)。
|
||||
* 供冒烟验证与设置页预览使用。
|
||||
*/
|
||||
export function getSliverSize(): { width: number; height: number } {
|
||||
@@ -918,18 +873,16 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||
*/
|
||||
|
||||
/**
|
||||
* 卡片相对窗口的内边距(必须与渲染进程 IslandApp.vue 的 CARD_PAD_X/CARD_PAD_Y 一致)。
|
||||
* 取 5px 是为了给展开态的投影(WinIsland: y+2、σ=3)留出不被窗口裁掉的余量。
|
||||
* 岛在固定窗口内的留白(= 阴影余量,见 `ISLAND_SHADOW_PAD`)。
|
||||
* 窗口尺寸 = 最大形态 + 2×该留白,因此投影永远不会被窗口裁掉。
|
||||
*/
|
||||
const CARD_PAD_X = 5;
|
||||
const CARD_PAD_Y = 5;
|
||||
/** 灵动岛与屏幕工作区边缘的留白 */
|
||||
const ISLAND_MARGIN = 8;
|
||||
/**
|
||||
* Windows 对窗口最小高度的实际限制(实测:请求 32px 会得到 36px)。
|
||||
* 空闲细缝等"极小形态"必须以此作为高度下限,否则断言与真实窗口会不一致。
|
||||
* 说明:Windows 对窗口最小高度有约 36px 的限制,但**只影响窗口**。
|
||||
* 新版架构里窗口是固定包围盒、岛在窗口内形变,因此"空闲细缝"可以真正做到 6px 宽/十几像素高
|
||||
* (与 WinIsland 的 hidden_width=5 一致),不再受该限制。
|
||||
*/
|
||||
const MIN_WINDOW_HEIGHT = 36;
|
||||
/**
|
||||
* 失焦收起的宽限期:刚展开的 500ms 内忽略 blur(避免"刚弹出就被抢焦点导致瞬间收起")。
|
||||
* 冒烟里"点击屏幕任意位置收起"的用例会在展开后等待约 700ms 再触发 blur,仍能正常验证收起。
|
||||
@@ -951,12 +904,6 @@ interface IslandAnchor {
|
||||
vValue: number;
|
||||
}
|
||||
|
||||
/** 卡片宽度取偶数:保证水平居中的卡片中心落在整数像素上,形变时不左右跳变 */
|
||||
function evenWidth(value: number): number {
|
||||
const safe = Math.max(2, Math.round(value));
|
||||
return safe - (safe % 2);
|
||||
}
|
||||
|
||||
export const island = new IslandController();
|
||||
|
||||
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
|
||||
@@ -982,6 +929,11 @@ export function registerIslandIpc(): void {
|
||||
|
||||
ipcMain.handle('island:get-appearance', () => island.getAppearance());
|
||||
|
||||
// 渲染进程命中测试结果 → 切换窗口是否接收鼠标
|
||||
ipcMain.on('island:set-interactive', (_event, interactive: boolean) => {
|
||||
island.setInteractive(interactive === true);
|
||||
});
|
||||
|
||||
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
|
||||
if (!payload?.action) return;
|
||||
island.handleAction(payload);
|
||||
|
||||
@@ -21,6 +21,23 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
/** 灵动岛每种形态的截图像素统计(用于"截图像素级留档"断言) */
|
||||
/** 岛几何:固定包围盒窗口 + 岛的真实矩形 + 形状路径尺寸 */
|
||||
interface IslandGeometry {
|
||||
window: { x: number; y: number; width: number; height: number };
|
||||
island: IslandRect;
|
||||
}
|
||||
|
||||
/** 岛在屏幕上的矩形(固定包围盒窗口内形变 → 断言都量这个) */
|
||||
interface IslandRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
radius?: number;
|
||||
pathWidth?: number;
|
||||
pathHeight?: number;
|
||||
}
|
||||
|
||||
interface IslandShotStats {
|
||||
name: string;
|
||||
/** 期望的窗口逻辑尺寸(CSS 像素) */
|
||||
@@ -35,6 +52,8 @@ interface IslandShotStats {
|
||||
reddish: number;
|
||||
/** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */
|
||||
edgeReddish: number;
|
||||
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
|
||||
outsideReddish?: number;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
@@ -47,6 +66,8 @@ function analyzeBitmap(image: NativeImage): {
|
||||
uniqueColors: number;
|
||||
reddish: number;
|
||||
edgeReddish: number;
|
||||
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
|
||||
outsideReddish?: number;
|
||||
} {
|
||||
const bitmap = image.toBitmap();
|
||||
const { width, height } = image.getSize();
|
||||
@@ -98,30 +119,112 @@ async function captureIsland(
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.documentElement.style.background = '#1f2733'; document.body.style.background = '#1f2733'; true`,
|
||||
);
|
||||
await sleep(120);
|
||||
await sleep(160);
|
||||
// 等岛形变收敛:像素级断言与留档都必须在"稳定帧"上做,
|
||||
// 否则会拍到形变中间帧(岛比目标略大/小),"岛外留白"统计随之误报。
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const first = await readIslandGeometryFrom(win);
|
||||
await sleep(50);
|
||||
const second = await readIslandGeometryFrom(win);
|
||||
if (
|
||||
first &&
|
||||
second &&
|
||||
Math.abs(first.island.width - second.island.width) < 0.6 &&
|
||||
Math.abs(first.island.height - second.island.height) < 0.6
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 先取岛的矩形 → 连拍两帧(第一帧可能是形变中的旧帧)→ 再取一次几何。
|
||||
// capturePage 返回的是"最近一次合成的帧",形变很快时会与 DOM 状态差一帧,
|
||||
// 因此用前后两次几何的**并集**做"岛之外"的判定,避免把岛边缘误算成外溢。
|
||||
await win.webContents.capturePage();
|
||||
await sleep(60);
|
||||
const image = await win.webContents.capturePage();
|
||||
// 紧接截图之后读几何:让"矩形"与"画面"尽可能来自同一时刻
|
||||
const geometry = await readIslandGeometryFrom(win);
|
||||
const union = unionIslandRect(geometry, geometry);
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
|
||||
);
|
||||
|
||||
const cropped = geometry ? cropToIsland(image, geometry, win) : image;
|
||||
// 岛之外的窗口留白区不允许出现红色(紧急光晕不外溢)——抗锯齿边界向内缩 2px
|
||||
const outsideReddish = union ? countReddishOutsideIsland(image, union, win, 3) : 0;
|
||||
const filePath = dir ? path.join(dir, `${name}.png`) : '';
|
||||
if (filePath) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, image.toPNG());
|
||||
fs.writeFileSync(filePath, cropped.toPNG());
|
||||
// 同时留档"未裁剪的整窗"图,便于人工核对岛外留白
|
||||
if (geometry) fs.writeFileSync(path.join(dir, `${name}-window.png`), image.toPNG());
|
||||
}
|
||||
|
||||
const size = image.getSize();
|
||||
const size = cropped.getSize();
|
||||
const stats: IslandShotStats = {
|
||||
name,
|
||||
expected,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
filePath,
|
||||
...analyzeBitmap(image),
|
||||
...analyzeBitmap(cropped),
|
||||
outsideReddish,
|
||||
};
|
||||
islandShots.push(stats);
|
||||
const layout = await win.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
if (!card) return null;
|
||||
const dump = (selector) => {
|
||||
const node = card.querySelector(selector);
|
||||
if (!node) return null;
|
||||
const r = node.getBoundingClientRect();
|
||||
const s = getComputedStyle(node);
|
||||
return {
|
||||
selector,
|
||||
rect: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)],
|
||||
display: s.display,
|
||||
dir: s.flexDirection,
|
||||
justify: s.justifyContent,
|
||||
opacity: s.opacity,
|
||||
};
|
||||
};
|
||||
return {
|
||||
card: (() => {
|
||||
const r = card.getBoundingClientRect();
|
||||
return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)];
|
||||
})(),
|
||||
items: [
|
||||
dump('.content'),
|
||||
dump('.pill-layer'),
|
||||
dump('.expanded-layer'),
|
||||
dump('.expanded-layer .head'),
|
||||
dump('.expanded-layer .title'),
|
||||
dump('.expanded-layer .body'),
|
||||
dump('.expanded-layer .foot'),
|
||||
].filter(Boolean),
|
||||
};
|
||||
})()`,
|
||||
)
|
||||
.catch(() => null);
|
||||
if (layout) {
|
||||
console.log(
|
||||
`[SMOKE] 布局诊断 ${name}:卡片=${layout.card.join(',')} ` +
|
||||
layout.items
|
||||
.map(
|
||||
(item: { selector: string; rect: number[]; display: string; dir: string; opacity: string }) =>
|
||||
`${item.selector}[${item.rect.join(',')} ${item.display}/${item.dir} op=${item.opacity}]`,
|
||||
)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
const redBox = reddishBBox(image);
|
||||
console.log(
|
||||
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` +
|
||||
`颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})`,
|
||||
`颜色=${stats.uniqueColors} 偏红=${stats.reddish} 外圈偏红=${stats.edgeReddish})` +
|
||||
` 诊断[红像素框=${redBox.x},${redBox.y} ${redBox.width}x${redBox.height} ` +
|
||||
`岛=${geometry ? `${geometry.island.x - geometry.window.x},${geometry.island.y - geometry.window.y} ${geometry.island.width}x${geometry.island.height}` : '-'} ` +
|
||||
`窗口=${geometry ? `${geometry.window.width}x${geometry.window.height}` : '-'}]`,
|
||||
);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
@@ -130,6 +233,127 @@ async function captureIsland(
|
||||
}
|
||||
}
|
||||
|
||||
/** 按岛的矩形裁剪截图(含比例换算:capturePage 返回物理像素) */
|
||||
function cropToIsland(
|
||||
image: Electron.NativeImage,
|
||||
geometry: IslandGeometry,
|
||||
win: BrowserWindow,
|
||||
): Electron.NativeImage {
|
||||
const scale = win.webContents.getZoomFactor() || 1;
|
||||
const ratio = image.getSize().width / Math.max(1, geometry.window.width);
|
||||
void scale;
|
||||
const x = Math.max(0, Math.round(geometry.island.x - geometry.window.x) * (ratio / 1));
|
||||
const y = Math.max(0, Math.round(geometry.island.y - geometry.window.y) * ratio);
|
||||
const width = Math.min(image.getSize().width - x, Math.round(geometry.island.width * ratio));
|
||||
const height = Math.min(image.getSize().height - y, Math.round(geometry.island.height * ratio));
|
||||
if (width <= 0 || height <= 0) return image;
|
||||
return image.crop({ x, y, width, height });
|
||||
}
|
||||
|
||||
/** 偏红像素的包围盒(诊断形状错位:与几何读数对比即可定位偏差来源) */
|
||||
function reddishBBox(image: Electron.NativeImage): { x: number; y: number; width: number; height: number } {
|
||||
const bitmap = image.toBitmap();
|
||||
const size = image.getSize();
|
||||
let left = size.width;
|
||||
let top = size.height;
|
||||
let right = -1;
|
||||
let bottom = -1;
|
||||
for (let y = 0; y < size.height; y += 1) {
|
||||
for (let x = 0; x < size.width; x += 1) {
|
||||
const index = (y * size.width + x) * 4;
|
||||
const blue = bitmap[index] ?? 0;
|
||||
const green = bitmap[index + 1] ?? 0;
|
||||
const red = bitmap[index + 2] ?? 0;
|
||||
const alpha = bitmap[index + 3] ?? 0;
|
||||
if (alpha > 40 && red > green + 12 && red > blue + 12) {
|
||||
if (x < left) left = x;
|
||||
if (y < top) top = y;
|
||||
if (x > right) right = x;
|
||||
if (y > bottom) bottom = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return right < 0
|
||||
? { x: 0, y: 0, width: 0, height: 0 }
|
||||
: { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
||||
}
|
||||
|
||||
/** 两次几何读数的并集(形变过程中宁可多算一点,也不会把岛边缘误判成"岛外") */
|
||||
function unionIslandRect(a: IslandGeometry | null, b: IslandGeometry | null): IslandGeometry | null {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
const left = Math.min(a.island.x, b.island.x);
|
||||
const top = Math.min(a.island.y, b.island.y);
|
||||
const right = Math.max(a.island.x + a.island.width, b.island.x + b.island.width);
|
||||
const bottom = Math.max(a.island.y + a.island.height, b.island.y + b.island.height);
|
||||
return {
|
||||
window: b.window,
|
||||
island: { x: left, y: top, width: right - left, height: bottom - top },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计"岛矩形之外"的偏红像素(窗口留白区)。
|
||||
* 新架构下窗口是固定包围盒,岛只占其中一部分:光晕一旦外溢就会落在这些留白里。
|
||||
*/
|
||||
function countReddishOutsideIsland(
|
||||
image: Electron.NativeImage,
|
||||
geometry: IslandGeometry,
|
||||
win: BrowserWindow,
|
||||
margin: number,
|
||||
): number {
|
||||
const bitmap = image.toBitmap();
|
||||
const size = image.getSize();
|
||||
const ratio = size.width / Math.max(1, geometry.window.width);
|
||||
void win;
|
||||
// margin 是**向外**的容差:岛的描边(1px 居中)与抗锯齿边都算"岛内",
|
||||
// 只有明显落在岛之外(> margin)的红色才算外溢。此前把它当"向内缩",
|
||||
// 结果把岛自身的红色描边统计成了"岛外偏红"。
|
||||
const left = (geometry.island.x - geometry.window.x - margin) * ratio;
|
||||
const top = (geometry.island.y - geometry.window.y - margin) * ratio;
|
||||
const right = (geometry.island.x - geometry.window.x + geometry.island.width + margin) * ratio;
|
||||
const bottom = (geometry.island.y - geometry.window.y + geometry.island.height + margin) * ratio;
|
||||
|
||||
let count = 0;
|
||||
for (let y = 0; y < size.height; y += 1) {
|
||||
for (let x = 0; x < size.width; x += 1) {
|
||||
if (x >= left && x <= right && y >= top && y <= bottom) continue;
|
||||
const index = (y * size.width + x) * 4;
|
||||
const blue = bitmap[index] ?? 0;
|
||||
const green = bitmap[index + 1] ?? 0;
|
||||
const red = bitmap[index + 2] ?? 0;
|
||||
const alpha = bitmap[index + 3] ?? 0;
|
||||
if (alpha > 40 && red > green + 12 && red > blue + 12) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** 读取岛的几何(截图用;与 runIslandChecks 内的同名逻辑保持一致的语义) */
|
||||
async function readIslandGeometryFrom(win: BrowserWindow): Promise<IslandGeometry | null> {
|
||||
const bounds = island.getWindowBounds();
|
||||
const rect = await win.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
if (!card) return null;
|
||||
const r = card.getBoundingClientRect();
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
||||
})()`,
|
||||
)
|
||||
.catch(() => null);
|
||||
if (!rect) return null;
|
||||
return {
|
||||
window: bounds,
|
||||
island: {
|
||||
x: bounds.x + rect.left,
|
||||
y: bounds.y + rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 打印灵动岛渲染进程的真实 DOM 结构(排查空白窗口) */
|
||||
async function dumpIslandDom(_label: string): Promise<string> {
|
||||
const win = island.getWindow();
|
||||
@@ -212,6 +436,48 @@ async function runIslandChecks(
|
||||
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* 读取"岛"的几何:窗口矩形(固定包围盒)+ 岛在屏幕上的真实矩形(渲染层里的 .island-card)。
|
||||
* 新架构下**窗口恒定、岛在窗口内形变**,因此所有尺寸类断言都必须量 `island` 而不是窗口。
|
||||
*/
|
||||
const readIslandGeometry = async (): Promise<IslandGeometry | null> => {
|
||||
const win = island.getWindowBounds();
|
||||
const rect = await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
if (!card) return null;
|
||||
const r = card.getBoundingClientRect();
|
||||
const raw = getComputedStyle(card).getPropertyValue('--card-r').trim();
|
||||
const path = card.querySelector('.shape path');
|
||||
const box = path && typeof path.getBBox === 'function' ? path.getBBox() : null;
|
||||
return {
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
radius: parseFloat(raw) || 0,
|
||||
pathWidth: box ? box.width : 0,
|
||||
pathHeight: box ? box.height : 0,
|
||||
};
|
||||
})()`,
|
||||
)
|
||||
.catch(() => null);
|
||||
if (!rect) return null;
|
||||
return {
|
||||
window: win,
|
||||
island: {
|
||||
x: win.x + rect.left,
|
||||
y: win.y + rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
radius: rect.radius,
|
||||
pathWidth: rect.pathWidth,
|
||||
pathHeight: rect.pathHeight,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// 2) 下课:自动弹出暂存通知的详情
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(900);
|
||||
@@ -228,8 +494,12 @@ async function runIslandChecks(
|
||||
await sleep(300);
|
||||
const sampledWidths: number[] = [];
|
||||
const sampler = setInterval(() => {
|
||||
const bounds = islandWindow?.getBounds();
|
||||
if (bounds) sampledWidths.push(bounds.width);
|
||||
// 新架构:量"岛"在屏幕上的真实宽度(窗口是固定包围盒,形变发生在窗口内)
|
||||
void readIslandGeometry()
|
||||
.then((geometry) => {
|
||||
if (geometry) sampledWidths.push(Math.round(geometry.island.width));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 16);
|
||||
island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), {
|
||||
inClass: true,
|
||||
@@ -245,13 +515,14 @@ async function runIslandChecks(
|
||||
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
|
||||
);
|
||||
|
||||
const urgentBounds = islandWindow?.getBounds();
|
||||
const urgentGeometry = await readIslandGeometry();
|
||||
const urgentBounds = urgentGeometry?.island ?? null;
|
||||
const rampWidths = [
|
||||
...new Set(sampledWidths.filter((width) => width > 0 && width < ISLAND_URGENT_SIZE.width)),
|
||||
];
|
||||
record(
|
||||
'紧急通知带展开动画(窗口从胶囊尺寸缓动到紧急尺寸)',
|
||||
rampWidths.length >= 3 && urgentBounds?.width === ISLAND_URGENT_SIZE.width,
|
||||
'紧急通知带展开动画(岛从胶囊尺寸弹簧展开到紧急尺寸)',
|
||||
rampWidths.length >= 3 && Math.abs((urgentBounds?.width ?? 0) - ISLAND_URGENT_SIZE.width) <= 2,
|
||||
`采样=${sampledWidths.length} 中间尺寸=${rampWidths.length} 最终=${urgentBounds?.width ?? '-'}x${urgentBounds?.height ?? '-'}`,
|
||||
);
|
||||
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
|
||||
@@ -275,16 +546,17 @@ async function runIslandChecks(
|
||||
inClass: false,
|
||||
});
|
||||
await sleep(60);
|
||||
const immediateBounds = islandWindow?.getBounds();
|
||||
const immediateGeometry = await readIslandGeometry();
|
||||
const initialIsland = immediateGeometry?.island ?? null;
|
||||
await sleep(640);
|
||||
const pillState = island.getState();
|
||||
record(
|
||||
'普通通知直接显示胶囊(无"上岛"入场动画)',
|
||||
hiddenBeforePush &&
|
||||
pillState.mode === 'pill' &&
|
||||
immediateBounds?.width === ISLAND_SIZES.pill.width &&
|
||||
immediateBounds?.height === ISLAND_SIZES.pill.height,
|
||||
`推送前隐藏=${hiddenBeforePush} 60ms 内窗口=${immediateBounds?.width ?? '-'}x${immediateBounds?.height ?? '-'}` +
|
||||
Math.abs((initialIsland?.width ?? 0) - ISLAND_SIZES.pill.width) <= 2 &&
|
||||
Math.abs((initialIsland?.height ?? 0) - ISLAND_SIZES.pill.height) <= 2,
|
||||
`推送前隐藏=${hiddenBeforePush} 60ms 内岛=${initialIsland?.width?.toFixed(1) ?? '-'}x${initialIsland?.height?.toFixed(1) ?? '-'}` +
|
||||
`(期望 ${ISLAND_SIZES.pill.width}x${ISLAND_SIZES.pill.height})mode=${pillState.mode}`,
|
||||
);
|
||||
await captureIsland('island-4-pill', ISLAND_SIZES.pill);
|
||||
@@ -357,23 +629,28 @@ async function runIslandChecks(
|
||||
`chevronClicked=${chevronClicked} mode=${afterChevron.mode}`,
|
||||
);
|
||||
|
||||
// 4.4) 收回过程中卡片不能出现"被拉扁的方框":
|
||||
// 采样卡片尺寸,必须始终是固定的胶囊/详情尺寸之一,绝不出现中间值。
|
||||
// 4.4) 新架构(照搬 WinIsland):窗口是固定包围盒,开合过程中**窗口一帧都不动**,
|
||||
// 岛在窗口内做弹簧形变。这里逐帧采样"窗口 + 岛":窗口必须完全恒定,岛形变必须连续。
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(700);
|
||||
const shrinkSamples: string[] = [];
|
||||
const shrinkSamples: {
|
||||
window: string;
|
||||
island: string;
|
||||
radius: number;
|
||||
pathWidth: number;
|
||||
pathHeight: number;
|
||||
}[] = [];
|
||||
const shrinkSampler = setInterval(() => {
|
||||
void islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const node = document.querySelector('.island-card');
|
||||
if (!node) return '';
|
||||
const rect = node.getBoundingClientRect();
|
||||
return Math.round(rect.width) + 'x' + Math.round(rect.height);
|
||||
})()`,
|
||||
)
|
||||
.then((value: string) => {
|
||||
if (value) shrinkSamples.push(value);
|
||||
void readIslandGeometry()
|
||||
.then((geometry) => {
|
||||
if (!geometry) return;
|
||||
shrinkSamples.push({
|
||||
window: `${geometry.window.width}x${geometry.window.height}@${geometry.window.x},${geometry.window.y}`,
|
||||
island: `${Math.round(geometry.island.width)}x${Math.round(geometry.island.height)}`,
|
||||
radius: Number(geometry.island.radius ?? 0),
|
||||
pathWidth: Number(geometry.island.pathWidth ?? 0),
|
||||
pathHeight: Number(geometry.island.pathHeight ?? 0),
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 40);
|
||||
@@ -381,20 +658,24 @@ async function runIslandChecks(
|
||||
await sleep(700);
|
||||
clearInterval(shrinkSampler);
|
||||
await sleep(120);
|
||||
// 允许的卡片尺寸由当前外观与各形态窗口尺寸推导(卡片 = 窗口 - 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));
|
||||
const windowSignatures = [...new Set(shrinkSamples.map((item) => item.window))];
|
||||
const islandSizes = shrinkSamples.map((item) => item.island);
|
||||
const islandWidths = shrinkSamples.map((item) => Number(item.island.split('x')[0]));
|
||||
const islandMonotonic = islandWidths.every(
|
||||
(width, index) => index === 0 || width <= (islandWidths[index - 1] ?? width) + 1,
|
||||
);
|
||||
// 画出来的形状必须与卡片框一致:SVG 路径 bbox ≈ 卡片宽高(±2px)。
|
||||
// 这能一次性抓住"viewBox/preserveAspectRatio 错位导致形状被缩放或残留角"这类问题。
|
||||
const pathFits = shrinkSamples.every(
|
||||
(item) =>
|
||||
Math.abs(item.pathWidth - Number(item.island.split('x')[0])) <= 2 &&
|
||||
Math.abs(item.pathHeight - Number(item.island.split('x')[1])) <= 2,
|
||||
);
|
||||
record(
|
||||
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)',
|
||||
shrinkSamples.length >= 3 && stretched.length === 0,
|
||||
`采样=${shrinkSamples.length} 尺寸集合=[${[...new Set(shrinkSamples)].join(',')}] 异常尺寸=[${stretched.join(',')}]`,
|
||||
'开合过程中窗口全程恒定、岛在窗口内连续形变(照搬 WinIsland 架构)',
|
||||
shrinkSamples.length >= 3 && windowSignatures.length === 1 && islandMonotonic && pathFits,
|
||||
`采样=${shrinkSamples.length} 窗口签名=[${windowSignatures.join(' ')}](要求只有 1 种) ` +
|
||||
`岛尺寸序列=[${islandSizes.slice(0, 8).join('→')}${islandSizes.length > 8 ? '…' : ''}] 单调收缩=${islandMonotonic} 形状与卡片框一致=${pathFits}`,
|
||||
);
|
||||
|
||||
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
|
||||
@@ -508,7 +789,7 @@ async function runIslandChecks(
|
||||
text:
|
||||
`${shot.name} ${shot.width}x${shot.height}(期望 ${shot.expected.width}x${shot.expected.height}, ` +
|
||||
`比例偏差 ${ratioDelta.toFixed(3)}) 不透明=${shot.opaque} 颜色=${shot.uniqueColors} ` +
|
||||
`偏红=${shot.reddish} 外圈偏红=${shot.edgeReddish}`,
|
||||
`偏红=${shot.reddish} 岛外偏红=${shot.outsideReddish ?? 0}`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -516,7 +797,7 @@ async function runIslandChecks(
|
||||
const wrongRatio = shotDetails.filter((shot) => shot.ratioDelta > 0.05);
|
||||
const urgentShot = shotDetails.find((shot) => shot.name.includes('urgent'));
|
||||
const urgentReddish = urgentShot?.reddish ?? 0;
|
||||
const edgeReddish = urgentShot?.edgeReddish ?? 0;
|
||||
const outsideReddish = urgentShot?.outsideReddish ?? 0;
|
||||
const pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
|
||||
const sizesOrdered = Boolean(
|
||||
pillShot &&
|
||||
@@ -538,11 +819,11 @@ async function runIslandChecks(
|
||||
` 形态尺寸递增=${sizesOrdered}`,
|
||||
);
|
||||
|
||||
// 7) 光晕只在卡片内部:截图最外圈(窗口留白)不允许出现红色外溢像素
|
||||
// 7) 光晕只在岛内部:固定的窗口包围盒里,岛之外的留白区不允许出现红色外溢像素
|
||||
record(
|
||||
'紧急红晕不外溢(窗口边缘无红色光晕像素)',
|
||||
urgentShot !== undefined && edgeReddish === 0 && urgentReddish > 200,
|
||||
`紧急态偏红=${urgentReddish} 最外圈偏红=${edgeReddish}(要求 0)`,
|
||||
'紧急光晕不外溢(岛之外的窗口留白区无红色像素)',
|
||||
urgentShot !== undefined && outsideReddish === 0 && urgentReddish > 200,
|
||||
`紧急态岛内偏红=${urgentReddish} 岛外留白偏红=${outsideReddish}(要求 0)`,
|
||||
);
|
||||
|
||||
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
|
||||
@@ -554,7 +835,7 @@ async function runIslandChecks(
|
||||
inClass: false,
|
||||
});
|
||||
await sleep(600);
|
||||
const pillBounds = islandWindow?.getBounds();
|
||||
const pillBounds = (await readIslandGeometry())?.island ?? null;
|
||||
const cssVars = await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
@@ -610,16 +891,14 @@ async function runIslandChecks(
|
||||
await sleep(700);
|
||||
const jitterSamples: { win: number; cardWidth: number; cardTop: number }[] = [];
|
||||
const jitterSampler = setInterval(() => {
|
||||
const winWidth = islandWindow?.getBounds().width ?? 0;
|
||||
void islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
return card ? { width: card.offsetWidth, top: card.offsetTop } : null;
|
||||
})()`,
|
||||
)
|
||||
.then((value: { width: number; top: number } | null) => {
|
||||
if (value) jitterSamples.push({ win: winWidth, cardWidth: value.width, cardTop: value.top });
|
||||
void readIslandGeometry()
|
||||
.then((geometry) => {
|
||||
if (!geometry) return;
|
||||
jitterSamples.push({
|
||||
win: geometry.window.width,
|
||||
cardWidth: Math.round(geometry.island.width),
|
||||
cardTop: Math.round(geometry.island.y),
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 30);
|
||||
@@ -628,19 +907,18 @@ async function runIslandChecks(
|
||||
clearInterval(jitterSampler);
|
||||
|
||||
const winSequence = jitterSamples.map((item) => item.win);
|
||||
const monotonicShrink = winSequence.every(
|
||||
(width, index) => index === 0 || width <= (winSequence[index - 1] ?? width) + 1,
|
||||
const windowConstant = new Set(winSequence).size <= 1;
|
||||
const islandSequence = jitterSamples.map((item) => item.cardWidth);
|
||||
const monotonicShrink = islandSequence.every(
|
||||
(width, index) => index === 0 || width <= (islandSequence[index - 1] ?? width) + 1,
|
||||
);
|
||||
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 - 10, ISLAND_SIZES.pill.width - 10];
|
||||
const widthOk = cardWidths.every((width) => expectedWidths.includes(width));
|
||||
record(
|
||||
'收回过程不抖动(窗口宽度单调收缩 + 卡片布局尺寸不拉伸)',
|
||||
jitterSamples.length >= 4 && monotonicShrink && topSpread <= 1 && widthOk && cardWidths.length <= 2,
|
||||
`采样=${jitterSamples.length} 窗口序列=[${winSequence.join(',')}] 单调收缩=${monotonicShrink} ` +
|
||||
`卡片上边缘波动=${topSpread}px 卡片宽度集合=[${cardWidths.join(',')}](期望 ${expectedWidths.join('/')})`,
|
||||
'收回过程不抖动(窗口恒定 + 岛宽度单调收缩 + 岛顶部不动)',
|
||||
jitterSamples.length >= 4 && windowConstant && monotonicShrink && topSpread <= 1,
|
||||
`采样=${jitterSamples.length} 窗口宽度集合=[${[...new Set(winSequence)].join(',')}](要求 1 个) ` +
|
||||
`岛宽度序列=[${islandSequence.join(',')}] 单调收缩=${monotonicShrink} 岛上边缘波动=${topSpread}px`,
|
||||
);
|
||||
// 10) 连续快速开合:最终状态与尺寸必须稳定
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
@@ -651,14 +929,14 @@ async function runIslandChecks(
|
||||
}
|
||||
await sleep(600);
|
||||
const rapidState = island.getState();
|
||||
const rapidBounds = islandWindow?.getBounds();
|
||||
const rapidBounds = (await readIslandGeometry())?.island ?? null;
|
||||
const currentAppearance = island.getAppearance();
|
||||
record(
|
||||
'连续快速展开/收起后状态与尺寸稳定',
|
||||
rapidState.mode === 'pill' &&
|
||||
rapidBounds?.width === currentAppearance.width &&
|
||||
rapidBounds?.height === currentAppearance.height,
|
||||
`mode=${rapidState.mode} 窗口=${rapidBounds?.width ?? '-'}x${rapidBounds?.height ?? '-'}` +
|
||||
Math.abs((rapidBounds?.width ?? 0) - currentAppearance.width) <= 2 &&
|
||||
Math.abs((rapidBounds?.height ?? 0) - currentAppearance.height) <= 2,
|
||||
`mode=${rapidState.mode} 岛=${rapidBounds?.width?.toFixed(1) ?? '-'}x${rapidBounds?.height?.toFixed(1) ?? '-'}` +
|
||||
`(期望 ${currentAppearance.width}x${currentAppearance.height})`,
|
||||
);
|
||||
|
||||
@@ -712,8 +990,8 @@ async function runIslandChecks(
|
||||
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
|
||||
await sleep(350);
|
||||
|
||||
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 };
|
||||
const expandedCard = { width: ISLAND_SIZES.expanded.width, height: ISLAND_SIZES.expanded.height };
|
||||
const pillCard = { width: ISLAND_SIZES.pill.width, height: ISLAND_SIZES.pill.height };
|
||||
|
||||
await runMotionProbe('expand');
|
||||
const expandSamples = [...motionSamples];
|
||||
@@ -729,24 +1007,24 @@ async function runIslandChecks(
|
||||
const maxCardH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardH)) : 0;
|
||||
const maxWinW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winW)) : 0;
|
||||
const maxWinH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winH)) : 0;
|
||||
const minWinW = expandSamples.length ? Math.min(...expandSamples.map((item) => item.winW)) : 0;
|
||||
const minWinH = expandSamples.length ? Math.min(...expandSamples.map((item) => item.winH)) : 0;
|
||||
const windowFrozen = maxWinW === minWinW && maxWinH === minWinH;
|
||||
const expandMonotonic = expandSamples.every(
|
||||
(item, index) => index === 0 || item.winW >= (expandSamples[index - 1]?.winW ?? 0) - 1,
|
||||
);
|
||||
const expandNoOvershoot =
|
||||
maxCardW <= expandedCard.width + 0.5 &&
|
||||
maxCardH <= expandedCard.height + 0.5 &&
|
||||
maxWinW <= ISLAND_SIZES.expanded.width + 1 &&
|
||||
maxWinH <= ISLAND_SIZES.expanded.height + 1;
|
||||
const expandNoOvershoot = maxCardW <= expandedCard.width + 0.5 && maxCardH <= expandedCard.height + 0.5;
|
||||
record(
|
||||
'展开过程不震动(锚点不动 + 尺寸不过冲)',
|
||||
'展开过程不震动(窗口冻结 + 岛锚点不动 + 尺寸不过冲)',
|
||||
expandSamples.length >= 5 &&
|
||||
windowFrozen &&
|
||||
cxSpread <= 0.5 &&
|
||||
topSpreadExpand <= 0.5 &&
|
||||
expandMonotonic &&
|
||||
expandNoOvershoot,
|
||||
`采样=${expandSamples.length} 卡片中心波动=${cxSpread.toFixed(2)}px 上边缘波动=${topSpreadExpand.toFixed(2)}px ` +
|
||||
`最大卡片=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height})` +
|
||||
` 最大窗口=${maxWinW}x${maxWinH}(目标 ${ISLAND_SIZES.expanded.width}x${ISLAND_SIZES.expanded.height}) 单调=${expandMonotonic}`,
|
||||
`采样=${expandSamples.length} 窗口=${maxWinW}x${maxWinH}(全程冻结=${windowFrozen}) ` +
|
||||
`岛中心波动=${cxSpread.toFixed(2)}px 岛上边缘波动=${topSpreadExpand.toFixed(2)}px ` +
|
||||
`最大岛=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height}) 单调=${expandMonotonic}`,
|
||||
);
|
||||
|
||||
await runMotionProbe('collapse');
|
||||
@@ -759,22 +1037,26 @@ async function runIslandChecks(
|
||||
? Math.max(...collapseSamples.map((item) => item.cardTop)) -
|
||||
Math.min(...collapseSamples.map((item) => item.cardTop))
|
||||
: 99;
|
||||
const minWinW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.winW)) : 0;
|
||||
const minCardW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.cardW)) : 0;
|
||||
const collapseWindowFrozen =
|
||||
new Set(collapseSamples.map((item) => item.winW)).size <= 1 &&
|
||||
new Set(collapseSamples.map((item) => item.winH)).size <= 1;
|
||||
const collapseMonotonic = collapseSamples.every(
|
||||
(item, index) =>
|
||||
index === 0 || item.winW <= (collapseSamples[index - 1]?.winW ?? Number.MAX_SAFE_INTEGER) + 1,
|
||||
index === 0 || item.cardW <= (collapseSamples[index - 1]?.cardW ?? Number.MAX_SAFE_INTEGER) + 1,
|
||||
);
|
||||
const collapseNoOvershoot = minCardW >= pillCard.width - 0.5 && minWinW >= ISLAND_SIZES.pill.width - 1;
|
||||
const collapseNoOvershoot = minCardW >= pillCard.width - 1;
|
||||
record(
|
||||
'收回过程不震动(锚点不动 + 尺寸不过冲)',
|
||||
'收回过程不震动(窗口冻结 + 岛锚点不动 + 尺寸不过冲)',
|
||||
collapseSamples.length >= 5 &&
|
||||
collapseWindowFrozen &&
|
||||
cxSpreadCollapse <= 0.5 &&
|
||||
topSpreadCollapse <= 0.5 &&
|
||||
collapseMonotonic &&
|
||||
collapseNoOvershoot,
|
||||
`采样=${collapseSamples.length} 卡片中心波动=${cxSpreadCollapse.toFixed(2)}px 上边缘波动=${topSpreadCollapse.toFixed(2)}px ` +
|
||||
`最小卡片宽=${minCardW.toFixed(1)}(目标 ${pillCard.width}) 最小窗口宽=${minWinW}(目标 ${ISLAND_SIZES.pill.width}) 单调=${collapseMonotonic}`,
|
||||
`采样=${collapseSamples.length} 窗口全程冻结=${collapseWindowFrozen} ` +
|
||||
`岛中心波动=${cxSpreadCollapse.toFixed(2)}px 岛上边缘波动=${topSpreadCollapse.toFixed(2)}px ` +
|
||||
`最小岛宽=${minCardW.toFixed(1)}(目标 ${pillCard.width}) 单调收缩=${collapseMonotonic}`,
|
||||
);
|
||||
|
||||
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
|
||||
@@ -908,6 +1190,41 @@ async function runIslandChecks(
|
||||
`毛玻璃 fill=${glassStyle.fill} 材质=${glassStyle.material};` +
|
||||
`主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.material}`,
|
||||
);
|
||||
// 10.7b) 固定大包围盒窗口的鼠标穿透(照搬 WinIsland set_cursor_hittest):
|
||||
// 默认整块窗口穿透,指针进入岛体才接收鼠标 —— 否则会吞掉桌面点击。
|
||||
await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
const r = card.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
clientX: r.left + r.width / 2,
|
||||
clientY: r.top + r.height / 2,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
})()`,
|
||||
)
|
||||
.catch(() => undefined);
|
||||
await sleep(180);
|
||||
const interactiveInside = island.getInteractive();
|
||||
await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 2, clientY: 2, bubbles: true }));
|
||||
return true;
|
||||
})()`,
|
||||
)
|
||||
.catch(() => undefined);
|
||||
await sleep(180);
|
||||
const interactiveOutside = island.getInteractive();
|
||||
record(
|
||||
'岛外鼠标穿透(固定大窗口不吞桌面点击)',
|
||||
interactiveInside === true && interactiveOutside === false,
|
||||
`指针在岛内 interactive=${interactiveInside},指针在岛外 interactive=${interactiveOutside}(要求 true / false)`,
|
||||
);
|
||||
island.setAppearance({ ...appearanceBefore, style: 'black' });
|
||||
await sleep(200);
|
||||
|
||||
@@ -916,15 +1233,15 @@ async function runIslandChecks(
|
||||
island.setAppearance({ ...appearanceBefore, idleSliver: true });
|
||||
await drainIsland();
|
||||
await sleep(420);
|
||||
const sliverBounds = islandWindow?.getBounds();
|
||||
const sliverBounds = (await readIslandGeometry())?.island ?? null;
|
||||
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
|
||||
await sleep(500);
|
||||
const afterSliverNotification = islandWindow?.getBounds();
|
||||
await sleep(700);
|
||||
const afterSliverNotification = (await readIslandGeometry())?.island ?? null;
|
||||
record(
|
||||
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
|
||||
sliverBounds?.width === sliver.width &&
|
||||
sliverBounds?.height === sliver.height &&
|
||||
afterSliverNotification?.width === island.getAppearance().width,
|
||||
Math.abs((sliverBounds?.width ?? 0) - sliver.width) <= 1 &&
|
||||
Math.abs((sliverBounds?.height ?? 0) - sliver.height) <= 1 &&
|
||||
Math.abs((afterSliverNotification?.width ?? 0) - island.getAppearance().width) <= 2,
|
||||
`细缝=${sliverBounds?.width ?? '-'}x${sliverBounds?.height ?? '-'}(期望 ${sliver.width}x${sliver.height})` +
|
||||
` 来消息后=${afterSliverNotification?.width ?? '-'}x${afterSliverNotification?.height ?? '-'} ${islandStateSummary()}`,
|
||||
);
|
||||
@@ -993,7 +1310,7 @@ async function runIslandChecks(
|
||||
for (const item of positionCases) {
|
||||
island.setAppearance({ ...appearanceBefore, position: item.position });
|
||||
await sleep(450);
|
||||
const bounds = islandWindow?.getBounds();
|
||||
const bounds = (await readIslandGeometry())?.island ?? null;
|
||||
const matched = Boolean(bounds) && item.verify(bounds as Electron.Rectangle);
|
||||
if (!matched) positionOk = false;
|
||||
positionDetails.push(
|
||||
@@ -1024,7 +1341,7 @@ async function runIslandChecks(
|
||||
await sleep(300);
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(60);
|
||||
const instantBounds = islandWindow?.getBounds();
|
||||
const instantBounds = (await readIslandGeometry())?.island ?? null;
|
||||
const expectedExpanded = ISLAND_SIZES.expanded;
|
||||
const animationsOff = island.getAppearance().animations === false;
|
||||
record(
|
||||
@@ -1038,14 +1355,15 @@ async function runIslandChecks(
|
||||
const measureCollapse = async (speed: number): Promise<number> => {
|
||||
island.setAppearance({ ...appearanceBefore, animations: true, speed });
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(500);
|
||||
await sleep(900);
|
||||
const started = Date.now();
|
||||
island.handleAction({ action: 'collapse' });
|
||||
const target = island.getAppearance().width;
|
||||
const deadline = started + 2500;
|
||||
const deadline = started + 3000;
|
||||
while (Date.now() < deadline) {
|
||||
const width = islandWindow?.getBounds().width ?? 0;
|
||||
if (Math.abs(width - target) <= 1) break;
|
||||
const geometry = await readIslandGeometry();
|
||||
const width = geometry?.island.width ?? 0;
|
||||
if (Math.abs(width - target) <= 1.5) break;
|
||||
await sleep(16);
|
||||
}
|
||||
return Date.now() - started;
|
||||
|
||||
Reference in new issue
Block a user