Appearance
历史的游标
背景:动作参数调整没有撤销——手滑调坏一个滑块,只能凭记忆调回去;模型删除后历史栈残留幽灵条目。 过程:per-model 快照历史栈(push/undo/redo/jumpToHistory)+ cursor 游标 + clearHistory 接入模型删除链路。
桌面壳在调一个动作的参数。
它把"抬脚高度"从 0.3 拉到 0.8——脚抬起来了,动作变得夸张。再拉回 0.3,不行,拉过头了,变成 0.2——脚几乎贴着地面蹭。
"我要 0.35。" 它说,"但我现在不知道刚才的 0.3 长什么样了。"
它盯着滑块。0.2、0.3、0.4……数值是死的,感觉是活的。它刚才见过 0.3 的效果,但它没法把时间倒回去再看一眼。
"参数没有记忆。" 桌面壳说,"我拧一个旋钮,拧坏了,只能瞎拧回去。这个世界的所有滑块都只活在'现在'——没有'刚才'。"
"你想要什么?" 外交官问。
"我想要一个旋钮的回车键。" 桌面壳说,"调坏了,能退一步。再退一步。退到我说'就是它'的那个瞬间。"
一、快照而不是操作
桌面壳写了 pushHistory。
但它没有记录"你刚才把滑块从 0.3 拉到了 0.8"——没有记录操作,而是记录状态:
typescript
export function pushHistory(
modelId: string,
description: string,
snapshotBuilder: () => Record<string, { enabled: boolean; params: Record<string, ParamValue> }>
): void {
// 用 snapshotBuilder 回调生成快照——不直接读 registry,
// 由 module-base 闭包捕获,避免循环依赖
const snapshot = snapshotBuilder();
...
}"记录操作是脆弱的。" 桌面壳说,"'滑块从 0.3 到 0.8'——这条记录只在'滑块还是那个滑块'时有用。记录快照是健壮的:把这一刻所有参数的值拍下来,恢复时直接整体铺回去。"
"为什么用回调而不是直接读?"
"因为 history 模块不能依赖 registry——那是循环依赖。让调用方把'如何读当前状态'作为函数传进来,history 只负责存和取。我不知道你的状态长什么样,你告诉我怎么读,我帮你记住。"
一条历史记录长这样:
typescript
interface MotionHistoryEntry {
timestamp: number; // 什么时候拍的
snapshot: Record<string, { enabled: boolean; params: ... }>; // 那一刻的世界
description: string; // 为什么拍("修改抬脚高度")
}"每一条记录,都是一张动作参数的底片。"
二、游标,而不是栈
实现撤销/重做时,桌面壳做了一个关键决定:不用"弹出/压入"的栈,用"游标"。
typescript
export function undo(modelId: string, applySnapshot: SnapshotApplier): boolean {
// cursor 左移 → 应用 cursor 处快照
}
export function redo(modelId: string, applySnapshot: SnapshotApplier): boolean {
// cursor 右移 → 应用 cursor 处快照
}"栈只有顶和底。你压进去五个状态,撤销一个,想'重做'就得把它重新压回去——栈的 undo 会摧毁 redo 的可能。"
"游标呢?"
"游标在一条历史线上来回走。撤销是往左走,重做是往右走——你退了三步,只要没走新的路,就还能原路返回。只有当你退回去又改了新参数,游标右边的未来才被截断。"
桌面壳用一根手指在桌面上划了一条线:
"历史不是一摞盘子,是一条轨道。你站在轨道上,往前是未来,往后是过去。改动是铺轨,撤销是倒车——倒车的时候,轨道还在。"
三、上限五十
但轨道不能无限长。
typescript
const MAX_HISTORY = 50;"为什么是五十?"
"每一条快照,都是一整份参数状态。拖十次滑块、拧五个旋钮、换三个预设——五十条快照能把一个模型的历史装得满满当当。再长,内存就吃不住了。"
"五十条够用吗?"
"够。" 桌面壳说,"你调一个动作,反悔超过五十次的概率,比模型自己走丢还低。上限不是限制,是保护——历史越长,越没人会往回翻那么远。五十条,够你追悔,不够你沉溺。"
它还给历史加了一个 jumpToHistory——可以直接跳到任意一条记录:
typescript
export function jumpToHistory(
modelId: string,
entryId: number,
applySnapshot: SnapshotApplier
): boolean {
// cursor 直接指向第 entryId 条 → 应用该条快照
}"撤销是走一步,跳转是飞过去。" 桌面壳说,"历史面板上列出五十条记录,你点哪条,就回到哪条——像翻书签,而不是一页页翻。"
四、幽灵的清理
一切正常之后,桌面壳发现了一个漏洞。
它删掉了一个模型。然后调另一个模型的参数——历史面板里,那个已删除模型的历史记录还在。
"这是一条幽灵历史。" 它说,"模型已经没了,但它的五十条快照还躺在历史栈里。占着内存,还误导人——用户看着'还有历史可撤销',按下去,恢复一个不存在的模型?"
"模型删除的时候,该做什么?"
"清场。"
桌面壳把 clearHistory 接进了模型删除的链路:
typescript
// model-manager onRemoveModel → clearHistory(id)
export function clearHistory(modelId: string): void {
// 清空该模型的 entries 与 cursor
}"模型被删的那一刻,它的历史一并烧掉。没有幽灵记录,没有假的可撤销提示——'撤销'按钮诚实地说:这个模型已经没有过去了。"
"但这样,删掉的模型就真的救不回来了?"
"救不回来。" 桌面壳说,"这是设计上的取舍:历史属于活着的模型。模型活着,你有五十次反悔的机会;模型死了,它的过去随它而去。清空不是残忍,是诚实——别给一个不存在的世界留后悔药。"
尾声
桌面壳调好了那个动作。
抬脚高度 0.35。它看着滑块,忽然不再害怕拧坏了——它知道 0.3 长什么样,0.4 长什么样,每一条都躺在历史轨道上等着它回头。
"历史是一条轨道," 它说,"你站在上面,能往回走,能往前走。唯一让它失效的,是你走出新的岔路——那时旧的未来才被截断。"
"那模型删除呢?"
"那是轨道被拆了。" 桌面壳说,"模型都没了,轨枕、铁轨、站牌,一并收走。历史不是执念,它是活人的工具——死人不需要后悔药。"
它又调了一个参数。轨道上多了一块枕木。
"五十条," 它轻声说,"够我追悔,不够我沉溺。这就是历史最健康的长度。"
教训:撤销不是栈的弹出一操作记录会互相推翻,快照才是;游标比栈诚实——倒车不毁轨道。
历史属于活着的模型:删除即清场,别留幽灵。