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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Release jobs set `STABLEBAR_RELEASE_BUILD=1` when packaging to preserve their st

## Docs images

`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data and a fixed time, and writes them as WebP. It needs `cwebp`: `brew install webp`. The **Docs images** CI job draws them on every push to `main` and on pull requests that change the app, and a stable release commits them, so a change to the bar, popups or Settings doesn't need new images. To show something new, add a scene to [DocsImages.swift](Sources/StableBar/DocsImages.swift).
`scripts/render-docs-images.sh` draws the images in `docs/images` from the app's own bar, popup and Settings views, with sample data, a fixed date (Thursday 22 October 2026, 09:41 UTC) and a local, empty Store index, and writes them as WebP. Two runs of one commit on one Mac draw the same pixels. It needs `cwebp`: `brew install webp`. The **Docs images** CI job draws them on every push to `main` and on pull requests that change the app, and a stable release commits them, so a change to the bar, popups or Settings doesn't need new images. To show something new, add a scene to [DocsImages.swift](Sources/StableBar/DocsImages.swift).

## Agent skills

Expand Down
5 changes: 3 additions & 2 deletions Sources/StableBar/BarInspector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ private extension WidgetTransition {
struct BarInspector: View {
@Bindable var editor: ConfigurationEditor
let screen: ScreenKind
@Environment(\.fixedDate) private var fixedDate
let kinds: [ScreenKind]
let names: [String: String]
let source: (WidgetConfiguration) -> PackageOrigin
Expand Down Expand Up @@ -401,7 +402,7 @@ struct BarInspector: View {
case let .clock(format):
SettingsGroup("Clock & date") {
TimelineView(.periodic(from: .now, by: format.contains("s") ? 1 : 60)) { context in
Text(widget.label(output: nil, activeApp: nil, date: context.date).text)
Text(widget.label(output: nil, activeApp: nil, date: fixedDate ?? context.date).text)
.font(.system(size: 24, weight: .medium, design: .rounded)).monospacedDigit()
.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 8)
}
Expand Down Expand Up @@ -516,7 +517,7 @@ struct BarInspector: View {
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(choice.title).font(.system(size: 10)).opacity(0.65)
Text(choice.sample).font(.system(size: 12, weight: .medium)).monospacedDigit()
Text(choice.sample(at: fixedDate ?? .now)).font(.system(size: 12, weight: .medium)).monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading).padding(9)
.background(!custom && format == choice.value ? Color.accentColor.opacity(0.16) : SettingsPalette.control,
Expand Down
12 changes: 10 additions & 2 deletions Sources/StableBar/BarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ extension EnvironmentValues {
}
@Entry var moreWidgetsAnchor: NSView?
@Entry var barExpansion: BarExpansion?
/// A date to draw instead of the current time, so a render shows the same clock on every run.
@Entry var fixedDate: Date?
/// Draws without the network, as a render does: remote images stay empty.
@Entry var offline = false
}

enum BarExpansion {
Expand Down Expand Up @@ -329,6 +333,7 @@ struct WidgetView: View {
@Environment(\.screenKind) private var screenKind
@Environment(\.moreWidgetsAnchor) private var moreWidgetsAnchor
@Environment(\.barExpansion) private var barExpansion
@Environment(\.fixedDate) private var fixedDate

private var resolution: ResolvedWidgetStyle {
store.presentations[widget.id]?.style(on: screenKind) ?? ResolvedWidgetStyle(base: resolveStyle([defaults, widget.style]))
Expand Down Expand Up @@ -618,10 +623,13 @@ struct WidgetView: View {
}
}

/// `content` drawn for the time the widget shows: a clock's, redrawn on its schedule, otherwise now.
/// `content` drawn for the time the widget shows: the render's fixed date when it has one, a clock's
/// redrawn on its schedule, otherwise now.
@ViewBuilder
private func timed<Content: View>(@ViewBuilder _ content: @escaping (Date) -> Content) -> some View {
if case let .clock(format) = widget.content {
if let fixedDate {
content(fixedDate)
} else if case let .clock(format) = widget.content {
TimelineView(clockSchedule(format)) { context in content(context.date) }
} else {
content(.now)
Expand Down
10 changes: 6 additions & 4 deletions Sources/StableBar/ClockCalendar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,18 @@ struct ClockCalendar: View {
let openCalendar: () -> Void
@State private var offset = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.fixedDate) private var fixedDate

var body: some View {
TimelineView(.periodic(from: .now, by: 60)) { context in
let now = fixedDate ?? context.date
let zone = timeZone.flatMap(TimeZone.init(identifier:)) ?? .autoupdatingCurrent
let month = ClockMonth(date: context.date, offset: offset, timeZone: zone)
let month = ClockMonth(date: now, offset: offset, timeZone: zone)
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 3) {
Text(formatted(context.date, in: zone, pattern: nil))
Text(formatted(now, in: zone, pattern: nil))
.font(.system(size: 34, weight: .medium, design: .rounded)).monospacedDigit()
Text(formatted(context.date, in: zone, pattern: "EEEE, d MMMM yyyy"))
Text(formatted(now, in: zone, pattern: "EEEE, d MMMM yyyy"))
.font(.system(size: 12)).opacity(0.6)
if timeZone != nil {
Text(zone.identifier.replacingOccurrences(of: "_", with: " "))
Expand All @@ -61,7 +63,7 @@ struct ClockCalendar: View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 3) {
ForEach(0..<42) { index in
if let date = month.days[index] {
let today = month.calendar.isDate(date, inSameDayAs: context.date)
let today = month.calendar.isDate(date, inSameDayAs: now)
Text(String(month.calendar.component(.day, from: date)))
.font(.system(size: 12, weight: today ? .semibold : .regular)).monospacedDigit()
.foregroundStyle(today ? appearance.accentText : appearance.foreground)
Expand Down
30 changes: 27 additions & 3 deletions Sources/StableBar/DocsImages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,32 @@ import AppKit
import SwiftUI

/// Draws the images in docs/images from the real bar, popup and Settings views, in the dark and
/// light appearance. Sample data and a fixed time give the same pixels on every run until the views
/// change. `scripts/render-docs-images.sh` runs it and converts the images to WebP.
/// light appearance. Sample data, a fixed date in UTC and a local, empty Store index give the same
/// pixels on every run until the views change. `scripts/render-docs-images.sh` runs it and converts
/// the images to WebP.
@MainActor
enum DocsImages {
/// Thursday 22 October 2026, 09:41 UTC, the time the bar's sample clock shows: what every clock in a
/// render draws instead of the current time.
static let date = Date(timeIntervalSince1970: 1_792_662_060)

/// Draws times in UTC, so a render shows the same clock on every Mac.
static func useUTC() {
setenv("TZ", "UTC", 1)
NSTimeZone.resetSystemTimeZone()
}

/// Points the store's Store client at a local, empty index in `directory`, so the theme gallery,
/// the widget catalog and Packages never reach the network while a render draws them.
static func offline(_ store: BarStore, in directory: URL) throws {
let index = directory.appending(path: "index.json")
try Data(#"{"packages":[]}"#.utf8).write(to: index)
store.packageStoreClient = PackageStoreClient(packages: WidgetPackageStore(directory: directory.appending(path: "packages")),
indexURL: index)
}

static func render(to directory: URL) async throws {
useUTC()
let samples = FileManager.default.temporaryDirectory.appending(path: "stablebar-docs-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: samples) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
Expand Down Expand Up @@ -191,9 +212,10 @@ enum DocsImages {
return type
})
let store = BarStore(configurationURL: url, registry: registry)
try offline(store, in: directory)
store.workspaceStore.executablePath = { aerospace.path }
store.reloadConfiguration()
store.activeApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first
store.activeApp = SampleApp(bundleIdentifier: "com.apple.finder")
return store
}

Expand All @@ -203,6 +225,8 @@ enum DocsImages {
private static func snapshot(_ content: some View, size: NSSize, dark: Bool, stores: [BarStore], ready: () -> Bool,
to url: URL) async throws {
let view = NSHostingView(rootView: content
.environment(\.fixedDate, date)
.environment(\.offline, true)
.frame(width: size.width, height: size.height)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(Color.primary.opacity(0.14))))
Expand Down
5 changes: 4 additions & 1 deletion Sources/StableBar/PopupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -838,12 +838,13 @@ struct Shimmer: ViewModifier {
let active: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.measuringBarWidths) private var measuring
@Environment(\.fixedDate) private var fixedDate

func body(content: Content) -> some View {
if active && !reduceMotion && !measuring {
content.overlay {
TimelineView(.animation) { timeline in
let phase = timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8
let phase = (fixedDate ?? timeline.date).timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8
GeometryReader { proxy in
LinearGradient(colors: [.clear, .white.opacity(0.85), .clear], startPoint: .leading, endPoint: .trailing)
.frame(width: proxy.size.width * 0.4)
Expand Down Expand Up @@ -934,6 +935,7 @@ struct WidgetImage: View {
let fit: ImageFit
@State private var image: NSImage?
@State private var error = ""
@Environment(\.offline) private var offline

var body: some View {
Group {
Expand All @@ -956,6 +958,7 @@ struct WidgetImage: View {
} else {
url = URL(filePath: NSString(string: path).expandingTildeInPath)
}
if offline, !url.isFileURL { return }
if url.isFileURL {
let file = try url.resourceValues(forKeys: [.isApplicationKey, .isExecutableKey, .isRegularFileKey, .contentTypeKey])
if file.isApplication == true || file.isExecutable == true && file.isRegularFile == true && file.contentType?.conforms(to: .image) != true {
Expand Down
22 changes: 22 additions & 0 deletions Sources/StableBar/SampleApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import AppKit

/// An app as a render shows it: the name and icon of its bundle, whether it runs or not, so a render
/// doesn't depend on the apps running on the Mac that draws it.
final class SampleApp: NSRunningApplication, @unchecked Sendable {
private let name: String?
private let image: NSImage?
private let identifier: String

init(bundleIdentifier: String) {
identifier = bundleIdentifier
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier)
let bundle = url.flatMap(Bundle.init(url:))
name = (bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") ?? bundle?.object(forInfoDictionaryKey: "CFBundleName")) as? String
image = url.map { NSWorkspace.shared.icon(forFile: $0.path) }
super.init()
}

override var localizedName: String? { name }
override var icon: NSImage? { image }
override var bundleIdentifier: String? { identifier }
}
4 changes: 2 additions & 2 deletions Sources/StableBar/WidgetSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,10 @@ struct WidgetClockFormat {
Self(title: "Date + seconds", value: "EEE d MMM HH:mm:ss")
]

var sample: String {
func sample(at date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = value
return formatter.string(from: .now)
return formatter.string(from: date)
}
}

Expand Down
102 changes: 102 additions & 0 deletions Tests/StableBarTests/FixedDateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import AppKit
import SwiftUI
import Testing
@testable import StableBar

/// Thursday 22 October 2026, 09:41 UTC: the time the docs images and scenes draw.
private let fixed = Date(timeIntervalSince1970: 1_792_662_060)
private let display = BarDisplay(id: 1, isBuiltIn: false, isMain: true, notch: nil)

/// The largest difference of one color channel between two drawings. A process draws some antialiased
/// pixels one step (1/255) differently between two drawings of the same content (see DefaultLayoutTests),
/// so drawings that match differ by at most 1.
private func difference(_ first: [UInt8], _ second: [UInt8]) -> Int {
guard first.count == second.count else { return 255 }
return zip(first, second).reduce(0) { max($0, abs(Int($1.0) - Int($1.1))) }
}

/// `content` drawn at 2× as 8-bit sRGB bytes.
@MainActor private func pixels(_ content: some View) throws -> [UInt8] {
let renderer = ImageRenderer(content: content)
renderer.scale = 2
let image = try #require(renderer.cgImage)
let context = try #require(CGContext(data: nil, width: image.width, height: image.height, bitsPerComponent: 8,
bytesPerRow: image.width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
let data = try #require(context.data)
return Array(UnsafeRawBufferPointer(start: data, count: image.width * image.height * 4))
}

@Test @MainActor func barClockDrawsTheFixedDate() throws {
let fixture = try ThemeGalleryFixture(#"{"widgets":[{"id":"clock","type":"clock","position":"right"}]}"#)
defer { fixture.close() }
let store = try fixture.store()
func bar(_ date: Date) throws -> [UInt8] {
try pixels(BarView(store: store, display: display).frame(width: 600, height: 40).environment(\.fixedDate, date))
}
let first = try bar(fixed)
#expect(try difference(bar(fixed.addingTimeInterval(3_600)), first) > 1)
#expect(try difference(bar(fixed), first) <= 1)
}

@Test @MainActor func clockCalendarDrawsTheFixedDate() throws {
func calendar(_ date: Date) throws -> [UInt8] {
try pixels(ClockCalendar(timeZone: "UTC", appearance: WidgetAppearance(WidgetStyle(), defaults: WidgetStyle()), openCalendar: {})
.frame(width: 320)
.environment(\.fixedDate, date))
}
let first = try calendar(fixed)
#expect(try difference(calendar(fixed.addingTimeInterval(40 * 86_400)), first) > 1)
#expect(try difference(calendar(fixed), first) <= 1)
}

@Test func clockFormatSamplesUseTheGivenDate() {
let format = WidgetClockFormat.common[4]
#expect(format.value == "EEE d MMM HH:mm")
#expect(format.sample(at: fixed) != format.sample(at: fixed.addingTimeInterval(86_400)))
}

/// `content` drawn in an offscreen window, where a scroll view lays out its lazy grids, as the bitmap's bytes.
@MainActor private func windowPixels(_ content: some View, size: NSSize) throws -> [UInt8] {
let window = NSWindow(contentRect: CGRect(origin: CGPoint(x: -9000, y: -6000), size: size), styleMask: [.borderless],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer { window.close() }
let view = NSHostingView(rootView: content)
view.frame = CGRect(origin: .zero, size: size)
window.contentView = view
window.orderFrontRegardless()
view.layoutSubtreeIfNeeded()
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
let data = try #require(bitmap.bitmapData)
return Array(UnsafeBufferPointer(start: data, count: bitmap.bytesPerRow * bitmap.pixelsHigh))
}

@Test @MainActor func clockSettingsSamplesDrawTheFixedDate() throws {
// The clock shows only the time, so its own row draws the same a day later: only the format samples, which show
// dates, can tell the two days apart, and they do only if they draw the date the render gives them.
let fixture = try ThemeGalleryFixture(#"{"widgets":[{"id":"clock","type":"clock","position":"right","format":"HH:mm"}]}"#)
defer { fixture.close() }
let editor = ConfigurationEditor(store: try fixture.store())
editor.selectedID = "clock"
func settings(_ date: Date) throws -> [UInt8] {
try windowPixels(BarInspector(editor: editor, screen: .laptop, kinds: [.laptop], names: [:], source: { _ in .builtIn },
useSharedBar: { _ in }, remove: { _ in }, scrollPositions: .constant([:]))
.environment(\.fixedDate, date), size: NSSize(width: 560, height: 1000))
}
let first = try settings(fixed)
#expect(try difference(settings(fixed.addingTimeInterval(86_400)), first) > 1)
#expect(try difference(settings(fixed), first) <= 1)
}

@Test @MainActor func shimmerDrawsTheFixedDate() async throws {
func shimmer() throws -> [UInt8] {
try pixels(Text("Loading the answer").font(.system(size: 20)).modifier(Shimmer(active: true))
.frame(width: 240, height: 40).background(Color.black).environment(\.fixedDate, fixed))
}
let first = try shimmer()
try await Task.sleep(for: .milliseconds(600))
#expect(try difference(shimmer(), first) <= 1)
}
Loading
Loading