diff --git a/internal/e2e/minicurtain_test.go b/internal/e2e/minicurtain_test.go index 2b23f47e..475e3431 100644 --- a/internal/e2e/minicurtain_test.go +++ b/internal/e2e/minicurtain_test.go @@ -150,6 +150,50 @@ func pumpHeightIs(t *testing.T, client *automationClient, id int, want float64, } } +// pumpCollapseIsMonotone 等一扇窗的高度降到 want,**一边逼帧一边断「单调 +// 递减 + 尾段步长」**(收起不弹、收尾不跳)。 +// +// ⚠️ **两条判据**: +// +// 1. **全程单调递减**:收起的弹簧是过阻尼(ζ = 1.1),高度**一路缩**—— +// 任何一次「涨回来」都是回弹(那是展开那一支的行为,不许出现在收起里); +// 2. **尾段每帧 ≤ maxStep**:窗高 < `tailUnder` 之后(板比缝矮那一段), +// 缝渐出之后每一帧掉的只是「板缩的 ~2px + 缝跟着缩的 ~2px」(实测 ≤ +// 4px,取 6 留一点余地)——而旧版收尾那帧是「1.8px 的板 + 10px 的缝 +// 一起砍掉」= 12px,会被这一条抓住。 +// +// ⚠️ **前半程不管步长**:弹簧前半段一步 ~30px 是它自己快,不是 bug。 +func pumpCollapseIsMonotone(t *testing.T, client *automationClient, id int, want, tailUnder, maxStep float64, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + prev := -1.0 + for { + pumpFrame(t, client, id) + _, _, _, h, ok := windowBounds(t, client, id) + if ok { + if prev >= 0 { + if h > prev+1 { + t.Errorf("收起过程窗高从 %.0f 涨到 %.0f:收起不该有回弹", prev, h) + return false + } + if prev < tailUnder && prev-h > maxStep { + t.Errorf("尾段窗高从 %.0f 一下掉到 %.0f(%.0fpx),want 每帧 ≤ %.0fpx:"+ + "缝该跟着板高渐出,不能整段砍掉", prev, h, prev-h, maxStep) + return false + } + } + prev = h + if math.Abs(h-want) < 1 { + return true + } + } + if time.Now().After(deadline) { + return false + } + time.Sleep(16 * time.Millisecond) + } +} + // TestTheMiniLyricsCurtain 断三件事:迷你窗建出来是 320×84、点封面之后窗变成 // 84 + 缝 + 400、选「收起歌词」之后窗回到 84。 // @@ -260,8 +304,17 @@ func TestTheMiniLyricsCurtain(t *testing.T) { if err != nil { t.Fatalf("再点一次封面列:%v", err) } - if !pumpHeightIs(t, client, miniID, 84, 15*time.Second) { + // 🔴 **收起的过程窗高只许往下走、尾段不许跳**:弹簧是过阻尼的(收起 + // 不弹 ⇒ 单调递减),而「缝在板快收完的时候整段砍掉」那一版会让收尾 + // 那帧掉 12px。这里一边逼帧一边量 `bounds.height`:全程断「不涨」, + // 尾段(窗高 < 104,即板比缝矮那一段)再断「每帧 ≤ 6px」。 + // + // ⚠️ **采样是真时间**:`pumpFrame` 一次走一帧,相邻两次间隔 ~16ms+ + // (一次 capture + 一次 bounds 查询)——比弹簧的步长密得多,任何「掉 + // 12px」的单帧都跑不掉。而前半程弹簧一步 ~30px(它自己快,不是这个 + // bug),所以步长判据只管尾段。 + if !pumpCollapseIsMonotone(t, client, miniID, 84, 104, 6, 15*time.Second) { _, _, _, h, _ := windowBounds(t, client, miniID) - t.Fatalf("再点封面之后迷你窗没有收回 84:bounds.height = %.0f", h) + t.Fatalf("收起的过程窗高不单调或尾段有单帧跳变(现在 h=%.0f)", h) } } diff --git a/internal/ui/minicurtain.go b/internal/ui/minicurtain.go index b0f7f8cc..f1727fa1 100644 --- a/internal/ui/minicurtain.go +++ b/internal/ui/minicurtain.go @@ -84,10 +84,21 @@ const ( // 的那一块照样拦底下应用的点击——这是换「独立歌词板」这个外观接受 // 的代价(票 #536 那个「两张板拼成一块」的旧版就是为了消这条缝)。 // - // ⚠️ **小三角住在缝里**(`viewMiniCurtailArrow`):10 正好容下一个 + // ⚠️ **小三角住在缝里**(`viewMiniCurtainArrow`):10 正好容下一个 // 6px 高的三角 + 上下各 2 的留空。 miniCurtainGap = 10 + // miniCurtainResizeQuiet 是「外因 resize(用户拖窗缘)停下多久」算用户 + // 松手了(Bug 2a)。 + // + // ⚠️ **它没有现成的「live-resize 结束」事件可以挂**:框架只给 + // `OnResize`(每一次 `windowDidResize:` 都叫,分不清是我们 `SetSize` + // 还是用户在拖)——所以「松手」按「**外因 resize 静默了多久**」判。 + // 150ms 是「人拖动一下和拖动两下之间」的那个量级:比一帧长得多(拖 + // 着的时候 `windowDidResize` 是几十毫秒一串地来的),又短到松手之后 + // 卷帘几乎立刻接着走。 + miniCurtainResizeQuiet = 150 * time.Millisecond + // miniPanelAlpha 是两张板(卡片 / 歌词板)底色的不透明度。 // // ⚠️ **0.88**:这一扇窗开了毛玻璃(`MiniWindowOptions.Vibrancy`), @@ -256,8 +267,36 @@ type miniCurtainState struct { // ⚠️ **为什么要记**:窗口尺寸是**原生**的,而每一帧都会算一次目标高度—— // 不记的话每一帧都要调一次 `SetSize`(一次原生调用),而高度没变的那 // 几帧是白调的。 + // + // ⚠️ **它也是「这一次 `windowDidResize:` 是不是我们引起的」的判据** + // (Bug 2a):`w.OnResize` 报上来的高度正好等于它 ⇒ 那是我们自己 + // `SetSize` 的回执;不等于 ⇒ 是**用户拖窗缘**改的。 setHeight int + // resizeForeign 是「窗的高度正在被**弹簧之外的人**改」那一档(Bug 2a)。 + // + // 🔴 **它管的是拉锯**:动画期每一帧 `miniCurtainSyncHeight` 都 + // `SetSize(want)`——而迷你窗**故意可缩放**(`MiniWindowOptions` 不给 + // `DisableResize`,拖宽拖高是位置记忆那一半的前提)。用户在动画期拖 + // 窗缘:原生把高改成 200 → 下一帧弹簧把它拽回「84 + 比例 × 全开」→ + // 原生 `windowDidResize:` 又报一次 → 再拽回去…… 60Hz 的拉锯,窗高 + // 在两个值之间抖。 + // + // ⇒ 「报上来的高度 ≠ 我们最近 `SetSize` 的那个」⇒ 那是用户拖的: + // 这一帧起**暂停对窗写 `SetSize`**,并把弹簧**钉在用户拖到的高度上** + // (`holdAt`)——松手之后从那儿接着走,不是从半路「跳回 84 再展开」。 + // + // ⚠️ **它是「最近一次外因 resize 的时刻」**:`zero` = 没在抢。而「松手」 + // 按「外因 resize 静默了 `miniCurtainResizeQuiet`」判(框架没有现成的 + // 「live-resize 结束」事件)。 + resizeForeign time.Time + + // resizeForeignH 是用户拖到的那个高度(`resizeForeign` 为真时的窗高)。 + // + // ⚠️ **它喂给 `holdAt`**:钉弹簧的是「画出来的高度 = 用户拖到的高 / + // 全开高」——钉的是**比例**,松开手它从那个比例往 goal 走。 + resizeForeignH int + // wasOpen 是**上一帧**开着没有。 // // ⚠️ **它是「念一句」那件事的判据**:展开 / 收起各念一次,而念的那一句 @@ -761,6 +800,12 @@ type miniWindow interface { // ⚠️ **它是票 #555 加的**:跨屏拖动之后要重新判它落在哪块屏上(而那一档 // 要能**在无头那一档触发**——所以它在这一层接口上,不在 `*mygo.Window` 上)。 OnMove(fn func()) (off func()) + // OnResize 装「窗被改尺寸了」那个钩子(Bug 2a 的拉锯守卫)。 + // + // ⚠️ **它分不出「是我们 `SetSize` 的还是用户拖窗缘的」**:原生 + // `windowDidResize:` 对两者都叫——区别只能看「报上来的高度是不是我们 + // 刚设的那个」(`miniOnWindowResized` 拿 `setHeight` 比)。 + OnResize(fn func()) (off func()) // OnClose 装「关窗之前」那个钩子。 OnClose(fn func(e *mygo.CloseEvent)) (off func()) } @@ -787,6 +832,9 @@ func (m miniWindowOf) Native() *mygo.Window { return m.w } func (m miniWindowOf) OnMove(fn func()) (off func()) { return m.w.OnMove(fn) } +func (m miniWindowOf) OnResize(fn func()) (off func()) { + return m.w.OnResize(fn) +} func (m miniWindowOf) OnClose(fn func(e *mygo.CloseEvent)) (off func()) { return m.w.OnClose(fn) } @@ -809,6 +857,36 @@ func (s *Shell) miniWindow() miniWindow { return s.miniWin } +// miniCurtainWinHeight 报「这一帧窗该有多高」(Bug 1:缝跟着板高渐出)。 +// +// h 是**这一帧画出来的板高**(动画值,`s.miniCurtain.height`)。 +// +// 🔴 **缝跟着板高渐出**:窗高 = 84 + 缝 + 板高——而**缝那 10 个像素在板 +// 快收完的时候才彻底消失**。旧版是 `h > 0` 就整段加 10:收尾那一帧板只剩 +// 1.8px 而缝还是满的,`1.8 + 10` 一帧砍掉 12px(前面每一步才掉 1~3px), +// 用户看到的就是「缩到一半忽然跳一下」。 +// +// ⇒ **缝按板高渐出**:`g = min(miniCurtainGap, h)`——板比缝还高的时候缝是 +// 满的(`miniCurtainGap`),板比缝矮的时候缝跟着它缩(`h`)。 +// - 收尾那几帧:板 1.8 → 缝 1.8,窗高 = 84 + 3.6 → 掉 ~4px(不是 12); +// - 板全开的时候:缝还是满的 10(窗高 = 84 + 10 + 板高),和旧版一样。 +// +// ⚠️ **它和「画哪一段」是两件事**:`viewMiniCurtain` 只在 `h > 0` 的时候画 +// ——缝那一段(`viewMiniCurtainArrow`)是画在「板高 > 0 的那段区间上」的; +// 而这里算的是**窗该有多高**——窗 = 卡片 + 缝 + 板,缝渐出不影响「缝里画 +// 不画三角」(三角只在板已经长出来的时候看得见)。 +func miniCurtainWinHeight(h float32) int { + if h <= 0 { + return miniHeight + } + // 板比缝矮的时候,缝跟着它缩(`miniCurtainGap` 之上不取)。 + g := int(h + 0.5) + if g > miniCurtainGap { + g = miniCurtainGap + } + return miniHeight + g + int(h+0.5) +} + // miniCurtainTargetHeight 是这一帧**窗该有**多高。 func (s *Shell) miniCurtainTargetHeight() int { if !s.miniCurtain.open { @@ -875,6 +953,19 @@ func (s *Shell) miniCurtainSyncHeight(c *ui.Context) { // 还有机会跑(而那正是 F1 复现出来的那个状态)。 s.miniCurtainFocusTick(c.Now()) + // 🔴 **外因 resize 静默了 ⇒ 用户松手了,接着走**(Bug 2a)。 + // + // ⚠️ **判据是「最近一次外因 resize 的时刻」**:框架只给 `OnResize` + //(每一次 `windowDidResize:` 都叫,分不清是拖还是我们的 `SetSize`)—— + // 所以「松手」按「外因 resize 静默了 `miniCurtainResizeQuiet`」判。 + // 弹簧此刻钉在用户拖到的高度上(`miniOnWindowResized` 里 `holdAt`), + // 从这里起它朝 goal 接着走——下一帧的 `SetSize` 恢复(`resizeForeign` + // 清成零值)。 + if !s.miniCurtain.resizeForeign.IsZero() && + c.Now().Sub(s.miniCurtain.resizeForeign) >= miniCurtainResizeQuiet { + s.miniCurtain.resizeForeign = time.Time{} + } + // 🔴 **Esc 收起**(票面:「Esc、再次点击封面、点收起键都能收」)。 // // ⚠️ **用的是窗口级 `Shortcut` 而不是 `OverlayShortcut`**:后者要一个 @@ -963,15 +1054,18 @@ func (s *Shell) miniCurtainSyncHeight(c *ui.Context) { // ⚠️ **四舍五入到整像素**:窗口尺寸是整数,而动画值是浮点——每一帧 // 都调 `SetSize` 的话,同一个高度会被反复设成同一个整数(原生那一边 // 自己比),而**真的变了才设**省掉一次原生调用。 - want := miniHeight + int(h+0.5) - // ⚠️ **窗高 = 84 + 缝 + 板高**(`miniCurtainGap` 那一条缝照吃点击, - // 所以板**往下挪** 10 而不是贴着卡片)——h 是**板高**,窗高要补上缝。 - if h > 0 { - want += miniCurtainGap - } - if s.miniCurtain.setHeight != want { - s.miniCurtain.setHeight = want - s.miniCurtainSetHeight(want) + // + // 🔴 **用户正拖着窗缘的时候不写 `SetSize`**(Bug 2a):不然 60Hz 的 + // 「你拽我拽」——原生改成用户拖的高,下一帧又被拽回动画值。 + // `miniOnWindowResized` 那一档已经把弹簧钉在用户拖到的高度上了,这里 + // 唯一的活是**别插手**。而「外因 resize 静默了 ⇒ 用户松手」在开头已经 + // 把 `resizeForeign` 清了——清了这里就恢复 `SetSize`。 + if s.miniCurtain.resizeForeign.IsZero() { + want := miniCurtainWinHeight(h) + if s.miniCurtain.setHeight != want { + s.miniCurtain.setHeight = want + s.miniCurtainSetHeight(want) + } } // 念一句:展开 / 收起各念一次(读屏软件)。 @@ -1000,7 +1094,13 @@ func (s *Shell) viewMiniCurtain(c *ui.Context, st app.State, t *ui.Theme, cardBG // // ⚠️ **它不画底**(缝就是窗的透明区)——画一块底的话,缝就成了「一段 // 半透明的带」,而不是「两块板之间的空当」。 - s.viewMiniCurtainArrow(c, cardBG) + // + // 🔴 **缝里那一段的高度跟着窗的缝渐出**(`miniCurtainWinHeight`):窗高 + // 是「84 + min(缝, 板) + 板」,而这里排的是「84 + 缝这一块 + 板」—— + // 缝这一块还按常数 10 排的话,板快收完那几帧「内容比窗高」,板尾被 + // 裁到窗外去(P2 审查抓到的那条)。所以缝的高按同一个 `min(gap, 板)` + // 取——三角只在缝还容得下它的时候画。 + s.viewMiniCurtainArrow(c, cardBG, h) // 🔴 **歌词板是一张独立的圆角板**(不是旧版「从卡片底下拉出来的一段」): // 四角全圆、自己画底、自己带影——它和卡片之间那条缝是**窗的透明区**。 // @@ -1061,17 +1161,26 @@ func miniCurtainCoverLabel(open bool) string { // 两笔拼成的**空心折线** ⌃(line 版),在 12px 里只剩两条一像素出头的 // 细线——目标图要的是 popover 那种**实心**的指向尾巴。这里没有现成的实心 // 三角图标,于是自己画(`Painter.FillPath`)。 -func (s *Shell) viewMiniCurtainArrow(c *ui.Context, cardBG ui.Color) { +func (s *Shell) viewMiniCurtainArrow(c *ui.Context, cardBG ui.Color, boardH float32) { + // 🔴 **缝这一块的高是「窗的缝」同一个数**(`miniCurtainWinHeight` 的 + // `min(gap, 板高)`):窗高按渐出算、这里还按常数 10 排的话,板比缝矮的 + // 那几帧「内容比窗高」,板尾被裁到窗外去。 + gap := miniCurtainWinHeight(boardH) - miniHeight - int(boardH+0.5) ui.Box(c).Key(miniCurtainArrowKey). - Height(miniCurtainGap).FillWidth(). + Height(float32(gap)).FillWidth(). Draw(func(p *ui.Painter, r ui.Rect) { + // ⚠️ **缝容不下三角的时候不画**(渐出收尾那几帧):三角 6 高, + // 缝 < 6 的时候它上下都出界——画出来的是「被窗裁掉的半个三角」。 + if gap < 6 { + return + } // 一个 12×6 的实心三角:尖朝上、底坐在歌词板的顶边上——跨在缝 // 和板边之间(缝 10 高,三角 6 高、居中 ⇒ 上沿 = (10−6)/2 = 2、 // 下沿 = 8)。 // // ⚠️ **尖指向封面列的中线**:`miniCoverCol` 84 ⇒ 中线在 42;三角 // 宽 12 ⇒ 左沿在 42 − 6 = 36。 - top := r.Y + (miniCurtainGap-6)/2 + top := r.Y + float32(gap-6)/2 left := r.X + miniCoverCol/2 - 6 // ⚠️ **`Path` 的方法是指针接收者**(`MoveTo` / `LineTo` / `Close`), // 字面量取不了址——要先命名再调,链式返回值才是它自己。 diff --git a/internal/ui/minicurtain_spring.go b/internal/ui/minicurtain_spring.go index 34649dcc..1d93efba 100644 --- a/internal/ui/minicurtain_spring.go +++ b/internal/ui/minicurtain_spring.go @@ -193,6 +193,81 @@ func (sp *miniCurtainSpring) start(goal float64, now time.Time) { sp.last = now } +// miniCurtainSpringShift 把「全开高」从 oldFull 换成 newFull,而**画出来的 +// 高度一个字都不跳**(Bug 2b)。 +// +// 🔴 **画出来的高度 = 比例 × 全开高**:`miniCurtainRetarget` 在跨屏拖动 / +// 改分辨率的时候重写 `target`(比如 400 → 250),而比例不动 ⇒ 下一帧画出 +// 来的高度是 `0.9 × 250` 而不是 `0.9 × 400`——**一帧跳掉 ~135px**,用户看 +// 到的就是卷帘忽然矮一截再慢慢滑回去。 +// +// ⇒ 换目标的时候**把弹簧整个按 k = newFull/oldFull 缩放**: +// +// - `p` 按 k 缩 ⇒ 「比例 × 新全开高」**正好是**「比例 × 旧全开高」——画出 +// 来的高度连着,不跳; +// - `v` 也按 k 缩 ⇒ 速度是**位置/秒**(而位置是比例)——缩了之后「像素 +// 速度」不变,接着滑过去的时候不会忽然快一倍或慢一倍; +// - `pos` 也按 k 缩 ⇒ 它是「换目标时从哪儿起步」的凭据——留着旧值的话下 +// 一次 `start` 会从错的高度起步。 +// +// ⚠️ **镜像那一支自动是对的**:展开那一段画出来的高度是 `1 − |1 − p|` +// (镜像),而 `pos` 存的正是**镜像后**的值——拿 `pos` 换算,过冲那一下 +// 不会把「更高的 p」错算成「更高的板」(`0.9 × 旧高 → 0.9 × 新高`,而不是 +// `1.1 × 新高`)。 +// +// ⚠️ **goal 不缩**:它是「去展开(1)还是去收着(0)」——是**方向**,不是 +// 像素量纲,跟全开高换多少无关。 +func (sp *miniCurtainSpring) shift(oldFull, newFull int) { + if oldFull <= 0 || newFull <= 0 || newFull == oldFull { + return + } + // 🔴 **画出来的高度不许跳**:drawn = pos × oldFull ⇒ 新比例 pos' = + // drawn / newFull = pos × oldFull / newFull。 + // + // ⚠️ **封顶 1**:画出来的高度永远不许超过目标(那是票 #307 镜像守着的 + // 底线)——新全开高比「这一帧画出来的高度」还矮的时候,只能落到新目标 + // 上(drawn' = newFull),而不是越过去。那一下的落差是**避不开的** + // (目标自己变小了),而它比「比例不动」的旧版少掉 `ratio` 那一截。 + r := float64(oldFull) / float64(newFull) + pos := min(sp.pos*r, 1) + // ⚠️ **`p` 也落到 pos 上**:两个分支的 `vis` 都从 `p` 出发——展开那一支 + // 是镜像(`1 − |1 − p|`),`p > 1` 会被翻成「更矮」而不是「更高」,所以 + // 过冲里的 `p` 不能按 r 缩(1.3 × 1.6 = 2.08 ⇒ vis ≈ 0,画出来直接掉 + // 到底);而 `pos` 那一侧 `p ≤ 1` 时 `vis = p`,钉 `p = pos'` 画出来的 + // 高度正好是 `pos' × newFull`——两段分支同一个公式都成立。 + sp.p = pos + sp.pos = pos + // ⚠️ **速度按 r 缩**:`v` 是「比例/秒」,而**像素速度**是 `v × full`—— + // 按 `oldFull / newFull` 缩过之后像素速度不变,接着滑的时候不会忽然 + // 快一倍或慢一倍。 + sp.v *= r +} + +// miniCurtainSpringHoldAt 把弹簧**钉在「画出来 vis 这么高」那一格**上 +// (用户拖窗缘的时候用,Bug 2a)。 +// +// ⚠️ **它和 `hold` 差在 goal**:`hold` 是「钉在目标上、再也不动」(测试量 +// 稳态用的);而这里是「**先停在用户拖到的高度**,松开手之后**接着往 goal +// 走**」——`moving` 是 true,下一帧它从这个高度朝 goal 起步,不是「跳回 +// 半路重新展开」。 +// +// ⚠️ **`p` 取 `min(vis, 1)`**:展开那一支画出来的高度是镜像(`1 − |1 − p|`), +// 而 vis 只到 1——钉在 `p = vis` 上,镜像之后 `1 − |1 − vis| = vis`,画出 +// 来的高度还是用户拖到的那个高;钉到 >1 的话镜像会把高度翻小(过冲那一半 +// 不该由这一档产生)。 +func (sp *miniCurtainSpring) holdAt(vis float64) { + if vis < 0 { + vis = 0 + } + if vis > 1 { + vis = 1 + } + sp.p = vis + sp.v = 0 + sp.pos = vis + sp.moving = true +} + // miniCurtainSpringReset 把弹簧**钉到 0** 并停掉它。 // // ⚠️ **它用在「立刻收起」那一下**(设置开关被关掉 / 关窗 / 退出):那几秒 diff --git a/internal/ui/minicurtain_spring_test.go b/internal/ui/minicurtain_spring_test.go index e8813834..254e46e9 100644 --- a/internal/ui/minicurtain_spring_test.go +++ b/internal/ui/minicurtain_spring_test.go @@ -173,12 +173,11 @@ func TestTheMiniCurtainUnrollsWithASpring(t *testing.T) { "过冲那一帧窗比内容高,而透明的那一块照样吃点击", i, f.window, miniHeight+miniCurtainGap+miniCurtainH, miniCurtainGap, miniCurtainH) } - want := miniHeight + int(f.height+0.5) - if f.height > 0 { - want += miniCurtainGap - } + // ⚠️ **窗高公式和生产那一个函数同一份**(`miniCurtainWinHeight`): + // 板快收完的时候那条缝是渐出的(收尾那帧不许一下掉 12px)。 + want := miniCurtainWinHeight(f.height) if f.window != want { - t.Errorf("第 %d 帧窗高是 %d,want %d(84 + 缝 + 这一帧画出来的卷帘高 %.1f):"+ + t.Errorf("第 %d 帧窗高是 %d,want %d(84 + 渐出的缝 + 这一帧画出来的卷帘高 %.1f):"+ "窗高必须每一帧都等于内容高", i, f.window, want, f.height) } } @@ -545,3 +544,362 @@ func TestTheMiniCurtainHasAGapBetweenTheCardAndTheBoard(t *testing.T) { miniCoverCol/2, miniWidth/2, maxDiff, atY) } } + +// TestTheMiniCurtainCollapseTailNeverDropsTwelvePixelsInOneFrame 盯「收起的 +// 最后一帧不许一下子掉 12 像素」(收起尾段抖动,Bug 1)。 +// +// 🔴 **那条缝跟着板高渐出**:窗高 = 84 + 缝 + 板高,而**缝那 10 个像素在板 +// 快收完的时候才彻底消失**——旧版是 `h > 0` 就整段加 10:收尾那一帧板剩 +// 1.8px 而缝还是满的,一帧砍掉 12px(前面每一步才 1~3px),用户看到的就是 +// 「缩到一半忽然跳一下」。 +// +// ⚠️ **判据是「窗高序列里相邻两帧最大的那一跳」**:弹簧是过阻尼的(收起 +// 不弹),所以窗高**单调下降**——而每一帧掉下来多少,量的是相邻差。缝里 +// 那 10px 渐出之后,每一帧掉的只是「板缩的那一点 + 缝跟着缩的那一点」, +// 合起来封顶 5px(渐出那几帧每帧缝 + 板各缩 ~2px)。 +// +// ⚠️ **断的是 `s.miniCurtain.setHeight`**(记下来的窗高),不是假窗——它 +// 正是 `miniCurtainSetHeight` 调 `SetSize` 之前比较的那个数,而「窗高一步 +// 跳 12」跳的就是它。 +func TestTheMiniCurtainCollapseTailNeverDropsTwelvePixelsInOneFrame(t *testing.T) { + s, a := miniCurtainShell(t) + if err := enqueueTwo(a, t); err != nil { + t.Fatal(err) + } + a.Toggle() + a.PutLyricsFrom("", "[00:00.00]第一句\n[00:05.00]第二句\n", "") + tt := miniCurtainTester(s) + tt.Frame() + + clickCurtainCover(t, tt) + settleCurtain(t, tt, s, true) + if !s.miniCurtain.open { + t.Fatalf("起点就不对:卷帘没有展开") + } + + s.miniCurtainCollapse(tt.Now()) + // ⚠️ **先画一帧**:弹簧是在画视图的时候推的(同 + // `TestTheMiniCurtainRollsUpWithoutBouncing` 里那一句)。 + tt.Frame() + frames := miniCurtainFrames(t, tt, s, 60) + + // 🔴 **尾段每一帧掉的量不许超过 5px**:缝渐出之后,收尾那几帧掉的只是 + // 「板缩的 ~2px + 缝跟着缩的 ~2px」——而 12px 那一跳是「1.8px 的板 + + // 10px 的缝一起砍掉」。 + // + // ⚠️ **只量尾段**(窗高 < 84 + 缝 + 缝 = 104 之后):收起的前半程弹簧 + // 一步就是 ~30px(那是弹簧自己快,不是这个 bug)——而这个 bug 只在 + // 「板比缝还矮」的尾段出现(`h < miniCurtainGap` 那一段,缝还是整段加)。 + tail := -1 + for i, f := range frames { + if f.window < miniHeight+2*miniCurtainGap { + tail = i + break + } + } + if tail < 0 { + t.Fatalf("窗高序列里根本没有「板比缝矮」的尾段(%v):判据覆盖不到这个 bug", + func() []int { + w := make([]int, len(frames)) + for i, f := range frames { + w[i] = f.window + } + return w + }()) + } + maxDrop := 0 + maxAt := -1 + for i := tail + 1; i < len(frames); i++ { + if d := frames[i-1].window - frames[i].window; d > maxDrop { + maxDrop, maxAt = d, i + } + } + if maxDrop > 5 { + wins := make([]int, len(frames)) + for i, f := range frames { + wins[i] = f.window + } + t.Errorf("尾段第 %d 帧窗高一下掉了 %dpx(%v),want 每一帧 ≤ 5px:"+ + "那条缝(%dpx)在板快收完的时候还是整段砍掉的——它该跟着板高渐出", + maxAt, maxDrop, wins, miniCurtainGap) + } + + // ⚠️ **整个收起过程窗高不许涨**(过阻尼,收起不弹——同 + // `TestTheMiniCurtainRollsUpWithoutBouncing` 那一条,这里量的是窗高)。 + for i := 1; i < len(frames); i++ { + if frames[i].window > frames[i-1].window { + t.Errorf("第 %d 帧窗高 %d 比上一帧 %d **涨回来了**:收起不该有回弹", + i, frames[i].window, frames[i-1].window) + } + } + + // ⚠️ **板全开的时候缝要满**:渐出只发生在「板比缝还矮」的那一小段, + // 正常高度上窗高还是 84 + 缝 10 + 板高。 + if got, want := miniCurtainWinHeight(miniCurtainH), miniHeight+miniCurtainGap+miniCurtainH; got != want { + t.Errorf("板全开的时候窗高是 %d,want %d(84 + 缝 %d + 板 %d):"+ + "缝只在板快收完的时候才渐出", got, want, miniCurtainGap, miniCurtainH) + } +} + +// TestTheMiniCurtainRetargetKeepsTheDrawnHeightContinuous 盯「跨屏 retarget +// 不许让画出来的高度跳一下」(Bug 2b)。 +// +// 🔴 **画出来的高度 = 比例 × 全开高**:`miniCurtainRetarget` 在跨屏拖动 / +// 改分辨率的时候重写 `target`(比如 400 → 250),而比例不动 ⇒ 下一帧画出 +// 来的高度是 `0.9 × 250` 而不是 `0.9 × 400`——**一帧跳掉 ~135px**,用户看 +// 到的就是卷帘忽然矮一截再慢慢滑回去。 +// +// ⇒ retarget 的时候要把弹簧**平移**:`p / v / pos` 都按「新的全开高」换算 +// (`spring.shift`),让「比例 × 新目标」**正好是**「比例 × 旧目标」—— +// 画出来的高度一个字都不跳,然后再滑到新目标。 +// +// ⚠️ **这一条直接驱动生产那个弹簧**(`miniCurtainSpring.shift` 就是生产 +// 那一档用的):模拟「展开到一半,全开高从 400 换成 250」,断**换目标那 +// 一帧画出来的高度和换之前那一帧差不到一个像素**。 +func TestTheMiniCurtainRetargetKeepsTheDrawnHeightContinuous(t *testing.T) { + sp := &miniCurtainSpring{} + t0 := time.Now() + sp.start(1, t0) + + // 展开到一半:60fps 推 10 帧(~167ms),画出来的高度在 0.6~1 之间。 + const fps = 60 + frame := func(n int) time.Time { return t0.Add(time.Duration(n) * time.Second / fps) } + var ratio float64 + for i := 1; i <= 10; i++ { + ratio = sp.step(frame(i)) + } + if ratio <= 0 || ratio > 1 { + t.Fatalf("换目标那一刻的比例是 %.3f(want 0~1 之间):起点就不对", ratio) + } + + // 🔴 **模拟 retarget:目标变大(250 → 400 先记着,这里是 400 → 500)**—— + // 画出来的**像素高度**不许跳。 + // + // ⚠️ **先量「变大」**:`drawn' = min(drawn, newFull)`——目标变大的方向 + // 画出来的高度**一点都不能跳**(`pos × 新全开 = pos × 旧全开`)。 + before := ratio * 400 + sp.shift(400, 500) + if after := sp.pos * 500; after-before < -1 || after-before > 1 { + t.Errorf("目标变大(400→500)那一刻画出来的高度从 %.1f 跳到 %.1f:"+ + "弹簧没有被平移", before, after) + } + + // 🔴 **目标变小(500 → 250)**:画出来的高度只能落到**新目标**上 + // (`drawn' = min(drawn, 250)`——画出来的高度永远不许超过目标,那是 + // 票 #307 镜像守着的底线)——而**不是**「比例不动」旧版的 + // `ratio × 250`(那一下要多掉 `ratio` 那一截)。 + before = sp.pos * 500 + sp.shift(500, 250) + if after := sp.pos * 250; after != 250 { + t.Errorf("目标变小(500→250)之后画出来的高度是 %.1f,want %d"+ + "(落到新目标上,而不是 `ratio × 新目标` = %.1f 那样多跳一截)", + after, 250, before/500*250) + } + + // ⚠️ **接着走不跳**:shift 之后再推一帧,画出来的高度还是连着上一帧 + // 的(不是「停在原地等弹簧爬过去」)。 + after := sp.pos * 250 + next := sp.step(frame(11)) * 250 + if d := next - after; d < -30 || d > 30 { + t.Errorf("shift 之后下一帧画出来 %.1f(上一帧 %.1f):"+ + "速度没有跟着平移,弹簧从错的地方重新起步", next, after) + } +} + +// TestTheMiniCurtainRetargetShiftAlsoCoversTheMirroredSpring 盯「展开那一支 +// 的镜像在 shift 里不破」。 +// +// ⚠️ **展开那一段画出来的高度是 `1 − |1 − p|`(镜像)**:`p` 过冲到 1.1 的 +// 时候画出来的是 0.9——而 `pos` 存的正是那个**镜像后**的值。⇒ `shift` 拿 +// `pos` 换算**不会**把「过冲那一下」错算成「更高的板」(0.9 × 旧高 → +// 0.9 × 新高,而不是 1.1 × 新高)。 +// +// ⚠️ **而收起那一支不镜像**(`vis = max(0, p)`):`pos == p`,shift 同一个 +// 公式两条都成立——所以这一条两支各推一遍。 +func TestTheMiniCurtainRetargetShiftAlsoCoversTheMirroredSpring(t *testing.T) { + t.Run("展开那一支(镜像)", func(t *testing.T) { + sp := &miniCurtainSpring{} + t0 := time.Now() + sp.start(1, t0) + // 推 16 帧让它真的过冲(ζ = 0.55 欠阻尼,峰在第 13~14 帧附近)。 + for i := 1; i <= 16; i++ { + sp.step(t0.Add(time.Duration(i) * time.Second / 60)) + } + if sp.p <= 1 { + t.Fatalf("推了 16 帧 `p` 还是 %.3f:这一段没有真的过冲,镜像那一半断不到", sp.p) + } + // 🔴 **过冲里 `p > 1`,而画出来的是镜像后的 `pos`**:shift 必须拿 + // `pos` 换算(`p` 按 r 缩的话 1.16 × 1.6 ≈ 1.9 ⇒ vis ≈ 0,画出来 + // 直接掉到底)。 + // + // ⚠️ **drawn(≈375)> 新目标(250)⇒ 落到新目标**:画出来的高度 + // 永远不许超过目标(#307 的底线),那一下只能到 250。 + before := sp.pos * 400 + sp.shift(400, 250) + if after := sp.pos * 250; after != 250 { + t.Errorf("过冲那一帧(drawn %.1f)shift 之后画出来 %.1f,want %d"+ + "(落到新目标,而不是 vis(p 按 r 缩) 那样掉到底)", before, after, 250) + } + }) + + t.Run("收起那一支(不镜像)", func(t *testing.T) { + sp := &miniCurtainSpring{} + t0 := time.Now() + sp.start(1, t0) + // 先推到全开,再收起一半。 + for i := 1; i <= 40; i++ { + sp.step(t0.Add(time.Duration(i) * time.Second / 60)) + } + sp.start(0, t0.Add(40*time.Second/60)) + for i := 41; i <= 50; i++ { + sp.step(t0.Add(time.Duration(i) * time.Second / 60)) + } + // 🔴 **drawn(≈143)< 新目标(250)⇒ 一点都不能跳**:`pos × 250` + // 必须正好是 `pos × 400`。 + before := sp.pos * 400 + sp.shift(400, 250) + if after := sp.pos * 250; after-before < -1 || after-before > 1 { + t.Errorf("收起一半(drawn %.1f < 新目标 250)shift 之后画出来 %.1f:"+ + "连续的那一档断了", before, after) + } + }) +} + +// TestTheMiniCurtainSetSizeIsSuppressedWhileTheUserResizes 盯「用户拖窗缘的 +// 时候弹簧不往回拽」(Bug 2a)。 +// +// 🔴 **拉锯是怎么来的**:动画期每一帧 `miniCurtainSyncHeight` 都会 +// `SetSize(want)`——而迷你窗**故意可缩放**(`MiniWindowOptions` 不给 +// `DisableResize`,拖宽拖高是位置记忆那一半的前提)。用户在动画期拖窗缘 +// 改高度:原生把高改成 200 → 下一帧弹簧把它拉回「84 + 比例 × 全开」→ +// 原生 `windowDidResize` 又报一次 → 再改回去…… 60Hz 的拉锯,窗高在两个 +// 值之间抖。 +// +// ⇒ **外因 resize(用户拖的)要认出来**:`w.OnResize` 报上来的高度 ≠ +// 「我们最近一次 `SetSize` 设的那个」⇒ 那是用户拖的,弹簧**暂停对它写 +// `SetSize`**,而且弹簧被钉在用户拖到的那个高度上(`holdAt`)——松手之后 +// 从那儿接着走,不是从半路「跳回 84 再展开」。 +// +// ⚠️ **「我们自己 `SetSize` 引起的那次 `windowDidResize`」不算**:它报上来 +// 的高度正好是我们刚设的那个(`setHeight` 记着它)——拿高度比就分得出 +// 「系统回执」和「用户在拖」。 +// +// ⚠️ **这一条走生产那条路**:`attachMiniWindowTo` 装的 `OnResize` 钩子调 +// 的正是生产那个函数(`miniOnWindowResized`),假窗手动触发它。 +func TestTheMiniCurtainSetSizeIsSuppressedWhileTheUserResizes(t *testing.T) { + s, a := miniCurtainShell(t) + if err := enqueueTwo(a, t); err != nil { + t.Fatal(err) + } + a.Toggle() + a.PutLyricsFrom("", "[00:00.00]第一句\n[00:05.00]第二句\n", "") + tt := miniCurtainTester(s) + tt.Frame() + + fake := newFakeMiniWindow() + s.attachMiniWindowTo(fake) + + clickCurtainCover(t, tt) + // ⚠️ **只推到一半**:动画还在走(`spring.moving` 为真)的时候用户伸手 + // 拖窗缘——停稳之后再拖不是这个 bug。 + for i := 0; i < 5; i++ { + tt.Advance(time.Second / 60) + tt.Frame() + } + if !s.miniCurtain.spring.moving { + t.Fatalf("推了 5 帧弹簧就停了:拉锯那一档(动画期用户拖窗)断不到") + } + + // 🔴 **用户把窗高拖成 200**(不是我们设的任何一帧):假窗先把自己改成 + // 200,再触发「窗被改了」那个钩子——和原生 `windowDidResize` 的顺序一样 + // (先改原生,再叫钩子)。 + fake.height = 200 + fake.resize() + + // 🔴 **这一帧弹簧不许往回拽**:`miniCurtainSyncHeight` 还会跑(帧照出), + // 但它不许 `SetSize`——假窗的高度必须**还是 200**,不是「84 + 比例 × 400」。 + tt.Advance(time.Second / 60) + tt.Frame() + if got := fake.height; got != 200 { + t.Errorf("用户拖窗改成 200 之后,弹簧又把它设成了 %d:"+ + "那一帧窗高被拽回动画值,拉锯就这么来的", got) + } + + // 🔴 **弹簧被钉在用户拖到的高度上**:松手(150ms 没有外因 resize)之后 + // 从 200 接着走——不是「跳回 84 再重新展开」。 + // + // ⚠️ **钉的不是「弹簧的 goal」**:goal 还是 1(展开),钉的是**画出来的 + // 高度**——200/400 = 0.5 那个比例上,松开手它从 0.5 往 1 走。 + tt.Advance(200 * time.Millisecond) + tt.Frame() + // 那一帧弹簧应该已经「解冻」了,接着往 1 走——假窗的高度又归弹簧管。 + if !s.miniCurtain.spring.moving { + t.Fatalf("用户松手之后弹簧没有接着走:卷帘停在半路不动了") + } + // ⚠️ **解冻之后窗高从 200 附近接着走**,不是从 84 或 400 跳过去。 + tt.Advance(time.Second / 60) + tt.Frame() + if got := fake.height; got < 150 || got > miniHeight+miniCurtainGap+miniCurtainH { + t.Errorf("解冻之后窗高是 %d:它没有从用户拖到的 200 附近接着走"+ + "(要么跳回 84 了,要么跳到全开高了)", got) + } +} + +// TestTheMiniCurtainDragToAShortBoardDoesNotSnapBack 盯「用户把窗缘拖到 +// 『板 < 10px』那一档松手后,窗不被拽回 84」(P1 审查抓到的那条)。 +// +// 🔴 **板高要反解 `miniCurtainWinHeight` 的不动点**:窗高 = 84 + 缝 + 板高, +// 而**缝是渐出的**(`min(gap, 板高)`)——拖到窗高 84~94 那一档时板 < 10px、 +// 缝 < 10px,按「常数缝」反解出来的是 0 或负,`holdAt(0)` 把弹簧钉到收着 +// ⇒ 松手后窗「啪」地回到 84,而不是从用户拖到的高度接着走。 +// +// ⚠️ **反解是两段**:`h − 84 ≥ 2×gap` 时缝是满的(`b = h − 84 − gap`); +// 更矮时缝跟着板缩(`b = round((h − 84)/2)`)——窗高 90 那一格反解出的 +// 板高是 3 不是 −4。 +func TestTheMiniCurtainDragToAShortBoardDoesNotSnapBack(t *testing.T) { + // 先断反函数本身:`miniCurtainWinHeight` ↔ `miniCurtainBoardHeight` + // 往返不丢(渐出那一段是两条分支,边界 2×gap 两侧各取几格)。 + for h := 1; h < 4*miniCurtainGap; h++ { + want := miniCurtainWinHeight(float32(h)) + if got := miniCurtainBoardHeight(want); int(got+0.5) != h { + t.Errorf("板高 %d ⇒ 窗高 %d ⇒ 反解板高 %.1f:正反不是同一个数"+ + "(缝渐出那一段的两条分支断了一条)", h, want, got) + } + } + + s, a := miniCurtainShell(t) + if err := enqueueTwo(a, t); err != nil { + t.Fatal(err) + } + a.Toggle() + a.PutLyricsFrom("", "[00:00.00]第一句\n[00:05.00]第二句\n", "") + tt := miniCurtainTester(s) + tt.Frame() + + fake := newFakeMiniWindow() + s.attachMiniWindowTo(fake) + + clickCurtainCover(t, tt) + for i := 0; i < 5; i++ { + tt.Advance(time.Second / 60) + tt.Frame() + } + if !s.miniCurtain.spring.moving { + t.Fatalf("推了 5 帧弹簧就停了:动画期拖窗那一档断不到") + } + + // 🔴 **用户把窗拖到 90 高**(板 ≈ 3px、缝 ≈ 3px 那一档):旧版按常数缝 + // 反解 `90 − 84 − 10 = −4` ⇒ 钉到 0,松手弹簧把窗拽回 84。 + fake.height = 90 + fake.resize() + + // 弹簧钉的应该是「板 ≈ 3 / 全开」那一格,不是 0。 + if got := s.miniCurtain.height; got <= 0 || got > 2*miniCurtainGap { + t.Errorf("窗拖到 90 之后 `s.miniCurtain.height` 是 %.1f:"+ + "反解没走缝渐出那一段(want 0 < h < %d)", got, 2*miniCurtainGap) + } + if pos := s.miniCurtain.spring.pos; pos <= 0 { + t.Errorf("窗拖到 90 之后弹簧 `pos` 是 %.3f:它被钉到了 0,"+ + "松手会拽回 84 而不是从用户拖到的高度接着走", pos) + } +} diff --git a/internal/ui/minicurtain_state_test.go b/internal/ui/minicurtain_state_test.go index 8261fa93..4c8d2ec1 100644 --- a/internal/ui/minicurtain_state_test.go +++ b/internal/ui/minicurtain_state_test.go @@ -69,6 +69,13 @@ type fakeMiniWindow struct { // onMove 是「窗被挪动了」那个钩子(票 #555)。 onMove func() + + // onResize 是「窗被改尺寸了」那个钩子(拉锯抖动那一票)。 + // + // ⚠️ **它记的是「高度被别人改了」**:用户拖窗缘走的是原生 + // `windowDidResize:`,和我们的 `SetSize` 到的是同一个钩子——区别只在 + // 「报上来的高度是不是我们刚设的那个」。 + onResize func() } func newFakeMiniWindow() *fakeMiniWindow { @@ -119,6 +126,21 @@ func (f *fakeMiniWindow) move(x, y int) { } } +func (f *fakeMiniWindow) OnResize(fn func()) (off func()) { + f.onResize = fn + return func() { f.onResize = nil } +} + +// resize 触发「窗被改尺寸了」那个钩子。 +// +// ⚠️ **它先改,再触发**——和原生的顺序一样(`windowDidResize:` 报的是已经 +// 改完的 frame)。调用方先把 `f.height` 改成「用户拖到的那个高」,再调它。 +func (f *fakeMiniWindow) resize() { + if f.onResize != nil { + f.onResize() + } +} + // Native 返回 nil —— 假窗**没有原生那一侧**。 // // ⚠️ **那几样东西(把 `s.win` 指过去、登记唤醒句柄、接可见性回调)在假窗那一 diff --git a/internal/ui/minidisplays.go b/internal/ui/minidisplays.go index 88f3946e..2bea8e79 100644 --- a/internal/ui/minidisplays.go +++ b/internal/ui/minidisplays.go @@ -31,7 +31,12 @@ // 「哪块面积大」是两件事,而这一票要的是前者。 package ui -import "github.com/egoist/mygo" +import ( + "math" + "time" + + "github.com/egoist/mygo" +) // miniMargin 是迷你窗离工作区边的那一道。 // @@ -473,8 +478,108 @@ func (s *Shell) miniCurtainRetarget() { return } if h := s.miniCurtainMaxHeight(); h > 0 { + // 🔴 **换目标的时候把弹簧平移**(Bug 2b):画出来的高度 = 比例 × 全开 + // 高——`target` 从 400 换成 250 而比例不动,下一帧画出来的是 + // `0.9 × 250` 而不是 `0.9 × 400`,**一帧跳掉 ~135px**。`shift` 把 + // `p / v / pos` 按新旧全开高的比缩放,让「比例 × 新目标」正好是 + // 「比例 × 旧目标」——画出来的高度连着,再滑到新目标。 + s.miniCurtain.spring.shift(s.miniCurtain.target, h) s.miniCurtain.target = h return } s.miniCurtainCollapseNow() } + +// miniOnWindowResized 是「窗被改尺寸了」那一档(Bug 2a 的拉锯守卫)。 +// +// 🔴 **它只管「外因 resize」**:原生 `windowDidResize:` 分不出「是我们 +// `SetSize` 的还是用户拖窗缘的」——判据是**报上来的高度**: +// +// - 高度 == `s.miniCurtain.setHeight`(我们最近设的那个)⇒ 是我们 +// `SetSize` 的回执,什么都不做; +// - 高度 ≠ 它 ⇒ 是**用户拖窗缘**改的:弹簧**立刻停笔** +// (`resizeForeign` 记下这一刻,`miniCurtainSyncHeight` 里那一支不再 +// `SetSize`),而且把弹簧**钉在用户拖到的高度上**(`holdAt`)——松开手 +// 之后从那儿接着走,不是「跳回 84 再展开」。 +// +// ⚠️ **只在卷帘有动画的时候管**:卷帘收着(`height ≤ 0` 且没开着)的时候, +// 弹簧压根不在写窗高,用户拖窗是**唯一**改尺寸的人——那一档没有拉锯可守 +// (`miniCurtainCollapseNow` 已经把窗钉回 84 了,而用户要是再往高拖,那是 +// 位置记忆的事,`StateKey` 记的就是那个高)。 +// +// ⚠️ **「松手」不在这里判**:框架没有「live-resize 结束」的事件——它按 +// 「外因 resize 静默了 `miniCurtainResizeQuiet`」在 `miniCurtainSyncHeight` +// 里清 `resizeForeign`。 +// +// ⚠️ **它跑在 UI(主)线程上**:`windowDidResize:` 是 AppKit 的 delegate +// 回调,AppKit 只在跑事件循环那条线程上发(mygo 的主循环被 +// `runtime.LockOSThread()` 钉在主线程,`internal/darwin/objc.go:367`), +// 而它一路同步进来——`windowDidResize:` → `w.h.Resized()` +// (`mygo/internal/darwin/window.go:2088`)→ `fire(&h.w.onResize)` +// (`mygo/window.go:1859`)→ 我们的闭包,**中间没有经过 `Window.do` 那一道 +// `onMain` 序列化,也不需要**:它和 `viewMini` / `miniCurtainSyncHeight` +// 跑的就是同一条线程,直接改 `s.miniCurtain` / 弹簧不是数据竞争。 +func (s *Shell) miniOnWindowResized() { + w := s.miniWindow() + if w == nil || w.IsDestroyed() { + return + } + _, h := w.Size() + if h <= 0 || h == s.miniCurtain.setHeight { + // 我们自己 `SetSize` 的回执(或者量不到)⇒ 不是用户拖的。 + return + } + // 🔴 **卷帘不在动的时候不管**:收着的窗被拖高是用户的位置记忆,弹簧 + // 也不在写它——这一档只守「动画期」那一段。 + if !s.miniCurtain.open && s.miniCurtain.height <= 0 { + return + } + now := time.Now() + s.miniCurtain.resizeForeign = now + s.miniCurtain.resizeForeignH = h + // 🔴 **把弹簧钉在用户拖到的高度上**:钉的是**比例**(`holdAt` 收的是 + // 画出来的高度 0~1),而全高是 `miniCurtainFullHeight`——松开手它从 + // 「用户拖到的高 / 全开高」那个比例朝 goal 接着走。 + // + // ⚠️ **钉的时候也要把「画出来的高度」同步过去**:`holdAt` 改的是弹簧 + // 内部,而 `s.miniCurtain.height` 是「这一帧画出来的板高」——不同步的 + // 话下一帧 `viewMiniCurtain` 还按旧的画,而窗已经停在用户拖的高度上 + // ⇒ 窗比内容高,透明那一块在吃点击。 + full := s.miniCurtainFullHeight() + if full > 0 { + board := miniCurtainBoardHeight(h) + s.miniCurtain.spring.holdAt(board / float64(full)) + s.miniCurtain.height = float32(board) + } +} + +// miniCurtainBoardHeight 是 `miniCurtainWinHeight` 的**反函数**:拿窗高反解 +// 板高(`miniOnWindowResized` 钉弹簧用)。 +// +// 🔴 **缝渐出之后它是两段,不是一个常数**:`miniCurtainWinHeight` 那一侧 +// 窗高 = 84 + 缝 + 板高,而**缝 = min(miniCurtainGap, 板高取整)**—— +// +// - 板比缝还高(`h − miniHeight ≥ 2×miniCurtainGap`):缝是满的, +// `b = h − miniHeight − miniCurtainGap`; +// - 板比缝矮(`h − miniHeight < 2×miniCurtainGap`):缝跟着板缩, +// `h − miniHeight = b + round(b) ≈ 2b`,`b = round((h − miniHeight)/2)`。 +// +// 拿常数缝反解(`h − miniHeight − miniCurtainGap`)的话,用户把窗缘拖到 +// 「板 < 10px」那一档(窗高 84~94)时 board 反解成 0 或负——松手后 +// `holdAt(0)` 把窗拽回 84,而不是从用户拖到的高度接着走(P1 审查抓到 +// 的那条)。 +// +// ⚠️ **取整是 `int(x+0.5)`(round),跟 `miniCurtainWinHeight` 同一把尺** +// ——正解反解用的舍入不一样的话,同一扇窗高会反解出隔壁那一档的板高。 +func miniCurtainBoardHeight(winH int) float64 { + r := winH - miniHeight + if r <= 0 { + return 0 + } + // 板比缝矮那一段:h − 84 = b + round(b),两段的分界在 r = 2×gap + //(b = gap 那一格正好落在「缝刚满」上,两段公式给出同一个数)。 + if r < 2*miniCurtainGap { + return math.Floor(float64(r)/2 + 0.5) + } + return float64(r - miniCurtainGap) +} diff --git a/internal/ui/miniplayer.go b/internal/ui/miniplayer.go index e0fe9cec..da807179 100644 --- a/internal/ui/miniplayer.go +++ b/internal/ui/miniplayer.go @@ -698,6 +698,16 @@ func (s *Shell) attachMiniWindowLifecycle(w miniWindow) { // 刻的宽高)。 s.miniCurtainCollapseNow() }) + + // 🔴 **窗被改尺寸了 ⇒ 判「是不是用户拖的」**(Bug 2a)。 + // + // ⚠️ **它认不出「是我们 `SetSize` 的还是用户拖窗缘的」**:原生 + // `windowDidResize:` 对两者都叫——判据是「报上来的高度是不是我们刚设 + // 的那个」(`s.miniCurtain.setHeight`),见 `miniOnWindowResized`。 + // + // ⚠️ **它装在 `miniWindow` 那一层上**(同 `OnMove`):无头那一档要能 + // 手动触发它(`fakeMiniWindow.resize`)。 + w.OnResize(func() { s.miniOnWindowResized() }) } // releaseMini 收掉迷你那个外壳自己那几摊**逐窗口**的资源。