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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 77 additions & 16 deletions internal/ui/follow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 是那个惯用的写法。
Expand All @@ -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`)。
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -1107,17 +1163,22 @@ 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("…")
}
// ⚠️ **签名空的时候整块不画**: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)
})
Expand Down
155 changes: 155 additions & 0 deletions internal/ui/follow_cols_test.go
Original file line number Diff line number Diff line change
@@ -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,别让这一行回来)")
}
}
18 changes: 10 additions & 8 deletions internal/ui/follow_grid_shot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading