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

+22 -20
View File
@@ -20,7 +20,7 @@
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 53/53) | | 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 54/54) |
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
@@ -179,7 +179,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项, 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(**班级账号登录** + 四类数据 + Socket.IO),实测 53/53 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 47/47)。 (**班级账号登录** + 四类数据 + Socket.IO),实测 54/54 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 48/48)。
## 默认账号与种子数据 ## 默认账号与种子数据
@@ -323,12 +323,14 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
> 顶/底 × 左/中/右 **6 个停靠位**、可选"空闲细缝"(WinIsland `hidden_width`)。 > 顶/底 × 左/中/右 **6 个停靠位**、可选"空闲细缝"(WinIsland `hidden_width`)。
> 逐项从 WinIsland 源码提取的设计 token 见 **[docs/winisland-design-tokens.md](docs/winisland-design-tokens.md)**。 > 逐项从 WinIsland 源码提取的设计 token 见 **[docs/winisland-design-tokens.md](docs/winisland-design-tokens.md)**。
> >
> 与 WinIsland 的两点**有意差异**(都写进了实现注释): > **架构也照搬 WinIsland(同一套做法,而不是"仿个外观")**:
> > 窗口一次创建成**固定包围盒**(最大形态 + 阴影留白),开合过程中**一帧都不移动/缩放窗口**;
> 1. WinIsland 用"固定大窗口 + 画布内形变",我们沿用"按状态缓动窗口尺寸"—— > 岛的全部形变(宽/高/圆角/内容交叉淡入)都发生在窗口内的渲染层,由 WinIsland 的弹簧驱动:
> 在 Electron 里前者需要常驻的点击穿透大窗口(容易吞掉桌面点击),后者已被 53 项断言覆盖(含 0.00px 不震动); > `force=(target-value)*0.10*dt` → `velocity=(velocity+force)*0.68^dt` → `value+=velocity*dt`
> 2. Windows 对透明窗口有约 **36px 的最小高度**(实测请求 32px 得到 36px), > (dt 以帧为单位;换向只保留 35% 动量、单帧位移不超过剩余距离 20%),并额外加**零过冲钳制**(越过目标立即吸附)。
> 因此"空闲细缝"的高度以 36px 为下限(WinIsland 因为窗口恒定所以不受此限)。 > 鼠标命中照搬 `set_cursor_hittest`:窗口默认 `setIgnoreMouseEvents(true, { forward: true })` 整块穿透,
> 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。
> 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。
| 场景 | 灵动岛行为 | | 场景 | 灵动岛行为 |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
@@ -426,9 +428,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。 - 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。
- 落盘位置:客户端 `config.json` 的 `island` 字段(旧配置没有该字段时自动补默认值,向后兼容)。 - 落盘位置:客户端 `config.json` 的 `island` 字段(旧配置没有该字段时自动补默认值,向后兼容)。
**形变动画(需求 4 抖动修复 + 无震动)**:窗口尺寸由**单条帧循环**驱动(优先 `requestAnimationFrame`, **形变动画(需求 4 抖动修复 + 无震动)**:岛在**固定包围盒窗口内**由弹簧逐帧逼近目标尺寸/圆角
主进程没有该全局时退化为 ~60fps 定时器),新动画会取消上一条并作废令牌,结束时对齐最终尺寸; (`src/island/spring.ts`,照搬 WinIsland 的 `physics.rs` 参数),窗口本身全程不动;
卡片始终是**固定尺寸 + 顶部居中锚定**,只让窗口露出/裁切,形变过程中卡片上边缘不动; 胶囊层与展开层用 WinIsland 的几何进度交叉淡入(展开层 `progress²`、胶囊层 `1 - 1.5×progress`);
展开动画结束后才取焦点。 展开动画结束后才取焦点。
为了让"开合"完全没有振动感,几何与缓动做了三件事(每一条都有实测断言): 为了让"开合"完全没有振动感,几何与缓动做了三件事(每一条都有实测断言):
@@ -534,7 +536,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 | | **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果, 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、53/53 通过、退出码 0)。灵动岛各状态的窗口截图会写到 已实测 `packaged: true`、54/54 通过、退出码 0)。灵动岛各状态的窗口截图会写到
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
@@ -889,14 +891,14 @@ pnpm db:migrate && pnpm db:seed
当前实测: 当前实测:
| 验证 | 结果 | | 验证 | 结果 |
| --------------------------------------------------------------------------------------------------- | ----------------------------------- | | -------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + 开合不震动 + WinIsland 风格 + 个性化全参数 + 托盘与退出无残留) | **53/53** ✅ | | `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/开合不震动 + 个性化全参数 + 托盘与退出无残留) | **54/54** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
## 常见问题(本机环境已知坑) ## 常见问题(本机环境已知坑)
+242 -107
View File
@@ -2,6 +2,9 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
ISLAND_SHADOW_PAD,
ISLAND_SIZE_DELTA,
ISLAND_SLIVER_WIDTH,
type IslandAppearance, type IslandAppearance,
PRIORITY_LABELS, PRIORITY_LABELS,
formatDate, formatDate,
@@ -9,6 +12,7 @@ import {
type IslandNotificationKind, type IslandNotificationKind,
type IslandState, type IslandState,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { SpringValue } from './spring.js';
import { squirclePath } from './squircle.js'; import { squirclePath } from './squircle.js';
/** /**
@@ -91,16 +95,6 @@ const stateColor = computed(() => {
return ''; return '';
}); });
/**
* 入场动画:胶囊直接出现(WinIsland 的 compact 没有入场动画),
* 展开 / 紧急只用透明度淡入,形变完全交给窗口尺寸(因此不会出现缩放回弹)。
*/
const transitionName = computed(() => (mode.value === 'pill' ? 'island-instant' : 'island-fade'));
const transitionDuration = computed(() => ({
enter: mode.value === 'pill' ? 0 : Math.round(140 / appearance.value.speed),
leave: Math.round(90 / appearance.value.speed),
}));
function expand(): void { function expand(): void {
bridge?.sendAction('expand'); bridge?.sendAction('expand');
} }
@@ -132,65 +126,190 @@ function openApp(): void {
/** 个性化外观:主进程下发后写入 CSS 变量,形状/排版/配色立即变化(无需重启) */ /** 个性化外观:主进程下发后写入 CSS 变量,形状/排版/配色立即变化(无需重启) */
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE }); const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
/** 卡片与窗口的内边距(必须与主进程 CARD_PAD_X / CARD_PAD_Y 一致) */ /** 各形态相对"胶囊"的尺寸增量(与主进程共用同一常量表,避免两边漂移) */
const CARD_PAD_X = 5; const EXTRA = ISLAND_SIZE_DELTA;
const CARD_PAD_Y = 5;
/** /**
* 各形态相对"胶囊窗口"的额外尺寸(必须与主进程 BASE.expanded/urgent/call 的增量一致): * 岛的目标尺寸(**岛本身**,不是窗口):
* 卡片尺寸 = 窗口尺寸 - 内边距,因此卡片始终在窗口内居中、四周留白恒定。 * - 胶囊:外观设置里的 width/height
* - 展开/紧急/叫人:胶囊 + `EXTRA`
* - 空闲细缝:6px 宽竖条(WinIsland hidden_width 的思路)
* 全部由下面的弹簧逐帧逼近,窗口(固定包围盒)全程不动。
*/ */
const EXTRA = { function targetSize(): { width: number; height: number } {
expanded: { width: 156, height: 186 },
urgent: { width: 172, height: 202 },
call: { width: 188, height: 218 },
} as const;
/** 当前形态的窗口尺寸(用于推出卡片尺寸) */
const windowSize = computed(() => {
const width = appearance.value.width; const width = appearance.value.width;
const height = appearance.value.height; const height = appearance.value.height;
if (mode.value === 'hidden') {
// 有"空闲细缝"时才收成细缝;否则保持胶囊尺寸——
// 窗口此时是隐藏的,没必要缩到细缝(否则下次弹出会从细缝"长大",多出入场动画)。
if (!appearance.value.idleSliver) return { width, height };
return { width: ISLAND_SLIVER_WIDTH, height: Math.max(12, Math.min(28, height - 22)) };
}
if (mode.value !== 'expanded') return { width, height }; if (mode.value !== 'expanded') return { width, height };
const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded; const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded;
return { width: width + extra.width, height: height + extra.height }; return { width: width + extra.width, height: height + extra.height };
}); }
/** 卡片像素尺寸(= 窗口 - 内边距) */
const cardSize = computed(() => ({
width: Math.max(2, windowSize.value.width - CARD_PAD_X * 2),
height: Math.max(2, windowSize.value.height - CARD_PAD_Y * 2),
}));
/** 圆角:胶囊取 h/2(满圆角),展开卡取 min(48*系数, w/2, h/2)(WinIsland expanded_island_radius) */ /** 圆角:胶囊取 h/2(满圆角),展开卡取 min(48*系数, w/2, h/2)(WinIsland expanded_island_radius) */
const cardRadius = computed(() => { function targetRadius(size: { width: number; height: number }): number {
const { width, height } = cardSize.value; if (mode.value !== 'expanded') return Math.min(appearance.value.radius, size.height / 2);
if (mode.value !== 'expanded') return Math.min(appearance.value.radius, height / 2);
const scaled = (48 * appearance.value.radius) / 20; const scaled = (48 * appearance.value.radius) / 20;
return Math.max(0, Math.min(scaled, width / 2, height / 2)); return Math.max(0, Math.min(scaled, size.width / 2, size.height / 2));
}); }
/** 连续圆角路径(超椭圆角,见 squircle.ts) */ /* ---------------------------------------------------------------- 弹簧形变 */
const cardPath = computed(() => squirclePath(cardSize.value.width, cardSize.value.height, cardRadius.value));
const springs = {
width: new SpringValue(DEFAULT_ISLAND_APPEARANCE.width),
height: new SpringValue(DEFAULT_ISLAND_APPEARANCE.height),
radius: new SpringValue(DEFAULT_ISLAND_APPEARANCE.radius),
};
/** 当前正在播放的形变值(渲染用) */
const morph = ref<{ width: number; height: number; radius: number }>({
width: DEFAULT_ISLAND_APPEARANCE.width,
height: DEFAULT_ISLAND_APPEARANCE.height,
radius: DEFAULT_ISLAND_APPEARANCE.radius,
});
/** 形变进度 0(胶囊)→ 1(展开):用于内容交叉淡入(WinIsland expanded_alpha / mini_alpha) */
const progress = ref(0);
let rafHandle: number | null = null;
let lastFrameAt = 0;
function syncTargets(snap = false): void {
const size = targetSize();
const radius = targetRadius(size);
if (snap || !appearance.value.animations) {
springs.width.snap(size.width);
springs.height.snap(size.height);
springs.radius.snap(radius);
morph.value = { width: size.width, height: size.height, radius };
progress.value = mode.value === 'expanded' ? 1 : 0;
return;
}
springs.width.setTarget(size.width);
springs.height.setTarget(size.height);
springs.radius.setTarget(radius);
startFrameLoop();
}
function startFrameLoop(): void {
if (rafHandle !== null) return;
lastFrameAt = performance.now();
const frame = (now: number): void => {
const delta = now - lastFrameAt;
lastFrameAt = now;
// 个性化"动画速度":直接缩放 dt(WinIsland 没有该设置,这是我们的扩展)
const scaled = delta * appearance.value.speed;
const moving = springs.width.step(scaled) || springs.height.step(scaled) || springs.radius.step(scaled);
morph.value = {
width: springs.width.value,
height: springs.height.value,
radius: springs.radius.value,
};
// 内容交叉淡入:胶囊层先淡出(进度 2/3 前退净),展开层按平方淡入(WinIsland 的做法)
const pill = appearance.value.height;
const expanded = targetSize().height;
const span = Math.abs(expanded - pill);
progress.value =
span < 1 ? (mode.value === 'expanded' ? 1 : 0) : Math.abs(morph.value.height - pill) / span;
if (moving || !springs.width.settled || !springs.height.settled || !springs.radius.settled) {
rafHandle = requestAnimationFrame(frame);
return;
}
// 收尾对齐(避免浮点残差)
const size = targetSize();
springs.width.snap(size.width);
springs.height.snap(size.height);
springs.radius.snap(targetRadius(size));
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
progress.value = mode.value === 'expanded' ? 1 : 0;
rafHandle = null;
};
rafHandle = requestAnimationFrame(frame);
}
/** /**
* 卡片内联样式:尺寸、形状、配色、排版全部由外观设置驱动。 * 实际生效的圆角:始终钳制到 `min(圆角, w/2, h/2)`。
* * 弹簧对"尺寸"与"圆角"是各自独立推进的,形变过程中圆角可能短暂大于 h/2;
* 注意 `left` 用 `calc(50% - 卡片宽/2)`(相对**当前窗口宽度**居中),而不是固定 padding: * 统一钳制既保证形状合法,也让 CSS 变量与真实绘制一致(便于断言)。
* 窗口在形变过程中宽度由主进程逐帧插值,卡片只有居中才能保证"卡片中心全程不动"(实测 0.00px)。
*/ */
const cardStyle = computed(() => { const effectiveRadius = computed(() =>
const card = cardSize.value; Math.max(0, Math.min(morph.value.radius, morph.value.width / 2, morph.value.height / 2)),
return { );
width: `${card.width}px`,
height: `${card.height}px`, /** 连续圆角路径(超椭圆角,见 squircle.ts) */
left: `calc(50% - ${card.width / 2}px)`, const cardPath = computed(() => squirclePath(morph.value.width, morph.value.height, effectiveRadius.value));
top: `${CARD_PAD_Y}px`,
/** 胶囊内容透明度:进度到 2/3 时完全退净(WinIsland MINI_FADE_RATE = 1.5) */
const pillAlpha = computed(() => Math.max(0, Math.min(1, 1 - progress.value * 1.5)));
/** 展开内容透明度:进度平方(WinIsland expanded_alpha = progress²) */
const expandedAlpha = computed(() => progress.value * progress.value);
/**
* 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。
* 水平:居中锚点用 `left: 50% + translateX(-50%)`;左/右锚点贴边(留阴影余量)。
*/
const islandStyle = computed(() => {
const position = appearance.value.position;
const style: Record<string, string> = {
width: `${morph.value.width}px`,
height: `${morph.value.height}px`,
'--card-path': `path("${cardPath.value}")`, '--card-path': `path("${cardPath.value}")`,
'--card-r': `${cardRadius.value}px`, '--card-r': `${morph.value.radius}px`,
'--state-color': stateColor.value || 'transparent', '--state-color': stateColor.value || 'transparent',
}; };
if (position.endsWith('left')) {
style.left = `${ISLAND_SHADOW_PAD}px`;
} else if (position.endsWith('right')) {
style.right = `${ISLAND_SHADOW_PAD}px`;
} else {
style.left = '50%';
style.transform = 'translateX(-50%)';
}
if (position.startsWith('bottom')) style.bottom = `${ISLAND_SHADOW_PAD}px`;
else style.top = `${ISLAND_SHADOW_PAD}px`;
return style;
}); });
/** 当前形态的样式类(供既有断言与样式复用) */
const islandClass = computed(() => ({
pill: mode.value === 'pill' || (mode.value === 'hidden' && !appearance.value.idleSliver),
expanded: mode.value === 'expanded',
'hidden-placeholder': mode.value === 'hidden' && !appearance.value.idleSliver,
sliver: mode.value === 'hidden' && appearance.value.idleSliver,
urgent: isUrgent.value,
call: isCall.value,
homework: isHomework.value,
'after-class': showAfterClassHint.value,
}));
/**
* 鼠标命中测试(照搬 WinIsland 的 set_cursor_hittest 思路):
* 指针在岛体上(或可交互控件上)时才让窗口接收鼠标,否则穿透,保证不吞桌面点击。
*/
let lastInteractive: boolean | null = null;
function updateInteractive(clientX: number, clientY: number): void {
const node = document.querySelector('.island-card') as HTMLElement | null;
let inside = false;
if (node) {
const rect = node.getBoundingClientRect();
const margin = 2;
inside =
clientX >= rect.left - margin &&
clientX <= rect.right + margin &&
clientY >= rect.top - margin &&
clientY <= rect.bottom + margin;
}
if (inside === lastInteractive) return;
lastInteractive = inside;
bridge?.setInteractive?.(inside);
}
function applyAppearance(next: IslandAppearance | null | undefined): void { function applyAppearance(next: IslandAppearance | null | undefined): void {
if (!next) return; if (!next) return;
appearance.value = next; appearance.value = next;
@@ -201,43 +320,63 @@ function applyAppearance(next: IslandAppearance | null | undefined): void {
style.setProperty('--island-w', `${next.width}px`); style.setProperty('--island-w', `${next.width}px`);
style.setProperty('--island-h', `${next.height}px`); style.setProperty('--island-h', `${next.height}px`);
style.setProperty('--island-anim', next.animations ? '1' : '0'); style.setProperty('--island-anim', next.animations ? '1' : '0');
// 形变时长(与主进程的缓动时长一致),用于内容淡入淡出的节奏对齐 style.setProperty('--island-dur', `${Math.round(320 / Math.max(0.25, next.speed))}ms`);
const morph = next.style === 'glass' ? 320 : 320;
style.setProperty('--island-dur', `${Math.round(morph / Math.max(0.25, next.speed))}ms`);
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
bridge?.onState((next) => { bridge?.onState((next) => {
state.value = next; state.value = next;
syncTargets();
}); });
const initial = await bridge?.getState(); const initial = await bridge?.getState();
if (initial) state.value = initial; if (initial) state.value = initial;
// 外观:先同步一次,再订阅后续改动(改设置立即生效) // 外观:先同步一次,再订阅后续改动(改设置立即生效)
bridge?.onAppearance((next) => applyAppearance(next)); bridge?.onAppearance((next) => {
applyAppearance(next);
syncTargets();
});
const initialAppearance = await bridge?.getAppearance?.(); const initialAppearance = await bridge?.getAppearance?.();
applyAppearance(initialAppearance); applyAppearance(initialAppearance);
syncTargets(true);
// 初始状态可能是 hidden(窗口未显示),此时同步一次目标即可
syncTargets();
}); });
</script> </script>
<template> <template>
<!--
固定包围盒窗口内的**单个**灵动岛(照搬 WinIsland):
岛的宽高/圆角由弹簧逐帧逼近,窗口本身不动;胶囊层与展开层在里面交叉淡入。
-->
<div class="island-root"> <div class="island-root">
<Transition :name="transitionName" :duration="transitionDuration"> <div
<!-- 胶囊态:WinIsland compact —— 小尺寸、满圆角、图标 + 文本 + 提示箭头 --> class="island-card"
<button :class="islandClass"
v-if="mode === 'pill' && notification" :data-style="appearance.style"
key="pill" :style="islandStyle"
type="button" @click="mode === 'expanded' ? onCardClick($event) : expand()"
class="island-card pill" >
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, 'after-class': showAfterClassHint }" <!--
:data-style="appearance.style" 形状层:显式给像素宽高 + preserveAspectRatio="none"。
:style="cardStyle" 只写 width/height:100% 时 SVG 会按 viewBox 比例做 preserveAspectRatio 缩放,
@click="expand" 形变过程中 viewBox 与元素尺寸不同步就会出现形状错位/残留角(实测肉眼可见)。
-->
<svg
class="shape"
:width="Math.max(1, morph.width)"
:height="Math.max(1, morph.height)"
:viewBox="`0 0 ${Math.max(1, morph.width)} ${Math.max(1, morph.height)}`"
preserveAspectRatio="none"
aria-hidden="true"
> >
<svg class="shape" :viewBox="`0 0 ${cardSize.width} ${cardSize.height}`" aria-hidden="true"> <path :d="cardPath" />
<path :d="cardPath" /> </svg>
</svg> <span class="content">
<span class="content"> <span class="tint" aria-hidden="true"></span>
<span class="tint" aria-hidden="true"></span>
<!-- 胶囊层(WinIsland compact):进度 > 2/3 时完全退净 -->
<span class="layer pill-layer" :style="{ opacity: pillAlpha }">
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }"> <span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true"> <svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path <path
@@ -263,7 +402,7 @@ onMounted(async () => {
{{ pillTitle }} {{ pillTitle }}
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template> <template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
</span> </span>
<span class="pill-sub">{{ notification.teacherName || '老师' }} · 点击查看</span> <span class="pill-sub">{{ notification?.teacherName || '老师' }} · 点击查看</span>
</span> </span>
<span class="pill-chevron" aria-hidden="true"> <span class="pill-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
@@ -277,23 +416,9 @@ onMounted(async () => {
</svg> </svg>
</span> </span>
</span> </span>
</button>
<!-- 展开态:WinIsland expanded —— 大圆角卡片、白字分级、底部胶囊按钮 --> <!-- 展开层(WinIsland expanded):进度²淡入 -->
<section <span class="layer expanded-layer" :style="{ opacity: expandedAlpha }">
v-else-if="mode === 'expanded' && notification"
key="expanded"
class="island-card expanded"
:class="{ urgent: isUrgent, call: isCall, homework: isHomework }"
:data-style="appearance.style"
:style="cardStyle"
@click="onCardClick"
>
<svg class="shape" :viewBox="`0 0 ${cardSize.width} ${cardSize.height}`" aria-hidden="true">
<path :d="cardPath" />
</svg>
<span class="content">
<span class="tint" aria-hidden="true"></span>
<header class="head"> <header class="head">
<span class="head-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }"> <span class="head-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true"> <svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
@@ -322,7 +447,7 @@ onMounted(async () => {
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span> <span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
<span class="head-sub">{{ subtitle }}</span> <span class="head-sub">{{ subtitle }}</span>
</div> </div>
<button type="button" class="icon-btn" title="收起" @click="collapse"> <button type="button" class="icon-btn" title="收起" @click.stop="collapse">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path <path
fill="none" fill="none"
@@ -335,30 +460,27 @@ onMounted(async () => {
</button> </button>
</header> </header>
<h3 class="title">{{ notification.title }}</h3> <h3 class="title">{{ notification?.title ?? '' }}</h3>
<div class="body"> <div class="body">
<p class="content-text">{{ notification.content }}</p> <p class="content-text">{{ notification?.content ?? '' }}</p>
<p v-if="callHint" class="call-hint">{{ callHint }}</p> <p v-if="callHint" class="call-hint">{{ callHint }}</p>
<p v-else-if="notification.subtitle" class="call-hint">{{ notification.subtitle }}</p> <p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
</div> </div>
<footer class="foot"> <footer class="foot">
<span v-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span> <span v-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
<span v-else class="more muted">来自班级小助手</span> <span v-else class="more muted">来自班级小助手</span>
<span class="actions"> <span class="actions">
<button type="button" class="ghost-btn" @click="openApp">打开应用</button> <button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
<button type="button" class="ghost-btn" @click="markRead">标为已读</button> <button type="button" class="ghost-btn" @click.stop="markRead">标为已读</button>
<button type="button" class="solid-btn" @click="dismiss"> <button type="button" class="solid-btn" @click.stop="dismiss">
{{ isCall ? '收到' : '知道了' }} {{ isCall ? '收到' : '知道了' }}
</button> </button>
</span> </span>
</footer> </footer>
</span> </span>
</section> </span>
</div>
<!-- 隐藏态占位(窗口不可见,仅保证 DOM 结构稳定) -->
<div v-else key="hidden" class="island-card hidden-placeholder"></div>
</Transition>
</div> </div>
</template> </template>
@@ -444,9 +566,8 @@ body {
/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */ /* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */
.island-card .shape { .island-card .shape {
position: absolute; position: absolute;
inset: 0; left: 0;
width: 100%; top: 0;
height: 100%;
display: block; display: block;
} }
@@ -456,16 +577,28 @@ body {
stroke-width: 1; stroke-width: 1;
} }
/* 内容层裁切到同一形状内 */ /* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
.island-card .content { .island-card .content {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: block;
flex-direction: column;
clip-path: var(--card-path); clip-path: var(--card-path);
overflow: hidden; overflow: hidden;
} }
.island-card .layer {
position: absolute;
inset: 0;
display: flex;
pointer-events: none;
}
/* 只有"当前形态"的那一层响应鼠标,避免透明层的按钮被误点 */
.island-card.pill .pill-layer,
.island-card.expanded .expanded-layer {
pointer-events: auto;
}
/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px) */ /* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px) */
.island-card.expanded .shape path { .island-card.expanded .shape path {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.11)); filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.11));
@@ -505,7 +638,7 @@ body {
/* ---------------------------------------------------------------- 胶囊(compact) */ /* ---------------------------------------------------------------- 胶囊(compact) */
.island-card.pill .content { .pill-layer {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: calc(var(--island-font, 13px) * 0.62); gap: calc(var(--island-font, 13px) * 0.62);
@@ -583,10 +716,12 @@ body {
/* ---------------------------------------------------------------- 展开卡(expanded) */ /* ---------------------------------------------------------------- 展开卡(expanded) */
.island-card.expanded .content { .expanded-layer {
/* 关键:展开层必须纵向排列。此前把 flex-direction 从基类挪到分层时漏了这一条,
导致展开卡内容横排(标题/元信息/按钮散落,实机可见) */
flex-direction: column;
padding: calc(var(--island-font, 13px) * 1.4) calc(var(--island-font, 13px) * 1.6); padding: calc(var(--island-font, 13px) * 1.4) calc(var(--island-font, 13px) * 1.6);
gap: calc(var(--island-font, 13px) * 0.7); gap: calc(var(--island-font, 13px) * 0.7);
/* 内容块整体垂直居中:卡片尺寸固定(锚点稳定),留白上下均分比"底部一大块空"更自然 */
justify-content: center; justify-content: center;
} }
@@ -0,0 +1,115 @@
/**
* WinIsland 同款弹簧(照搬 `src/utils/physics.rs` 与 `src/window/app/frame.rs` 的参数与更新式)。
*
* 原始实现(Rust,每帧调用一次,`dt` 以**帧**为单位):
* ```text
* force = (target - value) * stiffness * dt
* velocity = (velocity + force) * damping^dt
* value += velocity * dt
* ```
* 主岛尺寸用的参数:`stiffness = 0.10`、`damping = 0.68`(阻尼比 ζ≈0.977,接近临界阻尼,
* 理论过冲 ~1e-6,肉眼不可见)。换向时还有两个限制(`redirect_velocity_towards`):
* 反向动量只保留 35%、单帧位移不超过剩余距离的 20%。
*
* 我们在此基础上**额外加了零过冲钳制**(越过目标立刻吸附并清零速度):
* 即便某帧卡顿导致 dt 变大,也绝不会出现"超过目标再弹回来"的震动。
*/
export interface SpringOptions {
/** 刚度(WinIsland 主岛尺寸:0.10) */
stiffness?: number;
/** 速度保留率(WinIsland 主岛尺寸:0.68) */
damping?: number;
/** 吸附阈值(px) */
snapThreshold?: number;
}
const MOMENTUM_RETENTION = 0.35;
const MAX_DISTANCE_RATIO = 0.2;
const MAX_DT = 6;
/** 一帧按 60fps 折算(与 WinIsland 的 dt 单位一致) */
const FRAME_MS = 1000 / 60;
export class SpringValue {
value: number;
velocity = 0;
target: number;
private readonly stiffness: number;
private readonly damping: number;
private readonly snapThreshold: number;
constructor(initial: number, options: SpringOptions = {}) {
this.value = initial;
this.target = initial;
this.stiffness = options.stiffness ?? 0.1;
this.damping = options.damping ?? 0.68;
this.snapThreshold = options.snapThreshold ?? 0.05;
}
setTarget(target: number): void {
if (target === this.target) return;
this.redirectVelocity(target);
this.target = target;
}
/** 直接落位(关闭动画 / 首次同步时使用) */
snap(target: number): void {
this.target = target;
this.value = target;
this.velocity = 0;
}
get settled(): boolean {
return Math.abs(this.target - this.value) <= this.snapThreshold && Math.abs(this.velocity) <= 0.01;
}
/**
* 换向限速(WinIsland):反向时只保留 35% 动量,并把速度幅值限制在"剩余距离的 20%"以内,
* 保证不会因为方向切换产生突跳。
*/
private redirectVelocity(target: number): void {
const distance = target - this.value;
if (distance === 0) {
this.velocity = 0;
return;
}
const sameDirection = Math.sign(distance) === Math.sign(this.velocity);
if (!sameDirection) this.velocity *= MOMENTUM_RETENTION;
const maxVelocity = Math.abs(distance) * MAX_DISTANCE_RATIO;
if (Math.abs(this.velocity) > maxVelocity) {
this.velocity = Math.sign(this.velocity) * maxVelocity;
}
}
/** 推进一帧;返回是否仍在运动 */
step(deltaMs: number): boolean {
const dt = Math.min(MAX_DT, Math.max(0.1, deltaMs / FRAME_MS));
const distance = this.target - this.value;
if (distance === 0 && Math.abs(this.velocity) <= 0.01) {
this.velocity = 0;
this.value = this.target;
return false;
}
const force = distance * this.stiffness * dt;
this.velocity = (this.velocity + force) * Math.pow(this.damping, dt);
const previous = this.value;
this.value += this.velocity * dt;
// 零过冲钳制:越过目标(或已足够接近)就吸附到目标并停止
const crossed = (this.target - previous) * (this.target - this.value) <= 0;
if (crossed || Math.abs(this.target - this.value) <= this.snapThreshold) {
this.value = this.target;
this.velocity = 0;
return false;
}
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
this.value = this.target;
this.velocity = 0;
return false;
}
return true;
}
}
+135 -183
View File
@@ -3,6 +3,8 @@ import { BrowserWindow, ipcMain, screen } from 'electron';
import { import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
ISLAND_POSITIONS, ISLAND_POSITIONS,
ISLAND_SHADOW_PAD,
ISLAND_SIZE_DELTA,
ISLAND_SLIVER_WIDTH, ISLAND_SLIVER_WIDTH,
ISLAND_STYLES, ISLAND_STYLES,
type IslandAppearance, type IslandAppearance,
@@ -35,37 +37,23 @@ import { getConfig } from './config.js';
/** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */ /** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */
/** /**
* 基准尺寸(对齐 WinIsland 的 `base_width/base_height` 与 `expanded_width/expanded_height` 比例): * 形态尺寸(**岛本身的尺寸**,不是窗口尺寸):
* - 胶囊:小而饱满(WinIsland compact 为 120×27,我们是文字型胶囊,取 216×34,圆角 = h/2 满圆角) * - 胶囊 = 用户设置的 width/height(WinIsland compact 是 120×27,我们是文字型胶囊,默认 268×44)
* - 展开卡:展开增量与渲染进程 IslandApp.vue 的 EXTRA 常量一一对应 * - 展开/紧急/叫人 = 胶囊 + `ISLAND_SIZE_DELTA`(与渲染进程共用同一常量表)
* (expanded +156×+218 / urgent +172×+238 / call +188×+254) *
* 窗口尺寸 = 最大形态 + `ISLAND_SHADOW_PAD`,**一次算好、开合过程中不变**(照搬 WinIsland:
* 它的窗口也是一次创建成最大包围盒,形变全部发生在窗口内的画布上)。
*/ */
const BASE = { const BASE = {
pill: { width: 216, height: 34 }, pill: { width: 216, height: 34 },
expanded: { width: 372, height: 220 }, expanded: { width: 216 + ISLAND_SIZE_DELTA.expanded.width, height: 34 + ISLAND_SIZE_DELTA.expanded.height },
urgent: { width: 388, height: 236 }, urgent: { width: 216 + ISLAND_SIZE_DELTA.urgent.width, height: 34 + ISLAND_SIZE_DELTA.urgent.height },
/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */ /** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */
call: { width: 404, height: 252 }, call: { width: 216 + ISLAND_SIZE_DELTA.call.width, height: 34 + ISLAND_SIZE_DELTA.call.height },
}; };
/** // 说明:几何动画已全部移交渲染进程(渲染层用 WinIsland 的弹簧逐帧逼近岛的宽高/圆角),
* 帧调度:优先 requestAnimationFrame(有则最平滑),主进程没有该全局时退化为 ~60fps 定时器。 // 主进程不再做窗口帧动画,因此原 scheduleFrame / cancelFrame / requestAnimationFrame 声明已移除。
* 关键点不是用哪个 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>);
}
const DEFAULT_SIZES: Record<IslandMode, { width: number; height: number }> = { const DEFAULT_SIZES: Record<IslandMode, { width: number; height: number }> = {
hidden: BASE.pill, hidden: BASE.pill,
@@ -83,6 +71,12 @@ let CALL_SIZE = { ...BASE.call };
*/ */
let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 }; let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
/**
* 固定包围盒窗口的尺寸:取所有形态里最大的一个,再加上阴影留白。
* **只在设置变化时重算一次**,开合过程中窗口尺寸恒定(照搬 WinIsland 的"一次建大窗口")。
*/
let BBOX_SIZE = { width: 0, height: 0 };
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */ /** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = { const TIMEOUTS = {
pill: 15_000, pill: 15_000,
@@ -129,6 +123,8 @@ class IslandController {
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE }; private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
/** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */ /** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */
private backgroundMaterial: string = 'none'; private backgroundMaterial: string = 'none';
/** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */
private interactive = false;
private state: IslandState = { private state: IslandState = {
mode: 'hidden', mode: 'hidden',
@@ -166,7 +162,8 @@ class IslandController {
if (this.win && !this.win.isDestroyed()) return; if (this.win && !this.win.isDestroyed()) return;
const win = new BrowserWindow({ const win = new BrowserWindow({
...this.computeBounds(SIZES.hidden), // 固定包围盒(最大形态 + 阴影留白):开合过程中**窗口不再移动/缩放**
...this.windowBounds(),
show: false, show: false,
frame: false, frame: false,
transparent: true, transparent: true,
@@ -195,7 +192,9 @@ class IslandController {
// 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质) // 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质)
this.backgroundMaterial = 'none'; this.backgroundMaterial = 'none';
this.applyBackgroundMaterial(); this.applyBackgroundMaterial();
win.setIgnoreMouseEvents(false); // 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
// 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。
win.setIgnoreMouseEvents(true, { forward: true });
win.on('closed', () => { win.on('closed', () => {
this.win = null; this.win = null;
this.ready = false; this.ready = false;
@@ -545,39 +544,6 @@ class IslandController {
return { hMode, hValue, vMode, vValue }; 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 { private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
this.state = { ...this.state, ...patch, updatedAt: Date.now() }; this.state = { ...this.state, ...patch, updatedAt: Date.now() };
@@ -598,6 +564,12 @@ class IslandController {
this.win.webContents.send('island:state', payload); this.win.webContents.send('island:state', payload);
} }
/**
* 灵动岛窗口是**固定包围盒**(照搬 WinIsland):开合过程中只改渲染层里岛的宽高/圆角,
* 窗口本身**一帧都不动**,因此不会有"逐帧移动+缩放透明窗口"带来的抖动与残影。
*
* 这里只负责三件事:可见性、焦点、透明度;几何交给渲染进程的弹簧形变。
*/
private syncWindow(): void { private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
@@ -617,47 +589,68 @@ class IslandController {
// 关闭时保持原行为(完全淡出并隐藏)。 // 关闭时保持原行为(完全淡出并隐藏)。
if (this.appearance.idleSliver) { if (this.appearance.idleSliver) {
this.cancelFade(); this.cancelFade();
this.setInteractive(false);
this.win.setOpacity(this.appearance.opacity); this.win.setOpacity(this.appearance.opacity);
if (!this.win.isVisible()) this.win.showInactive(); if (!this.win.isVisible()) this.win.showInactive();
this.animateBounds(SLIVER_SIZE);
return; return;
} }
this.fadeOut(); this.fadeOut();
return; return;
} }
const size = this.setInteractive(true);
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;
if (!this.win.isVisible()) { if (!this.win.isVisible()) {
const pillBounds = this.computeBounds(SIZES.pill); // 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画
if (size === SIZES.pill) {
this.cancelFade();
// 必须使用个性化透明度:这里以前硬编码 1,导致"隐藏后再弹出胶囊"会忽略透明度设置
this.win.setOpacity(this.appearance.opacity);
this.win.setBounds(pillBounds);
this.win.showInactive();
return;
}
// 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见
this.cancelFade(); this.cancelFade();
this.win.setOpacity(0); this.win.setOpacity(this.appearance.opacity);
this.win.setBounds(pillBounds);
this.win.showInactive(); this.win.showInactive();
this.animateBounds(size, { spring: true });
this.fadeIn();
return; 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 下没有原生窗口动画,这里按帧插值实现"形变"效果。 * 几何动画已完全移交渲染进程(固定窗口内做弹簧形变),这里只保留一个"取消"入口,
* * 供 `hideImmediately()` 等在打断场景下调用(现在无需真正取消任何窗口动画)。
* 用**单一 requestAnimationFrame 循环**驱动(需求 4):
* - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动
* - 锚点固定:锚点在动画开始前算一次(整数),过程中只让"对侧边"移动
* - 缓动**单调不过冲**:早期版本用 easeOutBack 弹簧缓动,窗口尺寸会超过目标再弹回来,
* 观感就是"开合时震一下";现在统一用 easeOutCubic,尺寸/位置只朝目标单向变化
* - 每帧只用整数像素、卡片宽度取偶数,卡片中心与不动的那条边全程恒定
* - 关闭动画(appearance.animations = false)时直接落到目标尺寸
*/ */
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 { private cancelBoundsAnimation(): void {
if (this.boundsFrame !== null) { this.boundsFrame = null;
cancelFrame(this.boundsFrame);
this.boundsFrame = null;
}
this.boundsToken += 1; this.boundsToken += 1;
} }
/** /**
* 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。 * 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。
* 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。 * 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。
*/ */
setAppearance(patch: Partial<IslandAppearance>): void { setAppearance(patch: Partial<IslandAppearance>): void {
const before = this.appearance;
this.appearance = normalizeAppearance({ ...this.appearance, ...patch }); this.appearance = normalizeAppearance({ ...this.appearance, ...patch });
recomputeSizes(this.appearance); recomputeSizes(this.appearance);
@@ -809,12 +735,10 @@ class IslandController {
} }
} }
this.emitAppearance(); this.emitAppearance();
// 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口)
this.applyWindowLayout();
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作) // 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
if (this.state.mode !== 'hidden') this.syncWindow(); if (this.state.mode !== 'hidden') this.syncWindow();
// 位置变化:即使当前不可见也先把窗口挪到新锚点,避免下次出现时"从旧位置飞过来"
if (this.state.mode === 'hidden' && before.position !== this.appearance.position) {
this.syncWindow();
}
logger.info( logger.info(
`灵动岛外观已更新:${this.appearance.width}x${this.appearance.height} 圆角=${this.appearance.radius} ` + `灵动岛外观已更新:${this.appearance.width}x${this.appearance.height} 圆角=${this.appearance.radius} ` +
`透明度=${this.appearance.opacity} 位置=${this.appearance.position} 风格=${this.appearance.style} ` + `透明度=${this.appearance.opacity} 位置=${this.appearance.position} 风格=${this.appearance.style} ` +
@@ -847,6 +771,31 @@ class IslandController {
return this.backgroundMaterial; 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 { getAppearance(): IslandAppearance {
return { ...this.appearance }; return { ...this.appearance };
} }
@@ -873,18 +822,24 @@ export function recomputeSizes(appearance: IslandAppearance): void {
}; };
URGENT_SIZE = grow(BASE.urgent); URGENT_SIZE = grow(BASE.urgent);
CALL_SIZE = grow(BASE.call); CALL_SIZE = grow(BASE.call);
// 空闲细缝:卡片宽固定 6px(WinIsland hidden_width 的思路),窗口 = 卡片 + 内边距。 // 空闲细缝:宽固定 6px(WinIsland hidden_width 的思路),高取胶囊高度的一部分。
// 注意:Windows 对(透明无边框)窗口有约 36px 的**最小高度**限制—— // 岛在固定窗口内形变,因此这里就是**岛的尺寸**,不受窗口最小高度限制。
// 实测请求 32px 时实际得到 36px,因此这里直接以 36px 为下限,避免断言/预览与真实不一致。
const sliverCardHeight = Math.max(16, Math.min(28, appearance.height - 22));
SLIVER_SIZE = { SLIVER_SIZE = {
width: Math.min(ISLAND_SLIVER_WIDTH + CARD_PAD_X * 2, appearance.width), width: ISLAND_SLIVER_WIDTH,
height: Math.max(MIN_WINDOW_HEIGHT, sliverCardHeight + CARD_PAD_Y * 2), 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 } { 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 一致)。 * 岛在固定窗口内的留白(= 阴影余量,见 `ISLAND_SHADOW_PAD`)。
* 取 5px 是为了给展开态的投影(WinIsland: y+2、σ=3)留出不被窗口裁掉的余量。 * 窗口尺寸 = 最大形态 + 2×该留白,因此投影永远不会被窗口裁掉。
*/ */
const CARD_PAD_X = 5;
const CARD_PAD_Y = 5;
/** 灵动岛与屏幕工作区边缘的留白 */ /** 灵动岛与屏幕工作区边缘的留白 */
const ISLAND_MARGIN = 8; const ISLAND_MARGIN = 8;
/** /**
* Windows 对窗口最小高度的实际限制(实测:请求 32px 会得到 36px)。 * 说明:Windows 对窗口最小高度有约 36px 的限制,但**只影响窗口**。
* 空闲细缝等"极小形态"必须以此作为高度下限,否则断言与真实窗口会不一致。 * 新版架构里窗口是固定包围盒、岛在窗口内形变,因此"空闲细缝"可以真正做到 6px 宽/十几像素高
* (与 WinIsland 的 hidden_width=5 一致),不再受该限制。
*/ */
const MIN_WINDOW_HEIGHT = 36;
/** /**
* 失焦收起的宽限期:刚展开的 500ms 内忽略 blur(避免"刚弹出就被抢焦点导致瞬间收起")。 * 失焦收起的宽限期:刚展开的 500ms 内忽略 blur(避免"刚弹出就被抢焦点导致瞬间收起")。
* 冒烟里"点击屏幕任意位置收起"的用例会在展开后等待约 700ms 再触发 blur,仍能正常验证收起。 * 冒烟里"点击屏幕任意位置收起"的用例会在展开后等待约 700ms 再触发 blur,仍能正常验证收起。
@@ -951,12 +904,6 @@ interface IslandAnchor {
vValue: number; vValue: number;
} }
/** 卡片宽度取偶数:保证水平居中的卡片中心落在整数像素上,形变时不左右跳变 */
function evenWidth(value: number): number {
const safe = Math.max(2, Math.round(value));
return safe - (safe % 2);
}
export const island = new IslandController(); export const island = new IslandController();
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */ /** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
@@ -982,6 +929,11 @@ export function registerIslandIpc(): void {
ipcMain.handle('island:get-appearance', () => island.getAppearance()); 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) => { ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
if (!payload?.action) return; if (!payload?.action) return;
island.handleAction(payload); island.handleAction(payload);
+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 { interface IslandShotStats {
name: string; name: string;
/** 期望的窗口逻辑尺寸(CSS 像素) */ /** 期望的窗口逻辑尺寸(CSS 像素) */
@@ -35,6 +52,8 @@ interface IslandShotStats {
reddish: number; reddish: number;
/** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */ /** 图像最外圈(2px)的偏红像素数:必须为 0,用于守住"卡片外不允许有光晕外溢" */
edgeReddish: number; edgeReddish: number;
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
outsideReddish?: number;
filePath: string; filePath: string;
} }
@@ -47,6 +66,8 @@ function analyzeBitmap(image: NativeImage): {
uniqueColors: number; uniqueColors: number;
reddish: number; reddish: number;
edgeReddish: number; edgeReddish: number;
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
outsideReddish?: number;
} { } {
const bitmap = image.toBitmap(); const bitmap = image.toBitmap();
const { width, height } = image.getSize(); const { width, height } = image.getSize();
@@ -98,30 +119,112 @@ async function captureIsland(
await win.webContents.executeJavaScript( await win.webContents.executeJavaScript(
`document.documentElement.style.background = '#1f2733'; document.body.style.background = '#1f2733'; true`, `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 image = await win.webContents.capturePage();
// 紧接截图之后读几何:让"矩形"与"画面"尽可能来自同一时刻
const geometry = await readIslandGeometryFrom(win);
const union = unionIslandRect(geometry, geometry);
await win.webContents.executeJavaScript( await win.webContents.executeJavaScript(
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`, `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`) : ''; const filePath = dir ? path.join(dir, `${name}.png`) : '';
if (filePath) { if (filePath) {
fs.mkdirSync(dir, { recursive: true }); 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 = { const stats: IslandShotStats = {
name, name,
expected, expected,
width: size.width, width: size.width,
height: size.height, height: size.height,
filePath, filePath,
...analyzeBitmap(image), ...analyzeBitmap(cropped),
outsideReddish,
}; };
islandShots.push(stats); 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( console.log(
`[SMOKE] 灵动岛截图:${filePath}(${stats.width}x${stats.height} 不透明=${stats.opaque} ` + `[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; return stats;
} catch (error) { } 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 结构(排查空白窗口) */ /** 打印灵动岛渲染进程的真实 DOM 结构(排查空白窗口) */
async function dumpIslandDom(_label: string): Promise<string> { async function dumpIslandDom(_label: string): Promise<string> {
const win = island.getWindow(); const win = island.getWindow();
@@ -212,6 +436,48 @@ async function runIslandChecks(
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`, `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) 下课:自动弹出暂存通知的详情 // 2) 下课:自动弹出暂存通知的详情
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(900); await sleep(900);
@@ -228,8 +494,12 @@ async function runIslandChecks(
await sleep(300); await sleep(300);
const sampledWidths: number[] = []; const sampledWidths: number[] = [];
const sampler = setInterval(() => { 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); }, 16);
island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), { island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), {
inClass: true, inClass: true,
@@ -245,13 +515,14 @@ async function runIslandChecks(
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`, `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 = [ const rampWidths = [
...new Set(sampledWidths.filter((width) => width > 0 && width < ISLAND_URGENT_SIZE.width)), ...new Set(sampledWidths.filter((width) => width > 0 && width < ISLAND_URGENT_SIZE.width)),
]; ];
record( 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 ?? '-'}`, `采样=${sampledWidths.length} 中间尺寸=${rampWidths.length} 最终=${urgentBounds?.width ?? '-'}x${urgentBounds?.height ?? '-'}`,
); );
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE); await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
@@ -275,16 +546,17 @@ async function runIslandChecks(
inClass: false, inClass: false,
}); });
await sleep(60); await sleep(60);
const immediateBounds = islandWindow?.getBounds(); const immediateGeometry = await readIslandGeometry();
const initialIsland = immediateGeometry?.island ?? null;
await sleep(640); await sleep(640);
const pillState = island.getState(); const pillState = island.getState();
record( record(
'普通通知直接显示胶囊(无"上岛"入场动画)', '普通通知直接显示胶囊(无"上岛"入场动画)',
hiddenBeforePush && hiddenBeforePush &&
pillState.mode === 'pill' && pillState.mode === 'pill' &&
immediateBounds?.width === ISLAND_SIZES.pill.width && Math.abs((initialIsland?.width ?? 0) - ISLAND_SIZES.pill.width) <= 2 &&
immediateBounds?.height === ISLAND_SIZES.pill.height, Math.abs((initialIsland?.height ?? 0) - ISLAND_SIZES.pill.height) <= 2,
`推送前隐藏=${hiddenBeforePush} 60ms 内窗口=${immediateBounds?.width ?? '-'}x${immediateBounds?.height ?? '-'}` + `推送前隐藏=${hiddenBeforePush} 60ms 内岛=${initialIsland?.width?.toFixed(1) ?? '-'}x${initialIsland?.height?.toFixed(1) ?? '-'}` +
`(期望 ${ISLAND_SIZES.pill.width}x${ISLAND_SIZES.pill.height})mode=${pillState.mode}`, `(期望 ${ISLAND_SIZES.pill.width}x${ISLAND_SIZES.pill.height})mode=${pillState.mode}`,
); );
await captureIsland('island-4-pill', ISLAND_SIZES.pill); await captureIsland('island-4-pill', ISLAND_SIZES.pill);
@@ -357,23 +629,28 @@ async function runIslandChecks(
`chevronClicked=${chevronClicked} mode=${afterChevron.mode}`, `chevronClicked=${chevronClicked} mode=${afterChevron.mode}`,
); );
// 4.4) 收回过程中卡片不能出现"被拉扁的方框": // 4.4) 新架构(照搬 WinIsland):窗口是固定包围盒,开合过程中**窗口一帧都不动**,
// 采样卡片尺寸,必须始终是固定的胶囊/详情尺寸之一,绝不出现中间值。 // 岛在窗口内做弹簧形变。这里逐帧采样"窗口 + 岛":窗口必须完全恒定,岛形变必须连续。
island.handleAction({ action: 'expand' }); island.handleAction({ action: 'expand' });
await sleep(700); await sleep(700);
const shrinkSamples: string[] = []; const shrinkSamples: {
window: string;
island: string;
radius: number;
pathWidth: number;
pathHeight: number;
}[] = [];
const shrinkSampler = setInterval(() => { const shrinkSampler = setInterval(() => {
void islandWindow?.webContents void readIslandGeometry()
.executeJavaScript( .then((geometry) => {
`(() => { if (!geometry) return;
const node = document.querySelector('.island-card'); shrinkSamples.push({
if (!node) return ''; window: `${geometry.window.width}x${geometry.window.height}@${geometry.window.x},${geometry.window.y}`,
const rect = node.getBoundingClientRect(); island: `${Math.round(geometry.island.width)}x${Math.round(geometry.island.height)}`,
return Math.round(rect.width) + 'x' + Math.round(rect.height); radius: Number(geometry.island.radius ?? 0),
})()`, pathWidth: Number(geometry.island.pathWidth ?? 0),
) pathHeight: Number(geometry.island.pathHeight ?? 0),
.then((value: string) => { });
if (value) shrinkSamples.push(value);
}) })
.catch(() => undefined); .catch(() => undefined);
}, 40); }, 40);
@@ -381,20 +658,24 @@ async function runIslandChecks(
await sleep(700); await sleep(700);
clearInterval(shrinkSampler); clearInterval(shrinkSampler);
await sleep(120); await sleep(120);
// 允许的卡片尺寸由当前外观与各形态窗口尺寸推导(卡片 = 窗口 - 2*5px 内边距) const windowSignatures = [...new Set(shrinkSamples.map((item) => item.window))];
const cardLabel = (size: { width: number; height: number }): string => const islandSizes = shrinkSamples.map((item) => item.island);
`${size.width - 10}x${size.height - 10}`; const islandWidths = shrinkSamples.map((item) => Number(item.island.split('x')[0]));
const allowedSizes = new Set([ const islandMonotonic = islandWidths.every(
cardLabel(ISLAND_SIZES.pill), (width, index) => index === 0 || width <= (islandWidths[index - 1] ?? width) + 1,
cardLabel(ISLAND_SIZES.expanded), );
cardLabel(ISLAND_URGENT_SIZE), // 画出来的形状必须与卡片框一致:SVG 路径 bbox ≈ 卡片宽高(±2px)。
cardLabel(ISLAND_CALL_SIZE), // 这能一次性抓住"viewBox/preserveAspectRatio 错位导致形状被缩放或残留角"这类问题。
]); const pathFits = shrinkSamples.every(
const stretched = [...new Set(shrinkSamples)].filter((size) => !allowedSizes.has(size)); (item) =>
Math.abs(item.pathWidth - Number(item.island.split('x')[0])) <= 2 &&
Math.abs(item.pathHeight - Number(item.island.split('x')[1])) <= 2,
);
record( record(
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)', '开合过程中窗口全程恒定、岛在窗口内连续形变(照搬 WinIsland 架构)',
shrinkSamples.length >= 3 && stretched.length === 0, shrinkSamples.length >= 3 && windowSignatures.length === 1 && islandMonotonic && pathFits,
`采样=${shrinkSamples.length} 尺寸集合=[${[...new Set(shrinkSamples)].join(',')}] 异常尺寸=[${stretched.join(',')}]`, `采样=${shrinkSamples.length} 窗口签名=[${windowSignatures.join(' ')}](要求只有 1 种) ` +
`岛尺寸序列=[${islandSizes.slice(0, 8).join('→')}${islandSizes.length > 8 ? '…' : ''}] 单调收缩=${islandMonotonic} 形状与卡片框一致=${pathFits}`,
); );
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊 // 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
@@ -508,7 +789,7 @@ async function runIslandChecks(
text: text:
`${shot.name} ${shot.width}x${shot.height}(期望 ${shot.expected.width}x${shot.expected.height}, ` + `${shot.name} ${shot.width}x${shot.height}(期望 ${shot.expected.width}x${shot.expected.height}, ` +
`比例偏差 ${ratioDelta.toFixed(3)}) 不透明=${shot.opaque} 颜色=${shot.uniqueColors} ` + `比例偏差 ${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 wrongRatio = shotDetails.filter((shot) => shot.ratioDelta > 0.05);
const urgentShot = shotDetails.find((shot) => shot.name.includes('urgent')); const urgentShot = shotDetails.find((shot) => shot.name.includes('urgent'));
const urgentReddish = urgentShot?.reddish ?? 0; 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 pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
const sizesOrdered = Boolean( const sizesOrdered = Boolean(
pillShot && pillShot &&
@@ -538,11 +819,11 @@ async function runIslandChecks(
` 形态尺寸递增=${sizesOrdered}`, ` 形态尺寸递增=${sizesOrdered}`,
); );
// 7) 光晕只在卡片内部:截图最外圈(窗口留白)不允许出现红色外溢像素 // 7) 光晕只在岛内部:固定的窗口包围盒里,岛之外的留白区不允许出现红色外溢像素
record( record(
'紧急红晕不外溢(窗口边缘无红色光晕像素)', '紧急光晕不外溢(岛之外的窗口留白区无红色像素)',
urgentShot !== undefined && edgeReddish === 0 && urgentReddish > 200, urgentShot !== undefined && outsideReddish === 0 && urgentReddish > 200,
`紧急态偏红=${urgentReddish} 最外圈偏红=${edgeReddish}(要求 0)`, `紧急态岛内偏红=${urgentReddish} 岛外留白偏红=${outsideReddish}(要求 0)`,
); );
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局 // 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
@@ -554,7 +835,7 @@ async function runIslandChecks(
inClass: false, inClass: false,
}); });
await sleep(600); await sleep(600);
const pillBounds = islandWindow?.getBounds(); const pillBounds = (await readIslandGeometry())?.island ?? null;
const cssVars = await islandWindow?.webContents.executeJavaScript( const cssVars = await islandWindow?.webContents.executeJavaScript(
`(() => { `(() => {
const style = getComputedStyle(document.documentElement); const style = getComputedStyle(document.documentElement);
@@ -610,16 +891,14 @@ async function runIslandChecks(
await sleep(700); await sleep(700);
const jitterSamples: { win: number; cardWidth: number; cardTop: number }[] = []; const jitterSamples: { win: number; cardWidth: number; cardTop: number }[] = [];
const jitterSampler = setInterval(() => { const jitterSampler = setInterval(() => {
const winWidth = islandWindow?.getBounds().width ?? 0; void readIslandGeometry()
void islandWindow?.webContents .then((geometry) => {
.executeJavaScript( if (!geometry) return;
`(() => { jitterSamples.push({
const card = document.querySelector('.island-card'); win: geometry.window.width,
return card ? { width: card.offsetWidth, top: card.offsetTop } : null; cardWidth: Math.round(geometry.island.width),
})()`, cardTop: Math.round(geometry.island.y),
) });
.then((value: { width: number; top: number } | null) => {
if (value) jitterSamples.push({ win: winWidth, cardWidth: value.width, cardTop: value.top });
}) })
.catch(() => undefined); .catch(() => undefined);
}, 30); }, 30);
@@ -628,19 +907,18 @@ async function runIslandChecks(
clearInterval(jitterSampler); clearInterval(jitterSampler);
const winSequence = jitterSamples.map((item) => item.win); const winSequence = jitterSamples.map((item) => item.win);
const monotonicShrink = winSequence.every( const windowConstant = new Set(winSequence).size <= 1;
(width, index) => index === 0 || width <= (winSequence[index - 1] ?? width) + 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 cardTops = jitterSamples.map((item) => item.cardTop);
const topSpread = cardTops.length > 0 ? Math.max(...cardTops) - Math.min(...cardTops) : 99; 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( record(
'收回过程不抖动(窗口宽度单调收缩 + 卡片布局尺寸不拉伸)', '收回过程不抖动(窗口恒定 + 岛宽度单调收缩 + 岛顶部不动)',
jitterSamples.length >= 4 && monotonicShrink && topSpread <= 1 && widthOk && cardWidths.length <= 2, jitterSamples.length >= 4 && windowConstant && monotonicShrink && topSpread <= 1,
`采样=${jitterSamples.length} 窗口序列=[${winSequence.join(',')}] 单调收缩=${monotonicShrink} ` + `采样=${jitterSamples.length} 窗口宽度集合=[${[...new Set(winSequence)].join(',')}](要求 1 个) ` +
`卡片上边缘波动=${topSpread}px 卡片宽度集合=[${cardWidths.join(',')}](期望 ${expectedWidths.join('/')})`, `岛宽度序列=[${islandSequence.join(',')}] 单调收缩=${monotonicShrink} 岛上边缘波动=${topSpread}px`,
); );
// 10) 连续快速开合:最终状态与尺寸必须稳定 // 10) 连续快速开合:最终状态与尺寸必须稳定
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
@@ -651,14 +929,14 @@ async function runIslandChecks(
} }
await sleep(600); await sleep(600);
const rapidState = island.getState(); const rapidState = island.getState();
const rapidBounds = islandWindow?.getBounds(); const rapidBounds = (await readIslandGeometry())?.island ?? null;
const currentAppearance = island.getAppearance(); const currentAppearance = island.getAppearance();
record( record(
'连续快速展开/收起后状态与尺寸稳定', '连续快速展开/收起后状态与尺寸稳定',
rapidState.mode === 'pill' && rapidState.mode === 'pill' &&
rapidBounds?.width === currentAppearance.width && Math.abs((rapidBounds?.width ?? 0) - currentAppearance.width) <= 2 &&
rapidBounds?.height === currentAppearance.height, Math.abs((rapidBounds?.height ?? 0) - currentAppearance.height) <= 2,
`mode=${rapidState.mode} 窗口=${rapidBounds?.width ?? '-'}x${rapidBounds?.height ?? '-'}` + `mode=${rapidState.mode} 岛=${rapidBounds?.width?.toFixed(1) ?? '-'}x${rapidBounds?.height?.toFixed(1) ?? '-'}` +
`(期望 ${currentAppearance.width}x${currentAppearance.height})`, `(期望 ${currentAppearance.width}x${currentAppearance.height})`,
); );
@@ -712,8 +990,8 @@ async function runIslandChecks(
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false }); island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
await sleep(350); await sleep(350);
const expandedCard = { width: ISLAND_SIZES.expanded.width - 10, height: ISLAND_SIZES.expanded.height - 10 }; const expandedCard = { width: ISLAND_SIZES.expanded.width, height: ISLAND_SIZES.expanded.height };
const pillCard = { width: ISLAND_SIZES.pill.width - 10, height: ISLAND_SIZES.pill.height - 10 }; const pillCard = { width: ISLAND_SIZES.pill.width, height: ISLAND_SIZES.pill.height };
await runMotionProbe('expand'); await runMotionProbe('expand');
const expandSamples = [...motionSamples]; const expandSamples = [...motionSamples];
@@ -729,24 +1007,24 @@ async function runIslandChecks(
const maxCardH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardH)) : 0; 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 maxWinW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winW)) : 0;
const maxWinH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winH)) : 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( const expandMonotonic = expandSamples.every(
(item, index) => index === 0 || item.winW >= (expandSamples[index - 1]?.winW ?? 0) - 1, (item, index) => index === 0 || item.winW >= (expandSamples[index - 1]?.winW ?? 0) - 1,
); );
const expandNoOvershoot = const expandNoOvershoot = maxCardW <= expandedCard.width + 0.5 && maxCardH <= expandedCard.height + 0.5;
maxCardW <= expandedCard.width + 0.5 &&
maxCardH <= expandedCard.height + 0.5 &&
maxWinW <= ISLAND_SIZES.expanded.width + 1 &&
maxWinH <= ISLAND_SIZES.expanded.height + 1;
record( record(
'展开过程不震动(锚点不动 + 尺寸不过冲)', '展开过程不震动(窗口冻结 + 岛锚点不动 + 尺寸不过冲)',
expandSamples.length >= 5 && expandSamples.length >= 5 &&
windowFrozen &&
cxSpread <= 0.5 && cxSpread <= 0.5 &&
topSpreadExpand <= 0.5 && topSpreadExpand <= 0.5 &&
expandMonotonic && expandMonotonic &&
expandNoOvershoot, expandNoOvershoot,
`采样=${expandSamples.length} 卡片中心波动=${cxSpread.toFixed(2)}px 上边缘波动=${topSpreadExpand.toFixed(2)}px ` + `采样=${expandSamples.length} 窗口=${maxWinW}x${maxWinH}(全程冻结=${windowFrozen}) ` +
`最大卡片=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height})` + `岛中心波动=${cxSpread.toFixed(2)}px 岛上边缘波动=${topSpreadExpand.toFixed(2)}px ` +
` 最大窗口=${maxWinW}x${maxWinH}(目标 ${ISLAND_SIZES.expanded.width}x${ISLAND_SIZES.expanded.height}) 单调=${expandMonotonic}`, `最大岛=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height}) 单调=${expandMonotonic}`,
); );
await runMotionProbe('collapse'); await runMotionProbe('collapse');
@@ -759,22 +1037,26 @@ async function runIslandChecks(
? Math.max(...collapseSamples.map((item) => item.cardTop)) - ? Math.max(...collapseSamples.map((item) => item.cardTop)) -
Math.min(...collapseSamples.map((item) => item.cardTop)) Math.min(...collapseSamples.map((item) => item.cardTop))
: 99; : 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 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( const collapseMonotonic = collapseSamples.every(
(item, index) => (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( record(
'收回过程不震动(锚点不动 + 尺寸不过冲)', '收回过程不震动(窗口冻结 + 岛锚点不动 + 尺寸不过冲)',
collapseSamples.length >= 5 && collapseSamples.length >= 5 &&
collapseWindowFrozen &&
cxSpreadCollapse <= 0.5 && cxSpreadCollapse <= 0.5 &&
topSpreadCollapse <= 0.5 && topSpreadCollapse <= 0.5 &&
collapseMonotonic && collapseMonotonic &&
collapseNoOvershoot, collapseNoOvershoot,
`采样=${collapseSamples.length} 卡片中心波动=${cxSpreadCollapse.toFixed(2)}px 上边缘波动=${topSpreadCollapse.toFixed(2)}px ` + `采样=${collapseSamples.length} 窗口全程冻结=${collapseWindowFrozen} ` +
`最小卡片宽=${minCardW.toFixed(1)}(目标 ${pillCard.width}) 最小窗口宽=${minWinW}(目标 ${ISLAND_SIZES.pill.width}) 单调=${collapseMonotonic}`, `岛中心波动=${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=${glassStyle.fill} 材质=${glassStyle.material};` +
`主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.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' }); island.setAppearance({ ...appearanceBefore, style: 'black' });
await sleep(200); await sleep(200);
@@ -916,15 +1233,15 @@ async function runIslandChecks(
island.setAppearance({ ...appearanceBefore, idleSliver: true }); island.setAppearance({ ...appearanceBefore, idleSliver: true });
await drainIsland(); await drainIsland();
await sleep(420); await sleep(420);
const sliverBounds = islandWindow?.getBounds(); const sliverBounds = (await readIslandGeometry())?.island ?? null;
island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false }); island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false });
await sleep(500); await sleep(700);
const afterSliverNotification = islandWindow?.getBounds(); const afterSliverNotification = (await readIslandGeometry())?.island ?? null;
record( record(
'空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)', '空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)',
sliverBounds?.width === sliver.width && Math.abs((sliverBounds?.width ?? 0) - sliver.width) <= 1 &&
sliverBounds?.height === sliver.height && Math.abs((sliverBounds?.height ?? 0) - sliver.height) <= 1 &&
afterSliverNotification?.width === island.getAppearance().width, Math.abs((afterSliverNotification?.width ?? 0) - island.getAppearance().width) <= 2,
`细缝=${sliverBounds?.width ?? '-'}x${sliverBounds?.height ?? '-'}(期望 ${sliver.width}x${sliver.height})` + `细缝=${sliverBounds?.width ?? '-'}x${sliverBounds?.height ?? '-'}(期望 ${sliver.width}x${sliver.height})` +
` 来消息后=${afterSliverNotification?.width ?? '-'}x${afterSliverNotification?.height ?? '-'} ${islandStateSummary()}`, ` 来消息后=${afterSliverNotification?.width ?? '-'}x${afterSliverNotification?.height ?? '-'} ${islandStateSummary()}`,
); );
@@ -993,7 +1310,7 @@ async function runIslandChecks(
for (const item of positionCases) { for (const item of positionCases) {
island.setAppearance({ ...appearanceBefore, position: item.position }); island.setAppearance({ ...appearanceBefore, position: item.position });
await sleep(450); await sleep(450);
const bounds = islandWindow?.getBounds(); const bounds = (await readIslandGeometry())?.island ?? null;
const matched = Boolean(bounds) && item.verify(bounds as Electron.Rectangle); const matched = Boolean(bounds) && item.verify(bounds as Electron.Rectangle);
if (!matched) positionOk = false; if (!matched) positionOk = false;
positionDetails.push( positionDetails.push(
@@ -1024,7 +1341,7 @@ async function runIslandChecks(
await sleep(300); await sleep(300);
island.handleAction({ action: 'expand' }); island.handleAction({ action: 'expand' });
await sleep(60); await sleep(60);
const instantBounds = islandWindow?.getBounds(); const instantBounds = (await readIslandGeometry())?.island ?? null;
const expectedExpanded = ISLAND_SIZES.expanded; const expectedExpanded = ISLAND_SIZES.expanded;
const animationsOff = island.getAppearance().animations === false; const animationsOff = island.getAppearance().animations === false;
record( record(
@@ -1038,14 +1355,15 @@ async function runIslandChecks(
const measureCollapse = async (speed: number): Promise<number> => { const measureCollapse = async (speed: number): Promise<number> => {
island.setAppearance({ ...appearanceBefore, animations: true, speed }); island.setAppearance({ ...appearanceBefore, animations: true, speed });
island.handleAction({ action: 'expand' }); island.handleAction({ action: 'expand' });
await sleep(500); await sleep(900);
const started = Date.now(); const started = Date.now();
island.handleAction({ action: 'collapse' }); island.handleAction({ action: 'collapse' });
const target = island.getAppearance().width; const target = island.getAppearance().width;
const deadline = started + 2500; const deadline = started + 3000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const width = islandWindow?.getBounds().width ?? 0; const geometry = await readIslandGeometry();
if (Math.abs(width - target) <= 1) break; const width = geometry?.island.width ?? 0;
if (Math.abs(width - target) <= 1.5) break;
await sleep(16); await sleep(16);
} }
return Date.now() - started; return Date.now() - started;
@@ -12,6 +12,8 @@ export interface IslandBridge {
/** 订阅个性化外观(设置页改动后实时生效) */ /** 订阅个性化外观(设置页改动后实时生效) */
onAppearance(handler: (appearance: IslandAppearance) => void): void; onAppearance(handler: (appearance: IslandAppearance) => void): void;
getAppearance(): Promise<IslandAppearance>; getAppearance(): Promise<IslandAppearance>;
/** 命中测试结果:指针是否在岛体上(决定窗口是否接收鼠标) */
setInteractive(interactive: boolean): void;
} }
const bridge: IslandBridge = { const bridge: IslandBridge = {
@@ -26,6 +28,9 @@ const bridge: IslandBridge = {
ipcRenderer.on('island:appearance', (_event, appearance: IslandAppearance) => handler(appearance)); ipcRenderer.on('island:appearance', (_event, appearance: IslandAppearance) => handler(appearance));
}, },
getAppearance: () => ipcRenderer.invoke('island:get-appearance'), getAppearance: () => ipcRenderer.invoke('island:get-appearance'),
setInteractive: (interactive) => {
ipcRenderer.send('island:set-interactive', interactive);
},
}; };
contextBridge.exposeInMainWorld('island', bridge); contextBridge.exposeInMainWorld('island', bridge);
+2
View File
@@ -73,6 +73,8 @@ export interface IslandRendererBridge {
getAppearance(): Promise<IslandAppearance>; getAppearance(): Promise<IslandAppearance>;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
getState(): Promise<IslandState>; getState(): Promise<IslandState>;
/** 命中测试结果:指针是否在岛体上(决定固定大窗口是否接收鼠标) */
setInteractive(interactive: boolean): void;
} }
declare global { declare global {
+15
View File
@@ -129,6 +129,21 @@ export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string>
*/ */
export const ISLAND_SLIVER_WIDTH = 6; export const ISLAND_SLIVER_WIDTH = 6;
/**
* 各形态相对"胶囊"的尺寸增量(**单一事实来源**,主进程与渲染进程共用):
* - 主进程据此计算"固定包围盒窗口"有多大(窗口只为最大形态留位置,开合时不改窗口);
* - 渲染进程据此计算岛的目标尺寸并做弹簧形变。
* 取值参考 WinIsland:胶囊 120×27 → 展开 360×200(宽 ×3、高 ×7.4),我们按内容需要取略小的比例。
*/
export const ISLAND_SIZE_DELTA = {
expanded: { width: 156, height: 186 },
urgent: { width: 172, height: 202 },
call: { width: 188, height: 218 },
} as const;
/** 阴影/光晕留白(窗口比最大形态多出的透明边,保证投影不被窗口裁掉) */
export const ISLAND_SHADOW_PAD = 10;
/** 岛内文本的排版系数(相对基础字号,集中在这里方便统一调整) */ /** 岛内文本的排版系数(相对基础字号,集中在这里方便统一调整) */
export const ISLAND_TYPE_SCALE = { export const ISLAND_TYPE_SCALE = {
/** 展开卡标题 */ /** 展开卡标题 */