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
22 changes: 22 additions & 0 deletions .github/workflows/profile-header-layout-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Profile header layout tests

on:
pull_request:
branches: [master]
paths:
- 'submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift'
- 'Tests/profile_header_layout/**'
- '.github/workflows/profile-header-layout-tests.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
regression:
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- name: Check status and rating layout with an empty name
run: python3 Tests/profile_header_layout/run.py
103 changes: 103 additions & 0 deletions Tests/profile_header_layout/fixtures.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import Foundation

extension CGRect {
var center: CGPoint { CGPoint(x: midX, y: midY) }
}

final class Layer {
var alpha: CGFloat = 1.0
}

final class Node {
var frame = CGRect.zero
var alpha: CGFloat = 1.0
var scale: CGFloat = 1.0
let layer = Layer()
}

final class Rating {
let view: Node? = Node()
}

struct Transition {
func updateFrameAdditiveToCenter(node: Node, frame: CGRect) { node.frame = frame }
func updateFrameAdditive(node: Node, frame: CGRect) { node.frame = frame }
func updateFrameAdditive(view: Node, frame: CGRect) { view.frame = frame }
func updateFrame(node: Node, frame: CGRect) { node.frame = frame }
func updatePosition(node: Node, position: CGPoint) { node.frame.origin = position }
func updateSublayerTransformScale(node: Node, scale: CGFloat) { node.scale = scale }
func updateSublayerTransformScaleAdditive(node: Node, scale: CGFloat) { node.scale = scale }
func updateAlpha(layer: Layer, alpha: CGFloat) { layer.alpha = alpha }
}

final class Header {
let titleNodeRawContainer = Node()
let titleNodeContainer = Node()
let titleNode = Node()
let subtitleNodeRawContainer = Node()
let subtitleNodeContainer = Node()
let subtitleNode = Node()
let panelSubtitleNode = Node()
let usernameNodeRawContainer = Node()
let usernameNodeContainer = Node()
let usernameNode = Node()
let subtitleRating: Rating? = Rating()
let subtitleBadgeView: Node? = Node()
var isAvatarExpanded = false
var navigationTransition: Bool?

func update(titleSize: CGSize, subtitleSize: CGSize) {
let transition = Transition()
let titleFrame = CGRect(origin: CGPoint(x: (390.0 - titleSize.width) / 2.0, y: 200.0), size: titleSize)
let subtitleFrame = CGRect(origin: CGPoint(x: (390.0 - subtitleSize.width) / 2.0, y: titleFrame.maxY + 1.0), size: subtitleSize)
let usernameFrame = CGRect(origin: CGPoint(x: 195.0, y: titleFrame.maxY + 1.0), size: .zero)
let expandedTitleScale: CGFloat = 0.8
let transitionFraction: CGFloat = 0.0
let transitionSourceTitleFrame = CGRect.zero
let transitionSourceSubtitleFrame = CGRect.zero
let titleHorizontalOffset: CGFloat = 0.0
let titleExpandedHorizontalOffset: CGFloat = 0.0
let titleCollapseFraction: CGFloat = 0.0
let titleMinScale: CGFloat = 0.6
let subtitleMinScale: CGFloat = 0.8
let titleOffset: CGFloat = 0.0
let apparentTitleLockOffset: CGFloat = 0.0
let subtitleOffset: CGFloat = 0.0
let panelSubtitleOffset: CGFloat = 0.0
let subtitleAlpha: CGFloat = 1.0
let subtitleBadgeSize: CGSize? = CGSize(width: 20.0, height: 20.0)
let subtitleRatingSize: CGSize? = CGSize(width: 30.0, height: 30.0)
let subtitleRatingInset: CGFloat = 29.0
fatalError("production layout")
}

func verify(titleSize: CGSize, subtitleSize: CGSize) {
update(titleSize: titleSize, subtitleSize: subtitleSize)
let expectedCenter = CGPoint(x: 195.0, y: 201.0 + titleSize.height + subtitleSize.height / 2.0)
precondition(subtitleNodeContainer.frame.origin == expectedCenter, "status must stay below the avatar even when the name has no glyphs")
precondition(titleNodeContainer.frame.origin.x == 195.0, "name container must remain centered")
precondition(titleNodeContainer.scale.isFinite && titleNodeContainer.scale > 0.0)
precondition(subtitleNodeContainer.scale.isFinite && subtitleNodeContainer.scale > 0.0)
let ratingFrame = subtitleRating!.view!.frame
precondition(ratingFrame.width == 30.0 && ratingFrame.height == 30.0)
precondition(ratingFrame.minX == -subtitleSize.width / 2.0 - 29.0, "rating must follow the status width")
precondition(ratingFrame.midY == 0.0, "rating must align vertically with status")
precondition(expectedCenter.y + ratingFrame.midY > 200.0)
}
}

var count = 0
for expanded in [false, true] {
for navigating in [false, true] {
for titleSize in [CGSize(width: 120.0, height: 34.0), CGSize(width: 0.0, height: 34.0), .zero] {
let header = Header()
header.isAvatarExpanded = expanded
header.navigationTransition = navigating ? true : nil
for subtitleSize in [CGSize(width: 60.0, height: 20.0), CGSize(width: 170.0, height: 20.0), .zero, CGSize(width: 60.0, height: 20.0)] {
header.verify(titleSize: titleSize, subtitleSize: subtitleSize)
count += 1
}
}
}
}
print("profile header layout: \(count) cases passed")
32 changes: 32 additions & 0 deletions Tests/profile_header_layout/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pathlib import Path
import shutil
import subprocess
import tempfile


ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift"


def main():
source = SOURCE.read_text(encoding="utf-8")
start = source.index("\n", source.index("let apparentBackgroundHeight =")) + 1
end = source.index("\n if displayStandardTitle {", start)
layout = source[start:end]
fixture = Path(__file__).with_name("fixtures.swift").read_text(encoding="utf-8")
fixture = fixture.replace(" fatalError(\"production layout\")", layout)
compiler = shutil.which("swiftc")
if compiler is None:
raise SystemExit("swiftc is required; run on macOS with Xcode.")
with tempfile.TemporaryDirectory(prefix="profile-header-layout-") as directory:
directory = Path(directory)
main_file = directory / "main.swift"
executable = directory / "layout-tests"
main_file.write_text(fixture, encoding="utf-8")
subprocess.run([compiler, str(main_file), "-o", str(executable)], check=True)
subprocess.run([str(executable)], check=True)
subprocess.run([compiler, "-frontend", "-parse", str(SOURCE)], check=True)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
Expand Up @@ -2179,7 +2179,7 @@ final class PeerInfoHeaderNode: ASDisplayNode {
let apparentHeight = (1.0 - transitionFraction) * backgroundHeight + transitionFraction * transitionSourceHeight
let apparentBackgroundHeight = (1.0 - transitionFraction) * backgroundHeight + transitionFraction * transitionSourceHeight

if !titleSize.width.isZero && !titleSize.height.isZero {
do {
if self.navigationTransition != nil {
var neutralTitleScale: CGFloat = 1.0
var neutralSubtitleScale: CGFloat = 1.0
Expand All @@ -2188,8 +2188,8 @@ final class PeerInfoHeaderNode: ASDisplayNode {
neutralSubtitleScale = 1.0
}

let titleScale = (transitionFraction * transitionSourceTitleFrame.height + (1.0 - transitionFraction) * titleFrame.height * neutralTitleScale) / (titleFrame.height)
let subtitleScale = max(0.01, min(10.0, (transitionFraction * transitionSourceSubtitleFrame.height + (1.0 - transitionFraction) * subtitleFrame.height * neutralSubtitleScale) / (subtitleFrame.height)))
let titleScale = titleFrame.height.isZero ? neutralTitleScale : (transitionFraction * transitionSourceTitleFrame.height + (1.0 - transitionFraction) * titleFrame.height * neutralTitleScale) / titleFrame.height
let subtitleScale = subtitleFrame.height.isZero ? neutralSubtitleScale : max(0.01, min(10.0, (transitionFraction * transitionSourceSubtitleFrame.height + (1.0 - transitionFraction) * subtitleFrame.height * neutralSubtitleScale) / subtitleFrame.height))

var titleFrame = titleFrame
if !self.isAvatarExpanded {
Expand Down