diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift index a29683f6..9d1032fa 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -50,6 +50,10 @@ struct ArtifactorySection: View { /// the same thing — a fact about a person at a screen, never about the loop. let seenPostID: Int? let isFolded: Bool + /// How tall the scroll box may grow before it scrolls — the rail's share for the + /// board (`LoopWorkspaceRail.artifactoryHeightCap`), never more than + /// `maxScrollHeight`. + var maxHeight: CGFloat = ArtifactorySection.maxScrollHeight let onToggleFold: () -> Void /// Posts as "a human" — a click in the app has no `ZMX_SESSION` and no loop identity, /// which is exactly what a person talking to the whole graph is. @@ -58,11 +62,10 @@ struct ArtifactorySection: View { /// Whether the mirrored records are unfolded. Local and unpersisted, unlike the /// section's own fold: opening the receipts is a thing you do once to answer a /// question, not a way you prefer to read the board. - /// Past this the board scrolls rather than pushing the sections above it off the rail. - /// Every note is on the board and reachable — this is only how much of it is on screen - /// before the wheel takes over. About ten posts at the rail's default width, which is - /// the number asked for: enough to read a conversation, not so many that the rail - /// is nothing but the board. + /// The most the board's scroll box will ever be, on any window: about ten posts at + /// the rail's default width — enough to read a conversation, not so many that the + /// rail is nothing but the board. The rail hands down a smaller cap on a short + /// window (`LoopWorkspaceRail.artifactoryHeightCap`); this is the ceiling on that. static let maxScrollHeight: CGFloat = 600 @State private var showsRecords = false @@ -112,7 +115,7 @@ struct ArtifactorySection: View { // content's, and `frame(maxHeight:)` under it clamps that. The box is exactly // as tall as the posts until the cap, and scrolls after — no state, nothing to // go stale, nothing to fire late. - .frame(maxHeight: Self.maxScrollHeight) + .frame(maxHeight: maxHeight) .fixedSize(horizontal: false, vertical: true) // Outside the scroll view on purpose. Inside it the composer was one more row // in a list that can be taller than the rail — it could open scrolled out of diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index 669f914d..7c0a0c4b 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -199,7 +199,32 @@ struct LoopWorkspaceRail: View { graph.edges.filter { $0.to == node.id }.compactMap { graph.nodes[id: $0.from] } } + /// The most of the rail the board may take before it scrolls, as a share of the + /// rail's own height. The other flexible sections — the summary, the diagram — are + /// greedy and yield; the board hugs its posts with `fixedSize` and does not. On a + /// short window a fixed 600pt cap was enough, with THIS LOOP's 118 and the summary's + /// 120 floor, to overflow the stack and push the foot of the rail off the bottom. + /// A share cannot overflow on its own, and 40% still shows a conversation. + static func artifactoryHeightCap(railHeight: CGFloat) -> CGFloat { + min(ArtifactorySection.maxScrollHeight, max(160, railHeight * 0.4)) + } + var body: some View { + // Measured at the outside, where a `GeometryReader` is the container and not a + // guess from within a scroll view — the rail's height is what the board's share + // is a share *of*. + GeometryReader { proxy in + stack(artifactoryCap: Self.artifactoryHeightCap(railHeight: proxy.size.height)) + } + .frame(width: width) + .frame(maxHeight: .infinity) + .background(Theme.workspaceRail) + .overlay(alignment: .leading) { + Rectangle().fill(.white.opacity(0.07)).frame(width: 1) + } + } + + private func stack(artifactoryCap: CGFloat) -> some View { VStack(alignment: .leading, spacing: 10) { // Above `THIS LOOP` rather than below it: what the loop is doing this second // outranks where it sits in the graph, and a section you have to scroll to is a @@ -244,6 +269,7 @@ struct LoopWorkspaceRail: View { if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) { ArtifactorySection( graph: graph, seenPostID: seenArtifactoryPostID, isFolded: isArtifactoryFolded, + maxHeight: artifactoryCap, onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost) } footer @@ -251,10 +277,6 @@ struct LoopWorkspaceRail: View { .padding(12) .frame(width: width, alignment: .leading) .frame(maxHeight: .infinity) - .background(Theme.workspaceRail) - .overlay(alignment: .leading) { - Rectangle().fill(.white.opacity(0.07)).frame(width: 1) - } } private func section( diff --git a/graphcode/Tests/ArtifactoryRailShareTests.swift b/graphcode/Tests/ArtifactoryRailShareTests.swift new file mode 100644 index 00000000..2e5bb8be --- /dev/null +++ b/graphcode/Tests/ArtifactoryRailShareTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import graphcode + +/// The board's scroll box is capped by a share of the rail, so a rigid box can never be +/// what pushes the foot of the rail off a short window. +@Suite +struct ArtifactoryRailShareTests { + @Test + func theBoardTakesAShareOfTheRailNotAFixedHeight() { + // A 900pt rail: 40% is 360, well under the 600 ceiling and well over the floor. + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 900) == 360) + // A tall rail: the share would exceed the ceiling, so the ceiling wins — ten posts + // is enough on any window. + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 2000) == 600) + #expect(ArtifactorySection.maxScrollHeight == 600) + } + + /// A very short rail still shows a couple of posts rather than a sliver; below the + /// floor the rail has bigger problems than this section. + @Test + func aShortRailStillShowsSomething() { + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 300) == 160) + #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 0) == 160) + } + + /// The share leaves room for everything rigid above it: THIS LOOP (118) plus the + /// summary's floor (120) plus chrome, on a rail that fits one 1280×800 window. + @Test + func theShareLeavesRoomForTheRigidSections() { + let rail: CGFloat = 700 + let board = LoopWorkspaceRail.artifactoryHeightCap(railHeight: rail) + let rigidAbove: CGFloat = 118 + 120 + 24 + 24 + 12 * 2 + #expect(board + rigidAbove < rail) + } +}