diff --git a/Sources/tart/Commands/Set.swift b/Sources/tart/Commands/Set.swift index 384fda8c..6a8b737b 100644 --- a/Sources/tart/Commands/Set.swift +++ b/Sources/tart/Commands/Set.swift @@ -14,7 +14,7 @@ struct Set: AsyncParsableCommand { @Option(help: "VM memory size in megabytes") var memory: UInt64? - @Option(help: "VM display resolution in a format of WIDTHxHEIGHT[pt|px]. For example, 1200x800, 1200x800pt or 1920x1080px. Units are treated as hints and default to \"pt\" (points) for macOS VMs and \"px\" (pixels) for Linux VMs when not specified.") + @Option(help: "VM display resolution in a format of WIDTHxHEIGHT[pt|px][@PPI]. For example, 1200x800, 1200x800pt, 1920x1080px or 3200x1800px@220. Units are treated as hints and default to \"pt\" (points) for macOS VMs and \"px\" (pixels) for Linux VMs when not specified. The optional @PPI (pixels-per-inch) hint applies to pixel-unit displays and, at a Retina-class value such as 220, makes a macOS guest expose a HiDPI (2x) mode regardless of the host display; it defaults to 72 (non-Retina) when omitted.") var display: VMDisplayConfig? @Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window")) @@ -57,6 +57,7 @@ struct Set: AsyncParsableCommand { vmConfig.display.height = display.height } vmConfig.display.unit = display.unit + vmConfig.display.ppi = display.ppi } vmConfig.displayRefit = displayRefit @@ -91,6 +92,16 @@ extension VMDisplayConfig: ExpressibleByArgument { public init(argument: String) { var argument = argument var unit: Unit? = nil + var ppi: Int? = nil + + // Optional "@PPI" pixels-per-inch hint, e.g. "3200x1800px@220". Parsed + // before the unit suffix since it always trails the whole spec. Only a + // positive value is kept; zero/negative (or non-numeric) is ignored so the + // display falls back to the 72 default rather than a broken configuration. + if let atIndex = argument.lastIndex(of: "@") { + ppi = Int(argument[argument.index(after: atIndex)...]).flatMap { $0 > 0 ? $0 : nil } + argument = String(argument[.. 0 { + return ppi + } + return 72 + } } extension VMDisplayConfig: CustomStringConvertible { var description: String { + var result = "\(width)x\(height)" if let unit { - "\(width)x\(height)\(unit.rawValue)" - } else { - "\(width)x\(height)" + result += unit.rawValue + } + if let ppi { + result += "@\(ppi)" } + return result } } diff --git a/Tests/TartTests/VMConfigTests.swift b/Tests/TartTests/VMConfigTests.swift index 34fb9012..b590a847 100644 --- a/Tests/TartTests/VMConfigTests.swift +++ b/Tests/TartTests/VMConfigTests.swift @@ -15,4 +15,61 @@ final class VMConfigTests: XCTestCase { vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px") XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig) } + + func testEffectivePixelsPerInchUsesConfiguredValue() throws { + let config = VMDisplayConfig(width: 3200, height: 1800, unit: .pixel, ppi: 220) + XCTAssertEqual(220, config.effectivePixelsPerInch) + } + + func testEffectivePixelsPerInchDefaultsTo72WhenUnset() throws { + // Upstream behavior: a pixel display with no PPI hint stays non-Retina (72 PPI). + let config = VMDisplayConfig(width: 1234, height: 5678, unit: .pixel, ppi: nil) + XCTAssertEqual(72, config.effectivePixelsPerInch) + } + + func testParsesPixelsPerInchSuffix() throws { + let config = VMDisplayConfig(argument: "3200x1800px@220") + XCTAssertEqual(VMDisplayConfig(width: 3200, height: 1800, unit: .pixel, ppi: 220), config) + } + + func testWithoutSuffixHasNilPixelsPerInch() throws { + // Backward compatibility: existing "WIDTHxHEIGHT[pt|px]" strings carry no PPI. + XCTAssertEqual( + VMDisplayConfig(width: 1234, height: 5678, unit: .pixel, ppi: nil), + VMDisplayConfig(argument: "1234x5678px")) + } + + func testDescriptionRoundTripsPixelsPerInch() throws { + let config = VMDisplayConfig(width: 3200, height: 1800, unit: .pixel, ppi: 220) + XCTAssertEqual("3200x1800px@220", config.description) + XCTAssertEqual(config, VMDisplayConfig(argument: config.description)) + } + + func testParsesPixelsPerInchWithoutExplicitUnit() throws { + let config = VMDisplayConfig(argument: "3200x1800@220") + XCTAssertEqual(VMDisplayConfig(width: 3200, height: 1800, unit: nil, ppi: 220), config) + } + + func testMalformedPixelsPerInchDegradesToNil() throws { + // A non-numeric PPI is ignored (falls back to the 72 default) rather than + // failing the parse — consistent with the parser's lenient dimensions. + let config = VMDisplayConfig(argument: "3200x1800px@notanumber") + XCTAssertEqual(VMDisplayConfig(width: 3200, height: 1800, unit: .pixel, ppi: nil), config) + XCTAssertEqual(72, config.effectivePixelsPerInch) + } + + func testRejectsNonPositivePixelsPerInchWhenParsing() throws { + // A zero or negative PPI is meaningless and must not persist — it would + // otherwise reach VZMacGraphicsDisplayConfiguration and fail to build the + // display instead of falling back to the 72 default. + XCTAssertNil(VMDisplayConfig(argument: "3200x1800px@0").ppi) + XCTAssertNil(VMDisplayConfig(argument: "3200x1800px@-5").ppi) + } + + func testEffectivePixelsPerInchIgnoresNonPositiveStoredValue() throws { + // Defense for a hand-edited config.json: a stored value <= 0 falls back to + // 72 rather than being handed to VZMacGraphicsDisplayConfiguration. + XCTAssertEqual(72, VMDisplayConfig(width: 100, height: 100, unit: .pixel, ppi: 0).effectivePixelsPerInch) + XCTAssertEqual(72, VMDisplayConfig(width: 100, height: 100, unit: .pixel, ppi: -5).effectivePixelsPerInch) + } }