☰
Pomotroid 自动浅色/深色模式实战:基于 prefers-color-scheme 的主题解析、双主题选择器与 SQLite 迁移
2026/10/12 3:07:30 网站建设 项目流程

【免费下载链接】pomotroid

:tomato: Simple and visually-pleasing Pomodoro timer

项目地址:https://gitcode.com/gh_mirrors/po/pomotroid
点击查看免费下载

Pomotroid 是一款基于 Tauri + Svelte 的番茄钟应用,其主题系统曾长期停留在"单个主题手动切换"的阶段:用户在浅色与深色环境之间移动时,必须手工更换主题。本文以仓库中openspec/changes/archive/2026-02-26-auto-light-and-dark-mode/下的设计文档(design.md)、方案文档(proposal.md)与规格文档(specs/theme-mode/spec.md)为主线,完整讲解该次迭代如何用三个设置字段取代单一theme字段、用window.matchMedia('(prefers-color-scheme: dark)')实现前端实时跟随操作系统深浅色、如何保证主窗口与设置窗口同步刷新,以及如何通过一次性 DB 迁移让老用户无感升级。读完本文,你将掌握这套"模式 + 双选择器 + 共享解析函数 + 幂等迁移"的完整实现思路,并能在自己的 Tauri 项目中直接复用。

背景:单一theme字段的局限性

在本次改动之前,Pomotroid 的主题机制非常朴素:

  • 设置存储在一张扁平的 SQLite 键值表settings中(迁移脚本定义了key TEXT PRIMARY KEY, value TEXT NOT NULL结构),当前激活主题就是表里的一个字符串键theme(主题名)。
  • 前端在启动时读取该值,按主题名查找到对应的主题 JSON,再把其中的颜色键值写入:root的 CSS 自定义属性。
  • 唯一的应用入口是applyTheme()(src/lib/stores/theme.ts),它把Theme.colors中的每一项(键已含--前缀,例如--color-background)逐个写入document.documentElement.style.setProperty(key, value)。主窗口与设置窗口共用这一个函数。

问题在于:这种模型无法感知操作系统当前的浅色/深色偏好。用户白天用浅色主题、晚上想切深色主题,只能手动进入设置更换,这与现代桌面应用"跟随系统"的预期相悖。从源码结构看,仓库内置了多达 38 个 JSON 主题(src-tauri/src/themes/mod.rs 通过include_str!在编译期嵌入),其中既有浅色主题(如pomotroid-light.json、github.json、solarized-light.json、rose-pine-dawn.json),也有大量深色主题,但应用并不区分它们的明暗属性——本次改动的范围也刻意排除了"在 JSON 中给主题标注 light/dark 分类"这一项(见 Non-Goals)。

新的设置模型:theme_mode+theme_light+theme_dark

设计文档给出的核心决策是Option B:彻底删除theme字段,而不是保留它作为缓存。三个新字段各司其职:

设置键类型取值 / 默认值语义
theme_modestring"auto"/"light"/"dark",默认"auto"决定活动主题如何解析
theme_lightstring主题名,仓库当前 Rust 默认值为"Pomotroid Light"浅色选择器选中的主题
theme_darkstring主题名,仓库当前 Rust 默认值为"Pomotroid"深色选择器选中的主题

需要说明一个细节:规格文档(specs/theme-mode/spec.md)在设计时要求两个默认值均为"Pomotroid",而当前仓库中 Rust 侧的 settings/defaults.rs 与Settings::default()(settings/mod.rs)实际落地的默认值是theme_light = "Pomotroid Light"、theme_dark = "Pomotroid"——这可以推断是后续"主题选择重设计"迭代调整的结果;前端 stores/settings.ts 的兜底默认值则仍是theme_light: 'Pomotroid'。阅读本文时请以当前仓库代码为准。

选择"推导而非缓存"的理由很关键:如果保留theme作为写透缓存,那么每次 OS 深浅色变化、每次选择器改动都必须同步更新它,还要跨主窗口与设置窗口维护一致性,存在漂移风险;而每次启动时用一次matchMedia.matches重新推导成本极低,从根源上消灭了"第二数据源"。该字段的移除是破坏性变更——任何直接读取设置 DB 的外部工具将看不到theme键,设计文档明确认为这是可接受的内部实现细节。

对应的类型层改动包括:RustSettings结构体把theme: String替换为三个新字段(settings/mod.rs);前端 types.ts 的Settings接口同步替换,并在注释中标注theme_mode合法值为'auto' | 'light' | 'dark'。

活动主题解析规则:一个共享函数搞定

活动主题永远在运行时推导,推导规则被抽成了唯一的共享函数resolveThemeName()(src/lib/utils/theme.ts):

import type { Settings } from '$lib/types'; export function resolveThemeName(settings: Settings, osDark: boolean): string { switch (settings.theme_mode) { case 'light': return settings.theme_light; case 'dark': return settings.theme_dark; default: // 'auto' return osDark ? settings.theme_dark : settings.theme_light; } }

规则可以浓缩为一张决议矩阵:

theme_modeOSprefers-color-scheme活动主题
autodarktheme_dark
autolighttheme_light
light任意恒为theme_light(忽略 OS)
dark任意恒为theme_dark(忽略 OS)

设计文档特别强调:主窗口+page.svelte与设置窗口settings/+page.svelte必须共用这一函数,因为两个窗口需要完全一致的解析逻辑,复制粘贴两份实现必然导致漂移。这对应 tasks.md 中 3.1 的交付物。

前端-only 的 OS 信号检测:为什么不需要 Rust 参与

实现 OS 深浅色检测只用了浏览器原生 API,没有引入任何新依赖(proposal.md 的 Impact 一节明确写了 "No new dependencies"):

const osDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

设计文档对这一决策给出了两个层次的论证:

  1. applyTheme()完全是前端行为。Rust 侧只需要持久化用户偏好(theme_mode/theme_light/theme_dark),永远不需要知道"当前解析出来的是哪个主题"。
  2. 备选方案成本过高:Tauri 的on_system_theme_changed窗口事件 → 发射自定义 IPC 事件 → 前端监听,要为同一个结果额外引入约 3 层管道。

另一个附带好处是无启动闪烁风险:matchMedia.matches是同步查询,不存在异步间隙,启动时可以在show()窗口之前完成主题应用(+page.svelte中applyTheme之后才调用getCurrentWebviewWindow().show())。这与仓库中"设置/统计窗口先隐藏创建、应用主题后再显示以避免白屏闪烁"的做法一脉相承。

双窗口集成:启动解析、实时监听、设置同步

主题逻辑在主窗口与设置窗口的实现是完全对称的,以主窗口 src/routes/+page.svelte 为例,完整生命周期包含三条路径:

① 启动解析:onMount中先getSettings()拿到完整设置,再getThemes()拿到全部主题,用resolveThemeName(s, osDark)找到活动主题并applyTheme();找不到时回退到themes[0]:

const osDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const active = themes.find((t) => t.name === resolveThemeName(s, osDark)) ?? themes[0]; if (active) applyTheme(active);

② 实时 OS 监听:注册matchMedia('(prefers-color-scheme: dark)')的change事件,仅在theme_mode === 'auto'时重新解析并应用主题——这正是 spec 中"OS 变化在非 Auto 模式下被忽略"场景的实现:

const mqListener = async (e: MediaQueryListEvent) => { if ($settings.theme_mode !== 'auto') return; const allThemes = await getThemes(); const t = allThemes.find((th) => th.name === resolveThemeName($settings, e.matches)); if (t) applyTheme(t); }; mq.addEventListener('change', mqListener);

③ 设置变更同步:监听 Rust 广播的settings:changed事件(由 commands.rs 的settings_set在每次保存后app.emit("settings:changed", ...)触发)。处理器先缓存旧值,再比较theme_mode、theme_light、theme_dark三者是否变化,任一变化即用新设置重新解析并应用——这样无论改动发生在哪个窗口,另一个窗口都会同步刷新。设置窗口 settings/+page.svelte 的逻辑完全相同。

此外还有一条兜底路径:自定义主题热重载(themes:changed事件)时也会按当前模式重新解析,确保主题文件被替换后界面立即刷新。

Appearance 界面:模式选择器 + 双折叠选择器 + 延迟预览

界面改造集中在 AppearanceSection.svelte,其状态模型是理解整个交互的关键:

let osDark = $state(window.matchMedia('(prefers-color-scheme: dark)').matches); let openPicker = $state<'light' | 'dark' | null>(null); // 手风琴:同时只展开一个 let lightIsActive = $derived( $settings.theme_mode === 'light' || ($settings.theme_mode === 'auto' && !osDark) ); let darkIsActive = $derived( $settings.theme_mode === 'dark' || ($settings.theme_mode === 'auto' && osDark) );

模式选择器是三个按钮的分段控件(Auto / Light / Dark),点击时先按"假设新模式"解析出主题并立即应用,再持久化theme_mode:

async function setMode(mode: string) { const resolved = resolveThemeName({ ...$settings, theme_mode: mode }, osDark); const t = themes.find((th) => th.name === resolved); if (t) applyTheme(t); await setSetting('theme_mode', mode); }

两个独立选择器各有一整套卡片列表(复用旧的单选择器卡片布局),每个卡片显示该主题自身的背景色、前景色、强调色以及三个 round 色块作为预览,选中项打勾,当前激活选择器中的选中项额外获得高亮边框。选择器的激活状态由lightIsActive/darkIsActive两个派生值决定:mode=light或mode=auto+OS 浅色时浅色选择器激活;mode=dark或mode=auto+OS 深色时深色选择器激活。

延迟预览(Deferred preview)是本次设计中一个反直觉但很正确的交互决策:点击非激活选择器中的主题,只调用setSetting保存,不调用applyTheme():

async function selectLight(theme: Theme) { if (lightIsActive) applyTheme(theme); // 仅激活时才应用 await setSetting('theme_light', theme.name); }

理由在 design.md 的 Decision 4 中写得很清楚:用户此刻是在配置未来状态。设想 OS 处于深色、模式为 Auto 时,用户在浅色选择器里挑主题——若立即应用,界面会突然变成与当前 OS 相悖的浅色主题,非常令人困惑。备选方案"无论是否激活一律预览"被否决,因为 Auto 模式 + OS 深色时操作浅色选择器的场景太容易踩坑。规格文档为此专门保留了"Auto 模式 + OS 深色 + 选择浅色主题 → 仅保存、活动主题不变"的验收场景。另外,AppearanceSection.svelte自身也挂了matchMedia监听(onMount中mq.addEventListener('change', mqListener)更新osDark),确保设置窗口打开期间用户切换 OS 深浅色时,激活徽章与高亮状态能实时纠正——这正是 design.md Risks 一节中"设置窗口不同步"风险的解法。

Rust 侧还有一个与界面联动的细节:settings_set在theme_mode/theme_light/theme_dark任一变化时,会同步更新托盘图标的配色(commands.rs)——托盘图标跟随活动主题而非模式,这印证了"Rust 只关心偏好、不推导活动主题"的分工。

数据库迁移:让老用户的 Nord 不变成 Pomotroid

为什么必须写迁移?设计文档给出了一个很容易被忽略的陷阱:seed/defaults 机制只对缺失键插入默认值(INSERT OR IGNORE,见 settings/mod.rs 的seed_defaults)。如果仅靠 seed,老用户 DB 里只有theme="Nord"而没有theme_light/theme_dark,新增字段会被塞进默认的 "Pomotroid",用户的 Nord 偏好就静默丢失了。

因此设计文档要求新增一次性迁移,在启动时、加载设置之前执行:

  1. 新增迁移写入 db/migrations.rs(迁移版本号机制:schema_version表 +run()中按version < N逐级执行,每步包在事务里,失败则整体回滚);
  2. 若theme_light键缺失:读取theme值,同时写入theme_light与theme_dark,并把theme_mode置为"auto";
  3. 删除(或保留为孤儿)旧的theme键——设计文档明确表示两种做法在功能上无差异,tasks.md 选择的是删除;
  4. 全新安装由DEFAULTSseed 直接得到三个新键;
  5. 无需回滚路径——迁移是纯增量的、对用户数据非破坏性的。

迁移的幂等性有测试保障:migration_is_idempotent对同一内存库连续执行两次run(),断言不报错且schema_version停在最新值(db/migrations.rs)。仓库中的既有迁移(如 MIGRATION_2 把time_*_mins转为time_*_secs)展示了同样的模式:INSERT OR IGNORE ... SELECT读取旧值写入新键,再DELETE旧键。规格文档为迁移定义了验收场景:老用户带自定义主题(如 Nord)升级后,两个选择器都显示 Nord 且模式为 Auto,界面无感知变化。

风险与权衡回顾

设计文档在 Risks / Trade-offs 一节做了三条明确评估,结合源码可逐一验证:

  • 启动闪烁(无风险):matchMedia是同步的,不存在异步间隙,两个窗口都在show()前完成主题应用(src/routes/+page.svelte、src/routes/settings/+page.svelte)。
  • 设置窗口失同步(有明确对策):设置窗口打开期间 OS 切换深浅色,激活高亮会过期——AppearanceSection自带的matchMedia监听实时更新osDark,派生值随之重算。
  • theme键移除是破坏性的(接受):直接读 DB 的外部工具会失去该键,但这是内部实现细节,应用自身通过迁移与推导完全自洽。

验证路径:从冒烟测试到类型检查

tasks.md 的 Cleanup & Verification 一节给出了完整的验收清单,可作为实现后的自检模板:

  • cargo test——确认移除 Rusttheme字段后无编译/测试破坏;
  • npm run check——确认前端零类型错误(theme引用全部清除);
  • 冒烟测试:全新安装默认 Auto 模式、两个选择器各显示默认主题激活;
  • 冒烟测试:切换模式与选择器,验证"正确应用或正确延迟";
  • 冒烟测试:Auto 模式下实时切换 OS 深浅色,两个窗口主题同步变化。

规格文档还要求迁移场景"对用户无可见变化",即老用户升级后看到的是与旧版一致的主题外观,但背后已切换为三字段模型。

小结

本次迭代的核心方法论值得记住:把"用户偏好"与"解析结果"彻底分离——DB 只存三个偏好字段,活动主题永远由resolveThemeName()在运行时推导;用前端原生 API 取代跨进程事件管道,避免为同一个结果引入多余的 Tauri 命令与事件层;用一次幂等迁移保护老用户数据,而不是依赖只补缺省值的 seed 机制。这套模式 + 双选择器 + 共享解析 + 迁移的组合,稍加改造即可复用于任何需要"跟随系统深浅色"的桌面应用。

【免费下载链接】pomotroid

:tomato: Simple and visually-pleasing Pomodoro timer

项目地址:https://gitcode.com/gh_mirrors/po/pomotroid
点击查看免费下载

相关推荐

上一篇:Grasscutter 报错排查:11 个高频错误码,对着日志就能改
下一篇:texture-synthesis重复变换技术:一次生成,多次应用的强大功能

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询