diff --git a/internal/ui/follow.go b/internal/ui/follow.go index 959aeca4..0033e94f 100644 --- a/internal/ui/follow.go +++ b/internal/ui/follow.go @@ -5,16 +5,17 @@ // 左边一列 Tab(第一格是「全部关注(N)」,之后每个分组一格),右边一个虚拟网格 // 装着关注的 UP 主,「全部关注」那一格顶上有一个搜索框。上游这一页的网格是 // `VirtualGridPageList` ——**和首页的推荐网格是同一个组件**,所以列数断点、格子 -// 间距取的是同一份(`list.GridColumnsFor` / `list.GridGap`),别在这里另写一 -// 张断点表。 +// 间距取的是同一份(`list.GridColumnsFor` / `list.GridGap`)。但歌卡和人像卡 +// 密度不同,用户 2026-11-03 参考网易云关注页拍板要更密——所以这一页自己拿一张 +// 断点表(`followColumnsFor`),**和首页刻意不同**。 // // # ⚠️ 为什么用户卡不直接用 `list.Grid` // // `list.Grid` 画的是**一首歌**:封面 / 歌名 / UP 名 / 时长,四个字段都取自 // `list.Music`。一张用户卡是头像 / 昵称 / 签名 / 认证——`list.Music` 上**没有** // 这几个字段,硬塞进去就是把「一张卡」伪装成「一首歌」,而伪装不了的字段最后 -// 要么画成空要么被编出来。共用的是**那一半真的能共用**:列数断点和格子间距 -// (`list.GridColumnsFor` / `list.GridGap`),以及换列数时守住滚动锚点那一套 +// 要么画成空要么被编出来。共用的是**那一半真的能共用**:格子间距 +// (`list.GridGap`),以及换列数时守住滚动锚点那一套 // (`keepAnchor`,见下)。 // // # 🔴 铁律四:每一格的身份是那个人的 mid,每一行的身份是这一行第一个人的 mid @@ -79,8 +80,11 @@ const ( followGroupSelectMinW = 120 ) -// followCardSide 是一张卡上头像的边长。上游是 `h-32 w-32`(128)。 -const followCardSide = 128 +// followCardSide 是一张卡上头像的边长。 +// +// ⚠️ **上游 HeroUI 是 `h-32 w-32`(128),但我们取的是 96**:用户 2026-11-03 +// 参考网易云关注页拍板——要的是那种清爽瘦卡,128 的头像在窄卡上占掉太多高度。 +const followCardSide = 96 // followActionPillRadius 是「取消关注」那条药丸的圆角。**999**—— // 药丸(胶囊)就是「圆角大于高度的一半」,999 是那个惯用的写法。 @@ -89,8 +93,50 @@ const followCardSide = 128 // ——跟着它走的话,用户把圆角调到 0,这条药丸就变成一个方块了。 const followActionPillRadius = 999 -// followCardPad 是卡里那圈留白。上游是 `p-4`(16)。 -const followCardPad = space.Px16 +// followCardPad 是卡里那圈留白。 +// +// ⚠️ **上游是 `p-4`(16),但我们取的是 12**:用户 2026-11-03 参考网易云关注页 +// 拍板——瘦卡的留白跟着瘦。 +const followCardPad = space.Px12 + +// followBreakpoint 是「到这个宽度就换成这么多列」。从宽到窄排,碰到的第一个算数。 +// +// ⚠️ **和首页歌卡的断点表刻意不同**:歌卡(`list.Grid`)的封面 140 高、横长形, +// Biu 那四档 1536/1280/1024/768 → 6/5/4/3 是歌卡的密度;而关注卡是**人像** +// (头像 + 名字 + 一行签名),窄得多,同一宽度下排得开更多列——用户 2026-11-03 +// 参考网易云关注页拍板要更密。 +// +// 判据是**容器**的宽,不是窗口的(理由同 `list.GridColumnsFor`)。 +type followBreakpoint struct { + width float32 + cols int +} + +// followBreakpoints 是关注页那张表:1536/1280/1024/768 → 7/6/5/4,比 768 窄 +// 还有一档 3 列,兜底(还没量出宽度)也是 3 列——和「最窄那一档」同一个数, +// 理由同 `list.gridMinCols`。 +var followBreakpoints = []followBreakpoint{ + {1536, 7}, + {1280, 6}, + {1024, 5}, + {768, 4}, +} + +// followMinCols 是最窄那一档的列数,也是宽度还没量出来时用的那个。 +const followMinCols = 3 + +// followColumnsFor 是容器有 w 那么宽时排几列(关注页专用)。 +// +// ⚠️ **和 `list.GridColumnsFor` 是两张表,别混**:歌卡和人像卡的密度不同 +// (理由见 `followBreakpoints`)。 +func followColumnsFor(w float32) int { + for _, b := range followBreakpoints { + if w >= b.width { + return b.cols + } + } + return followMinCols +} // followRowGap 是**行与行之间**的缝。上游是 `virtual-grid-page-list` 的 // `ROW_GAP = 16`(`src/components/virtual-grid-page-list/index.tsx:22`)。 @@ -764,7 +810,7 @@ func (s *Shell) viewFollowGrid(c *ui.Context, f *followPage) { // ResizeObserver 晚一帧是同一个节奏。 w, _ = c.Size() } - cols := list.GridColumnsFor(w) + cols := followColumnsFor(w) // ⚠️ 必须在换掉 `f.cols` **之前**算锚点:keepFollowAnchor 里的行号是用**旧** // 的列数换算的。`f.cols = cols` 在这一段的末尾(画完之后),不在这里。 s.keepFollowAnchor(f, cols) @@ -974,13 +1020,17 @@ func (s *Shell) viewFollowRow(c *ui.Context, f *followPage, i, cols int) { // 不占位也不画边框)。 return } - s.viewFollowCardGuarded(c, f, j) + s.viewFollowCardGuarded(c, f, j, cols) } }) } // viewFollowCardGuarded 画一张卡,画崩了只废掉这一张。 -func (s *Shell) viewFollowCardGuarded(c *ui.Context, f *followPage, i int) { +// +// ⚠️ **`cols` 是这一帧正在摆的列数**(`viewFollowRow` 传下来的),不是上一帧的 +// `f.cols`——换列数那一帧格子已经按新列数摆了,而 `f.cols` 要等这一段画完才换 +// (`keepFollowAnchor` 要先拿旧列数算锚点),拿旧的去算「游标在第几格」会落错行。 +func (s *Shell) viewFollowCardGuarded(c *ui.Context, f *followPage, i, cols int) { u := f.users[i] if f.failed[u.Mid] { ui.Text(c, "这一格画不出来").FontSize(scale.Font(c, scale.Size13)).TextColor(c.Theme().TextMuted). @@ -996,19 +1046,25 @@ func (s *Shell) viewFollowCardGuarded(c *ui.Context, f *followPage, i int) { log.Printf("biu: 关注页那张卡画崩了(%d):%v\n%s", u.Mid, rec, debug.Stack()) } }() - s.viewFollowCard(c, u, f.keys.selectedCell(i, f.cols), f.keys.ring(c)) + s.viewFollowCard(c, u, f.keys.selectedCell(i, cols), f.keys.ring(c)) } // viewFollowCard 画一张卡。 // // 结构照 Biu 的 `follow-list/user-card.tsx`:头像一块,下面昵称,再下面签名 -// (`line-clamp-2`)。**整张卡点一下 = 跳进那个 UP 的空间**(上游 `onPress` +// (一行)。**整张卡点一下 = 跳进那个 UP 的空间**(上游 `onPress` // 就是 `navigate(/user/${u.mid})`)。 // // ⚠️ **认证那一块画成一行字而不是头像右下角那枚闪电**:上游那一枚是 // `RiVerifiedFill`,而我们的图标资源里**没有那一枚**(这一票补的是界面上一处一处 // 用得上的那一批,不是一个分类整个搬进来)。`official_verify.desc` 我们已经解出来 // 了(`bili/follow.go`),摆一行字是最诚实的画法。 +// +// # 为什么是「瘦卡」而不是上游那张大卡 +// +// 上游 HeroUI 的卡是 `h-32 w-32` 头像 + `p-4` 留白 + `text-lg` 名字 + +// `line-clamp-2` 签名,一张卡又宽又高。用户 2026-11-03 参考网易云关注页拍板: +// 头像 96、留白 12、名字 16、签名只留一行——要的是那种清爽瘦卡。 func (s *Shell) viewFollowCard(c *ui.Context, u app.FollowUser, selected, ring bool) { t := c.Theme() name := u.Name @@ -1107,8 +1163,11 @@ func (s *Shell) viewFollowCard(c *ui.Context, u app.FollowUser, selected, ring b // 卡片底再往文字色推一档,比页面底暗),所以指针压上去的时候那个洞 // 不会跟着卡片一起变没。 s.spaceAvatarWith(c, u.Face, followCardSide, false, t.Background) - // 名字 18、签名 14,照 `user-card.tsx:84-85` 的 `text-lg` / `text-sm`。 - ui.Text(c, name).FontSize(scale.Font(c, scale.Size18)).MaxLines(1).Ellipsis("…") + // 名字 16、签名 14。 + // + // ⚠️ **上游是 `text-lg`(18),但我们取的是 `Size16`**:用户 2026-11-03 + // 参考网易云关注页拍板——瘦卡的字号跟着瘦。 + ui.Text(c, name).FontSize(scale.Font(c, scale.Size16)).MaxLines(1).Ellipsis("…") if u.Official != "" { ui.Text(c, u.Official).FontSize(scale.Font(c, scale.Size12)).TextColor(t.TextMuted). MaxLines(1).Ellipsis("…") @@ -1116,8 +1175,10 @@ func (s *Shell) viewFollowCard(c *ui.Context, u app.FollowUser, selected, ring b // ⚠️ **签名空的时候整块不画**:Biu 那一格是空的,我们这里留一个 6 的 // 间距 + 一段空白,看起来像卡少了一截(理由同 `list.Grid` 里时长那一列)。 if u.Sign != "" { + // ⚠️ **上游是 `line-clamp-2`,但我们取的是 MaxLines(1)**:用户 2026-11-03 + // 参考网易云关注页拍板——瘦卡的签名只留一行。 ui.Text(c, u.Sign).FontSize(scale.Font(c, scale.Size14)).LineHeight(leading.Signature).TextColor(t.TextMuted). - TextAlign(ui.Center).MaxLines(2).Ellipsis("…") + TextAlign(ui.Center).MaxLines(1).Ellipsis("…") } s.viewFollowActions(c, u, shown) }) diff --git a/internal/ui/follow_cols_test.go b/internal/ui/follow_cols_test.go new file mode 100644 index 00000000..003aed30 --- /dev/null +++ b/internal/ui/follow_cols_test.go @@ -0,0 +1,155 @@ +// 「我的关注」那一页的**列数断点**和**瘦卡**的几个数(票 follow-slim)。 +// +// # 为什么和首页用**不同**的断点表 +// +// 首页歌卡(`list.Grid`)的封面是 140 高、横长形,Biu 那四档 1536/1280/1024/768 +// → 6/5/4/3 是**歌卡**的密度。关注卡是**人像**(头像 + 名字 + 一行签名), +// 窄得多,同一宽度下排得开更多列——用户 2026-11-03 参考网易云关注页拍板, +// 要的是那种清爽瘦卡,于是这一页自己拿一张表(`followColumnsFor`)。 +// +// # 表里每个断点上下各钉一个宽度 +// +// 写法照 `widgets/list/grid_test.go` 的 `TestColumnsFollowTheBiuBreakpoints`: +// 断点本身和断点上方 1 像素是「刚好够到下一档」和「还没够到」的分界, +// 判成 `>=` 还是 `>` 写错了只有这一条看得出来。 +package ui + +import ( + "os" + "strings" + "testing" +) + +// TestFollowColumnsFollowThePortraitBreakpoints 钉住关注页那张表: +// +// >= 1536 → 7 列 >= 1280 → 6 列 +// >= 1024 → 5 列 >= 768 → 4 列 比 768 还窄 → 3 列 +// +// 兜底(还没量出宽度)也是 3 列——和「最窄那一档」同一个数,理由同 +// `widgets/list/grid.go` 的 `gridMinCols`。 +func TestFollowColumnsFollowThePortraitBreakpoints(t *testing.T) { + for _, c := range []struct { + w float32 + cols int + }{ + {0, 3}, {500, 3}, {767, 3}, // 比 768 窄:3 列 + {768, 4}, {900, 4}, {1023, 4}, // 768~1023:4 列 + {1024, 5}, {1200, 5}, {1279, 5}, // 1024~1279:5 列 + {1280, 6}, {1400, 6}, {1535, 6}, // 1280~1535:6 列 + {1536, 7}, {1900, 7}, // 1536 以上:7 列 + } { + if got := followColumnsFor(c.w); got != c.cols { + t.Errorf("容器宽 %v 排 %d 列,want %d", c.w, got, c.cols) + } + } +} + +// TestTheFollowCardIsTheSlimKind 钉住这张卡的几个数: +// +// - 头像边长 **96**(上游 HeroUI 是 `h-32` = 128,但用户 2026-11-03 参考 +// 网易云关注页拍板要更瘦); +// - 卡里那圈留白 **12**(上游是 `p-4` = 16,用户同一条要更紧凑); +// - 名字字号 **16**(`scale.Size16`,上游 `text-lg` = 18); +// - 签名 **MaxLines(1)**(上游 `line-clamp-2`,用户同一拍板改成一行)。 +// +// 头像和留白是常量,直接比;字号和行数断的是**画面上量出来的盒子高**: +// 16 点那行实测约 18.9、一行签名约 19.6,18 点那行实测约 21.2、两行签名约 +// 39。阈值取 20 / 25——旧值和新值差得够开。 +func TestTheFollowCardIsTheSlimKind(t *testing.T) { + if followCardSide != 96 { + t.Errorf("头像边长是 %v,want 96(用户 2026-11-03 参考网易云关注页拍板)", followCardSide) + } + if followCardPad != 12 { + t.Errorf("卡里那圈留白是 %v,want 12(用户 2026-11-03 参考网易云关注页拍板)", followCardPad) + } + + b := newUIExplore(t) + uiFollowTags(b, map[int]any{0: "默认分组"}) + uiFollowList(b, 2, []any{ + uiFollowUser(11, "第一个 UP"), uiFollowUser(12, "第二个 UP"), + }) + uiFollowSearch(b, "", nil) + uiFollowTag(b, 0, []any{}) + + s, tt, _ := newSpaceShell(t, b, true, "77") + s.navigate(followPath) + tt.Frame() + f := s.followPageOf() + waitFrames(t, tt, "人到了", func() bool { return len(f.users) == 2 && f.tagStarted }) + tt.Frame() + + // 第二张卡的名字不是整行的无障碍名字(那是第一张卡的),所以 `Find` + // 拿到的就是名字那一段文字本身。 + name, ok := tt.Find("第二个 UP") + if !ok { + t.Fatalf("画面上找不到名字,文字有 %q", tt.Texts()) + } + if name.H > 20 { + t.Errorf("名字那一段 %.1f 高——18 点那一档是 21.2,16 点该是约 18.9(用户要 Size16)", name.H) + } + sign, ok := tt.Find("第二个 UP 的签名") + if !ok { + t.Fatalf("画面上找不到签名,文字有 %q", tt.Texts()) + } + if sign.H > 25 { + t.Errorf("签名那一段 %.1f 高——MaxLines(2) 下是约 39,MaxLines(1) 该是约 19.6(用户要一行)", sign.H) + } +} + +// TestTheSelectedCellDividesByTheGivenColumns 钉住 `selectedCell` 那一步除法本身: +// +// 格号 ÷ 列数 == 游标所在的行号 +// +// 游标是**行号**(`sel`),而高亮是**格**——「这一格选中没有」要拿它的行号去比。 +// 列数给错了(比如换成上一帧的),这一除就把整行高亮挪到别的格上。 +func TestTheSelectedCellDividesByTheGivenColumns(t *testing.T) { + var k listKeys + k.sel = 0 + // 4 列下第 0 行是格 0~3,5 列下是格 0~4:第 4 格是分界。 + if k.selectedCell(4, 4) { + t.Error("4 列下游标在第 0 行,第 4 格不该选中(4/4=1)") + } + if !k.selectedCell(4, 5) { + t.Error("5 列下游标在第 0 行,第 4 格该选中(4/5=0)") + } + // 列数传 0 或负数(「还没量出列数」那一帧)要答「没选中」,不能除零。 + if k.selectedCell(0, 0) || k.selectedCell(0, -1) { + t.Error("列数还没量出来的时候,没有一格该算选中") + } +} + +// TestTheCardSeesTheFramesColumns 钉住「换列数那一帧,高亮拿的是这一帧的列数」。 +// +// # 根因(票 #740 对抗性审查的 P1) +// +// `viewFollowCardGuarded` 原来调 `f.keys.selectedCell(i, f.cols)`——`f.cols` 是 +// **上一帧**的列数(`f.cols = cols` 在 `viewFollowGrid` 末尾、画完这一段才换, +// `keepFollowAnchor` 要先拿旧列数算锚点)。而 `viewFollowRow` 已经按**这一帧的 +// 新列数**摆格子了。于是换列数的那一帧,高亮拿旧列数去算「游标在第几格」, +// 落错行——选中行秃掉最后一格、下一行多一块环。 +// +// 修法:`selectedCell` 吃「这一帧正在摆的 cols」——`viewFollowRow` 顺着传下来。 +// +// # 为什么钉的是源码而不是截图 +// +// 那一帧在真窗口里一闪而过,而无头 Tester 的 `SetSize`/`Frame` 都会在内部把帧 +// 推进到「再也没有东西要画」为止——等拿到图,`f.cols` 已经追上了新列数, +// 旧代码也画对了。试过了,截不到。所以这一条照 +// `TestEveryPageThatDrawsItsOwnListRegistersIt` 的先例,钉在源码上: +// `viewFollowCardGuarded` 必须把形参 `cols`(`viewFollowRow` 传下来的、这一帧 +// 正在摆的那个)递给 `selectedCell`,不许递 `f.cols`。 +func TestTheCardSeesTheFramesColumns(t *testing.T) { + src, err := os.ReadFile("follow.go") + if err != nil { + t.Fatalf("读不到 follow.go:%v", err) + } + body := string(src) + if !strings.Contains(body, "selectedCell(i, cols)") { + t.Error("viewFollowCardGuarded 没把这一帧的 cols 传给 selectedCell——" + + "换列数那一帧高亮会拿上一帧的列数算「游标在第几格」,落错行") + } + if strings.Contains(body, "selectedCell(i, f.cols)") { + t.Error("viewFollowCardGuarded 又把上一帧的 f.cols 递回给 selectedCell——" + + "换列数那一帧高亮落错行(票 #740 的 P1,别让这一行回来)") + } +} diff --git a/internal/ui/follow_grid_shot_test.go b/internal/ui/follow_grid_shot_test.go index acdb37e5..9af38984 100644 --- a/internal/ui/follow_grid_shot_test.go +++ b/internal/ui/follow_grid_shot_test.go @@ -13,17 +13,19 @@ package ui import ( "os" "testing" - - "github.com/ShellMonster/PudMusic/internal/ui/widgets/list" ) // TestRenderFollowGridShots 把「我的关注」那个网格存成 PNG,深浅各一套。 // // # 为什么摆九个 UP 主 // -// 窗口摆成 **1400 宽**(默认的 1200 只有两列),网格于是是**三列**,九个人排成 -// **三行**——行与行之间那 16 的缝有**两处**可看,列与列之间那 16 的缝有**两处** -// 可看。 +// 窗口摆成 **1400 宽**,网格拿到 1148(≥ 1024 那一档)于是是**五列**—— +// 九个人排成**两行**(5 + 4)。行与行之间那 16 的缝有**一处**可看, +// 列与列之间那 16 的缝有**四处**可看。 +// +// ⚠️ **五列是 follow-slim 那一票的事**:关注卡是人像卡、比歌卡窄,这一页自己 +// 拿一张断点表(`followColumnsFor`,1536/1280/1024/768 → 7/6/5/4), +// 和首页歌卡那张表刻意不同(用户 2026-11-03 参考网易云关注页拍板要更密)。 func TestRenderFollowGridShots(t *testing.T) { dir := os.Getenv("BIU_SHOT_DIR") if dir == "" { @@ -53,10 +55,10 @@ func TestRenderFollowGridShots(t *testing.T) { f := s.followPageOf() waitFrames(t, tt, "人到了", func() bool { return len(f.users) == 9 && f.tagStarted }) // ⚠️ **列数要几帧才稳**:列数是按**上一帧**量出来的容器宽算的 - // (`followPage.width` → `list.GridColumnsFor`),头一帧按一个还没量准 - // 的宽排成 5 列,下一帧量到真的才排成 3 列(理由同 `follow.go` 里 + // (`followPage.width` → `followColumnsFor`),头一帧按一个还没量准 + // 的宽排成 6 列,下一帧量到真的才排成 5 列(理由同 `follow.go` 里 // `keepFollowAnchor` 那段)。不等它稳,拍下来的是**上一档列数**。 - waitFrames(t, tt, "列数稳了", func() bool { return f.cols == list.GridColumnsFor(f.width) }) + waitFrames(t, tt, "列数稳了", func() bool { return f.cols == followColumnsFor(f.width) }) // 指针挪开,别让它压在某一张卡上(悬停会升起来一条操作条)。 tt.Move(30, 30) homeFrame(tt) diff --git a/internal/ui/follow_grid_test.go b/internal/ui/follow_grid_test.go index b6b1a7b2..cd5cb335 100644 --- a/internal/ui/follow_grid_test.go +++ b/internal/ui/follow_grid_test.go @@ -46,18 +46,18 @@ import ( // # 为什么列数要等它稳 // // 列数是按**上一帧**量出来的容器宽算的(`followPage.width` → -// `list.GridColumnsFor`),头一帧按一个还没量准的宽排成 5 列,下一帧量到真的 -// 才排成 4 列(理由同 `follow.go` 里 `keepFollowAnchor` 那段)。不等它稳的话, +// `followColumnsFor`),头一帧按一个还没量准的宽排成 6 列,下一帧量到真的 +// 才排成 5 列(理由同 `follow.go` 里 `keepFollowAnchor` 那段)。不等它稳的话, // 量到的是**上一档列数**摆出来的画面。 // -// ⚠️ **4 列是票 #367 之后的事**:左边 200 宽的分组栏收成页头那个下拉之后, -// 同一个 1400×900 窗口里网格拿到 1148(≥ 1024 那一档),原来是 948(≥ 768 -// 那一档、3 列)。列数跟着容器宽走,所以这一处断言也跟着换。 +// ⚠️ **5 列是 follow-slim 那一票的事**:关注卡是人像卡、比歌卡窄,这一页 +// 自己拿一张断点表(`followColumnsFor`,1536/1280/1024/768 → 7/6/5/4), +// 同一个 1400×900 窗口里网格拿到 1148(≥ 1024 那一档)于是是 5 列。 func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) { b := newUIExplore(t) uiFollowTags(b, map[int]any{0: "默认分组"}) - // 六个 UP 主:窗口摆得下四列(左栏收成下拉之后网格宽 1148 ≥ 1024), - // 于是**两行**(4 + 2)——行间距量一次,列间距量三次。 + // 六个 UP 主:窗口摆得下五列(网格宽 1148 ≥ 1024 那一档), + // 于是**两行**(5 + 1)——行间距量一次,列间距量四次。 uiFollowList(b, 6, []any{ uiFollowUser(11, "第一个 UP"), uiFollowUser(12, "第二个 UP"), uiFollowUser(13, "第三个 UP"), uiFollowUser(14, "第四个 UP"), @@ -71,11 +71,11 @@ func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) { tt.Frame() f := s.followPageOf() waitFrames(t, tt, "人到了", func() bool { return len(f.users) == 6 && f.tagStarted }) - waitFrames(t, tt, "列数稳了", func() bool { return f.cols == list.GridColumnsFor(f.width) }) + waitFrames(t, tt, "列数稳了", func() bool { return f.cols == followColumnsFor(f.width) }) tt.Frame() - if f.cols != 4 { - t.Fatalf("网格宽 %v 应该是 4 列,实际是 %d 列", f.width, f.cols) + if f.cols != 5 { + t.Fatalf("网格宽 %v 应该是 5 列,实际是 %d 列", f.width, f.cols) } if len(f.users) != 6 { t.Fatalf("要六个人才量得出两行,实际是 %d 个", len(f.users)) @@ -85,7 +85,11 @@ func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) { cardW := list.CellWidth(f.width, f.cols) // ① 行与行之间 16。 - for row := 1; row < len(f.users)/f.cols; row++ { + // + // ⚠️ **行数按向上取整算**(6 人 / 5 列 = 2 行):直接 `len/cols` 取整在 + // 不满一行时会少一行——5 列 6 人是 1,于是第二行那一道缝永远量不到。 + rows := (len(f.users) + f.cols - 1) / f.cols + for row := 1; row < rows; row++ { above := followCardBox(t, tt, f.users[(row-1)*f.cols], cardW) below := followCardBox(t, tt, f.users[row*f.cols], cardW) if d := below.Y - (above.Y + above.H); absF(d-16) > 0.5 { @@ -104,16 +108,17 @@ func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) { } } - // ③ 卡里那圈留白还是 16。 + // ③ 卡里那圈留白是 12(follow-slim 那一票把上游的 16 改成了 12, + // 用户 2026-11-03 参考网易云关注页拍板)。 // - // ⚠️ **这一条盯的是「缝是从哪儿来的」**:这一票的 16 是**行与行之间**的, - // 不是把卡里的留白加大凑出来的——留白加大了的话,卡和页面底之间也一起撑开 - // 了,而那不是行间距(票面第 2 条)。 + // ⚠️ **这一条盯的是「缝是从哪儿来的」**:行间距的 16 是**行与行之间**的, + // 不是卡里的留白——留白加大或减小,卡和页面底之间会跟着撑开或收紧, + // 而那不是行间距。 // - // 顺带一提:① 量出来的行缝正好是 16 这件事本身也钉住了它——留白要是被加大 - // 过,行缝就会量成 0。 - if followCardPad != 16 { - t.Errorf("卡里那圈留白是 %v,该是 16——这一票的缝是行间距,不是卡片内边距", followCardPad) + // 顺带一提:① 量出来的行缝正好是 16 这件事本身也钉住了它——留白要是被改 + // 成了 16 以外凑行缝的数,行缝就会量成别的。 + if followCardPad != 12 { + t.Errorf("卡里那圈留白是 %v,该是 12(follow-slim 那一票改的瘦卡留白)", followCardPad) } } diff --git a/internal/ui/follow_test.go b/internal/ui/follow_test.go index 26454444..e525ff37 100644 --- a/internal/ui/follow_test.go +++ b/internal/ui/follow_test.go @@ -1115,8 +1115,9 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) { } // TestTheFollowCardTypographyAndItsHoverBar 钉住关注卡的两处: -// 名字 18、签名 14(上游 `user-card.tsx:84-85` 的 `text-lg` / `text-sm`), -// 以及鼠标移上去才升起来的那条操作条(300ms,`translate-y-20` → 0)。 +// 名字 16、签名 14(上游 `user-card.tsx:84-85` 的 `text-lg` / `text-sm`, +// follow-slim 那一票把名字改成了 `Size16`,用户 2026-11-03 参考网易云关注页 +// 拍板),以及鼠标移上去才升起来的那条操作条(300ms,`translate-y-20` → 0)。 func TestTheFollowCardTypographyAndItsHoverBar(t *testing.T) { b := newUIExplore(t) uiFollowTags(b, map[int]any{0: "默认分组"}) @@ -1136,15 +1137,15 @@ func TestTheFollowCardTypographyAndItsHoverBar(t *testing.T) { // ① 字号。第二张卡的名字不是整行的无障碍名字(那是第一张卡的),所以 // `Find` 拿到的就是名字那一段文字本身;签名一直是唯一的。 // - // 量的是**盒子高**:行高跟着字号走,18 点那行实测 21.2、14 点加 1.4 行高 - // 实测 19.6。取 20.5 / 19.0 两条线——旧值 15 / 12 分别是 17.7 / 16.8, + // 量的是**盒子高**:行高跟着字号走,16 点那行实测 18.8、14 点加 1.4 行高 + // 实测 19.6。取 18 / 19 两条线——旧值 15 / 12 分别是 17.7 / 16.8, // 离得够开。 name, ok := tt.Find("第二个 UP") if !ok { t.Fatalf("画面上找不到名字,文字有 %q", tt.Texts()) } - if name.H < 20.5 { - t.Errorf("名字那一段只有 %.1f 高,want ≥ 20.5(上游是 text-lg,18 点)", name.H) + if name.H < 18 { + t.Errorf("名字那一段只有 %.1f 高,want ≥ 18(16 点那一档实测 18.8)", name.H) } sign, ok := tt.Find("第二个 UP 的签名") if !ok { @@ -1153,6 +1154,12 @@ func TestTheFollowCardTypographyAndItsHoverBar(t *testing.T) { if sign.H < 19 { t.Errorf("签名那一段只有 %.1f 高,want ≥ 19(上游是 text-sm,14 点)", sign.H) } + // ⚠️ **签名只留一行**(follow-slim 那一票:上游 `line-clamp-2` 改成了 + // `MaxLines(1)`,用户 2026-11-03 参考网易云关注页拍板)。两行是约 39, + // 一行是约 19.6——卡 25 那条线,旧值差得够开。 + if sign.H > 25 { + t.Errorf("签名那一段 %.1f 高——MaxLines(1) 下该是约 19.6(用户 2026-11-03 拍板只要一行)", sign.H) + } // ② 操作条平时不在画面上。 if _, ok := tt.Find("取消关注"); ok { diff --git a/internal/ui/widgets/list/grid.go b/internal/ui/widgets/list/grid.go index 0ee6c914..95288940 100644 --- a/internal/ui/widgets/list/grid.go +++ b/internal/ui/widgets/list/grid.go @@ -87,14 +87,16 @@ const GridGap = gridGap // 用途:给 `Grid` 之外的网格画格子(`internal/ui` 的稍后再看 / 历史记录那一档)。 func CellWidth(w float32, cols int) float32 { return cellWidth(w, cols) } -// GridColumnsFor 是容器有 w 那么宽时排几列。 +// GridColumnsFor 是容器有 w 那么宽时排几列(**歌卡网格**的表)。 // // ⚠️ **判据是容器的宽,不是窗口的**——页面左边还有侧栏,窗口宽不等于网格宽。 // 用它的页面要量自己那个列表元素的盒子(`Element.Bounds()` 量的是**上一帧** // 的盒子,和 `Grid.View` 末尾那一句同一个节奏)。 // -// 用途是给 `Grid` 之外的网格用(关注页的用户卡,见 `internal/ui/follow.go`)。 -// 两者必须用同一个函数,否则用户在同一窗口宽度下会看到两种列数。 +// ⚠️ **这张表是歌卡的密度**(封面 140 高、横长形)。人像卡那种更密的网格用 +// 页面自己的表——关注页是 `followColumnsFor`(`internal/ui/follow.go`), +// 同一窗口宽度下刻意比这里多一列(用户 2026-11-03 参考网易云关注页拍板)。 +// 别把那张表改回这一个:列数不同是**拍板**,不是不一致。 func GridColumnsFor(w float32) int { return columnsFor(w) } // columnsFor 是容器有 w 那么宽时排几列。