diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarIconButton.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarIconButton.swift index c1f42de68685..9525c830a87c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarIconButton.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarIconButton.swift @@ -60,7 +60,7 @@ final class SidebarIconButton: ClickableButton { self.label = label self.pointSize = pointSize super.init(frame: .zero) - renderIcon() + // The image comes when the button first shows (``renderIcon()``): a row's hover controls stay hidden. imagePosition = .imageOnly // An image-only button: no "Button" title for VoiceOver and hint mode (they read the label). title = "" @@ -79,8 +79,6 @@ final class SidebarIconButton: ClickableButton { convenience init(symbol: String, pointSize: @escaping () -> CGFloat = { Metrics.smallIconSize }, label: String) { self.init(icon: IconName(symbol), pointSize: pointSize, label: label) self.symbol = symbol - renderedSize = 0 - renderIcon() } @available(*, unavailable) @@ -132,7 +130,11 @@ final class SidebarIconButton: ClickableButton { onMenuOpenChange?(false) } + /// Makes the icon image for the current point size, once the button shows: a hidden button, or one in a + /// hidden row, has none yet (REOPEN-F7: each workspace row's 5 hover-only buttons made theirs before the + /// window's first frame, about 4 ms of it). Showing, joining a window, a layout and a size read make it. private func renderIcon() { + guard !isHiddenOrHasHiddenAncestor else { return } let size = pointSize() guard size != renderedSize else { return } renderedSize = size @@ -147,6 +149,13 @@ final class SidebarIconButton: ClickableButton { renderIcon() } + /// Sized by its icon, as an image-only button is. `renderedSize` (0: no icon yet), not `image`: AppKit answers a + /// button with no image of its own with a shared 1 x 1 placeholder, so `image` is never nil here. + override var intrinsicContentSize: NSSize { + if renderedSize == 0 { renderIcon() } + return super.intrinsicContentSize + } + nonisolated override var wantsUpdateLayer: Bool { true } override var acceptsFirstResponder: Bool { @@ -169,6 +178,7 @@ final class SidebarIconButton: ClickableButton { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + if window != nil { renderIcon() } if takesKeyFocus { keyRepaint.track() } } @@ -212,6 +222,7 @@ final class SidebarIconButton: ClickableButton { override func viewDidUnhide() { super.viewDidUnhide() + renderIcon() hover.pointer?.refresh() } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift index 34bcba995cbd..771d3177c271 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift @@ -167,6 +167,7 @@ final class WorkspaceRowView: SidebarRowView { layer?.addSublayer(progressTrack) closeButton.isHidden = true audioButton.isHidden = true + pinButton.isHidden = true // hover only (layoutPin shows it), so it makes no image before then placeholderBar.wantsLayer = true placeholderBar.layer?.cornerRadius = Metrics.chipCornerRadius(height: SidebarStyle.placeholderBarHeight) placeholderBar.isHidden = true diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarIconButtonLazyImageTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarIconButtonLazyImageTests.swift new file mode 100644 index 000000000000..ac24b380284d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarIconButtonLazyImageTests.swift @@ -0,0 +1,84 @@ +import AppKit +import CmuxNextIcons +@testable import CmuxNextSidebar +import Testing +import CmuxNextTestSupport + +/// REOPEN-F7: a reopened window's sidebar makes 5 icon buttons per workspace row (close, audio, pin, tree fold, +/// more) and they stay hidden until the pointer is on the row. Each made its icon image when it was made: about +/// 4 ms of the profiled window build before the first frame (`SidebarIconButton.renderIcon`). A hidden button +/// needs no image; it makes it when it first shows. +@MainActor @Suite(.closesItsWindows, .serialized) struct SidebarIconButtonLazyImageTests { + /// The button's icon image: AppKit answers a button with no image of its own with a shared 1 x 1 + /// placeholder, so "no icon yet" is no image at least 2 points wide. + private static func icon(of button: SidebarIconButton) -> NSImage? { + button.image.flatMap { $0.size.width > 1 ? $0 : nil } + } + + private static func window() -> NSWindow { + NSWindow(contentRect: NSRect(x: 200, y: 400, width: 300, height: 200), styleMask: [.titled], backing: .buffered, defer: false) + } + + @Test func aHiddenButtonMakesNoImageUntilItShows() throws { + let window = Self.window() + let button = SidebarIconButton(icon: .actionClose, label: "Close Workspace") + button.isHidden = true + button.frame = NSRect(x: 20, y: 20, width: 20, height: 20) + let content = try #require(window.contentView) + content.addSubview(button) + content.layoutSubtreeIfNeeded() + #expect(Self.icon(of: button) == nil, "a hidden button made its icon image") + button.isHidden = false + let image = try #require(Self.icon(of: button), "a shown button has no icon image") + #expect(image.isTemplate) + #expect(image.size.width > 0) + } + + @Test func aButtonInAHiddenRowMakesNoImageUntilTheRowShows() throws { + let window = Self.window() + let row = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) + row.isHidden = true + let button = SidebarIconButton(icon: .actionClose, label: "Close Workspace") + button.frame = NSRect(x: 160, y: 5, width: 20, height: 20) + row.addSubview(button) + let content = try #require(window.contentView) + content.addSubview(row) + content.layoutSubtreeIfNeeded() + #expect(Self.icon(of: button) == nil, "a button in a hidden row made its icon image") + row.isHidden = false + #expect(Self.icon(of: button) != nil, "the button of a shown row has no icon image") + } + + @Test func aShownButtonHasItsImageInAWindowAndWithoutOne() throws { + let window = Self.window() + let shown = SidebarIconButton(icon: .actionClose, label: "Close Workspace") + try #require(window.contentView).addSubview(shown) + #expect(Self.icon(of: shown) != nil, "a button shown in a window has no icon image") + // Sized by its image outside a window too (a stack or constraints read its intrinsic size). + let loose = SidebarIconButton(icon: .actionClose, label: "Close Workspace") + #expect(loose.intrinsicContentSize.width > 0) + #expect(Self.icon(of: loose) != nil) + } + + @Test func aNewIconOnAHiddenButtonWaitsToo() throws { + let window = Self.window() + let button = SidebarIconButton(icon: .disclosureExpanded, label: "Collapse") + button.isHidden = true + try #require(window.contentView).addSubview(button) + button.icon = .disclosureCollapsed + #expect(Self.icon(of: button) == nil) + button.isHidden = false + #expect(Self.icon(of: button) != nil) + } + + /// Review of #33112 (P3): a workspace row's Unpin button was not hidden at init, so it made its image when the row + /// joined the window, before the row's first layout hid it. Every hover-only control starts hidden. + @Test func aNewRowsHoverControlsStartHidden() throws { + let row = WorkspaceRowView(key: .workspace(WorkspaceID("lazy-controls"))) + for (name, button) in [("close", row.closeButton), ("pin", row.pinButton), ("audio", row.audioButton), + ("tree fold", row.treeFoldButton)] { + #expect(button.isHidden, "a new row's \(name) button starts shown") + #expect(Self.icon(of: button) == nil, "a new row's \(name) button made its image") + } + } +}