From 87af0d7a11730c24a37de1c45b3d5269987e057f Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 3 Nov 2026 10:13:56 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=9D=A1=E4=B8=89?= =?UTF-8?q?=E6=9E=9A=E8=AF=BB=E6=95=B0=E6=94=B6=E6=88=90=E7=BD=91=E6=98=93?= =?UTF-8?q?=E4=BA=91=E5=BC=8F=E7=BC=BA=E5=8F=A3=EF=BC=9A=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=8F=B3=E4=B8=8A=E8=A7=92=E8=A2=AB=E5=92=AC=E6=8E=89=E4=B8=80?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E6=95=B0=E5=AD=97=E5=B5=8C=E5=9C=A8=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版(PR #736)的角标贴在图形右上外侧;本票改成网易云式——图形 右上角被一块底色圆角块「咬掉」,数字嵌进那个缺口里。 - 缺口块:Attach(TopRight→TopRight),上沿探出 2、左缘收进图形右缘 6(只咬右上角那一口,不包住整个图形——宽字档「99k+」≈22 比图形 18 还宽,全包会把图标吃得只剩下半截),块的主体往右探进图形外 的空气里。宽按字量(MeasureText + 两侧 3),「+」结构上截不掉。 - 缺口色 = 按钮底下那块底(coverWash(t.Background)),调用方传进 countBadge;悬停/按下不用换色——flat.feel 的 DrawOver 画在一切 之上,缺口和四周同帧变深。 - 缺口块 PassThrough:底下的按钮照点,悬停照常。 - 三枚键共用同一份(赞键自己拼图形盒子也走 countBadge),键宽 仍是 32、带不带数字都一样。 - E2E 改成「缺口嵌字」判据:图形右上角那一口被咬成底色(以前是 字形笔画)、数字左缘收进图形右缘以内、主体探在图形右侧空气里。 --- internal/e2e/counts_test.go | 63 ++++--- internal/ui/comments_entry.go | 8 +- internal/ui/like.go | 9 +- internal/ui/playbar.go | 5 +- internal/ui/playbar_counts.go | 264 ++++++++++++++++++----------- internal/ui/playbar_counts_test.go | 88 ++++++---- 6 files changed, 273 insertions(+), 164 deletions(-) diff --git a/internal/e2e/counts_test.go b/internal/e2e/counts_test.go index 80697e64..d69d1f77 100644 --- a/internal/e2e/counts_test.go +++ b/internal/e2e/counts_test.go @@ -13,9 +13,10 @@ package e2e // // 1. **角标文本在无障碍树里是那一枚键的子节点**(它是 `Attach` 上去的, // 无障碍树的父子关系跟着元素树走,不跟「它悬在哪儿」走)。 -// 2. **角标的 bounds 落在那一枚键的右上象限**(`Attach(AnchorTopRight, -// AnchorTopLeft)`:角标左缘贴图形右缘、上缘贴图形上沿,数字整个在图形 -// 右上外侧)。 +// 2. **角标咬进图形的右上角**(`Attach(AnchorTopRight, AnchorTopRight)` + +// `Top(-out).Right(-(notchW-bite))`:缺口块的左缘收进图形右缘 +// `badgeNotchBite`(6),把那一角的字形笔画盖成底色,数字从缺口里往右 +// 排、主体探在图形外的空气里)。 // // 这两层在无头 Tester 里问不到——框架只在**辅助技术问起**时才描述那一帧 // (`mygo/ui/access.go` 的 `accessibilityOn`:真窗口里是系统的读屏软件问起, @@ -57,8 +58,10 @@ import ( // bounds**。这一层于是断两样: // // 1. 键的无障碍名字里带着那个数(「赞 9k+」)——读屏用户听得到。 -// 2. `capture` 回来的画面上,**键的右上象限有墨迹、而图形正右方那一带没有** -// ——横排那一版(数字在图形右边那一格、中心 y ≈ 键中心 y)立刻红。 +// 2. `capture` 回来的画面上,**图形的右上角被咬掉一块**(那一带以前是字形 +// 笔画、现在是底色),而**数字的墨迹从图形右缘以内起排、一直探到图形 +// 右侧的空气里**——外侧角标那一版(数字整条在图形右缘以右、图形的角 +// 完好无损)和横排那一版(数字中心 y ≈ 键中心 y)都立刻红。 func TestThePlayBarCountsSitAsBadges(t *testing.T) { if os.Getenv("BIU_RUN_MYGO_E2E") != "1" { t.Skip("设置 BIU_RUN_MYGO_E2E=1 才运行真窗口 E2E") @@ -151,28 +154,38 @@ func TestThePlayBarCountsSitAsBadges(t *testing.T) { // 底色取样点:键的左上角(键没有底、是播放条那一行的底色)。 bg := pixelAt(img, geom, key.Bounds.X+2, key.Bounds.Y+2) - // 🔴 **两条判据把「贴在右上外侧」钉死**(角标的左上角钉在图形盒子 - // 右上角 = icx+9, icy−9,不收不压,字左对齐从图形右缘起排): + // 🔴 **三条判据把「缺口嵌字」钉死**(缺口块的左缘收进图形右缘 + // `badgeNotchBite` = 6 → 咬掉图形右上角 [icx+3, icx+9] 那一带; + // 数字在缺口里居中,左起 ≈ icx+6、贴着图形上沿那一带往右排): // - // R1 [icx+13, icx+27] × [icy−9, icy+1]——角标的**本体那一带**:实测 - // 数字笔画落在图形右缘往右 ≈ icx+9 起、贴着图形上沿的横带里。 - // 横排版(数字中线 ≈ icy、左缘起在图形右沿 +2)的左半截会撞进 - // 这里的下半截——所以这条还要带上「上沿那一半」:横排版的数字 - // 上不到 icy−9 那一带。 - // R2 [icx+9, icx+11.5]——角标的**左端**:`AnchorTopLeft` 把它钉在图形 - // 右缘(icx+9),左端就落在 icx+9——**一点不往图形上收**。而横排 - // 那一版的数字从图形右沿 +2 起排(icx+11 才有墨),icx+9 那一带 - // 是空的;往回收 1.5 那版左端在 icx+7.5,这里反而没墨。这条有墨 - // ⇒ 数字的左缘就贴着图形右缘,不多不少。 - if ink := darkerIn(img, geom, icx+13, icy-9, 14, 10, bg); ink == 0 { - t.Errorf("「%s」键图形右上外侧(%.0f,%.0f 起 14×10 DIP)一点墨迹都没有——"+ - "数字没有收成角标(角标本体在图形右缘往右、贴着上沿那一带)", - c.name, icx+13, icy-9) + // R1 [icx+6, icx+10] × [icy−8, icy+1]——数字**骑在图形右缘上**的那一截: + // 缺口里的数字左端起在 icx+6(图形右缘以**内** 3 像素),所以图形 + // 右缘里外各几像素都有字——外侧角标那一版这里只剩图形右缘右半边 + // 有墨(左半边是完好的字形笔画、颜色是图形色不是字色……这条量的是 + // 「比底色深」,横排版数字中线 ≈ icy 也上不到 icy−8 这一带)。 + // R2 [icx+3, icx+5.5] × [icy−7, icy−3]——图形右上角**被咬掉的那一口**: + // 缺口块把它盖成了底色。以前这里有三枚图标各自的笔画(星的右臂、 + // 拇指背的起弧、气泡的右上那一段弧)——缺口的判据就是这一带 + // **不再有比底色深的像素**。外侧角标那一版(图形完好)这里全是 + // 墨迹;横排版更是整段笔画。 + // R3 [icx+10, icx+22] × [icy−8, icy+1]——数字**探在图形右侧空气里**的 + // 主体:缺口往右长过图形右缘(最窄那一档也长出 ≈9 像素),「9k+」 + // 那一串的本体就落在这一带。缺了它说明数字全缩在图形框上——那是 + // 「盖住整个图形」的错误版,不是咬在角上的缺口。 + if ink := darkerIn(img, geom, icx+6, icy-8, 4, 9, bg); ink == 0 { + t.Errorf("「%s」键图形右缘一带(%.0f,%.0f 起 4×9 DIP)一点墨迹都没有——"+ + "数字没有骑进图形的缺口(缺口嵌字是数字左缘收进图形右缘以内、骑在缺口上)", + c.name, icx+6, icy-8) } - if ink := darkerIn(img, geom, icx+9, icy-9, 2.5, 10, bg); ink == 0 { - t.Errorf("「%s」键图形右缘贴边(%.0f,%.0f 起 2.5×10 DIP)一点墨迹都没有——"+ - "数字的左缘不在图形右缘上(横排版左缘在缝后、内收版左缘在图形上,这里都该是空的)", - c.name, icx+9, icy-9) + if ink := darkerIn(img, geom, icx+3, icy-7, 2.5, 4, bg); ink != 0 { + t.Errorf("「%s」键图形右上角(%.0f,%.0f 起 2.5×4 DIP)还有 %d 个比底色深的像素——"+ + "那一角没有被咬掉(缺口要把图形右上角的笔画盖成底色,这一带该是空的)", + c.name, icx+3, icy-7, ink) + } + if ink := darkerIn(img, geom, icx+10, icy-8, 12, 9, bg); ink == 0 { + t.Errorf("「%s」键图形右侧空气里(%.0f,%.0f 起 12×9 DIP)一点墨迹都没有——"+ + "数字没有探出图形右缘(缺口主体在图形外面,「9k+」那一串的本体在这一带)", + c.name, icx+10, icy-8) } } } diff --git a/internal/ui/comments_entry.go b/internal/ui/comments_entry.go index 0e2a65ea..d29c7773 100644 --- a/internal/ui/comments_entry.go +++ b/internal/ui/comments_entry.go @@ -110,8 +110,12 @@ func (s *Shell) viewCommentsButton(c *ui.Context, st app.State) { Height(iconHitMd).Padding(space.None, space.Px7) btn.Children(func() { // ⚠️ **结构是共享的那一份**(`countKeyIcon`):一个 18×18 的图形盒子, - // 角标挂在盒子的右上角。角标的上限 / 字号 / 挂点在 `playbar_counts.go`。 - countKeyIcon(c, iconComments, btn.Hovered(), btn.Pressed(), count) + // 缺口块挂在盒子的右上角。缺口 / 字号 / 挂点在 `playbar_counts.go`。 + // + // ⚠️ **缺口色 = 按钮底下那块底**:播放条那一列的底是 + // `coverWash(t.Background)`(和这一列自己同一个来源,`playbar.go`)。 + countKeyIcon(c, iconComments, btn.Hovered(), btn.Pressed(), count, + s.coverWash(c.Theme(), c.Theme().Background)) }) if s.withTip(c, btn, commentsKeyTip(count)).Clicked() { diff --git a/internal/ui/like.go b/internal/ui/like.go index 2d65a966..59e749a6 100644 --- a/internal/ui/like.go +++ b/internal/ui/like.go @@ -864,13 +864,16 @@ func (s *Shell) likeButton(c *ui.Context, name, label string, color ui.Color, li // 18 + 7 + 7 = 32,和没有数字时**一个字节都不差**。 ui.Box(c).Size(iconMd, iconMd).Children(func() { drawIcon(c) - // 🔴 **角标**(票 #701 起、本票起收成右上角):获赞数。 + // 🔴 **缺口角标**(票 #701 起带数、本票起收成网易云式缺口):获赞数。 // - // ⚠️ **走共享的那一份**(`countBadge`):挂点 / 字号 / 上限 / 颜色 + // ⚠️ **走共享的那一份**(`countBadge`):缺口块 / 挂点 / 字号 / 颜色 // **只有一处**——赞键自己拼图形盒子(那两层是绝对定位的,有自己的锚 // 结构),但角标不许另写一份。 + // + // ⚠️ **缺口色 = 按钮底下那块底**:播放条那一列的底是 + // `coverWash(t.Background)`(和播放条自己同一个来源,`playbar.go`)。 if hasCount { - countBadge(c, count) + countBadge(c, count, s.coverWash(c.Theme(), c.Theme().Background)) } }) }) diff --git a/internal/ui/playbar.go b/internal/ui/playbar.go index 79a93343..0fd37294 100644 --- a/internal/ui/playbar.go +++ b/internal/ui/playbar.go @@ -940,7 +940,10 @@ func (s *Shell) viewFavButton(c *ui.Context, st app.State) { btn := flat.Button(c, "").Label(countKeyLabel("收藏", count)). Height(iconHitMd).Padding(space.None, space.Px7) btn.Children(func() { - countKeyIcon(c, favIcon(faved), btn.Hovered(), btn.Pressed(), count) + // ⚠️ **缺口色 = 按钮底下那块底**:播放条那一列的底是 + // `coverWash(t.Background)`(和这一列自己同一个来源)。 + countKeyIcon(c, favIcon(faved), btn.Hovered(), btn.Pressed(), count, + s.coverWash(c.Theme(), c.Theme().Background)) }) if faved { // ⚠️ **只在这一支设颜色**:没收藏的那一支一个字都不改,好让「默认那个颜色」 diff --git a/internal/ui/playbar_counts.go b/internal/ui/playbar_counts.go index 52396298..bbefd7b9 100644 --- a/internal/ui/playbar_counts.go +++ b/internal/ui/playbar_counts.go @@ -1,60 +1,68 @@ // 播放条左边第三段那三枚键(赞 / 收藏 / 评论)各自的**读数**(票 #701 起带数, -// 本票起收成**角标**)。 +// 随后收成角标,本票起收成**缺口嵌字**)。 // // # 这一族和「播放量那一列」是两件事 // // 播放量那一列折成「1.2万」(`list.PlayCountText`)——它地方宽,要的是「约数」。 -// 这一族折成「1k+ / 10k+ / 99k+ / 1亿+」(`list.CountText`)——它挂在图标的 -// 右上角当**角标**,地方一点也不宽。 +// 这一族折成「1k+ / 10k+ / 99k+ / 1亿+」(`list.CountText`)——它嵌在图标的 +// 右上角缺口里当**角标**,地方一点也不宽。 // -// # 本票的形状:**角标,不是横排** +// # 本票的形状:**网易云式缺口,不是外挂角标** // -// 改之前是「图形 18 │ 缝 2 │ 数字」横排(`countKeyRow`):数字占一格,带数字的 -// 那一枚键因此宽 44.5——900 宽的窗口上左列右端离进度条滑块只剩 5.20 像素 -// (票 #682 / #701 量出来的)。那是「地方不够」被逼出来的形状。 +// 上一版(PR #736)的角标贴在图形右上**外侧**:`Attach(AnchorTopRight, +// AnchorTopLeft)`,图标本体完整、数字整个悬在外面。 // -// 现在改成**网易云式**:数字折成一个小字角标,**左缘贴图形右缘、上缘贴图形 -// 上沿**——`Attach(AnchorTopRight, AnchorTopLeft)` 把角标的左上角摆在图形盒子的 -// 右上角(数字整个在图形右上**外侧**,不压住图标本体、也不往角里收)。角标是 -// **绝对定位**的,不占布局—— -// 于是那一枚键的宽回到 **32**(图形 18 + 两侧各 7),和没有数字时一模一样。 +// 现在改成**网易云式**:图形的右上角**被挖掉一块**(露出底色),数字**嵌进那个 +// 缺口里**——视觉上数字坐在图形右上角的凹陷处,而不是叠加在外侧。 // -// ⇒ 三枚键各窄 12.5,第三段从 165.5 缩回 **137**(四枚 32 + 三道 3 的键间缝) -// ——**净让 28.5 像素给了进度条**(`playbarLeftMinW` 的宽度账见 `playbar.go`)。 +// 「挖空」用**盖**实现,不抠矢量字形:在图形盒子的右上角盖一块**播放条底色** +// 的圆角块(`badgeNotch`),缺口把那一角的字形笔画整个盖住(视觉上图标缺了一 +// 角),数字居中画在这块底色上。缺口块随数字自适应宽——窄档缺口小、最宽档 +// 「99k+」(≈22.0)也容得下,**「+」不靠任何截断兜底**。 +// +// 缺口块和数字都是**绝对定位**的,不占布局—— +// 于是那一枚键的宽仍是 **32**(图形 18 + 两侧各 7),和没有数字时一模一样。 +// +// # 🔴 缺口块的颜色 = 按钮底下那一块底 +// +// 缺口要「看着是挖的、不是贴的」,它的颜色就得和**数字底下的实际背景**逐像素 +// 一致——错一档就是「图标上贴了块补丁」。播放条的底是 +// `coverWash(t.Background)`(随封面取色微染,`playbar.go`),所以缺口色由 +// **调用方**传进来(`countBadge` 的 `bg`)——三个调用点都在播放条那一列里, +// 给的是同一个值。 +// +// ⚠️ **悬停 / 按下不用跟着换色**:按钮的悬停淡底是 `flat.feel` 挂在按钮上的 +// `DrawOver`(`mygo/ui/element.go` 的 `paintAfterFn`)——它画在**一切**(含缺口 +// 块和数字)**之上**,于是缺口块和它的四周**同帧变深**,缝还是看不见。如果缺口 +// 色自己再叠一遍悬停色,反而会画出「缺口比旁边深一档」的方块。 // // # 🔴 角标不占布局,但它照样登记命中 // // 框架的绝对定位元素绘制时排在普通子元素**之后**(`mygo/ui/paint.go` 的两趟 -// 循环)——所以角标画在图形**上面**。而它**照旧登记命中**(`mygo/ui/runtime.go`) -// ——于是角标那一小块底下,按钮收不到指针事件。 +// 循环)——所以缺口块画在图形**上面**。而它**照旧登记命中** +// (`mygo/ui/runtime.go`)——于是缺口块底下那一小块,按钮收不到指针事件。 // -// ⇒ 所以角标必须 **`PassThrough()`**:它不读屏、不点、不吃悬停——它是「粘在图形 -// 右上角的一串字」,不是一枚键。`nav-feed` 那个红点(`navbar_feed.go` 的 -// `viewDynamicDotMark`)是同一个形状、同一个理由。 +// ⇒ 所以缺口块必须 **`PassThrough()`**:它不读屏、不点、不吃悬停——它是「图形 +// 右上角挖出来的一个缺口」,不是一枚键。`nav-feed` 那个红点(`navbar_feed.go` +// 的 `viewDynamicDotMark`)是同一个形状、同一个理由。 // // # 三枚键的**共性**(所以这里有一份共享的配方) // -// 图形 18(`iconMd`)在一个 18×18 的盒子里,角标挂在这个盒子的右上角 +// 图形 18(`iconMd`)在一个 18×18 的盒子里,缺口块挂在这个盒子的右上角 // // 角标折出来的字是 `list.CountText`(那一族的折法只有这一处),而**约束** -// (字号 / 颜色 / 宽度上限 / 省略号 / PassThrough / 挂点)在 `countBadge` 这一 -// 个函数里——**三枚键共用**(赞键的图形那一块是绝对定位的两层,它自己拼盒子, +// (字号 / 颜色 / 缺口块的挂点与圆角 / PassThrough)在 `countBadge` 这一个 +// 函数里——**三枚键共用**(赞键的图形那一块是绝对定位的两层,它自己拼盒子, // 但**角标走同一份**)。 // -// # 🔴 两个口径(别把它们混起来) -// -// **字宽** 那一串字本身有多宽(`ui.Painter.MeasureText` 量得)——它**随平台 -// 字体变**,所以不是个能拿来钉死的数。 -// **上限** 角标那一格**最多占多宽**(`playbarBadgeMaxW` = 22.5)——它是**布局** -// 的数,而布局的数**必须是常数**。 +// # 🔴 「+」不许截:从「上限保险」换成「根本不设上限」 // -// ⇒ 上限是 **22.5**(量出来的:9 号字下折法最宽的那一档「99k+」≈22.0,刚好 -// 装下——理由见 `playbarBadgeMaxW` 的注释),而**「+」不许被截掉**那条铁律 -// 原样保留:截断切的是末尾,而「+」永远是最后一个字符。 +// 横排时代和外侧角标时代,数字格钉着一个**布局上限**(22.5):那一格是「借来 +// 的固定宽」,折法哪天改宽了,截断切的就是末尾、而「+」永远是最后一个字符。 // -// ⚠️ **上限管的是「角标自己的那一格」**,不是「它伸出去多远」:角标挂在图形 -// 右上角,宽出来的那一截伸到图形**右上方的空气里**(按钮上面、右边那枚键的 -// 左上),那是视觉余量,**不进任何一列的宽度账**——所以 22.5 不再挤左列。 +// 缺口时代**没有那个上限**:缺口块随字长宽(`fitWidth` 走内容宽),字多宽缺口 +// 就多宽——「+」**结构上截不掉**(没有给截断发生的格子)。`TestTheCountNeverLosesItsPlus` +// 的判据照旧:每一档单独画出来,墨迹都不许比字宽窄。 package ui import ( @@ -62,18 +70,19 @@ import ( "github.com/ShellMonster/PudMusic/internal/ui/widgets/flat" "github.com/ShellMonster/PudMusic/internal/ui/widgets/list" + "github.com/ShellMonster/PudMusic/internal/ui/widgets/space" "github.com/egoist/mygo/ui" ) // 这一族的几个尺寸。 // -// 🔴 **角标不占布局**:它 `Attach` 在图形盒子的右上角(`AnchorTopRight`), +// 🔴 **缺口块不占布局**:它 `Attach` 在图形盒子的右上角(`AnchorTopRight`), // 是**绝对定位**的——所以「带不带数字」对那一枚键的宽**一个字都不差**:都是 // 32(图形 18 + 两侧各 7 的内边距)。 // -// ⚠️ **图形盒子是 18×18**,不是按钮的 32×32:角标挂在**图形**上(那是它读的 +// ⚠️ **图形盒子是 18×18**,不是按钮的 32×32:缺口块挂在**图形**上(那是它读的 // 数的那一枚的身份),而不是按钮盒的角上——挂在按钮角上会跑出图形之外一整圈 -// (7 的内边距),角标就悬在按钮的右上角、盖住悬停淡底,而不是「图形右上」。 +// (7 的内边距),缺口就挖在按钮的右上角、而不是「图形右上」。 const ( // playbarBadgeFont 是角标的字号(**固定 9,不乘系统文本缩放**)。 // @@ -97,9 +106,8 @@ const ( // // 🔴 **本票是 9,不是 10**:对照网易云实测(用户视觉审查),角标的字约是 // 图标高的 1/3——18 的图形配 10pt 的数字太大、且和图标的视觉重量拉不开。 - // 收到 9(≈ 图标高的一半以下),数字才像「挂在图形上的小尾巴」而不是 - // 「和图形并排的另一件东西」;同时「99k+」从 24.49 收到 ≈22,键与键之间 - // 留得出可辨的缝(理由见 `playbarBadgeMaxW`)。 + // 收到 9(≈ 图标高的一半以下),数字才像「嵌在缺口里的读数」而不是 + // 「和图形并排的另一件东西」。 // // # 🔴 **这个取舍的完整账**(票 #701 的审查,本票照旧成立) // @@ -128,24 +136,51 @@ const ( // ⚠️ **别处的字照旧跟着 scale 走**:歌名、UP 名、别的读数——**只有这一格** // 不跟(理由见上)。 playbarBadgeFont = 9 - // playbarBadgeMaxW 是角标那一格**最宽**多少(22.5)。 + // badgeNotchOut 是缺口块的上缘从图形盒子的上沿**往上探**多少(2)。 + // + // 网易云那个缺口不是缩在图形框里面的:它包住图形的角——块的上缘越过图形 + // 上沿 2 像素,缺口下缘那段圆弧才落在字形上,看着是「图标被咬了一角」, + // 而不是「图标里面挖了一块」。 + badgeNotchOut = 2 + // badgeNotchBite 是缺口块的左缘**收进图形右缘**多少(6)。 + // + // 🔴 **缺口是咬在角上的,不是包住整个图形**:缺口块的左缘只收进图形 + // 右缘 6 像素——咬掉的是右上角那一小块(图形的右肩、上沿右端那截 + // 笔画),块的主体往右伸到图形**外面**的空气里、数字的主体也跟出去。 + // + // 对照:如果让缺口块把整个字宽都收在图形上(宽字那一档「99k+」≈22 比 + // 图形 18 还宽),缺口会从图形右缘一路吃到左缘——实测那一档图形只剩 + // 下半截一撇,星标缩成「∧」、拇指剩个拐角,图标本身认不出来了。 + badgeNotchBite = 6 + // badgeNotchR 是缺口块的圆角。 + // + // 缺口块的四个角都圆——**左下角**那个圆弧就是「咬进图形里」的那一口(其余 + // 三角都探在图形外的空气里,圆不圆没人看见)。5 ≈ 块高(12 上下)的 + // 一半再小一点:再圆缺口就成了个胶囊、左上缘退成一道弧线,咬的那一口 + // 反而没有「凹进去一块」的直感。 + badgeNotchR = 5 + // badgeNotchMinW 是缺口块的**最小宽**。 + // + // 缺口块随字长宽,但字很短的时候(「123」「1k+」之前的小数)块太小就缩成 + // 一个点、嵌不住「缺了一角」的形状。12 是「缺口还能读出是一个凹陷」的下限。 + badgeNotchMinW = 12 + // badgeNotchMinH 是缺口块的**最小高**。 // - // 🔴 **22.5 是量出来的上限,不是随手取的**:`list.CountText` 折出来的几档 + // 9 号字的行高约 11——留一点垂直余量,缺口块盖住的那一截字形才比数字 + // 本身厚一圈(数字不是贴着缺口上下沿画的)。 + badgeNotchMinH = 12 + + // playbarBadgeMaxW 是数字那一串字**实测能到多宽**的参考值(22.5)—— + // 它已经**不再当上限用**(缺口块随字长宽,不封顶),留它是因为测试拿它 + // 量「那一格画出来的墨迹」作对照(`isolatedInkWidth` / `scaledCellInks`)。 + // + // 🔴 **22.5 是量出来的,不是随手取的**:`list.CountText` 折出来的几档 // 实测(9 号字,macOS,`ui.Painter.MeasureText` 量得,约为 10 号字的 0.9): // // 「9k+」 ≈16.2 「99k+」 ≈22.0 ← 最宽的那一档 // 「10k+」 ≈20.4 「1亿+」 ≈21.2 // - // ⇒ **22.5 是「刚好装下最宽那一档(99k+ ≈22.0)再加一点抗锯齿余量」的数**。 - // - // 🔴 **上限不许低于它**:**截断切的是末尾,而「+」永远是最后一个字符**—— - // 「+」**总是第一个被切掉的东西**,而「+」是这一族的语义(「至少这么多」, - // 没了就是撒谎)。⇒ 最宽的那一档必须本身就装得下上限,一档都不许靠截断 - // 兜底(理由同 10 号字那一版的实测,见 `widgets/list/count.go` 的文件头)。 - // - // ⚠️ **和改之前(横排那一版)的差别**:那时这个上限挡的是「字变宽把左列 - // 撑宽」——左列是常数,而角标**不占布局**,所以它现在挡的只有「角标那一格 - // 自己被截断」这一件事(结果一样:那个「+」不许没)。 + // ⇒ 它是「刚好装下最宽那一档(99k+ ≈22.0)再加一点抗锯齿余量」的数。 playbarBadgeMaxW = 22.5 ) @@ -219,88 +254,121 @@ func countKeyTip(name string, n int64, tip string) string { return tip } -// countBadge 是挂在**图形盒子右上角**的那枚角标(三枚键共用)。 +// countBadge 是挂在**图形盒子右上角**的那枚**缺口角标**(三枚键共用)。 // // ⚠️ **这一格的约束只有这一处**:收藏键和评论键走 `countKeyIcon`,而**赞键自己 // 拼图形盒子**(它那两层背景 + 拇指是绝对定位的,见 `like.go` 的 `likeButton`) -// ——但**角标走这里**,于是三枚键的「挂点 / 字号 / 颜色 / 上限 / PassThrough」 +// ——但**角标走这里**,于是三枚键的「缺口块 / 挂点 / 字号 / 颜色 / PassThrough」 // **只有一份**。 // -// # 🔴 形状:绝对定位、不占布局、不读屏、不吃指针 +// # 🔴 形状:一块底色圆角块(缺口)+ 嵌在里头的数字 +// +// Attach(AnchorTopRight, AnchorTopRight) 缺口块的右上角钉在图形盒子的右上角, +// 再上探 / 右探各 `badgeNotchOut` +// PassThrough() 不登记命中(底下的按钮照点) // -// Attach(AnchorTopRight, AnchorTopLeft) 角标的左上角钉在图形盒子的右上角(外侧) -// PassThrough() 不登记命中(底下的按钮照点) +// 「挖空」是**盖**的:缺口块刷成**按钮底下那块底**的颜色(`bg`,调用方给), +// 图形右上角那一截字形笔画就被它盖住——视觉上图标缺了一角,数字居中画在缺口 +// 上,坐在凹陷里。 // // ⚠️ **绝对定位是相对父元素的 padding box**(`mygo/ui/element.go` 的 `Attach` // 文档:「puts its point self on the point at of the parent's padding box」)—— // 而父元素是**图形盒子**(18×18),不是按钮(32×32)。挂在按钮上会多出 7 的 -// 内边距,角标就悬在按钮的右上角、盖住悬停淡底,而不是「图形右上」。 +// 内边距,缺口就挖到按钮的角上去了,而不是「图形右上」。 // -// 🔴 **必须 `PassThrough()`**:角标画在图形**上面**(绝对定位排在普通子元素 +// 🔴 **`bg` 必须是「数字底下的实际背景」**——播放条那一列的底是 +// `coverWash(t.Background)`(封面取色染过的 `t.Background`),调用方在播放条 +// 那一列里现算现传。缺口色错一档,「挖的」就成了「贴的」。 +// +// ⚠️ **悬停 / 按下不跟着变**:按钮的悬停淡底是 `DrawOver` 画在一切之上(含缺口 +// 块)——缺口和它的四周同帧变深,缝看不见;缺口自己再叠一层就画出深色方块了。 +// 文件头《缺口块的颜色》那一节记着这笔账。 +// +// 🔴 **必须 `PassThrough()`**:缺口块画在图形**上面**(绝对定位排在普通子元素 // 之后,`mygo/ui/paint.go`),而命中测试照旧登记它(`mygo/ui/runtime.go`)—— -// 不 PassThrough 的话,角标底下那一小块按钮**点不动**(`nav-feed` 那个红点是 +// 不 PassThrough 的话,缺口块底下那一小块按钮**点不动**(`nav-feed` 那个红点是 // 同一个形状、同一个理由:`navbar_feed.go` 的 `viewDynamicDotMark`)。 // // ⚠️ **颜色是 `TextMuted`**:角标是**附属读数**,不是那一枚键的身份——图标才是。 // 它和播放条上别的次要读数(UP 名、时间)同一个色阶。 // -// 🔴 **`MaxWidth` + `MaxLines(1)` + 省略号**:宽度账见 `playbarBadgeMaxW` 的 -// 注释——**当前折法一档都不截**(最宽的「99k+」≈22.0 ≤ 上限 22.5),而上限本身 -// 是**保险**:折法哪天改宽了,它挡住「角标那一格自己被截断」——截断先切末尾, -// 那个「+」就没了,而「+」是这一族的语义。 +// 🔴 **数字没有宽度上限**:缺口块随字长宽(一行字、不包折),所以「+」**结构上 +// 截不掉**——没有给截断发生的格子。这是从「上限 22.5 的保险」变成「不封顶」的 +// 理由,见文件头《「+」不许截》那一节。 // // ⚠️ **字号是**固定的**(`playbarBadgeFont`),不是 `scale.Font(c, …)`**—— // 理由见那个常量的注释(系统文本放大 125% 就会切掉那个「+」,而「+」是这一族 // 的语义)。 -func countBadge(c *ui.Context, n int64) { +func countBadge(c *ui.Context, n int64, bg ui.Color) { t := playbarCountText(n) if t == "" { - // ⚠️ **没有数字时这一格不画**:角标连建都不建(占一个绝对定位的孩子是 + // ⚠️ **没有数字时这一格不画**:缺口块连建都不建(占一个绝对定位的孩子是 // 小事,但「没有数」和「零个赞」是两件事——见 `TestTheThreeCountsNeverShowZero`)。 return } - ui.Text(c, t).FontSize(playbarBadgeFont). - TextColor(c.Theme().TextMuted). - // 🔴 **MinWidth 不许少**:`Attach` 里框架按父盒子的 padding box(18×18) - // 给可用宽,而 `fitWidth` 取 `min(内容, 可用)`——不钉 MinWidth 的话, - // 角标那一格被夹成 18,「1亿+」就被截成「1亿…」,**那个「+」没了** - // (它是这一族的语义)。钉到上限那一档,一格都不截。 - MinWidth(playbarBadgeMaxW).MaxWidth(playbarBadgeMaxW). - // ⚠️ **字左对齐**:角标那一格钉成上限宽、左缘钉在图形右缘——窄的那 - // 一档靠左让**字的左缘贴着图形右肩**,宽窄几档左端都落在同一个点上。 - TextAlign(ui.Start). - MaxLines(1).Ellipsis("…"). - // 🔴 **角标的左上角钉在图形盒子的右上角、一点都不挪**—— - // `Attach(AnchorTopRight, AnchorTopLeft)`:左缘=图形右缘、上缘=图形 - // 上缘。实测教训:往左收 1.5(`Right(1.5)`)「9k+」的 9 就压回拇指肚 - // 上;往下压 2.5(`Top(2.5)`)数字中线就落到图标中线、看着是「居中」 - // 不是「贴上沿」。网易云那个形状是**左缘贴右缘、上缘贴上沿**—— - // 数字整个在图形右上外侧,图形完整露出。 - Attach(ui.AnchorTopRight, ui.AnchorTopLeft). - // 🔴 **不吃指针**:它是「粘在图形右上角的一串字」,不是一枚键——底下的 - // 按钮照点,悬停照常。 - PassThrough() + // 🔴 **缺口块的宽按字量出来,不靠盒子的 fitWidth**:`Attach` 里框架按 + // 父盒子的 padding box(18×18)给绝对定位元素的**可用宽**,fit 型盒子被 + // 夹成 18 ——字再宽盒子也只有 18,「99k+」就漏出去了。所以这里先用 + // `MeasureText` 量出那一串字的宽、**显式钉成盒子的宽**(量不准时退回 + // 上限那一档 `playbarBadgeMaxW`)。字宽 + 两侧 `space.Px3` 的余量 = 缺口块宽。 + textW, _ := c.MeasureText(0, ui.Span{Text: t, Size: playbarBadgeFont}) + notchW := textW + 2*space.Px3 + if notchW < badgeNotchMinW { + notchW = badgeNotchMinW + } + ui.Box(c).Key("count-badge-notch").Size(notchW, badgeNotchMinH). + // 🔴 **缺口块的上沿越过图形上沿 `badgeNotchOut`,左缘只收进图形右缘 + // `badgeNotchBite`**——`Attach` 的 `Top(…)` 往**下**挪、`Right(…)` 往 + // **左**挪(`navbar_feed.go` 记着同一笔:MyGo 的 inset 正负号和 CSS + // 一样),所以「往外探」要传**负数**:`Right(-(notchW - bite))` 把块的 + // 右缘推出图形右缘 notchW − bite 像素,左缘就正好收在图形里 6 像素 + // ——咬的是右上角那一小块,不是包着整个图形(理由见 `badgeNotchBite`)。 + Attach(ui.AnchorTopRight, ui.AnchorTopRight). + Top(-badgeNotchOut).Right(-(notchW - badgeNotchBite)). + // ⚠️ **四个角全圆**:左下那一口是咬进图形的,其余三角探在外面(见 + // `badgeNotchR`)。 + Radius(badgeNotchR). + Background(bg). + // 字在缺口里居中,左右各留 `space.Px3`——数字上下贴着缺口内沿、 + // 左右各留一道缝,看着是「嵌进去」不是「糊上去」。 + Padding(space.None, space.Px3).Center(). + // 🔴 **不吃指针**:它是「图形右上角挖出来的一个缺口」,不是一枚键—— + // 底下的按钮照点,悬停照常。 + PassThrough(). + Children(func() { + ui.Text(c, t).FontSize(playbarBadgeFont). + TextColor(c.Theme().TextMuted). + // 🔴 **MinWidth 钉成字宽**:盒子的宽已经按字钉死了,但文字自身 + // 的可用宽还是按「父盒子可用宽」走——不钉的话框架按缺口的 + // 可用宽(≈ 字宽 + padding 那一份)给,字会被夹成截断。 + // 钉成 `textW`,一格都不截——「+」结构上丢不了(理由见文件头)。 + MinWidth(textW).MaxWidth(textW). + SingleLine() + }) } -// countKeyIcon 是「图形盒子 + 角标」那一枚里的**图形那一块**(收藏 / 评论共用)。 +// countKeyIcon 是「图形盒子 + 缺口角标」那一枚里的**图形那一块**(收藏 / 评论共用)。 // -// # 🔴 结构:一个 18×18 的盒子,图形在盒子里,角标挂在盒子右上角 +// # 🔴 结构:一个 18×18 的盒子,图形在盒子里,缺口块挂在盒子右上角 // // 改之前图形是「行里的一个 18 宽的孩子」,数字是它右边的一格。现在图形回到 -// **和没有数字时一模一样的形状**:18×18 的盒子(`iconMd`),而角标**不占布局** +// **和没有数字时一模一样的形状**:18×18 的盒子(`iconMd`),而缺口块**不占布局** // ——所以这一枚键的宽是 32,**带不带数字都一样**。 // -// ⚠️ **盒子是给角标当锚的**:`Attach` 相对**父元素的 padding box**——所以角标 -// 要挂在**一个正好 18 宽**的盒子上,它的右上角才是图形的右上角(挂在按钮上 -// 会多出 7 的内边距,角标就悬到按钮角上去了)。 +// ⚠️ **盒子是给缺口块当锚的**:`Attach` 相对**父元素的 padding box**——所以缺口 +// 块要挂在**一个正好 18 宽**的盒子上,它的右上角才是图形的右上角(挂在按钮上 +// 会多出 7 的内边距,缺口就挖到按钮角上去了)。 // // ⚠️ **悬停变主色**(`flat.IconAccentOnHover`):和相邻几枚一样(上游 // `hover:text-primary`)——而 `flat.Button` 只会铺一层淡底,图形自己不换色的话 // 悬停反馈就只剩那层淡底。 // -// ⚠️ **按下缩放**(`flat.PressScale`):纯图标键才缩,而这一枚是「图标 + 角标」 -// ——图形缩、角标不缩(角标缩了会跟着抖,读数不该动)。 -func countKeyIcon(c *ui.Context, iconName string, hovered, pressed bool, n int64) { +// ⚠️ **按下缩放**(`flat.PressScale`):纯图标键才缩,而这一枚是「图标 + 缺口 +// 角标」——图形缩、缺口不缩(缺口缩了会跟着抖,读数不该动)。 +// +// ⚠️ **`bg` 顺着传**:缺口色 = 按钮底下那块底(理由见 `countBadge`),调用方 +// 在播放条那一列里现算(`coverWash(t.Background)`)。 +func countKeyIcon(c *ui.Context, iconName string, hovered, pressed bool, n int64, bg ui.Color) { ui.Box(c).Size(iconMd, iconMd).Children(func() { // 图形在盒子的正中(`Attach` 是相对盒子的 padding box 居中)。 ic := ui.Icon(c, icon(iconName)).FontSize(iconMd). @@ -309,8 +377,8 @@ func countKeyIcon(c *ui.Context, iconName string, hovered, pressed bool, n int64 ic = ic.TextColor(col) } flat.PressScale(ic, pressed, false) - // 🔴 **角标挂在盒子右上角**——它是绝对定位的,不占布局,而它的父盒子 + // 🔴 **缺口块挂在盒子右上角**——它是绝对定位的,不占布局,而它的父盒子 // 是 18×18,于是右上角正好是图形的右上角。 - countBadge(c, n) + countBadge(c, n, bg) }) } diff --git a/internal/ui/playbar_counts_test.go b/internal/ui/playbar_counts_test.go index 04c52569..7e4e9d18 100644 --- a/internal/ui/playbar_counts_test.go +++ b/internal/ui/playbar_counts_test.go @@ -314,9 +314,14 @@ func scaledCellInks(t *testing.T, withPlus, withoutPlus string, scale float32) ( // 上的)——裸建会把它的中心钉在整行的右上角,量到的不是它的墨迹。 tt := mygoui.NewTester(func(c *mygoui.Context) { mygoui.Row(c).Children(func() { + // ⚠️ **缺口块会探出图形盒子的左界**(宽字那一档的缺口比 18 的盒子 + // 还宽、往左长出去)——把盒子摆在行中间,探出去的那一截才不会被 + // 窗口左缘裁掉、墨迹才量得全。 + mygoui.Spacer(c).Grow(1) mygoui.Box(c).Size(iconMd, iconMd).Children(func() { - countBadge(c, countForText(withPlus)) + countBadge(c, countForText(withPlus), c.Theme().Background) }) + mygoui.Spacer(c).Grow(1) }) }, 300, 60) tt.SetPreferences(mygoui.Preferences{TextScale: scale}) @@ -428,25 +433,25 @@ func TestTheThreeCountsSayTheNumberToScreenReaders(t *testing.T) { } } -// TestTheThreeCountsSitAsBadgesOnTheIconsTopRight 钉住**数字贴在图形右上角外侧**。 +// TestTheThreeCountsSitInsideANotchOnTheIconsTopRight 钉住**数字嵌在图形右上角的缺口里**。 // -// 🔴 **这是本票的主体**:数字从「图形右边那一格」挪成「图形右上角外侧挂着的 -// 角标」(网易云式)——它**不占布局**,而是 `Attach(AnchorTopRight, -// AnchorTopLeft)` 把角标的左上角钉到图形盒子的右上角,**不收不压**:左缘就是 -// 图形右缘、上缘就是图形上缘。 +// 🔴 **这是本票的主体**:数字从「图形右上角外侧挂着的角标」挪成「嵌在图形 +// 右上角缺口里的读数」(网易云式)——它**不占布局**,缺口块钉在图形盒子的 +// 右上角、包住那个角往外探 `badgeNotchOut`,数字居中画在缺口里。 // // ⚠️ **判据是角标自身的 bounds 相对图形盒子 bounds 的位置**(视觉审查钉下的 -// 三条真值): +// 四条真值): // -// 角标的上缘 ≈ 图形盒子的上缘 (它贴着图形上沿,不是居中) -// 角标的左缘 ≈ 图形盒子的右缘 (内收会压回图标本体) -// 角标的中心 > 图形盒子的右缘 (主体整个在图形右缘以右) +// 角标的左缘收在图形右半部以内(≥ 图形中线) (数字从图形的右上角里长出来) +// 角标的左缘在图形右缘以内 (嵌进去,不是贴在右缘外侧) +// 角标的下缘明显在图形中线的上方 (坐在右上角,不是骑在正中) +// 角标的右缘没越过图形右缘 + 最宽那一档 (缺口主体探在图形外的空气里) // -// 反过来说:横排那一版(角标上缘 ≈ 图形中心 y、左缘在图形右缘往右一整格之外)、 -// 「压住图形」那一版(角标中心落在图形内部)和「内收骑角」那一版(左缘收进 -// 图形右缘以内)都立刻红。 +// 反过来说:外侧角标那一版(角标左缘正好落在图形右缘,一条缝都不重叠)、横排 +// 那一版(角标中心 y ≈ 图形中心 y)和「缺口包住整个图形」那一版(宽字档数字 +// 左缘跑到图形左半去了)都立刻红。 // -// ⚠️ **三枚都要断**:只断一枚的话,「收藏 / 评论走了角标、赞还停在横排」那 +// ⚠️ **三枚都要断**:只断一枚的话,「收藏 / 评论走了缺口、赞还停在外侧角标」那 // 一档照样绿——而那一档正是本票最容易漏的(赞键的图形那一块是绝对定位的两层, // 和另外两枚不是同一条路径)。 // @@ -454,7 +459,7 @@ func TestTheThreeCountsSayTheNumberToScreenReaders(t *testing.T) { // 而那一枚键的框是它再往外一层。Tester 不给父链——所以这里**用按钮的框**倒推: // 按钮的**中心**就是图形盒子的中心(带数字时图形在按钮正中),图形盒子的半边 // 是 `iconMd/2`。 -func TestTheThreeCountsSitAsBadgesOnTheIconsTopRight(t *testing.T) { +func TestTheThreeCountsSitInsideANotchOnTheIconsTopRight(t *testing.T) { tt, _ := countsPlaybarTester(t, 1200, 1286, 2468, 9999) for _, c := range []struct { @@ -477,28 +482,41 @@ func TestTheThreeCountsSitAsBadgesOnTheIconsTopRight(t *testing.T) { // 图形盒子的中心 = 按钮的中心(带数字时图形在按钮正中、角标不占布局), // 半边是 iconMd/2 = 9。 icx, icy := key.X+key.W/2, key.Y+key.H/2 - iTop, iRight := icy-iconMd/2, icx+iconMd/2 - - // 🔴 **角标的上缘就是图形盒子的上缘**(`AnchorTopLeft` 对齐上沿,不挪—— - // 视觉审查实测:往下压 2.5 数字中线就落到图标中线、看着是「居中」不是 - // 「贴上沿」)。允许 ±1 的浮点晃。 - if badge.Y < iTop-1 || badge.Y > iTop+1 { - t.Errorf("「%s」的角标上缘在 %.1f,图形上缘在 %.1f——它没有贴着图形上沿"+ - "(网易云式是数字上缘≈图形上缘)", c.name, badge.Y, iTop) + iRight := icx + iconMd/2 + + // 🔴 **数字的左缘收在图形右半部以内**——缺口咬的是右上角那一小块, + // 数字的头几个字符从图形右上角的凹陷里长出来:左缘落在图形中线以右、 + // 图形右缘以左。外侧角标那一版的左缘正好是图形右缘(`AnchorTopLeft`, + // 一条缝都不重叠),这条判据把两版分得干干净净。 + if badge.X >= iRight-1 { + t.Errorf("「%s」的角标左缘在 %.1f,图形右缘在 %.1f——数字没有嵌进缺口"+ + "(它还是贴在图形右缘外侧,而网易云式是收进缺口里、和图形有交叠)", + c.name, badge.X, iRight) } - // 🔴 **角标的左缘就是图形盒子的右缘**(`AnchorTopLeft` 对齐右沿,不内收—— - // 视觉审查实测:内收 1.5,「9k+」的 9 就压回拇指肚上)。允许 ±1 的浮点晃。 - if badge.X < iRight-1 || badge.X > iRight+1 { - t.Errorf("「%s」的角标左缘在 %.1f,图形右缘在 %.1f——它没有贴着图形右缘"+ - "(内收会压回图标本体,悬出去会离图形太远)", c.name, badge.X, iRight) + // 🔴 **左缘不许跑进图形左半**——缺口只咬右上角那一口 + // (`badgeNotchBite`),宽字那一档(「99k+」≈22)的缺口主体往右探进 + // 图形外的空气里。谁要是把缺口改成「包住整个图形」(宽字档数字左缘 + // 跑到图形左半去),图形只剩半截、这条立刻红。 + if badge.X < icx-1 { + t.Errorf("「%s」的角标左缘在 %.1f,图形中线在 %.1f——缺口咬得太深"+ + "(它该只咬右上角那一小块,不是包住整个图形——宽字档那样会吃掉大半个图标)", + c.name, badge.X, icx) } - // 🔴 **主体整个在图形右缘以右**:角标的中心离图形右缘还有半格字宽—— - // 图形盒子的右半边一个像素都不该被数字盖到(中心锚那一版角标中心落在 - // 图形内部,立刻红)。 - bx := badge.X + badge.W/2 - if bx < iRight { - t.Errorf("「%s」的角标中心在 %.1f,图形右缘在 %.1f——角标主体压住了图形"+ - "(要的是挂在图形右肩外侧,不是盖在它上头)", c.name, bx, iRight) + // 🔴 **下缘明显在图形中线的上方**——缺口挖的是右上角,数字坐在上 1/3 + // 那一带,不是骑在图形正中。9 号字行高约 11,坐在缺口里时下缘 ≈ + // 图形上缘 + 10.5——离中线(上缘 + 9)还有一段。允许 ±1 的浮点晃。 + if badge.Y+badge.H > icy+1 { + t.Errorf("「%s」的角标下缘在 %.1f,图形中线在 %.1f——数字骑到图形正中去了"+ + "(缺口嵌字是数字坐在右上角,不是居中)", c.name, badge.Y+badge.H, icy) + } + // 🔴 **右缘不许探出缺口**:缺口块右缘探出图形右缘 `notchW − bite` + // 像素(最宽那一档「99k+」是 ≈22.3),数字居中在缺口里——它的右缘 + // 不越过缺口右缘。这条只钉「右缘 ≤ 图形右缘 + 最宽探出量 + 余量」, + // 不钉「整个在图形框里面」(缺口主体本来就在外面)。 + if badge.X+badge.W > iRight+playbarBadgeMaxW-badgeNotchBite+1 { + t.Errorf("「%s」的角标右缘在 %.1f,缺口右缘最多在 %.1f——数字探出缺口去了"+ + "(缺口探出的那一截是空着的余量,字该坐在缺口里)", + c.name, badge.X+badge.W, iRight+playbarBadgeMaxW-badgeNotchBite) } } } From e8ce0cb41bbb97c16d90de0a8a2dc8f8997d0c39 Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 3 Nov 2026 10:48:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=EF=BC=9A?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=E5=B7=A6=E7=BC=98=E5=88=B0=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E8=B5=B7=E7=AC=94=E7=95=99=E4=B8=80=E5=9C=88=E7=9C=8B=E5=BE=97?= =?UTF-8?q?=E8=A7=81=E7=9A=84=E5=BA=95=E8=89=B2=E7=BC=9D=EF=BC=88PadL=208?= =?UTF-8?q?=20/=20PadR=206=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视觉审查打回:数字和图标贴得太近——放大看「99k+」的字身几乎顶在 星形/拇指/气泡的笔画上,比网易云那种「豁口里留一圈白」挤。 缺口块的左右 padding 从对称 3 改成不对称:咬进图形的那一边留 8 (数字起笔和图形残笔之间有一眼能看到的缝),探在空气里的那一边 留 6。缺口块宽跟着两边一起长(textW + 8 + 6),不是字往右挤。 测试跟着新几何改:数字左缘钉在「缺口左缘 + PadL」那一带(PadL 大于 bite 时它探出图形右缘 PadL−bite——缝在数字左边);右缘上限 换成最宽探出量 textW+PadL+PadR−bite。E2E 加一条「缝」判据:缺口 左端到数字起笔之间的那一带一个比底色深的像素都不该有。 --- internal/e2e/counts_test.go | 41 ++++++++++++----------- internal/ui/playbar_counts.go | 26 ++++++++++++--- internal/ui/playbar_counts_test.go | 53 ++++++++++++++++-------------- 3 files changed, 71 insertions(+), 49 deletions(-) diff --git a/internal/e2e/counts_test.go b/internal/e2e/counts_test.go index d69d1f77..39cbc73f 100644 --- a/internal/e2e/counts_test.go +++ b/internal/e2e/counts_test.go @@ -15,8 +15,9 @@ package e2e // 无障碍树的父子关系跟着元素树走,不跟「它悬在哪儿」走)。 // 2. **角标咬进图形的右上角**(`Attach(AnchorTopRight, AnchorTopRight)` + // `Top(-out).Right(-(notchW-bite))`:缺口块的左缘收进图形右缘 -// `badgeNotchBite`(6),把那一角的字形笔画盖成底色,数字从缺口里往右 -// 排、主体探在图形外的空气里)。 +// `badgeNotchBite`(6),把那一角的字形笔画盖成底色;数字从缺口里往右 +// 排、和咬掉的笔画之间留着 `badgeNotchPadL` 那一道底色缝,主体探在 +// 图形外的空气里)。 // // 这两层在无头 Tester 里问不到——框架只在**辅助技术问起**时才描述那一帧 // (`mygo/ui/access.go` 的 `accessibilityOn`:真窗口里是系统的读屏软件问起, @@ -156,36 +157,38 @@ func TestThePlayBarCountsSitAsBadges(t *testing.T) { // 🔴 **三条判据把「缺口嵌字」钉死**(缺口块的左缘收进图形右缘 // `badgeNotchBite` = 6 → 咬掉图形右上角 [icx+3, icx+9] 那一带; - // 数字在缺口里居中,左起 ≈ icx+6、贴着图形上沿那一带往右排): + // 数字在缺口里,左缘起在缺口左缘 + `badgeNotchPadL`(8)≈ icx+11、 + // 贴着图形上沿那一带往右排——**数字和被咬的笔画之间留着一道看得见的 + // 底色缝**,那是视觉审查钉下的:「豁口里留一圈白」): // - // R1 [icx+6, icx+10] × [icy−8, icy+1]——数字**骑在图形右缘上**的那一截: - // 缺口里的数字左端起在 icx+6(图形右缘以**内** 3 像素),所以图形 - // 右缘里外各几像素都有字——外侧角标那一版这里只剩图形右缘右半边 - // 有墨(左半边是完好的字形笔画、颜色是图形色不是字色……这条量的是 - // 「比底色深」,横排版数字中线 ≈ icy 也上不到 icy−8 这一带)。 + // R1 [icx+5.5, icx+10.5] × [icy−8, icy+1]——缺口左端到数字起笔之间的 + // **底色缝**:这一带在缺口块上、可是字还没起笔(PadL 那 8 像素), + // 所以一个比底色深的像素都不该有。外侧角标那一版(数字从图形右缘 + // 紧贴着起排)这里有墨;「缝挤回 3」那一版这里也是墨——这条断的 + // 就是「数字和笔画之间留没留缝」。 // R2 [icx+3, icx+5.5] × [icy−7, icy−3]——图形右上角**被咬掉的那一口**: // 缺口块把它盖成了底色。以前这里有三枚图标各自的笔画(星的右臂、 // 拇指背的起弧、气泡的右上那一段弧)——缺口的判据就是这一带 // **不再有比底色深的像素**。外侧角标那一版(图形完好)这里全是 // 墨迹;横排版更是整段笔画。 - // R3 [icx+10, icx+22] × [icy−8, icy+1]——数字**探在图形右侧空气里**的 - // 主体:缺口往右长过图形右缘(最窄那一档也长出 ≈9 像素),「9k+」 - // 那一串的本体就落在这一带。缺了它说明数字全缩在图形框上——那是 - // 「盖住整个图形」的错误版,不是咬在角上的缺口。 - if ink := darkerIn(img, geom, icx+6, icy-8, 4, 9, bg); ink == 0 { - t.Errorf("「%s」键图形右缘一带(%.0f,%.0f 起 4×9 DIP)一点墨迹都没有——"+ - "数字没有骑进图形的缺口(缺口嵌字是数字左缘收进图形右缘以内、骑在缺口上)", - c.name, icx+6, icy-8) + // R3 [icx+11, icx+30] × [icy−8, icy+1]——数字**探在图形右侧空气里**的 + // 主体:缺口往右长过图形右缘,「9k+」那一串的本体就落在这一带。 + // 缺了它说明数字全缩在图形框上——那是「盖住整个图形」的错误版, + // 不是咬在角上的缺口。 + if ink := darkerIn(img, geom, icx+5.5, icy-8, 5, 9, bg); ink != 0 { + t.Errorf("「%s」键缺口左端到数字起笔之间(%.0f,%.0f 起 5×9 DIP)有 %d 个比底色深的像素——"+ + "数字和被咬的笔画之间那道底色缝没了(视觉审查:「豁口里要留一圈白」,"+ + "PadL 那 8 像素就是这道缝)", c.name, icx+5.5, icy-8, ink) } if ink := darkerIn(img, geom, icx+3, icy-7, 2.5, 4, bg); ink != 0 { t.Errorf("「%s」键图形右上角(%.0f,%.0f 起 2.5×4 DIP)还有 %d 个比底色深的像素——"+ "那一角没有被咬掉(缺口要把图形右上角的笔画盖成底色,这一带该是空的)", c.name, icx+3, icy-7, ink) } - if ink := darkerIn(img, geom, icx+10, icy-8, 12, 9, bg); ink == 0 { - t.Errorf("「%s」键图形右侧空气里(%.0f,%.0f 起 12×9 DIP)一点墨迹都没有——"+ + if ink := darkerIn(img, geom, icx+11, icy-8, 19, 9, bg); ink == 0 { + t.Errorf("「%s」键图形右侧空气里(%.0f,%.0f 起 19×9 DIP)一点墨迹都没有——"+ "数字没有探出图形右缘(缺口主体在图形外面,「9k+」那一串的本体在这一带)", - c.name, icx+10, icy-8) + c.name, icx+11, icy-8) } } } diff --git a/internal/ui/playbar_counts.go b/internal/ui/playbar_counts.go index bbefd7b9..3ffb19ac 100644 --- a/internal/ui/playbar_counts.go +++ b/internal/ui/playbar_counts.go @@ -159,6 +159,18 @@ const ( // 一半再小一点:再圆缺口就成了个胶囊、左上缘退成一道弧线,咬的那一口 // 反而没有「凹进去一块」的直感。 badgeNotchR = 5 + // badgeNotchPadL / badgeNotchPadR 是缺口块在数字**左 / 右**各留的空隙。 + // + // 🔴 **左 8、右 6,不对称**(视觉审查打回的那一笔):缺口左缘那一边是 + // **咬进图形里**的——数字的起笔和图形的残笔只隔这一道缝,3 像素实测是 + // 「字身几乎顶在笔画上」(放大看比网易云那种「豁口里留一圈白」挤得多)。 + // 左边给到 8(咬的那一边多咬一点没关系,要的是数字和被咬的笔画之间有 + // 一眼能看到的底色缝);右边探在空气里,6 就够。 + // + // ⚠️ **缺口块宽跟着两边一起长**(`countBadge` 里 `textW + PadL + PadR`)—— + // 不是「盒子不变、字往右挤」。 + badgeNotchPadL = space.Px8 + badgeNotchPadR = space.Px6 // badgeNotchMinW 是缺口块的**最小宽**。 // // 缺口块随字长宽,但字很短的时候(「123」「1k+」之前的小数)块太小就缩成 @@ -310,9 +322,11 @@ func countBadge(c *ui.Context, n int64, bg ui.Color) { // 父盒子的 padding box(18×18)给绝对定位元素的**可用宽**,fit 型盒子被 // 夹成 18 ——字再宽盒子也只有 18,「99k+」就漏出去了。所以这里先用 // `MeasureText` 量出那一串字的宽、**显式钉成盒子的宽**(量不准时退回 - // 上限那一档 `playbarBadgeMaxW`)。字宽 + 两侧 `space.Px3` 的余量 = 缺口块宽。 + // 上限那一档 `playbarBadgeMaxW`)。字宽 + 左 `badgeNotchPadL` + 右 + // `badgeNotchPadR` 的余量 = 缺口块宽(左宽右窄:咬进图形的那一边留 + // 一圈看得见的底色缝,理由见常量注释)。 textW, _ := c.MeasureText(0, ui.Span{Text: t, Size: playbarBadgeFont}) - notchW := textW + 2*space.Px3 + notchW := textW + badgeNotchPadL + badgeNotchPadR if notchW < badgeNotchMinW { notchW = badgeNotchMinW } @@ -329,9 +343,11 @@ func countBadge(c *ui.Context, n int64, bg ui.Color) { // `badgeNotchR`)。 Radius(badgeNotchR). Background(bg). - // 字在缺口里居中,左右各留 `space.Px3`——数字上下贴着缺口内沿、 - // 左右各留一道缝,看着是「嵌进去」不是「糊上去」。 - Padding(space.None, space.Px3).Center(). + // 字在缺口里,左边留 `badgeNotchPadL`、右边留 `badgeNotchPadR` + // (CSS 序:上 右 下 左)——咬进图形的那一边是 8:数字起笔和图形 + // 残笔之间要有一眼能看到的底色缝(视觉审查打回的那一笔:3 像素的 + // 缝放大看是「字身几乎顶在笔画上」)。 + Padding(space.None, badgeNotchPadR, space.None, badgeNotchPadL).Center(). // 🔴 **不吃指针**:它是「图形右上角挖出来的一个缺口」,不是一枚键—— // 底下的按钮照点,悬停照常。 PassThrough(). diff --git a/internal/ui/playbar_counts_test.go b/internal/ui/playbar_counts_test.go index 7e4e9d18..5f7c86ae 100644 --- a/internal/ui/playbar_counts_test.go +++ b/internal/ui/playbar_counts_test.go @@ -442,12 +442,12 @@ func TestTheThreeCountsSayTheNumberToScreenReaders(t *testing.T) { // ⚠️ **判据是角标自身的 bounds 相对图形盒子 bounds 的位置**(视觉审查钉下的 // 四条真值): // -// 角标的左缘收在图形右半部以内(≥ 图形中线) (数字从图形的右上角里长出来) -// 角标的左缘在图形右缘以内 (嵌进去,不是贴在右缘外侧) +// 角标的左缘落在缺口左缘 + PadL 那一带 (嵌进缺口里,左右留缝) +// 角标的左缘不许深过「咬的那一口」 (缺口只咬右上角,不是包住图形) // 角标的下缘明显在图形中线的上方 (坐在右上角,不是骑在正中) -// 角标的右缘没越过图形右缘 + 最宽那一档 (缺口主体探在图形外的空气里) +// 角标的右缘没越过图形右缘 + 最宽探出量 (缺口主体探在图形外的空气里) // -// 反过来说:外侧角标那一版(角标左缘正好落在图形右缘,一条缝都不重叠)、横排 +// 反过来说:外侧角标那一版(角标左缘正好贴图形右缘起排、上下位置也低)、横排 // 那一版(角标中心 y ≈ 图形中心 y)和「缺口包住整个图形」那一版(宽字档数字 // 左缘跑到图形左半去了)都立刻红。 // @@ -484,23 +484,24 @@ func TestTheThreeCountsSitInsideANotchOnTheIconsTopRight(t *testing.T) { icx, icy := key.X+key.W/2, key.Y+key.H/2 iRight := icx + iconMd/2 - // 🔴 **数字的左缘收在图形右半部以内**——缺口咬的是右上角那一小块, - // 数字的头几个字符从图形右上角的凹陷里长出来:左缘落在图形中线以右、 - // 图形右缘以左。外侧角标那一版的左缘正好是图形右缘(`AnchorTopLeft`, - // 一条缝都不重叠),这条判据把两版分得干干净净。 - if badge.X >= iRight-1 { - t.Errorf("「%s」的角标左缘在 %.1f,图形右缘在 %.1f——数字没有嵌进缺口"+ - "(它还是贴在图形右缘外侧,而网易云式是收进缺口里、和图形有交叠)", - c.name, badge.X, iRight) - } - // 🔴 **左缘不许跑进图形左半**——缺口只咬右上角那一口 - // (`badgeNotchBite`),宽字那一档(「99k+」≈22)的缺口主体往右探进 - // 图形外的空气里。谁要是把缺口改成「包住整个图形」(宽字档数字左缘 - // 跑到图形左半去),图形只剩半截、这条立刻红。 - if badge.X < icx-1 { - t.Errorf("「%s」的角标左缘在 %.1f,图形中线在 %.1f——缺口咬得太深"+ + // 🔴 **数字的左缘贴在缺口里、挨着图形右缘**——缺口块的左缘收进图形 + // 右缘 `badgeNotchBite`,而数字左缘起在缺口左缘 + `badgeNotchPadL`: + // PadL > bite 时左缘探出图形右缘 PadL−bite 像素(那道看得见的底色 + // 缝在数字**左边**);PadL ≤ bite 时左缘收进图形。两档都钉: + // 左缘 ≥ iRight−2(不许深过咬的那一口 + 1)且 ≤ iRight+PadL−bite+1 + // (不许飘到缺口右半截去)。外侧角标那一版的左缘起在图形右缘 + // 紧贴(AnchorTopLeft 是 0 偏移、没有左边那道缝),这条判据也把 + // 「缝挤没了」和「字贴到图形残笔上」两档分出来。 + left := badge.X - iRight + if left < -float32(badgeNotchBite)-1 { + t.Errorf("「%s」的角标左缘在图形右缘以**内** %.1f——缺口咬得太深"+ "(它该只咬右上角那一小块,不是包住整个图形——宽字档那样会吃掉大半个图标)", - c.name, badge.X, icx) + c.name, -left) + } + if left > float32(badgeNotchPadL-badgeNotchBite)+1 { + t.Errorf("「%s」的角标左缘在图形右缘以**外** %.1f——数字飘出缺口去了"+ + "(左缘该起在缺口左缘 + PadL 那一带 ≈ %.0f)", + c.name, left, float32(badgeNotchPadL-badgeNotchBite)) } // 🔴 **下缘明显在图形中线的上方**——缺口挖的是右上角,数字坐在上 1/3 // 那一带,不是骑在图形正中。9 号字行高约 11,坐在缺口里时下缘 ≈ @@ -510,13 +511,15 @@ func TestTheThreeCountsSitInsideANotchOnTheIconsTopRight(t *testing.T) { "(缺口嵌字是数字坐在右上角,不是居中)", c.name, badge.Y+badge.H, icy) } // 🔴 **右缘不许探出缺口**:缺口块右缘探出图形右缘 `notchW − bite` - // 像素(最宽那一档「99k+」是 ≈22.3),数字居中在缺口里——它的右缘 - // 不越过缺口右缘。这条只钉「右缘 ≤ 图形右缘 + 最宽探出量 + 余量」, - // 不钉「整个在图形框里面」(缺口主体本来就在外面)。 - if badge.X+badge.W > iRight+playbarBadgeMaxW-badgeNotchBite+1 { + // 像素(最宽那一档「99k+」是 textW+PadL+PadR−bite ≈ 30.3),数字的 + // 右缘在缺口右缘以里 PadR——它的右缘不越过缺口右缘。这条只钉 + //「右缘 ≤ 图形右缘 + 最宽探出量 + 余量」,不钉「整个在图形框里面」 + //(缺口主体本来就在外面)。 + if badge.X+badge.W > iRight+playbarBadgeMaxW+float32(badgeNotchPadL+badgeNotchPadR-badgeNotchBite)+1 { t.Errorf("「%s」的角标右缘在 %.1f,缺口右缘最多在 %.1f——数字探出缺口去了"+ "(缺口探出的那一截是空着的余量,字该坐在缺口里)", - c.name, badge.X+badge.W, iRight+playbarBadgeMaxW-badgeNotchBite) + c.name, badge.X+badge.W, + iRight+playbarBadgeMaxW+float32(badgeNotchPadL+badgeNotchPadR-badgeNotchBite)) } } } From 9056405a202ae6e2e2ba38373c444299e86a8e0f Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 3 Nov 2026 11:00:01 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=20P1=EF=BC=9A=E9=87=8F=E4=B8=8D=E5=88=B0=E5=AD=97?= =?UTF-8?q?=E5=AE=BD=E6=97=B6=E6=95=B0=E5=AD=97=E8=A2=AB=E8=A3=81=E6=88=90?= =?UTF-8?q?=E9=9B=B6=E5=AE=BD=20/=20=E9=95=BF=E6=8C=89=E4=B8=89=E8=BF=9E?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=E4=B8=8D=E8=B7=9F=E5=9B=BE=E6=A0=87=E6=8A=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. MeasureText 回 0 时 textW=0 → notchW 只剩两侧 padding(≥最小宽、 兜底救不回),而 Text 的 MinWidth(0).MaxWidth(0) 把整串数字裁成 零宽——画面上剩一个空底色缺口。抽一支纯函数 badgeTextWidth:量 出来的值 ≤0 就退回上限档 playbarBadgeMaxW(缺口画宽一点,数字 一定画得出)。MeasureText 在 *ui.Context 上、mock 不掉,所以把 「量出来的值 → 兜底后的值」抽成纯函数,测试喂 0/负/正常宽三档。 2. 赞键长按三连的抖动 Translate(dx,0) 只作用在底色层和图标层,缺 口块挂在图形盒子上不吃同一个平移——按住 2.5s 全程是「图标在抖、 缺口原地不动」。给 countBadge 加一个 dx 参数:赞键传自己的 likeShakeDX,收藏/评论/测试传 0(不抖的那几枚就是恒等变换)。 --- internal/ui/like.go | 6 ++++- internal/ui/playbar_counts.go | 38 ++++++++++++++++++++++++++---- internal/ui/playbar_counts_test.go | 33 +++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/internal/ui/like.go b/internal/ui/like.go index 59e749a6..2e3bfc56 100644 --- a/internal/ui/like.go +++ b/internal/ui/like.go @@ -872,8 +872,12 @@ func (s *Shell) likeButton(c *ui.Context, name, label string, color ui.Color, li // // ⚠️ **缺口色 = 按钮底下那块底**:播放条那一列的底是 // `coverWash(t.Background)`(和播放条自己同一个来源,`playbar.go`)。 + // + // 🔴 **`dx` 传图形自己那一份抖**(不是 0):长按三连的时候底下那两层 + // 在 `dx` 上抖,缺口块不吃同一个平移就是「图标在抖、缺口原地不动」 + // ——按住 2.5s 全程缺口漂离图标(对抗性审查查出的一笔)。 if hasCount { - countBadge(c, count, s.coverWash(c.Theme(), c.Theme().Background)) + countBadge(c, count, s.coverWash(c.Theme(), c.Theme().Background), dx) } }) }) diff --git a/internal/ui/playbar_counts.go b/internal/ui/playbar_counts.go index 3ffb19ac..d8f06531 100644 --- a/internal/ui/playbar_counts.go +++ b/internal/ui/playbar_counts.go @@ -311,7 +311,31 @@ func countKeyTip(name string, n int64, tip string) string { // ⚠️ **字号是**固定的**(`playbarBadgeFont`),不是 `scale.Font(c, …)`**—— // 理由见那个常量的注释(系统文本放大 125% 就会切掉那个「+」,而「+」是这一族 // 的语义)。 -func countBadge(c *ui.Context, n int64, bg ui.Color) { +// badgeTextWidth 是「量出来的字宽」过一道兜底之后的宽度。 +// +// 🔴 **`MeasureText` 量不到的时候不许把 0 传下去**(对抗性审查查出的一笔): +// 字体后端还没就绪 / 量不到那一串时它回 0——而不挡的话,`notchW` 只剩 +// 两侧 padding(14 ≥ `badgeNotchMinW`,**连最小小宽那道兜底都救不回来**), +// 更糟的是 `Text` 的 `MinWidth(0).MaxWidth(0)` 会把**整串数字裁成零宽**—— +// 画面上剩一个空底色缺口,数字整个没了。⇒ 量不到就退回上限那一档 +// `playbarBadgeMaxW`(缺口画宽一点,可数字一定画得出)。 +// +// ⚠️ **它是单独一支函数,不是 `countBadge` 里的一行**——`MeasureText` 是 +// `*ui.Context` 上的方法、mock 不掉,所以这条分支只能这样测:把「量出来的 +// 值 → 兜底后的值」抽成一个纯函数,0 / 负数 / 正常宽三档各喂一遍。 +func badgeTextWidth(measured float32) float32 { + if measured <= 0 { + return playbarBadgeMaxW + } + return measured +} + +// `dx` 是缺口块这一帧的**水平平移**——它和图形同抖。赞键长按三连的时候 +// 图形盒子里那两层(底色 + 拇指)在抖(`likeShakeDX`),缺口块不吃同一个 +// 平移的话,按住 2.5s 全程是「图标在抖、缺口原地不动」——缺口漂离图标 +// (对抗性审查查出的一笔)。⇒ 抖动的那一条调用方把自己的 `dx` 传进来, +// 不抖的调用方(收藏 / 评论 / 测试脚手架)传 0。 +func countBadge(c *ui.Context, n int64, bg ui.Color, dx float32) { t := playbarCountText(n) if t == "" { // ⚠️ **没有数字时这一格不画**:缺口块连建都不建(占一个绝对定位的孩子是 @@ -326,6 +350,7 @@ func countBadge(c *ui.Context, n int64, bg ui.Color) { // `badgeNotchPadR` 的余量 = 缺口块宽(左宽右窄:咬进图形的那一边留 // 一圈看得见的底色缝,理由见常量注释)。 textW, _ := c.MeasureText(0, ui.Span{Text: t, Size: playbarBadgeFont}) + textW = badgeTextWidth(textW) notchW := textW + badgeNotchPadL + badgeNotchPadR if notchW < badgeNotchMinW { notchW = badgeNotchMinW @@ -338,7 +363,11 @@ func countBadge(c *ui.Context, n int64, bg ui.Color) { // 右缘推出图形右缘 notchW − bite 像素,左缘就正好收在图形里 6 像素 // ——咬的是右上角那一小块,不是包着整个图形(理由见 `badgeNotchBite`)。 Attach(ui.AnchorTopRight, ui.AnchorTopRight). - Top(-badgeNotchOut).Right(-(notchW - badgeNotchBite)). + Top(-badgeNotchOut).Right(-(notchW-badgeNotchBite)). + // 🔴 **和图形同抖**:赞键长按三连的时候图形盒子里那两层在 `dx` 上抖, + // 缺口块不吃同一个平移就是「图标在抖、缺口原地不动」——按住 2.5s + // 全程缺口漂离图标。不抖的调用方传 0,这一条就是恒等变换。 + Translate(dx, 0). // ⚠️ **四个角全圆**:左下那一口是咬进图形的,其余三角探在外面(见 // `badgeNotchR`)。 Radius(badgeNotchR). @@ -394,7 +423,8 @@ func countKeyIcon(c *ui.Context, iconName string, hovered, pressed bool, n int64 } flat.PressScale(ic, pressed, false) // 🔴 **缺口块挂在盒子右上角**——它是绝对定位的,不占布局,而它的父盒子 - // 是 18×18,于是右上角正好是图形的右上角。 - countBadge(c, n, bg) + // 是 18×18,于是右上角正好是图形的右上角。**dx 传 0**:收藏 / 评论没有 + // 长按抖动(那是赞键独有的三连),缺口块不用跟着谁动。 + countBadge(c, n, bg, 0) }) } diff --git a/internal/ui/playbar_counts_test.go b/internal/ui/playbar_counts_test.go index 5f7c86ae..3065ba40 100644 --- a/internal/ui/playbar_counts_test.go +++ b/internal/ui/playbar_counts_test.go @@ -291,6 +291,37 @@ func TestTheCountKeepsItsPlusWhenTheSystemTextIsEnlarged(t *testing.T) { } } +// TestTheBadgeWidthNeverCollapsesToZero 钉住 **`MeasureText` 量不到的时候缺口 +// 不塌成空块**。 +// +// 🔴 **对抗性审查查出的一笔**:字体后端还没就绪 / 量不到那一串时 +// `MeasureText` 回 0——不挡的话 `notchW` 只剩两侧 padding(14 ≥ +// `badgeNotchMinW`,连最小小宽那道兜底都救不回来),更糟的是 `Text` 的 +// `MinWidth(0).MaxWidth(0)` 会把**整串数字裁成零宽**——画面上剩一个空底色 +// 缺口,数字整个没了。 +// +// ⚠️ **判据是 `badgeTextWidth` 那一支纯函数**:`MeasureText` 是 `*ui.Context` +// 上的方法、mock 不掉,所以把「量出来的值 → 兜底后的值」抽了出来,这里 +// 0 / 负数 / 正常宽三档各喂一遍。 +func TestTheBadgeWidthNeverCollapsesToZero(t *testing.T) { + for _, c := range []struct { + name string + measured float32 + want float32 + }{ + {"量不到(0)", 0, playbarBadgeMaxW}, + {"量出负数(防御)", -3, playbarBadgeMaxW}, + {"正常宽(1k+ ≈14)", 14.8, 14.8}, + {"最宽档(99k+ ≈22)", 22.0, 22.0}, + } { + if got := badgeTextWidth(c.measured); got != c.want { + t.Errorf("%s:badgeTextWidth(%.1f) = %.1f,want %.1f——"+ + "量不到的时候没退回上限档,数字会被裁成零宽、剩一个空缺口", + c.name, c.measured, got, c.want) + } + } +} + // scaledCellInks 量「**界面上那一格**」在给定的系统文本缩放下,它自己的墨迹 // 有多宽(带「+」的那一串,和不带「+」的那一串)。 // @@ -319,7 +350,7 @@ func scaledCellInks(t *testing.T, withPlus, withoutPlus string, scale float32) ( // 窗口左缘裁掉、墨迹才量得全。 mygoui.Spacer(c).Grow(1) mygoui.Box(c).Size(iconMd, iconMd).Children(func() { - countBadge(c, countForText(withPlus), c.Theme().Background) + countBadge(c, countForText(withPlus), c.Theme().Background, 0) }) mygoui.Spacer(c).Grow(1) })