From 0245d41d753e1f96311989921d7dd9dbaa65bc1c Mon Sep 17 00:00:00 2001 From: winebarrel Date: Sun, 9 Aug 2026 17:04:14 +0900 Subject: [PATCH 1/2] Add CI, SwiftLint and SwiftFormat Modelled on winebarrel/Neco: a lint job, a format job, and a build job that also feeds its compiler log to `swiftlint analyze`. The build job covers both schemes. Two violations were real enough to fix rather than silence. ConfigureSheetController.buildInterface() had grown to 79 lines, so the grid, its rows and the button strip each get their own method. Options parsing was branchy enough to trip cyclomatic_complexity; splitting the flags that take a value from the ones that do not makes both halves small. PreviewApp.main() is the one suppression: @main means the runtime calls it and nothing else does, which unused_declaration cannot see. SwiftFormat's redundantSelf turned `let field = self.field` into `let field = field`, which reads like it initialises itself. Renamed the local to `area`. In the format job the paths come before --lint. The flag takes an optional value, so `swiftformat --lint Sources Preview` treats Sources as that value and fails. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 56 ++++++++++++ .swift-version | 1 + .swiftformat | 1 + .swiftlint.yml | 16 ++++ Preview/PreviewApp.swift | 70 ++++++++------ README.md | 7 ++ Sources/ConfigureSheetController.swift | 122 +++++++++++-------------- Sources/MacstifyEngine.swift | 42 +++++---- Sources/MacstifySettings.swift | 12 +-- Sources/MacstifyView.swift | 12 ++- 10 files changed, 214 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swift-version create mode 100644 .swiftformat create mode 100644 .swiftlint.yml 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..1d74f8b 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 { @@ -118,7 +120,7 @@ enum PreviewApp { 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 +207,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 +225,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 +269,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..f21c864 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 diff --git a/Sources/MacstifyEngine.swift b/Sources/MacstifyEngine.swift index 8667609..943dad2 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 @@ -160,19 +162,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 From 9e6bf8e456d6034e974d4ab57bb28929911f6cd7 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Sun, 9 Aug 2026 17:08:11 +0900 Subject: [PATCH 2/2] Wrap single-line if bodies SwiftFormat 0.62 enables wrapIfStatementBodies, which 0.61 did not have. CI installs the current release, so it failed on formatting the local run had passed. Co-Authored-By: Claude Opus 5 (1M context) --- Preview/PreviewApp.swift | 8 ++++++-- Sources/ConfigureSheetController.swift | 4 +++- Sources/MacstifyEngine.swift | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Preview/PreviewApp.swift b/Preview/PreviewApp.swift index 1d74f8b..65a4199 100644 --- a/Preview/PreviewApp.swift +++ b/Preview/PreviewApp.swift @@ -114,8 +114,12 @@ 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) diff --git a/Sources/ConfigureSheetController.swift b/Sources/ConfigureSheetController.swift index f21c864..387d5fd 100644 --- a/Sources/ConfigureSheetController.swift +++ b/Sources/ConfigureSheetController.swift @@ -198,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 943dad2..a03e7e6 100644 --- a/Sources/MacstifyEngine.swift +++ b/Sources/MacstifyEngine.swift @@ -150,7 +150,9 @@ final class MacstifyEngine { timeSinceGhost += dt let recordGhost = timeSinceGhost >= Self.ghostInterval - if recordGhost { timeSinceGhost = 0 } + if recordGhost { + timeSinceGhost = 0 + } for index in polygons.indices { var polygon = polygons[index]