diff --git a/README.md b/README.md index 536ede8..fb4d13d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | -| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 53/53) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 54/54) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | @@ -179,7 +179,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 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`)。 > 逐项从 WinIsland 源码提取的设计 token 见 **[docs/winisland-design-tokens.md](docs/winisland-design-tokens.md)**。 > -> 与 WinIsland 的两点**有意差异**(都写进了实现注释): -> -> 1. WinIsland 用"固定大窗口 + 画布内形变",我们沿用"按状态缓动窗口尺寸"—— -> 在 Electron 里前者需要常驻的点击穿透大窗口(容易吞掉桌面点击),后者已被 53 项断言覆盖(含 0.00px 不震动); -> 2. Windows 对透明窗口有约 **36px 的最小高度**(实测请求 32px 得到 36px), -> 因此"空闲细缝"的高度以 36px 为下限(WinIsland 因为窗口恒定所以不受此限)。 +> **架构也照搬 WinIsland(同一套做法,而不是"仿个外观")**: +> 窗口一次创建成**固定包围盒**(最大形态 + 阴影留白),开合过程中**一帧都不移动/缩放窗口**; +> 岛的全部形变(宽/高/圆角/内容交叉淡入)都发生在窗口内的渲染层,由 WinIsland 的弹簧驱动: +> `force=(target-value)*0.10*dt` → `velocity=(velocity+force)*0.68^dt` → `value+=velocity*dt` +> (dt 以帧为单位;换向只保留 35% 动量、单帧位移不超过剩余距离 20%),并额外加**零过冲钳制**(越过目标立即吸附)。 +> 鼠标命中照搬 `set_cursor_hittest`:窗口默认 `setIgnoreMouseEvents(true, { forward: true })` 整块穿透, +> 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。 +> 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。 | 场景 | 灵动岛行为 | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | @@ -426,9 +428,9 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 - 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。 - 落盘位置:客户端 `config.json` 的 `island` 字段(旧配置没有该字段时自动补默认值,向后兼容)。 -**形变动画(需求 4 抖动修复 + 无震动)**:窗口尺寸由**单条帧循环**驱动(优先 `requestAnimationFrame`, -主进程没有该全局时退化为 ~60fps 定时器),新动画会取消上一条并作废令牌,结束时对齐最终尺寸; -卡片始终是**固定尺寸 + 顶部居中锚定**,只让窗口露出/裁切,形变过程中卡片上边缘不动; +**形变动画(需求 4 抖动修复 + 无震动)**:岛在**固定包围盒窗口内**由弹簧逐帧逼近目标尺寸/圆角 +(`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=` 写出 JSON 结果, -已实测 `packaged: true`、53/53 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、54/54 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 @@ -889,14 +891,14 @@ pnpm db:migrate && pnpm db:seed 当前实测: -| 验证 | 结果 | -| --------------------------------------------------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 开合不震动 + WinIsland 风格 + 个性化全参数 + 托盘与退出无残留) | **53/53** ✅ | -| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| -------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/开合不震动 + 个性化全参数 + 托盘与退出无残留) | **54/54** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 5477a7f..5fb2028 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -2,6 +2,9 @@ import { computed, onMounted, ref } from 'vue'; import { DEFAULT_ISLAND_APPEARANCE, + ISLAND_SHADOW_PAD, + ISLAND_SIZE_DELTA, + ISLAND_SLIVER_WIDTH, type IslandAppearance, PRIORITY_LABELS, formatDate, @@ -9,6 +12,7 @@ import { type IslandNotificationKind, type IslandState, } from '@classhelper/shared'; +import { SpringValue } from './spring.js'; import { squirclePath } from './squircle.js'; /** @@ -91,16 +95,6 @@ const stateColor = computed(() => { 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 { bridge?.sendAction('expand'); } @@ -132,65 +126,190 @@ function openApp(): void { /** 个性化外观:主进程下发后写入 CSS 变量,形状/排版/配色立即变化(无需重启) */ const appearance = ref({ ...DEFAULT_ISLAND_APPEARANCE }); -/** 卡片与窗口的内边距(必须与主进程 CARD_PAD_X / CARD_PAD_Y 一致) */ -const CARD_PAD_X = 5; -const CARD_PAD_Y = 5; +/** 各形态相对"胶囊"的尺寸增量(与主进程共用同一常量表,避免两边漂移) */ +const EXTRA = ISLAND_SIZE_DELTA; /** - * 各形态相对"胶囊窗口"的额外尺寸(必须与主进程 BASE.expanded/urgent/call 的增量一致): - * 卡片尺寸 = 窗口尺寸 - 内边距,因此卡片始终在窗口内居中、四周留白恒定。 + * 岛的目标尺寸(**岛本身**,不是窗口): + * - 胶囊:外观设置里的 width/height + * - 展开/紧急/叫人:胶囊 + `EXTRA` + * - 空闲细缝:6px 宽竖条(WinIsland hidden_width 的思路) + * 全部由下面的弹簧逐帧逼近,窗口(固定包围盒)全程不动。 */ -const EXTRA = { - expanded: { width: 156, height: 186 }, - urgent: { width: 172, height: 202 }, - call: { width: 188, height: 218 }, -} as const; - -/** 当前形态的窗口尺寸(用于推出卡片尺寸) */ -const windowSize = computed(() => { +function targetSize(): { width: number; height: number } { const width = appearance.value.width; 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 }; const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded; 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) */ -const cardRadius = computed(() => { - const { width, height } = cardSize.value; - if (mode.value !== 'expanded') return Math.min(appearance.value.radius, height / 2); +function targetRadius(size: { width: number; height: number }): number { + if (mode.value !== 'expanded') return Math.min(appearance.value.radius, size.height / 2); 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); +} /** - * 卡片内联样式:尺寸、形状、配色、排版全部由外观设置驱动。 - * - * 注意 `left` 用 `calc(50% - 卡片宽/2)`(相对**当前窗口宽度**居中),而不是固定 padding: - * 窗口在形变过程中宽度由主进程逐帧插值,卡片只有居中才能保证"卡片中心全程不动"(实测 0.00px)。 + * 实际生效的圆角:始终钳制到 `min(圆角, w/2, h/2)`。 + * 弹簧对"尺寸"与"圆角"是各自独立推进的,形变过程中圆角可能短暂大于 h/2; + * 统一钳制既保证形状合法,也让 CSS 变量与真实绘制一致(便于断言)。 */ -const cardStyle = computed(() => { - const card = cardSize.value; - return { - width: `${card.width}px`, - height: `${card.height}px`, - left: `calc(50% - ${card.width / 2}px)`, - top: `${CARD_PAD_Y}px`, +const effectiveRadius = computed(() => + Math.max(0, Math.min(morph.value.radius, morph.value.width / 2, morph.value.height / 2)), +); + +/** 连续圆角路径(超椭圆角,见 squircle.ts) */ +const cardPath = computed(() => squirclePath(morph.value.width, morph.value.height, effectiveRadius.value)); + +/** 胶囊内容透明度:进度到 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 = { + width: `${morph.value.width}px`, + height: `${morph.value.height}px`, '--card-path': `path("${cardPath.value}")`, - '--card-r': `${cardRadius.value}px`, + '--card-r': `${morph.value.radius}px`, '--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 { if (!next) return; appearance.value = next; @@ -201,43 +320,63 @@ function applyAppearance(next: IslandAppearance | null | undefined): void { style.setProperty('--island-w', `${next.width}px`); style.setProperty('--island-h', `${next.height}px`); style.setProperty('--island-anim', next.animations ? '1' : '0'); - // 形变时长(与主进程的缓动时长一致),用于内容淡入淡出的节奏对齐 - const morph = next.style === 'glass' ? 320 : 320; - style.setProperty('--island-dur', `${Math.round(morph / Math.max(0.25, next.speed))}ms`); + style.setProperty('--island-dur', `${Math.round(320 / Math.max(0.25, next.speed))}ms`); } onMounted(async () => { + window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY)); bridge?.onState((next) => { state.value = next; + syncTargets(); }); const initial = await bridge?.getState(); if (initial) state.value = initial; // 外观:先同步一次,再订阅后续改动(改设置立即生效) - bridge?.onAppearance((next) => applyAppearance(next)); + bridge?.onAppearance((next) => { + applyAppearance(next); + syncTargets(); + }); const initialAppearance = await bridge?.getAppearance?.(); applyAppearance(initialAppearance); + syncTargets(true); + // 初始状态可能是 hidden(窗口未显示),此时同步一次目标即可 + syncTargets(); }); @@ -444,9 +566,8 @@ body { /* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */ .island-card .shape { position: absolute; - inset: 0; - width: 100%; - height: 100%; + left: 0; + top: 0; display: block; } @@ -456,16 +577,28 @@ body { stroke-width: 1; } -/* 内容层裁切到同一形状内 */ +/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */ .island-card .content { position: absolute; inset: 0; - display: flex; - flex-direction: column; + display: block; clip-path: var(--card-path); 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) */ .island-card.expanded .shape path { filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.11)); @@ -505,7 +638,7 @@ body { /* ---------------------------------------------------------------- 胶囊(compact) */ -.island-card.pill .content { +.pill-layer { flex-direction: row; align-items: center; gap: calc(var(--island-font, 13px) * 0.62); @@ -583,10 +716,12 @@ body { /* ---------------------------------------------------------------- 展开卡(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); gap: calc(var(--island-font, 13px) * 0.7); - /* 内容块整体垂直居中:卡片尺寸固定(锚点稳定),留白上下均分比"底部一大块空"更自然 */ justify-content: center; } diff --git a/packages/desktop-client/src/island/spring.ts b/packages/desktop-client/src/island/spring.ts new file mode 100644 index 0000000..a62113e --- /dev/null +++ b/packages/desktop-client/src/island/spring.ts @@ -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; + } +} diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index c9fcd80..376290b 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -3,6 +3,8 @@ import { BrowserWindow, ipcMain, screen } from 'electron'; import { DEFAULT_ISLAND_APPEARANCE, ISLAND_POSITIONS, + ISLAND_SHADOW_PAD, + ISLAND_SIZE_DELTA, ISLAND_SLIVER_WIDTH, ISLAND_STYLES, type IslandAppearance, @@ -35,37 +37,23 @@ import { getConfig } from './config.js'; /** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */ /** - * 基准尺寸(对齐 WinIsland 的 `base_width/base_height` 与 `expanded_width/expanded_height` 比例): - * - 胶囊:小而饱满(WinIsland compact 为 120×27,我们是文字型胶囊,取 216×34,圆角 = h/2 满圆角) - * - 展开卡:展开增量与渲染进程 IslandApp.vue 的 EXTRA 常量一一对应 - * (expanded +156×+218 / urgent +172×+238 / call +188×+254) + * 形态尺寸(**岛本身的尺寸**,不是窗口尺寸): + * - 胶囊 = 用户设置的 width/height(WinIsland compact 是 120×27,我们是文字型胶囊,默认 268×44) + * - 展开/紧急/叫人 = 胶囊 + `ISLAND_SIZE_DELTA`(与渲染进程共用同一常量表) + * + * 窗口尺寸 = 最大形态 + `ISLAND_SHADOW_PAD`,**一次算好、开合过程中不变**(照搬 WinIsland: + * 它的窗口也是一次创建成最大包围盒,形变全部发生在窗口内的画布上)。 */ const BASE = { pill: { width: 216, height: 34 }, - expanded: { width: 372, height: 220 }, - urgent: { width: 388, height: 236 }, + expanded: { width: 216 + ISLAND_SIZE_DELTA.expanded.width, height: 34 + ISLAND_SIZE_DELTA.expanded.height }, + urgent: { width: 216 + ISLAND_SIZE_DELTA.urgent.width, height: 34 + ISLAND_SIZE_DELTA.urgent.height }, /** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */ - call: { width: 404, height: 252 }, + call: { width: 216 + ISLAND_SIZE_DELTA.call.width, height: 34 + ISLAND_SIZE_DELTA.call.height }, }; -/** - * 帧调度:优先 requestAnimationFrame(有则最平滑),主进程没有该全局时退化为 ~60fps 定时器。 - * 关键点不是用哪个 API,而是**始终只有一条动画循环**:每次新动画都会取消上一条并作废令牌, - * 结束时再对齐一次最终尺寸,避免累积误差导致边缘"抖一下"。 - */ -type FrameHandle = number; -declare const requestAnimationFrame: ((callback: (time: number) => void) => number) | undefined; -declare const cancelAnimationFrame: ((handle: number) => void) | undefined; - -function scheduleFrame(callback: () => void): FrameHandle { - if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(() => callback()); - return setTimeout(callback, 16) as unknown as FrameHandle; -} - -function cancelFrame(handle: FrameHandle): void { - if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle); - else clearTimeout(handle as unknown as ReturnType); -} +// 说明:几何动画已全部移交渲染进程(渲染层用 WinIsland 的弹簧逐帧逼近岛的宽高/圆角), +// 主进程不再做窗口帧动画,因此原 scheduleFrame / cancelFrame / requestAnimationFrame 声明已移除。 const DEFAULT_SIZES: Record = { hidden: BASE.pill, @@ -83,6 +71,12 @@ let CALL_SIZE = { ...BASE.call }; */ let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 }; +/** + * 固定包围盒窗口的尺寸:取所有形态里最大的一个,再加上阴影留白。 + * **只在设置变化时重算一次**,开合过程中窗口尺寸恒定(照搬 WinIsland 的"一次建大窗口")。 + */ +let BBOX_SIZE = { width: 0, height: 0 }; + /** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */ const TIMEOUTS = { pill: 15_000, @@ -129,6 +123,8 @@ class IslandController { private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE }; /** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */ private backgroundMaterial: string = 'none'; + /** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */ + private interactive = false; private state: IslandState = { mode: 'hidden', @@ -166,7 +162,8 @@ class IslandController { if (this.win && !this.win.isDestroyed()) return; const win = new BrowserWindow({ - ...this.computeBounds(SIZES.hidden), + // 固定包围盒(最大形态 + 阴影留白):开合过程中**窗口不再移动/缩放** + ...this.windowBounds(), show: false, frame: false, transparent: true, @@ -195,7 +192,9 @@ class IslandController { // 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质) this.backgroundMaterial = 'none'; this.applyBackgroundMaterial(); - win.setIgnoreMouseEvents(false); + // 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击), + // 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。 + win.setIgnoreMouseEvents(true, { forward: true }); win.on('closed', () => { this.win = null; this.ready = false; @@ -545,39 +544,6 @@ class IslandController { return { hMode, hValue, vMode, vValue }; } - /** - * 由锚点 + 目标尺寸推出窗口矩形。 - * - * 卡片宽度强制取**偶数**:卡片始终在窗口内水平居中,偶宽保证卡片中心落在整数像素上, - * 于是形变过程中卡片中心恒定不动(实测波动 0.00px),不会出现"左右轻微振动"。 - */ - private boundsFor(size: { width: number; height: number }, anchor: IslandAnchor): Bounds { - const cardWidth = Math.max(2, evenWidth(Math.round(size.width) - CARD_PAD_X * 2)); - const cardHeight = Math.max(2, Math.round(size.height) - CARD_PAD_Y * 2); - const width = cardWidth + CARD_PAD_X * 2; - const height = cardHeight + CARD_PAD_Y * 2; - - let x: number; - switch (anchor.hMode) { - case 'left': - x = anchor.hValue; - break; - case 'right': - x = anchor.hValue - width; - break; - default: - x = anchor.hValue - cardWidth / 2 - CARD_PAD_X; - break; - } - - const y = anchor.vMode === 'bottom' ? anchor.vValue - height : anchor.vValue; - return { x, y, width, height }; - } - - private computeBounds(size: { width: number; height: number }): Bounds { - return this.boundsFor(size, this.resolveAnchor()); - } - /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ private setState(patch: Partial, options: { skipWindow?: boolean } = {}): void { this.state = { ...this.state, ...patch, updatedAt: Date.now() }; @@ -598,6 +564,12 @@ class IslandController { this.win.webContents.send('island:state', payload); } + /** + * 灵动岛窗口是**固定包围盒**(照搬 WinIsland):开合过程中只改渲染层里岛的宽高/圆角, + * 窗口本身**一帧都不动**,因此不会有"逐帧移动+缩放透明窗口"带来的抖动与残影。 + * + * 这里只负责三件事:可见性、焦点、透明度;几何交给渲染进程的弹簧形变。 + */ private syncWindow(): void { if (!this.win || this.win.isDestroyed()) return; @@ -617,47 +589,68 @@ class IslandController { // 关闭时保持原行为(完全淡出并隐藏)。 if (this.appearance.idleSliver) { this.cancelFade(); + this.setInteractive(false); this.win.setOpacity(this.appearance.opacity); if (!this.win.isVisible()) this.win.showInactive(); - this.animateBounds(SLIVER_SIZE); return; } this.fadeOut(); return; } - const size = - this.state.mode === 'expanded' - ? this.state.active - ? this.isImmediate(this.state.active) - ? this.state.active.kind === 'call' - ? CALL_SIZE - : URGENT_SIZE - : SIZES.expanded - : SIZES.expanded - : SIZES.pill; - + this.setInteractive(true); if (!this.win.isVisible()) { - const pillBounds = this.computeBounds(SIZES.pill); - // 普通通知:直接出现在屏幕上,不做"上岛"入场动画 - if (size === SIZES.pill) { - this.cancelFade(); - // 必须使用个性化透明度:这里以前硬编码 1,导致"隐藏后再弹出胶囊"会忽略透明度设置 - this.win.setOpacity(this.appearance.opacity); - this.win.setBounds(pillBounds); - this.win.showInactive(); - return; - } - // 展开 / 紧急:一定从胶囊尺寸开始缓动,保证"展开动画"看得见 + // 窗口位置由固定包围盒决定(applyWindowLayout),这里只负责显示 + 透明度 this.cancelFade(); - this.win.setOpacity(0); - this.win.setBounds(pillBounds); + this.win.setOpacity(this.appearance.opacity); this.win.showInactive(); - this.animateBounds(size, { spring: true }); - this.fadeIn(); return; } - this.animateBounds(size, { spring: size !== SIZES.pill }); + } + + /** + * 按当前外观把窗口放到锚点上(**只在尺寸/位置设置变化时调用**,开合时不调用)。 + * 窗口尺寸 = 最大形态 + 2×阴影留白,因此任何形态的岛都能完整容纳。 + */ + private applyWindowLayout(): void { + if (!this.win || this.win.isDestroyed()) return; + const next = this.windowBounds(); + const current = this.win.getBounds(); + if ( + current.x === next.x && + current.y === next.y && + current.width === next.width && + current.height === next.height + ) { + return; + } + this.win.setBounds(next); + } + + /** 固定包围盒窗口的矩形:以锚点定位,尺寸恒为"最大形态 + 阴影留白" */ + private windowBounds(): Bounds { + const width = Math.round(BBOX_SIZE.width); + const height = Math.round(BBOX_SIZE.height); + const anchor = this.resolveAnchor(); + + // 窗口比岛大 ISLAND_SHADOW_PAD(给投影留白),因此窗口要再偏移一个 pad, + // 保证**岛**的边/中心精确落在锚点上(而不是让窗口落在锚点上)。 + const pad = ISLAND_SHADOW_PAD; + let x: number; + switch (anchor.hMode) { + case 'left': + x = anchor.hValue - pad; + break; + case 'right': + x = anchor.hValue + pad - width; + break; + default: + x = anchor.hValue - Math.round(width / 2); + break; + } + const y = anchor.vMode === 'bottom' ? anchor.vValue + pad - height : anchor.vValue - pad; + + return { x, y, width, height }; } /** 作废进行中的淡入淡出动画 */ @@ -717,86 +710,19 @@ class IslandController { } /** - * 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果。 - * - * 用**单一 requestAnimationFrame 循环**驱动(需求 4): - * - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动 - * - 锚点固定:锚点在动画开始前算一次(整数),过程中只让"对侧边"移动 - * - 缓动**单调不过冲**:早期版本用 easeOutBack 弹簧缓动,窗口尺寸会超过目标再弹回来, - * 观感就是"开合时震一下";现在统一用 easeOutCubic,尺寸/位置只朝目标单向变化 - * - 每帧只用整数像素、卡片宽度取偶数,卡片中心与不动的那条边全程恒定 - * - 关闭动画(appearance.animations = false)时直接落到目标尺寸 + * 几何动画已完全移交渲染进程(固定窗口内做弹簧形变),这里只保留一个"取消"入口, + * 供 `hideImmediately()` 等在打断场景下调用(现在无需真正取消任何窗口动画)。 */ - private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void { - if (!this.win || this.win.isDestroyed()) return; - const from = this.win.getBounds(); - const anchor = this.resolveAnchor(); - const to = this.boundsFor(size, anchor); - if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return; - - this.cancelBoundsAnimation(); - const token = ++this.boundsToken; - - if (!this.appearance.animations) { - this.win.setBounds(to); - return; - } - - // 形变时长:展开稍长一点(视觉上更"从容"),收回更快;倍速来自个性化设置 - const duration = Math.round( - (options.spring === true ? 300 : 220) / Math.max(0.25, this.appearance.speed), - ); - const fromCardWidth = Math.max(2, evenWidth(from.width - CARD_PAD_X * 2)); - const fromCardHeight = Math.max(2, from.height - CARD_PAD_Y * 2); - const toCardWidth = Math.max(2, evenWidth(to.width - CARD_PAD_X * 2)); - const toCardHeight = Math.max(2, to.height - CARD_PAD_Y * 2); - const startedAt = Date.now(); - - const frame = (): void => { - if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return; - const progress = Math.min(1, (Date.now() - startedAt) / duration); - // easeOutCubic:单调递减斜率,永不越过 1(因此不会有回弹) - const eased = 1 - Math.pow(1 - progress, 3); - - const cardWidth = Math.max( - 2, - evenWidth(Math.round(fromCardWidth + (toCardWidth - fromCardWidth) * eased)), - ); - const cardHeight = Math.max(2, Math.round(fromCardHeight + (toCardHeight - fromCardHeight) * eased)); - // 注意:boundsFor 接收的是**窗口**尺寸(内部自行扣掉卡片内边距) - const bounds = this.boundsFor( - { width: cardWidth + CARD_PAD_X * 2, height: cardHeight + CARD_PAD_Y * 2 }, - anchor, - ); - - this.win.setBounds(bounds); - if (progress < 1) { - this.boundsFrame = scheduleFrame(frame); - return; - } - // 收尾对齐到最终尺寸,避免四舍五入留下误差(会让卡片边缘"抖一下") - this.win.setBounds(to); - this.boundsFrame = null; - this.applyFocusable(); - }; - this.boundsFrame = scheduleFrame(frame); - } - - /** 取消进行中的尺寸动画(帧循环) */ private cancelBoundsAnimation(): void { - if (this.boundsFrame !== null) { - cancelFrame(this.boundsFrame); - this.boundsFrame = null; - } + this.boundsFrame = null; this.boundsToken += 1; } /** * 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。 - * 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。 + * 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。 */ setAppearance(patch: Partial): void { - const before = this.appearance; this.appearance = normalizeAppearance({ ...this.appearance, ...patch }); recomputeSizes(this.appearance); @@ -809,12 +735,10 @@ class IslandController { } } this.emitAppearance(); + // 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口) + this.applyWindowLayout(); // 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作) if (this.state.mode !== 'hidden') this.syncWindow(); - // 位置变化:即使当前不可见也先把窗口挪到新锚点,避免下次出现时"从旧位置飞过来" - if (this.state.mode === 'hidden' && before.position !== this.appearance.position) { - this.syncWindow(); - } logger.info( `灵动岛外观已更新:${this.appearance.width}x${this.appearance.height} 圆角=${this.appearance.radius} ` + `透明度=${this.appearance.opacity} 位置=${this.appearance.position} 风格=${this.appearance.style} ` + @@ -847,6 +771,31 @@ class IslandController { return this.backgroundMaterial; } + /** + * 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`): + * 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。 + * 渲染进程做命中测试(指针是否在"岛"的可见区域/可交互控件内),再回调这里切换。 + */ + setInteractive(interactive: boolean): void { + if (this.interactive === interactive) return; + this.interactive = interactive; + if (!this.win || this.win.isDestroyed()) return; + // forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程, + // 这样渲染进程才能发现指针进入岛体并重新打开命中。 + this.win.setIgnoreMouseEvents(!interactive, { forward: true }); + } + + /** 当前是否接收鼠标(供冒烟验证) */ + getInteractive(): boolean { + return this.interactive; + } + + /** 固定包围盒窗口的矩形(供冒烟验证"窗口全程不变") */ + getWindowBounds(): Bounds { + if (!this.win || this.win.isDestroyed()) return { x: 0, y: 0, width: 0, height: 0 }; + return this.win.getBounds(); + } + getAppearance(): IslandAppearance { return { ...this.appearance }; } @@ -873,18 +822,24 @@ export function recomputeSizes(appearance: IslandAppearance): void { }; URGENT_SIZE = grow(BASE.urgent); CALL_SIZE = grow(BASE.call); - // 空闲细缝:卡片宽固定 6px(WinIsland hidden_width 的思路),窗口 = 卡片 + 内边距。 - // 注意:Windows 对(透明无边框)窗口有约 36px 的**最小高度**限制—— - // 实测请求 32px 时实际得到 36px,因此这里直接以 36px 为下限,避免断言/预览与真实不一致。 - const sliverCardHeight = Math.max(16, Math.min(28, appearance.height - 22)); + // 空闲细缝:宽固定 6px(WinIsland hidden_width 的思路),高取胶囊高度的一部分。 + // 岛在固定窗口内形变,因此这里就是**岛的尺寸**,不受窗口最小高度限制。 SLIVER_SIZE = { - width: Math.min(ISLAND_SLIVER_WIDTH + CARD_PAD_X * 2, appearance.width), - height: Math.max(MIN_WINDOW_HEIGHT, sliverCardHeight + CARD_PAD_Y * 2), + width: ISLAND_SLIVER_WIDTH, + height: Math.max(12, Math.min(28, appearance.height - 22)), + }; + + // 固定窗口包围盒 = 最大形态 + 2×阴影留白(岛在其中居中/贴边形变) + const maxWidth = Math.max(SIZES.pill.width, SIZES.expanded.width, URGENT_SIZE.width, CALL_SIZE.width); + const maxHeight = Math.max(SIZES.pill.height, SIZES.expanded.height, URGENT_SIZE.height, CALL_SIZE.height); + BBOX_SIZE = { + width: maxWidth + ISLAND_SHADOW_PAD * 2, + height: maxHeight + ISLAND_SHADOW_PAD * 2, }; } /** - * 空闲细缝的**窗口**尺寸(`getBounds()` 返回值;卡片尺寸 = 再减去内边距)。 + * 空闲细缝的**岛**尺寸(渲染进程里 `.island-card` 的宽高;窗口是固定包围盒,不代表当前形态)。 * 供冒烟验证与设置页预览使用。 */ export function getSliverSize(): { width: number; height: number } { @@ -918,18 +873,16 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance { */ /** - * 卡片相对窗口的内边距(必须与渲染进程 IslandApp.vue 的 CARD_PAD_X/CARD_PAD_Y 一致)。 - * 取 5px 是为了给展开态的投影(WinIsland: y+2、σ=3)留出不被窗口裁掉的余量。 + * 岛在固定窗口内的留白(= 阴影余量,见 `ISLAND_SHADOW_PAD`)。 + * 窗口尺寸 = 最大形态 + 2×该留白,因此投影永远不会被窗口裁掉。 */ -const CARD_PAD_X = 5; -const CARD_PAD_Y = 5; /** 灵动岛与屏幕工作区边缘的留白 */ const ISLAND_MARGIN = 8; /** - * Windows 对窗口最小高度的实际限制(实测:请求 32px 会得到 36px)。 - * 空闲细缝等"极小形态"必须以此作为高度下限,否则断言与真实窗口会不一致。 + * 说明:Windows 对窗口最小高度有约 36px 的限制,但**只影响窗口**。 + * 新版架构里窗口是固定包围盒、岛在窗口内形变,因此"空闲细缝"可以真正做到 6px 宽/十几像素高 + * (与 WinIsland 的 hidden_width=5 一致),不再受该限制。 */ -const MIN_WINDOW_HEIGHT = 36; /** * 失焦收起的宽限期:刚展开的 500ms 内忽略 blur(避免"刚弹出就被抢焦点导致瞬间收起")。 * 冒烟里"点击屏幕任意位置收起"的用例会在展开后等待约 700ms 再触发 blur,仍能正常验证收起。 @@ -951,12 +904,6 @@ interface IslandAnchor { vValue: number; } -/** 卡片宽度取偶数:保证水平居中的卡片中心落在整数像素上,形变时不左右跳变 */ -function evenWidth(value: number): number { - const safe = Math.max(2, Math.round(value)); - return safe - (safe % 2); -} - export const island = new IslandController(); /** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */ @@ -982,6 +929,11 @@ export function registerIslandIpc(): void { ipcMain.handle('island:get-appearance', () => island.getAppearance()); + // 渲染进程命中测试结果 → 切换窗口是否接收鼠标 + ipcMain.on('island:set-interactive', (_event, interactive: boolean) => { + island.setInteractive(interactive === true); + }); + ipcMain.on('island:action', (_event, payload: IslandActionPayload) => { if (!payload?.action) return; island.handleAction(payload); diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 769ab72..17f4528 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -21,6 +21,23 @@ function sleep(ms: number): Promise { } /** 灵动岛每种形态的截图像素统计(用于"截图像素级留档"断言) */ +/** 岛几何:固定包围盒窗口 + 岛的真实矩形 + 形状路径尺寸 */ +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 { + 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 { 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 => { + 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 => { 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; diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index 8cfc9d5..f35e9d4 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -12,6 +12,8 @@ export interface IslandBridge { /** 订阅个性化外观(设置页改动后实时生效) */ onAppearance(handler: (appearance: IslandAppearance) => void): void; getAppearance(): Promise; + /** 命中测试结果:指针是否在岛体上(决定窗口是否接收鼠标) */ + setInteractive(interactive: boolean): void; } const bridge: IslandBridge = { @@ -26,6 +28,9 @@ const bridge: IslandBridge = { ipcRenderer.on('island:appearance', (_event, appearance: IslandAppearance) => handler(appearance)); }, getAppearance: () => ipcRenderer.invoke('island:get-appearance'), + setInteractive: (interactive) => { + ipcRenderer.send('island:set-interactive', interactive); + }, }; contextBridge.exposeInMainWorld('island', bridge); diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 8599e45..8b30131 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -73,6 +73,8 @@ export interface IslandRendererBridge { getAppearance(): Promise; sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; getState(): Promise; + /** 命中测试结果:指针是否在岛体上(决定固定大窗口是否接收鼠标) */ + setInteractive(interactive: boolean): void; } declare global { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0e7f6a3..865ce31 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -129,6 +129,21 @@ export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string> */ 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 = { /** 展开卡标题 */