diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0853ca1..b00e4beb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Scroll position lost when switching away from a table tab and back. (#2424) - A table statistics command on every switch between two table tabs. (#2424) - Another table's size and row count in Table Info when its statistics arrive late. (#2424) +- Active editor tab barely distinguishable from the inactive ones, worst in light appearance. (#2428) +- No selected editor tab at all in a background window on macOS 14 and 15. (#2428) +- Editor tab strip ignoring Increase Contrast and Reduce Transparency. (#2428) - Crash when a file changes in a linked SQL folder. (#2432) - Crash when a file changes in a linked connection folder, and on quit with one configured. - iOS: crash the first time the system reports memory pressure. diff --git a/TablePro/Theme/MaterialAccessibility.swift b/TablePro/Theme/MaterialAccessibility.swift index e1dfec272..fa0a649ea 100644 --- a/TablePro/Theme/MaterialAccessibility.swift +++ b/TablePro/Theme/MaterialAccessibility.swift @@ -17,6 +17,15 @@ internal enum MaterialRole { } } +/// The one rule for both settings, so a view that answers them by hand cannot drift from the six +/// that answer them through `themeMaterial`. The editor tab strip is that view: glass has no +/// `Material` to swap, so it leaves glass behind for its own opaque surfaces instead. +internal enum SolidSurfacePreference { + internal static func prefersSolid(reduceTransparency: Bool, contrast: ColorSchemeContrast) -> Bool { + reduceTransparency || contrast == .increased + } +} + private struct AccessibleMaterialBackground: ViewModifier { let role: MaterialRole let material: Material @@ -25,7 +34,7 @@ private struct AccessibleMaterialBackground: ViewModifier { @Environment(\.colorSchemeContrast) private var contrast func body(content: Content) -> some View { - if reduceTransparency || contrast == .increased { + if SolidSurfacePreference.prefersSolid(reduceTransparency: reduceTransparency, contrast: contrast) { content.background(role.solidFallback) } else { content.background(material) @@ -42,7 +51,7 @@ private struct AccessibleMaterialBackgroundShape: ViewModifier { @Environment(\.colorSchemeContrast) private var contrast func body(content: Content) -> some View { - if reduceTransparency || contrast == .increased { + if SolidSurfacePreference.prefersSolid(reduceTransparency: reduceTransparency, contrast: contrast) { content.background(role.solidFallback, in: shape) } else { content.background(material, in: shape) @@ -57,7 +66,7 @@ internal struct AccessibleMaterialScrim: View { @Environment(\.colorSchemeContrast) private var contrast var body: some View { - if reduceTransparency || contrast == .increased { + if SolidSurfacePreference.prefersSolid(reduceTransparency: reduceTransparency, contrast: contrast) { Rectangle().fill(MaterialRole.scrim.solidFallback) } else { Rectangle().fill(material) diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index a907a9ac9..711286450 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -22,9 +22,11 @@ import UniformTypeIdentifiers /// concentric at their ends, the selected one inset two points inside the track, which is why it /// never overruns the track's curve. /// -/// Track, selected tab and new-tab button are all glass, which is what the system does too. The -/// increments are small because each one samples the glass beneath it, and that is the effect: -/// the selection reads through its stroke and its label, not through a brighter fill. +/// Track, selected tab and new-tab button are all glass, which is what the system does too, but +/// the track and the tab on it are not the same glass. Two `.regular` surfaces stacked carry no +/// step of their own, because each samples the backdrop under the window rather than the glass it +/// sits on, so the selection would be whatever the content behind the strip happened to be. +/// `EditorTabStripEmphasis` holds them apart, and carries the measurements that set the distance. internal struct EditorTabStrip: View { internal let tabManager: QueryTabManager /// The dimension this engine's tabs are anchored to, so a label can name the container it @@ -35,6 +37,9 @@ internal struct EditorTabStrip: View { internal let onCloseOthers: (UUID) -> Void internal let onCloseAll: () -> Void internal let onNewTab: () -> Void + /// Left unset by the app, which reads the two accessibility settings instead. A test sets it, + /// because glass does not rasterise. + internal var surfaceStyle: EditorTabStripSurfaceStyle? @State private var hoveredTabId: UUID? /// The tab under the pointer during a reorder. Held here rather than in the item, because the @@ -42,12 +47,18 @@ internal struct EditorTabStrip: View { /// flight, so a line does not appear between two tabs that are mid-swap. @State private var draggingTabId: UUID? @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency internal var body: some View { glassContainer { HStack(spacing: EditorTabStripLayout.trackSpacing) { track - EditorTabStripNewButton(action: onNewTab, isWindowActive: isWindowActive) + EditorTabStripNewButton( + action: onNewTab, + isWindowActive: isWindowActive, + prefersSolidSurfaces: prefersSolidSurfaces + ) } .frame(height: EditorTabStripLayout.trackHeight) } @@ -119,7 +130,7 @@ internal struct EditorTabStrip: View { .padding(EditorTabStripLayout.trackPadding) } .frame(height: EditorTabStripLayout.trackHeight) - .trackSurface() + .trackSurface(prefersSolidSurfaces: prefersSolidSurfaces) .onDrop(of: [.text], delegate: EditorTabStripDropReset(draggingTabId: $draggingTabId)) } @@ -134,6 +145,7 @@ internal struct EditorTabStrip: View { isSelected: tabManager.selectedTab?.id == tab.id, isHovered: hoveredTabId == tab.id, isWindowActive: isWindowActive, + prefersSolidSurfaces: prefersSolidSurfaces, showsLeadingSeparator: EditorTabStripLayout.showsSeparator( before: index, tabIds: tabManager.tabs.map(\.id), @@ -182,6 +194,16 @@ internal struct EditorTabStrip: View { private var isWindowActive: Bool { controlActiveState != .inactive } + + /// One reader for the whole strip, so a track and the tab on it can never disagree about + /// whether they are glass. + private var prefersSolidSurfaces: Bool { + if let surfaceStyle { return surfaceStyle == .solid } + return EditorTabStripEmphasis.prefersSolidSurfaces( + reduceTransparency: reduceTransparency, + contrast: colorSchemeContrast + ) + } } /// Reorders the strip as a tab is dragged over its neighbours, rather than waiting for the drop. @@ -240,29 +262,13 @@ private struct EditorTabStripDropReset: DropDelegate { } } -/// Read off the system's own tab bar rather than chosen. Every value is a semantic `NSColor` so -/// the light appearance inverts with the system instead of needing a second hand-tuned palette: -/// a fill that lifts the track above dark chrome recesses it below light chrome, which is what a -/// track is supposed to do in both. -private enum EditorTabStripPalette { - /// Only reached before macOS 26, where there is no glass to stand in for the system's track - /// material. This is an opaque tone rather than an alpha wash for the same reason the material - /// is: measured at rgb(220) light and rgb(70) dark, against a system track of rgb(228) and - /// rgb(77), it is the closest system colour that stays lighter than the chrome in both. - static var trackFill: Color { Color(nsColor: .unemphasizedSelectedContentBackgroundColor) } - /// Half the weight of a separator. `separatorColor` was twice the measured edge and read as a - /// drawn outline rather than the lit rim the system puts there. - static var trackEdge: Color { Color(nsColor: .quinaryLabel) } - static var hoverFill: Color { Color(nsColor: .tertiarySystemFill) } - static var separator: Color { Color(nsColor: .separatorColor) } -} - private struct EditorTabStripItem: View { let tab: QueryTab let label: EditorTabLabelResolver.Label let isSelected: Bool let isHovered: Bool let isWindowActive: Bool + let prefersSolidSurfaces: Bool let showsLeadingSeparator: Bool let position: Int let count: Int @@ -276,8 +282,6 @@ private struct EditorTabStripItem: View { let onMoveLeft: () -> Void let onMoveRight: () -> Void - @Environment(\.colorScheme) private var colorScheme - var body: some View { ZStack { if showsLeadingSeparator { @@ -345,7 +349,7 @@ private struct EditorTabStripItem: View { isSelected: isSelected, isHovered: isHovered, isWindowActive: isWindowActive, - isLightAppearance: colorScheme == .light + prefersSolidSurfaces: prefersSolidSurfaces ) } @@ -460,6 +464,7 @@ private struct EditorTabStripCloseButtonStyle: ButtonStyle { private struct EditorTabStripNewButton: View { let action: () -> Void let isWindowActive: Bool + let prefersSolidSurfaces: Bool @Environment(\.colorScheme) private var colorScheme @@ -475,7 +480,7 @@ private struct EditorTabStripNewButton: View { .contentShape(Circle()) } .buttonStyle(.plain) - .newTabSurface(isLightAppearance: colorScheme == .light) + .newTabSurface(isLightAppearance: colorScheme == .light, prefersSolidSurfaces: prefersSolidSurfaces) .help(Text("New Tab")) .accessibilityLabel(Text("New Tab")) } @@ -484,19 +489,21 @@ private struct EditorTabStripNewButton: View { private extension View { /// The track is a material, not a wash. Sampling the system's own bar against three different /// chrome colours shows it converging on a fixed tone rather than tinting whatever is behind - /// it: about 78 percent opaque over rgb(77) in dark, and 85 percent over rgb(228) in light. It - /// therefore reads *lighter* than the chrome in both appearances, which no alpha-based system - /// fill can do. `secondarySystemFill` is a white wash in dark and a black one in light, so it - /// lands within a few points of the system in dark and inverts in light, a track darker than - /// the titlebar it sits in. `glassEffect` is measured within six points of the system in both. + /// it: about 78 percent opaque over rgb(77) in dark, and 85 percent over rgb(228) in light. + /// `secondarySystemFill` is a white wash in dark and a black one in light, so it lands within a + /// few points of the system in dark and inverts in light, a track darker than the titlebar it + /// sits in. + /// + /// The tint is what makes it a *track* rather than a second pane at the same height as the tab + /// on it. `EditorTabStripEmphasis` carries the measurements. /// /// The glass goes on the track's own content rather than behind it as a `.background`, because /// a `GlassEffectContainer` raises the glass it holds above the container's other content: a /// track drawn as a sibling layer paints over the tabs it is supposed to sit under. @ViewBuilder - func trackSurface() -> some View { - if #available(macOS 26.0, *) { - glassEffect(.regular, in: Capsule(style: .continuous)) + func trackSurface(prefersSolidSurfaces: Bool) -> some View { + if #available(macOS 26.0, *), !prefersSolidSurfaces { + glassEffect(.regular.tint(EditorTabStripEmphasis.trackTint), in: Capsule(style: .continuous)) } else { background( Capsule(style: .continuous) @@ -520,10 +527,10 @@ private extension View { isSelected: Bool, isHovered: Bool, isWindowActive: Bool, - isLightAppearance: Bool + prefersSolidSurfaces: Bool ) -> some View { if isSelected { - selectedTabSurface(isLightAppearance: isLightAppearance, isWindowActive: isWindowActive) + selectedTabSurface(prefersSolidSurfaces: prefersSolidSurfaces) } else if isHovered, isWindowActive { background(Capsule(style: .continuous).fill(EditorTabStripPalette.hoverFill)) } else { @@ -534,29 +541,41 @@ private extension View { /// Glass on macOS 26 and later, and the flat control fill that preceded it before that. /// `controlBackgroundColor` is not the fallback: it matches the window background exactly in /// dark mode, so the raised tab would read as a hole punched in its own track. + /// + /// The fill does not step down for a background window. Reaching for the track's own + /// `unemphasizedSelectedContentBackgroundColor` there left the two identical, and the shadow + /// that was supposed to cover for it was drawn in light alone and clipped away by the track's + /// own 24pt capsule anyway, so a background window showed no selected tab at all. The system + /// keeps its selected tab drawn in a background window; only the labels step down, which they + /// already do in `titleColor`. + /// + /// The rim is the half of the selection that survives a background window on glass, where + /// macOS attenuates the tint. @ViewBuilder - func selectedTabSurface(isLightAppearance: Bool, isWindowActive: Bool) -> some View { - if #available(macOS 26.0, *) { - glassEffect(.regular, in: Capsule(style: .continuous)) + func selectedTabSurface(prefersSolidSurfaces: Bool) -> some View { + if #available(macOS 26.0, *), !prefersSolidSurfaces { + glassEffect(.regular.tint(EditorTabStripEmphasis.selectionTint), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .strokeBorder( + EditorTabStripEmphasis.selectionEdge, + lineWidth: EditorTabStripEmphasis.selectionEdgeWidth + ) + ) } else { - background( - Capsule(style: .continuous) - .fill(Color(nsColor: isWindowActive ? .controlColor : .unemphasizedSelectedContentBackgroundColor)) - .shadow( - color: .black.opacity(isLightAppearance ? 0.12 : 0), - radius: isLightAppearance ? 1 : 0, - y: isLightAppearance ? 0.5 : 0 - ) - ) + background(Capsule(style: .continuous).fill(EditorTabStripPalette.selectedFill)) } } /// The one genuine press target in the strip, so this is where interactive glass belongs. /// The tab capsule does not take it: the tab a click lands on is an unselected one, which /// carries no glass to respond. + /// + /// It keeps the untinted material on purpose. The button sits outside the track, so it belongs + /// at the height of the chrome rather than recessed into a channel it is not in. @ViewBuilder - func newTabSurface(isLightAppearance: Bool) -> some View { - if #available(macOS 26.0, *) { + func newTabSurface(isLightAppearance: Bool, prefersSolidSurfaces: Bool) -> some View { + if #available(macOS 26.0, *), !prefersSolidSurfaces { glassEffect(.regular.interactive(), in: Circle()) } else { background( diff --git a/TablePro/Views/Main/EditorTabStripSurfaces.swift b/TablePro/Views/Main/EditorTabStripSurfaces.swift new file mode 100644 index 000000000..b4299d4d9 --- /dev/null +++ b/TablePro/Views/Main/EditorTabStripSurfaces.swift @@ -0,0 +1,90 @@ +// +// EditorTabStripSurfaces.swift +// TablePro +// + +import SwiftUI + +/// Read off the system's own tab bar rather than chosen. Every value is a semantic `NSColor` so +/// the light appearance inverts with the system instead of needing a second hand-tuned palette. +/// +/// These are also what the strip falls back to when it may not use glass at all, which is why the +/// selected fill is a tone of its own rather than the track's. Sharing one constant between the +/// two is what left a background window with no selection: both resolved to opaque rgb(220) in +/// light and rgb(70) in dark, a delta of exactly zero. +internal enum EditorTabStripPalette { + /// An opaque tone rather than an alpha wash for the same reason the system's material is: + /// measured at rgb(220) light and rgb(70) dark, against a system track of rgb(228) and + /// rgb(77), it is the closest system colour that stays lighter than the chrome in both. + internal static var trackFill: Color { Color(nsColor: .unemphasizedSelectedContentBackgroundColor) } + /// Measured at rgb(255) light and rgb(115) dark over the track, so the selected tab stands + /// 35 and 45 levels clear of it whether or not the window is in front. The system keeps its + /// own selected tab drawn in a background window too; only the labels step down. + internal static var selectedFill: Color { Color(nsColor: .controlColor) } + /// Half the weight of a separator. `separatorColor` was twice the measured edge and read as a + /// drawn outline rather than the lit rim the system puts there. + internal static var trackEdge: Color { Color(nsColor: .quinaryLabelColor) } + internal static var hoverFill: Color { Color(nsColor: .tertiarySystemFill) } + internal static var separator: Color { Color(nsColor: .separatorColor) } +} + +/// Which of the two surface sets the strip draws. +/// +/// The app resolves this from the environment. A test pins it, because a tinted `glassEffect` +/// cannot be rasterised at all: `cacheDisplay` comes back with an empty bitmap for the entire +/// hosting view, the strip's own titles and close button included, not merely for the glass. +internal enum EditorTabStripSurfaceStyle { + case glass + case solid +} + +/// How far apart the track and the selected tab are held when both are glass. +/// +/// Liquid Glass samples the backdrop beneath a stack, never the glass it sits on, so a `.regular` +/// capsule inside a `.regular` track carries no step of its own. Measured on the shipping strip, +/// the selected tab was rgb(248) against a track of rgb(250) in light, a contrast of 1.017 to 1, +/// and across a backdrop sweep the step turned over: +57 levels above black, -1 above white. +/// +/// The system never stacks one material on itself. A runtime probe of `NSTabBar` on macOS 27 gives +/// an `NSSubduedGlassEffectView` track carrying plain `NSGlassEffectView` tabs, measured at +/// rgb(236) and rgb(253) in light and rgb(83) and rgb(89) in dark. `NSGlassEffectViewStyle` +/// publishes only `regular` and `clear`, so that subdued style is out of reach. +/// +/// These two tints stand in for it. Both surfaces sample the same backdrop and are pushed in +/// opposite directions, so the step between them stops being a function of what the window is +/// over: measured from a black backdrop to a white one, the worst case is 1.189 to 1 in dark and +/// 1.183 to 1 in light, above the system's own 1.098 and 1.161, and the sign never turns over. +/// +/// Both are neutral because `Glass.regular` discards hue: a red track against a green selection +/// measures exactly like no tint at all. Only lightness reaches the surface, which is what the +/// Human Interface Guidelines ask for anyway, colour on the background rather than on the text. +internal enum EditorTabStripEmphasis { + internal static let trackTint = Color.black.opacity(0.12) + internal static let selectionTint = Color.white.opacity(0.22) + + /// Ink rather than material, so it survives what the tints do not. macOS attenuates a glass + /// tint in a window that is not key, measured at rgb(134) falling to rgb(94) for the selected + /// tab, and the system's own bar gives up there too: its selected tab reads seven levels + /// darker than its track in a background window. The rim is the part of the selection that + /// survives, and it is a shape rather than a colour, which is the channel Apple's + /// differentiate-without-colour criteria ask for. + internal static var selectionEdge: Color { Color(nsColor: .separatorColor) } + + /// One device pixel on the 2x displays this chrome is drawn for. + internal static let selectionEdgeWidth: CGFloat = 0.5 + + /// Glass answers to neither Increase Contrast nor Reduce Transparency. Rendering the strip + /// under `accessibilityHighContrastAqua` and `accessibilityHighContrastDarkAqua` produces + /// pixels identical to plain aqua and darkAqua, measured. So the strip leaves glass behind for + /// the opaque surfaces above, which carry 1.371 to 1 in light and 1.991 to 1 in dark. + /// + /// The rule itself belongs to `SolidSurfacePreference`, which the six views that reach both + /// settings through `themeMaterial` also use. Glass has no `Material` to swap, so the strip + /// answers the same question with a different surface rather than a second rule. + internal static func prefersSolidSurfaces( + reduceTransparency: Bool, + contrast: ColorSchemeContrast + ) -> Bool { + SolidSurfacePreference.prefersSolid(reduceTransparency: reduceTransparency, contrast: contrast) + } +} diff --git a/TableProTests/Views/Main/EditorTabStripChromeTests.swift b/TableProTests/Views/Main/EditorTabStripChromeTests.swift index 43c8d521e..523c35862 100644 --- a/TableProTests/Views/Main/EditorTabStripChromeTests.swift +++ b/TableProTests/Views/Main/EditorTabStripChromeTests.swift @@ -11,18 +11,24 @@ import Testing /// What this suite can and cannot see is decided by Liquid Glass, not by the strip. /// -/// The track and the selected tab are both `glassEffect` surfaces, and glass is composited by the -/// window server rather than drawn into a view's own context: `cacheDisplay`, `layer.render` and -/// `dataWithPDF` all come back with zero non-transparent pixels for it, measured. So no assertion -/// here can describe the track's material. That is checked by comparing the running app against -/// the system's own tab bar, which is where the geometry in `EditorTabStripLayout` came from too. +/// Glass is composited by the window server rather than drawn into a view's own context, so +/// `cacheDisplay`, `layer.render` and `dataWithPDF` all come back with zero non-transparent pixels +/// for it. Once the glass carries a tint that goes further: the bitmap is empty for the *whole* +/// hosting view, the strip's own titles and close button included, and every measurement below +/// reads exactly zero. Measured, and it is why this suite pins the surface style rather than +/// letting the strip choose. /// -/// What rasterises is everything drawn *on* the glass: the titles, the separators and the close -/// button. That is the half worth guarding anyway, because it is the half that broke. A -/// `GlassEffectContainer` raises the glass it holds above the container's other content, so the -/// first two attempts at this strip painted the glass over the selected tab's own title and then -/// over its close button, leaving a tab whose label was dimmer than its neighbours' and which had -/// no visible way to close it. Both tests below fail if that returns. +/// So the strip is rendered here as `.solid`, which is what macOS 14 and 15 draw and what anyone +/// with Increase Contrast or Reduce Transparency on draws at any version. That half had no +/// coverage at all until it shipped a background window with no selected tab. The glass half is +/// checked by comparing the running app against the system's own tab bar, which is where the +/// geometry in `EditorTabStripLayout` came from too. +/// +/// What these tests guard is everything drawn *on* the surface: the titles, the separators and the +/// close button. That is the half that broke twice. A `GlassEffectContainer` raises the glass it +/// holds above the container's other content, so the first two attempts at this strip painted the +/// glass over the selected tab's own title and then over its close button, leaving a tab whose +/// label was dimmer than its neighbours' and which had no visible way to close it. @Suite("Editor tab strip chrome") @MainActor struct EditorTabStripChromeTests { @@ -61,13 +67,24 @@ struct EditorTabStripChromeTests { return Array(stride(from: leading, to: leading + EditorTabStripLayout.accessoryWidth, by: 1)) } + /// Between the close-button box and the title, so the sample lands on the tab's own surface + /// and on no glyph. + private static func surfaceColumns(ofTabAt index: Int) -> [CGFloat] { + let leading = tabOrigin(index) + EditorTabStripLayout.accessoryInset + + EditorTabStripLayout.accessoryWidth + 4 + return [leading] + } + /// The middle of a tab, where its title is centred and no accessory reaches. private static func titleColumns(ofTabAt index: Int) -> [CGFloat] { let centre = tabOrigin(index) + tabWidth / 2 return Array(stride(from: centre - 40, through: centre + 40, by: 2)) } - private func makeHost(appearance: NSAppearance.Name) -> NSView { + private func makeHost( + appearance: NSAppearance.Name, + activeState: ControlActiveState = .inactive + ) -> NSView { let manager = QueryTabManager() manager.tabs = ["Album", "Artist", "Customer"].map { QueryTab(title: $0) } manager.selectedTabId = manager.tabs.first?.id @@ -78,7 +95,8 @@ struct EditorTabStripChromeTests { onClose: { _ in }, onCloseOthers: { _ in }, onCloseAll: {}, - onNewTab: {} + onNewTab: {}, + surfaceStyle: .solid ) let content = ZStack { @@ -90,6 +108,7 @@ struct EditorTabStripChromeTests { } } .frame(width: Self.width, height: Self.totalHeight) + .environment(\.controlActiveState, activeState) let host = NSHostingView(rootView: AnyView(content)) host.frame = NSRect(x: 0, y: 0, width: Self.width, height: Self.totalHeight) @@ -183,6 +202,32 @@ struct EditorTabStripChromeTests { #expect(selected > unselected) } + /// The bug this pins: the selected capsule used to be filled with the track's own + /// `unemphasizedSelectedContentBackgroundColor` whenever the window was not in front, and both + /// resolve opaque, so a background window on macOS 14 and 15 showed no selected tab at all. + /// The shadow that was meant to cover for it was drawn in light alone and clipped away by the + /// track's own 24pt capsule. + /// + /// Sampled away from the title and the close button, and in both window states, because the + /// state that broke is the one this suite renders by default. + @Test( + "The selected tab's surface stands clear of the track in either window state", + arguments: [NSAppearance.Name.aqua, .darkAqua], [ControlActiveState.key, .inactive] + ) + func selectedSurfaceStandsClearOfTheTrack(appearance: NSAppearance.Name, activeState: ControlActiveState) { + let host = makeHost(appearance: appearance, activeState: activeState) + + let row = Self.margin + EditorTabStripLayout.trackHeight / 2 + let selected = brightness(of: host, rows: [row], columns: Self.surfaceColumns(ofTabAt: 0)) + let plain = brightness(of: host, rows: [row], columns: Self.surfaceColumns(ofTabAt: 2)) + + guard let selectedTone = selected.first, let plainTone = plain.first else { + Issue.record("The strip did not rasterise") + return + } + #expect(abs(selectedTone - plainTone) > 0.05) + } + /// The band is 36pt and only its top 28 carry the track, so the strip must not paint the /// clearance the system leaves between the tab bar and the content below it. @Test("The band leaves its bottom clearance to the window", arguments: [NSAppearance.Name.aqua, .darkAqua]) diff --git a/TableProTests/Views/Main/EditorTabStripSurfacesTests.swift b/TableProTests/Views/Main/EditorTabStripSurfacesTests.swift new file mode 100644 index 000000000..aac0e568f --- /dev/null +++ b/TableProTests/Views/Main/EditorTabStripSurfacesTests.swift @@ -0,0 +1,146 @@ +// +// EditorTabStripSurfacesTests.swift +// TableProTests +// +// The tab strip's selected surface cannot be rasterised on macOS 26 and later, because glass is +// composited by the window server rather than drawn into a view's context. What can be pinned is +// the arithmetic underneath it: that the track and the selection are pulled in opposite +// directions, and that the opaque surfaces the strip falls back to are two colours rather than +// one. They were one, and a background window on macOS 14 and 15 showed no selected tab at all. +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +@Suite("Editor tab strip surfaces") +@MainActor +struct EditorTabStripSurfacesTests { + nonisolated private static let appearances: [NSAppearance.Name] = [ + .aqua, + .darkAqua, + .accessibilityHighContrastAqua, + .accessibilityHighContrastDarkAqua, + ] + + /// `NSColor.relativeLuminance` drops the alpha component, so a translucent fill has to be laid + /// over what it covers before it can be measured at all. `controlColor` is white at a quarter + /// alpha in dark, which reads as pure white until it is composited. + private func composite(_ source: Color, over destination: NSColor) -> NSColor { + guard let top = NSColor(source).usingColorSpace(.sRGB), + let bottom = destination.usingColorSpace(.sRGB) + else { return destination } + let alpha = top.alphaComponent + return NSColor( + srgbRed: top.redComponent * alpha + bottom.redComponent * (1 - alpha), + green: top.greenComponent * alpha + bottom.greenComponent * (1 - alpha), + blue: top.blueComponent * alpha + bottom.blueComponent * (1 - alpha), + alpha: 1 + ) + } + + private func contrast(_ one: NSColor, _ other: NSColor) -> CGFloat { + let first = one.relativeLuminance + let second = other.relativeLuminance + return (max(first, second) + 0.05) / (min(first, second) + 0.05) + } + + private func resolve(_ appearance: NSAppearance.Name, _ body: () -> T) -> T? { + var result: T? + NSAppearance(named: appearance)?.performAsCurrentDrawingAppearance { result = body() } + return result + } + + // MARK: - The opaque surfaces + + /// The bug this suite exists for. `selectedFill` used to be the track's own + /// `unemphasizedSelectedContentBackgroundColor` whenever the window was not in front, and both + /// resolve opaque, so the selected capsule and the track it sat in were the same pixel. + @Test( + "The opaque selected fill is never the track's own colour", + arguments: EditorTabStripSurfacesTests.appearances + ) + func selectedFillIsNotTheTrackFill(appearance: NSAppearance.Name) { + let pair = resolve(appearance) { + ( + NSColor(EditorTabStripPalette.trackFill).usingColorSpace(.sRGB), + NSColor(EditorTabStripPalette.selectedFill).usingColorSpace(.sRGB) + ) + } + guard let track = pair?.0, let selected = pair?.1 else { + Issue.record("The strip's opaque fills did not resolve") + return + } + /// Compared on resolved components rather than with `==`, which answers how a colour was + /// built rather than what it draws. + #expect( + track.redComponent != selected.redComponent + || track.alphaComponent != selected.alphaComponent + ) + } + + @Test( + "The opaque selected tab stands clear of the track it sits in", + arguments: EditorTabStripSurfacesTests.appearances + ) + func opaqueSelectionStandsClearOfTheTrack(appearance: NSAppearance.Name) { + let measured = resolve(appearance) { () -> CGFloat in + let track = self.composite(EditorTabStripPalette.trackFill, over: .windowBackgroundColor) + let selected = self.composite(EditorTabStripPalette.selectedFill, over: track) + return self.contrast(selected, track) + } + guard let measured else { + Issue.record("The strip's opaque fills did not resolve") + return + } + /// The system's own tab bar measures 1.161 to 1 in light and 1.098 to 1 in dark. The opaque + /// path carries far more than that, 1.371 and 1.991, because it is what the strip falls + /// back to when the user has asked for Increase Contrast or Reduce Transparency. + #expect(measured > 1.25) + } + + // MARK: - The glass tints + + /// Neither tint means anything on its own. What holds the selection apart from its track is + /// that they are pushed in opposite directions from the same backdrop, which is the one thing + /// two `.regular` surfaces cannot do. + @Test("The track and the selection are tinted away from each other") + func tintsPullInOppositeDirections() { + let backdrop = NSColor(srgbRed: 0.5, green: 0.5, blue: 0.5, alpha: 1) + let subdued = composite(EditorTabStripEmphasis.trackTint, over: backdrop) + let lifted = composite(EditorTabStripEmphasis.selectionTint, over: backdrop) + + #expect(subdued.relativeLuminance < backdrop.relativeLuminance) + #expect(lifted.relativeLuminance > backdrop.relativeLuminance) + #expect(lifted.relativeLuminance > subdued.relativeLuminance) + } + + /// `Glass.regular` discards hue and reads only lightness, so a tint that carries a colour + /// measures the same as no tint at all and the selection goes back to being whatever the + /// backdrop makes it. + @Test("Both tints are neutral") + func tintsCarryNoHue() { + for tint in [EditorTabStripEmphasis.trackTint, EditorTabStripEmphasis.selectionTint] { + guard let resolved = NSColor(tint).usingColorSpace(.sRGB) else { + Issue.record("A tab strip tint did not resolve") + return + } + #expect(resolved.redComponent == resolved.greenComponent) + #expect(resolved.greenComponent == resolved.blueComponent) + #expect(resolved.alphaComponent > 0) + #expect(resolved.alphaComponent < 1) + } + } + + // MARK: - Leaving glass behind + + @Test("Either accessibility setting alone takes the strip off glass") + func solidSurfacesAnswerBothSettings() { + #expect(!EditorTabStripEmphasis.prefersSolidSurfaces(reduceTransparency: false, contrast: .standard)) + #expect(EditorTabStripEmphasis.prefersSolidSurfaces(reduceTransparency: true, contrast: .standard)) + #expect(EditorTabStripEmphasis.prefersSolidSurfaces(reduceTransparency: false, contrast: .increased)) + #expect(EditorTabStripEmphasis.prefersSolidSurfaces(reduceTransparency: true, contrast: .increased)) + } +}