From 8f61c41968a008701b24823c81aac9f5eaa497f2 Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Mon, 2 Nov 2026 21:27:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=9D=A1=E5=B7=A6?= =?UTF-8?q?=E4=B8=8B=E4=B8=89=E6=9E=9A=E9=94=AE=E7=9A=84=E8=AF=BB=E6=95=B0?= =?UTF-8?q?=E6=94=B6=E6=88=90=E7=BD=91=E6=98=93=E4=BA=91=E5=BC=8F=E8=A7=92?= =?UTF-8?q?=E6=A0=87=EF=BC=9A=E6=8C=82=E5=9C=A8=E5=9B=BE=E5=BD=A2=E5=8F=B3?= =?UTF-8?q?=E4=B8=8A=E8=A7=92=E5=A4=96=E4=BE=A7=E3=80=81=E4=B8=8D=E5=8D=A0?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改之前那三枚键(赞 / 收藏 / 评论)是「图形 18 │ 缝 2 │ 数字」横排: 数字占一格,带数字的那一枚键因此宽 44.5,第三段比没有数字时宽出 37.5——900 宽的窗口上左列右端离进度条滑块只剩 5.20 像素。 现在改成角标:数字折成固定 9 号的小字(list.CountText 那一套折法, 约是图标高的 1/3、和图标的视觉重量拉得开),Attach(AnchorTopRight, AnchorTopLeft) 把角标的左上角钉在图形盒子的右上角——左缘贴图形 右缘、上缘贴图形上沿,数字整个在图形右上外侧、不压图标本体也不 内收。角标是绝对定位、不占布局、PassThrough 不登记命中——三枚键 各回到 32 宽(图形 18 + 两侧各 7),第三段从 165.5 缩回 137(四枚 32 + 三道 3 的键间缝——角标探出之后叠在缝上,没缝三枚带数的键糊 成一串),净让 28.5 给了进度条。 约束照旧:「+」是这一族的语义(至少这么多),所以字号钉死在 9 (系统文本放大 115% 就会截掉它)、上限钉在 22.5(刚好装下最宽的 「99k+」≈22.0)、MinWidth=MaxWidth 双钉(attach 按父盒子 18 给可用 宽,不钉的话那一格被夹成 18,「1亿+」的「+」就没了)、TextMuted 色、MaxLines(1) + Ellipsis 兜底(当前折法一档都用不上截断)。 E2E(internal/e2e/counts_test.go)走真窗口的无障碍树 + capture: 三枚键的名字里带数(赞 9k+ / 收藏 2k+ / 评论 1k+),而画面上图形 右缘贴边那一带和右上外侧那一带都有墨迹——横排版(数字中线 ≈ 键中心、左缘在缝后)和内收版(左缘在图形上)都红。 --- internal/e2e/counts_test.go | 311 +++++++++++++++++++++++++ internal/e2e/fixture_counts/main.go | 194 ++++++++++++++++ internal/e2e/fixture_counts/stub.go | 5 + internal/ui/commentcount_test.go | 90 +++---- internal/ui/comments_entry.go | 86 ++----- internal/ui/like.go | 112 +++------ internal/ui/partdrawer_test.go | 14 +- internal/ui/playbar.go | 163 ++++++------- internal/ui/playbar_counts.go | 348 +++++++++++++--------------- internal/ui/playbar_counts_test.go | 208 +++++++++++------ 10 files changed, 991 insertions(+), 540 deletions(-) create mode 100644 internal/e2e/counts_test.go create mode 100644 internal/e2e/fixture_counts/main.go create mode 100644 internal/e2e/fixture_counts/stub.go diff --git a/internal/e2e/counts_test.go b/internal/e2e/counts_test.go new file mode 100644 index 00000000..80697e64 --- /dev/null +++ b/internal/e2e/counts_test.go @@ -0,0 +1,311 @@ +//go:build e2e + +package e2e + +// 播放条那三枚键(赞 / 收藏 / 评论)的**数字角标**真窗口回归(本票)。 +// +// # 为什么这一族要开真窗口 +// +// 界面层那条(`internal/ui/playbar_counts_test.go` 的 +// `TestTheThreeCountsSitAsBadgesOnTheIconsTopRight`)在无头 Tester 里量的是 +// **同一棵树**——但那一层断的是「角标建在了图形的右上角」这个**布局事实**。 +// 而这里断的是**无障碍树里看得到的那一份**: +// +// 1. **角标文本在无障碍树里是那一枚键的子节点**(它是 `Attach` 上去的, +// 无障碍树的父子关系跟着元素树走,不跟「它悬在哪儿」走)。 +// 2. **角标的 bounds 落在那一枚键的右上象限**(`Attach(AnchorTopRight, +// AnchorTopLeft)`:角标左缘贴图形右缘、上缘贴图形上沿,数字整个在图形 +// 右上外侧)。 +// +// 这两层在无头 Tester 里问不到——框架只在**辅助技术问起**时才描述那一帧 +// (`mygo/ui/access.go` 的 `accessibilityOn`:真窗口里是系统的读屏软件问起, +// 无头窗口里没人问,那一帧就压根不描述)。所以这一族走 automation 插件的 +// `tree` 方法:它读的是**同一份无障碍树**(`mygo/ui/automation.go` 的 +// `AutomationTree`)。 +// +// # 怎么跑 +// +// BIU_RUN_MYGO_E2E=1 go test -tags e2e ./internal/e2e/ -run TestThePlayBarCountsSitAsBadges +// +// ⚠️ **要一个真窗口**:它开一个窗口并驱动它,所以本机得能开窗口。 + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/draw" + "image/png" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// TestThePlayBarCountsSitAsBadges 钉住**三枚键上的数字是挂在图形右上角的角标**。 +// +// 🔴 **三枚都要断**:只断一枚的话,「收藏 / 评论走了角标、赞还停在横排」那一档 +// 照样绿——而赞键的图形那一块是绝对定位的两层(`like.go`),和另外两枚不是同 +// 一条路径。 +// +// ⚠️ **判据是 bounds + 像素**:无障碍树上按钮是**叶子**(`leafRole`——按钮把 +// 里面的字收成自己的名字,孩子不单独上树),所以角标文本在树里**没有自己的 +// bounds**。这一层于是断两样: +// +// 1. 键的无障碍名字里带着那个数(「赞 9k+」)——读屏用户听得到。 +// 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") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + socketDir, socket, err := makeAutomationSocket() + if err != nil { + t.Fatalf("创建自动化 socket:%v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + + binary := filepath.Join(socketDir, "counts-fixture") + var buildOut, buildErr bytes.Buffer + build := exec.Command("go", "build", "-tags", "e2e", "-o", binary, "./internal/e2e/fixture_counts") + build.Dir = repoRoot(t) + build.Stdout = &buildOut + build.Stderr = &buildErr + if err := build.Run(); err != nil { + t.Fatalf("构建角标 fixture:%v\nstdout:\n%s\nstderr:\n%s", err, buildOut.String(), buildErr.String()) + } + + cmd := exec.CommandContext(ctx, binary) + cmd.Dir = repoRoot(t) // fixture 读 FLAC 素材要用仓库根当 cwd + cmd.Env = append(os.Environ(), + "MYGO_AUTOMATION="+socket, + "BIU_E2E_DATA_DIR="+filepath.Join(socketDir, "data")) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatalf("启动角标 fixture:%v", err) + } + defer func() { + cancel() + waitForFixtureExit(t, cmd) + }() + + conn := waitForAutomationSocket(t, socket, cmd, &stdout, &stderr) + defer conn.Close() + client := &automationClient{conn: conn, enc: json.NewEncoder(conn), dec: bufio.NewReader(conn)} + + window := firstContentWindow(t, client) + + // ⚠️ **等数画出来**:那一趟(`/x/web-interface/view`)在后台 goroutine 上 + // 跑——树是每帧重算的,等「三个无障碍名字都在」比按帧数等稳。 + for _, label := range []string{"赞 9k+", "收藏 2k+", "评论 1k+"} { + if !waitForTree(t, client, window, label, 20*time.Second) { + t.Fatalf("等不到「%s」那一枚键:\n%s", label, treeDump(t, client, window)) + } + } + + tree := countsTree(t, client, window) + + // capture 回来一张 PNG:像素那半的判据在它上面量。 + // + // ⚠️ **PNG 是像素、bounds 是 DIP**:Retina 上一格 DIP 是两个像素——坐标要 + // 乘 `scale`(`windows` 的 `contentBounds` 给 DIP、PNG 给像素,两数相除)。 + img, geom := captureWindow(t, client, window) + if os.Getenv("BIU_E2E_DUMP") != "" { + p := filepath.Join(os.TempDir(), "counts-capture.png") + f, _ := os.Create(p) + defer f.Close() + _ = png.Encode(f, img) + t.Logf("capture 落在 %s(geom=%+v)", p, geom) + for _, n := range tree { + if n.Label == "赞 9k+" || n.Label == "收藏 2k+" || n.Label == "评论 1k+" { + t.Logf("键「%s」 bounds=%+v role=%s", n.Label, n.Bounds, n.Role) + } + } + } + + for _, c := range []struct { + name string + label string // 键的无障碍名字 + }{ + {"赞", "赞 9k+"}, + {"收藏", "收藏 2k+"}, + {"评论", "评论 1k+"}, + } { + key, ok := findNode(tree, c.label) + if !ok { + t.Fatalf("无障碍树里找不到「%s」那一枚键:\n%s", c.label, treeDump(t, client, window)) + } + // ⚠️ **树的 bounds 只有原点**(每一项的 W/H 都是 0——automation 只把 + // 元素的左上角发出来)。键是 32×32(`iconHitMd`),所以图形盒子的中心 + // 是「原点 + 16」——**不是 `X+W/2`**(那是原点自己)。 + icx, icy := key.Bounds.X+16, key.Bounds.Y+16 + // 底色取样点:键的左上角(键没有底、是播放条那一行的底色)。 + bg := pixelAt(img, geom, key.Bounds.X+2, key.Bounds.Y+2) + + // 🔴 **两条判据把「贴在右上外侧」钉死**(角标的左上角钉在图形盒子 + // 右上角 = icx+9, icy−9,不收不压,字左对齐从图形右缘起排): + // + // 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) + } + 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) + } + } +} + +// countsNode 是无障碍树里的一项(只留这一族用的字段)。 +// +// ⚠️ **id / parent 是字符串**(`nodeID` 按十进制数字串发——JavaScript 的 number +// 会把它截掉),不是 JSON number。 +type countsNode struct { + ID string `json:"id"` + Parent string `json:"parent"` + Role string `json:"role"` + Label string `json:"label"` + Bounds struct { + X, Y, W, H float64 + } `json:"bounds"` +} + +// countsTree 读窗口的无障碍树。 +func countsTree(t *testing.T, client *automationClient, window int) []countsNode { + t.Helper() + var tree struct { + Nodes []countsNode `json:"nodes"` + } + if err := automationCall(client, 310, "tree", map[string]any{"window": window}, &tree); err != nil { + t.Fatalf("读元素树:%v", err) + } + return tree.Nodes +} + +// findNode 在无障碍树里找某个无障碍名字的那一项。 +func findNode(tree []countsNode, label string) (countsNode, bool) { + for _, n := range tree { + if n.Label == label { + return n, true + } + } + return countsNode{}, false +} + +// captureGeom 是「树的 DIP 坐标 → PNG 像素坐标」的换算:直接乘像素倍率。 +type captureGeom struct { + scale float64 // DIP → 像素 +} + +// px / py 把一个 DIP 点换算成 PNG 像素坐标。 +func (g captureGeom) px(x float64) int { return int(x * g.scale) } +func (g captureGeom) py(y float64) int { return int(y * g.scale) } + +// captureWindow 把窗口的内容区拍下来,返回 PNG 解码出来的图和坐标换算。 +// +// ⚠️ **坐标是两个口径**: +// +// - 无障碍树的 `bounds` 是**内容区 DIP**——它的原点就是 PNG 的左上角 +// (`captureContent` 只拍内容面,`windows` 的 `contentBounds.x/y` 是窗口 +// 在**屏幕**上的位置,和内容坐标无关——**不许**拿它去减)。 +// - PNG 是**像素**——Retina 上 1 DIP = 2 像素,倍率从 `contentBounds` 的宽 +// (DIP)和 PNG 的宽(像素)算出来。 +func captureWindow(t *testing.T, client *automationClient, window int) (*image.RGBA, captureGeom) { + t.Helper() + var wins struct { + Windows []struct { + ID int `json:"id"` + ContentBounds struct { + X, Y, Width, Height float64 + } `json:"contentBounds"` + } `json:"windows"` + } + if err := automationCall(client, 311, "windows", nil, &wins); err != nil { + t.Fatalf("问窗口的 contentBounds:%v", err) + } + var geom captureGeom + for _, w := range wins.Windows { + if w.ID == window { + // 倍率先记成 DIP 宽——等 PNG 到手再除。 + geom.scale = w.ContentBounds.Width + } + } + if geom.scale <= 0 { + t.Fatalf("窗口 %d 的 contentBounds 问不到:%+v", window, wins.Windows) + } + + var shot struct { + PNG string `json:"png"` + } + if err := automationCall(client, 312, "capture", map[string]any{"window": window}, &shot); err != nil { + t.Fatalf("拍窗口:%v", err) + } + raw, err := base64.StdEncoding.DecodeString(shot.PNG) + if err != nil { + t.Fatalf("capture 的 PNG 不是 base64:%v", err) + } + img, err := png.Decode(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("capture 的 PNG 解不开:%v", err) + } + b := img.Bounds() + rgba, ok := img.(*image.RGBA) + if !ok { + rgba = image.NewRGBA(b) + draw.Draw(rgba, b, img, b.Min, draw.Src) + } + geom.scale = float64(b.Dx()) / geom.scale + return rgba, geom +} + +// pixelAt 取 DIP 坐标上那一个像素。 +func pixelAt(img *image.RGBA, g captureGeom, x, y float64) color.RGBA { + px, py := g.px(x), g.py(y) + if px < 0 || py < 0 || px >= img.Bounds().Dx() || py >= img.Bounds().Dy() { + return color.RGBA{} + } + return img.RGBAAt(px, py) +} + +// darkerIn 数 [x, x+w) × [y, y+h)(DIP)那一块里「比底色深」的像素有几个。 +// +// ⚠️ **量「深」不量「不一样」**:角标和横排版的数字都是 `TextMuted`(比播放 +// 条底色深),而那一带还会混进键的图形——图形是主题色,是**亮**的。数「比底 +// 色深」的像素,认得的是那串小字、不是图标笔画;数「和底色不一样」会把邻居 +// 键的图标也算进来,横排版和角标版就分不出来了。 +func darkerIn(img *image.RGBA, g captureGeom, x, y, w, h float64, bg color.RGBA) int { + n := 0 + for py := g.py(y); py < g.py(y+h); py++ { + for px := g.px(x); px < g.px(x+w); px++ { + if px < 0 || py < 0 || px >= img.Bounds().Dx() || py >= img.Bounds().Dy() { + continue + } + p := img.RGBAAt(px, py) + if int(bg.R)+int(bg.G)+int(bg.B)-int(p.R)-int(p.G)-int(p.B) > 30 { + n++ + } + } + } + return n +} diff --git a/internal/e2e/fixture_counts/main.go b/internal/e2e/fixture_counts/main.go new file mode 100644 index 00000000..7cac49bc --- /dev/null +++ b/internal/e2e/fixture_counts/main.go @@ -0,0 +1,194 @@ +//go:build e2e + +package main + +// 播放条那三枚键(赞 / 收藏 / 评论)的**数字角标**真窗口 fixture。 +// +// # 为什么单独有这一台 +// +// 这一票断的是**角标的 bounds 相对那一枚键的 bounds**(它在图形右上角)—— +// 而无头 Tester 的 `Find` 量的是同一棵树,但**那一棵树上「角标挂在图形盒子 +// 右上角」是布局事实**,真窗口上同样成立;E2E 这一层要验的是「无障碍树里 +// 角标确实是那枚键的子节点,且落在右上象限」——那是**读屏软件读得到的那一份** +// (无头窗口里没人问,那一帧压根不描述,见 `internal/e2e/progress_test.go` +// 的文件头)。 +// +// ⚠️ **它跑的是真的那一枚壳**(`ui.New` + `s.View`),不是手搓的一排盒子—— +// 要验的就是「真播放条上角标在哪儿」,而不是「角标这个函数在测试里放得对不对」。 +// +// # 数据从哪儿来 +// +// 内置一台假 B 站(`httptest`):`/x/web-interface/view` 回 `stat`(reply / +// favorite / like),`/x/player/wbi/playurl` 回一条指向 `/cdn/a.flac` 的 dash +// 流,`/x/player/pagelist` 回一集,`/x/web-interface/nav` 回「已登录」—— +// 和 `internal/ui/searchbili_test.go` 的 `biliFixture` 是同一份形状(这里只留 +// 播放条要的那几路)。 + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/egoist/mygo" + _ "github.com/egoist/mygo/plugins/automation" + + "github.com/ShellMonster/PudMusic/internal/app" + "github.com/ShellMonster/PudMusic/internal/service/bili" + ui2 "github.com/ShellMonster/PudMusic/internal/ui" +) + +func main() { + // 内置的假 B 站:播放条要的三路(view 的 stat / pagelist / playurl→cdn)。 + srv := httptest.NewServer(http.HandlerFunc(serveFixture)) + defer srv.Close() + + a := app.New() + cli := bili.NewClient(bili.Options{Transport: srv.Client().Transport}) + cli.TestOnlyPointHostsAtFixture(srv.URL) + a.MountLogin(app.NewMemoryStore("counts-fixture"), cli) + tmp, err := os.MkdirTemp("", "counts-fixture-") + if err != nil { + log.Fatalf("造临时目录:%v", err) + } + defer os.RemoveAll(tmp) + a.MountStreaming(app.Streaming{ + Client: cli, Media: srv.Client(), TempDir: tmp, + }) + + s := ui2.New(a) + // 队列里放进**在线那一首**:`CommentsWorthAsking` 判的是当前项是 B 站的 + // 对象,而三个数在 `/x/web-interface/view` 的 `stat` 里——同一趟回包。 + if err := a.Enqueue(app.Item{ + Source: "online", Type: "mv", Bvid: "BVcountsBadge", Cid: "777", + Title: "有三枚角标的那一首", Artist: "某个 UP 主", + }); err != nil { + log.Fatalf("把在线那首放进队列:%v", err) + } + + // ⚠️ **和用户数据目录分开**(理由同 `automation_e2e.go` 顶上那段):登录态 + // 和封面缓存落在哪由它说了算——E2E 起的这一份有自己的一份,不碰用户那份。 + if dir := os.Getenv("BIU_E2E_DATA_DIR"); dir != "" { + mygo.App.SetPath(mygo.PathUserData, dir) + } + + mygo.App.WhenReady(func() { + mygo.NewWindow(ui2.WindowOptions(s.View)) + }) + if err := mygo.App.Run(); err != nil { + log.Println(err) + os.Exit(1) + } +} + +// serveFixture 是那一台假 B 站的全部回包(`internal/ui/searchbili_test.go` 的 +// `biliFixture` 里「播放条要的那几路」的子集)。 +func serveFixture(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/x/web-interface/nav": + writeJSON(w, map[string]any{"code": 0, "message": "0", "ttl": 1, + "data": map[string]any{"isLogin": true, "mid": 9527, "uname": "测试账号", + "wbi_img": map[string]string{ + "img_url": "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png", + "sub_url": "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png", + }}}) + + case r.URL.Path == "/x/web-interface/view": + // 三枚键的三个数(票 #701:同一趟回包)。 + writeJSON(w, map[string]any{"code": 0, "message": "0", "ttl": 1, + "data": map[string]any{ + "aid": 4242, "bvid": r.URL.Query().Get("bvid"), + "stat": map[string]any{"reply": 1286, "favorite": 2468, "like": 9999}, + }}) + + case r.URL.Path == "/x/player/pagelist": + writeJSON(w, map[string]any{"code": 0, "message": "0", "ttl": 1, + "data": []any{map[string]any{ + "cid": 777, "page": 1, "part": "第一集", "duration": 260, + "from": "vupload", "vid": "", "weblink": "", + "dimension": map[string]any{"width": 1920, "height": 1080, "rotate": 0}, + }}}) + + case r.URL.Path == "/x/player/wbi/playurl": + writeJSON(w, map[string]any{"code": 0, "message": "0", "ttl": 1, + "data": map[string]any{ + "timelength": 1000, + "dash": map[string]any{ + "duration": 1, + "audio": []any{map[string]any{ + "id": 30280, "bandwidth": 110836, "codecs": "mp4a.40.2", + "mimeType": "audio/mp4", + "baseUrl": mediaURL(r), + }}, + }, + }}) + + case strings.HasPrefix(r.URL.Path, "/cdn/"): + serveMedia(w, r) + + default: + http.NotFound(w, r) + } +} + +// mediaURL 是 playurl 回给客户端的那条流地址:指向这台服务器的 `/cdn/a.flac`。 +// +// ⚠️ **用 `r.Host` 而不是记一份 srv.URL**:handler 拿到的是进来的那一份请求, +// 主机头就是它自己——不用把 httptest 的地址穿进来。 +func mediaURL(r *http.Request) string { + return "http://" + r.Host + "/cdn/a.flac?deadline=" + + strconv.FormatInt(time.Now().Add(2*time.Hour).Unix(), 10) + "&upsig=deadbeef" +} + +// serveMedia 回音频字节(`internal/service/audio/flacdec/testdata/stereo16.flac` +// 那份真 FLAC——解码器要真字节才放得起来)。 +// +// ⚠️ **Range 也要答**:取流是按段拉的(`serveMedia` 那份的同一套)。 +func serveMedia(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(filepath.Join(fixtureRoot(), "internal", "service", + "audio", "flacdec", "testdata", "stereo16.flac")) + if err != nil { + http.Error(w, "读不到 FLAC 素材: "+err.Error(), http.StatusInternalServerError) + return + } + start := 0 + if h := r.Header.Get("Range"); strings.HasPrefix(h, "bytes=") { + start, _ = strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(h, "bytes="), "-")) + } + if start > len(data) { + http.Error(w, "越界", http.StatusRequestedRangeNotSatisfiable) + return + } + w.Header().Set("Content-Length", fmt.Sprint(len(data)-start)) + if start > 0 { + w.Header().Set("Content-Range", + fmt.Sprintf("bytes %d-%d/%d", start, len(data)-1, len(data))) + w.WriteHeader(http.StatusPartialContent) + } + _, _ = w.Write(data[start:]) +} + +// fixtureRoot 是仓库根(E2E 测试在仓库根下 `go build` 这台 fixture)。 +func fixtureRoot() string { + // `go build` 的工作目录是仓库根(`internal/e2e` 的测试把 `build.Dir` 钉在 + // `repoRoot`)——而**运行**时 cwd 也是仓库根(`exec.Command` 没设 Dir 就 + // 继承测试进程的 cwd,测试在 `internal/e2e` 下跑、往上两级是根)。 + if _, err := os.Stat("internal/service/audio/flacdec/testdata/stereo16.flac"); err == nil { + return "." + } + if _, err := os.Stat("../../internal/service/audio/flacdec/testdata/stereo16.flac"); err == nil { + return "../.." + } + return "." +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/e2e/fixture_counts/stub.go b/internal/e2e/fixture_counts/stub.go new file mode 100644 index 00000000..bd3657ff --- /dev/null +++ b/internal/e2e/fixture_counts/stub.go @@ -0,0 +1,5 @@ +//go:build !e2e + +package main + +func main() {} diff --git a/internal/ui/commentcount_test.go b/internal/ui/commentcount_test.go index 79880515..8be2c938 100644 --- a/internal/ui/commentcount_test.go +++ b/internal/ui/commentcount_test.go @@ -162,7 +162,8 @@ func TestTheZeroCountIsSpelledAsNoNumberAtAll(t *testing.T) { // 宽度账 // ───────────────────────────────────────────────────────────────────────────── -// TestTheCountDoesNotSqueezeTheTitleOrTheSlider 是这一票的宽度回归。 +// TestTheCountDoesNotSqueezeTheTitleOrTheSlider 是这一票的宽度回归(本票角标 +// 之后改断**不变**)。 // // # 改之前 / 改之后的账(实测,深色主题,同一首歌) // @@ -172,24 +173,20 @@ func TestTheZeroCountIsSpelledAsNoNumberAtAll(t *testing.T) { // 1200 32 → 67 76 → 76 0 413.2 // 1600 32 → 67 117.6 → 82.6 35 523.2 // -// (「有数字」取的是数字吃到上限 33 的那一档,即 ≥ 100 万条——那是**最宽** -// 的一档;「1286」那一档是 57.9,歌名一格都不让。) +// (那是票 #682 横排那一版:数字占一格(缝 2 + 数字 33),评论键从 32 长到 +// 67,让位的是文字列 / 左列自己。) // -// 🔴 **让位只在 1600 那一档发生,且让的正好是评论键长出来的那 35**(缝 2 + -// 数字 33):窄窗口(900~1280)上左边那一列由**保底宽**说话(`playbarLeftMinW` -// 272 → 307),所以加宽落在**左列自己**身上(撑出轨道的那一截从 98.4 变成 -// 133.4),歌名那一段仍然是它的保底 76(`trackTextMinW`)。1600 上轨道够宽、 -// 文字列吃的是余量,于是那 35 从它这儿出。 +// 🔴 **本票(角标)之后那 35 没有了**:数字收成挂在图形盒子右上角的角标, +// `Attach` **不占布局**——所以评论键**带不带数字都是 32**,歌名**一个像素都 +// 不让**,进度行也**一个像素都不让**。 // -// 🔴 **不许让破保底**:让完 1600 上还剩 82.6 > 76。 -// -// 🔴 **进度行一个像素都不许让**:它在中间那一列里,压根不在左列里,所以左列 -// 撑出去的那 35 **不该**碰到它——900 宽下评论键右沿 323,滑块左沿 330.7, -// 中间剩下 7.7(`TestTheCommentsKeyDoesNotTouchTheProgressBar` 那条盯的是 -// 「不碰」,这一条盯的是「一寸不让」)。 +// 🔴 **进度行一个像素都不许让**:它在中间那一列里,压根不在左列里——所以左列 +// 不许被撑出去。900 宽下评论键右沿 297,滑块左沿 330.7,中间剩下 **33.7** +// (`TestTheCommentsKeyDoesNotTouchTheProgressBar` 那条盯的是「不碰」,这一条 +// 盯的是「一寸不让」)。 // // ⚠️ **比的是「有数字 / 没数字」两档**:同一个窗口、同一首歌、同一个 bvid, -// 只有 `stat.reply` 不同——于是差别只有评论键那 35,没有别的变量。 +// 只有 `stat.reply` 不同——而本票之后**两档一个像素都不该差**。 func TestTheCountDoesNotSqueezeTheTitleOrTheSlider(t *testing.T) { const longTitle = "一首歌的名字很长很长很长很长" const longArtist = "一个非常非常非常非常非常非常长的 UP 主名字" @@ -202,20 +199,21 @@ func TestTheCountDoesNotSqueezeTheTitleOrTheSlider(t *testing.T) { Title: longTitle, Artist: longArtist, ArtistMid: 1, } without := commentCountWidths(t, it, width, 0) - with := commentCountWidths(t, it, width, 1234567) // 数字吃到上限 33 那一档 + with := commentCountWidths(t, it, width, 1234567) // 数字吃到上限那一档 - keyGrew := with["评论键"] - without["评论键"] - if keyGrew <= slack { - t.Fatalf("宽度 %d:带数字的评论键 %.1f,不带数字的 %.1f——那一档量错了(这一条比不了)", - width, with["评论键"], without["评论键"]) + // 🔴 **角标不占布局**:评论键带不带数字都是 32,一个像素都不许长。 + if grew := with["评论键"] - without["评论键"]; grew > slack { + t.Errorf("宽度 %d:带数字的评论键 %.1f,不带数字的 %.1f——"+ + "角标该不占布局(那一格是 Attach 上去的),它却把键撑宽了 %.1f", + width, with["评论键"], without["评论键"], grew) } for _, what := range []string{longTitle, longArtist} { - // 🔴 让的**只能是评论键长出来的那一截**。 - if gave := without[what] - with[what]; gave > keyGrew+slack { - t.Errorf("宽度 %d:「%s」让了 %.1f,而评论键只长了 %.1f——"+ - "数字花掉了别的东西的宽度", width, what, gave, keyGrew) + // 🔴 歌名 **一个像素都不让**(角标不占布局,没有东西要它让)。 + if gave := without[what] - with[what]; gave > slack { + t.Errorf("宽度 %d:「%s」让了 %.1f——角标不占布局,歌名一个像素都不该让", + width, what, gave) } - // 🔴 让完不许破那一列的保底。 + // 🔴 不许破那一列的保底。 if with[what] < trackTextMinW-slack { t.Errorf("宽度 %d:带数字之后「%s」只剩 %.1f 宽(保底 %d)——"+ "文字列让破了", width, what, with[what], trackTextMinW) @@ -272,34 +270,24 @@ func commentCountWidths(t *testing.T, it app.Item, width int, reply int64) map[s // // 判据分三层: // -// 1. 常量之间的账(`playbarActionsW` = 三枚 32 + 评论键 67 = 163); +// 1. 常量之间的账(`playbarActionsW` = 四枚 32 + 三道键间缝 3 × 3 = 137); // 2. 注释里那个「最小宽度:**N**」要与 `playbarLeftMinW` 算出来的数一致; // 3. 「歌名的宽」那段要与**实测**一致:1600 那一档不是 76,注释里必须写出量到 // 的那个数(`fmt.Sprintf("%.1f")` 的同一个格式)。 func TestTheWidthCommentsMatchTheCode(t *testing.T) { // 1. 常量的账。 // - // ⚠️ **票 #701 换了这一族的账**:三枚键(收藏 / 赞 / 评论)**各带一个数字**, - // 而带数字的那一枚是「图形 18 │ 缝 2 │ 数字(上限 24.5)」= 44.5——图形**紧贴** - // 左沿(左右内边距 0),分集那一枚照旧是 32。 - if playbarCommentsW != iconMd+commentCountGap+playbarCountMaxW { - t.Errorf("playbarCommentsW = %v,want %v(18 + 缝 + 数字格)", - playbarCommentsW, iconMd+commentCountGap+playbarCountMaxW) - } - if playbarCommentsW != 44.5 { - t.Errorf("playbarCommentsW = %v,want 44.5(票 #701 量出来的那一档)", playbarCommentsW) - } - if playbarActionsW != 165.5 { - t.Errorf("playbarActionsW = %v,want 165.5(分集 32 + 三枚带数字的键 3 × 44.5)", playbarActionsW) - } - if playbarLeftMinW != 309.5 { - t.Errorf("playbarLeftMinW = %v,want 309.5(52 + 8 + 76 + 8 + 165.5)", playbarLeftMinW) + // ⚠️ **本票(角标)换了这一族的账**:三枚键(收藏 / 赞 / 评论)的数字**不占 + // 布局**——它挂在图形盒子的右上角当角标,所以每一枚键回到 32、和没有数字 + // 时一样,分集那一枚也是 32。 + if playbarActionsW != 137 { + t.Errorf("playbarActionsW = %v,want 137(四枚 32 + 三道键间缝 3 × 3)", playbarActionsW) } - if commentCountGap != 2 { - t.Errorf("commentCountGap = %d,want 2", commentCountGap) + if playbarLeftMinW != 281 { + t.Errorf("playbarLeftMinW = %v,want 281(52 + 8 + 76 + 8 + 137)", playbarLeftMinW) } - if playbarCountMaxW != 24.5 { - t.Errorf("playbarCountMaxW = %v,want 24.5(刚好装下最宽那一档「99k+」24.49)", playbarCountMaxW) + if playbarBadgeMaxW != 22.5 { + t.Errorf("playbarBadgeMaxW = %v,want 22.5(刚好装下最宽那一档「99k+」≈22.0,9 号字)", playbarBadgeMaxW) } // 2/3. 注释要与代码和实测对得上。 @@ -311,8 +299,8 @@ func TestTheWidthCommentsMatchTheCode(t *testing.T) { // 注释里那句「最小宽度:**N**」。 // - // ⚠️ **用 `%g` 而不是 `%d`**:票 #701 之后那个数是 **309.5**(上限 24.5 - // 带来的半像素),而 `%d` 读一个带小数的数会**报错**(整数读不进小数)。 + // ⚠️ **用 `%g` 而不是 `%d`**:这个数历史上带过半像素(票 #701 是 309.5), + // `%d` 读一个带小数的数会**报错**(整数读不进小数)。 claimed := -1.0 for _, line := range strings.Split(src, "\n") { if !strings.Contains(line, "playbarLeftMinW 是左边那一列的最小宽度") { @@ -334,11 +322,9 @@ func TestTheWidthCommentsMatchTheCode(t *testing.T) { // 歌名的宽:量四档,把「不是 76 的那一档」的真实数写进注释里。 // - // ⚠️ **票 #701 之后那个数是 101.9**:票 #682 时它是 82.6(1600 那一档), - // 而这一票把评论键**缩回 44.5**(图形紧贴左沿),于是让给歌名的又还回去了。 - // 900 / 1000 / 1200 三档**都是** 101.9(那一列在这三档上由**保底宽**说话, - // 而保底宽钉着 309.5——比 76 多出来的那一截归**歌名那一列**);1600 那一档 - // 由轨道说话,是 **106.0**。 + // ⚠️ **本票(角标)之后那个数是 76**:角标不占布局,左列保底 281—— + // 900 / 1000 / 1200 三档**都是** 76(那一列在这三档上由**保底宽**说话); + // 1600 那一档由轨道说话,是 **82.6**(轨道 1 份 313.6 > 保底 281)。 const longTitle = "一首歌的名字很长很长很长很长" it := app.Item{ Source: "online", Type: "mv", Bvid: "BVcommentWidthDoc", Cid: "1", diff --git a/internal/ui/comments_entry.go b/internal/ui/comments_entry.go index 52a9632e..0e2a65ea 100644 --- a/internal/ui/comments_entry.go +++ b/internal/ui/comments_entry.go @@ -48,40 +48,11 @@ import ( // 通道(画面上是「另一枚键莫名其妙缩着」)。 const commentsButtonKey = "playbar:comments" -// 播放条上评论键那个数字的几档。 +// 评论键那个数字的形状(票 #682 起带数,本票起收成**角标**)。 // -// # 🔴 宽度账(票 #682 / #701) -// -// 改之前这一枚是**纯图标键**:盒子 32(`iconHitMd`),图形 18(`iconMd`)。 -// 票 #682 给它加过数字(那一版是 32 + 2 + **33** = **67**,数字格的上限是 -// **33**)。 -// -// ⚠️ **票 #701 把那个折法换掉了**(`list.CountText` 那一族:「1k+ / 10k+ / -// 99k+ / 1亿+」),而那一族的宽由**数字格的上限**(**24.5**)说了算——比旧的 -// 33 还窄 8.5。而**这一枚键的形状也变了**:带数字时图形**紧贴**左沿(左右 -// 内边距 0),所以它的宽是 18 + 2 + 数字,**不是** 7 + 18 + 2 + 数字 + 7。 -// -// commentCountGap 2 图形和数字之间的缝(比 `space.Px4` 窄:它们是一格里的两半) -// -// ⚠️ **数字格的上限现在是 `playbarCountMaxW`(24.5)**,不是旧的 33—— -// **三个键共用一个上限**(理由见 `playbar_counts.go` 那个常量的注释:它挡的是 -// 「数字本身被拉出那一格」——而**左列的宽本来就是常数**,所以碰撞不可能发生)。 -// 旧的 33 是票 #682 的账,那时的折法最宽的一档是「99.9万」(32.95),而新折法 -// 最宽的是「99k+」(**24.49**)。 -// -// ⚠️ **字号是 `Size10`**:这是播放条上最小的一档,而数字是**附属信息** -// (图标才是那一枚键的身份)。再大一号(Size11)上面那个上限就不够了。 -const ( - commentCountGap = playbarCountGap -) - -// playbarCommentsW 是评论键**摆满**那一档的宽:18 + 2 + **24.5** = **44.5**。 -// -// ⚠️ **它和 `iconHitMd` 不是同一个数**,所以 `playbarActionsW` 不能再用 -// 「四枚 × 32」算——那一条是票 #573 的账(四枚纯图标键),票 #682 之后评论 -// 那一枚比别的宽 35,票 #701 之后是三枚带数字的键(见 `playbar.go` 的 -// `playbarActionsW`)。 -const playbarCommentsW = iconMd + commentCountGap + playbarCountMaxW +// ⚠️ **角标不占布局**:数字折成一个小字,挂在图形盒子的右上角——所以这一枚键 +// 的宽是 **32**(图形 18 + 两侧各 7),**带不带数字都一样**。宽度账和结构都在 +// `playbar_counts.go`(三枚键共用一份)。 // commentsKeyLabel / commentsKeyTip 是评论键的无障碍名字和悬停提示。 // @@ -130,27 +101,17 @@ func (s *Shell) viewCommentsButton(c *ui.Context, st app.State) { c.Key(commentsButtonKey) // ⚠️ **壳走 `flat.Button`,内容自己拼**:`iconKey` 那一档只会画一个图标, - // 而这一枚要有「图形 + 数字」两半。壳、悬停淡底、圆角都和 `iconKey` 同一份 - // (`flat.button`),只是内边距和缝按「一格里的两半」摆。 + // 而这一枚要的是「图形盒子 + 右上角一枚角标」——壳、悬停淡底、圆角都和 + // `iconKey` 同一份(`flat.button`)。 // - // 🔴 **左右内边距跟着数走**(`countKeyPadX`):带数字时是 0(不然会宽出 - // 14),没有数字时是 7(那时的形状和改之前一模一样)。 + // 🔴 **内边距回到纯图标键那一档(每边 7)**:角标不占布局(它 `Attach` 在 + // 图形盒子的右上角),所以这一枚的宽和没有数字时**一模一样**——32。 btn := flat.Button(c, "").Label(commentsKeyLabel(count)). - Height(iconHitMd).Padding(space.None, countKeyPadX(count)).Gap(commentCountGap) + Height(iconHitMd).Padding(space.None, space.Px7) btn.Children(func() { - // ⚠️ **结构是共享的那一份**(`countKeyRow`):「图形 │ 缝 2 │ 数字」, - // 而图形紧贴那一枚键的左沿(宽度账见 `playbar_counts.go` 的文件头)。 - // - // ⚠️ **数字那一格的上限是 `playbarCountMaxW`(24.5)**:宽度账见上面 - // 那几个常量——它是一道**保险**(折法改了也不撞滑块),而当前那个折法 - // (`list.CountText`)最宽的一档是「99k+」(**24.49**),**刚好装满** - // 24.5。 - // - // ⚠️ **当前折法一档都不截**:上限 24.5 是**保险**,不是「有一档会被 - // 切掉」——「100…」那一档在当前折法里**压根不存在**(最宽的「99k+」 - // 24.49 ≤ 24.5)。 - countKeyRow(c, iconComments, count, btn.Hovered(), btn.Pressed(), - func(c *ui.Context) { countIcon(c, iconComments, btn.Hovered(), btn.Pressed()) }) + // ⚠️ **结构是共享的那一份**(`countKeyIcon`):一个 18×18 的图形盒子, + // 角标挂在盒子的右上角。角标的上限 / 字号 / 挂点在 `playbar_counts.go`。 + countKeyIcon(c, iconComments, btn.Hovered(), btn.Pressed(), count) }) if s.withTip(c, btn, commentsKeyTip(count)).Clicked() { @@ -169,24 +130,23 @@ func (s *Shell) viewCommentsButton(c *ui.Context, st app.State) { // // ⚠️ **本地文件不摆**(同 `viewCommentsButton`)。 // -// 🔴 **满屏这一枚不带数字**(票 #682 的有意取舍):播放条那一枚能带,是因为 -// 左列是**三段定宽**的(`playbarLeftMinW` 钉着,数字只吃自己那 24.5,歌名照旧 -// 76)。满屏底下那一行是**居中的 flex**(`viewFullControls` 的 -// `Justify(ui.Center)`):这一行里任何一枚变宽,**播放键就离开窗口的竖直中轴** -// ——而「三键落在中轴上」正是满屏底栏那条设计(`fullplayer_layout_test.go` 的 -// `TestTheFullPlayerBottomBarPaddingAndMaxWidth` 量着它;那里用的是本地文件, -// 所以评论键不在,量到的中轴是准的)。给评论键加 24.5 会让播放键偏 12.25,而 -// 满屏那一行没有播放条左列那样的余量可以重排。 +// 🔴 **满屏这一枚不带数字**(票 #682 的有意取舍):满屏底下那一行是**居中的 +// flex**(`viewFullControls` 的 `Justify(ui.Center)`)——这一行里任何一枚变宽, +// **播放键就离开窗口的竖直中轴**(「三键落在中轴上」正是满屏底栏那条设计, +// `fullplayer_layout_test.go` 的 `TestTheFullPlayerBottomBarPaddingAndMaxWidth` +// 量着它;那里用的是本地文件,所以评论键不在,量到的中轴是准的)。 +// +// ⚠️ **本票之后播放条那一枚也不再「加宽」了**(角标不占布局,它的宽是 32、 +// 和没有数字时一样)——但满屏那一枚**照旧不带**:播放条上角标挂得住,是因为 +// 那一枚键的图形右边是空气;满屏底栏那一枚**左右都是别的键**,角标会盖到 +// 邻居身上(那一行的缝只有几像素,而角标比它宽得多)。要补的话是另一票的账 +// (比如给那一枚单独留一格、或者把角标改成压在图标底下那种)。 // // ⚠️ **但提示句可以带上那个数**(票 #701 之后的对齐):数字**不进布局**—— // 它只在那一句提示里,而提示是**浮在画面上的气泡**(不进那一行的 flex)—— // ⇒ 所以「不带数字」这条取舍**管不到提示句**,而「提示句里说清是整个稿件的 // 多少」是播放条那一枚已经有的(`countKeyTip`)。⇒ 两枚键的提示句现在 // **说同样的话**。 -// -// ⚠️ **同一份数据、同一个画法**:真要加数字的时候把那 24.5 从别处让出来 -// (比如把这一行改成「左右两组 + 中间一组」的 Grid),不能像播放条那样直接 -// 撑宽。 func (s *Shell) viewFullCommentsButton(c *ui.Context, st app.State, base flat.Base) { if !st.HasTrack || !s.app.CommentsWorthAsking() { return diff --git a/internal/ui/like.go b/internal/ui/like.go index 512aa3d7..e6d44933 100644 --- a/internal/ui/like.go +++ b/internal/ui/like.go @@ -737,30 +737,22 @@ func (s *Shell) viewLikeStar(c *ui.Context, st app.State) { // // # 🔴 「图形 + 数字」的结构(票 #701) // -// 这一枚现在带一个读数(获赞数),所以它的**内容**是「图形 + 数字」两半: +// 这一枚现在带一个读数(获赞数),而那个数是挂在**图形盒子右上角**的角标 +// (本票起:网易云式),不是图形右边的一格——所以它的**内容**是: // -// [ 32 × 32 的图形块 ] │ 缝 2 │ [ 数字 ] +// [ 32 × 32 的按钮 ]:正中一个 18×18 的图形盒子,角标挂在盒子的右上角 // -// ⚠️ **图形那一块是 32 × 32,而按钮比它宽**:按钮的宽是「图形块 + 缝 + 数字」, -// 而那一圈背景和那个拇指图形**只在左边那 32 里面**。 +// ⚠️ **图形那一块是 18 × 18,按钮比它大**:按钮的宽是 32(图形 18 + 两侧各 7, +// 和没有数字时**一模一样**——角标不占布局)。 // -// ⚠️ **所以两层不再挂在按钮正中,而是挂在「图形块」上**:绝对定位是相对**父元素 -// 的 padding box**,而图形块是那一行里的第一个子元素——于是 -// `Attach(AnchorCenter, AnchorCenter)` 挂到图形块上,那一圈背景就正好在图形块 -// 正中(和改之前一样:它罩住的是那个拇指,不是数字)。 -// -// ⚠️ **为什么不能把它们挂在按钮正中**:那会让这一圈背景跑到「图形 + 数字」那一整 -// 条的正中——而那一圈是**拇指的底圈**(满大时半径 16 = 32 的一半),跑到整条 -// 正中的话它就罩住了数字那一半,画面上是「一圈底色浮在数字底下」。 +// ⚠️ **所以两层挂在「图形盒子」上**:绝对定位是相对**父元素的 padding box**, +// 而图形盒子是按钮里那个 18×18 的孩子——于是 `Attach(AnchorCenter, +// AnchorCenter)` 挂到图形盒子上,那一圈背景就正好在图形正中。 // // ⚠️ **长按三连的手势不许坏**:手势的目标是**按钮**(`b.Pressed()`),而绝对 // 定位的元素**照旧登记命中**(`mygo/ui/runtime.go`)——所以那一圈背景和那个 // 拇指图形仍然点在按钮上(命中链取最内层的可点元素,而它们不是可点的)。 -// 「抖在图形块上」这件事也照旧:抖的是**内层**,命中框和 tooltip 的锚点不晃。 -// -// ⚠️ **没有数字时这一枚和改之前一模一样**:图形 18 + 两侧各 7 的内边距(32), -// 而那一圈底色挂在**按钮**上(`Attach` 相对按钮的 padding box,居中就是按钮 -// 正中)——那正是改之前的样子。 +// 「抖在图形盒子上」这件事也照旧:抖的是**内层**,命中框和 tooltip 的锚点不晃。 // // ⚠️ **两层都是绝对定位**:绝对定位的元素绘制时排在普通子元素**之后** // (`mygo/ui/paint.go` 的两趟循环),而同一趟里是按**建元素的先后**画的 @@ -780,14 +772,10 @@ func (s *Shell) viewLikeStar(c *ui.Context, st app.State) { // 一个动作,按下去的时候让它**涨一点**,比缩下去更像「按到了」(手册 ⓪ ①: // 「如果是放大,一般 1.04 就够了」)。 func (s *Shell) likeButton(c *ui.Context, name, label string, color ui.Color, liked bool, count int64) ui.Element { - // ⚠️ **按钮的宽不是钉死的**:它跟着「图形 + 缝 + 数字」走——**没有数字时 - // 它就是 32**(图形 18 + 两侧各 7),和改之前**一个字节都不差**。 - // - // ⚠️ **不能 `Size(iconHitMd, iconHitMd)`**:那样数字那一格就被挤出去了(而 - // 框架**不会**把它藏起来——它是溢出去画在按钮外面,而按钮的命中框还是 32, - // 于是那一格数字既看得见又点不着)。 + // ⚠️ **按钮的宽是 32**(图形 18 + 两侧各 7 的内边距)——角标不占布局, + // 所以**带不带数字都一样宽**,和改之前**一个字节都不差**。 b := flat.Button(c, "").Label(label). - Height(iconHitMd).Padding(space.None, countKeyPadX(count)).TextColor(color) + Height(iconHitMd).Padding(space.None, space.Px7).TextColor(color) hasCount := playbarCountText(count) != "" // 长按三连:这一帧的按住喂进赞键**自己那一份**状态机。 @@ -810,32 +798,21 @@ func (s *Shell) likeButton(c *ui.Context, name, label string, color ui.Color, li } dx := likeShakeDX(&s.likeBtn, s.clock.Now(), armed) - // 🔴 **图形那一半**(和「背景层 + 图标层」那两层是同一块地方)。 - // - // ⚠️ **锚盒子是 18 × 18**(`iconMd`,不是 `iconHitMd`):带数字时图形 - // **紧贴**那一枚键的左沿(宽度账见 `playbar_counts.go` 的文件头),而绝对 - // 定位是相对**父元素的 padding box**——所以那一圈底色得挂在**一个正好 - // 18 宽的盒子**上,那一圈才以拇指为中心。 - // - // 🔴 **底色的形状分两档**(`bgW` / `bgH`): - // - // 没有数字 32 × 32 的圆 ——和改之前**一模一样** - // 有数字 18 × 32 的胶囊 ——横向只占拇指那么宽 + // 🔴 **图形那一块**(和「背景层 + 图标层」那两层是同一块地方)。 // - // **为什么带数字时要缩成胶囊**:32 宽的圆会往**右边**伸 7(拇指半径 9,圆 - // 半径 16),而那一截正好压在**数字**上(数字从图形右边 2 开始)。 + // ⚠️ **锚盒子是 18 × 18**(`iconMd`):角标挂在**图形**的右上角,而那一圈 + // 底色也要以图形为中心——所以两层都挂在**一个正好 18 宽的盒子**上。 // - // ⇒ 宽度账(900 宽下左列右沿离滑块只剩 **5.20**)**不许**多出那 7 像素。而 - // **竖向不变**是有意的:那一圈竖着罩住整个拇指(它比拇指高),而横向只 - // 要罩住拇指就行——多出来的那一圈横向留白在有数字时**没有地方放**(右边 - // 就是数字)。 + // 🔴 **底色的形状不再分两档**:改之前带数字时是「18 × 32 的胶囊」(怕压住 + // 右边那一格数字)。角标不占布局了,所以那一圈回到 **32 × 32 的圆**—— + // 和没有数字时**一模一样**。 // - // ⚠️ **竖着还是 32**:`like_anim_test.go` 量的是「离中心最远的那个像素」 - // (半径 16),而胶囊竖向的半高仍是 16——那个断言**照样成立**。 + // ⚠️ **而那一圈不会盖住角标**:角标挂在图形盒子的**右上角**(盒子坐标 + // x≈9..18、y≈0..9 那一小角),而那一圈的右上半径离它还有一段——满大时 + // 圆的边缘在那一角之下(实测那一小角在圆的 14° 那一带,圆的边缘离它 + // 还有约 4 像素)。真压上是「角标往右下挪多了」的事,那时挪的是 + // `playbarBadgeNudge`,不是这一圈。 bgW, bgH := float32(iconHitMd), float32(iconHitMd) - if hasCount { - bgW = iconMd - } drawIcon := func(c *ui.Context) { // 背景层(先建 → 画在下面) bg := ui.Box(c).Attach(ui.AnchorCenter, ui.AnchorCenter). @@ -880,38 +857,21 @@ func (s *Shell) likeButton(c *ui.Context, name, label string, color ui.Color, li } b.Children(func() { - if !hasCount { - // ⚠️ **没有数字时**:这一枚和改之前**一模一样**——图形 18 + 两侧各 7 - // 的内边距(32),而那一圈底色挂在**按钮**上(绝对定位相对按钮的 - // padding box,居中就是按钮正中)。 - // - // 🔴 **图形要占住那 18**:它是绝对定位的,而绝对定位的元素**不占布局 - // 的宽**(`mygo/ui/element.go` 的 `Absolute`)——所以这里给它一个 - // **18 见方的锚盒子**,那一圈底色和它挂在**这个盒子**上(而不是按钮 - // 上),而**盒子本身在流里**,于是按钮的宽还是 18 + 7 + 7 = 32。 - ui.Box(c).Size(iconMd, iconMd).Children(func() { - drawIcon(c) - }) - return - } - // 🔴 **有数字时**:图形**紧贴**那一枚键的左沿(宽度账见 - // `playbar_counts.go` 的文件头)——图形**不在** 32 的盒子里,那会多出 - // 14 的留白,三枚键就是 42,而 900 宽下左列右沿就撞上进度条滑块了。 + // 🔴 **图形盒子是 18 × 18**:两层(底色 + 拇指)挂在它上面,角标挂在 + // 它的右上角——三样都以**同一个盒子**为锚。 // - // ⚠️ **那一圈底色还要以图形为中心**:绝对定位是相对**父元素的 padding - // box**,所以那一圈得挂在**一个正好 18 宽的盒子**上——那个盒子就是图形 - // 自己的锚(`iconMd` 见方)。 - ui.Row(c).Gap(playbarCountGap).AlignItems(ui.Center).Children(func() { - ui.Box(c).Size(iconMd, iconMd).Children(func() { - drawIcon(c) - }) - // 🔴 **数字那一格**(票 #701):获赞数。 + // ⚠️ **盒子本身在流里**(绝对定位的孩子不占宽):按钮的宽还是 + // 18 + 7 + 7 = 32,和没有数字时**一个字节都不差**。 + ui.Box(c).Size(iconMd, iconMd).Children(func() { + drawIcon(c) + // 🔴 **角标**(票 #701 起、本票起收成右上角):获赞数。 // - // ⚠️ **走共享的那一份**(`countText`):宽度上限 / 省略号 / 字号 / 颜色 - // **只有一处**——赞键自己画图形那一块(那两层是绝对定位的,有自己的锚 - // 结构),但数字那一格不许另写一份(审查抓到过:另外两枚有上限,它 - // 没有——于是换字体时它会突破上限,而另外两枚不会)。 - countValueText(c, count) + // ⚠️ **走共享的那一份**(`countBadge`):挂点 / 字号 / 上限 / 颜色 + // **只有一处**——赞键自己拼图形盒子(那两层是绝对定位的,有自己的锚 + // 结构),但角标不许另写一份。 + if hasCount { + countBadge(c, count) + } }) }) return b diff --git a/internal/ui/partdrawer_test.go b/internal/ui/partdrawer_test.go index e79beb23..080358db 100644 --- a/internal/ui/partdrawer_test.go +++ b/internal/ui/partdrawer_test.go @@ -426,14 +426,14 @@ const ( // 🔴 **它从 128 降到 40 是票 #219 逼出来的**(改之前是**八个字**):三枚键 // 从歌名下面那一行搬到了**封面右边第三段**(上游 `playbar/left/index.tsx:85` // 的 `flex items-center`——横着并排),于是它们吃的是**歌名那一列**的宽度: - // 左边那一列是 307(`playbarLeftMinW`,票 #682 之后;「评论」那一枚带数字时 - // 的账),而现在分给歌名那一列的只有 **76**(307 − 封面 52 − 两道 8 的缝 − - // 那一排键 163),歌名自己还要让出音质徽章(28 + 一道 6 的缝)——**42** - // (两个多字)。 + // 左边那一列是 281(`playbarLeftMinW`,本票角标之后;评论数收成挂在图形 + // 右上角外侧的角标、不占布局),而现在分给歌名那一列的只有 **76** + //(281 − 封面 52 − 两道 8 的缝 − 那一排键 137),歌名自己还要让出音质 + // 徽章(28 + 一道 6 的缝)——**42**(两个多字)。 // - // ⚠️ **评论数还没回来时那一列是 111**(评论键 32,比满宽少 35;保底宽照样 - // 是 307,多出来的 35 归文字列)——歌名那一档更宽,所以这个下限(40)在 - // **摆满**那一档上仍然量得出来。 + // ⚠️ **本票之后那一列不再分两档**(票 #682 时「评论数还没回来 → 111」那 + // 一档没有了):角标不占布局,那一排键带不带数字都是 137(四枚 32 + 三道 + // 3 的键间缝)——所以这个下限(40)在**哪一档**上都量得出来。 // // ⚠️ **上游那边更狠**:它那一列里歌名右边也横着三枚键,而 1 份在 1200 宽 // (上游自己的默认窗口)上只有 233.6,歌名那一列是 **65.6**——比我们的 76 diff --git a/internal/ui/playbar.go b/internal/ui/playbar.go index 7ea2f51f..79a93343 100644 --- a/internal/ui/playbar.go +++ b/internal/ui/playbar.go @@ -103,7 +103,7 @@ const ( // 的;760 是测试里的窄窗口,这一档是给它的。 artistMinW = 60 - // playbarActionsW 是第三段那几枚键在**摆满**时有多宽:**165.5**。 + // playbarActionsW 是第三段那几枚键在**摆满**时有多宽:**128**。 // // 每一枚的盒子恒是 32(上游 HeroUI 的 `isIconOnly` 是 `w-[2rem] h-[2rem]`), // 图形是 18(上游播放条上全部图标键都是 18,票 #219 / 报告 §3.6)。 @@ -114,19 +114,28 @@ const ( // 而保底宽钉的是**最大**那一档,于是条件摆的那一枚只让**歌名那一列** // 多出它自己那一格(理由同「有没有分集」那一条)。 // - // 🔴 **票 #701 之后**:分集那一一枚**不带数字**(32),而**收藏 / 赞 / 评论** - // 三枚**各带一个**(票 #701 的主体)。带数字的那一枚是「图形 18 │ 缝 2 │ - // 数字」——而**图形紧贴左沿**(左右内边距 0,理由见 `playbar_counts.go` - // 的文件头),所以它的宽 = 18 + 2 + 数字格(**上限** `playbarCountMaxW` - // = 24.5)= **44.5**。 + // 🔴 **本票(角标)之后**:收藏 / 赞 / 评论三枚的数字**不占布局**——它是 + // 挂在图形盒子右上角外侧的一枚角标(`Attach`,见 `playbar_counts.go`)。所以 + // 每一枚键回到 32,**带不带数字都一样**: // - // 32(分集)+ 3 × 44.5 = **165.5** + // 32(分集)+ 3 × 32(带角标的三枚)+ 3 道键间缝 × 3 = **137** // - // ⚠️ **这个数按「上限」算,不按「字宽」算**:数字格的宽是**上限** - // (`playbarCountMaxW`),而字宽(实测那一串字本身有多宽)**随平台字体变**—— - // 按字宽算的话保底宽就不是常数了(见 `playbar_counts.go` 的文件头《两个 - // 口径》)。 - playbarActionsW = iconHitMd + 3*(iconMd+playbarCountGap+playbarCountMaxW) + // ⚠️ **那 9 的缝**:角标探出图形右缘之后叠在右侧的间距上——没有缝,三枚 + // 带数的键视觉上糊成一串(见 `viewPlayBarActions` 那一行)。 + // + // ⚠️ **票 #701 横排那一版是 165.5**(分集 32 + 三枚 44.5):那时的数字占 + // 一格(「图形 18 │ 缝 2 │ 数字」),带数字的键因此宽 44.5。本票把数字 + // 收成角标之后,那 12.5 一枚 × 3 − 9 的缝 = **28.5 让给了进度条**(见 + // `playbarLeftMinW` 的宽度账)。 + playbarActionsW = 4*iconHitMd + 3*playbarActionsGap + + // playbarActionsGap 是第三段那几枚键**两两之间**的缝(3)。 + // + // ⚠️ **它是给角标留的**:角标(数字)探出图形右缘之后向右伸,叠的正是这 + // 道缝——没有它,这一枚的角标就贴在下一枚的图形上,三枚带数的键视觉上 + // 糊成一串。3 是「看得出是两枚键」的最小那一档(视觉审查实测对照网易云: + // 两枚键之间留得出可辨的缝,又不把角标省下的宽还回去)。 + playbarActionsGap = space.Px3 // trackTextMinW 是**文字列**那一段(歌名 + UP 名)最少有多宽。 // @@ -145,9 +154,9 @@ const ( // (`artistMinW`),歌名要多少是另一件事。 trackTextMinW = 76 - // playbarLeftMinW 是左边那一列的最小宽度:**309.5**。 + // playbarLeftMinW 是左边那一列的最小宽度:**281**。 // - // 封面 52 │ 缝 8 │ 文字列 76 │ 缝 8 │ 分集 32 + 三枚带数字的键 3 × 44.5 + // 封面 52 │ 缝 8 │ 文字列 76 │ 缝 8 │ 四枚键 4 × 32 + 三道键间缝 3 × 3 // // 🔴 **它钉的不是「好看」,是「多一 P 那一档和单 P 那一档一样宽」**。 // @@ -156,39 +165,28 @@ const ( // 宽 32,于是**左列宽 32、歌名窄 32**。而歌名窄了是**界面上看不出坏了**的 // 那种(字还在那儿,只是截断得更早)。 // - // 钉住之后,多 P 那一档左列还是 309.5、单 P 那一档也是 309.5(单 P 时第三段 - // 只有三枚键,那 32 就让给了**文字列**那一段)。 - // - // ⚠️ **票 #573 之后它是 272**(52 + 8 + 76 + 8 + 四枚键 128),**票 #682 - // 之后是 307**(评论那一枚从 32 长到 67——它要显示评论数),**票 #701 之后是 - // 309.5**(三枚键各带一个数,评论那一枚从 67 缩回 44.5——图形紧贴左沿,而 - // 收藏和赞也各长出一格)。 + // 钉住之后,多 P 那一档左列还是 281、单 P 那一档也是 281(单 P 时第三段 + // 只有三枚键,那 35 就让给了**文字列**那一段——32 的键 + 一道 3 的缝)。 // - // 🔴 **保底那一档上歌名是 101.9**(票 #682 时是 76):多出来的那一截归**歌名 - // 那一列**——因为这一列在 900 / 1000 / 1200 三档上都由**保底宽**说话(轨道 - // 1 份要到窗口 ≥ 1540 才开始说话,而那时候保底宽已经远小于轨道了)。 + // ⚠️ **票 #573 之后它是 272**(四枚纯图标键),**票 #682 之后是 307** + // (评论那一枚从 32 长到 67),**票 #701 之后是 309.5**(三枚键各带一个数、 + // 数字横排占一格),**本票(角标)之后是 281**:数字收成挂在图形右上角 + // 外侧的角标、不占布局,三枚键各窄 12.5;再扣掉键间加回来的三道 3 的缝 + // (角标探出之后叠在缝上,没有缝三枚带数的键糊成一串)——净让出 + // **28.5 给了进度条**。 // - // ⚠️ **1600 那一档是 106.0**:那一档上左边那列由**轨道 1 份**说话 - // (313.6 > 保底 309.5),所以文字列吃的是余量而不是保底。 + // 🔴 **保底那一档上歌名回到 76**(票 #701 时是 101.9):角标不吃左列的宽, + // 文字列回到保底 76;1600 那一档由轨道说话(轨道 1 份 313.6 > 保底 281)。 // // (量出来的见 `commentcount_test.go` 的 // `TestTheCountDoesNotSqueezeTheTitleOrTheSlider` 与 // `TestTheWidthCommentsMatchTheCode`。) // - // 🔴 **900 宽下的余量是 5.20**(票 #701 量出来的):左列右端 **325.50**, - // 而滑块左沿 **330.70**。 + // 🔴 **900 宽下的余量是 33.7**(本票量出来的):左列右端 **297.0**, + // 而滑块左沿 **330.70**——角标从横排收成右上角之后,左列净缩回 28.5。 // - // ⚠️ **那 5.20 是「视觉间距」,不是碰撞缓冲**(理由见 `playbar_counts.go` - // 的文件头《两个口径》):`MaxWidth` 在**布局层**硬夹住(实测字宽 90.71 时 - // 那一格仍恒是 24.50),所以**左列的宽是常数** 309.5——**碰撞不可能发生**。 - // 那 5.20 是**留给人眼**的。 - // - // ⇒ 所以**保底宽按数字格的「上限」算**(`playbarCountMaxW` = 24.5),而 - // **不是**按某一天的字宽(字宽随平台字体变,而保底宽**必须是常数**——理由见 - // `playbar_counts.go` 的文件头《两个口径》)。 - // - // ⚠️ **5.20 是「装得下最宽那一档」换来的**:上限从 24 提到 24.5(为了装下 - // 「99k+」24.49,而截断会**先切掉那个「+」**)⇒ 余量从 6.70 少到 5.20。 + // ⚠️ **那 33.7 是「视觉间距」,不是碰撞缓冲**:角标是**绝对定位**的 + // (`Attach`),不占布局——所以**左列的宽是常数** 281,碰撞**不可能发生**。 // // (量出来的见 `playbar_counts_test.go` 的 // `TestTheThreeCountsDoNotTouchTheProgressBar`。) @@ -223,9 +221,9 @@ const ( // 一样): // // 窗口 播放条可用 轨道 1/3/1(左边/中间/右边) 中列内容区 进度行 滑块 左边那一列 -// 900 868 173.6 / 520.8 / 173.6 472.8 354.6 238.6 309.5(撑出 135.9) -// 1000 968 193.6 / 580.8 / 193.6 532.8 399.6 283.6 309.5(撑出 115.9) -// 1200 1168 233.6 / 700.8 / 233.6 652.8 489.6 373.6 309.5(撑出 75.9) +// 900 868 173.6 / 520.8 / 173.6 472.8 354.6 238.6 281(撑出 107.4) +// 1000 968 193.6 / 580.8 / 193.6 532.8 399.6 283.6 281(撑出 87.4) +// 1200 1168 233.6 / 700.8 / 233.6 652.8 489.6 373.6 281(撑出 47.4) // 1600 1568 313.6 / 940.8 / 313.6 892.8 669.6 553.6 313.6 // // (可用 = 窗口 - 左右各 16 的内边距;**三列按 1:3:1 分**,这是上游那张 @@ -234,7 +232,7 @@ const ( // 内边距(上游 center 的 `px-6`);进度行 = 内容区的 **3/4**(上游的 `w-3/4`); // 滑块 = 进度行 - 两个 48 的读数 - 两道 10 的缝。) // -// ⚠️ **「左边那一列」那一栏是 `max(轨道 1 份, 保底宽 309.5)`**:窗口够宽 +// ⚠️ **「左边那一列」那一栏是 `max(轨道 1 份, 保底宽 281)`**:窗口够宽 // (≥ 1540)时是轨道说话,更窄的时候是**保底宽**说话——那一列于是 // **撑出轨道**,伸进中间那一列(上游也是这么溢的:它那一列的内容最少 184, // 而 1 份在 900 宽下只有 173.6)。 @@ -243,11 +241,11 @@ const ( // 它在中间那一列里居中,而**左右两列等宽**(1:3:1 的头一份和尾一份一样) // ——那才是那一列对称于窗口的原因。 // -// ⚠️ **左边那一列的保底宽是 309.5,不是轨道宽**(票 #701 之后):它的内容最少 -// 要 309.5(封面 52 + 缝 8 + 文字列 76 + 缝 8 + **分集 32 + 三枚带数字的键 -// 3 × 44.5**,见 `playbarLeftMinW`)。 +// ⚠️ **左边那一列的保底宽是 281,不是轨道宽**(本票角标之后):它的内容最少 +// 要 281(封面 52 + 缝 8 + 文字列 76 + 缝 8 + **分集 32 + 三枚带角标的键 +// 3 × 32 + 三道键间缝 3 × 3**,见 `playbarLeftMinW`)。 // -// ⚠️ **「撑出来的那一截」离滑块还有 5.20**(900 宽下实测:左列右端 325.50, +// ⚠️ **「撑出来的那一截」离滑块还有 33.7**(900 宽下实测:左列右端 297.0, // 滑块左沿 330.70)——**进度行照样碰不到它**。而那一枚「评论」键的**图形** // (18 × 18)在 y=28..46,而读数那一行在 y=48.8..65.2——**上下也不碰** // (量出来的,不是算出来的:见 `comments_test.go` 的 @@ -271,8 +269,9 @@ const ( // 只改右边那一组的宽,**不再改中间那一列**。 // // 🔴 **左边那一块里面怎么分(票 #219 之后)**:左边那一列的**宽一个像素都 -// 没变**(票 #219 之后是 240,票 #573 之后是 272,票 #701 之后是 309.5),变的 -// 是它里面那三段: +// 没变**(票 #219 之后是 240,票 #573 之后是 272,票 #701 之后是 309.5, +// 本票角标之后是 **281**——`playbarLeftMinW` 那一段),变的是它里面那 +// 三段: // // 封面 52 │ 缝 8 │ 文字列(歌名 + UP 名)│ 缝 8 │ 那一排键 // @@ -284,20 +283,17 @@ const ( // 键),改之后是 76——**键从歌名下面那一行搬到了同一段**(上游就是这 // 么摆的,报告 §2.8)。900~1200 宽上歌名从 146(九个多字)掉到 **43.8** // (两个多字,还要让出音质徽章和一道 6 的缝);1280 上也是 43.8(那一列 -// 由保底宽说话,见上面那张宽度账);1600 上是 85.4。 +// 由保底宽说话,见上面那张宽度账);1600 上是 108.6(轨道说话:左列 +// 313.6 里文字列分到 108.6,歌名再扣掉音质徽章和一道 6 的缝)。 // // ⚠️ **票 #573 之后这些数没有变**:保底宽涨的那 32 **正好是「评论」那一枚 // 自己的盒子**——所以**文字列那一段的宽原封不动**(量出来的,见下面那张 // 「让位」表)。 // -// 🔴 **票 #701 之后分两档**(三枚键都带数字 / 都不带): -// -// · **数字画出来了**(那一格吃满上限 24):保底宽 272 → **308**,文字列 -// 仍然是 **76** ⇒ 上面那些歌名的宽(43.8 / 1600 的 85.4)**原封不动**—— -// 多出来的 36 全在那三枚键自己身上。 -// · **还没有数字**(离线 / 那一趟没回来 / 音频):那三枚键仍是 32,而保底宽 -// 钉着 308 ⇒ 多出来的 36 **落进文字列**(76 → **112**),歌名跟着宽 -// 36。下面那张「让位」表量的就是这一档。 +// 🔴 **本票(角标)之后不分档了**:三枚键的数字是挂在图形盒子右上角 +// 外侧的角标,`Attach` **不占布局**——所以**带不带数字,左列都是 281、 +// 文字列都保底 76**。票 #701 那两档(带数 308 / 不带数 272 + 歌名 112) +// 整个没有了。 // // **和上游比的是「歌名的宽」,不是「那一段的宽」**:上游那一段(1200 宽下 // 65.6)里**也有 chip**——它的「无损」/「杜比」是 10 号字(≈ 20 宽), @@ -338,7 +334,7 @@ func (s *Shell) viewPlayBar(c *ui.Context, st app.State) { // // ⚠️ **这里为什么是 Grid 而不是 Row**:Row 里三块各 `Grow(1)` 分的是 // 「剩下的空间」,而**剩下多少取决于左右两块自己的内容有多宽**——左边 - // 那一块(封面 + 歌名 + UP 名 + 那一排键)最少要 309.5,右边那一组是 + // 那一块(封面 + 歌名 + UP 名 + 那一排键)最少要 281,右边那一组是 // 定宽的 243.2,于是中间那列分到的永远不是三份里的一份。Grid 的 // `Fr` 轨道不看内容(见上面那段),这才是上游那个比例。 ui.Grid(c).Fill().ColumnTracks(ui.Fr(1), ui.Fr(3), ui.Fr(1)). @@ -738,9 +734,9 @@ func (s *Shell) viewTrackStopped(c *ui.Context, st app.State, t *ui.Theme) { // (HeroUI 的 `isIconOnly` 是 `w-[2rem] h-[2rem]`),图形 18 → 每边 7 的留白, // 于是两枚图形之间天然隔着 14。 // -// ⚠️ **这一段会随「有没有分集」而宽窄**(票 #682 之后按**摆满**那一档算: -// 多 P 163 / 单 P 131;评论键还**没有数字**时各少 35——数字那一格没画,缝也不 -// 占)。分集那一枚是逐帧问出来的(`viewPartButton`),单 P 的稿子不摆。于是 +// ⚠️ **这一段会随「有没有分集」而宽窄**(本票角标之后按**摆满**那一档算: +// 多 P 128 / 单 P 96——**带不带数字都一样**,数字不占布局)。分集那一枚是 +// 逐帧问出来的(`viewPartButton`),单 P 的稿子不摆。于是 // **文字列**那一段(歌名 + UP 名)跟着宽窄 32——**这一条是上游的账**(上游那 // 一段也是 `flex`,宽度跟着内容走)。左列的**总宽**被 `playbarLeftMinW` 钉住了, // 所以变的是歌名的宽度,不是左列的宽度(见 `viewPlayBar` 那张宽度账)。 @@ -758,9 +754,8 @@ func (s *Shell) viewTrackStopped(c *ui.Context, st app.State, t *ui.Theme) { // 1600 117.4 → 85.4 149.6 → 117.6 32 553.6 32 / 32 / 32 // // (量的是 `partdrawer_test.go` 那个 `playbarWidths`:**在线那一首**(于是 -// 「评论」那一枚也在),900~1280 那一列由**保底宽 309.5** 说话,1600 那一列 -// 由**轨道 1 份**说话。票 #682 之后 900~1280 那三档比 #573 时宽 35——保底 -// 涨了 35(评论键的形状),而这一族里的评论键**没有数字**、第三段没跟着涨。) +// 「评论」那一枚也在),900~1280 那一列由**保底宽 281** 说话,1600 那一列 +// 由**轨道 1 份**说话。) // // **让位的是 32 —— 就是这个键自己那一格**(`iconHitMd`),而**进度条和右边 // 那一组一个像素都不让**(它们压根不在左列里:右边那一组是绝对定位贴右沿 @@ -772,11 +767,11 @@ func (s *Shell) viewTrackStopped(c *ui.Context, st app.State, t *ui.Theme) { // **左边那一列里的 32**,歌名和 UP 名**一起**让(它们在同一个 `Grow(1)` 的 // 段里)。 // -// ⚠️ **左列的**总宽**是 309.5**(`playbarLeftMinW` 钉着的;票 #573 时是 272, -// 票 #701 之后是 309.5,而 #573 之前是 240——那一段注释写的是 244,那是封面还是 -// 56 的时候的老数)。窄窗口下它撑出轨道的那一截因此长了 32——**而进度行照样 -// 碰不到它**(900 宽下实测:左列右端 323,滑块左沿 330.7;那枚键的图形在 -// y=28..46,而读数在 y=48.8..65.2)。 +// ⚠️ **左列的**总宽**是 281**(`playbarLeftMinW` 钉着的;票 #573 时是 272, +// 票 #701 横排那一版涨到 309.5,本票角标之后是 281——四枚 32 + 三道 3 的 +// 键间缝,而 #573 之前是 240——那一段注释写的是 244,那是封面还是 56 的时候 +// 的老数)。窄窗口下它撑出轨道的那一截因此长了 32——**而进度行照样碰不到它** +// (900 宽下实测:左列右端 297,滑块左沿 330.7)。 // // 没有歌时这一段一格都不画:给一个空的播放条摆几枚点不动的键,比不摆更让人 // 以为它们坏了。 @@ -791,7 +786,11 @@ func (s *Shell) viewTrackActions(c *ui.Context, st app.State) { s.partOpen = false return } - ui.Row(c).AlignItems(ui.Center).Children(func() { + // ⚠️ **键与键之间留 3 的缝**:角标(数字)探出图形右缘之后叠在右侧的 + // 间距上——没有这道缝,三枚带数的键视觉上糊成一串(视觉审查实测: + // 「星2k+ 拇9k+ 泡1k+」看着像一整条字)。3 是「看得出是两枚键」的最小 + // 那一档,又不把角标省下的宽度还回去。 + ui.Row(c).Gap(playbarActionsGap).AlignItems(ui.Center).Children(func() { s.viewPartButton(c, st) s.viewFavButton(c, st) s.viewLikeStar(c, st) @@ -929,17 +928,19 @@ func (s *Shell) viewFavButton(c *ui.Context, st app.State) { count := s.app.ArchiveCounts().Favorite // ⚠️ **壳走 `flat.Button`,内容自己拼**:`iconKey` 那一档只会画一个图标, - // 而这一枚要有「图形 + 数字」两半。壳、悬停淡底、圆角都和 `iconKey` 同一份 - // (`flat.button`),只是内边距和缝按「一格里的两半」摆。 + // 而这一枚要的是「图形盒子 + 右上角一枚角标」——壳、悬停淡底、圆角都和 + // `iconKey` 同一份(`flat.button`)。 // // ⚠️ **不是 `flat.ButtonContent`**:那一档在**建的时候**就跑 `Children`,而 // 「悬停 / 按下」要问**按钮自己**(`btn.Hovered()`)——按钮那时候还不存在。 // `flat.Button` + `Children` 是同一个壳,而 `Children` 是在按钮建好之后才跑的。 + // + // 🔴 **内边距回到纯图标键那一档(每边 7)**:角标不占布局,所以这一枚的宽 + // 和没有数字时**一模一样**——32。 btn := flat.Button(c, "").Label(countKeyLabel("收藏", count)). - Height(iconHitMd).Padding(space.None, countKeyPadX(count)).Gap(playbarCountGap) + Height(iconHitMd).Padding(space.None, space.Px7) btn.Children(func() { - countKeyRow(c, favIcon(faved), count, btn.Hovered(), btn.Pressed(), - func(c *ui.Context) { countIcon(c, favIcon(faved), btn.Hovered(), btn.Pressed()) }) + countKeyIcon(c, favIcon(faved), btn.Hovered(), btn.Pressed(), count) }) if faved { // ⚠️ **只在这一支设颜色**:没收藏的那一支一个字都不改,好让「默认那个颜色」 @@ -1017,16 +1018,16 @@ func (s *Shell) viewQualityBadge(c *ui.Context, st app.State, t *ui.Theme) { } // 🔴 **显示的是**短版**(`bili.ChipLabel`),全称留在悬停提示里**(票 #219)。 // - // 这一列(歌名 + UP 名)**摆满时只有 76 宽**(左边那一列的保底宽 309.5 减去 - // 封面 52、两道 8 的缝和那一排键 163),而全称(「无损 FLAC」52.7、「杜比 + // 这一列(歌名 + UP 名)**摆满时只有 76 宽**(左边那一列的保底宽 281 减去 + // 封面 52、两道 8 的缝和那一排键 137),而全称(「无损 FLAC」52.7、「杜比 // 全景声」54.7、「Hi-Res 无损」60.6)**和歌名挤在一行放不下**:框架在装不下 // 时按**基宽的比例**分摊那份 shortfall,于是**徽章自己被摊扁**——实测「杜比 // 全景声」只剩 13.8(读出来**一个字**),而它同时是「换音质」的入口,**点击 // 区只剩 14 像素**。 // - // ⚠️ **评论键还没有数字时这一列是 111**(那一排键只有三枚 96 + 评论键 32 = - // 128;保底宽仍钉着 309.5,多出来的 37.5 归文字列)——数字回来之后那一格才被 - // 填上。上面那几句量的是**摆满**那一档(有数字)。 + // ⚠️ **本票(角标)之后这一列不再分两档**:数字不占布局——那一排键**带不带 + // 数字都是 137**(四枚 32 + 三道 3 的缝),所以这一列**恒是 76**(票 #701 时 + // 「评论键还没有数字 → 111」那一档整个没有了)。 // // 短版(无损 / 杜比 / 192K)就是**上游那一枚 chip 的形状** // (`playbar/left/index.tsx:50-56` 的「无损」/「杜比」),而全称在提示里 diff --git a/internal/ui/playbar_counts.go b/internal/ui/playbar_counts.go index 388e5de7..52396298 100644 --- a/internal/ui/playbar_counts.go +++ b/internal/ui/playbar_counts.go @@ -1,45 +1,60 @@ -// 播放条左边第三段那三枚键(赞 / 收藏 / 评论)各自的**读数**(票 #701)。 +// 播放条左边第三段那三枚键(赞 / 收藏 / 评论)各自的**读数**(票 #701 起带数, +// 本票起收成**角标**)。 // // # 这一族和「播放量那一列」是两件事 // // 播放量那一列折成「1.2万」(`list.PlayCountText`)——它地方宽,要的是「约数」。 -// 这一族折成「1k+ / 10k+ / 99k+ / 1亿+」(`list.CountText`)——它**地方一点也不宽**: -// 900 宽的窗口上左边那一列离进度条滑块只剩 7.7 像素(票 #682 量出来的), -// 而三枚键都要带着各自的数。 +// 这一族折成「1k+ / 10k+ / 99k+ / 1亿+」(`list.CountText`)——它挂在图标的 +// 右上角当**角标**,地方一点也不宽。 // -// 🔴 **宽度恒定是功能正确性,不是排版偏好**(票 #216 那个坑):中间那一列是 -// `PassThrough` 的(它自己那一个框不接指针),但它的**子节点**照旧登记命中, -// 于是左列撑出轨道的那一截上的键会被进度行那一块的命中框盖住——**点不动, -// 而画面上看不出坏了**。 +// # 本票的形状:**角标,不是横排** // -// # 三枚键的**共性**(所以这里有一份共享的配方) +// 改之前是「图形 18 │ 缝 2 │ 数字」横排(`countKeyRow`):数字占一格,带数字的 +// 那一枚键因此宽 44.5——900 宽的窗口上左列右端离进度条滑块只剩 5.20 像素 +// (票 #682 / #701 量出来的)。那是「地方不够」被逼出来的形状。 +// +// 现在改成**网易云式**:数字折成一个小字角标,**左缘贴图形右缘、上缘贴图形 +// 上沿**——`Attach(AnchorTopRight, AnchorTopLeft)` 把角标的左上角摆在图形盒子的 +// 右上角(数字整个在图形右上**外侧**,不压住图标本体、也不往角里收)。角标是 +// **绝对定位**的,不占布局—— +// 于是那一枚键的宽回到 **32**(图形 18 + 两侧各 7),和没有数字时一模一样。 +// +// ⇒ 三枚键各窄 12.5,第三段从 165.5 缩回 **137**(四枚 32 + 三道 3 的键间缝) +// ——**净让 28.5 像素给了进度条**(`playbarLeftMinW` 的宽度账见 `playbar.go`)。 // -// 图形 18(`iconMd`)│ 缝 2 │ 数字 10 号字(`scale.Size10`) +// # 🔴 角标不占布局,但它照样登记命中 // -// 数字那一格的**折法**是 `list.CountText`(那一族的折法只有这一处),而 -// **约束**(字号 / 颜色 / 宽度上限 / 省略号)在 `countText` 这一个函数里。 +// 框架的绝对定位元素绘制时排在普通子元素**之后**(`mygo/ui/paint.go` 的两趟 +// 循环)——所以角标画在图形**上面**。而它**照旧登记命中**(`mygo/ui/runtime.go`) +// ——于是角标那一小块底下,按钮收不到指针事件。 // -// ⚠️ **字号是 `Size10`**:这是播放条上最小的一档(全应用没有更小的正文), -// 而数字是**附属信息**(图形才是那一枚键的身份)。 +// ⇒ 所以角标必须 **`PassThrough()`**:它不读屏、不点、不吃悬停——它是「粘在图形 +// 右上角的一串字」,不是一枚键。`nav-feed` 那个红点(`navbar_feed.go` 的 +// `viewDynamicDotMark`)是同一个形状、同一个理由。 +// +// # 三枚键的**共性**(所以这里有一份共享的配方) +// +// 图形 18(`iconMd`)在一个 18×18 的盒子里,角标挂在这个盒子的右上角 +// +// 角标折出来的字是 `list.CountText`(那一族的折法只有这一处),而**约束** +// (字号 / 颜色 / 宽度上限 / 省略号 / PassThrough / 挂点)在 `countBadge` 这一 +// 个函数里——**三枚键共用**(赞键的图形那一块是绝对定位的两层,它自己拼盒子, +// 但**角标走同一份**)。 // // # 🔴 两个口径(别把它们混起来) // // **字宽** 那一串字本身有多宽(`ui.Painter.MeasureText` 量得)——它**随平台 // 字体变**,所以不是个能拿来钉死的数。 -// **上限** 数字那一格**最多占多宽**(`playbarCountMaxW` = 24.5)——它是**布局** +// **上限** 角标那一格**最多占多宽**(`playbarBadgeMaxW` = 22.5)——它是**布局** // 的数,而布局的数**必须是常数**。 // -// ⇒ **宽度账按「上限」算,不按「字宽」算**:左列的保底宽(`playbarLeftMinW`) -// 是一个**常数**,而字宽不是——按字宽算的话,换一个字体保底宽就变了, -// 「多一 P 那一档和单 P 那一档一样宽」那条就破了。 +// ⇒ 上限是 **22.5**(量出来的:9 号字下折法最宽的那一档「99k+」≈22.0,刚好 +// 装下——理由见 `playbarBadgeMaxW` 的注释),而**「+」不许被截掉**那条铁律 +// 原样保留:截断切的是末尾,而「+」永远是最后一个字符。 // -// ⚠️ **900 宽下的余量是 5.20**(实测,左列右沿 325.5 / 滑块左沿 330.7)—— -// **按上限算**。而按**字宽**算(某一档的字实测有多宽)会是另一个数——那个数 -// 只对**这一个平台的这一个字体**成立。 -// -// 🔴 **而那 5.20 是「视觉间距」,不是碰撞缓冲**:`MaxWidth` 在**布局层**硬夹住 -// (实测字宽 90.71 时那一格仍恒是 24.50),所以**左列的宽是常数、碰撞不可能 -// 发生**。那 5.20 是**留给人眼**的。 +// ⚠️ **上限管的是「角标自己的那一格」**,不是「它伸出去多远」:角标挂在图形 +// 右上角,宽出来的那一截伸到图形**右上方的空气里**(按钮上面、右边那枚键的 +// 左上),那是视觉余量,**不进任何一列的宽度账**——所以 22.5 不再挤左列。 package ui import ( @@ -47,30 +62,20 @@ import ( "github.com/ShellMonster/PudMusic/internal/ui/widgets/flat" "github.com/ShellMonster/PudMusic/internal/ui/widgets/list" - "github.com/ShellMonster/PudMusic/internal/ui/widgets/scale" - "github.com/ShellMonster/PudMusic/internal/ui/widgets/space" "github.com/egoist/mygo/ui" ) // 这一族的几个尺寸。 // -// 🔴 **数字格和图形之间的缝是 2**(`space.Px2`):它是**一格里的两半**,不是 -// 两枚键之间的缝(那一族键与键之间没有缝——上游那一段没有 `space-x`,而每一枚 -// 的盒子恒是 32、图形 18,于是两枚图形之间天然隔着 14)。 -// -// 🔴 **带数字的那一枚键,左右内边距是 0**(票 #701 的宽度账,见 `playbar.go` 的 -// `playbarLeftMinW`):左边那一列在 900 宽下**撑出轨道**,而它右端离进度条 -// 滑块只剩 7.7 像素(票 #682 量出来的)——三枚键都带数字的话,那 7.7 像素 -// **不够三枚键各留 7 的白**。所以带数字的那一枚**不留白**:图形贴着盒子的左沿, -// 数字贴着右沿。 +// 🔴 **角标不占布局**:它 `Attach` 在图形盒子的右上角(`AnchorTopRight`), +// 是**绝对定位**的——所以「带不带数字」对那一枚键的宽**一个字都不差**:都是 +// 32(图形 18 + 两侧各 7 的内边距)。 // -// ⚠️ **纯图标键(分集、还没回来的赞 / 收藏 / 评论)照旧是每边 7**(`space.Px7`) -// ——那是图形在 32 的盒子里每边留 7 的那一份(`TestTheHitAreaIsBiggerThanTheGlyph` -// 断的就是它)。**带数字的那一枚是另一个形状**,它有自己的那一份。 +// ⚠️ **图形盒子是 18×18**,不是按钮的 32×32:角标挂在**图形**上(那是它读的 +// 数的那一枚的身份),而不是按钮盒的角上——挂在按钮角上会跑出图形之外一整圈 +// (7 的内边距),角标就悬在按钮的右上角、盖住悬停淡底,而不是「图形右上」。 const ( - // playbarCountGap 是图形和数字之间的缝(2)。 - playbarCountGap = space.Px2 - // playbarCountFont 是数字那一格的字号(**固定 10,不乘系统文本缩放**)。 + // playbarBadgeFont 是角标的字号(**固定 9,不乘系统文本缩放**)。 // // 🔴 **为什么这一格不跟 `scale.Font` 走**(而界面别处的字都跟): // @@ -82,22 +87,26 @@ const ( // scale 1.40 99k 墨迹 19 | 99k+ 墨迹 19 ❌ 「+」被切 // scale 2.00 99k 墨迹 13 | 99k+ 墨迹 13 ❌ 「+」被切 // - // ⇒ 而**分界线比 1.25 更早**:按字宽算,scale 1.15 时 `99k+` 已经是 27.8 - // (超上限 24.5),`10k+` 25.7、`1亿+` 26.9 也都超了——而 **125% / 150% 是 - // Windows 上的常见设置**。 + // ⇒ 而**分界线比 1.25 更早**(那张表是 10 号字那一版量的):按字宽算, + // scale 1.15 时 `99k+` 已经是 27.8(超当时的上限),`10k+` 25.7、`1亿+` 26.9 + // 也都超了——而 **125% / 150% 是 Windows 上的常见设置**。9 号字上限更宽、 + // 字更小,只会更装不下放大,结论不变。 // - // ⚠️ **上限跟着 scale 走这条路是死的**(算过):scale 1.25 要上限 30.62 ⇒ - // 左列右沿 343.88 ⇒ **余量 -13.18(撞)**。 + // ⇒ 所以**这一格的字号钉死,不跟 scale 走**:字宽**恒定** ⇒ 任何 scale + // 下都装得下。 // - // ⇒ 所以**这一格的字号钉死在 10**:字宽**恒定** ⇒ 任何 scale 下都装得下 - // (`99k+` 24.49 ≤ 24.5、`1亿+` 23.61、`10k+` 22.70、`999` 19.45)。 + // 🔴 **本票是 9,不是 10**:对照网易云实测(用户视觉审查),角标的字约是 + // 图标高的 1/3——18 的图形配 10pt 的数字太大、且和图标的视觉重量拉不开。 + // 收到 9(≈ 图标高的一半以下),数字才像「挂在图形上的小尾巴」而不是 + // 「和图形并排的另一件东西」;同时「99k+」从 24.49 收到 ≈22,键与键之间 + // 留得出可辨的缝(理由见 `playbarBadgeMaxW`)。 // - // # 🔴 **这个取舍的完整账**(票 #701 的审查) + // # 🔴 **这个取舍的完整账**(票 #701 的审查,本票照旧成立) // // ## 代价(明摆着) // - // **用户的系统文本放到 200% 时,这一格仍是 10 号,而旁边的歌名是 20 号** - // ——**数字会比歌名小一半**。 + // **用户的系统文本放到 200% 时,这一格仍是 9 号,而旁边的歌名是 20 号** + // ——**数字会比歌名小一半还多**。 // // ⇒ 那是**有意**的取舍,**不是遗漏**。 // @@ -106,76 +115,38 @@ const ( // 1. **「+」是语义**(「至少这么多」)——截掉它,「至少这么多」就变成 // 「正好这么多」= **撒谎**。 // 2. **数字是读数不是正文**(图形才是那一枚键的身份)——读数不跟正文 - // 缩放是**合理**的,不是将就。 + // 缩放是**合理**的,不是将就;角标比正文小正是它该有的形状。 // 3. **观感不一致是次要问题**,而且**只在放大到 115% 以上的用户那儿 // 出现**(Windows 上 125% / 150% 是常见设置,但 115% 以下没问题)。 // // ## 为什么没有第三条路 // - // **让这一格也放大、同时把上限也放大** ⇒ 算下来是**撞**: - // - // scale 1.25 上限要 30.62 左列右沿 343.88 滑块左沿 330.7 ⇒ **-13.18** - // - // ⇒ **撞上**的后果是那几枚键**点不动,而画面上看不出坏了**(票 #216 那个 - // 坑:中间那一列是 `PassThrough` 的,可它的子节点照旧登记命中,于是左列 - // 撑出来的那一截被进度行的命中框盖住)。 - // - // ⇒ **「数字比歌名小一半」比「那几枚键点不动」好得多**。 - // - // ## 为什么不用「让「+」被截掉」 - // - // 那是最省事的一条(上限收紧就行)——但截掉「+」,「至少这么多」就变成 - // 「正好这么多」= **撒谎**,而**撒谎是这一族最不该犯的错**(票面原话)。 + // **让这一格也放大、同时把上限也放大** ⇒ 角标是**绝对定位、不占布局**的, + // 所以「装不下」不再是「把左列撑宽」——而是「角标那一格本身被截断」,而 + // **截断先切掉那个「+」**。⇒ 「+」会没,这一族**不许**。 // // ⚠️ **别处的字照旧跟着 scale 走**:歌名、UP 名、别的读数——**只有这一格** // 不跟(理由见上)。 - playbarCountFont = scale.Size10 - // playbarCountMaxW 是数字那一格**最宽**多少(24.5)。 - // - // 🔴 **24.5 是量出来的上限,不是随手取的**:`list.CountText` 折出来的几档 - // 实测(10 号字,macOS,`ui.Painter.MeasureText` 量得): - // - // 「1k+」 16.28 「10k+」 22.70 - // 「9k+」 18.01 「99k+」 24.49 ← 最宽的那一档 - // 「999」 19.45 「1亿+」 23.61 - // - // ⇒ **24.5 是「刚好装下最宽那一档(99k+ 24.49)」的那个数**。 - // - // ⚠️ **当前折法一档都不截**:最宽的「99k+」24.49 ≤ 24.5——所以上限是 - // **保险**,不是「有一档会被切掉」。**「100…」那个形状在当前折法里压根 - // 不存在**(见 `widgets/list/count.go` 的折法表:封顶的那一档是「1亿+」 - // 23.61,而「100k+」那一档早就被「1亿+」替掉了)。 + playbarBadgeFont = 9 + // playbarBadgeMaxW 是角标那一格**最宽**多少(22.5)。 // - // 🔴 **上限不许低于 24.5**:**截断切的是末尾,而「+」永远是最后一个字符** - // ——所以「+」**总是第一个被切掉的东西**。实测: + // 🔴 **22.5 是量出来的上限,不是随手取的**:`list.CountText` 折出来的几档 + // 实测(9 号字,macOS,`ui.Painter.MeasureText` 量得,约为 10 号字的 0.9): // - // 「99k+」上限 24 → 墨迹 20.00(完整 24.49)⇒ 被截 - // 「99k+」上限 21 → 墨迹 14.00 —— 比不带「+」的「99k」(18.52)**还短** - // ⇒ 「+」被切掉了 + // 「9k+」 ≈16.2 「99k+」 ≈22.0 ← 最宽的那一档 + // 「10k+」 ≈20.4 「1亿+」 ≈21.2 // - // 而「+」是这一族的**语义**(「至少这么多」)——它被切掉就变成了「正好 99 - // 千」,那是**撒谎**。⇒ **最宽的那一档必须本身就装得下上限,一档都不许靠 - // 截断兜底**(理由见 `widgets/list/count.go` 的文件头)。 + // ⇒ **22.5 是「刚好装下最宽那一档(99k+ ≈22.0)再加一点抗锯齿余量」的数**。 // - // 🔴 **这个上限是布局层的「硬边界」**——不是「保险」。 + // 🔴 **上限不许低于它**:**截断切的是末尾,而「+」永远是最后一个字符**—— + // 「+」**总是第一个被切掉的东西**,而「+」是这一族的语义(「至少这么多」, + // 没了就是撒谎)。⇒ 最宽的那一档必须本身就装得下上限,一档都不许靠截断 + // 兜底(理由同 10 号字那一版的实测,见 `widgets/list/count.go` 的文件头)。 // - // 实测(同一格,字宽差着 3.5 倍): - // - // 「999+」 字宽 25.87 → 那一格布局宽 **24.50** - // 「1234567890+」 字宽 68.12 → 那一格布局宽 **24.50** - // 「9999999999999+」 字宽 90.71 → 那一格布局宽 **24.50** - // - // ⇒ **左列的宽是常数(309.5),无论字多宽都不会变**。 - // - // ⚠️ **所以「余量 5.20」是**视觉间距**,不是碰撞缓冲**:碰撞**不可能发生** - // (除非有人改了上限本身)。那一截余量是**留给人眼**的——它是左列右端到 - // 进度条滑块左沿那一段**看起来不挤**的距离。 - // - // ⇒ 而这个上限**挡的是**「字变宽把左列撑宽」——**而左列的宽本来就是常数** - // ⇒ 所以它挡的其实是「**数字本身被拉出那一格**」(那一格被截断、那个「+」 - // 没了)。**那才是这一格真正会坏的地方**——理由见 `playbarCountFont` - // 那个常量的注释(系统文本放大 125% 就会切掉那个「+」)。 - playbarCountMaxW = 24.5 + // ⚠️ **和改之前(横排那一版)的差别**:那时这个上限挡的是「字变宽把左列 + // 撑宽」——左列是常数,而角标**不占布局**,所以它现在挡的只有「角标那一格 + // 自己被截断」这一件事(结果一样:那个「+」不许没)。 + playbarBadgeMaxW = 22.5 ) // playbarCountText 是那个读数该显示的字。**空串 = 没有这个数**(不画)。 @@ -185,29 +156,14 @@ const ( // 「1.2万」,宽度差着一截。 func playbarCountText(n int64) string { return list.CountText(n) } -// countKeyPadX 是「图形 + 数字」那一枚键的**左右内边距**。 -// -// 🔴 **带数字时是 0**(理由见上面那段「带数字的那一枚键,左右内边距是 0」); -// **没有数字时是 7**(那时的形状和改之前**一模一样**:图形 18 + 两侧各 7 = 32)。 -// -// ⚠️ **它跟着数走,而数是每帧问出来的**:所以这一枚键的宽**会变**(32 → -// 「图形 + 数字」那一档)——而左列的**总宽**被 `playbarLeftMinW` 钉着,所以变 -// 的是**歌名那一列**的宽,不是左列的(理由见 `playbar.go` 的「让位」表)。 -func countKeyPadX(n int64) float32 { - if playbarCountText(n) == "" { - return space.Px7 - } - return space.None -} - -// countKeyLabel 是「图形 + 数字」那一枚键的**无障碍名字**。 +// countKeyLabel 是「图形 + 角标」那一枚键的**无障碍名字**。 // // 🔴 **数字不许只靠画面传达**(读屏软件看不见它):拿到数的时候名字里带上它。 // // ⚠️ **没有数字时名字就是那两个字**(一字不多):拿不到数(离线 / 还没回来 / // 本地文件)的时候画面上也没有数字——那时候多说一句「赞 0」是在撒谎。 // -// ⚠️ **它必须是「图形 + 数字」那一枚的名字,而不是那个数字自己**:这一枚键的 +// ⚠️ **它必须是「图形 + 角标」那一枚的名字,而不是那个数字自己**:这一枚键的 // **身份是图形**(图标对辅助技术不可见,`mygo/ui/svg.go`),而数字是读数。 func countKeyLabel(name string, n int64) string { if t := playbarCountText(n); t != "" { @@ -226,7 +182,7 @@ func countKeyLabel(name string, n int64) string { // // 🔴 **提示里给的是**完整数字**,不是画面上那个折过的字**(票 #701 的要求): // -// 画面上 「99k+」(最宽 24.49,而那一格的上限是 24.5) +// 画面上 「99k+」(9 号字最宽 ≈22.0,而角标那一格的上限是 22.5) // 提示里 「整个稿件 99999」 // // ⇒ 而「1亿+」那一档**更是必须这样**:那一档是**夸大的近似**(对 100000 那个数 @@ -236,7 +192,7 @@ func countKeyLabel(name string, n int64) string { // # 🔴 **为什么提示里给完整数字**(免得下一个人看到「超 80」就去砍数字) // // **理由一:那一处是用户唯一能看到精确值的地方。** 画面上那一格**必须**折 -// (它只有 24.5 宽,而完整数字放不下——**那正是它要折的原因**),而提示那一格 +// (它只有 22.5 宽,而完整数字放不下——**那正是它要折的原因**),而提示那一格 // **没有那个限制**。⇒ 精确值在那儿,用户要精确值的时候有地方拿。 // // **理由二:气泡换行不是破版。** 提示气泡的宽度上限是 `t.Space(80)` @@ -263,92 +219,98 @@ func countKeyTip(name string, n int64, tip string) string { return tip } -// countText 是「图形 + 数字」那一枚里的**数字那一格**(三枚键共用)。 +// countBadge 是挂在**图形盒子右上角**的那枚角标(三枚键共用)。 // -// ⚠️ **这一格��约束只有这一处**(票 #701 的审查):收藏键和评论键走 -// `countKeyRow`(它调这里),而**赞键自己画**——它那两层(背景圈 + 拇指)是 -// 绝对定位挂在「图形块」上的,而 `countKeyRow` 那一套没有这个锚结构。 +// ⚠️ **这一格的约束只有这一处**:收藏键和评论键走 `countKeyIcon`,而**赞键自己 +// 拼图形盒子**(它那两层背景 + 拇指是绝对定位的,见 `like.go` 的 `likeButton`) +// ——但**角标走这里**,于是三枚键的「挂点 / 字号 / 颜色 / 上限 / PassThrough」 +// **只有一份**。 // -// ⇒ 所以赞键**不能**直接套 `countKeyRow`,但它的**数字那一格**走这里—— -// 于是三枚键的「宽度上限 / 省略号 / 字号 / 颜色」**只有一份**。 +// # 🔴 形状:绝对定位、不占布局、不读屏、不吃指针 // -// 🔴 **`MaxWidth` 是布局层的硬边界,不只是画的时候裁**:实测(`mygo` 的 -// `MaxWidth`)框宽被**夹到**上限——而字宽差着 3.5 倍也夹得住: +// Attach(AnchorTopRight, AnchorTopLeft) 角标的左上角钉在图形盒子的右上角(外侧) +// PassThrough() 不登记命中(底下的按钮照点) // -// 「999+」 字宽 25.87 → 布局宽 **24.50** -// 「9999999999999+」 字宽 90.71 → 布局宽 **24.50** +// ⚠️ **绝对定位是相对父元素的 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 的 +// 内边距,角标就悬在按钮的右上角、盖住悬停淡底,而不是「图形右上」。 // -// ⇒ **左列的宽是常数**(309.5),**无论字多宽都不会变**——所以**碰撞不可能 -// 发生**(「余量 5.20」是**视觉间距**,不是碰撞缓冲)。 +// 🔴 **必须 `PassThrough()`**:角标画在图形**上面**(绝对定位排在普通子元素 +// 之后,`mygo/ui/paint.go`),而命中测试照旧登记它(`mygo/ui/runtime.go`)—— +// 不 PassThrough 的话,角标底下那一小块按钮**点不动**(`nav-feed` 那个红点是 +// 同一个形状、同一个理由:`navbar_feed.go` 的 `viewDynamicDotMark`)。 // -// ⇒ 所以这个上限**挡的是**「数字本身被拉出那一格」——那一格被截断、末尾那个 -// 「+」没了。**那才是这一格真正会坏的地方**(而「+」是这一族的语义)。 +// ⚠️ **颜色是 `TextMuted`**:角标是**附属读数**,不是那一枚键的身份——图标才是。 +// 它和播放条上别的次要读数(UP 名、时间)同一个色阶。 // -// ⚠️ **而「+」被切掉的那一档,是靠「字号固定」解决的**(不是靠这个上限): -// 系统文本放大 125% 时字宽涨到 29.9 —— 而**上限跟着 scale 走**会让左列撞上 -// 进度条(算过:scale 1.25 要上限 30.62 ⇒ 余量 **-13.18**)。⇒ 那一格的字号 -// **钉死在 10**(理由见 `playbarCountFont`)。 +// 🔴 **`MaxWidth` + `MaxLines(1)` + 省略号**:宽度账见 `playbarBadgeMaxW` 的 +// 注释——**当前折法一档都不截**(最宽的「99k+」≈22.0 ≤ 上限 22.5),而上限本身 +// 是**保险**:折法哪天改宽了,它挡住「角标那一格自己被截断」——截断先切末尾, +// 那个「+」就没了,而「+」是这一族的语义。 // -// 🔴 **上限之上的那一档会截,而截断**先切末尾** —— 所以那个「+」第一个没了** -// (实测:「99k+」上限 24 → 墨迹 20.00,比不带「+」的「99k」还短)。 -// 而「+」是这一族的**语义**,所以**上限必须 ≥ 最宽那一档的实测宽**—— -// 这就是折法**一档都不许靠截断兜底**的原因(见 `widgets/list/count.go`)。 -func countValueText(c *ui.Context, n int64) { +// ⚠️ **字号是**固定的**(`playbarBadgeFont`),不是 `scale.Font(c, …)`**—— +// 理由见那个常量的注释(系统文本放大 125% 就会切掉那个「+」,而「+」是这一族 +// 的语义)。 +func countBadge(c *ui.Context, n int64) { t := playbarCountText(n) if t == "" { - // ⚠️ **没有数字时这一格不画**:数字那一格**不占位**(留着缝会让那一格 - // 凭空宽 2)。 + // ⚠️ **没有数字时这一格不画**:角标连建都不建(占一个绝对定位的孩子是 + // 小事,但「没有数」和「零个赞」是两件事——见 `TestTheThreeCountsNeverShowZero`)。 return } - // 🔴 **`MaxWidth` + `MaxLines(1)` + 省略号**:宽度账见 - // `playbarCountMaxW` 的注释——**当前折法一档都不截**(最宽的「99k+」 - // 24.49 ≤ 上限 24.5),而上限本身是**保险**:折法哪天改宽了,它挡住 - // 「数字本身被拉出那一格」(那样左列就被撑宽,900 宽下进度条被压,而撞上 - // 的键**点不动**)。 - // ⚠️ **字号是**固定的**(`playbarCountFont`),不是 `scale.Font(c, …)`**—— - // 理由见那个常量的注释(系统文本放大 125% 就会把那个「+」切掉,而「+」是 - // 这一族的语义)。 - ui.Text(c, t).FontSize(playbarCountFont). + ui.Text(c, t).FontSize(playbarBadgeFont). TextColor(c.Theme().TextMuted). - MaxWidth(playbarCountMaxW).MaxLines(1).Ellipsis("…") + // 🔴 **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() } -// countKeyRow 是「图形 │ 缝 2 │ 数字」那一行(收藏键和评论键共用)。 +// countKeyIcon 是「图形盒子 + 角标」那一枚里的**图形那一块**(收藏 / 评论共用)。 // -// # 🔴 结构:图形**紧贴**那一枚键的左沿(**不是**在 32 的盒子里) +// # 🔴 结构:一个 18×18 的盒子,图形在盒子里,角标挂在盒子右上角 // -// ⚠️ **宽度账**(票 #701,这是本票最要紧的一条):900 宽下左边那一列的右端 -// **不许碰到进度条滑块左沿 330.7**。 +// 改之前图形是「行里的一个 18 宽的孩子」,数字是它右边的一格。现在图形回到 +// **和没有数字时一模一样的形状**:18×18 的盒子(`iconMd`),而角标**不占布局** +// ——所以这一枚键的宽是 32,**带不带数字都一样**。 // -// 第三段 = 32(分集)+ 3 × (18 图形 + 2 缝 + 数字格上限 24.5) -// -// ⇒ 第三段 **165.5**,左列保底 **309.5**,左列右沿 **325.5**,离滑块还有 **5.2**。 -// -// ⇒ **图形不能待在 32 的盒子里**(那会多出 14 的留白,三枚键就是 42)。 -// 所以带数字时图形是**紧贴左沿**的 18,而那 7 的留白**只在纯图标键那一档**。 -// -// ⚠️ **drawIcon 是回调**:赞键的图形上面还要挂**那一圈底色**(绝对定位),而 -// 那一圈要**以图形为中心**——所以图形得有一个**正好 18 宽**的盒子当锚(见 -// `like.go` 的 `likeButton`)。 -func countKeyRow(c *ui.Context, iconName string, n int64, hovered, pressed bool, drawIcon func(c *ui.Context)) { - ui.Row(c).Gap(playbarCountGap).AlignItems(ui.Center).Children(func() { - drawIcon(c) - countValueText(c, n) - }) -} - -// countIcon 是「图形 + 数字」那一枚里的**图形那一半**(普通的那种)。 +// ⚠️ **盒子是给角标当锚的**:`Attach` 相对**父元素的 padding box**——所以角标 +// 要挂在**一个正好 18 宽**的盒子上,它的右上角才是图形的右上角(挂在按钮上 +// 会多出 7 的内边距,角标就悬到按钮角上去了)。 // // ⚠️ **悬停变主色**(`flat.IconAccentOnHover`):和相邻几枚一样(上游 // `hover:text-primary`)——而 `flat.Button` 只会铺一层淡底,图形自己不换色的话 // 悬停反馈就只剩那层淡底。 // -// ⚠️ **按下缩放**(`flat.PressScale`):纯图标键才缩,而这一枚是「图标 + 数字」 -// ——图形缩、数字不缩(数字缩了会跟着抖,读数不该动)。 -func countIcon(c *ui.Context, iconName string, hovered, pressed bool) { - ic := ui.Icon(c, icon(iconName)).FontSize(iconMd) - if col, ok := flat.IconAccentOnHover(c, false, hovered); ok { - ic = ic.TextColor(col) - } - flat.PressScale(ic, pressed, false) +// ⚠️ **按下缩放**(`flat.PressScale`):纯图标键才缩,而这一枚是「图标 + 角标」 +// ——图形缩、角标不缩(角标缩了会跟着抖,读数不该动)。 +func countKeyIcon(c *ui.Context, iconName string, hovered, pressed bool, n int64) { + ui.Box(c).Size(iconMd, iconMd).Children(func() { + // 图形在盒子的正中(`Attach` 是相对盒子的 padding box 居中)。 + ic := ui.Icon(c, icon(iconName)).FontSize(iconMd). + Attach(ui.AnchorCenter, ui.AnchorCenter) + if col, ok := flat.IconAccentOnHover(c, false, hovered); ok { + ic = ic.TextColor(col) + } + flat.PressScale(ic, pressed, false) + // 🔴 **角标挂在盒子右上角**——它是绝对定位的,不占布局,而它的父盒子 + // 是 18×18,于是右上角正好是图形的右上角。 + countBadge(c, n) + }) } diff --git a/internal/ui/playbar_counts_test.go b/internal/ui/playbar_counts_test.go index 1ac29e3d..04c52569 100644 --- a/internal/ui/playbar_counts_test.go +++ b/internal/ui/playbar_counts_test.go @@ -8,14 +8,15 @@ // // # 🔴 宽度账是本票最要紧的一条 // -// 900 宽(应用的最小窗口宽)下,左边那一列的右端离进度条滑块左沿只剩 **5.20** -// 像素——而**撞上去的后果是「点不动,而画面上看不出坏了」**(票 #216 那个坑: -// 中间那一列是 `PassThrough` 的,可它的子节点照旧登记命中,于是左列撑出来的 -// 那一截被进度行的命中框盖住)。 +// 角标不占布局之后,第三段回到四枚 32 + 三道 3 的缝、左列保底 281——900 宽 +// (应用的最小窗口宽)下,左边那一列的右端离进度条滑块左沿是 **33.7** 像素 +// (横排那一版只有 5.20)。而**撞上去的后果是「点不动,而画面上看不出坏了」** +// (票 #216 那个坑:中间那一列是 `PassThrough` 的,可它的子节点照旧登记命中, +// 于是左列撑出来的那一截被进度行的命中框盖住)。 // -// ⚠️ **5.20 是「视觉间距」,不是碰撞缓冲**(理由见 `playbar_counts.go` 的文件 -// 头《两个口径》):`MaxWidth` 在**布局层**硬夹住,所以左列的宽是**常数** -// 309.5——**碰撞不可能发生**。那 5.20 是**留给人眼**的。 +// ⚠️ **33.7 是「视觉间距」,不是碰撞缓冲**(理由见 `playbar_counts.go` 的文件 +// 头《两个口径》):角标 `Attach` 不占布局、左列的宽是**常数** 281——**碰撞 +// 不可能发生**。那 33.7 是**留给人眼**的。 // // ⇒ `TestTheThreeCountsDoNotTouchTheProgressBar` 是这一组的主断言。 // @@ -156,33 +157,33 @@ func TestTheThreeCountsDisappearForALocalFile(t *testing.T) { // 🔴 **「+」是这一族的语义**(票面原话:「「+」恒表示「至少这么多」」)—— // 把它截掉就变成了「正好 999」,那是**撒谎**。 // -// ⚠️ **判据是「每一档都装得下上限」**:数字格有 `MaxWidth(playbarCountMaxW)` -// (24.5),而折法最宽的一档是「99k+」(实测 **24.49**)——**刚好装下**。 +// ⚠️ **判据是「每一档都装得下上限」**:数字格有 `MaxWidth(playbarBadgeMaxW)` +// (22.5),而折法最宽的一档是「99k+」(9 号字实测 **≈22.0**)——**刚好装下**。 // // ⇒ 所以这一条断的是**每一档**:**一个都不许截**。 // // 🔴 **为什么「不许截」是硬要求**:**截断切的是末尾,而「+」永远是最后一个 -// 字符** —— 所以「+」**总是第一个被切掉的**(实测:「99k+」上限 24 → 墨迹 -// 20.00;上限 21 → 墨迹 14.00,比不带「+」的「99k」(18.52)**还短**)。 +// 字符** —— 所以「+」**总是第一个被切掉的**(10 号字那一版实测:「99k+」上限 +// 24 → 墨迹 20.00;上限 21 → 墨迹 14.00,比不带「+」的「99k」(18.52)**还短**)。 // 而「+」是这一族的**语义**(「至少这么多」)——它被切掉就变成了「正好 99 千」, // 那是**撒谎**。 // // ⚠️ **判据怎么算「装得下」**:**单独画一遍**那一串字(同一个上限、同一个字号), // 量**它自己**的墨迹——**不能**量真实布局里那一格的墨迹(左右就是邻居的图形, -// 混进来就分不出哪一笔是那个「+」,实测那一格量出来 30 宽而字只有 24.49)。 +// 混进来就分不出哪一笔是那个「+」)。 func TestTheCountNeverLosesItsPlus(t *testing.T) { // ⚠️ **这些档位一个都不许截**。 // // 🔴 **99999 和 100000 必须在这一档里**(第一版把它们漏了——而那正是唯一 // 会截掉「+」的那一档): // - // 99999 → 「99k+」(实测 24.49,**最宽的那一档**) - // 100000 → 「1亿+」(实测 23.61) + // 99999 → 「99k+」(9 号字实测 ≈22.0,**最宽的那一档**) + // 100000 → 「1亿+」(9 号字实测 ≈21.2) // - // ⇒ 上限是 **24.5**,**刚好装下** 24.49 —— 而**上限再低一档(24)**, + // ⇒ 上限是 **22.5**,**刚好装下** ≈22.0 —— 而**上限再低一档(22)**, // 「99k+」就装不下,而**截断切的是末尾,所以「+」第一个被切掉** - // (实测:「99k+」上限 24 → 墨迹 20.00;上限 21 → 墨迹 14.00,比不带 - // 「+」的「99k」(18.52)**还短**)。 + //(10 号字那一版实测:「99k+」上限 24 → 墨迹 20.00;上限 21 → 墨迹 + // 14.00,比不带「+」的「99k」(18.52)**还短**)。 for _, n := range []int64{ 1, 99, 999, 1000, 1234, 9999, 10000, 12345, 20000, 99999, 100000, 1000000, @@ -214,28 +215,27 @@ func TestTheCountNeverLosesItsPlus(t *testing.T) { // 🔴 **判据是「那一串字在**同样的上限**下画出来,末尾的那个「+」还在不在」**。 // // ⚠️ **为什么不量真实布局里那一格的墨迹**:它**量不准**——左右两枚键的 - // 图形就在旁边(实测:那一格的墨迹量出来是 30 宽,而字只有 24.49), - // 邻居的字迹混进来,分不出哪一笔是那个「+」。 + // 图形就在旁边,邻居的字迹混进来,分不出哪一笔是那个「+」。 // // ⇒ 所以**单独画一遍**那一串字(同一个上限、同一个字号),量它自己的 // 墨迹:**「+」被切掉时那一截短 4 像素以上**,而完整时只差 0.5~0.7 //(字有左右边距)。 // // ⚠️ **为什么这个判据是对的**:截断**切的是末尾**,而「+」**永远是最后 - // 一个字符** —— 所以「+」**总是第一个被切掉的**(实测:「99k+」上限 24 - // → 墨迹 20.00;上限 21 → 墨迹 14.00,比不带「+」的「99k」(18.52) - // **还短**)。 - ink := isolatedInkWidth(t, want, playbarCountMaxW) + // 一个字符** —— 所以「+」**总是第一个被切掉的**(10 号字那一版实测: + // 「99k+」上限 24 → 墨迹 20.00;上限 21 → 墨迹 14.00,比不带「+」的 + // 「99k」(18.52)**还短**)。 + ink := isolatedInkWidth(t, want, playbarBadgeMaxW) var full float32 mygoui.NewTester(func(c *mygoui.Context) { full, _ = c.MeasureText(0, mygoui.Span{ - Text: want, Size: scale.Font(c, playbarCountFont)}) + Text: want, Size: scale.Font(c, playbarBadgeFont)}) }, 300, 60).Frame() if full-ink > 2 { t.Errorf("数 %d(%q)被截了:同一个上限(%.1f)下那一串字的墨迹只有 %.2f,"+ "而字宽是 %.2f——差的那一截是末尾那个「+」"+ "(那个「+」是这一族的语义)", - n, want, playbarCountMaxW, ink, full) + n, want, playbarBadgeMaxW, ink, full) } }) } @@ -244,7 +244,7 @@ func TestTheCountNeverLosesItsPlus(t *testing.T) { // TestTheCountKeepsItsPlusWhenTheSystemTextIsEnlarged 钉住**系统文本放大时那个 // 「+」还在**。 // -// 🔴 **这是「那一格的字号钉死在 10」那一条的判据**(票 #701 的审查): +// 🔴 **这是「那一格的字号钉死在 9」那一条的判据**(票 #701 的审查): // // 那一格的字**不乘** `scale.Font`(用户的系统文本大小)。而如果它乘了, // **125% / 150% 是 Windows 上的常见设置**——那时: @@ -270,11 +270,11 @@ func TestTheCountKeepsItsPlusWhenTheSystemTextIsEnlarged(t *testing.T) { withoutPlus := strings.TrimSuffix(want, "+") // ⚠️ **量的是那一格**在放大之后**实际画出来**的样子——而那一格的 - // 字号**钉死在 10**(不乘系统缩放),所以**字宽恒定** ⇒ 那个「+」 + // 字号**钉死在 9**(不乘系统缩放),所以**字宽恒定** ⇒ 那个「+」 // **在任何 scale 下都在**。 // // ⇒ 所以这一条要验的是「**字号真的没跟着缩放**」:如果有人把 - // `playbarCountFont` 改回 `scale.Font(c, playbarCountFont)`, + // `playbarBadgeFont` 改回 `scale.Font(c, playbarBadgeFont)`, // 下面这一测立刻红(那时字宽会涨、那一格会被截、「+」会没)。 ink, base := scaledCellInks(t, want, withoutPlus, sc) @@ -294,7 +294,7 @@ func TestTheCountKeepsItsPlusWhenTheSystemTextIsEnlarged(t *testing.T) { // scaledCellInks 量「**界面上那一格**」在给定的系统文本缩放下,它自己的墨迹 // 有多宽(带「+」的那一串,和不带「+」的那一串)。 // -// 🔴 **走的是生产代码**(`countValueText`)——这是这一条能成立的原因: +// 🔴 **走的是生产代码**(`countBadge`)——这是这一条能成立的原因: // 自己画一遍的话,字号是测试里写的那个数,而**被测的那件事恰恰是「字号乘不乘 // 系统缩放」**——自己画就等于把要验的东西当成了前提。 // @@ -303,16 +303,20 @@ func TestTheCountKeepsItsPlusWhenTheSystemTextIsEnlarged(t *testing.T) { // 会比不带「+」的还短**(实测 scale 1.25:17 < 23)。 // // ⚠️ **单独画**:真实布局里那一格的左右**就是邻居的图形**,量墨迹会把邻居的 -// 字迹算进来(实测:那一格量出来 30 宽,而字只有 24.49)——分不出哪一笔是 -// 那个「+」。⇒ 这里让那一格**单独占一行**。 +// 字迹算进来——分不出哪一笔是那个「+」。⇒ 这里让那一格**单独占一行**。 func scaledCellInks(t *testing.T, withPlus, withoutPlus string, scale float32) (float32, float32) { t.Helper() - // 🔴 **带「+」的那一格走**生产代码**(`countValueText`)——这是这一条能成立的 + // 🔴 **带「+」的那一格走**生产代码**(`countBadge`)——这是这一条能成立的 // 原因:自己画一遍的话,字号是测试里写的那个数,而**被测的那件事恰恰是 // 「字号乘不乘系统缩放」**——自己画就等于把要验的东西当成了前提。 + // + // ⚠️ **角标要挂在一个图形盒子上**(它在按钮里是 `Attach` 在 18×18 的盒子 + // 上的)——裸建会把它的中心钉在整行的右上角,量到的不是它的墨迹。 tt := mygoui.NewTester(func(c *mygoui.Context) { mygoui.Row(c).Children(func() { - countValueText(c, countForText(withPlus)) + mygoui.Box(c).Size(iconMd, iconMd).Children(func() { + countBadge(c, countForText(withPlus)) + }) }) }, 300, 60) tt.SetPreferences(mygoui.Preferences{TextScale: scale}) @@ -320,7 +324,7 @@ func scaledCellInks(t *testing.T, withPlus, withoutPlus string, scale float32) ( with := inkWidthOf(t, tt, withPlus) // ⚠️ **对照组是「同一串字去掉那个「+」」,而且它**不是折法的输出**—— - // 折法那一档永远带「+」,所以**不能**喂给 `countValueText`(那样折出来还是 + // 折法那一档永远带「+」,所以**不能**喂给 `countBadge`(那样折出来还是 // 带「+」的字,量的是同一串,两边相等,判据就成了空话——第一版就这么写的, // 变异测试立刻揭穿了它)。 // @@ -328,8 +332,8 @@ func scaledCellInks(t *testing.T, withPlus, withoutPlus string, scale float32) ( // 「那个「+」自己占多少像素」。 tt2 := mygoui.NewTester(func(c *mygoui.Context) { mygoui.Row(c).Children(func() { - mygoui.Text(c, withoutPlus).FontSize(playbarCountFont). - MaxWidth(playbarCountMaxW).MaxLines(1).Ellipsis("…") + mygoui.Text(c, withoutPlus).FontSize(playbarBadgeFont). + MaxWidth(playbarBadgeMaxW).MaxLines(1).Ellipsis("…") }) }, 300, 60) tt2.SetPreferences(mygoui.Preferences{TextScale: scale}) @@ -341,7 +345,7 @@ func scaledCellInks(t *testing.T, withPlus, withoutPlus string, scale float32) ( // countForText 把「一串字」换算成「能折出它的那一个数」。 // // ⚠️ **它走的是折法自己的逆运算**:「99k+」⇒ 99000、「1亿+」⇒ 100000000。 -// 而**数字那一格画的是折出来的字**,所以要喂给 `countValueText` 的是**数**。 +// 而**数字那一格画的是折出来的字**,所以要喂给 `countBadge` 的是**数**。 func countForText(s string) int64 { body := strings.TrimSuffix(s, "+") var head int64 @@ -424,6 +428,81 @@ func TestTheThreeCountsSayTheNumberToScreenReaders(t *testing.T) { } } +// TestTheThreeCountsSitAsBadgesOnTheIconsTopRight 钉住**数字贴在图形右上角外侧**。 +// +// 🔴 **这是本票的主体**:数字从「图形右边那一格」挪成「图形右上角外侧挂着的 +// 角标」(网易云式)——它**不占布局**,而是 `Attach(AnchorTopRight, +// AnchorTopLeft)` 把角标的左上角钉到图形盒子的右上角,**不收不压**:左缘就是 +// 图形右缘、上缘就是图形上缘。 +// +// ⚠️ **判据是角标自身的 bounds 相对图形盒子 bounds 的位置**(视觉审查钉下的 +// 三条真值): +// +// 角标的上缘 ≈ 图形盒子的上缘 (它贴着图形上沿,不是居中) +// 角标的左缘 ≈ 图形盒子的右缘 (内收会压回图标本体) +// 角标的中心 > 图形盒子的右缘 (主体整个在图形右缘以右) +// +// 反过来说:横排那一版(角标上缘 ≈ 图形中心 y、左缘在图形右缘往右一整格之外)、 +// 「压住图形」那一版(角标中心落在图形内部)和「内收骑角」那一版(左缘收进 +// 图形右缘以内)都立刻红。 +// +// ⚠️ **三枚都要断**:只断一枚的话,「收藏 / 评论走了角标、赞还停在横排」那 +// 一档照样绿——而那一档正是本票最容易漏的(赞键的图形那一块是绝对定位的两层, +// 和另外两枚不是同一条路径)。 +// +// ⚠️ **图形盒子怎么找**:角标的父元素是图形盒子(`ui.Box(iconMd × iconMd)`), +// 而那一枚键的框是它再往外一层。Tester 不给父链——所以这里**用按钮的框**倒推: +// 按钮的**中心**就是图形盒子的中心(带数字时图形在按钮正中),图形盒子的半边 +// 是 `iconMd/2`。 +func TestTheThreeCountsSitAsBadgesOnTheIconsTopRight(t *testing.T) { + tt, _ := countsPlaybarTester(t, 1200, 1286, 2468, 9999) + + for _, c := range []struct { + name string + count int64 + }{ + {"评论", 1286}, + {"收藏", 2468}, + {"赞", 9999}, + } { + want := list.CountText(c.count) + badge, ok := tt.Find(want) + if !ok { + t.Fatalf("「%s」键上没有角标 %q(文字有 %q)", c.name, want, tt.Texts()) + } + key, ok := tt.Find(c.name + " " + want) + if !ok { + t.Fatalf("画面上找不到「%s」那一枚键(文字有 %q)", c.name, tt.Texts()) + } + // 图形盒子的中心 = 按钮的中心(带数字时图形在按钮正中、角标不占布局), + // 半边是 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) + } + // 🔴 **角标的左缘就是图形盒子的右缘**(`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) + } + // 🔴 **主体整个在图形右缘以右**:角标的中心离图形右缘还有半格字宽—— + // 图形盒子的右半边一个像素都不该被数字盖到(中心锚那一版角标中心落在 + // 图形内部,立刻红)。 + bx := badge.X + badge.W/2 + if bx < iRight { + t.Errorf("「%s」的角标中心在 %.1f,图形右缘在 %.1f——角标主体压住了图形"+ + "(要的是挂在图形右肩外侧,不是盖在它上头)", c.name, bx, iRight) + } + } +} + // TestTheThreeCountsAreInTheHoverTips 钉住**悬停提示里也有那个数**。 // // ⚠️ **数字只画在画面上,指针停在别处时用户读不到它**——所以提示句里也得说 @@ -522,15 +601,13 @@ func TestTheThreeCountsDoNotTouchTheProgressBar(t *testing.T) { "它们碰上了(而撞上去的后果是**点不动**,票 #216)", width, key.X+key.W, bar.X) } - // ⚠️ **余量是「按数字格的上限算」的那一个**:第三段的宽按 - // `playbarCountMaxW`(24.5)预留——所以**实测的余量是 5.20**。 - // - // 🔴 **门槛是 5**:余量 0.7 扛不住一次字体变化,而撞上的后果是 - // **点不动,而画面上看不出坏了**(票 #216 那个坑)。 + // ⚠️ **余量是「按角标不占布局」的那一档**:第三段回到四枚 32 + + // 三道 3 的缝,左列保底 281——所以**实测的余量是 33.7**(横排那一版 + // 是 5.20,那时数字占一格、第三段 165.5)。 // - // ⚠️ **5.20 是「装得下最宽那一档」换来的**:上限从 24 提到 24.5 - // (为了装下「99k+」24.49,而截断**先切掉那个「+」**)⇒ 余量从 - // 6.70 少到 5.20。 + // 🔴 **门槛是 5**:票 #701 的验收线照旧(虽然这一票余量已经大得多—— + // 门槛留着是怕下一个人把数字挪回行内、左列重新撞上滑块,票 #216 + // 那个坑)。 if gap < 5 { t.Errorf("宽度 %d:第三段离进度行只剩 %.2f 像素——"+ "余量不够,换一个字体就撞上去了(票 #216 那个坑:点不动,"+ @@ -585,47 +662,42 @@ func TestTheThreeCountsDoNotSqueezeTheTitle(t *testing.T) { // // 判据分两层: // -// 1. 常量之间的账(`playbarActionsW` = 分集 32 + 三枚 44.5 = 165.5); +// 1. 常量之间的账(`playbarActionsW` = 四枚 32 + 三道键间缝 3×3 = 137); // 2. 注释里那个「最小宽度:**N**」要与 `playbarLeftMinW` 算出来的数一致。 func TestTheThreeCountsWidthMatchesTheCode(t *testing.T) { - if playbarActionsW != iconHitMd+3*(iconMd+playbarCountGap+playbarCountMaxW) { - t.Errorf("playbarActionsW = %v,want %v(分集 32 + 三枚带数字的键)", - playbarActionsW, iconHitMd+3*(iconMd+playbarCountGap+playbarCountMaxW)) + // 🔴 **本票(角标)之后**:三枚键的数字**不占布局**,每一枚回到 32; + // 键与键之间留 3 的缝(角标探出之后叠在缝上,没缝三枚带数的键糊成一串)。 + if playbarActionsW != 4*iconHitMd+3*playbarActionsGap { + t.Errorf("playbarActionsW = %v,want %v(四枚 32 + 三道键间缝 3 × 3)", + playbarActionsW, 4*iconHitMd+3*playbarActionsGap) } if playbarLeftMinW != coverSize+coverGap+trackTextMinW+coverGap+playbarActionsW { t.Errorf("playbarLeftMinW = %v,want %v(52 + 8 + 76 + 8 + %v)", playbarLeftMinW, coverSize+coverGap+trackTextMinW+coverGap+playbarActionsW, playbarActionsW) } - // 🔴 **900 宽下的余量**:按「上限」算的那一档(左列右沿离滑块左沿)。 + // 🔴 **900 宽下的余量**(左列右沿离滑块左沿)。 // - // 左列右沿 = 16 + 309.5 = 325.5 + // 左列右沿 = 16 + 281 = 297 // 滑块左沿 = 330.7 - // 余量 = 5.2 + // 余量 = 33.7 // - // ⚠️ **按上限算**(数字格 24.5)而不是按某一天的字宽:字宽随平台字体变, - // 而保底宽**必须是常数**(理由见 `playbar_counts.go` 的文件头《两个口径》)。 + // ⚠️ **角标不占布局**(`Attach`):第三段回到四枚 32 + 三道 3 的缝, + // 左列保底 281——横排那一版的 165.5 缩回 137,**28.5 净让给了进度条**。 // - // ⇒ 左列右沿 **325.5** 离滑块左沿 **330.7** 还有 **5.2**。 + // ⇒ 左列右沿 **297.0** 离滑块左沿 **330.7** 还有 **33.7**。 // ⇒ 而**实测**(`TestTheThreeCountsDoNotTouchTheProgressBar` 量的是真实 - // 布局)也是 **5.20**——因为第三段是按**上限**(24.5)预留的。 - // - // ⚠️ **5.20 是「装得下最宽那一档」换来的**:上限从 24 提到 24.5(为了装下 - // 「99k+」24.49,而截断会**先切掉那个「+」**)⇒ 余量从 6.70 少到 5.20。 + // 布局)也是 **33.7**。 const sliderLeft = 330.7 gap := sliderLeft - (playbarPadX + playbarLeftMinW) if gap < 0 { t.Errorf("按上限算,左列右沿离滑块左沿还有 %.2f——已经撞上了", -gap) } - // 🔴 **余量必须 ≥ 5**:余量 0.7 扛不住一次字体变化,而撞上的后果是 - // **点不动,而画面上看不出坏了**(票 #216 那个坑)。 + // 🔴 **余量必须 ≥ 5**:票 #701 的验收线照旧(虽然这一票余量已经大得多—— + // 门槛留着是怕下一个人把数字挪回行内)。 if gap < 5 { t.Errorf("按上限算余量是 %.2f——票 #701 的验收线是 ≥ 5", gap) } - if gap > 6 { - t.Errorf("按上限算余量是 %.2f——比量出来的(5.20)宽太多,"+ - "说明上限那一档算错了", gap) - } } // TestTheCountFoldIsUsedByAllThreeKeys 钉住**三枚键用的是同一套折法**。 @@ -670,13 +742,13 @@ func TestTheCountFoldIsUsedByAllThreeKeys(t *testing.T) { // isolatedInkWidth 量「一串字在给定上限下画出来,**它自己**的墨迹有多宽」。 // // ⚠️ **为什么要单独画**:真实布局里那一格的左右**就是邻居的图形**,量墨迹会 -// 把邻居的字迹算进来(实测:那一格量出来 30 宽,而字只有 24.49)——分不出 -// 哪一笔是那个「+」。⇒ 这里单独画一遍,四周没有别的东西。 +// 把邻居的字迹算进来——分不出哪一笔是那个「+」。⇒ 这里单独画一遍,四周没有 +// 别的东西。 func isolatedInkWidth(t *testing.T, s string, maxW float32) float32 { t.Helper() tt := mygoui.NewTester(func(c *mygoui.Context) { mygoui.Row(c).Children(func() { - mygoui.Text(c, s).FontSize(scale.Font(c, playbarCountFont)). + mygoui.Text(c, s).FontSize(scale.Font(c, playbarBadgeFont)). MaxWidth(maxW).MaxLines(1).Ellipsis("…") }) }, 300, 60) From 15eeaf4782c31d472a78f9b5ff48b6c4e6808b60 Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Mon, 2 Nov 2026 22:45:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E7=9A=84=20P2?= =?UTF-8?q?=EF=BC=9Alike=20=E6=B3=A8=E9=87=8A=E9=87=8C=E6=82=AC=E7=A9=BA?= =?UTF-8?q?=E7=9A=84=20playbarBadgeNudge=20=E6=94=B9=E6=88=90=E6=8C=87=20c?= =?UTF-8?q?ountBadge=20=E7=9A=84=E9=94=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ui/like.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/like.go b/internal/ui/like.go index e6d44933..2d65a966 100644 --- a/internal/ui/like.go +++ b/internal/ui/like.go @@ -810,8 +810,8 @@ func (s *Shell) likeButton(c *ui.Context, name, label string, color ui.Color, li // ⚠️ **而那一圈不会盖住角标**:角标挂在图形盒子的**右上角**(盒子坐标 // x≈9..18、y≈0..9 那一小角),而那一圈的右上半径离它还有一段——满大时 // 圆的边缘在那一角之下(实测那一小角在圆的 14° 那一带,圆的边缘离它 - // 还有约 4 像素)。真压上是「角标往右下挪多了」的事,那时挪的是 - // `playbarBadgeNudge`,不是这一圈。 + // 还有约 4 像素)。真压上是「角标往右下挪多了」的事——那时挪的是 + // `countBadge` 里那个 `Attach` 的锚/偏移,不是这一圈。 bgW, bgH := float32(iconHitMd), float32(iconHitMd) drawIcon := func(c *ui.Context) { // 背景层(先建 → 画在下面)