From 143d4b147e9cfcf804c149cdd62b67bd5e7603f3 Mon Sep 17 00:00:00 2001 From: kittenello <174290551+kittenello@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:48:20 +0300 Subject: [PATCH] fix(profile): keep status and rating positioned for empty names --- .../workflows/profile-header-layout-tests.yml | 22 ++++ Tests/profile_header_layout/fixtures.swift | 103 ++++++++++++++++++ Tests/profile_header_layout/run.py | 32 ++++++ .../Sources/PeerInfoHeaderNode.swift | 6 +- 4 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/profile-header-layout-tests.yml create mode 100644 Tests/profile_header_layout/fixtures.swift create mode 100644 Tests/profile_header_layout/run.py diff --git a/.github/workflows/profile-header-layout-tests.yml b/.github/workflows/profile-header-layout-tests.yml new file mode 100644 index 00000000000..704dfd45ee9 --- /dev/null +++ b/.github/workflows/profile-header-layout-tests.yml @@ -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 diff --git a/Tests/profile_header_layout/fixtures.swift b/Tests/profile_header_layout/fixtures.swift new file mode 100644 index 00000000000..4a3eaaa5ea3 --- /dev/null +++ b/Tests/profile_header_layout/fixtures.swift @@ -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") diff --git a/Tests/profile_header_layout/run.py b/Tests/profile_header_layout/run.py new file mode 100644 index 00000000000..5a497f460ed --- /dev/null +++ b/Tests/profile_header_layout/run.py @@ -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() diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index 800b336d2c1..87c7d95b05b 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -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 @@ -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 {