diff --git a/README.md b/README.md index 71920eb..536ede8 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 离线缓存 | ✅ 已完成(冒烟验证 49/49) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 53/53) | | 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),实测 49/49 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 43/43)。 +(**班级账号登录** + 四类数据 + Socket.IO),实测 53/53 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 47/47)。 ## 默认账号与种子数据 @@ -314,8 +314,21 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 ### 灵动岛(桌面通知浮窗) -客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口, -常驻桌面**中上方**(屏幕工作区顶部居中,`y = workArea.y + 8`)。它是"消息到达"的第一现场: +客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口。它是"消息到达"的第一现场。 + +> **视觉与排版参考 [WinIsland](https://github.com/WinIslandProject/WinIsland)(Rust/Direct2D 的 Windows 动态岛)重写**: +> 连续圆角(超椭圆 squircle)而不是 `border-radius`、纯黑/亚克力毛玻璃/主题色三种底、 +> Apple 系统色强调色、白字 alpha 分级、**所有文本 = 基础字号 × 排版系数**、 +> 胶囊 `h/2` 满圆角 + 展开卡 `min(48, w/2, h/2)`(WinIsland `expanded_island_radius`)、 +> 顶/底 × 左/中/右 **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 因为窗口恒定所以不受此限)。 | 场景 | 灵动岛行为 | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | @@ -394,18 +407,20 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 **设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效): -| 设置项 | 区间/取值 | 说明 | -| ---------- | --------------------------------- | ------------------------------------------------------------------------------ | -| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+264/+292/+308px`),内容用 flex + 溢出滚动承载 | -| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动 | -| 圆角 | 8–32px | 卡片圆角 | -| 不透明度 | 40%–100% | 窗口整体不透明度 | -| 字号 | 11–18px | 卡片基础字号 | -| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 | -| 主题色 | 取色器 | 用于高亮、按钮、进度条(CSS 变量下发到灵动岛渲染进程) | -| 显示位置 | 顶部居中 / 左上 / 右上 / 底部居中 | 按住 `workArea` 计算,多屏跟随主屏 | -| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) | -| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` | +| 设置项 | 区间/取值 | 说明 | +| ------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+186/+202/+218px`),内容用 flex 承载且不溢出 | +| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动(`+156/+172/+188px`) | +| 圆角 | 8–32px | 胶囊取 `min(圆角, h/2)`(WinIsland 胶囊是满圆角);展开卡取 `min(48×圆角/20, w/2, h/2)` | +| 不透明度 | 40%–100% | 窗口整体不透明度(所有显示路径都会应用,含"隐藏后再弹出胶囊") | +| **字号** | **11–20px** | **基础字号**:岛内所有文本 = 基础字号 × 排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74),改一处整体等比缩放 | +| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 | +| 主题色 | 取色器(默认 Apple 系统蓝 `#0a84ff`) | 用于高亮、按钮、进度条、主题色风格(CSS 变量下发到灵动岛渲染进程) | +| **视觉风格** | **纯黑 / 毛玻璃(亚克力)/ 主题色渐变** | 纯黑 = WinIsland `default`;毛玻璃调用 `win.setBackgroundMaterial('acrylic')`(真·桌面模糊,等价 HostBackdropBrush) | +| **显示位置** | **顶部/底部 × 左/中/右(6 个锚点)** | 按 `workArea` 计算;顶/底决定不动的是上边还是下边,左/中/右决定水平锚点 | +| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) | +| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` | +| **空闲细缝** | **开/关(默认关)** | 参考 WinIsland `hidden_width`:开启后没有消息时不再完全隐藏,而是留一条 6px 宽的竖条(受 Windows 最小窗口高度 36px 限制) | - 越界值在**主进程**统一夹紧(`normalizeIslandAppearance`),用户填坏配置也不会让布局错乱。 - 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。 @@ -425,7 +440,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 3. **卡片宽度取偶数**:卡片在窗口内水平居中,偶宽保证卡片中心落在整数像素上。 冒烟实测(逐帧采样窗口矩形 + 卡片真实屏幕矩形):展开时**卡片中心波动 0.00px、上边缘波动 0.00px**, -窗口最大 404×316(等于目标,不再过冲到 411×329),卡片最大 396×308(不再放大到 400.6px 再回落); +窗口最大 424×230(等于目标),卡片最大 414×220(重写前是 411×329 / 400.6×311.6 的过冲); 收回时同样 0.00px / 0.00px、最小窗口 268,窗口宽度序列 `356→318→286→274→270→268…` 单调收缩无回跳; 连续 5 次快速开合后状态与尺寸稳定。 @@ -505,18 +520,21 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") | | 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 | | **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** | -| **开合不震动:展开** | ✅ 逐帧采样 55 帧:卡片中心波动 **0.00px**、上边缘波动 **0.00px**、最大窗口 `404x316`(=目标,无过冲)、单调扩张 | -| **开合不震动:收回** | ✅ 逐帧采样 57 帧:卡片中心/上边缘波动 **0.00px**、最小窗口 `268`、宽度序列单调收缩无回跳 | +| **开合不震动:展开** | ✅ 逐帧采样 57 帧:卡片中心波动 **0.00px**、上边缘波动 **0.00px**、最大窗口 `424x230`(=目标,无过冲)、单调扩张 | +| **开合不震动:收回** | ✅ 逐帧采样 56 帧:卡片中心/上边缘波动 **0.00px**、最小窗口 `268`、宽度序列单调收缩无回跳 | +| **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** | +| **WinIsland 风格:三种底即时切换** | ✅ 纯黑 `fill=rgb(0,0,0)`/材质 `none`;毛玻璃 `fill=rgba(10,10,14,.59)`/材质 **`acrylic`**;主题色 `fill=rgb(11,11,15)` | +| **WinIsland 空闲细缝** | ✅ 空闲窗口收成 `16x36`(含 Windows 36px 最小高度下限)→ 来消息自动回到 `268x44`;关闭细缝后空闲 `isVisible=false` | | **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` | -| **个性化:停靠位置(4 个锚点)** | ✅ 左上 `8,8` / 右上 `1164,8` / 底部居中 `586,800` / 顶部居中 `586,8` | +| **个性化:停靠位置(6 个锚点)** | ✅ 左上 `8,8` / 右上 `1216,8` / 底部居中 `586,800` / 底部左 / 底部右 / 顶部居中 `586,8` | | **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` | -| **个性化:动画开关 / 速度** | ✅ 关闭动画后 60ms 内即为 `404x316`(无过渡);speed=0.5 收回 435ms vs speed=2 收回 128ms | -| **个性化:持久化** | ✅ 写入客户端配置后重新读取完全一致(尺寸/圆角/透明度/主题色/字号/动画/速度/位置/置顶) | +| **个性化:动画开关 / 速度** | ✅ 关闭动画后 60ms 内即到位(无过渡);speed=2 收回明显快于 speed=0.5 | +| **个性化:持久化** | ✅ 写入客户端配置后重新读取完全一致(尺寸/圆角/透明度/主题色/字号/动画/速度/位置/置顶/风格/细缝) | | **退出清理:托盘 / 灵动岛 / 窗口** | ✅ `isTrayReady()=false`、`island.isReady()=false`、窗口全部销毁 | | **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、49/49 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、53/53 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 @@ -871,14 +889,14 @@ pnpm db:migrate && pnpm db:seed 当前实测: -| 验证 | 结果 | -| -------------------------------------------------------------------------------------------------- | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 开合不震动 + 个性化全参数 + 托盘与退出无残留) | **49/49** ✅ | -| `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 风格 + 个性化全参数 + 托盘与退出无残留) | **53/53** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) @@ -990,6 +1008,15 @@ pnpm db:migrate && pnpm db:seed | `packages/web-admin/src/components/TimeLayoutImportDialog.vue` | ClassIsland 时间配置导入弹窗(粘贴/选文件、解析预览、覆盖/合并、已存列表) | | `packages/shared/src/types.ts` 的导入 DTO | `TableImportPreview` / `TableImportResult` / `TimeLayoutDto` 等前后端共用 | +阶段 11(参考 WinIsland 重写灵动岛)新增文件: + +| 路径 | 说明 | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `docs/winisland-design-tokens.md` | 从 WinIsland 源码逐条提取的设计 token(几何/配色/排版/层级/布局/图标 + 动效与设置模型) | +| `packages/desktop-client/src/island/squircle.ts` | 连续圆角(超椭圆角)路径生成,对齐 WinIsland `utils/shape.rs` 的 continuous rounded rect | +| `packages/desktop-client/src/island/IslandApp.vue` | 灵动岛 UI 全量重写:squircle + 三种底 + Apple 系统色 + 字号系数驱动的排版 + 无回弹过渡 | +| `packages/desktop-client/src/main/island.ts` | 6 锚点、空闲细缝、亚克力材质、Windows 最小高度下限;尺寸常量与渲染进程 EXTRA 一一对应 | + 阶段 10(学生端班级账号)新增文件: | 路径 | 说明 | diff --git a/WinIsland-master.zip b/WinIsland-master.zip new file mode 100644 index 0000000..86a1cc1 Binary files /dev/null and b/WinIsland-master.zip differ diff --git a/docs/screenshots/island/island-2-after-class.png b/docs/screenshots/island/island-2-after-class.png index 89e98b3..cbde9dd 100644 Binary files a/docs/screenshots/island/island-2-after-class.png and b/docs/screenshots/island/island-2-after-class.png differ diff --git a/docs/screenshots/island/island-3-urgent.png b/docs/screenshots/island/island-3-urgent.png index 648e11f..7793b51 100644 Binary files a/docs/screenshots/island/island-3-urgent.png and b/docs/screenshots/island/island-3-urgent.png differ diff --git a/docs/screenshots/island/island-4-pill.png b/docs/screenshots/island/island-4-pill.png index 213ef00..38b7baf 100644 Binary files a/docs/screenshots/island/island-4-pill.png and b/docs/screenshots/island/island-4-pill.png differ diff --git a/docs/screenshots/island/island-5-clicked.png b/docs/screenshots/island/island-5-clicked.png index 1eeccd9..2d7d98f 100644 Binary files a/docs/screenshots/island/island-5-clicked.png and b/docs/screenshots/island/island-5-clicked.png differ diff --git a/docs/screenshots/island/island-7-homework.png b/docs/screenshots/island/island-7-homework.png index 4fb906a..8b60429 100644 Binary files a/docs/screenshots/island/island-7-homework.png and b/docs/screenshots/island/island-7-homework.png differ diff --git a/docs/screenshots/island/island-8-call.png b/docs/screenshots/island/island-8-call.png index 1f3b414..9137e9a 100644 Binary files a/docs/screenshots/island/island-8-call.png and b/docs/screenshots/island/island-8-call.png differ diff --git a/docs/winisland-design-tokens.md b/docs/winisland-design-tokens.md new file mode 100644 index 0000000..f96b67f --- /dev/null +++ b/docs/winisland-design-tokens.md @@ -0,0 +1,563 @@ +# WinIsland 视觉设计系统(Design Token 规格) + +来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。 +所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。 + +关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**, +下文数值默认 scale = 1.0。文字颜色在渲染入口被硬编码为 `Color::WHITE`(`src/core/render.rs` +`draw_expanded_layer` / `draw_compact_layer` 传 `text_color: Color::WHITE`, `text_color_sec: Color::WHITE`), +所有"次级/次要"文字靠 **alpha** 或 **灰度通道值** 表达,而不是换颜色。 + +--- + +## 1. 几何(几何 token) + +### 1.1 两个状态的尺寸 + +| 项 | compact / mini | expanded | 来源 | +| -------- | ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- | +| 宽 | `base_width = 120.0` | `expanded_width = 360.0` | `src/core/config.rs` `AppConfig::default()` | +| 高 | `base_height = 27.0` | `expanded_height = 200.0` | 同上 | +| 圆角 r | `h / 2`(=13.5)→ 胶囊/体育场形 | `min(48*scale, w/2, h/2)` = **48.0** | `src/window/app/frame.rs` `default_target_r`;`src/utils/shape.rs` `expanded_island_radius()` | +| 全局缩放 | `compact_scale = 1.0` | `expanded_scale = 1.0` | `src/core/config.rs` | + +圆角函数原文(`src/utils/shape.rs`): + +```rust +pub(crate) fn expanded_island_radius(w: f32, h: f32, scale: f32) -> f32 { + (48.0 * scale).min(w / 2.0).min(h / 2.0).max(0.0) +} +``` + +compact 圆角:`IslandSprings::new` 里 `r = (base_height * compact_scale) / 2.0`(`src/window/app.rs`); +展开时 `default_target_r = compact_content_h / 2.0`(`src/window/app/frame.rs` `update_spring_targets`)。 + +### 1.2 compact 宽度的动态规则(不是固定 120) + +`src/window/app/layout.rs` `compute_lyric_target_width()`: + +- 无媒体 / 不在播放态:宽 = `base_width` = **120** +- 有媒体但无歌词:宽 = `base_width + 35.0` = **155** +- 有歌词(不滚动):`w = 60.0 + 文本宽`,再 `clamp(base_width+35, MAX_LYRIC_WIDTH)` + - `min_w = base_width + 35.0`、`MAX_LYRIC_WIDTH = 700.0`(`src/core/config.rs`) +- 有歌词(滚动模式,`lyrics_scroll = true`):`natural_w = 60.0 + text_w`,超过 `lyrics_scroll_max_width`(默认 + `300.0`,`default_lyrics_scroll_max_width`)则固定为该上限;内部可用文本宽 = `(fixed_w - 59.0) * compact_scale` +- 二级歌词存在时:高度 = `max(base_height*scale, mini_lyric_pair_height)`, + 其中 `lyric_pair_height = 主字号*(1 + 0.85 + 0.18) + 8*scale` + (`SECONDARY_LYRIC_SCALE = 0.85`、`LYRIC_PAIR_GAP_SCALE = 0.18` @ `src/core/render/mini.rs`) + → 默认字号 12 时 = 12×2.03 + 8 = **32.36 px 高**,r ≈ 16.18 + +### 1.3 挂件(compact widget)导致的最小宽度 + +`src/ui/widget/compact/mod.rs`: + +- `CONTENT_EDGE_INSET = 9.0`、`CONTENT_GAP = 7.0` +- `widget_width()`:`Time = 48.0`、`ResourceUsage = 132.0` +- 三组对齐宽度:`aligned_layout_width = 9*2 + left + center + right + 7*(占用组数-1)` +- 单侧扩展宽:`extension_width = CONTENT_EDGE_INSET + strip_width + CONTENT_GAP` + (例:左 Time → 9+48+7 = **64**;ResourceUsage → 9+132+7 = **148**) +- 最终 compact 宽 = `max(布局宽, base_width)` + +### 1.4 compact overlay(通知 / 音量)会替换 base 尺寸 + +| overlay | 宽 | 高 | 圆角 | 来源 | +| ------- | ------------------------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- | +| 通知 | `max(base_width, 330.0) * scale` = **330** | `max(base_height, 82.0) * scale` = **82** | `h/2` = **41** | `src/ui/compact/notification.rs` `target_size()`;r 取自 `src/window/app/frame.rs`(`size.height/2.0`) | +| 音量 | `(base_width + 72.0) * scale` = **192** | `(base_height + 10.0) * scale` = **37** | `h/2` = **18.5** | `src/ui/compact/volume.rs` `target_size()` | + +### 1.5 隐藏态尺寸 + +- `hidden_width` 默认 `5.0`(`default_hidden_width` @ `src/core/config.rs`), +- `MIN_HIDDEN_WIDTH = 0.0`、`MAX_HIDDEN_WIDTH = 400.0` +- 隐藏时高度收缩到 `hidden_width * compact_scale`,并保留 `hidden_reveal_h = 1.0`(1px 可点击边); + 位移距离 `hide_distance = concealed_height + TOP_OFFSET`(`src/window/app/layout.rs`) + +### 1.6 锚点(两种状态之间什么不动) + +定位全部来自 `src/window/app/layout.rs` `compute_island_layout()` / `compute_window_position()`: + +- **顶部居中停靠(默认)**:`island_y = TOP_OFFSET = 10.0`(**顶边固定**), + `offset_x = (os_w - w) / 2.0`(**水平中心固定**) +- **底部停靠**:`island_y = os_h - TOP_OFFSET - h`(**底边固定**,向上生长) +- **左/右停靠**:`offset_x = TOP_OFFSET` 或 `os_w - TOP_OFFSET - w`(对应侧边固定) +- `TOP_OFFSET: i32 = 10`(`src/core/config.rs`)= 岛顶边距屏幕顶 10 px +- 状态切换由三个弹簧同时插值:`w/h/r` 参数为 `stiffness = 0.10, damping = 0.68`(`src/window/app/frame.rs`), + 页面横移 `view` 用 `0.12 / 0.68`;隐藏用 `(0.12, 0.70)` 隐藏 / `(0.08, 0.78)` 显示 +- **compact 内容不跟随变大的岛做垂直居中重排**:mini 内容画在 `stable_island_y`(= 顶边)加上 `base_h` 带内居中 + (`src/core/render/mini.rs`:封面 `y = stable_offset_y + (base_h - size)/2`,歌词 `center_y = stable_offset_y + base_h/2`)。 + 效果=岛向下长大,胶囊内容始终贴在顶部那条带里。 +- 交叉淡出:`expanded_alpha = expansion_progress² * (1-hide_progress)`, + `mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1-hide_progress)` + (`MINI_FADE_RATE = 1.5` @ `src/core/render.rs`)→ 即进度 0~0.667 之间完成交叉淡出 + +### 1.7 有没有"挖孔/刘海"造型? + +**没有。** 代码里没有 `SetWindowRgn` / `CreateRoundRectRgn` / notch / cutout 相关调用(已全仓 grep 确认), +也没有"顶部贴边 + 上方两角变直角"的特例分支。造型是: + +- 一个**悬浮胶囊**(compact,r = h/2),**距屏幕顶 10 px**; +- 展开后是**四角一致的连续超椭圆**(squircle),r 最大 48。 + +圆角实现不是普通圆弧,而是 Flutter `RoundedSuperellipse` 的移植(`src/utils/shape.rs`, +注释标明 "Rounded-superellipse geometry adapted from Flutter under the BSD-3-Clause license"): + +- `superellipse_parameters()`:11 项查表 `PARAMETERS`(如 `(2.0, 1.13276676) … (6.43023796, 2.98020421)`), + ratio > 5 时线性外推 `exponent = 6.43023796 + 1.559599389*(ratio-5.0)`、`factor = 2.98020421 + 0.522807185*(ratio-5.0)` +- `Octant::new()`:13 项 `BEZIER_FACTORS`(`(0.7078, 8.3194) … (0.9705, 1.2880)`),用 conic + cubic 拼四分之一角 +- 路径缓存 `PATH_CACHE_CAPACITY = 64` +- **Web 复刻提示**:CSS `border-radius` 是正圆角,观感会偏"软";要还原请用 SVG path 或 + `corner-shape: squircle`(若可用)/ `clip-path` 近似。**未找到**等价的简单圆角公式——真实几何就是上面那张表。 + +### 1.8 窗口留白 / 裁切 + +- `PADDING: f32 = 80.0`(`src/core/config.rs`);窗口尺寸 = `(岛宽 + 80, 岛高 + 80)` + (`src/window/app/layout.rs` `required_window_size()`)→ **岛四周各 40 px 透明留白**,供阴影/动画使用 +- 绘制时 `canvas.clip_path(island_path, ClipOp::Intersect)`,所有内容裁在岛形内; + 1px 描边在 `canvas.restore()` **之后**绘制(`src/core/render.rs` `draw_island()`) +- 挂件网格额外内缩,避免内容进圆角(`src/ui/widget/expanded/mod.rs` `widget_grid_layout`): + ```rust + corner_inset = radius * (1.0 - FRAC_1_SQRT_2); // 48 * 0.29289 = 14.06 + inset = (corner_inset + 8.0*scale).max(24.0*scale); // = 24.0(scale=1) + gap = 7.0 * scale; + ``` + +### 1.9 expanded 内容网格 + +- `WIDGET_GRID_COLS = 6`、`WIDGET_GRID_ROWS = 3`、`WIDGET_GRID_SLOTS = 18`(`src/core/config.rs`) +- 槽位宽 = `(inner_w - 7*5) / 6`,高 = `(inner_h - 7*2) / 3`; + 用 360×200、inset 24 计算:inner = 312×152 → **slot ≈ 46.17 × 46.0**(`src/ui/widget/expanded/mod.rs`) +- 组件占格 `WidgetKind::span()`:`Clock = (2,1)`、`ResourceUsage = (2,1)`、`Calendar = (2,2)`、`Settings = (1,1)` + (`src/core/config.rs`) + +--- + +## 2. 配色(颜色 token) + +### 2.1 岛背景(`src/core/render/background.rs` `draw_background()`) + +| 样式 | 颜色值 | 说明 | +| --------------------------- | -------------------------------------------------------------- | -------------------- | +| `default`(`SOLID_STYLE`) | `Color::BLACK` = **#000000**,完全不透明 | `bg_color` | +| `glass` + host backdrop | `Color::from_argb(150, 10, 10, 14)` = **rgba(10,10,14,0.588)** | `draw_host_glass()` | +| `glass` 无 backdrop(回退) | `Color::from_argb(205, 32, 32, 36)` = **rgba(32,32,36,0.804)** | `fallback_color` | +| `dynamic` 底色 | `Color::from_rgb(32, 32, 36)` = **#202024**,不透明 | `draw_effect_base()` | +| `dynamic` 叠加层 | `Color::from_argb(120, 20, 20, 24)` = **rgba(20,20,24,0.471)** | 盖在旋转模糊封面之上 | +| `dynamic` 无封面回退 | 同 glass 分支(150/10,10,14 或 205/32,32,36) | | + +`dynamic` 封面动效参数(同一函数): + +- 集显:`rotate_speed = 0.015 rad/s, drift_speed_x = 0.075, drift_speed_y = 0.06, drift_amp_x = 10.0, drift_amp_y = 7.5` +- 独显:`rotate_speed = 0.03, drift_speed_x = 0.15, drift_speed_y = 0.12, drift_amp_x = 20.0, drift_amp_y = 15.0` +- 旋转矩形边长 `side_len = diagonal * 1.3` + +**亚克力/backdrop 真实实现**(`src/window/d3d.rs` + `src/utils/win32.rs`): +Windows 11 `DWMWA_USE_HOSTBACKDROPBRUSH`,失败时退回 `SetWindowCompositionAttribute` +(`AccentPolicy { state: 5, flags: 0, gradient_color: 0 }`,state 5 = host backdrop); +再用 `Compositor::CreateHostBackdropBrush()` + `RoundedRectangleGeometry` 裁圆角 +(`HOST_BACKDROP_INSET = 1.0`,几何 = `w-2, h-2, radius-1`)。 +**未找到**任何模糊半径数值——模糊由系统 HostBackdropBrush 决定。 + +### 2.2 描边(`src/core/render.rs` `draw_island_border()`) + +```rust +BORDER_WIDTH = 1.0; BORDER_INSET = BORDER_WIDTH / 2.0; // 0.5 +SOLID_STYLE = "default"; SOLID_BORDER_ALPHA = 30; EFFECT_BORDER_ALPHA = 40; +``` + +- 颜色 = **纯白**,alpha: + - `default` 样式:`rgba(255,255,255, 30/255=0.118)`,**并再乘 `(1 - expansion_progress)`**(展开后描边消失) + - `glass` / `dynamic`:`rgba(255,255,255, 40/255=0.157)`,不随展开衰减 +- 描边矩形 = `(x+0.5, y+0.5, w-1, h-1)`,圆角 `r-0.5` + +### 2.3 阴影(`src/core/render.rs` `draw_expanded_shadow()`,仅展开时) + +- 颜色 `Color::from_argb((28.0 * opacity) as u8, 0,0,0)` = **rgba(0,0,0,0.11)** +- `opacity = expansion_progress² * (1 - hide_progress)`,`opacity <= 0.01` 直接跳过 +- 偏移 `offset_y = 2.0 * scale`;模糊 `sigma = min(3.0*scale, margin/3.0)`(margin = 到画布边界的余量) +- 用 `ClipOp::Difference` 只画在岛形之外 +- **未找到内阴影**(没有任何 inner shadow 实现) + +### 2.4 分隔线 + +| 位置 | 颜色 | 尺寸 | 来源 | +| -------------------- | -------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- | +| compact 挂件之间 | `rgba(255,255,255, 0.14*alpha)` | 宽 `max(scale,1)`,从 `top+25%h` 到 `bottom-25%h` | `src/ui/widget/compact/mod.rs` `draw_separator()` | +| compact 资源占用内部 | `rgba(255,255,255, 0.10*alpha)` | 宽 `max(0.75*scale, 0.75)`,半高 `DIVIDER_HALF_HEIGHT = 6.0*scale` | `src/ui/widget/compact/resource_usage.rs` | +| expanded 资源环分隔 | `rgba(白, 0.09*alpha)` | 宽 `scale.max(0.75)`,从 `y+0.2h` 到 `y+0.8h` | `src/ui/widget/expanded/resource_usage.rs` | +| 设置窗口分隔线 | `rgba(255,255,255,26)` 暗 / `rgba(20,28,40,18)` 亮 | — | `src/utils/color.rs` | + +### 2.5 文字颜色与不透明度分层 + +主文字一律 **#FFFFFF**;次级用 alpha: + +| 元素 | 有效颜色 | 来源 | +| ------------------ | ------------------------------------------------------------ | ---------------------------------------------------- | +| 通知 app 名 | `rgba(255,255,255, 0.65*alpha)` | `src/ui/compact/notification.rs` | +| 通知标题 | `rgba(255,255,255, alpha)` | 同上 | +| 通知详情 | `rgba(255,255,255, 0.72*alpha)` | 同上 | +| 音量标签 | `rgba(255,255,255, 0.9*alpha)` | `src/ui/compact/volume.rs` | +| expanded 歌手 | `rgba(白, alpha*0.6)` | `src/ui/expanded/music_view.rs` `draw_track_text()` | +| 迷你歌词主行 | 白 alpha | `src/core/render/mini.rs` | +| 迷你歌词次行 | `rgb(176,176,176)`(`SECONDARY_LYRIC_CHANNEL = 176`) | 同上 | +| 逐字歌词"未唱"部分 | `rgb(190,190,190)`(`PENDING_LYRIC_CHANNEL = 190`) | 同上 | +| 进度条时间 | 白,`PROGRESS_TIME_IDLE_ALPHA = 0.5` → hover 1.0(线性插值) | `src/ui/expanded/music_view.rs` | +| 挂件时钟 | 白 alpha(bold) | `src/ui/widget/expanded/time.rs` | +| 挂件日历 月 | `0.78*alpha` | `src/ui/widget/expanded/calendar.rs` | +| 挂件日历 日 | `1.0*alpha` | 同上 | +| 挂件日历 星期 | `0.62*alpha` | 同上 | +| 挂件资源 数值 | `1.0*alpha`(bold) | `src/ui/widget/expanded/resource_usage.rs` | +| 挂件资源 标签 | `0.58*alpha` | 同上 | +| 封面占位块 | `rgba(白, 0.15*alpha)` | `src/ui/expanded/music_view.rs` `draw_placeholder()` | + +### 2.6 强调色 / 状态色 + +岛内(`src/ui/widget/resource_usage.rs`): + +```rust +CPU_COLOR = rgb(50, 190, 246) // #32BEF6 +RAM_COLOR = rgb(175, 82, 222) // #AF52DE +WARNING_COLOR = rgb(255, 159, 10) // #FF9F0A +CRITICAL_COLOR = rgb(255, 69, 58) // #FF453A +``` + +阈值曲线 `usage_color(base, usage)`: + +- `usage <= 0.75` → base +- `0.75 < usage <= 0.9` → base ↔ WARNING 线性混合,`t = (usage-0.75)/0.15` +- `0.9 < usage` → WARNING ↔ CRITICAL 线性混合,`t = (usage-0.9)/0.1` + 采样间隔 `SAMPLE_INTERVAL = 1s`;无数值时显示 `—`。 + +进度条/音量条(`src/ui/expanded/music_view.rs`、`src/ui/compact/volume.rs`): + +```rust +PROGRESS_TRACK_ALPHA = 0.15; // 轨道 rgba(白, 0.15*alpha) +PROGRESS_TIME_IDLE_ALPHA = 0.5; // 时间文字静止透明度 +// 音量轨道 rgba(255,255,255, 0.28*alpha),填充纯白 alpha +``` + +音量的"填充"就是**白色**(不是强调色),图标仍是白色。 + +可视化条颜色**来自专辑封面**(`src/ui/expanded/music_view/palette.rs`): + +- 把封面画到 8×8 的 GPU surface(`PALETTE_SAMPLE_SIZE = 8`)读回求平均 RGB +- `primary = brighten(avg, 1.3)`、`secondary = brighten(avg, 1.5)` +- `brighten` 用 BT.601 亮度 `0.299R + 0.587G + 0.114B`;若 < 80 则整体加 `80 - brightness`(抬到最低亮度 80) +- 调色板 = `[primary, secondary, primary]`,缓存上限 50 条 +- 无封面默认值(`default_media_palette`):`rgb(180,180,180)` + `rgb(100,100,100)` +- 采样完全失败的兜底:`rgb(200,200,200)` + +### 2.7 不同状态是否不同配色? + +- **同一套**:底 + 白字 + alpha 分层;通知 / 音量 / 音乐 / 挂件没有各自的独立配色, + 视觉区分靠 **尺寸(330×82 / 192×37 / 120×27)** 与 **内容排版**,而不是换色。 +- 差异只在两处:**可视化条用封面取色**(2.6),**资源占用用 CPU/RAM/警告/危险四色**(2.6)。 +- 岛背景三样式(default/glass/dynamic)才是真正的配色分支(2.1)。 + +### 2.8 设置窗口色板(另一套,`src/utils/color.rs`,供参考) + +暗色主题 `dark_settings_theme()`: + +``` +win_bg #1C1C1E sidebar_bg #242426 group_bg #2C2C2E card_highlight #48484A +accent #0A84FF text_pri #F5F5F7 text_sec #AEAEB2 disabled #636366 +danger #E6372D toggle_on #30D158 toggle_off #636366 +sidebar_hover rgba(255,255,255,20) separator rgba(255,255,255,26) +popup_bg #323234 popup_border rgba(255,255,255,40) popup_shadow rgba(0,0,0,60) +popup_separator rgba(255,255,255,30) control_bg #3A3A3C control_hover #48484A +control_disabled #303032 control_border rgba(255,255,255,36) +group_border rgba(255,255,255,24) shadow rgba(0,0,0,45) scrollbar rgba(255,255,255,60) +``` + +亮色主题 `light_settings_theme()`: + +``` +win_bg #F4F5F7 sidebar_bg #FAFAFC group_bg #FFFFFF card_highlight #EFF2F7 +text_pri #1F2228 text_sec #5B626E disabled #9297A1 accent #0067C0 +danger #C42B1C toggle_on #0078D4 toggle_off #B0B5BE +selection_bg #E0EEFA selection_text #005599 +sidebar_hover rgba(20,32,48,14) separator rgba(24,32,44,18) +popup_bg #FFFFFF popup_border rgba(20,28,40,24) popup_shadow rgba(20,28,40,20) +control_bg #F7F8FA control_hover #EBEFF4 control_disabled #F1F2F5 +control_border rgba(20,28,40,24) group_border rgba(20,28,40,14) +shadow rgba(20,28,40,20) scrollbar rgba(52,58,68,52) +``` + +设置窗口另有"岛预览"配色(与真实渲染略有差异,`src/utils/settings_ui/renderer/widget_preview.rs`): +glass 预览 `rgba(24,24,28,220)`;dynamic 预览为线性渐变 **`rgb(18,12,36)` → `rgb(8,24,48)`**(左上→右下); +default 预览 `rgb(10,10,10)`;预览描边 alpha `52`(glass) / `38`(其他);预览阴影 `rgba(0,0,0,72)`,偏移 +4。 +→ 若前端需要一张**静态渐变**替代 `dynamic`,这两个色值就是现成可抄的。 + +--- + +## 3. 排版(字体 token) + +### 3.1 字体族(`src/utils/font.rs`) + +1. 用户自定义字体:`config.custom_font_path` → `FontMgr::new_from_data()`(优先于全部) +2. ASCII 文本:`"Microsoft YaHei"` → `"Segoe UI"` → 系统默认(`legacy_make_typeface`) +3. 非 ASCII:逐字符匹配 `match_family_style_character("", style, &["zh-CN","ja-JP","en-US"], ch)`, + 再依次尝试 `"Segoe UI Emoji"` → `"Microsoft YaHei"` → `"Segoe UI Symbol"` → `"Segoe UI"` +4. 合成粗体:`needs_synthetic_bold()` = 请求 `weight >= 600` 且字体本身 `weight < 600` → `font.set_embolden(true)` +5. 亚像素渲染始终开启 `font.set_subpixel(true)` + +字重只有两档:`FontStyle::bold()`(700)与 `FontStyle::normal()`(400); +滚动文本判定粗体用 `style.weight() >= 700`(`src/utils/scroll.rs`)。 + +**字距 / 行高**:代码里**未找到** letter-spacing 或 line-height 参数; +行距全部通过显式 y 偏移实现(见下表)。文本绘制走 Skia `draw_str`, +大字文本(标题/歌手)`render_as_paths = true`(`src/utils/scroll.rs` 调用点)。 + +### 3.2 字号表(默认 `config.font_size = 0.0` 时用内置值;>0 时用户值参与计算) + +| 用途 | 字号 | 字重 | 说明 / 来源 | +| -------------------- | -------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------- | +| mini 歌词主行 | `font_size*0.8*scale` 或 **12.0\*scale** | normal | `src/core/render/mini.rs` `lyric_font_size()` | +| mini 歌词次行 | 主字号 × `SECONDARY_LYRIC_SCALE = 0.85` | normal | 同上 | +| mini 歌词对高度 | `主*(1+0.85+0.18) + 8*scale` | — | `lyric_pair_height()` | +| mini 插件文字(主) | `font_size*0.7*scale` 或 **11.0\*scale** | **bold** | `PLUGIN_FONT_SCALE = 0.7`、`DEFAULT_PLUGIN_FONT_SIZE = 11.0` | +| mini 插件文字(次) | 主 × `0.8` | normal | `PLUGIN_SECONDARY_FONT_SCALE = 0.8`,行距 `font_size*1.3`(`PLUGIN_SECONDARY_LINE_SPACING = 1.3`) | +| expanded 曲名 | `font_size*scale` 或 **15.0\*scale** | **bold** | `src/ui/expanded/music_view.rs` | +| expanded 歌手 | 同上 **15.0\*scale** | normal | 基线 = 曲名基线 + `22.0*scale` | +| 进度时间 | `font_size*0.67*scale` 或 **10.0\*scale** | normal | `PROGRESS_TIME_FONT_SCALE = 0.67`、`DEFAULT_PROGRESS_TIME_FONT_SIZE = 10.0` | +| 通知 app 名 | **11.0\*scale** | normal | `src/ui/compact/notification.rs` | +| 通知标题 | **13.0\*scale** | **bold** | 同上 | +| 通知详情 | **11.0\*scale** | normal | 行间距 `DETAIL_LINE_GAP = 21.0*scale` | +| 音量标签 | **12.0\*scale** | normal | `src/ui/compact/volume.rs` | +| compact 时钟挂件 | `clamp(h*0.43, 10, 12)` | **bold** | `src/ui/widget/compact/time.rs` | +| compact 资源—标签 | `METRIC_LABEL_SIZE = 7.0*scale` | **bold** | `src/ui/widget/compact/resource_usage.rs` | +| compact 资源—数值 | `METRIC_VALUE_SIZE = 10.0*scale` | **bold** | 同上 | +| expanded 时钟挂件 | `min(h*0.60, w*0.31)`,最小 `13.0*scale` | **bold** | `src/ui/widget/expanded/time.rs` | +| expanded 日历—月 | `clamp(h*0.14, 9, 15)*scale` | **bold** | `src/ui/widget/expanded/calendar.rs` | +| expanded 日历—日 | `min(h*0.53, w*0.70)`,最小 `26.0*scale` | **bold** | 同上 | +| expanded 日历—星期 | `clamp(h*0.12, 8, 12)*scale` | normal | 同上 | +| expanded 资源—圆环值 | `clamp(d*0.27, 7, 10.5*scale)`,d = 圆环直径 | **bold** | `src/ui/widget/expanded/resource_usage.rs` | +| expanded 资源—标签 | `clamp(h*0.12, 6, 8*scale)` | **bold** | 同上 | +| 设置窗口—行标签/值 | **13.0** | normal | `src/utils/settings_ui/renderer/items.rs` | +| 设置窗口—页签标签 | **11.5** | normal(选中为 bold) | `src/window/settings/renderer.rs` | +| 设置窗口—弹窗项 | **12.0** | normal | 同上 | +| 设置窗口—Markdown | `BODY_SIZE = 12.0`,`LINE_HEIGHT = 18.0` | — | `src/window/settings/pages/plugins/markdown.rs` | + +字体基线规则(值得照抄): + +- 居中对齐用 **字形包围盒** 居中:`baseline_y = bounds.top + (bounds.h - glyph.h)/2 - glyph.top` + (`src/ui/widget/expanded/mod.rs` `draw_widget_text_centered()`) +- 时间文字基线 = 进度条中心 + `0.35*字号`(`PROGRESS_TIME_BASELINE_SCALE = 0.35`) +- 迷你歌词单行时 `primary_y = center_y + size/3`;双行时按堆叠高度对齐(见 5.1) + +### 3.3 是否随窗口尺寸 / 用户设置缩放 + +- 随 `compact_scale` / `expanded_scale` 缩放(乘在所有尺寸上) +- 字号受 `config.font_size`(默认 0 = 用内置值)影响:只有 mini 歌词、mini 插件、expanded 曲名/歌手、进度时间用它 +- 挂件字号是**几何驱动**的(按自身矩形宽高 clamp),与 `font_size` 无关 +- 设置窗口有自己的 `settings_theme`(`system` / 暗 / 亮) + +--- + +## 4. 层级与效果 + +### 4.1 圆角 + +见 1.7。总结:连续超椭圆(squircle),compact 满圆角(h/2),expanded 最大 48。 +挂件卡片 `widget_corner_radius = min(12.0*scale, min(w,h)/2)`(`src/ui/widget/expanded/mod.rs`)。 +迷你封面 `MINI_COVER_RADIUS = 5.0`,expanded 封面 `16.0*scale`,通知图标 `11.0*scale`。 + +### 4.2 阴影 / 发光 + +- **外阴影**:仅 expanded,`rgba(0,0,0,0.11)`,y+2,blur σ = `min(3*scale, margin/3)`(见 2.3) +- **内阴影**:**未找到** +- **发光**:只出现在资源占用圆环——`glow` 圆半径 `diameter*0.57`,颜色 `accent`,alpha `0.07*alpha` + (`src/ui/widget/expanded/resource_usage.rs`) +- 设置窗口:窗口阴影 `rgba(0,0,0,90)`、弹出菜单阴影 `theme.popup_shadow` + 偏移 +2(`src/window/settings/renderer.rs`); + 拖拽幽灵阴影 `rgba(0,0,0,90)`,偏移 +4 + +### 4.3 模糊半径(全部数值) + +| 场景 | 参数 | 来源 | +| ----------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------- | +| 运动模糊(整体) | 横向 = ` | w速度 | *0.3 + | view速度 | *w*0.4`,纵向 = ` | h速度 | *0.3`;上限集显 `(4.0, 3.5)`、独显 `(12.0, 10.0)`;仅在 σ>0.1 时 `saveLayer` | `src/utils/blur.rs` `calculate_blur_sigmas()`、`src/core/render.rs` `MIN_BLUR_SIGMA = 0.1` | +| 封面翻转 | `σ = (1- | cos | )^0.6 * 8.0 * scale`,x 方向再 ×0.3 | `src/ui/expanded/music_view.rs` | +| 暂停按钮速度模糊 | ` | 弹簧速度 | * 16.0 * scale`,上限 `2.5*scale` | `PAUSE_CONTROL_BLUR_SCALE = 16.0`、`PAUSE_CONTROL_MAX_BLUR = 2.5` | +| 快进/快退按钮 | `(1 - t/0.3).max(0) * 6.0 * scale`,x×0.3 | 同文件 `draw_skip_button()` | +| 歌词切换(Blur 模式) | `σ = 6.0*scale`,x 压缩 `LYRIC_TRANSITION_BLUR_X_SCALE = 0.25`,位移 `LYRIC_TRANSITION_OFFSET = 10.0*scale` | `src/core/render/mini.rs` | +| dynamic 背景封面 | 先降采样到 **64×64**,再 `image_filters::blur((8.0, 8.0), TileMode::Clamp)` | `src/utils/backdrop.rs` | +| host backdrop(亚克力) | **未找到数值**(由系统 HostBackdropBrush 决定,仅 `HOST_BACKDROP_INSET = 1.0`) | `src/window/d3d.rs` | + +### 4.4 不透明度分层与可见性阈值 + +``` +MIN_VISIBLE_OPACITY = 0.01 // 低于此不绘制 +MIN_BLUR_SIGMA = 0.1 +MINI_FADE_RATE = 1.5 // mini 淡出速率 +COLLAPSED_VISUALIZER_HEIGHT_SCALE = 0.45 // 收起时可视化高度 = 45% +``` + +- `expanded_alpha = expansion_progress² * (1 - hide_progress)`(平方 → 展开"迟入场") +- `mini_alpha = clamp(1 - expansion_progress*1.5, 0, 1) * (1 - hide_progress)` +- 可视化高度比例 `visualizer_height_scale = 0.45 + 0.55 * expansion_progress` +- 歌词额外淡出 `LYRIC_EXPANSION_FADE_RATE = 2.5`(`src/core/render/mini.rs`) +- 展开页横向滑动:`page_shift = view_offset * current_w`; + 音乐页 `translate(-page_shift)`,挂件页 `translate(current_w - page_shift)` + (`src/core/render/expanded.rs`);`view` 弹簧 `0.12/0.68` + +### 4.5 卡片四周留白 / 裁切 + +见 1.8:窗口 = 岛 + 80(每侧 40 透明);内容 `clip_path(Intersect)`; +描边在裁切外画;挂件网格 inset 24。 + +--- + +## 5. 布局骨架 + +### 5.1 compact(mini)120×27 + +`src/core/render/mini.rs`: + +``` +MIN_MINI_CONTENT_WIDTH = 45.0 // 可用宽小于此不画内容 +MINI_COVER_SIZE = 18.0 // 封面尺寸 +MINI_COVER_LEFT_INSET = 10.0 // 封面左边距 +MINI_COVER_RADIUS = 5.0 // 封面圆角 +MINI_VISUALIZER_RIGHT_INSET = 17.0 +MINI_VISUALIZER_WIDTH_SCALE = 0.55 +MINI_VISUALIZER_SMOOTHING = (0.6, 0.08) // (上升, 下降) +LYRIC_LEFT_INSET = 30.0 +LYRIC_RIGHT_INSET = 29.0 +``` + +- **左**:18×18 封面,x = 10,y 垂直居中(`(base_h - 18)/2 = 4.5`),圆角 5,中心裁剪 +- **中**:歌词,可用区 x ∈ [30, w-29],垂直居中;主行居中(滚动模式改为左对齐并从 x=30 起滚动), + 次行始终水平居中;主行单行时 `y = center_y + size/3`,双行时 + `stack_h = size + size*0.85 + size*0.18`,主行 `y = center_y - stack_h/2 + size*0.8`, + 次行 `y = center_y + stack_h/2 - secondary*0.2` +- **右**:可视化条,中心 x = `w - 17`,中心 y = `base_h/2` +- 无媒体时改由 compact 挂件条接管:Left / Center / Right 三组; + 有 mini 内容时只画 Left 与(Center+Right)两组并贴两端,并在内容两侧画竖分隔线 + (`src/ui/widget/compact/mod.rs` `draw()`) +- 挂件宽:时间 **48**、资源 **132**;边缘 inset **9**;组内间距 **7** + +### 5.2 expanded 音乐页 360×200 + +`src/ui/expanded/music_view.rs` 常量全表: + +``` +CONTENT_PADDING = 24.0 // 四周内边距 +COVER_SIZE = 64.0 // 封面 64×64,圆角 16.0*scale,位置 (24,24) +TRACK_TEXT_GAP = 18.0 // 封面右到文字左 +TRACK_TEXT_RIGHT_INSET = 70.0 // 文字右侧预留(给可视化条) +TRACK_TITLE_BASELINE_OFFSET= 26.0 // 曲名基线 y = 24 + 26 = 50 +PROGRESS_TOP_GAP = 24.0 // 进度条中心 y = 24 + 64 + 24 = 112 +PROGRESS_TIME_WIDTH = 28.0 // 两端时间文字预留宽 +PROGRESS_TIME_GAP = 4.0 +PROGRESS_BAR_HEIGHT = 6.5 // 常态条高 +PROGRESS_BAR_HOVER_GROWTH = 3.5 // hover 增至 10.0 +PLAYBACK_CONTROLS_TOP_GAP = 42.0 // 控制行中心 y = 112 + bar_h/2 + 42 ≈ 157.25 +SKIP_BUTTON_GAP = 75.0 // 上一首/下一首中心距播放键 75 +PAGE_ARROW_RIGHT_INSET = 7.5 // 右侧翻页条 x = w - 7.5,y = h/2 +PAGE_ARROW_FADE_RATE = 5.0 +COLLAPSED_VISUALIZER_INSET = 17.0 // 视觉条右侧 inset 随展开 17 → 37 +EXPANDED_VISUALIZER_INSET = 37.0 +VISUALIZER_TITLE_OFFSET = 4.0 // 视觉条中心 y = 曲名基线 - 4 +``` + +- **左**:64 封面;**中**:曲名(15 bold)/歌手(15, α0.6) 两行,x = 24+64+18 = **106**;**右**:可视化条 +- 进度条:左右各内缩 24,两端 28 宽时间 + 4 间距;条圆角 = bar_h/2; + 填充宽度 `max(bar_total_w*progress, bar_h)`;填充只圆左端(RRect 左上/左下半径 = bar_h/2,右侧 0) +- 控制行中心 x = 180;播放键在中心、上一首 105、下一首 255 +- 点击热区(`src/ui/expanded/music_view/controls.rs`):播放 **40×40**,上/下一首 **36×36**, + 进度条热区高 **16.0\*scale**,封面整块可双击(激活媒体 App) +- 动画参数:`PROGRESS_SMOOTHING = 0.15`、`PROGRESS_HOVER_SMOOTHING = 0.18`、 + `PROGRESS_HOVER_SNAP_THRESHOLD = 0.005`、`PROGRESS_START_THRESHOLD = 0.02`、`PROGRESS_JUMP_THRESHOLD = 0.3`、 + `PLAY_STATE_RESPONSE = 0.18`、`SKIP_ANIMATION_DURATION_SECS = 0.5`、封面翻转 0.6s、 + `PAUSE_CONTROL_STIFFNESS = 0.18`、`PAUSE_CONTROL_DAMPING = 0.64`、`PAUSE_CONTROL_MIN_SCALE = 0.8`、 + `PAUSE_CONTROL_MAX_SCALE = 1.03`、`PAUSE_CONTROL_PRESS_VELOCITY = -0.18` +- 播放/暂停过渡:`cover_scale = 0.85 + 0.15*pause_t`、`cover_brightness = 0.75 + 0.25*pause_t` + (即暂停时封面缩小到 85% 并压暗到 75%),图标用 `smoothstep` 交叉淡出 + +### 5.3 expanded 挂件页(同 360×200) + +- 6×3 网格,inset 24,gap 7(见 1.9) +- 卡片:背景 `rgba(28,28,30, 0.05*alpha)`,描边 `1px rgba(255,255,255, 0.16*alpha)`,圆角 `min(12*scale, min(w,h)/2)` +- 左侧翻页条 x = **7.5**,y = `h/2`(3×14 圆角矩形,α0.4);右侧同(x = w-7.5) +- 内置挂件各视图排布: + - **时钟**:整卡居中,字号 `min(h*0.6, w*0.31)`(≥13),bold,`HH:MM` 格式 + - **日历**:月 `y+0.08h..0.26h`(bold,α0.78)/日 `y+0.27h..0.75h`(bold,字号 `min(0.53h,0.70w)`,≥26)/ + 星期 `y+0.78h..0.92h`(normal,α0.62) + - **资源占用(2×1)**:中间竖分隔(α0.09,`y+0.2h..0.8h`);两个圆环各占一半: + 环心 `(center_x, top+0.43h)`,直径 `min(0.58h, 0.62w)`(≥20),描边 `min(3*scale, d*0.12)` 圆头, + 底环 α0.13,进度弧从 **-90°** 起扫 `usage*360°`(α0.92),值文字 bold 居中,标签在 `top+0.76h`(α0.58) + - **设置(1×1)**:整卡居中齿轮图标,`icon_scale = min(w,h)*0.38/24`,α0.72 + +### 5.4 compact 各业务视图差异 + +| 视图 | 尺寸 | 排布 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| 时间挂件 | 48×27 | 居中 `HH:MM`,字号 `clamp(0.43h, 10..12)` bold,基线 `center_y + size*0.36` | +| 资源挂件 | 132×27 | 两个 metric 各 `(w-8)/2` 宽;标签 7px bold 在左(accent α0.78),数值 10px bold 在右(白); | +| 文字基线 `center_y + scale`;下方 2px 进度轨(y = 基线+5),轨 α0.13,填充 accent α0.9;中间分隔 α0.1,半高 6 | +| 通知 | 330×82 | 图标 42×42 圆角 11 @ x+18 垂直居中;文本左 = 有图标 `x+72` / 无图标 `x+20`;右内缩 18; | +| app 名基线 `top+22`(11px α0.65);标题 13px bold,基线 `top+34`(无 app 名)/ `top+43`(有 app 名)/ 垂直居中 `top+(h+13)/2`(两者皆无);详情 11px α0.72 在标题下 21px | +| 音量 | 192×37 | 喇叭图标 20px,中心 `x = left+21`;标签 12px @ `left+37`(α0.9);轨道从 `label_x + label_w + 26` 到 `right-14`,高 4 圆角 2,轨 α0.28,填充白 α1.0 | + +### 5.5 动画时长(通知 / 音量) + +- 通知:`DISPLAY_DURATION = 5s`,`ENTER_DURATION = 220ms`(`ease_out_cubic = 1-(1-t)³`), + `FADE_DURATION = 280ms`;入场位移 `(1-enter)*7.0*scale`(从上滑下 7px) +- 音量:`DISPLAY_DURATION = 1600ms`,`FADE_DURATION = 240ms`, + `VOLUME_CHANGE_THRESHOLD = 0.002`(音量变化阈值,低于此不显示) +- 歌词切换进度用 `smoothstep`:`p*p*(3-2p)`(`src/core/render/mini.rs`) +- 长文本跑马灯(`src/utils/scroll.rs`):先停 **2.0s**,再以 **35.0\*scale px/s** 滚动, + 循环间隔 `full_w + 50*scale`,裁剪区 `(x, y-size*1.2, max_w, size*1.5)` + +--- + +## 6. 图标 + +**体系**:全部是 Skia 内的 **SVG path 数据 + 填充(`PaintStyle::Fill`)**,没有线性描边图标体系, +也没有 icon font。颜色一律单色:由 `text_color`(= 白)+ alpha 决定;只有资源环用 accent 色。 +**未找到**线宽(stroke-width)概念——唯一的描边图标是设置窗口的翻页箭头(`1.8px`, round cap/join)。 + +| 图标 | 路径坐标系 | 缩放 | 实际视觉尺寸 | 来源 | +| ---------------- | ------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| 播放 ▶ | 640 单位(128..544) | `0.083 * scale` | ≈ 34.5 × 37 px | `src/icons/controls.rs` `draw_play_button` | +| 暂停 ⏸ | 640 单位 | `0.083 * scale` | ≈ 34.5 × 37 px | `draw_pause_button` | +| 快退/快进三角 | 640 单位(中心 336,320) | `path_scale * global_scale`,静止 `0.050`(左)与 `0.055`(右);动画中 `0.050~0.055` | 约 21 × 22 px | `draw_control_triangle`,`src/ui/expanded/music_view.rs` `draw_skip_button`(中心 ±10.92*scale) | +| 音乐(封面占位) | 1024 单位 | `scale*1.8`,内部 `0.024` | 随卡片 | `src/icons/music.rs` | +| 设置齿轮 | 1024 单位 | `min(w,h)*0.38/24`,内部 `0.024` | 卡片 38% 短边 | `src/icons/settings.rs` | +| 音量(4 档) | 25.75 单位 | `size / 25.75`,偏移 `(-11.089286, -7.883929)` | 传 20.0*scale | `src/icons/volume.rs`;档位 `level<=0→静音, <=1/3→低, <=2/3→中, else→高` | +| 翻页指示条 | 不是 path | — | `3.0*scale × 14.0*scale` 圆角矩形,圆角 `2.0*scale`,α×0.4 | `src/icons/arrows.rs` | + +其他图标使用点: + +- 通知 App 图标:`AppDisplayInfo::GetLogo(Size{64,64})`,按 alpha>8 计算可见包围盒,等比缩放塞进 42×42,圆角 11 裁切 +- 封面:64×64(expanded)与 18×18(mini),中心裁剪,`FilterMode::Linear + MipmapMode::Linear` +- 迷你封面圆角 5、expanded 封面圆角 16(连续超椭圆路径,不是普通 `clip_rrect`) + +--- + +## 7. 明确「未找到」的项 + +1. **刘海/挖孔造型**:不存在(无 `SetWindowRgn`、无顶部直角特例) +2. **内阴影 / inset shadow**:代码中无任何实现 +3. **字距(letter-spacing)与行高倍数**:无;行距全靠显式 y 偏移 +4. **亚克力的模糊半径/透明度数值**:由 Windows `HostBackdropBrush` 系统决定,代码只给 `HOST_BACKDROP_INSET = 1.0` +5. **强调色用于岛内 UI**:岛内几乎没有强调色(只有资源占用的 CPU/RAM/警告/危险色); + 统一强调色 `#0A84FF` 仅存在于**设置窗口**(`COLOR_ACCENT`) +6. **岛自身的亮/暗主题**:岛屿永远是深底白字;明暗主题只在设置窗口 +7. **固定渐变色背景**:真实 `dynamic` 背景是封面图,没有固定渐变; + 仅设置页预览用 `rgb(18,12,36) → rgb(8,24,48)`(可当静态替代) +8. **CSS 等价的超椭圆圆角公式**:真实实现是 Flutter 超椭圆查表(`src/utils/shape.rs`),没有简单的 border-radius 参数 + +--- + +## 8. 给 Web/Electron 复刻的映射速查 + +- 岛容器:定位在屏幕顶上 `top: 10px`、水平居中,尺寸与圆角用 spring 插值 + (`cubic-bezier` 或 JS 弹簧,参考 `stiffness 0.10 / damping 0.68`);`overflow: hidden` 做裁切 +- compact:`120×27`(有歌词时宽度跟着文本走,上限 700;只有媒体无歌词时 155),`border-radius: 13.5px`(胶囊) +- expanded:`360×200`,`border-radius: 48px`(想要 iOS 味请用 SVG 超椭圆路径) +- 背景:`#000` 不透明;"glass" 用 `backdrop-filter` + `background: rgba(10,10,14,.588)`; + "dynamic" 用封面模糊层 + `rgba(20,20,24,.471)` 叠加 +- 描边:`1px solid rgba(255,255,255,.118)`(纯色样式,展开时淡出)/ `.157`(glass、dynamic) +- 阴影:仅展开 `0 2px σ rgba(0,0,0,.11)`(σ ≈ 3px) +- 文字:`-apple-system / "Microsoft YaHei" / "Segoe UI"`,白 + alpha 分层(.65 / .72 / .9 / .6 / .5 / .58 / .62) +- 图标:内联 SVG,`fill: currentColor`,播放/暂停按 640 坐标系、尺寸 ≈ 34.5×37 diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 11a01e5..5477a7f 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -9,14 +9,23 @@ import { type IslandNotificationKind, type IslandState, } from '@classhelper/shared'; +import { squirclePath } from './squircle.js'; /** - * 灵动岛渲染进程。 + * 灵动岛渲染进程 —— 视觉与排版参考 WinIsland(Rust/Direct2D 的 Windows 动态岛)。 * - * 窗口尺寸由主进程按状态缓动(见 src/main/island.ts),这里只负责: - * - 按状态渲染 胶囊 / 展开卡片 - * - 内容交叉淡入与弹簧缩放(形变动画的"内容层") - * - 把用户点击(展开 / 收起 / 标为已读 / 打开应用)回传主进程 + * 对齐点(详见 docs/winisland-design-tokens.md): + * - 造型:**连续圆角(超椭圆 squircle)**而不是 border-radius(WinIsland `utils/shape.rs`); + * 展开卡半径取 `min(48 * 圆角系数, w/2, h/2)`,胶囊半径取 `h/2`(满圆角); + * - 底色:默认纯黑(WinIsland `default` 风格),可选亚克力毛玻璃(`glass` → 主进程 + * `setBackgroundMaterial('acrylic')`,等价于 WinIsland 的 HostBackdropBrush); + * - 描边:1px 白、alpha 30(纯黑)/ 40(玻璃、主题色),与 WinIsland `BORDER_*_ALPHA` 同值; + * - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`); + * - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**, + * 因此「字号」设置一改,岛内文字整体等比缩放(WinIsland `font_size` 模型); + * - 动效:单调缓动(无过冲、无回弹),只有淡入淡出与窗口形变,符合"开合不震动"。 + * + * 窗口尺寸仍由主进程按状态缓动(见 src/main/island.ts),渲染进程负责形状、排版与交互回传。 */ const state = ref({ mode: 'hidden', @@ -74,15 +83,22 @@ const callHint = computed(() => notification.value?.subtitle ?? ''); /** 下课补发时提示"其实上课期间就到了" */ const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass); +/** 状态色(WinIsland 资源占用色板同源):危险红 / 警告橙 / 强调蓝 */ +const stateColor = computed(() => { + if (isUrgent.value) return '#ff453a'; + if (isCall.value) return '#ff9f0a'; + if (isHomework.value) return appearance.value.accent; + return ''; +}); + /** - * 入场动画: - * - 胶囊(普通通知):**没有入场动画**,直接出现在屏幕上 - * - 展开 / 紧急:弹簧缩放入场,配合窗口尺寸缓动形成"展开"效果 + * 入场动画:胶囊直接出现(WinIsland 的 compact 没有入场动画), + * 展开 / 紧急只用透明度淡入,形变完全交给窗口尺寸(因此不会出现缩放回弹)。 */ -const transitionName = computed(() => (mode.value === 'pill' ? 'island-instant' : 'island-pop')); +const transitionName = computed(() => (mode.value === 'pill' ? 'island-instant' : 'island-fade')); const transitionDuration = computed(() => ({ - enter: mode.value === 'pill' ? 0 : 260, - leave: 140, + enter: mode.value === 'pill' ? 0 : Math.round(140 / appearance.value.speed), + leave: Math.round(90 / appearance.value.speed), })); function expand(): void { @@ -113,9 +129,68 @@ function openApp(): void { bridge?.sendAction('open-app'); } -/** 个性化外观:主进程下发后写入 CSS 变量,卡片尺寸/圆角/字号/主题色随之实时变化 */ +/** 个性化外观:主进程下发后写入 CSS 变量,形状/排版/配色立即变化(无需重启) */ const appearance = ref({ ...DEFAULT_ISLAND_APPEARANCE }); +/** 卡片与窗口的内边距(必须与主进程 CARD_PAD_X / CARD_PAD_Y 一致) */ +const CARD_PAD_X = 5; +const CARD_PAD_Y = 5; + +/** + * 各形态相对"胶囊窗口"的额外尺寸(必须与主进程 BASE.expanded/urgent/call 的增量一致): + * 卡片尺寸 = 窗口尺寸 - 内边距,因此卡片始终在窗口内居中、四周留白恒定。 + */ +const EXTRA = { + expanded: { width: 156, height: 186 }, + urgent: { width: 172, height: 202 }, + call: { width: 188, height: 218 }, +} as const; + +/** 当前形态的窗口尺寸(用于推出卡片尺寸) */ +const windowSize = computed(() => { + const width = appearance.value.width; + const height = appearance.value.height; + 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); + const scaled = (48 * appearance.value.radius) / 20; + return Math.max(0, Math.min(scaled, width / 2, height / 2)); +}); + +/** 连续圆角路径(超椭圆角,见 squircle.ts) */ +const cardPath = computed(() => squirclePath(cardSize.value.width, cardSize.value.height, cardRadius.value)); + +/** + * 卡片内联样式:尺寸、形状、配色、排版全部由外观设置驱动。 + * + * 注意 `left` 用 `calc(50% - 卡片宽/2)`(相对**当前窗口宽度**居中),而不是固定 padding: + * 窗口在形变过程中宽度由主进程逐帧插值,卡片只有居中才能保证"卡片中心全程不动"(实测 0.00px)。 + */ +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`, + '--card-path': `path("${cardPath.value}")`, + '--card-r': `${cardRadius.value}px`, + '--state-color': stateColor.value || 'transparent', + }; +}); + function applyAppearance(next: IslandAppearance | null | undefined): void { if (!next) return; appearance.value = next; @@ -126,6 +201,9 @@ 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`); } onMounted(async () => { @@ -134,7 +212,7 @@ onMounted(async () => { }); const initial = await bridge?.getState(); if (initial) state.value = initial; - // 外观:先同步一次,再订阅后续改动 + // 外观:先同步一次,再订阅后续改动(改设置立即生效) bridge?.onAppearance((next) => applyAppearance(next)); const initialAppearance = await bridge?.getAppearance?.(); applyAppearance(initialAppearance); @@ -142,126 +220,140 @@ onMounted(async () => { diff --git a/packages/desktop-client/src/island/squircle.ts b/packages/desktop-client/src/island/squircle.ts new file mode 100644 index 0000000..3a76fb3 --- /dev/null +++ b/packages/desktop-client/src/island/squircle.ts @@ -0,0 +1,79 @@ +/** + * 连续圆角(squircle / Apple 连续曲率圆角)路径生成。 + * + * 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表 + * 构造 `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡的半径取 + * `expanded_island_radius = min(48 * scale, w/2, h/2)`。 + * + * 这里用等价的超椭圆角公式生成 SVG path: + * 角点区域按 x = r * (1 - cos^p(t)), y = r * (1 - sin^p(t)) 采样, + * 其中 p = 2/n、n 取 4.2 时最接近 Apple 的"连续曲率"观感(普通圆角相当于 n = 2)。 + * 直边部分保持完全笔直,因此与 WinIsland 的"直边 + 连续圆角"一致。 + * + * 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),所以圆角半径恒定、不被拉伸。 + */ + +/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */ +const SUPERELLIPSE_EXPONENT = 4.2; + +/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */ +const CORNER_SEGMENTS = 14; + +function cornerPoints(radius: number): { x: number; y: number }[] { + const p = 2 / SUPERELLIPSE_EXPONENT; + const points: { x: number; y: number }[] = []; + for (let index = 0; index <= CORNER_SEGMENTS; index += 1) { + const t = (index / CORNER_SEGMENTS) * (Math.PI / 2); + const cos = Math.max(0, Math.cos(t)); + const sin = Math.max(0, Math.sin(t)); + // t=0 → 角外侧(贴直边),t=π/2 → 角内侧 + points.push({ + x: radius * (1 - Math.pow(cos, p)), + y: radius * (1 - Math.pow(sin, p)), + }); + } + return points; +} + +const round = (value: number): number => Math.round(value * 100) / 100; + +/** + * 生成连续圆角矩形路径。 + * + * @param width 卡片宽度(px) + * @param height 卡片高度(px) + * @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2)) + */ +export function squirclePath(width: number, height: number, radius: number): string { + const w = Math.max(1, Math.round(width * 100) / 100); + const h = Math.max(1, Math.round(height * 100) / 100); + const r = Math.max(0, Math.min(radius, w / 2, h / 2)); + + if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`; + + const corner = cornerPoints(r); + const parts: string[] = []; + + // 从左上角的"上方直边起点"开始,顺时针绘制 + parts.push(`M${round(r)},0`); + // 上边 → 右上角 + parts.push(`H${round(w - r)}`); + for (const point of corner) parts.push(`L${round(w - r + point.x)},${round(point.y)}`); + // 右边 → 右下角 + parts.push(`V${round(h - r)}`); + for (const point of corner) parts.push(`L${round(w - point.y)},${round(h - r + point.x)}`); + // 下边 → 左下角 + parts.push(`H${round(r)}`); + for (const point of corner) parts.push(`L${round(r - point.x)},${round(h - point.y)}`); + // 左边 → 左上角 + parts.push(`V${round(r)}`); + for (const point of corner) parts.push(`L${round(point.y)},${round(r - point.x)}`); + parts.push('Z'); + + return parts.join(' '); +} + +/** 展开卡半径(与 WinIsland 的 expanded_island_radius 同构:min(48*scale, w/2, h/2)) */ +export function expandedRadius(width: number, height: number, radius: number): number { + return Math.max(0, Math.min(Math.max(radius, 24), width / 2, height / 2)); +} diff --git a/packages/desktop-client/src/main/config.ts b/packages/desktop-client/src/main/config.ts index 4b65c6b..f15360b 100644 --- a/packages/desktop-client/src/main/config.ts +++ b/packages/desktop-client/src/main/config.ts @@ -28,7 +28,15 @@ function normalizeIslandAppearance(input?: Partial): IslandApp const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback; return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100)); }; - const positions: IslandAppearance['position'][] = ['top-center', 'top-left', 'top-right', 'bottom-center']; + const positions: IslandAppearance['position'][] = [ + 'top-center', + 'top-left', + 'top-right', + 'bottom-center', + 'bottom-left', + 'bottom-right', + ]; + const styles: IslandAppearance['style'][] = ['black', 'glass', 'tinted']; return { height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height), width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width), @@ -38,13 +46,17 @@ function normalizeIslandAppearance(input?: Partial): IslandApp typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent, - fontSize: clamp(input?.fontSize, 11, 18, DEFAULT_ISLAND_APPEARANCE.fontSize), + fontSize: clamp(input?.fontSize, 11, 20, DEFAULT_ISLAND_APPEARANCE.fontSize), animations: input?.animations !== false, speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed), position: positions.includes(input?.position as IslandAppearance['position']) ? (input?.position as IslandAppearance['position']) : DEFAULT_ISLAND_APPEARANCE.position, alwaysOnTop: input?.alwaysOnTop !== false, + style: styles.includes(input?.style as IslandAppearance['style']) + ? (input?.style as IslandAppearance['style']) + : DEFAULT_ISLAND_APPEARANCE.style, + idleSliver: input?.idleSliver === true, }; } diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 0f33ccb..79b69fa 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -2,6 +2,9 @@ import path from 'node:path'; import { BrowserWindow, ipcMain, screen } from 'electron'; import { DEFAULT_ISLAND_APPEARANCE, + ISLAND_POSITIONS, + ISLAND_SLIVER_WIDTH, + ISLAND_STYLES, type IslandAppearance, type IslandMode, type IslandNotification, @@ -31,12 +34,18 @@ 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) + */ const BASE = { - pill: { width: 268, height: 44 }, - expanded: { width: 404, height: 316 }, - urgent: { width: 424, height: 344 }, + pill: { width: 216, height: 34 }, + expanded: { width: 372, height: 220 }, + urgent: { width: 388, height: 236 }, /** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */ - call: { width: 440, height: 360 }, + call: { width: 404, height: 252 }, }; /** @@ -68,6 +77,12 @@ let SIZES: Record = { ...DEFAULT_ let URGENT_SIZE = { ...BASE.urgent }; let CALL_SIZE = { ...BASE.call }; +/** + * 空闲"细缝"尺寸(参考 WinIsland 的 hidden_width:空闲态是一条很窄的圆角柱)。 + * 高度取胶囊高度的一部分,宽度固定(`ISLAND_SLIVER_WIDTH`)。 + */ +let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 }; + /** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */ const TIMEOUTS = { pill: 15_000, @@ -112,6 +127,8 @@ class IslandController { private boundsFrame: number | null = null; /** 外观设置(设置页可调,主进程持久化) */ private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE }; + /** 当前生效的窗口背景材质('acrylic' | 'none'),用于避免重复设置与冒烟断言 */ + private backgroundMaterial: string = 'none'; private state: IslandState = { mode: 'hidden', @@ -175,6 +192,9 @@ class IslandController { win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + // 启动时即应用已保存的风格(glass 需要窗口创建后立刻设置材质) + this.backgroundMaterial = 'none'; + this.applyBackgroundMaterial(); win.setIgnoreMouseEvents(false); win.on('closed', () => { this.win = null; @@ -392,6 +412,12 @@ class IslandController { hide(): void { this.clearTimers(); if (!this.win || this.win.isDestroyed()) return; + // 开启"空闲细缝"时不能走 fadeOut + skipWindow:那样会完全隐藏, + // 必须让 syncWindow() 把窗口收成一条细缝(上课时段仍由 syncWindow 兜底为立即隐藏)。 + if (this.appearance.idleSliver) { + this.setState({ mode: 'hidden', reason: null }); + return; + } this.fadeOut(); this.setState({ mode: 'hidden', reason: null }, { skipWindow: true }); } @@ -497,26 +523,18 @@ class IslandController { const margin = ISLAND_MARGIN; const center = Math.round(area.x + area.width / 2); - switch (this.appearance.position) { - case 'top-left': - return { hMode: 'left', hValue: area.x + margin, vMode: 'top', vValue: area.y + margin }; - case 'top-right': - return { - hMode: 'right', - hValue: area.x + area.width - margin, - vMode: 'top', - vValue: area.y + margin, - }; - case 'bottom-center': - return { - hMode: 'center', - hValue: center, - vMode: 'bottom', - vValue: area.y + area.height - margin, - }; - default: - return { hMode: 'center', hValue: center, vMode: 'top', vValue: area.y + margin }; - } + // 6 个停靠位置(顶/底 × 左/中/右),与 WinIsland 的 DockPosition 一一对应 + const position = this.appearance.position; + const vMode: IslandAnchor['vMode'] = position.startsWith('bottom') ? 'bottom' : 'top'; + const vValue = vMode === 'bottom' ? area.y + area.height - margin : area.y + margin; + const hMode: IslandAnchor['hMode'] = position.endsWith('left') + ? 'left' + : position.endsWith('right') + ? 'right' + : 'center'; + const hValue = + hMode === 'left' ? area.x + margin : hMode === 'right' ? area.x + area.width - margin : center; + return { hMode, hValue, vMode, vValue }; } /** @@ -587,6 +605,15 @@ class IslandController { this.applyFocusable(); if (this.state.mode === 'hidden') { + // 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱; + // 关闭时保持原行为(完全淡出并隐藏)。 + if (this.appearance.idleSliver) { + this.cancelFade(); + this.win.setOpacity(this.appearance.opacity); + if (!this.win.isVisible()) this.win.showInactive(); + this.animateBounds(SLIVER_SIZE); + return; + } this.fadeOut(); return; } @@ -761,11 +788,13 @@ class IslandController { * 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。 */ setAppearance(patch: Partial): void { + const before = this.appearance; this.appearance = normalizeAppearance({ ...this.appearance, ...patch }); recomputeSizes(this.appearance); if (this.win && !this.win.isDestroyed()) { this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); + this.applyBackgroundMaterial(); if (this.win.isVisible()) { // 显示中:立即应用透明度(隐藏时由 fadeIn 负责) this.win.setOpacity(this.appearance.opacity); @@ -774,13 +803,42 @@ class IslandController { this.emitAppearance(); // 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作) 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.animations} ` + - `速度=${this.appearance.speed}`, + `透明度=${this.appearance.opacity} 位置=${this.appearance.position} 风格=${this.appearance.style} ` + + `字号=${this.appearance.fontSize} 动画=${this.appearance.animations} 速度=${this.appearance.speed} ` + + `空闲细缝=${this.appearance.idleSliver}`, ); } + /** + * 应用窗口背景材质(对应 WinIsland 的 `DWMWA_USE_HOSTBACKDROPBRUSH` / HostBackdropBrush): + * - `glass` 风格:Windows 11 的 acrylic 亚克力(真正的桌面模糊) + * - 其他风格:`none`(纯色由渲染进程绘制,避免多余的模糊开销) + * 平台不支持时静默降级为半透明纯色。 + */ + private applyBackgroundMaterial(): void { + if (!this.win || this.win.isDestroyed()) return; + const material = this.appearance.style === 'glass' ? 'acrylic' : 'none'; + if (this.backgroundMaterial === material) return; + try { + this.win.setBackgroundMaterial(material); + this.backgroundMaterial = material; + } catch (error) { + logger.warn(`设置窗口背景材质失败(降级为纯色):${(error as Error).message}`); + this.backgroundMaterial = 'none'; + } + } + + /** 当前窗口背景材质(供冒烟验证与设置页展示) */ + getBackgroundMaterial(): string { + return this.backgroundMaterial; + } + getAppearance(): IslandAppearance { return { ...this.appearance }; } @@ -807,6 +865,22 @@ 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)); + 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), + }; +} + +/** + * 空闲细缝的**窗口**尺寸(`getBounds()` 返回值;卡片尺寸 = 再减去内边距)。 + * 供冒烟验证与设置页预览使用。 + */ +export function getSliverSize(): { width: number; height: number } { + return { ...SLIVER_SIZE }; } /** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */ @@ -819,13 +893,13 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance { radius: clamp(input.radius, 8, 32), opacity: clamp(input.opacity, 0.4, 1), accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent, - fontSize: clamp(input.fontSize, 11, 18), + fontSize: clamp(input.fontSize, 11, 20), animations: input.animations !== false, speed: clamp(input.speed, 0.5, 2), - position: ['top-center', 'top-left', 'top-right', 'bottom-center'].includes(input.position) - ? input.position - : 'top-center', + position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center', alwaysOnTop: input.alwaysOnTop !== false, + style: ISLAND_STYLES.includes(input.style) ? input.style : 'black', + idleSliver: input.idleSliver === true, }; } @@ -835,11 +909,19 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance { * 现在所有形变统一走单调的 easeOutCubic(见 animateBounds),不再有任何回弹。 */ -/** 卡片相对窗口的内边距(必须与渲染进程 CSS 的 4px / 3px 一致) */ -const CARD_PAD_X = 4; -const CARD_PAD_Y = 3; +/** + * 卡片相对窗口的内边距(必须与渲染进程 IslandApp.vue 的 CARD_PAD_X/CARD_PAD_Y 一致)。 + * 取 5px 是为了给展开态的投影(WinIsland: y+2、σ=3)留出不被窗口裁掉的余量。 + */ +const CARD_PAD_X = 5; +const CARD_PAD_Y = 5; /** 灵动岛与屏幕工作区边缘的留白 */ const ISLAND_MARGIN = 8; +/** + * Windows 对窗口最小高度的实际限制(实测:请求 32px 会得到 36px)。 + * 空闲细缝等"极小形态"必须以此作为高度下限,否则断言与真实窗口会不一致。 + */ +const MIN_WINDOW_HEIGHT = 36; interface Bounds { x: number; diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index cfb2c54..769ab72 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -5,7 +5,7 @@ import type { BrowserWindow, NativeImage } from 'electron'; import type { IslandAppearance, IslandNotification } from '@classhelper/shared'; import { getConfig, saveConfig } from './config.js'; import { getDiagnostics } from './ipc.js'; -import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js'; +import { getSliverSize, island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js'; import { destroyTray, getTrayState, isTrayReady } from './tray.js'; interface SmokeResult { @@ -381,7 +381,15 @@ async function runIslandChecks( await sleep(700); clearInterval(shrinkSampler); await sleep(120); - const allowedSizes = new Set(['396x308', '416x336', '432x352', '260x38']); + // 允许的卡片尺寸由当前外观与各形态窗口尺寸推导(卡片 = 窗口 - 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)); record( '收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)', @@ -626,7 +634,7 @@ async function runIslandChecks( 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 - 8, ISLAND_SIZES.pill.width - 8]; + const expectedWidths = [ISLAND_SIZES.expanded.width - 10, ISLAND_SIZES.pill.width - 10]; const widthOk = cardWidths.every((width) => expectedWidths.includes(width)); record( '收回过程不抖动(窗口宽度单调收缩 + 卡片布局尺寸不拉伸)', @@ -704,8 +712,8 @@ async function runIslandChecks( island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false }); await sleep(350); - const expandedCard = { width: ISLAND_SIZES.expanded.width - 8, height: ISLAND_SIZES.expanded.height - 6 }; - const pillCard = { width: ISLAND_SIZES.pill.width - 8, height: ISLAND_SIZES.pill.height - 6 }; + 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 }; await runMotionProbe('expand'); const expandSamples = [...motionSamples]; @@ -771,6 +779,168 @@ async function runIslandChecks( // 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效 await sleep(250); + /** 排空灵动岛队列:连续收起直到没有活动通知与排队通知(否则"空闲态"断言会被下一条顶掉) */ + const drainIsland = async (): Promise => { + for (let index = 0; index < 20; index += 1) { + const current = island.getState(); + if (!current.active && current.queued.length === 0) return; + island.handleAction({ action: 'dismiss' }); + await sleep(120); + } + }; + + /** 灵动岛状态摘要(失败时用于定位是"没排空队列"还是"几何没生效") */ + const islandStateSummary = (): string => { + const current = island.getState(); + return `mode=${current.mode} active=${current.active?.id ?? 'null'} queued=${current.queued.length} visible=${ + islandWindow?.isVisible() ?? '-' + }`; + }; + + /** 等待灵动岛渲染出指定选择器(最多约 2s) */ + const waitForIslandDom = async (selector: string, timeoutMs = 2000): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const found = await islandWindow?.webContents + .executeJavaScript(`Boolean(document.querySelector(${JSON.stringify(selector)}))`) + .catch(() => false); + if (found) return true; + await sleep(60); + } + return false; + }; + + // 10.6) 字号:所有文本都由基础字号推导,改设置必须"立刻看到"(WinIsland 的 font_size 模型) + const readFontSizes = async (): Promise> => + (await islandWindow?.webContents + .executeJavaScript( + `(() => { + const pick = (selector) => { + const node = document.querySelector(selector); + if (!node) return 0; + return Math.round(parseFloat(getComputedStyle(node).fontSize) * 100) / 100; + }; + return { + 'pill-title': pick('.pill-title'), + 'pill-sub': pick('.pill-sub'), + title: pick('.title'), + content: pick('.content-text'), + ghost: pick('.ghost-btn'), + }; + })()`, + ) + .catch(() => ({}))) ?? {}; + + const probeFontScale = async (fontSize: number): Promise> => { + island.setAppearance({ ...appearanceBefore, fontSize }); + await drainIsland(); + island.pushNotification(makeNotification('smoke-font', 'NORMAL', '字号实时生效校验'), { + inClass: false, + }); + await waitForIslandDom('.island-card.pill .pill-title'); + await sleep(120); + const pillSizes = await readFontSizes(); + island.handleAction({ action: 'expand' }); + await waitForIslandDom('.island-card.expanded .title'); + await sleep(160); + const expandedSizes = await readFontSizes(); + // 两次读取各有一半元素不存在(返回 0),合并时只采纳 >0 的有效值 + const merged: Record = { ...pillSizes }; + for (const [key, value] of Object.entries(expandedSizes)) { + if (value > 0) merged[key] = value; + } + return merged; + }; + + await drainIsland(); + const fontSmall = await probeFontScale(13); + const fontLarge = await probeFontScale(19); + const fontKeys = Object.keys(fontSmall).filter((key) => (fontSmall[key] ?? 0) > 0); + const expectedRatio = 19 / 13; + const fontRatios = fontKeys.map((key) => ({ + key, + ratio: (fontLarge[key] ?? 0) / (fontSmall[key] ?? 1), + })); + const fontUniform = fontRatios.every((item) => Math.abs(item.ratio - expectedRatio) <= 0.03); + record( + '字号设置立刻生效(岛内所有文本按基础字号等比缩放)', + fontKeys.length >= 5 && fontUniform, + `13px→[${fontKeys.map((key) => `${key}=${fontSmall[key]}`).join(' ')}] ` + + `19px→[${fontKeys.map((key) => `${key}=${fontLarge[key]}`).join(' ')}] ` + + `期望倍率=${expectedRatio.toFixed(3)} 实测=[${fontRatios.map((item) => `${item.key}:${item.ratio.toFixed(3)}`).join(' ')}] ` + + `${islandStateSummary()}`, + ); + + // 10.7) 视觉风格:纯黑 / 毛玻璃(亚克力)/ 主题色渐变,切换立即生效 + const probeStyle = async ( + style: IslandAppearance['style'], + ): Promise<{ fill: string; stroke: string; material: string }> => { + island.setAppearance({ ...appearanceBefore, style }); + await sleep(260); + const measured = await islandWindow?.webContents + .executeJavaScript( + `(() => { + const path = document.querySelector('.island-card .shape path'); + if (!path) return null; + const style = getComputedStyle(path); + return { fill: style.fill, stroke: style.stroke }; + })()`, + ) + .catch(() => null); + return { + fill: String(measured?.fill ?? '-'), + stroke: String(measured?.stroke ?? '-'), + material: island.getBackgroundMaterial(), + }; + }; + + const blackStyle = await probeStyle('black'); + const glassStyle = await probeStyle('glass'); + const tintedStyle = await probeStyle('tinted'); + record( + '视觉风格切换立刻生效(纯黑 / 毛玻璃亚克力 / 主题色)', + blackStyle.fill === 'rgb(0, 0, 0)' && + blackStyle.material === 'none' && + glassStyle.fill.startsWith('rgba(10, 10, 14') && + glassStyle.material === 'acrylic' && + tintedStyle.fill === 'rgb(11, 11, 15)', + `纯黑 fill=${blackStyle.fill} 材质=${blackStyle.material};` + + `毛玻璃 fill=${glassStyle.fill} 材质=${glassStyle.material};` + + `主题色 fill=${tintedStyle.fill} 材质=${tintedStyle.material}`, + ); + island.setAppearance({ ...appearanceBefore, style: 'black' }); + await sleep(200); + + // 10.8) 空闲细缝(参考 WinIsland hidden_width):开启后空闲留一条细缝,来消息再展开 + const sliver = getSliverSize(); + island.setAppearance({ ...appearanceBefore, idleSliver: true }); + await drainIsland(); + await sleep(420); + const sliverBounds = islandWindow?.getBounds(); + island.pushNotification(makeNotification('smoke-sliver', 'NORMAL', '空闲细缝校验'), { inClass: false }); + await sleep(500); + const afterSliverNotification = islandWindow?.getBounds(); + record( + '空闲细缝:空闲缩为细缝、来消息自动展开(WinIsland hidden_width)', + sliverBounds?.width === sliver.width && + sliverBounds?.height === sliver.height && + afterSliverNotification?.width === island.getAppearance().width, + `细缝=${sliverBounds?.width ?? '-'}x${sliverBounds?.height ?? '-'}(期望 ${sliver.width}x${sliver.height})` + + ` 来消息后=${afterSliverNotification?.width ?? '-'}x${afterSliverNotification?.height ?? '-'} ${islandStateSummary()}`, + ); + + island.setAppearance({ ...appearanceBefore, idleSliver: false }); + island.handleAction({ action: 'dismiss' }); + await sleep(450); + record( + '关闭空闲细缝后空闲再次完全隐藏(保持原行为)', + islandWindow?.isVisible() === false, + `isVisible=${islandWindow?.isVisible() ?? '-'}`, + ); + island.pushNotification(makeNotification('smoke-after-sliver', 'NORMAL', '细缝关闭后恢复'), { + inClass: false, + }); + await sleep(320); // 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化 // (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示) const workArea = screen.getPrimaryDisplay().workArea; @@ -830,7 +1000,7 @@ async function runIslandChecks( `${item.position}=${bounds ? `${bounds.x},${bounds.y}` : '-'}${matched ? '✓' : '✗'}`, ); } - record('个性设置:停靠位置生效(左上/右上/底部居中/顶部居中)', positionOk, positionDetails.join(' ')); + record('个性设置:停靠位置生效(6 个锚点)', positionOk, positionDetails.join(' ')); island.setAppearance(appearanceBefore); // 11.4 置顶开关 → 窗口 alwaysOnTop diff --git a/packages/desktop-client/src/renderer/views/SettingsView.vue b/packages/desktop-client/src/renderer/views/SettingsView.vue index 89eaa0e..1b35a9f 100644 --- a/packages/desktop-client/src/renderer/views/SettingsView.vue +++ b/packages/desktop-client/src/renderer/views/SettingsView.vue @@ -6,10 +6,13 @@ import { DEFAULT_ISLAND_APPEARANCE, ISLAND_APPEARANCE_RANGES as RANGES, ISLAND_POSITION_LABELS, + ISLAND_STYLE_LABELS, + ISLAND_STYLES, ISLAND_POSITIONS, formatDate, type IslandAppearance, type IslandPosition, + type IslandStyle, } from '@classhelper/shared'; import type { DesktopAppInfo } from '../../types/desktop.js'; import { pingHealth, setApiBaseUrl } from '../api/http.js'; @@ -120,6 +123,11 @@ const positionOptions = ISLAND_POSITIONS.map((value) => ({ label: ISLAND_POSITION_LABELS[value], })); +const styleOptions = ISLAND_STYLES.map((value) => ({ + value, + label: ISLAND_STYLE_LABELS[value], +})); + /** 读取当前生效的外观(主进程为单一事实来源) */ async function loadIslandAppearance(): Promise { if (!window.desktop?.islandGetAppearance) return; @@ -279,10 +287,25 @@ onMounted(async () => { 用于高亮、按钮与进度条 + + + + + 纯黑 / 毛玻璃(亚克力)/ 主题色渐变 + { " /> + + + 参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏) +
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 8effa45..0e7f6a3 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -72,12 +72,17 @@ export const DEFAULT_ISLAND_APPEARANCE = { width: 268, radius: 20, opacity: 1, - accent: '#6cc4ff', + /** 默认强调色采用 Apple 系统蓝(与 WinIsland 的系统色体系一致) */ + accent: '#0a84ff', fontSize: 13, animations: true, speed: 1, position: 'top-center', alwaysOnTop: true, + /** 纯黑底(WinIsland default 风格) */ + style: 'black', + /** 空闲时完全隐藏(与原行为一致;打开后空闲会留一条细缝) */ + idleSliver: false, } as const; /** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */ @@ -86,19 +91,59 @@ export const ISLAND_APPEARANCE_RANGES = { width: { min: 220, max: 420 }, radius: { min: 8, max: 32 }, opacity: { min: 0.4, max: 1 }, - fontSize: { min: 11, max: 18 }, + fontSize: { min: 11, max: 20 }, speed: { min: 0.5, max: 2 }, } as const; -/** 停靠位置可选值 */ -export const ISLAND_POSITIONS = ['top-center', 'top-left', 'top-right', 'bottom-center'] as const; +/** 停靠位置可选值(与 WinIsland 的 DockPosition 一致:顶/底 × 左/中/右) */ +export const ISLAND_POSITIONS = [ + 'top-center', + 'top-left', + 'top-right', + 'bottom-center', + 'bottom-left', + 'bottom-right', +] as const; export const ISLAND_POSITION_LABELS: Record<(typeof ISLAND_POSITIONS)[number], string> = { 'top-center': '顶部居中', 'top-left': '左上角', 'top-right': '右上角', 'bottom-center': '底部居中', + 'bottom-left': '左下角', + 'bottom-right': '右下角', }; + +/** 视觉风格可选值(参考 WinIsland 的 island_style) */ +export const ISLAND_STYLES = ['black', 'glass', 'tinted'] as const; + +export const ISLAND_STYLE_LABELS: Record<(typeof ISLAND_STYLES)[number], string> = { + black: '纯黑(灵动岛默认)', + glass: '毛玻璃(半透明模糊)', + tinted: '主题色渐变', +}; + +/** + * 空闲细缝尺寸(参考 WinIsland hidden_width = 5:空闲态是一条很窄的圆角柱)。 + * 宽度固定 6px,高度取胶囊高度的一部分,视觉上就是一条"小黑条"。 + */ +export const ISLAND_SLIVER_WIDTH = 6; + +/** 岛内文本的排版系数(相对基础字号,集中在这里方便统一调整) */ +export const ISLAND_TYPE_SCALE = { + /** 展开卡标题 */ + title: 1.08, + /** 正文 */ + body: 0.95, + /** 次要说明 / 元信息 */ + meta: 0.78, + /** 徽标 / 按钮 */ + badge: 0.76, + /** 胶囊主标题 */ + pillTitle: 0.92, + /** 胶囊副标题 */ + pillSub: 0.74, +} as const; /** 默认学期周次上限 */ export const DEFAULT_WEEK_COUNT = 20; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index bfc0a9c..a58dbb1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -415,26 +415,45 @@ export interface IslandAppearance { height: number; /** 胶囊宽度(px,220~420) */ width: number; - /** 卡片圆角(px,8~32) */ + /** 卡片圆角(px,8~32):采用连续圆角(squircle),此值为四角的基准半径 */ radius: number; /** 整体不透明度(0.4~1) */ opacity: number; /** 主题色(强调色,用于高亮与按钮) */ accent: string; - /** 基础字号(px,11~18) */ + /** + * 基础字号(px,11~20)——参考 WinIsland 的 font_size: + * 岛内**所有文本**都由它乘以各自的排版系数得出,因此改字号会整体等比缩放。 + */ fontSize: number; /** 是否启用动画(关闭后展开/收起为瞬时) */ animations: boolean; /** 动画速度倍率(0.5 慢 ~ 2 快) */ speed: number; - /** 显示位置 */ + /** 显示位置(顶/底 × 左/中/右,与 WinIsland 的 DockPosition 对齐) */ position: IslandPosition; /** 是否始终置顶 */ alwaysOnTop: boolean; + /** + * 视觉风格(参考 WinIsland 的 island_style): + * - `black`:纯黑底(默认,WinIsland `default` 风格) + * - `glass`:半透明 + 背景模糊(WinIsland `glass` 风格) + * - `tinted`:强调色渐变(本产品特色,保持与原主题色一致) + */ + style: IslandStyle; + /** + * 空闲时是否保留一条"细缝"(参考 WinIsland 的 hidden_width:空闲态是一条很窄的圆角柱)。 + * 关闭时(默认)空闲即完全隐藏,与原行为一致。 + */ + idleSliver: boolean; } -/** 灵动岛停靠位置 */ -export type IslandPosition = 'top-center' | 'top-left' | 'top-right' | 'bottom-center'; +/** 灵动岛停靠位置(6 个锚点,与 WinIsland 一致) */ +export type IslandPosition = + 'top-center' | 'top-left' | 'top-right' | 'bottom-center' | 'bottom-left' | 'bottom-right'; + +/** 灵动岛视觉风格 */ +export type IslandStyle = 'black' | 'glass' | 'tinted'; /** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */ export interface IslandState {