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

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