diff --git a/internal/e2e/follow_test.go b/internal/e2e/follow_test.go
new file mode 100644
index 00000000..2778bff0
--- /dev/null
+++ b/internal/e2e/follow_test.go
@@ -0,0 +1,274 @@
+//go:build e2e
+
+package e2e
+
+// 「我的关注」页头分组下拉的真窗口回归(票 #367 的那一档)。
+//
+// # 为什么要有这一族
+//
+// 这一票把左边 200 宽的分组栏收成页头右上角的一枚下拉,而界面层那一族
+// (`internal/ui/follow_test.go` / `ticket213_test.go`)造的是**假外壳**——
+// 它断不到两件事:
+//
+// 1. **下拉的弹层在真窗口上真的摆开**(弹层是**覆盖层**,无头 Tester 那一层
+// 和真窗口的 hit chain / 覆盖层合成是两条路——`SelectBase` 的 `Popup` 走
+// `coreOverlay`,真窗口上它盖在内容区上头还是压在底下,只有真窗口验得了)。
+// 2. **Esc 真的把弹层收掉**(`OverlayShortcut` 那条路在无头里是一条 `Key`
+// 调用,在真窗口上是「覆盖层先吃这个键」的一整条分发)。
+//
+// # 怎么跑
+//
+// BIU_RUN_MYGO_E2E=1 go test -tags e2e ./internal/e2e/ -run TestTheFollowGroupSelect
+//
+// ⚠️ **要一个真窗口**:它开一个窗口并驱动它,所以本机得能开窗口。
+//
+// ⚠️ **它跑的是整个应用**(不是 fixture):要验的就是这个应用的那一枚下拉。
+//
+// ⚠️ **它只断「弹层开得了、Esc 收得掉」这两件窗级的事**——「哪一项摆什么
+// 颜色、分组名长怎么截、三态怎么画」都归界面层那一族和视觉审查,这里不碰
+// (理由同 `minicurtain_test.go` 顶上那一段:E2E 断机制和几何,不断像不像)。
+
+import (
+ "os"
+ "testing"
+ "time"
+)
+
+// TestTheFollowGroupSelect 断**页头那枚「选择关注分组」在真窗口上点得开、
+// Esc 收得掉**。
+//
+// # 为什么判据是「那一枚 Label 在不在」而不是「弹层的某一项在不在」
+//
+// 弹层里的项是**动态**的(「全部关注(N)」/ 分组名 / 「还没有分组」/「正在
+// 加载分组…」/「分组没拉出来」——哪一条出现取决于 B 站那一拉回什么),而
+// E2E 起的是**真应用**:种的是假凭据(见 `seedFakeSession`),分组那一拉真
+// 发出去真被拒,弹层里画的是「分组没拉出来」——那也是「弹层摆开了」。所以
+// **开不开**的判据只用「弹层里有东西出现」,而**收没收掉**用「弹层独有的
+// 那几句都不在了」。
+//
+// ⚠️ **「选择关注分组」那枚 Label 本身每一帧都在**(它是触发器的无障碍名,
+// 弹层收着时也在),所以不能拿「它出现了」当弹层开了——得拿**弹层里独有的
+// 那句话**(三态里的任何一句都算)。
+func TestTheFollowGroupSelect(t *testing.T) {
+ if os.Getenv("BIU_RUN_MYGO_E2E") != "1" {
+ t.Skip("设置 BIU_RUN_MYGO_E2E=1 才运行真窗口 E2E")
+ }
+
+ // ⚠️ **要先种一份假登录态**:「我的关注」标了 `HideInMenuWhenLoggedOut`,
+ // 没凭据时侧栏里根本没有它(见 `startRealAppWith` 那一段)。
+ client, stop := startRealAppWith(t, true)
+ defer stop()
+ window := firstContentWindow(t, client)
+
+ // ⚠️ **先把窗口催出一帧**:`act` 按 key 找人走的是**上一帧留下的元素树**
+ // (`protocol.go` 的 act → `d.tree`),而 occluded 的窗一帧都没画过时那棵
+ // 树是空的——问出来的「没有那一项」其实是「还没有树」。`pumpFrame` 逼一帧
+ // 再走(理由同 `minicurtain_test.go` 那一处)。
+ pumpFrame(t, client, window)
+
+ // 先走到「我的关注」:侧栏那一项的 key 是 `side-item:/follow`
+ //(`sidebar.go` 的 `side-item:` + `rt.Match`)。
+ //
+ // ⚠️ **用 `act` 的 press 按,不用 `input` 的 click**:侧栏那一项是整个
+ // 可点的一块,`press` 走无障碍那条路——它比「点在某个坐标上」更贴近
+ // 「用户按了这一项」,而且不随窗口尺寸和侧栏折叠态漂。
+ //
+ // ⚠️ **等的是「页头那一枚出现了」而不是「侧栏那一项被点了」**:换路由
+ // 是异步的,那一帧可能还没画——`waitForTree` 是等「出现了」,不是
+ // 「等了几帧」(`docs/agents/testing.md` 的判据)。
+ // ⚠️ **按 id 走 `act` 的 press**:`side-item:` 那一格自己没进树(树里只有
+ // 它里面那枚按钮的 Label),而 `Bounds` 在这个应用里全是零(accessibility
+ // 不报位置),坐标点不着——所以只能按 **id**(`act` 认 id 不认 Label)。
+ // 而那一枚按钮的 `actions` 里有 `press`(它是 `flagClickable` 的),
+ // 按 id 发过去就真的是「按了这一项」。
+ id, ok := e2eNodeIDByLabel(t, client, window, "我的关注")
+ if !ok {
+ t.Fatalf("侧栏里没有「我的关注」那一项:\n%s", treeDump(t, client, window))
+ }
+ err := automationCall(client, 500, "act", map[string]any{
+ "window": window,
+ "id": id,
+ "action": "press",
+ }, nil)
+ if err != nil {
+ t.Fatalf("按侧栏「我的关注」:%v", err)
+ }
+ if !waitForTree(t, client, window, "选择关注分组", 20*time.Second) {
+ t.Fatalf("进关注页之后没有「选择关注分组」那一枚:\n%s", treeDump(t, client, window))
+ }
+
+ // 点开下拉。
+ //
+ // ⚠️ **触发器也是按 id 发 press**:它是 `SelectBase` 的 Trigger(
+ // `flagClickable`,`actions` 里有 `press`),挂在「选择关注分组」那个
+ // Label 上(`follow.go` 的 `trigger.Label(...)`)——和侧栏那一项同一
+ // 条路。
+ if !followSelectOpen(t, client, window) {
+ t.Fatalf("点开「选择关注分组」之后弹层没摆开:\n%s", treeDump(t, client, window))
+ }
+
+ // Esc 收掉它。
+ //
+ // ⚠️ **Esc 走 `input` 的 key**(`OverlayShortcut` 那条路),不走菜单:
+ // 「收弹层」是覆盖层自己的事,不是应用菜单的事。
+ err = automationCall(client, 501, "input", map[string]any{
+ "window": window,
+ "events": []map[string]any{
+ {"type": "key", "key": "Escape"},
+ },
+ }, nil)
+ if err != nil {
+ t.Fatalf("按 Esc:%v", err)
+ }
+ // 弹层里那句话没了才算收掉(等「消失」也要等,不能问一次就当收了)。
+ //
+ // ⚠️ **「全部关注」不在 gone 名单里**:它同时是触发器收起时写的字(停在
+ // 「全部关注」那一格时 `followPaneTitle` 就回它)——弹层收掉之后它**还在**
+ // 树上,拿它当「弹层独有的字」这一条就永远等不到它消失。
+ if !waitForTreeGone(t, client, window, "选择关注分组", 15*time.Second, "正在加载分组", "分组没拉出来", "还没有分组") {
+ t.Fatalf("按了 Esc 之后弹层没收掉:\n%s", treeDump(t, client, window))
+ }
+}
+
+// followSelectOpen 点开页头那枚「选择关注分组」,等弹层里出现**任何一句
+// 弹层独有的字**(三态里的哪一句都行——假凭据下分组那拉真发真败,画的是
+// 「分组没拉出来」,那也算弹层摆开了)。
+func followSelectOpen(t *testing.T, client *automationClient, window int) bool {
+ t.Helper()
+ id, ok := e2eNodeIDByLabel(t, client, window, "选择关注分组")
+ if !ok {
+ t.Logf("树里没有按得动的「选择关注分组」")
+ return false
+ }
+ err := automationCall(client, 502, "act", map[string]any{
+ "window": window,
+ "id": id,
+ "action": "press",
+ }, nil)
+ if err != nil {
+ t.Logf("按「选择关注分组」那一枚:%v", err)
+ return false
+ }
+ // 弹层里独有的任何一句:三态里的哪一句都算「摆开了」。
+ //
+ // ⚠️ **「全部关注」不在名单里**:它同时是**触发器收起时**写的字(停在
+ // 「全部关注」那一格时 `followPaneTitle` 就回它)——拿它当「弹层开了」
+ // 的判据,会在弹层还没开时就命中(那一枚每帧都在)。这里只能靠**三态
+ // 独有的**那三句。
+ for _, want := range []string{"正在加载分组", "分组没拉出来", "还没有分组"} {
+ if waitForTree(t, client, window, want, 8*time.Second) {
+ return true
+ }
+ }
+ return false
+}
+
+// e2eNode 是元素树里的一项(只取这一族用得上的那几个字段)。
+type e2eNode struct {
+ ID any `json:"id"`
+ Parent any `json:"parent"`
+ Label string `json:"label"`
+ Actions []string `json:"actions"`
+}
+
+func hasE2EAction(list []string, want string) bool {
+ for _, s := range list {
+ if s == want {
+ return true
+ }
+ }
+ return false
+}
+
+// e2eNodeIDByLabel 是「树里写着这个词、且按得动」的那一项的 id。
+//
+// ⚠️ **认 id 不认 Label**:`act` 只接 id 或 key,而「选择关注分组」「我的
+// 关注」那几格自己没上 Key(`side-item:` 那格是容器,不进树)——Label 是
+// 名字、key 是钥匙,名字对得上不等于钥匙开得了。按 id 是唯一能按到那一枚的
+// 路。
+//
+// ⚠️ **为什么要挑 `actions` 里有 `press` 的**:Label 是画上去的字,字自己
+// 也在树里占一项(`Text`),而**字不吃 press**——给它发 press 等于按了一张
+// 图片。可点的是**外面那一格**(`flagClickable` 的那一个),它的 `actions`
+// 里才有 `press`。
+//
+// ⚠️ **`Parent` 记的是父的 id,不是下标**(`protocol.go` 塞的是
+// `t.Nodes[n.Parent].ID`)——往父链上找要**按 id 找父**,别把 `Parent`
+// 当下标用。
+func e2eNodeIDByLabel(t *testing.T, client *automationClient, window int, label string) (any, bool) {
+ t.Helper()
+ var tree struct {
+ Nodes []e2eNode `json:"nodes"`
+ }
+ if err := automationCall(client, 503, "tree", map[string]any{"window": window}, &tree); err != nil {
+ return nil, false
+ }
+ byID := make(map[any]int, len(tree.Nodes))
+ for i, n := range tree.Nodes {
+ byID[n.ID] = i
+ }
+ for _, n := range tree.Nodes {
+ if n.Label != label {
+ continue
+ }
+ // 理想情况:写着这个词的那一项自己就按得动(按钮把 Label 带在身上)。
+ if hasE2EAction(n.Actions, "press") {
+ return n.ID, true
+ }
+ // 不然它是一个 Text——往父链上找第一个有 press 的,那才是「按这里」。
+ for p := n.Parent; p != nil; {
+ pi, ok := byID[p]
+ if !ok {
+ break
+ }
+ pn := tree.Nodes[pi]
+ if hasE2EAction(pn.Actions, "press") {
+ return pn.ID, true
+ }
+ p = pn.Parent
+ }
+ }
+ return nil, false
+}
+
+// waitForTreeGone 等那几句**弹层独有的字**都从树里消失(触发器自己的
+// Label 不算——它收着弹层时也在)。
+//
+// ⚠️ **它和 `waitForTree` 是同一件事的反面**:Esc 是异步的,弹层收掉是
+// 下一帧的事——问一次就断定收没收到,是「等了几帧」那种判据。
+func waitForTreeGone(t *testing.T, client *automationClient, window int, keep string, timeout time.Duration, gone ...string) bool {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for {
+ var tree struct {
+ Nodes []struct {
+ Label string `json:"label"`
+ } `json:"nodes"`
+ }
+ if err := automationCall(client, 504, "tree", map[string]any{"window": window}, &tree); err != nil {
+ return false
+ }
+ labels := make(map[string]bool, len(tree.Nodes))
+ for _, n := range tree.Nodes {
+ labels[n.Label] = true
+ }
+ // 触发器自己还在(它是「收起那一格」,收着弹层时也在)。
+ if !labels[keep] {
+ return false
+ }
+ anyGone := false
+ for _, g := range gone {
+ if labels[g] {
+ anyGone = true
+ break
+ }
+ }
+ if !anyGone {
+ return true
+ }
+ if time.Now().After(deadline) {
+ return false
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+}
diff --git a/internal/e2e/menu_test.go b/internal/e2e/menu_test.go
index c49085ec..eec94e38 100644
--- a/internal/e2e/menu_test.go
+++ b/internal/e2e/menu_test.go
@@ -127,6 +127,22 @@ func TestTheApplicationMenu(t *testing.T) {
// ⚠️ **编的是根目录那个 main**,不是 `fixture/` 里的那个——要验的就是
// **这个应用的菜单**,而菜单是应用级的。
func startRealApp(t *testing.T) (*automationClient, func()) {
+ return startRealAppWith(t, false)
+}
+
+// startRealAppWith 是 `startRealApp` 的内里,`seedSession` 说要不要先往
+// E2E 那份数据目录里塞一份**假**登录态。
+//
+// ⚠️ **为什么要有这一档**:有几页只在「登录了」才出现(侧栏的「我的关注 /
+// 稍后再看 / 历史记录」标了 `HideInMenuWhenLoggedOut`),而 E2E 起的是真
+// 应用——没凭据时它们压根不在菜单里,连路由都进不去。塞一份假 `session.json`
+// 之后应用当「已登录」跑(`fileStore.Load` 只看 SESSDATA 在不在),而那三页
+// 就都能进了。
+//
+// ⚠️ **假的是「登录态」,不是「B 站」**:接口还是会真发(假的 SESSDATA 会被
+// B 站拒),所以「列表拉到没有」走的是**真失败**那条路——而那一档的弹层
+// 三态(「分组没拉出来」)正是要验的。
+func startRealAppWith(t *testing.T, seedSession bool) (*automationClient, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
socketDir, socket, err := makeAutomationSocket()
@@ -135,6 +151,11 @@ func startRealApp(t *testing.T) (*automationClient, func()) {
t.Fatalf("创建自动化 socket:%v", err)
}
binary := filepath.Join(socketDir, "biugo-e2e")
+ // ⚠️ **先种凭据再启动**:`fileStore` 读的是启动那一刻的数据目录——
+ // 进程起来之后再写就来不及了(它只在启动时 Load 一次)。
+ if seedSession {
+ seedFakeSession(t, filepath.Join(socketDir, "data"))
+ }
var buildOut, buildErr bytes.Buffer
build := exec.Command("go", "build", "-tags", "e2e", "-o", binary, ".")
build.Dir = repoRoot(t)
@@ -173,6 +194,40 @@ func startRealApp(t *testing.T) (*automationClient, func()) {
return client, stop
}
+// seedFakeSession 往 E2E 那份数据目录里写一份**假** `session.json`。
+//
+// ⚠️ **只让 `fileStore.Load` 当「已登录」**:判据是 `SESSDATA` 在不在
+// (`credentials.go` 的 `LoggedIn`)——值是不是真的能过 B 站不验(E2E 里
+// 接口真发、真被拒,而那正是「拉失败」那条路)。Cookies 里那三条(SESSDATA /
+// bili_jct / DedeUserID)和顶上的同名字段对齐,不然 `Normalize` 会以 Cookies
+// 为准把上面那三个字段抹掉。
+func seedFakeSession(t *testing.T, dataDir string) {
+ t.Helper()
+ dir := filepath.Join(dataDir, "credentials")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatalf("建凭据目录:%v", err)
+ }
+ session := map[string]any{
+ "SESSDATA": "e2e-fake-sessdata",
+ "bili_jct": "e2e-fake-jct",
+ "DedeUserID": "2333",
+ "savedAt": time.Now().UTC().Format(time.RFC3339),
+ "cookies": []map[string]any{
+ {"name": "SESSDATA", "value": "e2e-fake-sessdata", "domain": ".bilibili.com", "path": "/"},
+ {"name": "bili_jct", "value": "e2e-fake-jct", "domain": ".bilibili.com", "path": "/"},
+ {"name": "DedeUserID", "value": "2333", "domain": ".bilibili.com", "path": "/"},
+ },
+ }
+ blob, err := json.Marshal(session)
+ if err != nil {
+ t.Fatalf("序列化假登录态:%v", err)
+ }
+ // 权限收 0600:`fileStore.readFile` 会把比它宽的权限判成「被放宽」。
+ if err := os.WriteFile(filepath.Join(dir, "session.json"), blob, 0o600); err != nil {
+ t.Fatalf("写假登录态:%v", err)
+ }
+}
+
// windowTitle 是那个内容窗口的标题。
//
// ⚠️ **第一栏(应用菜单)的名字就是窗口标题**(框架的 `RoleAppMenu` 用
diff --git a/internal/ui/currentitem_test.go b/internal/ui/currentitem_test.go
index bba21132..c34c2026 100644
--- a/internal/ui/currentitem_test.go
+++ b/internal/ui/currentitem_test.go
@@ -152,30 +152,9 @@ func TestTheCurrentItemIsFlatEverywhere(t *testing.T) {
name = "深色"
}
t.Run(name, func(t *testing.T) {
- t.Run("关注页分组", func(t *testing.T) {
- b := newUIExplore(t)
- uiFollowTags(b, map[int]any{-10: "特别关注"})
- uiFollowList(b, 3, []any{uiFollowUser(11, "第一个 UP")})
- uiFollowSearch(b, "", nil)
- s, tt, _ := newSpaceShell(t, b, true, "77", systemIsDark(dark))
- s.navigate(followPath)
- tt.Frame()
- f := s.followPageOf()
- waitFrames(t, tt, "分组到了", func() bool { return f.tagStarted })
- tt.Frame()
-
- cur, ok := tt.Find("全部关注(3)")
- if !ok {
- t.Fatalf("左栏没有「全部关注(3)」那一格,文字有 %q", tt.Texts())
- }
- sib, ok := tt.Find("特别关注(1)")
- if !ok {
- t.Fatalf("左栏没有「特别关注(1)」那一格,文字有 %q", tt.Texts())
- }
- theme := BuildTheme(app.DefaultSettings(""), dark)
- assertCurrentIsFlat(t, currentSpot{cur, sib, "关注页分组",
- flatSolidCurrentText(theme)}, tt.Image(), dark)
- })
+ // ⚠️ **关注页原来有第三处(左边那列分组)——#367 改版把它收成页头
+ // 下拉了**:下拉里「当前值」是淡底(`enumSelect` 的口径),不是导航列
+ // 上那种「当前在哪」的实心主色信标,所以它不再归这一条管。
t.Run("动态页作者栏", func(t *testing.T) {
b := newUIExplore(t)
diff --git a/internal/ui/follow.go b/internal/ui/follow.go
index bd4deb81..959aeca4 100644
--- a/internal/ui/follow.go
+++ b/internal/ui/follow.go
@@ -52,7 +52,6 @@ import (
"github.com/ShellMonster/PudMusic/internal/notice"
"github.com/ShellMonster/PudMusic/internal/ui/rowkey"
- "github.com/ShellMonster/PudMusic/internal/ui/widgets/flat"
"github.com/ShellMonster/PudMusic/internal/ui/widgets/leading"
"github.com/ShellMonster/PudMusic/internal/ui/widgets/list"
"github.com/ShellMonster/PudMusic/internal/ui/widgets/motion"
@@ -69,8 +68,16 @@ import (
// 而且错得很安静——这一页退回占位页,没人看得出来(理由同 `laterPath`)。
const followPath = "/follow"
-// followPaneW 是左边那一列的宽度。上游是 `w-50`(200)。
-const followPaneW = 200
+// followGroupSelectH / followGroupSelectMinW 是右上角那个分组下拉的尺寸。
+//
+// ⚠️ **它得认得出「是一个下拉」**:收起那一格只写当前那一组的名字 + 一枚箭头,
+// 比搜索框矮一截(`settingsSelectH` 是 32,页头里和它对齐);而宽度按当前那一格
+// 的名字走(分组名是用户自己起的,可长可短),设一个下限不让它缩成一格箭
+// 头。
+const (
+ followGroupSelectH = settingsSelectH
+ followGroupSelectMinW = 120
+)
// followCardSide 是一张卡上头像的边长。上游是 `h-32 w-32`(128)。
const followCardSide = 128
@@ -122,14 +129,14 @@ var followActionBarKey = struct{ followActionBar bool }{}
// (`follow-list/index.tsx:296`)。
const followSearchLabel = "搜索关注"
-// followTabAll 是左边第一格「全部关注」的身份。
+// followTabAll 是下拉里第一项「全部关注」的身份。
//
// ⚠️ **用 `all` 这个串而不是空串**:空串在「分组列表还没拉到」那一档也是空串,
// 而那一档要画的是「还没拉到」,不是「全部关注」。两个都空的话那一帧画出来
-// 第一格没有名字,而用户会以为整个分组列表是空的。
+// 那一项没有名字,而用户会以为整个分组列表是空的。
const followTabAll = "all"
-// followTagTab 是分组那一格的身份。分组 id 可正可负(B 站固定有 -10 特别关注
+// followTagTab 是分组那一项的身份。分组 id 可正可负(B 站固定有 -10 特别关注
// 和 0 默认分组),所以身份是带前缀的串而不是那个数本身。
func followTagTab(tagID int) string { return "tag:" + strconv.Itoa(tagID) }
@@ -191,7 +198,7 @@ var followEmptyTag = emptyState{
// ⚠️ **只由界面线程读写**,所以里面没有一个锁;后台那一条线只往 box 那个带锁
// 的信箱里投,投完叫醒界面(铁律三)。
type followPage struct {
- // ── 左边那一列 ──
+ // ── 分组那一摊 ──
//
// tags 是关注分组。tagStarted / tagErr 是这一摊自己的状态——它和右边那一格
// 是两次独立的请求,各拉各的,各失败各的。
@@ -215,13 +222,9 @@ type followPage struct {
pager upPager
- // state 是网格的行状态,paneState 是左边那一列的。建一次存着,每帧用
- //(铁律四:换列数是布局变了,每帧新建等于每帧跳回顶上)。
- //
- // ⚠️ **两个 ListState 不能共用**:MyGo 会在「同一个 ListState 被两个 List
- // 显示」时 panic,而共用还会把两边的滚动位置搅在一起。
- state ui.ListState
- paneState ui.ListState
+ // state 是网格的行状态。建一次存着,每帧用(铁律四:换列数是布局变了,
+ // 每帧新建等于每帧跳回顶上)。
+ state ui.ListState
// rowKeys 记着网格每一行的身份(**预先装箱好的那一份**)。
//
// ⚠️ 装箱那一步是要分配的(`runtime.convTstring`),现算的话每帧每行一次
@@ -371,13 +374,11 @@ func (f *followPage) resetUsers() {
// ⚠️ **外面已经包了一层 recover**(`router.go` 的 `Shell.page`)。
// ⚠️ **问 `r` 不要问 `c.Router()`**(理由同 `upspace.go`)。
//
-// ⚠️ **这一页没有页标题,而名字是有的**(票 #127):它写在**左边那一列顶上**
-// ——`followPaneTitle` 给出「全部关注」/ 那一组的名字 / 「我的关注」
-// (理由见那个函数)。上游那一页的 `
` 给的也是同一句话
-// (`src/pages/follow-list/index.tsx:206`:`全部关注` / 分组名 / `我的关注`),
-// 只是上游把它摆在右上角、我们摆在左边那一列的头顶上——那一列本来就是分组
-// 列表,「现在是哪一组」和「哪一列」是同一件事。
-// 顶栏那一处页名票 #127 去掉之后,这一页的名字就只剩这一处,**不重复**。
+// ⚠️ **这一页的页名是页头那个 24 号的「我的关注」**(`viewFollowHeader`),而
+// 「现在看的是哪一组」是页头右上角那枚下拉自己的事(`followPaneTitle` 给
+// 「全部关注」/ 那一组的名字 / 「我的关注」——分组还没拉到时的兜底)。上游
+// 那一页把同一句话塌成一个 ``(`src/pages/follow-list/index.tsx:206`)——
+// 我们这里是**两个字**:页名不换,下拉跟着组换。
func (s *Shell) viewFollow(c *ui.Context, _ *ui.Route) {
// 🔴 铁律二:每帧第一件事就是装主题,而且必须在最开头。
s.applyFrameTheme(c)
@@ -404,14 +405,12 @@ func (s *Shell) viewFollow(c *ui.Context, _ *ui.Route) {
return
}
- ui.Row(c).Key("page:" + followPath).Grow(1).FillHeight().Children(func() {
- s.viewFollowPane(c, f)
- ui.Column(c).Key("follow-body").Grow(1).FillHeight().Padding(space.Px16).
- Gap(space.Px8).Children(func() {
+ ui.Column(c).Key("page:"+followPath).Grow(1).Padding(space.None, pagePadX).Gap(space.Px8).
+ Children(func() {
+ s.viewFollowHeader(c, f)
s.viewFollowSearch(c, f)
s.viewFollowGrid(c, f)
})
- })
// 「回到顶部」那一枚。滚过 400 像素才出现(上游的数,见 `widgets/list` 的
// `BackToTop`)。
@@ -480,140 +479,159 @@ func (f *followPage) scrollTop() {
f.state.ScrollTo(0, ui.Start)
}
-// viewFollowPane 是左边那一列:标题 + 「全部关注(N)」+ 每个分组一格。
-//
-// # 这一列是**侧栏的邻居**:画法照侧栏那一套(票 #367)
-//
-// 这一列紧挨着侧栏摆着,用户一眼就要看出它们是同一件东西的两级。所以这一列
-// 的每一处都取侧栏那一份,不另写一份:
-//
-// - **底色用方案的 `sidebar`**(`Shell.sidebarBackground`,和侧栏那一栏同一个
-// 出口)——于是侧栏 + 这一列是同一片「导航区」,内容区是另一片。
-// - **选中那一格是实心主色底**(`flatSolidCurrent`)——
-// 和侧栏菜单当前项**同一个函数**,所以两个色值逐字节相同。
-// - **没选中的是次要色**:这一列是这一页里的第二层导航,选中那一格才是要看的
-// 那一格;次要色让「现在在哪」一眼看得出来。
-// - **行高、字号、内边距**照侧栏收藏夹那一行(`viewFavoriteRow`):14 的字、
-// 上下 6 的内边距、行与行之间 2 的缝。
-// ⚠️ **侧栏的「菜单项」是 13**(框架的默认字号,macOS 上 13)——那是**带图标**
-// 的那一族;这一列是**纯文字**的行,和侧栏的收藏夹行同族,所以是 14
-// (DESIGN.md §二的正文档;「用户动态」那一页的作者栏也是 14)。
-// ⚠️ **左右不一样**:侧栏收藏夹那一行的**左**内边距是 20(票 #371:那一列
-// 的字在 36,因为分组标题左边有一枚箭头图标),而这一列的字在 16——这一列
-// 自己就贴着内容区的左边沿,用不着给谁让位。
-// - **圆角走主题的 `Radius`**,底色、字色一个都不写死——方案换了整套跟着换。
-//
-// ⚠️ **左边的行和右边的格是两个 `ListState`,不能共用**(MyGo 会在同一个
-// ListState 被两个 List 显示时 panic)。
-func (s *Shell) viewFollowPane(c *ui.Context, f *followPage) {
+// viewFollowHeader 是页头那一行:左边标题 + 关注总数,右边分组下拉。
+//
+// 上游 `follow-list/index.tsx:206` 的 `` 在这一页是「全部关注 / 分组名 /
+// 我的关注」,而**我们页头一直写「我的关注」**——「现在在哪一组」是右上角那个
+// 下拉自己的事(它的触发器写着当前那一格的名字),不是页头的事。上游那一支
+// 在「停在某一组」和「停在全部」之间换名字,而我们这一页的名字本来就不换。
+//
+// 布局照 `later.go` 的 `viewLaterHeader`:标题在左、右边一枚控件,`Spacer`
+// 吃掉中间的空白。
+func (s *Shell) viewFollowHeader(c *ui.Context, f *followPage) {
t := c.Theme()
- // 右缘一条分界线,照上游 `follow-list/index.tsx:204` 的 `border-r`
- // (`border-divider/40`)。
- ui.Column(c).Key("follow-pane").Width(followPaneW).FillHeight().
- Padding(space.Px8, space.Px8).Gap(space.Px8).
- // 🔴 **底色是方案的 `sidebar`,不是页面底色**:这一列和侧栏是邻居,
- // 两栏同色才是一家人(理由见这一段的抬头)。
- Background(s.sidebarBackground(t)).
- BorderWidth(0, 1, 0, 0).BorderColor(t.Border.Alpha(paneDividerA)).Children(func() {
- // ⚠️ **失败也要说失败**:`tagErr` 有值的时候下面那一支已经把分组列表
- // 换成了错误提示,而标题还在——用户点「重试」时看得出自己重试的是
- // 哪一摊。
- //
- // ⚠️ **这一句是「这一栏叫什么」,不是页标题**:所以它走**侧栏分组标题
- // 那一档**(12 / 500 / 次要色),而不是 14 的正文档——改之前它是 14 的
- // 正文色,而它下面那些选项是 13 的字,两样挨在一起、字号也差一个字,
- // 用户会把「这一栏叫什么」和「这一栏里的选项」当成同一层。
- ui.Text(c, followPaneTitle(f)).FontSize(scale.Font(c, scale.Size12)).
- FontWeight(weightMedium).TextColor(t.TextMuted).
- PaddingX(space.Px8).MaxLines(1).Ellipsis("…")
- // ⚠️ **「拉失败了」必须排在「空」前面**:反过来的话第一页失败时左边
- // 是一片「还没有分组」,而 `tagStarted` 仍是 false、`tagErr` 非 nil,
- // 切走再切回来也不会重拉——用户会以为他真的一个分组都没建过。
- if !f.tagStarted && f.tagErr != nil {
- s.viewFollowPaneFailed(c, f)
- return
- }
- // 🔴 **「没拉到」和「一个分组都没有」是两件事**,界面上是两句话:
- // 前者要按重试,后者不用(那是真的没有分组,B 站那会儿返回空数组)。
- if !f.tagStarted {
- ui.Text(c, followPaneLoading).FontSize(scale.Font(c, scale.Size12)).
- TextColor(t.TextMuted).PaddingX(space.Px8)
- return
+ ui.Row(c).Key("follow:page-title").FillWidth().AlignItems(ui.Center).Children(func() {
+ ui.Text(c, "我的关注").FontSize(scale.Font(c, scale.PageTitle))
+ // 总数是淡一档的数字徽章,跟在标题后面(上游 `index.tsx:224` 的
+ // `allCount ? \`(${allCount})\` : ""`)。
+ if f.total > 0 {
+ ui.Text(c, strconv.Itoa(f.total)).FontSize(scale.Font(c, scale.Size14)).
+ TextColor(t.TextMuted).Padding(space.Px2, space.Px6)
}
- // ⚠️ Key 挂在外面的 Column 上:List 是自己管状态的控件,事后再挂 key
- // 会 panic(理由同 `dynamicfeed.go`)。
- //
- // ⚠️ **没有分组的时候这一格不 Grow**:Grow 是「吃掉剩下的空间」,它一长,
- // 后面那句「还没有分组」就被顶到这一列的最底下,离它该解释的那一格十万
- // 八千里(而那一格只有一行,压根不需要滚动)。
- groups := ui.Column(c).Key("follow-pane-list").FillWidth()
- if len(f.tags) > 0 {
- groups = groups.Grow(1).FillHeight()
- }
- groups.Children(func() {
- // 登记「内容区里当前那一个能被方向键走位的」(票 #245 的 E4)。
- // ⚠️ **分组栏先登记、网格后登记**,于是网格赢——它是这一页的主内容。
- s.registerContentFocus(list.HandleFocus(&f.paneState.Handle))
- // ⚠️ **行与行之间 2 的缝**:侧栏的菜单列和收藏夹列都是这一档
- // (`sidebar.go` / `sidebar_favorites.go` 的 `Gap(space.Px2)`)。
- pane := ui.List(c, &f.paneState, len(f.tags)+1).
- Grow(1).FillHeight().Gap(space.Px2).
- Rows(func(r ui.ListRow) { s.viewFollowPaneRow(r.Context, f, r.Index) })
- s.trackScrollbar(pane)
- })
- // 🔴 **「一个分组都没有」要有一句话**:什么都不摆的话用户以为这一列
- // 没拉出来(而它确实拉出来了,只是 B 站那会儿返回空数组)。
- // 抄侧栏收藏夹那一句的写法(`viewFavoritesEmptyLine`):12 的次要色。
- if len(f.tags) == 0 {
- ui.Text(c, followPaneNoTags).FontSize(scale.Font(c, scale.Size12)).
- TextColor(t.TextMuted).PaddingX(space.Px8)
- }
+ ui.Spacer(c)
+
+ s.viewFollowGroupSelect(c, f, t)
})
}
-// followPaneLoading 是「分组还在拉」那一句话。
+// viewFollowGroupSelect 是右上角那个「全部关注 ▾」下拉。
//
-// ⚠️ **它是「还在拉」,不是「没有」**:这一档和「还没有分组」是两句话,
-// 混起来用户会以为自己一个分组都没建过(而他只是还没等到)。
-const followPaneLoading = "正在加载分组…"
-
-// followPaneNoTags 是「一个分组都没有」那一句话。
+// # 为什么是它替掉左边那一栏
//
-// ⚠️ **和侧栏那一句(「还没有收藏夹」)是同一个写法**:12 的次要色,一句话,
-// 不摆插画——这一列只有 200 宽,插画摆不下,而这句话要说的事情很小。
-const followPaneNoTags = "还没有分组"
-
-// viewFollowPaneFailed 是「分组没拉出来」那一档:一句结论 + 原始错误 + 重试。
+// 原来左边 200 宽 × 全高的一栏只干「选组」一件事(票 #367),而分组是用户自己
+// 建的,想看有哪些分组是一个主动动作——所以它降为页头里的一枚下拉,内容区
+// 一行顶到窗口右缘。网易云那张参考图里「分组」也收在页头,不是一栏。
//
-// # 三样东西各是各的
+// # 收起那一格写的是**当前那一格**的名字
//
-// - **结论**("分组没拉出来"):12 的正文色——这一档说的是「出了什么事」。
-// - **原始错误**:12 的次要色,最多两行——给用户复现时用(和
-// `viewLoadError` 那一档同一个做法)。
-// - **重试**:一枚扁平按钮,**左对齐**——这一列里所有东西都是左对齐的,
-// 而框架的按钮默认把内容居中,居中的那一枚和上面那两句话不在一条竖线上。
-func (s *Shell) viewFollowPaneFailed(c *ui.Context, f *followPage) {
- t := c.Theme()
- ui.Text(c, "分组没拉出来").FontSize(scale.Font(c, scale.Size12)).
- TextColor(t.Text).PaddingX(space.Px8)
- ui.Text(c, f.tagErr.Error()).FontSize(scale.Font(c, scale.Size12)).
- TextColor(t.TextMuted).PaddingX(space.Px8).MaxLines(2).Ellipsis("…")
- if flatButton(c, "重试拉分组").FontSize(scale.Font(c, scale.Size12)).
- // ⚠️ **左右内边距是 8**:这一列里所有的字都起在同一条竖线上(这一列的
- // 内边距 8 + 行自己的内边距 8 = 16)。扁平按钮自带的 14 会把字推到
- // 22,那一列就歪了。
- Padding(space.Px4, space.Px8).Justify(ui.Start).Clicked() {
- f.tagErr, f.tagLoading = nil, true
- go s.fetchFollowTags(context.Background(), f)
+// 停在「全部关注」写「全部关注」,停在某一组写那一组的名字(上游
+// `index.tsx:206` 的 `` 同一个口径),而**不是**这一页的名字——这一页的
+// 名字是左边那个 24 号的「我的关注」。两件事在页头上是两个字。
+//
+// # 弹层里的三态和原来左栏是同一套
+//
+// 「分组还在拉」/「分组没拉出来(可重试)」/「还没有分组」——那三句话在左栏
+// 时是三行字,收成下拉之后是弹层里的三条。一句都不能少:B 站返回空数组和
+// 「我们没拉到」在界面上是两件事。
+//
+// # 分组项的画法照 `enumSelect`(settings_enum.go)
+//
+// 高亮那一档是 `flatSolid`(实心主色底),当前值那一档是淡底——同一个出口,
+// 和设置页的下拉一个样。唯一不同的是「全部关注(N)」和第一个分组之间加一道
+// 分界(票 #367 那条线的同一个理由:一个是全集,一个是用户自己建的组)。
+func (s *Shell) viewFollowGroupSelect(c *ui.Context, f *followPage, t *ui.Theme) {
+ // 收起那一格写的字:当前那一格的名字,和上游 index.tsx:206 的 ``
+ // 同一个口径(`followPaneTitle` 复用)。
+ //
+ // ⚠️ **下拉的值绑 `f.tab` 本体**:`SelectBase` 选中那一项时是把值写进绑定的
+ // 字段,绑到一个暂存格的话暂存格要在这一帧开头回填成 `f.tab`——而 `choose()`
+ // 在输入处理里写暂存格、回填在视图构建里跑,顺序反过来就把用户那一选抹掉了。
+ // 绑本体则 `Changed()` 只在真的换了的时候才响,换组要做的几件事(放掉搜索词、
+ // 换代、清行)都由 `applyFollowTab` 收着。
+ sel := ui.SelectBase(c, &f.tab)
+ trigger := sel.Trigger.Height(followGroupSelectH).MinWidth(followGroupSelectMinW).
+ Radius(t.Radius).Background(t.Surface).Border(1, t.Border).
+ Padding(space.None, space.Px10).AlignItems(ui.Center).Gap(space.Px6)
+ trigger.Label("选择关注分组")
+ trigger.Children(func() {
+ ui.Text(c, followPaneTitle(f)).FontSize(scale.Font(c, scale.Size14)).
+ SingleLine().Grow(1)
+ ui.Icon(c, chevronIcon()).TextColor(t.TextMuted)
+ })
+
+ sel.Popup(func(panel ui.Element) {
+ popupPanel(t, panel.Grow(1).MaxWidth(280).Padding(space.Px4)).Children(func() {
+ // ⚠️ **三态先判**:分组还在拉 / 拉失败了 / 还没有分组——和原来左栏
+ // 那三句话是同一套,收成下拉之后一样都不能少。
+ switch {
+ case !f.tagStarted && f.tagErr != nil:
+ ui.Text(c, "分组没拉出来").FontSize(scale.Font(c, scale.Size12)).
+ TextColor(t.Text).PaddingX(space.Px8)
+ ui.Text(c, f.tagErr.Error()).FontSize(scale.Font(c, scale.Size12)).
+ TextColor(t.TextMuted).PaddingX(space.Px8).MaxLines(2).Ellipsis("…")
+ if flatButton(c, "重试拉分组").FontSize(scale.Font(c, scale.Size12)).
+ Padding(space.Px4, space.Px8).Justify(ui.Start).Clicked() {
+ f.tagErr, f.tagLoading = nil, true
+ go s.fetchFollowTags(context.Background(), f)
+ }
+ return
+ case !f.tagStarted:
+ ui.Text(c, "正在加载分组…").FontSize(scale.Font(c, scale.Size12)).
+ TextColor(t.TextMuted).PaddingX(space.Px8)
+ return
+ }
+ // 「全部关注(N)」是第一项;它和分组之间有一道分界(票 #367 那条线
+ // 的同一个理由:一个是全集,一个是用户自己建的组)。
+ s.viewFollowGroupItem(c, f, sel, followTabAll,
+ fmt.Sprintf("全部关注%s", countSuffix(f.total)), t)
+ if len(f.tags) > 0 {
+ ui.Divider(c).Background(t.Border).MarginY(space.Px4)
+ }
+ for _, tg := range f.tags {
+ name := tg.Name + countSuffix(tg.Count)
+ if name == "" {
+ name = "这一组"
+ }
+ s.viewFollowGroupItem(c, f, sel, followTagTab(tg.ID), name, t)
+ }
+ if len(f.tags) == 0 {
+ // 🔴 **「一个分组都没有」要有一句话**:什么都不摆的话弹层里只有
+ // 一项「全部关注」,用户以为整个下拉没拉出来(而它确实拉出来了,
+ // 只是 B 站那会儿返回空数组)。
+ ui.Text(c, "还没有分组").FontSize(scale.Font(c, scale.Size12)).
+ TextColor(t.TextMuted).PaddingX(space.Px8).PaddingY(space.Px4)
+ }
+ })
+ })
+
+ // ⚠️ **`Changed()` 必须放在 `sel.Popup` 之后**(`input.go` 的注释:「先配好
+ // 控件再问响应」):`Changed()` 内部会 `applyInputs()`——那一趟里跑的是弹层里
+ // 那些 `Item` 的 `choose()`,而 Item 的输入回调是 `Popup` 这一段里注册上去的。
+ // 放在 `Popup` 之前查的话,`applyInputs` 跑的时候这一帧的 Item 还没建出来,
+ // `choose()` 落到下一帧(`f.tab` 那时已被写进新值),`Changed()` 自己报 false
+ // ——`applyFollowTab` 永远不进,分组看着切了、底下那格还是上一组的人。
+ if trigger.Changed() {
+ s.applyFollowTab(f)
}
}
-// followPaneTitle 是左边顶上那一行的名字。
+// viewFollowGroupItem 是弹层里的一项。
+//
+// 高亮(指针 / 键盘游标在这一项)走 `flatSolid`(实心主色底),当前值那一档
+// 是淡底——和 `enumSelect` 里那两项同一个出口(settings_enum.go:64-76)。
+func (s *Shell) viewFollowGroupItem(c *ui.Context, f *followPage, sel ui.SelectParts[string],
+ tab, label string, t *ui.Theme) {
+ opt := sel.Item(tab).Padding(space.Px6, space.Px10).Radius(t.Radius)
+ switch {
+ case opt.Highlighted():
+ opt = flatSolid(c, opt)
+ case tab == f.tab:
+ opt = opt.Background(t.Surface)
+ }
+ opt.Children(func() {
+ ui.Text(c, label).FontSize(scale.Font(c, scale.Size14)).SingleLine().Grow(1)
+ })
+}
+
+// followPaneTitle 是下拉收起那一格写的名字:当前停在哪一格。
//
// ⚠️ **每一档都说得出是哪一档**:分组没拉到 / 拉失败了 / 停在「全部关注」/
// 停在某一组。Biu 上游那一支(`index.tsx:206`)在这几种情况下都塌成同一个
// 「我的关注」,而左上角突然换名字是最难自己发现的那一类毛病。
+//
+// ⚠️ **它和页头那个 24 号的「我的关注」是两回事**:页头是这一页的名字(不换),
+// 这一格是「现在看的是哪一批」(跟着分组换)。两件事在页头上是两个字。
func followPaneTitle(f *followPage) string {
if !f.tagStarted {
return "我的关注"
@@ -634,109 +652,6 @@ func followPaneTitle(f *followPage) string {
return "这一组"
}
-// viewFollowPaneRow 画左边的一行。
-//
-// 行身份是**这个分组是谁**(`followTabAll` / `followTagTab(id)`),不是序号
-// (铁律四)。B 站的分组 id 可正可负,撞不上 `all` 那个串。
-//
-// # 一行长什么样(票 #367)
-//
-// 这一行是**侧栏菜单项的弟弟**:同样的淡底选中态、同样的字、同样的内边距。
-// 三样都取侧栏那一份:
-//
-// - 选中 → `flatSolidCurrent`(实心主色底);
-// - 没选中 → 次要色;
-// - 悬停 / 按下 → `flat.Feel`(压一层半透明的前景色,和侧栏菜单项同一档)。
-func (s *Shell) viewFollowPaneRow(c *ui.Context, f *followPage, i int) {
- t := c.Theme()
-
- tab, label := followTabAll, fmt.Sprintf("全部关注%s", countSuffix(f.total))
- if i > 0 && i-1 < len(f.tags) {
- tg := f.tags[i-1]
- // 分组 id 可正可负,身份带前缀(见 followTagTab)。
- tab, label = followTagTab(tg.ID), tg.Name+countSuffix(tg.Count)
- if label == "" {
- label = "这一组"
- }
- }
-
- // 🔴 **「全部关注(N)」和后面的分组之间要有一道分界**(票 #367)。
- //
- // 它们不是一伙的:前一个是「我关注的人」这个全集,后一个是「B 站上
- // 我自己建的分组」。摆成同一个样子的时候用户分不清「这一格是全部」还是
- // 「这一格也是一个分组」。
- //
- // ⚠️ **分界画在第一个分组那一行的上面**,不画在「全部关注」那一行的下面:
- // 画在下面的话,「全部关注」被选中时那道线就落在它的淡底上(淡底那一格
- // 的底色会盖住它),而「全部关注」恰恰是**最常被选中**的那一格。
- if i == 1 {
- // ⚠️ **这一道线用 `t.Border` 的原色,不打折**:这一列左右两片是**同色**
- // 的(都是方案的 `sidebar`),而这一列右缘那道线是隔着两种颜色的
- // (导航区 / 内容区)。同色之间要分界,只能靠线本身——打折的话
- // 暖纸那几套(线的底色是文字色叠 12%)几乎看不见,而经典那几套
- // (`#dbdbdb`)看得见,同一个控件在两套主题里差这么远不行。
- ui.Divider(c).Background(t.Border).MarginY(space.Px4)
- }
-
- // ⚠️ **内边距是侧栏收藏夹那一行的那一份**(上下 6、左右 8):这一列和
- // 侧栏是邻居,行高和缩进得是一套。
- //
- // ⚠️ **左右那 8 和侧栏收藏夹那一行不一样了**(票 #371):那一行的左内边距
- // 现在是 20(它的字在 36,分组标题左边有一枚箭头图标),而这一列的字在
- // 16——这一列自己就贴着内容区的左边沿。
- //
- // ⚠️ **这一行自己不铺底色**:没选中的那些行是**透**的——底是这一列的底。
- // (框架那一层行悬停底在这一列里**不存在**:它只在「列表有游标」时才铺,
- // 而这一列的列表没有游标,见下面 `flat.Feel` 那一段。)
- box := ui.Row(c).Grow(1).Key("follow-tab:"+tab).Padding(space.Px6, space.Px8).Radius(t.Radius).
- AlignItems(ui.Center)
- if tab == f.tab {
- // 当前正在看的那一格:和侧栏菜单当前项**同一套**画法(**实心主色底**,
- // 见 `flatSolidCurrent`)。
- //
- // 🔴 **这一列和侧栏是邻居**:它们挨着摆,而「当前在哪」在两列里说的是
- // 同一件事——两种画法并排的话,用户看到的是「这个应用没做完」(那正是
- // 审计 F-02 / F-06 当初删实心底的理由,而这一档反过来是用户点名要的)。
- //
- // ⚠️ **字色是 `t.AccentText`**(主色底上读得出来的那一档),不是写死的
- // 白字——理由见 `flatSolidCurrent`。
- box = flatSolidCurrent(c, box)
- } else {
- // 没选中的那些是**次要色**:这一列是这一页里的第二层导航,选中那一格
- // 才是要看的那一格。侧栏的菜单项是正文色(那是第一层导航),这一列
- // 不是——两列的字色差一档,层次才分得出来。
- box = box.TextColor(t.TextMuted)
- }
- // 悬停 / 按下的反馈:和侧栏菜单项同一档(`flat.Feel`——压一层半透明的前景色)。
- //
- // ⚠️ **这一层是这一列唯一的悬停反馈**:框架给「指针压着的那一行」铺的那层
- // `t.SurfaceHover`(`mygo/ui/list.go` 的 styleFn)**只在「列表有游标」时
- // 才铺**(`s.cursor() != nil`),而这一列的列表没有游标(`f.paneState` 的
- // `Selected` / `Selection` 从头到尾没设过)——所以这里没有第二层要盖。
- //
- // ⚠️ **悬停那一层是「压」不是「换」**:换成 `t.SurfaceHover` 的话,指针一落到
- // 选中那一格上,实心底就被整块顶掉——「当前在哪」是这一列里唯一的方向标,
- // 被一次悬停盖掉比多画一层底更坏(`widgets/flat` 的文件头写着同一件事)。
- //
- // 🔴 **选中那一格要「按那一层底算」的那一支**:按主题算的话,浅色主题的
- // 正文色是黑的,8% 的黑压在主色底上几乎看不出变化(悬停反馈等于没有)。
- if tab == f.tab {
- box = flatSolidCurrentFeel(c, box)
- } else {
- box = flat.Feel(c, box)
- }
- // ⚠️ **点了就换查询,而且换查询必须重置行**(见 resetUsers 的注释)。
- if box.Clicked() {
- s.selectFollowTab(f, tab)
- }
- box.Children(func() {
- // ⚠️ **单行 + 省略号**:分组名是用户自己起的(「常听的那些不太熟但是每首
- // 都想循环到会唱完整首的 UP 主」),不截断的话这一行会长高一截、把下面
- // 那些分组整体顶下去。侧栏收藏夹那一行是同一个做法。
- ui.Text(c, label).Grow(1).FontSize(scale.Font(c, scale.Size14)).MaxLines(1).Ellipsis("…")
- })
-}
-
// countSuffix 是 Tab 名字后面那个括号。**0 就不画**(上游 `index.tsx:224` 是
// `allCount ? … : ""`)——一个空的「()」在界面上像加载坏了。
func countSuffix(n int) string {
@@ -746,19 +661,17 @@ func countSuffix(n int) string {
return fmt.Sprintf("(%d)", n)
}
-// selectFollowTab 切到某一格。
+// applyFollowTab 是换组要做的那几件事,在 `f.tab` 已经写进新值之后调。
//
-// ⚠️ **切到已经停着的那一格什么都不做**:清一次行的话,用户点回刚才看的那一格
-// 就要重新拉一遍、滚动位置也回顶——而他没换任何条件。
-func (s *Shell) selectFollowTab(f *followPage, tab string) {
- if f.tab == tab {
- return
- }
- f.tab = tab
+// 入口只有一条:下拉选中(`SelectBase` 自己把新值写进 `f.tab`,然后
+// `Changed()` 响)。⚠️ **所以这里不能再判一次「新值等不等于旧值」**——到这里
+// 时 `f.tab` 已经是新值,再比就永远相等,切换整个不生效。而「停在已经停着的
+// 那一项不响」这件事 `SelectBase` 的 `Changed()` 自己管(值没变它不报告)。
+func (s *Shell) applyFollowTab(f *followPage) {
// ⚠️ **换分组顺手把搜索词放掉**:搜索框只在「全部关注」那一格画
//(上游 `index.tsx:280`),而那个词是按「全部关注」那条接口搜出来的。
// 留着它的话切回「全部关注」时用户会看到上次搜的那一批,而他记得自己没搜过。
- if tab != followTabAll {
+ if f.tab != followTabAll {
f.applied, f.draft = "", ""
}
f.gen++
@@ -895,7 +808,7 @@ func (s *Shell) viewFollowGrid(c *ui.Context, f *followPage) {
// ⚠️ Key 挂在外面的 Column 上,理由见 `dynamicfeed.go`。
ui.Column(c).Key("follow-grid").Grow(1).FillHeight().FillWidth().Children(func() {
// 登记「内容区里当前那一个能被方向键走位的」(票 #245 的 E4)。
- // 网格在分组栏**之后**登记,所以它赢——它是这一页的主内容。
+ // 网格是这一页里唯一的走位对象(分组栏已收成页头下拉,见票 #367 改版)。
//
// ⚠️ **登记的是 `&f.keys` 而不是裸的句柄**:这一份的 `TakeFocus` 还要
// 把游标摆到第一行(走进来就在第一格上)。
@@ -1392,7 +1305,7 @@ func (s *Shell) fetchFollowTags(ctx context.Context, f *followPage) {
// 🔴 **gen 也在这一份里**——它是「换分组 / 换搜索词时 +1」的那个代次,而下面
// 拿它填进 `followResult` 就是**读**它。第一版这里是 `gen: int(f.gen)`,直接
// 在 goroutine 里读界面线程正在写的那个字段:`go test -race` 一抓一个准
-// (Read at follow.go:889 / Previous write at selectFollowTab)。
+// (Read at follow.go:889 / Previous write at applyFollowTab)。
//
// 🔴 **loaded 在这儿是有用的,不是多传**:B 站那三个接口给的是**总数**,而
// 「还有没有」要拿**累计加载数**去比总数——总数 25、每页 20,第二页发回来 5 个,
diff --git a/internal/ui/follow_grid_test.go b/internal/ui/follow_grid_test.go
index 078ad1d4..b6b1a7b2 100644
--- a/internal/ui/follow_grid_test.go
+++ b/internal/ui/follow_grid_test.go
@@ -47,12 +47,17 @@ import (
//
// 列数是按**上一帧**量出来的容器宽算的(`followPage.width` →
// `list.GridColumnsFor`),头一帧按一个还没量准的宽排成 5 列,下一帧量到真的
-// 才排成 3 列(理由同 `follow.go` 里 `keepFollowAnchor` 那段)。不等它稳的话,
+// 才排成 4 列(理由同 `follow.go` 里 `keepFollowAnchor` 那段)。不等它稳的话,
// 量到的是**上一档列数**摆出来的画面。
+//
+// ⚠️ **4 列是票 #367 之后的事**:左边 200 宽的分组栏收成页头那个下拉之后,
+// 同一个 1400×900 窗口里网格拿到 1148(≥ 1024 那一档),原来是 948(≥ 768
+// 那一档、3 列)。列数跟着容器宽走,所以这一处断言也跟着换。
func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) {
b := newUIExplore(t)
uiFollowTags(b, map[int]any{0: "默认分组"})
- // 六个 UP 主:窗口摆得下三列,于是**两行**——行间距量两次,列间距量两次。
+ // 六个 UP 主:窗口摆得下四列(左栏收成下拉之后网格宽 1148 ≥ 1024),
+ // 于是**两行**(4 + 2)——行间距量一次,列间距量三次。
uiFollowList(b, 6, []any{
uiFollowUser(11, "第一个 UP"), uiFollowUser(12, "第二个 UP"),
uiFollowUser(13, "第三个 UP"), uiFollowUser(14, "第四个 UP"),
@@ -69,8 +74,8 @@ func TestTheFollowGridHasSixteenBetweenRowsAndColumns(t *testing.T) {
waitFrames(t, tt, "列数稳了", func() bool { return f.cols == list.GridColumnsFor(f.width) })
tt.Frame()
- if f.cols != 3 {
- t.Fatalf("网格宽 %v 应该是 3 列,实际是 %d 列", f.width, f.cols)
+ if f.cols != 4 {
+ t.Fatalf("网格宽 %v 应该是 4 列,实际是 %d 列", f.width, f.cols)
}
if len(f.users) != 6 {
t.Fatalf("要六个人才量得出两行,实际是 %d 个", len(f.users))
diff --git a/internal/ui/follow_test.go b/internal/ui/follow_test.go
index ac30855c..26454444 100644
--- a/internal/ui/follow_test.go
+++ b/internal/ui/follow_test.go
@@ -159,15 +159,45 @@ func TestTheFollowPageShowsTheFollowedPeople(t *testing.T) {
t.Errorf("关注的 %q 没画出来,文字有 %q", want, tt.Texts())
}
}
- // 左边的分组 Tab:一个都不能少。⚠️ **-10 和 0 是 B 站固定给的**,少了
- // 「特别关注」在网页版明明有。
+ // 页头:大号标题 + 关注总数。上游 index.tsx:206 的 ``(`全部关注` /
+ // 分组名 / `我的关注`)在左侧栏塌成同一个「我的关注」,而我们的页头是
+ // **这一页的名字**——「现在在哪一组」是右上角那个下拉自己的事。
+ //
+ // ⚠️ **「我的关注」侧栏里也有**(侧栏那一项的字)——`showsText` 找到
+ // 侧栏那一枚也算过,`Find` 回的也是第一枚(侧栏的)。所以这里数**有几
+ // 枚**:侧栏那一枚本来就在,页头那一枚画没画看的是「是不是 ≥2」。
+ count := 0
+ for _, txt := range tt.Texts() {
+ if txt == "我的关注" {
+ count++
+ }
+ }
+ if count < 2 {
+ t.Errorf("「我的关注」只有 %d 枚——页头那一枚没画(侧栏那一枚是本来就有的)", count)
+ }
+ // ⚠️ **「3」也不能只问「树里有没有」**——树里任何一个单独的字「3」都算
+ // 过。它得是**页头上那一枚**:量它的位置——在侧栏右缘(`sideTargetWidth`)
+ // 之外才算。
+ total, ok := tt.Find("3")
+ if !ok {
+ t.Fatalf("页头没有关注总数,文字有 %q", tt.Texts())
+ }
+ if total.X < float32(sideTargetWidth(s.app.SettingsSnapshot()))-1 {
+ t.Errorf("总数「3」画在 x=%.1f——在侧栏那边,不是页头上那一枚", total.X)
+ }
+ // ⚠️ **分组都在下拉里**:点开才看得见,而 -10 和 0 是 B 站固定给的,
+ // 少了「特别关注」在网页版明明有。
//
// ⚠️ 名字后面那个括号也在断言里:上游 `index.tsx:238` 是
// `${tag.name}${tag.count ? `(${tag.count})` : ""}`,而 0 的那一档不画括号
// ——差的就是这一处,而空括号在界面上像加载坏了。
+ if err := tt.Click("全部关注"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
for _, want := range []string{"全部关注(3)", "特别关注(1)", "默认分组(1)", "常听的(1)"} {
if !showsText(tt, want) {
- t.Errorf("左边没有 %q 那一格,文字有 %q", want, tt.Texts())
+ t.Errorf("下拉里没有 %q 那一项,文字有 %q", want, tt.Texts())
}
}
// 签名和认证也要画出来(它们是这张卡上除头像之外仅有的两行字)。
@@ -177,10 +207,6 @@ func TestTheFollowPageShowsTheFollowedPeople(t *testing.T) {
if !showsText(tt, "第一个 UP 的认证") {
t.Errorf("认证没画出来,文字有 %q", tt.Texts())
}
- // 切到某一格时左上角要跟着换名字(上游 index.tsx:206 那一支)。
- if !showsText(tt, "全部关注") {
- t.Errorf("左上角没画「全部关注」,文字有 %q", tt.Texts())
- }
}
// TestNotLoggedInSaysSoAndSendsNothing 是这一页最要紧的一条。
@@ -264,8 +290,13 @@ func TestEachTagTabShowsThatTagsPeople(t *testing.T) {
waitFrames(t, tt, "第一格到了", func() bool { return len(f.users) == 1 && f.tagStarted })
tt.Frame()
+ // ⚠️ **切组走下拉**:先点开触发器(它写的是当前那一格的名字),再选那一项。
+ if err := tt.Click("全部关注"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
if err := tt.Click("常听的(1)"); err != nil {
- t.Fatalf("点不到「常听的」那一格:%v,文字有 %q", err, tt.Texts())
+ t.Fatalf("下拉里点不到「常听的」:%v,文字有 %q", err, tt.Texts())
}
tt.Frame()
// ⚠️ 等的是**那一个人**,不是「有一个人」:切组之前画面上就已经有一个人了,
@@ -281,10 +312,10 @@ func TestEachTagTabShowsThatTagsPeople(t *testing.T) {
if showsText(tt, "全部关注里的那个人") {
t.Errorf("换组之后上一组的人还在画面上,文字有 %q", tt.Texts())
}
- // ⚠️ **左上角跟着换**:停在某一组的时候它该是那一组的名字(上游
+ // ⚠️ **下拉上的字跟着换**:停在某一组的时候触发器该写那一组的名字(上游
// index.tsx:206 那一支),而塌回「全部关注」的话用户以为自己在看全部。
if !showsText(tt, "常听的") {
- t.Errorf("左上角没跟着换组,文字有 %q", tt.Texts())
+ t.Errorf("分组下拉没跟着换组,文字有 %q", tt.Texts())
}
// ⚠️ **搜索框只在「全部关注」那一格画**(上游 index.tsx:280):B 站的搜索
// 接口搜的是「我关注的人」的全集,搜不出「某一组里的谁」。
@@ -329,7 +360,12 @@ func TestTheSearchBoxFindsAFollowedPerson(t *testing.T) {
t.Errorf("搜索那一支发出去的词是 %q", got)
}
// ⚠️ **「全部关注(N)」那个 N 不能被搜到的结果顶掉**:搜「周杰伦」搜到 1 个,
- // 而那一格下面挂着的 N 说的是我关注了 1 个人——两件事,挂错了就是一句假话。
+ // 而下拉里挂着的 N 说的是我关注了 1 个人——两件事,挂错了就是一句假话。
+ // 下拉项点开才看得见,所以先开下拉再断。
+ if err := tt.Click("全部关注"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
if !showsText(tt, "全部关注(1)") {
t.Errorf("「全部关注」那个 N 被搜索结果顶掉了,文字有 %q", tt.Texts())
}
@@ -873,10 +909,14 @@ func TestAFailedTagListDoesNotRetryItselfForever(t *testing.T) {
"「没拉到」,而每次失败都要一帧新画面,于是自己转起来了",
after-before, before, after)
}
- // 失败之后要说得清,并且给一条路走。
+ // 失败之后要说得清,并且给一条路走。这两句在下拉**弹层里**,先点开触发器。
//
// ⚠️ **这里要用 `tt.HasText`(子串),不能用 `showsText`(全等)**:那一行是
// 「分组没拉出来:<原因>」,原因跟着错走,而全等匹配钉不住一个前缀。
+ if err := tt.Click("选择关注分组"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
if !tt.HasText("分组没拉出来") {
t.Errorf("没说清是分组没拉出来,文字有 %q", tt.Texts())
}
@@ -913,11 +953,19 @@ func TestAFailedTagListCanBeRetriedByHand(t *testing.T) {
waitFrames(t, tt, "分组拉失败了", func() bool { return f.tagErr != nil })
failed = false // B 站这一次肯给了
+ // 「重试拉分组」在下拉**弹层里**:先点开触发器。
+ if err := tt.Click("选择关注分组"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
if err := tt.Click("重试拉分组"); err != nil {
t.Fatalf("点不到重试:%v,文字有 %q", err, tt.Texts())
}
waitFrames(t, tt, "重试之后分组到了", func() bool { return f.tagStarted && f.tagErr == nil })
tt.Frame()
+ // ⚠️ **别再点一下触发器**:「重试拉分组」是弹层里的普通按钮,不是下拉的
+ // `Item`——点它不收弹层(弹层还开着),再点触发器就是把它**关上**。
+ // 弹层每帧都重建,新拉到的分组自己长出来。
// ⚠️ 同样是子串:那一格的名字后面跟着 `(1)`(B 站的分组成员数)。
if !tt.HasText("重试之后有了") {
t.Errorf("重试之后分组没画出来,文字有 %q", tt.Texts())
@@ -973,8 +1021,13 @@ func TestAFullyOverlappingPageStillLoadsTheNextOne(t *testing.T) {
tt.Frame()
scrollFollowToEnd(t, tt, f)
+ // 总数挂在下拉的「全部关注(N)」那一项上:点开下拉再断。
+ if err := tt.Click("全部关注"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
if !showsText(tt, "全部关注(39)") {
- t.Errorf("Tab 上的总数对不上,文字有 %q", tt.Texts())
+ t.Errorf("下拉里的总数对不上,文字有 %q", tt.Texts())
}
// ⚠️ **断的是「末尾」**:滚到底之后画面上是 25..39,所以问的是 UP 39。
// (问 UP 21 量到的是「屏幕能显示几行」,不是「分页有没有停」。)
@@ -1008,6 +1061,12 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) {
return f.selfMid == 77 && f.total == 7
})
tt.Frame()
+ // 「全部关注(7)」是下拉项,点开下拉才看得见;而判据是**画面上**那句话,
+ // 所以先开下拉再断。
+ if err := tt.Click("全部关注"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
if !showsText(tt, "全部关注(7)") {
t.Fatalf("第一个账号的总数没画出来,文字有 %q", tt.Texts())
}
@@ -1015,7 +1074,7 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) {
// 换分组:那个 N **不许动**——它说的是「这个账号关注了多少人」,跟着账号走而
// 不跟着「停在哪个分组」走(Biu 那边换组它也还在,见 `index.tsx:224`)。
if err := tt.Click("默认分组(1)"); err != nil {
- t.Fatalf("点不到分组:%v,文字有 %q", err, tt.Texts())
+ t.Fatalf("下拉里点不到分组:%v,文字有 %q", err, tt.Texts())
}
tt.Frame()
waitFrames(t, tt, "切到那一组", func() bool { return f.tab == followTagTab(0) })
@@ -1040,6 +1099,10 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) {
// ⚠️ **判据没放宽,反而更严**:「全部关注(7)」那一栏正是 `f.total` 画出来的,
// 而它换账号**之前**就在画面上——所以这个判据不可能提前成立,等到的就是
// 「重画成新账号那个数」。末尾那两行断言照样断内部的 `f.total`。
+ //
+ // ⚠️ **这一帧画面上的 N 是下拉里那一项**:换账号那一刻下拉要是还开着,
+ // 触发器上的字会跟着换,而「全部关注(7)」那一句也会跟着消失——所以它**不用
+ // 真的看得见**,只要不再出现在画面的文字里就算数。
waitFrames(t, tt, "新账号的那个 N 掉下来了", func() bool {
return f.selfMid == 88 && !showsText(tt, "全部关注(7)")
})
@@ -1047,7 +1110,7 @@ func TestSwitchingAccountsDoesNotKeepTheOldTotal(t *testing.T) {
t.Errorf("换账号之后那个 N 仍然是 %d——它是上一个账号的关注数", f.total)
}
if showsText(tt, "全部关注(7)") {
- t.Errorf("新账号的 Tab 上还挂着上一个账号的关注数,文字有 %q", tt.Texts())
+ t.Errorf("新账号的下拉上还挂着上一个账号的关注数,文字有 %q", tt.Texts())
}
}
@@ -1133,9 +1196,11 @@ func TestTheFollowCardTypographyAndItsHoverBar(t *testing.T) {
}
}
-// TestTheFollowPaneHasARightBorder 钉住左栏右缘那条分界线(上游
-// `follow-list/index.tsx:204` 的 `border-r`)。
-func TestTheFollowPaneHasARightBorder(t *testing.T) {
+// TestTheFollowPageHasNoLeftPane 钉住「我的关注」不再有左边那一栏。
+//
+// 票:分组从「页内一栏」降为「页头里的下拉」——200 宽 × 全高的分栏整个删掉,
+// 内容区直接从侧栏右边开始,一行顶到窗口右缘。
+func TestTheFollowPageHasNoLeftPane(t *testing.T) {
b := newUIExplore(t)
uiFollowTags(b, map[int]any{0: "默认分组"})
uiFollowList(b, 1, []any{uiFollowUser(11, "一个人")})
@@ -1149,16 +1214,25 @@ func TestTheFollowPaneHasARightBorder(t *testing.T) {
waitFrames(t, tt, "人到了", func() bool { return len(f.users) == 1 && f.tagStarted })
tt.Frame()
- pageLeft := int(sideTargetWidth(s.app.SettingsSnapshot()))
- img := tt.Image()
- bx := pageLeft + followPaneW - 1
- if got, ref := img.RGBAAt(bx, 400), img.RGBAAt(bx-4, 400); got == ref {
- t.Errorf("左栏右缘 (%d,400) 是 %v,和左边四像素一样——那条分界线没画", bx, got)
+ // 内容区(那张卡)的左缘应该贴着侧栏右缘,而不是再让出 200。
+ //
+ // ⚠️ 量的不是「pane 还在不在」(代码里已经没有 pane 了),而是「卡是不是从
+ // 侧栏右边就开始」——这才是「没有左栏」在画面上的意思。卡本身没有文字,
+ // 拿卡上的名字定位:第一张卡的名字就在卡上,它的左缘 ≈ 卡的左缘。
+ av, ok := tt.Find("一个人")
+ if !ok {
+ t.Fatalf("卡上的名字没画出来,文字有 %q", tt.Texts())
+ }
+ sideW := float32(sideTargetWidth(s.app.SettingsSnapshot()))
+ // 内容区从侧栏右缘开始,左边还有页边距(pagePadX)+ 卡里那圈留白。
+ // 留出 200+ 才可能是「左栏还在」——这里卡一个比那小得多的数。
+ if av.X-sideW > 64 {
+ t.Errorf("内容区的左缘在侧栏右边 %.0f 之外——左边那一栏还占着位置", av.X-sideW)
}
}
// TestUnfollowingFromTheCardRemovesIt 钉住操作条上那个「取消关注」真的能按:
-// 写接口走后台、成功后那一格从画面上消失、左栏的人数跟着减一。
+// 写接口走后台、成功后那一格从画面上消失、下拉的人数跟着减一。
func TestUnfollowingFromTheCardRemovesIt(t *testing.T) {
b := newUIExplore(t)
uiSpaceData(b, "我自己", 0, 1)
@@ -1194,8 +1268,13 @@ func TestUnfollowingFromTheCardRemovesIt(t *testing.T) {
if !b.hit("/x/relation/modify") {
t.Errorf("取关没有真的发出去(假 B 站没收到 /x/relation/modify)")
}
+ // 取关之后下拉里的「全部关注(N)」跟着减一:点开下拉再断。
+ if err := tt.Click("全部关注"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
if !showsText(tt, "全部关注(1)") {
- t.Errorf("取关之后左栏的人数没跟着减一,文字有 %q", tt.Texts())
+ t.Errorf("取关之后下拉里的人数没跟着减一,文字有 %q", tt.Texts())
}
}
@@ -1252,7 +1331,7 @@ func TestTheFollowGridRowHasNoHoverSlab(t *testing.T) {
homeFrame(tt)
hovered := tt.Image()
- // ② 指针停在左栏(离内容区远远的)。
+ // ② 指针停在内容区左边那片空白上(离卡远远的)。
tt.Move(250, 600)
homeFrame(tt)
away := tt.Image()
@@ -1317,7 +1396,7 @@ func TestTheFollowSentinelRowHasNoHoverSlab(t *testing.T) {
homeFrame(tt)
hovered := tt.Image()
- // ② 指针停在左栏。
+ // ② 指针停在内容区左边那片空白上。
tt.Move(250, 600)
homeFrame(tt)
away := tt.Image()
diff --git a/internal/ui/realdata_test.go b/internal/ui/realdata_test.go
index 2fb279e0..8aa0ebb5 100644
--- a/internal/ui/realdata_test.go
+++ b/internal/ui/realdata_test.go
@@ -356,6 +356,12 @@ var realDataLedger = []realDataPage{
s.navigate(followPath)
realDataWait(t, tt, "关注的 UP 主画出来了",
func() bool { return tt.HasText("关注页那位 UP 主") })
+ // ⚠️ **「全部关注(N)」和分组名都在页头下拉里**(#367 改版):点开
+ // 触发器它们才在画面上——`Want` 里的断言量的是「点开之后的那一屏」。
+ if err := tt.Click("选择关注分组"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
return s, tt
},
// ⚠️ 两条都要:一条是**接口给的真内容**,一条是「全部关注(N)」那个 N——
diff --git a/internal/ui/settings_enum.go b/internal/ui/settings_enum.go
index 7738c890..677a501f 100644
--- a/internal/ui/settings_enum.go
+++ b/internal/ui/settings_enum.go
@@ -83,12 +83,14 @@ func enumSelect[T comparable](c *ui.Context, value *T, items []enumItem[T], labe
//
// MyGo 自己的 `ui.Select` 里那一个没导出(`ui/widgets.go` 的 `chevron`),这里
// 自己画一个同样的,免得为了一个 10x10 的箭头去改框架。
-func chevronIcon() *ui.SVG {
- const shape = ``
- return ui.MustParseSVG([]byte(``))
-}
+func chevronIcon() *ui.SVG { return chevronSVG }
+
+// chevronSVG 是解析好的那一份(`MustParseSVG` 的文档原话就是「解析一次,别在
+// view 里解析」):解析一次实测 70 次分配 / 5.4 KB,每帧都解析的话一个下拉就
+// 顶爆关注页那一档的帧预算(票 #367 实测 +56 次 / +5.9 KB)。
+var chevronSVG = ui.MustParseSVG([]byte(``))
// settingsSelectH 是设置页下拉收起时的高度(设计规范 §7.9:控件 32 高)。
const settingsSelectH = 32
diff --git a/internal/ui/ticket213_shot_test.go b/internal/ui/ticket213_shot_test.go
index ddc6bc15..ee8d459e 100644
--- a/internal/ui/ticket213_shot_test.go
+++ b/internal/ui/ticket213_shot_test.go
@@ -1,15 +1,15 @@
-// 票 #367 的画面验收:「我的关注」那一页左边那列分组按当前风格重做。
+// 票 #367 改版的画面验收:「我的关注」的分组收进页头右上角的下拉。
//
// BIU_SHOT_DIR=/some/dir go test ./internal/ui/ -run TestRenderFollowPaneShots
//
// # 深浅两套都要出
//
-// 这一列的选中项是**淡主色底 + 按那层底现算的字**——浅色下那层底和页面底色差得
-// 很远,深色下只差十几个通道。**只看浅色的话深色下那一层根本看不出来**。
+// 下拉里高亮那一档是**实心主色底 + 按那层底现算的字**——浅色下那层底和页面底色
+// 差得很远,深色下只差十几个通道。**只看浅色的话深色下那一层根本看不出来**。
//
// # 主题方案也要各看一眼
//
-// 方案压过底色 / 主色 / 圆角三项,而这一列的底色、淡底、圆角全是从主题现算的
+// 方案压过底色 / 主色 / 圆角三项,而下拉的底色、高亮、圆角全是从主题现算的
// ——**经典那一套过了不等于方案那一套也过了**(暖纸的底是暖的、暮光是琥珀)。
//
// ⚠️ **一个字都不许打真 B 站接口**:全是内存夹具 + 无头渲染。
@@ -28,14 +28,14 @@ import (
//
// ⚠️ **名字里带一个很长的**:分组名过长时怎么办是这一票要定的事之一,出图里
// 得看得见它是怎么处理的。
+//
+// ⚠️ **用 `uiFollowTagsOrdered`,不用 `uiFollowTags`**:出图是要给人看的,而
+// map 的遍历顺序是随机的——同一组数据深浅两张跑出来分组次序不一样的话,
+// 对图的人得先猜「这俩是不是同一组数据」。
func followPaneShotFixture(t *testing.T) *uiExplore {
t.Helper()
b := newUIExplore(t)
- uiFollowTags(b, map[int]any{
- -10: "特别关注",
- 0: "默认分组",
- 777: "常听的那些不太熟但是每首都想循环到会唱完整首的 UP 主",
- })
+ uiFollowTagsOrdered(b, followPaneTags)
uiFollowList(b, 3, []any{
uiFollowUser(11, "第一个 UP"), uiFollowUser(12, "第二个 UP"),
uiFollowUser(13, "第三个 UP"),
@@ -60,14 +60,23 @@ func followPaneShotShell(t *testing.T, dark bool, preset app.ThemePreset) (*Shel
waitFrames(t, tt, "关注的人和分组到了", func() bool {
return len(f.users) == 3 && f.tagStarted
})
- // 指针停在内容区:停在分组那一列上会叠一层悬停底,而那一格的颜色就不是
+ // 指针停在内容区:停在下拉触发器上会叠一层悬停底,而那一格的颜色就不是
// 「平时的样子」了。
tt.Move(900, 500)
homeFrame(tt)
return s, tt
}
-// TestRenderFollowPaneShots 把这一页左边那列存成 PNG。
+// openFollowGroupSelectForShot 点开分组下拉再回一帧(弹层摆开的那一屏)。
+func openFollowGroupSelectForShot(t *testing.T, tt *mygoui.Tester) {
+ t.Helper()
+ if err := tt.Click("选择关注分组"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
+ }
+ tt.Frame()
+}
+
+// TestRenderFollowPaneShots 把这一页和下拉摆开的样子存成 PNG。
func TestRenderFollowPaneShots(t *testing.T) {
dir := os.Getenv("BIU_SHOT_DIR")
if dir == "" {
@@ -79,36 +88,40 @@ func TestRenderFollowPaneShots(t *testing.T) {
name = "深色"
}
t.Run(name, func(t *testing.T) {
- // 01 经典(Biu 绿):这一列的「底子」。
+ // 01 经典(Biu 绿):整页 + 收起那一格。
{
_, tt := followPaneShotShell(t, dark, app.ThemePresetClassic)
- save(t, dir, "01-分组栏-经典-"+name+".png", tt)
+ save(t, dir, "01-页面-经典-"+name+".png", tt)
}
- // 02 暖纸(默认方案):底色、淡底、圆角都是从方案来的。
+ // 02 暖纸(默认方案):下拉摆开的那一屏——底色、高亮、圆角都从方案来。
{
_, tt := followPaneShotShell(t, dark, app.ThemePresetWarmPaper)
- save(t, dir, "02-分组栏-暖纸-"+name+".png", tt)
+ openFollowGroupSelectForShot(t, tt)
+ save(t, dir, "02-下拉摆开-暖纸-"+name+".png", tt)
}
// 03 暮光·琥珀:另一套色相(暖纸和暮光的底色差得最远)。
{
_, tt := followPaneShotShell(t, dark, app.ThemePresetDuskAmber)
- save(t, dir, "03-分组栏-暮光-"+name+".png", tt)
+ openFollowGroupSelectForShot(t, tt)
+ save(t, dir, "03-下拉摆开-暮光-"+name+".png", tt)
}
- // 07 悬停:指针停在**没选中**的那一格上。
+ // 07 悬停:指针停在**没选中**的那一项上。
//
- // ⚠️ **悬停反馈要和侧栏菜单项是同一档**(压一层半透明的前景色),
+ // ⚠️ **高亮那一档是实心主色底**(`flatSolid`,和设置页下拉同一个出口),
// 所以这一张要看得见那一层。
{
_, tt := followPaneShotShell(t, dark, app.ThemePresetWarmPaper)
+ openFollowGroupSelectForShot(t, tt)
r, ok := tt.Find("特别关注(1)")
if !ok {
- t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts())
+ t.Fatalf("分组下拉里没有「特别关注(1)」那一项,文字有 %q", tt.Texts())
}
tt.Move(r.X+r.W/2, r.Y+r.H/2)
tt.Frame()
save(t, dir, "07-悬停-"+name+".png", tt)
}
- // 04 三态:加载中 / 拉失败了 / 一个分组都没有。
+ // 04 三态:加载中 / 拉失败了 / 一个分组都没有——**都在弹层里**,所以
+ // 每一张都是「点开下拉的那一屏」。
//
// ⚠️ **加载中那一档要停在一半**:分组那一拉是后台发的,而假 B 站
// 回得太快——存图的时候它已经回来了,画面上就成了「加载好了」那一档。
@@ -131,6 +144,7 @@ func TestRenderFollowPaneShots(t *testing.T) {
s.navigate(followPath)
tt.Frame()
homeFrame(tt)
+ openFollowGroupSelectForShot(t, tt)
save(t, dir, "04-加载中-"+name+".png", tt)
}
{
@@ -150,6 +164,7 @@ func TestRenderFollowPaneShots(t *testing.T) {
f := s.followPageOf()
waitFrames(t, tt, "分组拉失败了", func() bool { return f.tagErr != nil })
homeFrame(tt)
+ openFollowGroupSelectForShot(t, tt)
save(t, dir, "05-拉失败-"+name+".png", tt)
}
{
@@ -167,6 +182,7 @@ func TestRenderFollowPaneShots(t *testing.T) {
f := s.followPageOf()
waitFrames(t, tt, "分组到了(空的)", func() bool { return f.tagStarted })
homeFrame(tt)
+ openFollowGroupSelectForShot(t, tt)
save(t, dir, "06-没有分组-"+name+".png", tt)
}
})
diff --git a/internal/ui/ticket213_test.go b/internal/ui/ticket213_test.go
index 4193f1ad..5e6bff1c 100644
--- a/internal/ui/ticket213_test.go
+++ b/internal/ui/ticket213_test.go
@@ -1,17 +1,17 @@
-// 票 #367:「我的关注」那一页左边那列分组,按当前的设计风格重做。
+// 票 #367 改版:「我的关注」左边那列分组收成页头右上角的一枚下拉。
//
-// 这一票只动**左边那一列**(`follow.go` 的 `viewFollowPane` / `viewFollowPaneRow`):
-// 底色、选中态、字号、内边距、三态。**布局骨架(左列 + 右网格)和右边那个网格
-// 一个像素没动**。
+// 这一票把那一列从「200 宽 × 全高的常驻栏」改成「页头上一枚『全部关注 ▾』下拉」:
+// 选组是主动动作(分组是用户自己建的,想看有哪些分组是自己点开的),常驻一栏只为
+// 这一件事占地——内容区现在一行顶到窗口右缘。
//
-// # 判据是**画面上的像素**,不是函数调用
+// # 判据是**画面上的像素**和**弹层里摆出来的字**
//
-// 「像一家人」只能看画面:底色是不是同一片、选中那一格是不是同一层淡底、字
-// 是不是同一档。所以这一组量的是像素(`bandPixels` / `modeOf` / `farthestFrom`),
-// 而断「有没有写死颜色」的那一条量的是**源码**(照 `shadow_test.go` 那一族的路子)。
+// 原来钉在左栏上的那几条(「全部关注」和分组分得开、长名字截断不换行、三态都在),
+// 收成下拉之后一样都不能少——只是它们现在活在一个**弹层**里:断言前先点开触发器
+// (`tt.Click("选择关注分组")`),不然弹层里的字根本不在画面上。
//
-// ⚠️ **深浅两套都要跑**:淡底那一层在浅色下和底色差得很远、在深色下只差十几个
-// 通道——只看浅色的话深色下那一层根本验不出来。
+// ⚠️ **深浅两套都要跑**:高亮那一档(实心主色底)在浅色下和底色差得很远、在深色下
+// 只差十几个通道——只看浅色的话深色下那一层根本验不出来。
package ui
import (
@@ -25,7 +25,6 @@ import (
"testing"
"github.com/ShellMonster/PudMusic/internal/app"
- "github.com/ShellMonster/PudMusic/internal/ui/widgets/ink"
mygoui "github.com/egoist/mygo/ui"
)
@@ -49,7 +48,7 @@ var followPaneTags = []followTagSpec{
// uiFollowTagsOrdered 给分组那一列装一份**顺序固定**的返回。
//
// ⚠️ **`uiFollowTags`(`follow_test.go`)用的是 map**,而 map 的遍历顺序是随机的
-// ——每一轮跑出来的分组次序都不一样。而这一票要断言「第一格和后面那几格分得
+// ——每一轮跑出来的分组次序都不一样。而这一票要断言「第一项和后面那几项分得
// 开」,那要一个**确定的顺序**。
func uiFollowTagsOrdered(b *uiExplore, tags []followTagSpec) {
b.on("/x/relation/tags", func(w http.ResponseWriter, r *http.Request) {
@@ -65,18 +64,9 @@ func uiFollowTagsOrdered(b *uiExplore, tags []followTagSpec) {
// followPaneSettings 是这一组测试用的那份设置。
//
-// ⚠️ **方案要显式写暖纸,不能靠 `app.DefaultSettings("")` 的默认**:这一组要量的是
-// **暖纸**这一套,而**经典那一套的 `sidebar` 就是页面底色**(两个都是白)——于是
-// 「这一列的底 == 方案的 `sidebar`」这条判据在经典底下**恒真**:把这一列的底整个
-// 删掉,测试照样绿(2026-10-25 的评审抓出来的)。
-//
-// 暖纸那一套的 `sidebar`(#EFE9DE)和页面底色(#F8F4ED)**不是一个色**,判据
-// 才立得住——`TestTheFollowPaneAndTheSidebarAreOneFamily` 里还有一条专门盯
-// 「这一套方案里这两个色不是同一个」。
-//
-// (票 #406 之后这一包的 `TestMain` 不再把方案钉成经典,所以显式写这一行是
-// **钉住这一组要量的那一套**,而不是「躲开默认」——两者的区别是:将来默认方案
-// 再变,这一组仍然量暖纸。)
+// ⚠️ **方案要显式写暖纸**:这一组量的是**暖纸**这一套,而经典那一套的 `sidebar`
+// 就是页面底色(两个都是白)——有些判据在它下面恒真(2026-10-25 的评审抓出来
+// 的那一处同形)。
func followPaneSettings() app.Settings {
st := app.DefaultSettings("")
st.ThemePreset = app.ThemePresetWarmPaper
@@ -105,98 +95,31 @@ func followPaneShell(t *testing.T, dark bool) (*Shell, *mygoui.Tester) {
return s, tt
}
-// TestTheFollowPaneAndTheSidebarAreOneFamily 钉住「这一列和侧栏是同一片」。
+// openFollowGroupSelect 点开页头右上角那枚分组下拉。
//
-// 三条判据:
+// ⚠️ **靠 Label 找**:触发器写的是当前那一格的名字(「全部关注」/ 某一组 /
+// 「我的关注」),那一格换组就变,而 Label 不变(`openSelect` 同一个理由)。
//
-// 1. **底色是同一片**——侧栏那一栏和这一列都是方案的 `sidebar`(和内容区不同色)。
-// 改之前这一列是页面底色,于是它和内容区是一体的,而旁边的侧栏是另一片:
-// 两片导航区夹着一片内容区,中间那道界是内容区的边。
-// 2. **这一套方案里 `sidebar` 和页面底色不是同一个色**——不然第一条**恒真**
-// (经典那一套的 `sidebar` 就是页面底色,见 `followPaneSettings` 的注释)。
-// 3. **选中那一格是同一层淡底**——两列都用 `flatCurrent`(`accentSoft` 淡底 +
-// 按那层底现算的字),所以两个色值逐字节相同。
-func TestTheFollowPaneAndTheSidebarAreOneFamily(t *testing.T) {
- for _, dark := range []bool{false, true} {
- name := "浅色"
- if dark {
- name = "深色"
- }
- t.Run(name, func(t *testing.T) {
- s, tt := followPaneShell(t, dark)
- theme := BuildTheme(followPaneSettings(), dark)
- img := tt.Image()
-
- // 判据二:**这一套方案里,方案的 `sidebar` 和页面底色不是同一个色**。
- //
- // ⚠️ **这一条是前一条的前提**:两个色一样的话,「这一列的底 == 侧栏的底
- // == 方案的 `sidebar`」恒成立——把这一列的底整个删掉,测试也绿
- //(2026-10-25 的评审抓出来的)。
- sidebar := s.sidebarBackground(theme)
- if nearRGBA(rgbaOf(sidebar), rgbaOf(theme.Background), 3) {
- t.Fatalf("这一套方案里 sidebar(%v)和页面底色(%v)是同一个色——"+
- "「这一列的底是方案的 sidebar」这条判据在它下面恒真,换一套方案",
- sidebar, theme.Background)
- }
-
- // 判据一:**底色同一片**。
- //
- // ⚠️ **取样点都在两栏的空处**(侧栏 x=190、这一列 x=390,都在底下那
- // 一段空白里):取在菜单项上会量到那一项的底,取在收藏夹上会量到别
- // 的东西。
- want := rgbaOf(sidebar)
- sidePx := pixelAt(img, 190, 650)
- panePx := pixelAt(img, 390, 650)
- if !nearRGBA(sidePx, want, 3) {
- t.Errorf("侧栏的底色是 %v,want %v", sidePx, want)
- }
- if !nearRGBA(panePx, want, 3) {
- t.Errorf("分组那一列的底色是 %v,want %v——它要和侧栏是同一片"+
- "(方案里的 `sidebar`)", panePx, want)
- }
- // 顺带钉住「内容区是另一片」:内容区的底是**页面底色**,不是 `sidebar`。
- bodyPx := pixelAt(img, 500, 650)
- if !nearRGBA(bodyPx, rgbaOf(theme.Background), 3) {
- t.Errorf("内容区的底色是 %v,want %v(页面底色)", bodyPx, theme.Background)
- }
-
- // 判据三:**选中那一格是同一层淡底**。
- sideCur, ok := tt.Find("我的关注")
- if !ok {
- t.Fatalf("侧栏上没有「我的关注」那一项,文字有 %q", tt.Texts())
- }
- paneCur, ok := tt.Find("全部关注(3)")
- if !ok {
- t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts())
- }
- sideBg := modeOf(bandPixels(img, sideCur))
- paneBgRow := modeOf(bandPixels(img, paneCur))
- if !nearRGBA(sideBg, paneBgRow, 3) {
- t.Errorf("侧栏当前项的底色是 %v,分组那一列当前项的底色是 %v——"+
- "两列的选中态得是同一个数", sideBg, paneBgRow)
- }
- // 🔴 **这一档是实心主色底**(票:用户要「深底色 + 白字」):淡底那一
- // 档和侧栏底色的对比度只有 1.09:1 ~ 1.29:1——几乎分不出来。
- if !nearRGBA(paneBgRow, rgbaOf(theme.Accent), 3) {
- t.Errorf("分组那一列当前项的底色是 %v,want %v(实心主色底)",
- paneBgRow, theme.Accent)
- }
- })
+// ⚠️ **不验「摆开之后画面上有什么」**:弹层里有三态(加载中 / 拉失败了 / 正常
+// 名单),三态摆的东西互不相同——验「全部关注(N) 在不在」会把前两态挡在外面。
+// 点开没点开由各调用方自己的断言兜底(它们都在找弹层里具体的字)。
+func openFollowGroupSelect(t *testing.T, tt *mygoui.Tester) {
+ t.Helper()
+ if err := tt.Click("选择关注分组"); err != nil {
+ t.Fatalf("点不到分组下拉:%v,文字有 %q", err, tt.Texts())
}
+ tt.Frame()
}
-// TestTheFollowPaneSelectedRowIsReadable 钉住「选中那一格:淡底 + 读得出来的字」。
-//
-// 三条判据缺一不可:
+// TestTheFollowGroupSelectSeparatesAllFromTheGroups 钉住「全部关注(N)」和分组项
+// 分得开——它们不是一伙的:前一个是「我关注的人」这个全集,后一个是「B 站上我
+// 自己建的分组」。摆成同一个样子的时候用户分不清「这一格是全部」还是「这一格
+// 也是一个分组」。
//
-// 1. **底是 `accentSoft` 淡底**,不是实心主色(§七「我们比上游好、不要改回去」
-// 里那一条;实心主色那一档是「被选中的选项」,不是「当前在哪」)。
-// 2. **字是按那层底现算的**(浅色下是压深到 ≥4.5:1 的那一档,深色下是主色)。
-// 3. **量出来的对比度 ≥ 4.5:1**——前两条是「配方对不对」,这一条是「屏幕上读不
-// 读得出来」。
-//
-// 另外:**没选中的是次要色**——这一列是第二层导航,选中那一格才是要看的那一格。
-func TestTheFollowPaneSelectedRowIsReadable(t *testing.T) {
+// 判据是**中间那道线**:它画在第一项和第一个分组之间,线两侧是同色(弹层面板
+// 是页面底色),所以只能靠线本身分——而它得是**主题的分隔线**(`t.Border`),
+// 不是随便一道什么线。
+func TestTheFollowGroupSelectSeparatesAllFromTheGroups(t *testing.T) {
for _, dark := range []bool{false, true} {
name := "浅色"
if dark {
@@ -204,99 +127,38 @@ func TestTheFollowPaneSelectedRowIsReadable(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
_, tt := followPaneShell(t, dark)
+ openFollowGroupSelect(t, tt)
theme := BuildTheme(followPaneSettings(), dark)
img := tt.Image()
cur, ok := tt.Find("全部关注(3)")
if !ok {
- t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts())
- }
- sib, ok := tt.Find("特别关注(1)")
- if !ok {
- t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts())
- }
-
- // 底:实心主色(票:用户要「深底色 + 白字」)。
- curBg := modeOf(bandPixels(img, cur))
- if !nearRGBA(curBg, rgbaOf(theme.Accent), 3) {
- t.Errorf("选中那一格的底色是 %v,want %v(实心主色底)",
- curBg, theme.Accent)
- }
- // 字:主色底上读得出来的那一档(`t.AccentText`)。
- //
- // ⚠️ **容差是 24**:字是抗锯齿画的,量到的那个像素是字色和底色的混合。
- curInk := farthestFrom(img, cur, curBg)
- wantInk := flatSolidCurrentText(theme)
- if !nearRGBA(curInk, rgbaOf(wantInk), 24) {
- t.Errorf("选中那一格的字色是 %v,want %v", curInk, wantInk)
- }
- // 对比度:屏幕上真的读得出来。
- if r := ink.Ratio(rgbaToColor(curInk), rgbaToColor(curBg)); r < 4.5 {
- t.Errorf("选中那一格的字压在底上只有 %.2f:1(< 4.5)", r)
- }
-
- // 没选中的那一格:**次要色**。
- //
- // ⚠️ **那一格没有底色**(没选中的行不铺底),所以它的「众数」是这一列
- // 的底色——字要从「离底最远的那一个像素」里量。
- sibBg := modeOf(bandPixels(img, sib))
- sibInk := farthestFrom(img, sib, sibBg)
- if !nearRGBA(sibInk, rgbaOf(theme.TextMuted), 24) {
- t.Errorf("没选中那一格的字色是 %v,want %v(次要色)",
- sibInk, theme.TextMuted)
- }
- })
- }
-}
-
-// TestTheFollowPaneSeparatesAllFromTheGroups 钉住「全部关注(N)」和分组格分得开。
-//
-// 它们不是一伙的:前一个是「我关注的人」这个全集,后一个是「B 站上我自己建的
-// 分组」。摆成同一个样子的时候用户分不清「这一格是全部」还是「这一格也是一个
-// 分组」。
-//
-// 判据是**中间那道线**:它画在第一个分组那一行的上面,线两侧是同色(这一列
-// 整列都是方案的 `sidebar`),所以只能靠线本身分——而它得是**主题的分隔线**
-// (`t.Border`),不是随便一道什么线。
-func TestTheFollowPaneSeparatesAllFromTheGroups(t *testing.T) {
- for _, dark := range []bool{false, true} {
- name := "浅色"
- if dark {
- name = "深色"
- }
- t.Run(name, func(t *testing.T) {
- s, tt := followPaneShell(t, dark)
- theme := BuildTheme(followPaneSettings(), dark)
- img := tt.Image()
-
- cur, ok := tt.Find("全部关注(3)")
- if !ok {
- t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts())
+ t.Fatalf("分组下拉里没有「全部关注(3)」那一项,文字有 %q", tt.Texts())
}
first, ok := tt.Find("特别关注(1)")
if !ok {
- t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts())
+ t.Fatalf("分组下拉里没有「特别关注(1)」那一项,文字有 %q", tt.Texts())
}
second, ok := tt.Find("默认分组(1)")
if !ok {
- t.Fatalf("分组那一列上没有「默认分组(1)」那一格,文字有 %q", tt.Texts())
+ t.Fatalf("分组下拉里没有「默认分组(1)」那一项,文字有 %q", tt.Texts())
}
- paneBg := rgbaOf(s.sidebarBackground(theme))
- // 线的颜色:主题的分隔线压在这一列的底上。
+ paneBg := rgbaOf(theme.Background)
+ // 线的颜色:主题的分隔线压在面板底上。
line := rgbaOf(theme.Border.Over(rgbaToColor(paneBg)))
- // 选中那一格(全部关注)的淡底——**它是这一带另一种「不是线」的色**,
+ // 高亮那一档的实心底——**它是这一带另一种「不是线」的色**,
// 判据必须把它排除掉(理由见 findSeparator)。
- selBg := rgbaOf(flatCurrentBg(theme))
+ selBg := rgbaOf(theme.Accent)
y := findSeparator(img, cur.X, cur.X+cur.W, cur.Y+cur.H, first.Y, paneBg, selBg, line)
if y < 0 {
t.Errorf("「全部关注(3)」和「特别关注(1)」之间没有分界——" +
- "那一格(全部关注)和后面那些(分组)不是一伙的")
+ "那一项(全部关注)和后面那些(分组)不是一伙的")
return
}
- // ⚠️ **分界只在第一格和分组之间**:分组和分组之间**不该**有线,
- // 否则那一列看起来像一列表格。
+ // ⚠️ **分界只在第一项和分组之间**:分组和分组之间**不该**有线,
+ // 否则弹层看起来像一列表格。
if y2 := findSeparator(img, first.X, first.X+first.W, first.Y+first.H, second.Y,
paneBg, selBg, line); y2 >= 0 {
t.Errorf("「特别关注(1)」和「默认分组(1)」之间也有一道线(y=%d)——"+
@@ -305,12 +167,11 @@ func TestTheFollowPaneSeparatesAllFromTheGroups(t *testing.T) {
// 🔴 **另一条独立的判据:分界那一带比别处宽**。
//
- // 「全部关注」那一格和第一个分组之间要留出分界线自己的上下留白
- // (4 + 4),而分组和分组之间只有行与行之间那 2 的缝。所以前者的
- // 字距**明显更大**——线被删掉(或者被换成一段空隙)的话,这一条
- // 也会红。
+ // 第一项和第一个分组之间要留出分界线自己的上下留白(4 + 4),而分组
+ // 和分组之间只有项与项之间那一点缝。所以前者的字距**明显更大**——线
+ // 被删掉(或者被换成一段空隙)的话,这一条也会红。
//
- // ⚠️ **量的是「字」之间的距离**:行之间那 2 的缝在两处都有,只有
+ // ⚠️ **量的是「字」之间的距离**:项之间那点缝在两处都有,只有
// 分界那一带多出分界线自己的留白。
gapFirst := first.Y - (cur.Y + cur.H)
gapSecond := second.Y - (first.Y + first.H)
@@ -326,11 +187,11 @@ func TestTheFollowPaneSeparatesAllFromTheGroups(t *testing.T) {
//
// # 一行要算「那道线」,得同时满足三样
//
-// 1. **不是这一列的底色**(`bg`)——底色的行不是线;
-// 2. **不是选中那一格的淡底**(`sel`)——⚠️ **这一条是必需的**(2026-10-25
-// 的评审抓出来的):「全部关注」那一格就铺着淡底,而暖纸浅色下那层淡底
-// (#E0F9E9)离那道线(#DBDBDB)**比离底色还近**——只判「离线更近」的
-// 话,淡底自己会被当成线,于是**把线删掉测试照样绿**。
+// 1. **不是面板的底色**(`bg`)——底色的行不是线;
+// 2. **不是高亮那一档的实心底**(`sel`)——⚠️ **这一条是必需的**(2026-10-25
+// 的评审抓出来的同形坑):高亮那一格铺着实心底,而实心底离那道线**比离底色
+// 还近**——只判「离线更近」的话,实心底自己会被当成线,于是**把线删掉测试
+// 照样绿**。
// 3. **离那道线更近**(或者至少不比离底色远)——线的那一档压上去的。
//
// ⚠️ **判据是「更靠近线那一档」,不是「等于线那个色」**:那一行的位置是算出来
@@ -347,7 +208,7 @@ func findSeparator(img *image.RGBA, x0, x1, y0, y1 float32, bg, sel, line color.
continue
}
if maxDiff(p, sel) < 6 {
- // 和选中那一格的淡底一样 → 那是那一格的底,不是线。
+ // 和高亮那一档的实心底一样 → 那是那一项的底,不是线。
run = 0
continue
}
@@ -365,19 +226,19 @@ func findSeparator(img *image.RGBA, x0, x1, y0, y1 float32, bg, sel, line color.
return -1
}
-// TestTheFollowPaneTruncatesALongGroupName 钉住「分组名太长怎么办」。
+// TestTheFollowGroupSelectTruncatesALongGroupName 钉住「分组名太长怎么办」。
//
// 答:**截断,不换行**——和侧栏收藏夹那一行、上游的 `truncate` 是同一个做法。
//
// 三条判据:
//
-// 1. **那个名字一行放不下**(量出来比这一列能给的宽还长)——所以它**必须**被
+// 1. **那个名字一行放不下**(量出来比弹层能给的宽还长)——所以它**必须**被
// 截断,否则就该换行了。
-// 2. **它就是一行**:那一行的字和旁边那些短名字的字一样高(换行的话会高出一截,
+// 2. **它就是一行**:那一项的字和旁边那些短名字的字一样高(换行的话会高出一截,
// 把下面那些分组整体顶下去)。
-// 3. **它没有越过这一列的右缘**(截断,不是让它溢出到内容区那边去)。
-func TestTheFollowPaneTruncatesALongGroupName(t *testing.T) {
- // ⚠️ **名字后面还跟着人数**(`全部关注(3)` 那一档的写法):量宽度和找那一格
+// 3. **它没有越过弹层的右缘**(弹层 `MaxWidth` 就是它的硬边界)。
+func TestTheFollowGroupSelectTruncatesALongGroupName(t *testing.T) {
+ // ⚠️ **名字后面还跟着人数**(`全部关注(3)` 那一档的写法):量宽度和找那一项
// 用的都是**画出来的那一句**。
const long = "常听的那些不太熟但是每首都想循环到会唱完整首的 UP 主(1)"
@@ -388,54 +249,60 @@ func TestTheFollowPaneTruncatesALongGroupName(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
_, tt := followPaneShell(t, dark)
+ openFollowGroupSelect(t, tt)
+
short, ok := tt.Find("特别关注(1)")
if !ok {
- t.Fatalf("分组那一列上没有「特别关注(1)」那一格,文字有 %q", tt.Texts())
+ t.Fatalf("分组下拉里没有「特别关注(1)」那一项,文字有 %q", tt.Texts())
}
- // 判据一:**那一句本身有多宽**比「那一格有多宽」重要。
+ // 判据一:**那一句本身有多宽**比「弹层有多宽」重要。
//
- // ⚠️ **量的是同一个字号**(界面上那一格是 14,见 `follow.go`)。
+ // ⚠️ **量的是同一个字号**(弹层里那一项是 14,见 `follow.go`)。
var measured float32
mygoui.NewTester(func(c *mygoui.Context) {
measured, _ = c.MeasureText(0, mygoui.Span{Text: long, Size: 14})
}, 300, 60).Frame()
if measured <= short.W {
- t.Skipf("那一句只有 %.1f 宽,这一格有 %.1f——放得下,这一票没测到"+
+ t.Skipf("那一句只有 %.1f 宽,这一项有 %.1f——放得下,这一票没测到"+
"「太长怎么办」", measured, short.W)
}
// 判据二:**它就是一行**:字的高度和旁边那一个短名字一样。
longBox, ok := tt.Find(long)
if !ok {
- t.Fatalf("分组那一列上没有那个长名字,文字有 %q", tt.Texts())
+ t.Fatalf("分组下拉里没有那个长名字,文字有 %q", tt.Texts())
}
if longBox.H > short.H+1 {
t.Errorf("那个长名字占 %.1f 高,而短名字占 %.1f——它换行了,"+
"下面那些分组会被顶下去", longBox.H, short.H)
}
- // 判据三:**没有越过这一列的右缘**。
+ // 判据三:**没有越过弹层的右缘**。
//
- // ⚠️ **右缘是「全部关注(3)」那一格的右缘加一点**:那一格是这一列里
- // 最宽的(它的底色铺满整列),而长名字那一格和它左对齐。
+ // ⚠️ **右缘是弹层里其它项的右缘**:弹层一整列对齐,最宽那一项的右缘
+ // 就是弹层的内缘——长名字那一项不许伸出去。
cur, ok := tt.Find("全部关注(3)")
if !ok {
- t.Fatalf("分组那一列上没有「全部关注(3)」那一格,文字有 %q", tt.Texts())
+ t.Fatalf("分组下拉里没有「全部关注(3)」那一项,文字有 %q", tt.Texts())
}
if longBox.X+longBox.W > cur.X+cur.W+1 {
- t.Errorf("那个长名字伸到 x=%.1f,而这一列到 x=%.1f——它溢出去了",
- longBox.X+longBox.W, cur.X+cur.W)
+ t.Errorf("那个长名字伸到 x=%.1f,而「全部关注(3)」那一项到 x=%.1f——"+
+ "它溢出弹层了", longBox.X+longBox.W, cur.X+cur.W)
}
})
}
}
-// TestTheFollowPaneHasThreeStates 钉住这一列的三态:**加载中 / 拉失败了 / 没有分组**。
+// TestTheFollowGroupSelectHasThreeStates 钉住弹层里的三态:
+// **加载中 / 拉失败了 / 没有分组**。
+//
+// 三句**互不通用**:用户读到「还没有分组」时不知道该做什么,而这三种情形要做的
+// 下一步完全不同(等 / 重试 / 去建分组)。
//
-// 三句**互不通用**:用户读到「还没有分组」时不知道该做什么,而这一列的三种
-// 情形要做的下一步完全不同(等 / 重试 / 去建分组)。
-func TestTheFollowPaneHasThreeStates(t *testing.T) {
+// ⚠️ **三句都在弹层里**:断言前都要先点开触发器——不然弹层根本没摆开,画面上
+// 一句都没有。
+func TestTheFollowGroupSelectHasThreeStates(t *testing.T) {
t.Run("加载中", func(t *testing.T) {
b := newUIExplore(t)
gate := make(chan struct{})
@@ -451,11 +318,12 @@ func TestTheFollowPaneHasThreeStates(t *testing.T) {
tt.Frame()
homeFrame(tt)
+ openFollowGroupSelect(t, tt)
if !showsText(tt, "正在加载分组…") {
- t.Errorf("分组还在拉,这一列上却没有「正在加载分组…」,文字有 %q", tt.Texts())
+ t.Errorf("分组还在拉,弹层里却没有「正在加载分组…」,文字有 %q", tt.Texts())
}
if showsText(tt, "还没有分组") {
- t.Error("分组还在拉,这一列上却说了「还没有分组」——那两件事不一样")
+ t.Error("分组还在拉,弹层里却说了「还没有分组」——那两件事不一样")
}
})
@@ -473,14 +341,15 @@ func TestTheFollowPaneHasThreeStates(t *testing.T) {
waitFrames(t, tt, "分组拉失败了", func() bool { return f.tagErr != nil })
homeFrame(tt)
+ openFollowGroupSelect(t, tt)
if !showsText(tt, "分组没拉出来") {
- t.Errorf("分组拉失败了,这一列上却没有说「分组没拉出来」,文字有 %q", tt.Texts())
+ t.Errorf("分组拉失败了,弹层里却没有说「分组没拉出来」,文字有 %q", tt.Texts())
}
if !showsText(tt, "重试拉分组") {
t.Errorf("分组拉失败了,却没有「重试拉分组」那一枚,文字有 %q", tt.Texts())
}
if showsText(tt, "还没有分组") {
- t.Error("分组拉失败了,这一列上却说了「还没有分组」——用户会以为他真的没建过")
+ t.Error("分组拉失败了,弹层里却说了「还没有分组」——用户会以为他真的没建过")
}
})
@@ -496,26 +365,63 @@ func TestTheFollowPaneHasThreeStates(t *testing.T) {
waitFrames(t, tt, "分组到了(空的)", func() bool { return f.tagStarted })
homeFrame(tt)
+ openFollowGroupSelect(t, tt)
if !showsText(tt, "还没有分组") {
- t.Errorf("一个分组都没有,这一列上却没有说「还没有分组」,文字有 %q", tt.Texts())
+ t.Errorf("一个分组都没有,弹层里却没有说「还没有分组」,文字有 %q", tt.Texts())
}
if showsText(tt, "正在加载分组…") {
- t.Error("分组已经拉到了(是空的),这一列上却还在说「正在加载分组…」")
+ t.Error("分组已经拉到了(是空的),弹层里却还在说「正在加载分组…」")
}
})
}
+// TestTheFollowGroupSelectShowsTheCurrentGroup 钉住「收起那一格写的是当前那一格
+// 的名字」——停在「全部关注」写「全部关注」,停在某一组写那一组的名字(上游
+// `index.tsx:206` 的 `` 同一个口径)。塌回「我的关注」的话,用户以为自己在
+// 看全部,而画面上摆的是某一组的人。
+func TestTheFollowGroupSelectShowsTheCurrentGroup(t *testing.T) {
+ b := newUIExplore(t)
+ uiFollowTags(b, map[int]any{777: "常听的"})
+ uiFollowList(b, 1, []any{uiFollowUser(11, "全部关注里的那个人")})
+ uiFollowSearch(b, "", nil)
+ uiFollowTag(b, 777, []any{uiFollowUser(77, "常听的那个人")})
+
+ s, tt, _ := newSpaceShell(t, b, true, "77")
+ s.navigate(followPath)
+ tt.Frame()
+ f := s.followPageOf()
+ waitFrames(t, tt, "第一格到了", func() bool { return len(f.users) == 1 && f.tagStarted })
+ tt.Frame()
+
+ // 停在「全部关注」:收起那一格写「全部关注」。
+ if !showsText(tt, "全部关注") {
+ t.Fatalf("收起那一格没写「全部关注」,文字有 %q", tt.Texts())
+ }
+
+ openFollowGroupSelect(t, tt)
+ if err := tt.Click("常听的(1)"); err != nil {
+ t.Fatalf("下拉里点不到「常听的」:%v,文字有 %q", err, tt.Texts())
+ }
+ waitFrames(t, tt, "换到那一组", func() bool { return f.tab == followTagTab(777) })
+ tt.Frame()
+
+ // 停在某一组:收起那一格写那一组的名字。
+ if !showsText(tt, "常听的") {
+ t.Errorf("换组之后收起那一格没跟着换,文字有 %q", tt.Texts())
+ }
+}
+
// TestTheFollowPaneHasNoHardcodedColorsOrRadii 钉住「颜色和圆角不许写死」。
//
-// 这一列的底色、淡底、字色、圆角**全都是从主题现算的**:主题方案换了整套跟着
+// 这一页的底色、淡底、字色、圆角**全都是从主题现算的**:主题方案换了整套跟着
// 换(暖纸 / 暮光 / 柔雾 / 经典),而写死的色值只对其中一套是对的。
//
-// 判据是**源码**:这一列不许出现颜色字面量,也不许给圆角写一个数。
+// 判据是**源码**:`follow.go` 不许出现颜色字面量,也不许给圆角写一个数。
//
-// ⚠️ **认的是「这一列」那几处**:整个 `follow.go` 都不许有——右边那个网格
-// (`viewFollowCard` 那些)也是从主题取色的,而「取消关注」那一枚药丸的圆角
-// 是 **999**(药丸是形状,不跟主题走,见 `followActionPillRadius` 的注释)。
-// 所以药丸那一处是**特许**的,其余一处都不许写死。
+// ⚠️ **认的是整份 `follow.go`**:右边那个网格(`viewFollowCard` 那些)也是从
+// 主题取色的,而「取消关注」那一枚药丸的圆角是 **999**(药丸是形状,不跟主题走,
+// 见 `followActionPillRadius` 的注释)。所以药丸那一处是**特许**的,其余一处都
+// 不许写死。
func TestTheFollowPaneHasNoHardcodedColorsOrRadii(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "follow.go", nil, 0)