diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c469202 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftLint + run: brew install swiftlint + - name: Run SwiftLint + run: swiftlint --strict + + format: + name: Format + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftFormat + run: brew install swiftformat + # Paths come first: --lint would otherwise swallow the one after it. + - name: Check formatting + run: swiftformat Sources Preview --lint + + build: + name: Build and Analyse + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftLint + run: brew install swiftlint + - name: Build and Analyse + run: | + set -o pipefail + for scheme in Macstify MacstifyPreview; do + xcodebuild build analyze \ + -project Macstify.xcodeproj \ + -scheme "$scheme" \ + -destination 'generic/platform=macOS' \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + | tee -a build.log + done + - name: SwiftLint Analyse + run: swiftlint analyze --strict --compiler-log-path build.log diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..e0ea36f --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.0 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..9825c81 --- /dev/null +++ b/.swiftformat @@ -0,0 +1 @@ +--swiftversion 6.0 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..1099eac --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,16 @@ +excluded: + - build # xcodebuild output + +# Require trailing commas to match SwiftFormat's trailingCommas rule. +trailing_comma: + mandatory_comma: true + +# Geometry code uses short names like x, y, dx, dy. +identifier_name: + min_length: + warning: 1 + error: 1 + +analyzer_rules: + - unused_declaration + - unused_import diff --git a/Preview/PreviewApp.swift b/Preview/PreviewApp.swift index 9f5947b..65a4199 100644 --- a/Preview/PreviewApp.swift +++ b/Preview/PreviewApp.swift @@ -22,6 +22,8 @@ import AppKit enum PreviewApp { private static var delegate: PreviewDelegate? + // Called by the Swift runtime through @main, never from code. + // swiftlint:disable:next unused_declaration static func main() { let options: Options do { @@ -112,13 +114,17 @@ enum PreviewApp { _ = NSApplication.shared var settings = MacstifySettings.load() - if isPreview { settings = settings.previewAdjusted } - if let speed { settings.speed = speed } + if isPreview { + settings = settings.previewAdjusted + } + if let speed { + settings.speed = speed + } let rect = NSRect(origin: .zero, size: size) let engine = MacstifyEngine(settings: settings) engine.reset(bounds: rect) - for _ in 0.. Bool { true } + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } } private struct Options { @@ -203,34 +211,13 @@ private struct Options { var iterator = arguments.makeIterator() while let argument = iterator.next() { switch argument { - case "--snapshot": - snapshotPath = try Self.value(after: argument, from: &iterator) - case "--frames": - let raw = try Self.value(after: argument, from: &iterator) - guard let value = Int(raw), value > 0 else { - throw OptionError("--frames expects a positive integer, got \(raw)") - } - frames = value - case "--size": - let raw = try Self.value(after: argument, from: &iterator) - let parts = raw.lowercased().split(separator: "x") - guard parts.count == 2, - let width = Double(parts[0]), let height = Double(parts[1]), - width > 0, height > 0 else { - throw OptionError("--size expects WIDTHxHEIGHT, got \(raw)") - } - size = NSSize(width: width, height: height) - explicitSize = true - case "--speed": - let raw = try Self.value(after: argument, from: &iterator) - guard let value = Double(raw), value > 0 else { - throw OptionError("--speed expects a positive number, got \(raw)") - } - speed = value case "--options": showOptions = true case "--preview": isPreview = true + case "--snapshot", "--frames", "--size", "--speed": + let raw = try Self.value(after: argument, from: &iterator) + try set(argument, to: raw, explicitSize: &explicitSize) default: throw OptionError("unknown argument: \(argument)") } @@ -242,6 +229,37 @@ private struct Options { } } + private mutating func set(_ flag: String, to raw: String, explicitSize: inout Bool) throws { + switch flag { + case "--snapshot": + snapshotPath = raw + case "--frames": + guard let value = Int(raw), value > 0 else { + throw OptionError("\(flag) expects a positive integer, got \(raw)") + } + frames = value + case "--size": + size = try Self.parseSize(raw) + explicitSize = true + default: + guard let value = Double(raw), value > 0 else { + throw OptionError("\(flag) expects a positive number, got \(raw)") + } + speed = value + } + } + + private static func parseSize(_ raw: String) throws -> NSSize { + let parts = raw.lowercased().split(separator: "x") + guard parts.count == 2, + let width = Double(parts[0]), let height = Double(parts[1]), + width > 0, height > 0 + else { + throw OptionError("--size expects WIDTHxHEIGHT, got \(raw)") + } + return NSSize(width: width, height: height) + } + private static func value( after flag: String, from iterator: inout some IteratorProtocol @@ -255,5 +273,7 @@ private struct Options { private struct OptionError: LocalizedError { let errorDescription: String? - init(_ message: String) { errorDescription = message } + init(_ message: String) { + errorDescription = message + } } diff --git a/README.md b/README.md index a02ef37..cfdd9c5 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,13 @@ snapshots are written at 2x, as on a Retina display. The screenshots above were produced with `--snapshot`. +CI builds and analyses both schemes and runs the same lint and format checks you can run locally: + +```sh +swiftlint --strict +swiftformat Sources Preview --lint +``` + ### Thumbnail The picker in System Settings shows `Contents/Resources/thumbnail.png` and `thumbnail@2x.png` diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift index a95a75e..387d5fd 100644 --- a/Sources/ConfigureSheetController.swift +++ b/Sources/ConfigureSheetController.swift @@ -39,60 +39,65 @@ final class ConfigureSheetController: NSObject { // MARK: - Interface private func buildInterface() { - let integer: (Double) -> String = { String(Int($0.rounded())) } - let multiplier: (Double) -> String = { String(format: "%.2f×", $0) } + let root = NSStackView(views: [makeGrid(), makeButtons()]) + root.orientation = .vertical + root.alignment = .width + root.spacing = 20 + root.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + root.translatesAutoresizingMaskIntoConstraints = false - let gridRows: [[NSView]] = [ - makeRow( - title: "Shapes:", - control: makeStepper(range: MacstifySettings.polygonCountRange), - describe: integer, - read: { Double($0.polygonCount) }, - write: { $0.polygonCount = Int($1.rounded()) } - ), - makeRow( - title: "Vertices:", - control: makeStepper(range: MacstifySettings.vertexCountRange), - describe: integer, - read: { Double($0.vertexCount) }, - write: { $0.vertexCount = Int($1.rounded()) } - ), - makeRow( - title: "Trail length:", - control: makeSlider(range: MacstifySettings.trailLengthRange), - describe: integer, - read: { Double($0.trailLength) }, - write: { $0.trailLength = Int($1.rounded()) } - ), - makeRow( - title: "Speed:", - control: makeSlider(range: MacstifySettings.speedRange), - describe: multiplier, - read: { $0.speed }, - write: { $0.speed = $1 } - ), - makeRow( - title: "Line width:", - control: makeSlider(range: MacstifySettings.lineWidthRange), - describe: { String(format: "%.1f pt", $0) }, - read: { $0.lineWidth }, - write: { $0.lineWidth = $1 } - ), - makeRow( - title: "Color speed:", - control: makeSlider(range: MacstifySettings.colorSpeedRange), - describe: multiplier, - read: { $0.colorSpeed }, - write: { $0.colorSpeed = $1 } - ), - ] + let content = NSView() + content.addSubview(root) + NSLayoutConstraint.activate([ + root.leadingAnchor.constraint(equalTo: content.leadingAnchor), + root.trailingAnchor.constraint(equalTo: content.trailingAnchor), + root.topAnchor.constraint(equalTo: content.topAnchor), + root.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) - let grid = NSGridView(views: gridRows) + window.contentView = content + content.layoutSubtreeIfNeeded() + window.setContentSize(content.fittingSize) + } + + private func makeGrid() -> NSGridView { + let grid = NSGridView(views: makeRows()) grid.translatesAutoresizingMaskIntoConstraints = false grid.column(at: 0).xPlacement = .trailing grid.rowSpacing = 10 grid.columnSpacing = 10 + return grid + } + + private func makeRows() -> [[NSView]] { + let integer: (Double) -> String = { String(Int($0.rounded())) } + let multiplier: (Double) -> String = { String(format: "%.2f×", $0) } + let points: (Double) -> String = { String(format: "%.1f pt", $0) } + let settings = MacstifySettings.self + + return [ + makeRow(title: "Shapes:", control: makeStepper(range: settings.polygonCountRange), + describe: integer, read: { Double($0.polygonCount) }, + write: { $0.polygonCount = Int($1.rounded()) }), + makeRow(title: "Vertices:", control: makeStepper(range: settings.vertexCountRange), + describe: integer, read: { Double($0.vertexCount) }, + write: { $0.vertexCount = Int($1.rounded()) }), + makeRow(title: "Trail length:", control: makeSlider(range: settings.trailLengthRange), + describe: integer, read: { Double($0.trailLength) }, + write: { $0.trailLength = Int($1.rounded()) }), + makeRow(title: "Speed:", control: makeSlider(range: settings.speedRange), + describe: multiplier, read: { $0.speed }, + write: { $0.speed = $1 }), + makeRow(title: "Line width:", control: makeSlider(range: settings.lineWidthRange), + describe: points, read: { $0.lineWidth }, + write: { $0.lineWidth = $1 }), + makeRow(title: "Color speed:", control: makeSlider(range: settings.colorSpeedRange), + describe: multiplier, read: { $0.colorSpeed }, + write: { $0.colorSpeed = $1 }), + ] + } + private func makeButtons() -> NSStackView { let restore = NSButton(title: "Restore Defaults", target: self, action: #selector(restoreDefaults)) let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancel)) @@ -105,26 +110,7 @@ final class ConfigureSheetController: NSObject { buttons.spacing = 12 buttons.setViews([restore], in: .leading) buttons.setViews([cancel, ok], in: .trailing) - - let root = NSStackView(views: [grid, buttons]) - root.orientation = .vertical - root.alignment = .width - root.spacing = 20 - root.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) - root.translatesAutoresizingMaskIntoConstraints = false - - let content = NSView() - content.addSubview(root) - NSLayoutConstraint.activate([ - root.leadingAnchor.constraint(equalTo: content.leadingAnchor), - root.trailingAnchor.constraint(equalTo: content.trailingAnchor), - root.topAnchor.constraint(equalTo: content.topAnchor), - root.bottomAnchor.constraint(equalTo: content.bottomAnchor), - ]) - - window.contentView = content - content.layoutSubtreeIfNeeded() - window.setContentSize(content.fittingSize) + return buttons } private func makeRow( @@ -172,7 +158,7 @@ final class ConfigureSheetController: NSObject { } private func makeSlider(range: ClosedRange) -> NSSlider { - makeSlider(range: Double(range.lowerBound)...Double(range.upperBound)) + makeSlider(range: Double(range.lowerBound) ... Double(range.upperBound)) } /// Pushes `settings` back out to every control, so one refresh path serves @@ -212,7 +198,9 @@ final class ConfigureSheetController: NSObject { parent.endSheet(window, returnCode: saved ? .OK : .cancel) } else { // Reachable from the preview app, which shows the sheet standalone. - if NSApp.modalWindow === window { NSApp.stopModal() } + if NSApp.modalWindow === window { + NSApp.stopModal() + } window.orderOut(nil) } onDismiss(saved) diff --git a/Sources/MacstifyEngine.swift b/Sources/MacstifyEngine.swift index 8667609..a03e7e6 100644 --- a/Sources/MacstifyEngine.swift +++ b/Sources/MacstifyEngine.swift @@ -43,7 +43,9 @@ final class MacstifyEngine { /// Ghosts to keep. `trailLength` counts the outlines actually on screen, /// and the live shape is one of them. - private var ghostCapacity: Int { max(0, settings.trailLength - 1) } + private var ghostCapacity: Int { + max(0, settings.trailLength - 1) + } init(settings: MacstifySettings = .load()) { self.settings = settings @@ -82,30 +84,30 @@ final class MacstifyEngine { polygons = [] timeSinceGhost = 0 - let field = self.field - guard field.width > 0, field.height > 0 else { return } + let area = field + guard area.width > 0, area.height > 0 else { return } - let speed = min(field.width, field.height) * Self.baseSpeedRatio * settings.speed + let speed = min(area.width, area.height) * Self.baseSpeedRatio * settings.speed // One shared rate: every shape keeps its initial slice of the colour // wheel forever. Per-shape rates let the hues drift together after a // minute or two, which reads as a bug rather than as palette cycling. let hueSpeed = Self.baseHueSpeed * settings.colorSpeed - polygons = (0.. 0, field.height > 0 else { return } + let area = field + guard area.width > 0, area.height > 0 else { return } timeSinceGhost += dt let recordGhost = timeSinceGhost >= Self.ghostInterval - if recordGhost { timeSinceGhost = 0 } + if recordGhost { + timeSinceGhost = 0 + } for index in polygons.indices { var polygon = polygons[index] @@ -160,19 +164,19 @@ final class MacstifyEngine { point.x += velocity.dx * dt point.y += velocity.dy * dt - if point.x < field.minX { - point.x = min(field.minX + (field.minX - point.x), field.maxX) + if point.x < area.minX { + point.x = min(area.minX + (area.minX - point.x), area.maxX) velocity.dx = -velocity.dx - } else if point.x > field.maxX { - point.x = max(field.maxX - (point.x - field.maxX), field.minX) + } else if point.x > area.maxX { + point.x = max(area.maxX - (point.x - area.maxX), area.minX) velocity.dx = -velocity.dx } - if point.y < field.minY { - point.y = min(field.minY + (field.minY - point.y), field.maxY) + if point.y < area.minY { + point.y = min(area.minY + (area.minY - point.y), area.maxY) velocity.dy = -velocity.dy - } else if point.y > field.maxY { - point.y = max(field.maxY - (point.y - field.maxY), field.minY) + } else if point.y > area.maxY { + point.y = max(area.maxY - (point.y - area.maxY), area.minY) velocity.dy = -velocity.dy } diff --git a/Sources/MacstifySettings.swift b/Sources/MacstifySettings.swift index 807723d..2d21c76 100644 --- a/Sources/MacstifySettings.swift +++ b/Sources/MacstifySettings.swift @@ -27,12 +27,12 @@ struct MacstifySettings: Equatable { colorSpeed: 1.0 ) - static let polygonCountRange = 1...8 - static let vertexCountRange = 3...12 - static let trailLengthRange = 1...40 - static let speedRange = 0.2...3.0 - static let lineWidthRange = 0.5...4.0 - static let colorSpeedRange = 0.0...3.0 + static let polygonCountRange = 1 ... 8 + static let vertexCountRange = 3 ... 12 + static let trailLengthRange = 1 ... 40 + static let speedRange = 0.2 ... 3.0 + static let lineWidthRange = 0.5 ... 4.0 + static let colorSpeedRange = 0.0 ... 3.0 private enum Key { static let polygonCount = "polygonCount" diff --git a/Sources/MacstifyView.swift b/Sources/MacstifyView.swift index 8b2ee13..e0db4ac 100644 --- a/Sources/MacstifyView.swift +++ b/Sources/MacstifyView.swift @@ -20,11 +20,13 @@ final class MacstifyView: ScreenSaverView { } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - override var isOpaque: Bool { true } + override var isOpaque: Bool { + true + } override func startAnimation() { super.startAnimation() @@ -38,7 +40,7 @@ final class MacstifyView: ScreenSaverView { setNeedsDisplay(bounds) } - override func draw(_ rect: NSRect) { + override func draw(_: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } engine.draw(in: bounds, context: context) } @@ -48,7 +50,9 @@ final class MacstifyView: ScreenSaverView { engine.resize(to: bounds) } - override var hasConfigureSheet: Bool { true } + override var hasConfigureSheet: Bool { + true + } override var configureSheet: NSWindow? { let controller = ConfigureSheetController { [weak self] saved in