- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
本文基于 MooTool 仓库中的差异记录 257-settings-nav-category-session.md,讲解 next-compose(Jetpack Compose 桌面版)如何对照 Electron 参考版(next/)实现“设置页左侧导航分类的会话记忆”:用户切换分类后离开设置页、再返回时,左侧选中项应恢复到最后所在的分类(如vault、ai),而不是每次都重置回「通用」。读完本篇,你可以掌握该功能的完整数据流(枚举分类模型 → 会话级存储 → 界面写回与恢复)、与 Electron 版的对照细节,以及如何用SettingsNavCategoryTest与 Gradle 测试命令完成验证。
1. 背景:对照 Electron 参考版的差距描述
原版差异文档将本需求定义为一项与 Electron 版(next/目录)的行为对齐:
SettingsPage在工具会话内用activeCategory记住当前左侧分类;离开设置再返回时不应总回到「通用」。
在 Electron 参考版中,该行为位于 SettingsWindow.tsx:
type SettingsCategory = 'general' | 'appearance' | 'layout' | 'editor' | 'network' | 'data' | 'vault' | 'runtime' | 'ai' | 'tools' | 'shortcuts' | 'about' function normalizeSettingsCategory(value?: string): SettingsCategory { const candidate = value ?? null return isSettingsCategory(candidate) ? candidate : 'general' } export function SettingsPage({ initialCategory, onBack }: ...) { const [activeCategory, setActiveCategory] = useState<SettingsCategory>( () => normalizeSettingsCategory(initialCategory)) const category = categories.find((item) => item.id === activeCategory) ?? categories[0] // ... <button onClick={() => setActiveCategory(item.id)} ... /> }可以归纳出 Electron 版的核心机制:activeCategory是一个组件级状态,分类 id 集合共 12 个(general到about),非法或空的入参统一经normalizeSettingsCategory回退到'general'。next-compose 版本的 DIFF-257 就是把这套语义用 Kotlin 复刻出来,并补上“点击写回 + 再次打开时恢复”的会话闭环。
2. 分类模型:SettingsNavCategory与解析回退
Compose 版的分类模型定义在 SettingsNavCategory.kt:
internal enum class SettingsNavCategory { General, Appearance, Layout, Editor, Network, Data, Vault, Runtime, Ai, Tools, Shortcuts, About, ; fun storageId(): String = name.lowercase() /** Electron `settings.category.*` label keys (SettingsWindow nav + content header). */ fun categoryLabelKey(): String = "settings.category.${name.lowercase()}" fun navIcon(): String = when (this) { General -> "⚙" Appearance -> "☼" // ... 共 12 个分支,与 Electron Lucide 图标一一对应(装饰性单色字形) } } internal fun settingsNavCategoryFromStorageId(id: String): SettingsNavCategory = SettingsNavCategory.entries.find { it.name.equals(id, ignoreCase = true) } ?: SettingsNavCategory.General /** Arrow Up/Down on settings left nav (align Electron category list keyboard). */ internal fun settingsNavCategoryStep(current: SettingsNavCategory, delta: Int): SettingsNavCategory { val entries = SettingsNavCategory.entries val next = (entries.indexOf(current) + delta).coerceIn(0, entries.lastIndex) return entries[next] }这里有三个设计点值得展开:
- id 与文案键同源:每个分类通过
storageId()生成小写字符串 id(vault、ai、runtime……),这正是会话记忆的存储值;同时categoryLabelKey()生成settings.category.{id}形式的 i18n 键,与 Electron 版categories数组中的labelKey(见 SettingsWindow.tsx)逐一对应。原始文档中“与settings.{id}文案键一致”的说法,落到源码层面即这里的settings.category.{id}前缀。 - 解析与回退:
settingsNavCategoryFromStorageId用忽略大小写的名称匹配查找枚举,找不到时回退General——对应 Electron 的normalizeSettingsCategory回退到'general'的语义,保证任何脏值(如"not-a-category")都不会让设置页崩溃。 - 键盘导航步长:
settingsNavCategoryStep支持上/下方向键在分类列表中移动,coerceIn把越界索引夹在首尾,实现“到顶到底后停留”的键盘行为;该函数对齐 Electron 分类列表的键盘操作(源码注释明确标注 align Electron)。
另有一个薄封装层 SettingsNavPresentation.kt,把fromStorageId/step/ 内容区头部图标与标题键收敛为一个object,供界面层统一调用,避免 UI 直接触碰底层解析逻辑。
3. 会话级存储:AppContainer.settingsNavCategoryId
记忆状态的存放位置在应用级容器 AppContainer.kt:
/** 设置页左侧当前分类(切工具再回设置时恢复,对照 Electron 会话内 `activeCategory`)。 */ var settingsNavCategoryId: String = SettingsNavCategory.General.storageId()从源码结构看有两个关键事实:
- 这是“会话”记忆,不是“持久”记忆。该字段是普通可变属性,初值为
general,并未写入AppSettings持久化模型,也不会随设置文件落盘。它的作用域是本次应用运行期间:切工具、离开设置再回来时恢复,应用重启后回到「通用」。这与文档标题“会话记忆(session)”的语义一致。 - 写入入口被归一。任何分类 id 在写入前都会经过
settingsNavCategoryFromStorageId(...).storageId()规范化,例如按分类打开设置的入口(AppContainer.openSettings):
fun openSettings(open: Boolean = true, categoryId: String? = null) { if (!categoryId.isNullOrBlank()) { settingsNavCategoryId = settingsNavCategoryFromStorageId(categoryId).storageId() } _showSettings.value = open }openSettings携带categoryId参数正是姊妹需求 DIFF-258(按分类打开设置页) 的入口,与本文的会话记忆共用同一存储字段,二者构成闭环:程序化跳转先规范化写入,界面恢复再从该字段读取。
4. 界面侧:点击写回与再次打开时恢复
界面逻辑集中在 SettingsScreen.kt:
var category by remember { mutableStateOf(SettingsNavPresentation.fromStorageId(container.settingsNavCategoryId)) } LaunchedEffect(showSettings, container.settingsNavCategoryId) { if (showSettings) { category = SettingsNavPresentation.fromStorageId(container.settingsNavCategoryId) } }- 首次进入:
remember初始化块用settingsNavCategoryId解析出当前分类,即“再次打开设置页从该 id 恢复”。 - 可见性/存储变化:
LaunchedEffect(showSettings, container.settingsNavCategoryId)在设置页可见、或存储 id 被外部(如openSettings(categoryId = ...))改写时,重新同步左侧选中项。 - 点击左侧导航时写回(对应文档“点击左侧导航时写回”):
SettingsNavItem( label = container.t(item.categoryLabelKey()), icon = item.navIcon(), selected = item == category, onClick = { category = item container.settingsNavCategoryId = item.storageId() } )- 方向键同样写回(SettingsScreen.kt):
Key.DirectionUp/DirectionDown经SettingsNavPresentation.step步进后,同步更新本地category与container.settingsNavCategoryId,键盘导航与鼠标点击共享同一持久化路径。
离开路径同样完整:工作台按Escape或点击侧边栏设置按钮时会调用container.openSettings(false)/openTool(...)(见 Workbench.kt 与 AppContainer.openTool),此时settingsNavCategoryId保持不变;下一次showSettings为true时,SettingsScreen按第 3 节的机制恢复到上次所在分类,而不是「通用」。
5. 验证:单元测试与 Gradle 命令
原始文档给出的验证手段是SettingsNavCategoryTest与命令./gradlew :composeApp:desktopTest --offline。测试用例位于 SettingsNavCategoryTest.kt,覆盖了会话记忆的四个关键契约:
@Test fun storageId_roundTrips_known_categories() { assertEquals(SettingsNavCategory.Vault, settingsNavCategoryFromStorageId("vault")) assertEquals(SettingsNavCategory.Data, settingsNavCategoryFromStorageId("data")) assertEquals(SettingsNavCategory.Runtime, settingsNavCategoryFromStorageId("runtime")) assertEquals("ai", SettingsNavCategory.Ai.storageId()) } @Test fun unknown_id_falls_back_to_general() { assertEquals(SettingsNavCategory.General, settingsNavCategoryFromStorageId("not-a-category")) } @Test fun arrowStepMovesWithinCategories() { assertEquals(SettingsNavCategory.Appearance, settingsNavCategoryStep(SettingsNavCategory.General, 1)) assertEquals(SettingsNavCategory.General, settingsNavCategoryStep(SettingsNavCategory.Appearance, -1)) assertEquals(SettingsNavCategory.About, settingsNavCategoryStep(SettingsNavCategory.About, 1)) } @Test fun categoryLabelKeysMatchElectronSettingsCategoryIds() { assertEquals("settings.category.general", SettingsNavCategory.General.categoryLabelKey()) assertEquals("settings.category.ai", SettingsNavCategory.Ai.categoryLabelKey()) assertEquals("settings.category.about", SettingsNavCategory.About.categoryLabelKey()) assertTrue(SettingsNavCategory.entries.all { it.navIcon().isNotBlank() }) }这四组断言分别保证:已知分类 id 与枚举双向可往返(vault/data/runtime/ai)、未知 id 回退general、方向键步进在边界处不越界、以及 i18n 文案键与 Electron 分类 id 严格对齐。在 next-compose 目录下执行:
./gradlew :composeApp:desktopTest --offline即可离线运行该模块的桌面端测试(--offline表示不拉取远端依赖,适用于 CI 缓存环境;若本地 Gradle 依赖尚未缓存,去掉该参数即可)。此外,设置分类相关的 i18n 覆盖还有 SettingsCategoryI18nTest.kt 与 SettingsNavPresentationTest.kt 可作交叉验证。
6. 小结
DIFF-257 用一个轻量设计解决了设置页的分类记忆问题:12 值枚举SettingsNavCategory作为分类的唯一来源(id、文案键、图标、键盘步进全部由它派生),AppContainer.settingsNavCategoryId作为会话期内唯一的存储点,SettingsScreen负责“读取恢复 + 点击/按键写回”双向同步,任何外部入口写入前统一经settingsNavCategoryFromStorageId规范化回退。这套实现与 Electron 参考版SettingsPage.activeCategory的语义一一对应,且以四个针对性单测锁定了回退、往返、步进与文案键对齐契约,是可复用的“跨技术栈 UI 会话状态对齐”范例。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool Next-Compose 随手记新建笔记保存守卫:DIFF-223 的实现原理、调用链与验证
MooTool Next Compose 随手记新建笔记保存守卫:DIFF 223 的实现原理、调用链与验证 导读 本文围绕 MooTool 的 Compose
桌面应用开发工具MooTool Next-Compose 中的 Vault 树滚动位置会话态:DIFF-251 设计与实现解析
MooTool Next Compose 中的 Vault 树滚动位置会话态:DIFF 251 设计与实现解析 导读 本篇文章围绕 MooTool 下一代桌面实
桌面应用开发工具MooTool next-compose:把 Vault 树右键菜单搬进会话层——DIFF-249 会话态实现详解
MooTool next compose:把 Vault 树右键菜单搬进会话层——DIFF 249 会话态实现详解 本篇以 MooTool 的 Kotlin 多
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考