Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions internal/e2e/minicurtain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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。
//
Expand Down Expand Up @@ -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)
}
}
137 changes: 123 additions & 14 deletions internal/ui/minicurtain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down Expand Up @@ -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 是**上一帧**开着没有。
//
// ⚠️ **它是「念一句」那件事的判据**:展开 / 收起各念一次,而念的那一句
Expand Down Expand Up @@ -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())
}
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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`**:后者要一个
Expand Down Expand Up @@ -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)
}
Comment on lines +1063 to +1068

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: resizeForeign suppresses only SetSize; spring.step still advances the height during the quiet window, so clearing the flag writes a newer height and jumps away from the user's size.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Logic error

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/ui/minicurtain.go
**Line:** 1063:1068
**Comment:**
	*Logic Error: `resizeForeign` suppresses only `SetSize`; `spring.step` still advances the height during the quiet window, so clearing the flag writes a newer height and jumps away from the user's size.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

// 念一句:展开 / 收起各念一次(读屏软件)。
Expand Down Expand Up @@ -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)
// 🔴 **歌词板是一张独立的圆角板**(不是旧版「从卡片底下拉出来的一段」):
// 四角全圆、自己画底、自己带影——它和卡片之间那条缝是**窗的透明区**。
//
Expand Down Expand Up @@ -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`),
// 字面量取不了址——要先命名再调,链式返回值才是它自己。
Expand Down
75 changes: 75 additions & 0 deletions internal/ui/minicurtain_spring.go
Original file line number Diff line number Diff line change
Expand Up @@ -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** 并停掉它。
//
// ⚠️ **它用在「立刻收起」那一下**(设置开关被关掉 / 关窗 / 退出):那几秒
Expand Down
Loading
Loading