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:
LoveChengke committed 2026-09-12 18:46:20 +08:00
1 parent 93447922dc
commit 7a4668cb1b
8 files changed
+954 -410

No files matched your search

+418 -100
View File
@@ -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;