diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index bf55b8d1..fc927b08 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -72,6 +72,14 @@ struct StreamCommands: Commands { } .keyboardShortcut("c", modifiers: [.control, .option, .shift]) .disabled(session?.isStreaming != true || session?.clipboardAvailable != true) + // Toggle the window's fullscreen. ⌃⌘F is the macOS-standard fullscreen combo; here it's + // explicit so it's discoverable AND survives capture — while streaming the stream view + // swallows keys, so InputCapture's monitor detects the same combo and posts the same + // notification the key window's FullscreenController observes. + Button("Toggle Fullscreen") { + NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil) + } + .keyboardShortcut("f", modifiers: [.control, .command]) #endif Divider() Button("Disconnect") { session?.disconnect() } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index c8c8f715..8abe3542 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -38,6 +38,7 @@ extension SettingsView { } #endif #if !os(tvOS) + renderScaleRow bitrateRows #endif } header: { @@ -54,6 +55,50 @@ extension SettingsView { } } + #if !os(tvOS) + /// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more + /// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the + /// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives. + @ViewBuilder var renderScaleRow: some View { + Picker("Render scale", selection: $renderScale) { + ForEach(RenderScale.presets, id: \.self) { scale in + Text(RenderScale.label(scale)).tag(scale) + } + } + // The concrete host resolution makes the cost legible. Only meaningful for the explicit mode + // (match-window derives the base from the live window, not these fields). + if renderScale != 1.0, !matchWindow { + let mode = RenderScale.apply( + baseWidth: width, baseHeight: height, + scale: renderScale, + maxDimension: RenderScale.maxDimension(codec: codec)) + Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + + /// Keyboard & mouse forwarding — applies wherever a hardware keyboard/mouse drives the stream + /// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path). + @ViewBuilder var inputSection: some View { + Section { + Picker("Modifier keys", selection: $modifierLayout) { + ForEach(ModifierLayout.allCases, id: \.self) { layout in + Text(layout.label).tag(layout.rawValue) + } + } + Toggle("Invert scroll direction", isOn: $invertScroll) + } header: { + Text("Keyboard & mouse") + } footer: { + Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail + + " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + #endif + #if os(iOS) // MARK: - Stream mode (iOS wheel) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 975512e4..cda632eb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -1,5 +1,6 @@ -// App settings. The host creates a native virtual output at exactly the chosen size/refresh — -// there is no scaling anywhere in the pipeline. +// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only +// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device +// downscales — supersampling for sharpness, or under-rendering for a lighter host/link). // // Navigation differs per platform, but all three group the same categories (General, Display, // Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses @@ -25,6 +26,10 @@ struct SettingsView: View { // windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays // pixel-exact rather than the presenter resampling a fixed-mode frame into the window. @AppStorage(DefaultsKey.matchWindow) var matchWindow = false + // Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the + // presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host / + // link). 1.0 = Native (the prior behaviour). + @AppStorage(DefaultsKey.renderScale) var renderScale = 1.0 @AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @@ -51,6 +56,12 @@ struct SettingsView: View { @AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true @AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false @AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10 + #if !os(tvOS) + // Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips + // both wheel axes; modifier-layout relocates the ⌥/⌘ → Alt/Super roles by physical position. + @AppStorage(DefaultsKey.invertScroll) var invertScroll = false + @AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue + #endif #if DEBUG && !os(tvOS) @State var showControllerTest = false #endif @@ -112,6 +123,7 @@ struct SettingsView: View { TabView { Form { streamModeSection + inputSection compositorSection wakeSection } @@ -242,6 +254,7 @@ struct SettingsView: View { Form { streamModeSection pointerSection + inputSection compositorSection wakeSection keepAliveSection // iOS-only content; empty on tvOS @@ -334,6 +347,10 @@ struct SettingsView: View { return ScrollView { VStack(spacing: 16) { TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) + TVSelectionRow( + title: "Render scale", + options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) }, + selection: $renderScale) TVSelectionRow( title: "Bitrate", options: SettingsOptions.bitrateOptions(current: bitrateKbps), diff --git a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index 66aa680c..3a00bd9d 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift @@ -41,6 +41,7 @@ import UIKit import Foundation import GameController import PunktfunkCore +import PunktfunkShared import os /// Diagnostic logging for the input path. Off by default (input is high-rate); set @@ -125,6 +126,12 @@ public final class InputCapture { public var onDisconnect: (() -> Void)? public var onCycleStats: (() -> Void)? + /// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the + /// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view + /// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the + /// menu handles it. Main queue. + public var onToggleFullscreen: (() -> Void)? + #if os(iOS) /// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides: /// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream. @@ -273,6 +280,14 @@ public final class InputCapture { break } } + // ⌃⌘F toggles the streaming window's fullscreen. Intercepted only while forwarding (the + // captured stream view swallows the menu's identical equivalent); the F is latched so its + // keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent). + if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ { + self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC + self.onToggleFullscreen?() + return nil + } return event } #endif @@ -318,7 +333,7 @@ public final class InputCapture { chordModifiersDown.removeAll() suppressedVK = nil for vk in pressedVKs { - connection.send(.key(vk, down: false)) + emitKey(vk, down: false) } for button in pressedButtons { connection.send(.mouseButton(button, down: false)) @@ -331,6 +346,15 @@ public final class InputCapture { residualScrollY = 0 } + /// The single wire boundary for a key event. Every `.key` send funnels through here so the + /// active location-based modifier layout is applied in exactly one place while all internal + /// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on + /// the physical VK. Read live from the setting so a mid-session change (rare) takes on the next + /// key without re-arming capture. Non-modifier VKs pass through untouched. + private func emitKey(_ vk: UInt32, down: Bool) { + connection.send(.key(Self.applyModifierLayout(vk, ModifierLayout.current), down: down)) + } + /// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when /// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release /// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release @@ -399,7 +423,7 @@ public final class InputCapture { inputLog.debug( "key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent") } - connection.send(.key(vk, down: down)) + emitKey(vk, down: down) } /// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive @@ -566,8 +590,15 @@ public final class InputCapture { /// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery /// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them). /// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away. - public func sendScroll(dx: Float, dy: Float) { + public func sendScroll(dx rawDx: Float, dy rawDy: Float) { guard forwarding else { return } + // Optionally invert both axes (read live). This is the ONE scroll sink for every platform — + // the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here — so the toggle + // flips them consistently. Residuals are accumulated AFTER inversion so a direction change + // between events doesn't strand a fractional remainder of the old sign. + let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll) + let dx = invert ? -rawDx : rawDx + let dy = invert ? -rawDy : rawDy let fy = dy + residualScrollY let fx = dx + residualScrollX let iy = fy.rounded(.towardZero) @@ -643,7 +674,7 @@ public final class InputCapture { } else { self.pressedVKs.remove(vk) } - self.connection.send(.key(vk, down: pressed)) + self.emitKey(vk, down: pressed) } #endif } diff --git a/clients/apple/Sources/PunktfunkKit/Input/KeyMaps.swift b/clients/apple/Sources/PunktfunkKit/Input/KeyMaps.swift index 3665135c..76a60cff 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/KeyMaps.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/KeyMaps.swift @@ -1,7 +1,28 @@ // InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all // platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path). +import PunktfunkShared + extension InputCapture { + /// Remap one modifier VK for the active location-based [`ModifierLayout`] just before it goes on + /// the wire. `.mac` (and every non-modifier VK) is the identity. `.windows` swaps the Alt vs + /// Super/Windows ROLE between the Option and Command keys while KEEPING the side, so a Windows + /// user's `⌘ = Alt, ⌥ = Win` muscle memory lands correctly: + /// L Command 0x5B ↔ L Alt 0xA4 · R Command 0x5C ↔ R Alt 0xA5. + /// It's applied ONLY at the send boundary (`InputCapture.emitKey`) — all press/release + /// bookkeeping stays on the physical VK, so modifier direction tracking and the client-local + /// ⌘-based shortcuts are untouched. The swap is its own inverse: a key that went down remapped + /// goes up remapped, so the host never sees a stuck modifier. + static func applyModifierLayout(_ vk: UInt32, _ layout: ModifierLayout) -> UInt32 { + guard layout == .windows else { return vk } + switch vk { + case 0x5B: return 0xA4 // L Command → L Alt (VK_LWIN → VK_LMENU) + case 0x5C: return 0xA5 // R Command → R Alt (VK_RWIN → VK_RMENU) + case 0xA4: return 0x5B // L Option → L Win (VK_LMENU → VK_LWIN) + case 0xA5: return 0x5C // R Option → R Win (VK_RMENU → VK_RWIN) + default: return vk + } + } /// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted /// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together). static let hidToVK: [Int: UInt32] = { diff --git a/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift b/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift index 6907af56..9f30bf91 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift @@ -15,6 +15,7 @@ // from looping request → rollback → request. import Foundation +import PunktfunkShared /// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline /// is unit-tested without a live connection or a UI (`MatchWindowTests`). @@ -51,6 +52,11 @@ public final class MatchWindowFollower { private weak var connection: PunktfunkConnection? private let debounce: TimeInterval private let minSpacing: TimeInterval + /// Render-scale multiplier applied to the live window size before it's requested, so the + /// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the + /// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling. + private let renderScale: Double + private let maxDimension: Int private var enabled: Bool private var work: DispatchWorkItem? @@ -74,15 +80,28 @@ public final class MatchWindowFollower { public init( connection: PunktfunkConnection, enabled: Bool, + renderScale: Double = 1.0, + maxDimension: Int = 8192, debounce: TimeInterval = 0.4, minSpacing: TimeInterval = 1.0 ) { self.connection = connection self.enabled = enabled + self.renderScale = RenderScale.sanitize(renderScale) + self.maxDimension = maxDimension self.debounce = debounce self.minSpacing = minSpacing } + /// The host-valid mode for a live window size: the window's physical pixels × the render scale, + /// aspect-preserved, even, and clamped to the codec ceiling — the match-window twin of + /// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0. + private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) { + RenderScale.apply( + baseWidth: widthPx, baseHeight: heightPx, + scale: renderScale, maxDimension: maxDimension) + } + /// Turn following on/off live (a mid-session settings change; off cancels a pending request). public func setEnabled(_ on: Bool) { enabled = on @@ -109,7 +128,7 @@ public final class MatchWindowFollower { /// mode yet → nothing to compare against, skip. private func reportSteering(widthPx: Int, heightPx: Int) { guard let connection else { return } - let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx) + let target = targetMode(widthPx: widthPx, heightPx: heightPx) let mode = connection.currentMode() guard mode.width > 0, mode.height > 0 else { return } if target.width == mode.width, target.height == mode.height { @@ -137,7 +156,7 @@ public final class MatchWindowFollower { schedule() return } - let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height) + let target = targetMode(widthPx: size.width, heightPx: size.height) let mode = connection.currentMode() pendingSize = nil guard let req = MatchWindow.request( diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index a92d3335..abead900 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -621,6 +621,12 @@ public final class StreamLayerView: NSView { guard let self, self.window?.isKeyWindow == true else { return } self.onDisconnectRequest?() } + capture.onToggleFullscreen = { [weak self] in + // App-level window action: post to the key window's FullscreenController (same routing as + // the Stream menu's ⌃⌘F item, so captured and released states hit one code path). + guard self?.window?.isKeyWindow == true else { return } + NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil) + } capture.onCycleStats = { [weak self] in guard self?.window?.isKeyWindow == true else { return } // Advance the shared tier setting directly — every @AppStorage reader (the HUD's @@ -671,7 +677,10 @@ public final class StreamLayerView: NSView { // default keeps the explicit mode. let follower = MatchWindowFollower( connection: connection, - enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) + enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false, + renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0, + maxDimension: RenderScale.maxDimension( + codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto")) follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower) matchFollower = follower layoutPresenter() diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 3afad18e..57af2f3e 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -385,7 +385,10 @@ public final class StreamViewController: StreamViewControllerBase { // default keeps the explicit mode. let follower = MatchWindowFollower( connection: connection, - enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) + enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false, + renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0, + maxDimension: RenderScale.maxDimension( + codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto")) follower.onResizeTarget = onResizeTarget matchFollower = follower #endif diff --git a/clients/apple/Sources/PunktfunkShared/ModifierLayout.swift b/clients/apple/Sources/PunktfunkShared/ModifierLayout.swift new file mode 100644 index 00000000..ec765f3e --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/ModifierLayout.swift @@ -0,0 +1,52 @@ +// Location-based modifier mapping — which Windows VK each PHYSICAL modifier position forwards to +// the host. A Mac keyboard's bottom row is `⌃ Control / ⌥ Option / ⌘ Command / space`; a Windows +// keyboard's is `Ctrl / ⊞ Super / Alt / space`. So the key NEAREST the space bar is Command on a +// Mac but Alt on Windows, and the next one out is Option on a Mac but the Windows key. A user who +// grew up on Windows reaches for Alt where the Mac has Command; this setting lets them get that +// muscle memory back WITHOUT relabelling keycaps — it remaps by physical position, not by a blunt +// "swap these two VKs" that would also drag Control/Shift or lose the left/right distinction. +// +// The model: keep the physical detection (which side, which key) exactly as the OS reports it, and +// swap only the Alt-vs-Super ROLE between the Option and Command keys, per side. So under `.windows` +// the ⌘ position emits Alt (VK_L/RMENU) and the ⌥ position emits the Windows key (VK_L/RWIN), while +// left stays left and right stays right. Control and Shift are in the same place on both keyboards, +// so they never move. Lives in PunktfunkShared because both the Settings UI (PunktfunkClient) and +// the wire-boundary remap (`InputCapture.applyModifierLayout`, PunktfunkKit) resolve it. + +import Foundation + +/// How the physical ⌥ Option / ⌘ Command keys map to host modifiers. The raw values are stable on +/// disk — rename the cases freely, never the strings. +public enum ModifierLayout: String, CaseIterable, Sendable { + /// Apple positions (default): ⌥ Option → Alt, ⌘ Command → Super/Windows. The current behaviour. + case mac + /// Windows positions: the key nearest the space bar (⌘ Command) → Alt, the next one (⌥ Option) + /// → the Windows/Super key. Side (left/right) is preserved. + case windows + + /// User-facing label (Settings picker). + public var label: String { + switch self { + case .mac: return "Mac (⌥ Alt · ⌘ Super)" + case .windows: return "Windows (⌘ Alt · ⌥ Super)" + } + } + + /// A one-line explanation for the setting's footer. + public var detail: String { + switch self { + case .mac: + return "The ⌥ Option key sends Alt and the ⌘ Command key sends the Windows key — the Apple layout." + case .windows: + return "The key nearest the space bar sends Alt and the next one sends the Windows key, matching a PC keyboard. Client shortcuts (⌘⎋ and friends) still use the physical ⌘ key." + } + } + + /// The persisted layout (default `.mac` when unset). + public static var current: ModifierLayout { + guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else { + return .mac + } + return ModifierLayout(rawValue: raw) ?? .mac + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/ModifierLayoutMappingTests.swift b/clients/apple/Tests/PunktfunkKitTests/ModifierLayoutMappingTests.swift new file mode 100644 index 00000000..51a56522 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/ModifierLayoutMappingTests.swift @@ -0,0 +1,51 @@ +import XCTest + +import PunktfunkShared +@testable import PunktfunkKit + +/// Pins the location-based modifier remap (`InputCapture.applyModifierLayout`) — the wire-boundary +/// swap that relocates the Alt/Super role between the physical ⌥/⌘ keys per side, without touching +/// Control/Shift or the physical detection upstream. +final class ModifierLayoutMappingTests: XCTestCase { + // The four physical modifier VKs the remap can touch, and everything else it must not. + private let lWin: UInt32 = 0x5B, rWin: UInt32 = 0x5C + private let lAlt: UInt32 = 0xA4, rAlt: UInt32 = 0xA5 + + func testMacLayoutIsIdentity() { + for vk: UInt32 in [lWin, rWin, lAlt, rAlt, 0xA0, 0xA2, 0x41, 0x1B] { + XCTAssertEqual(InputCapture.applyModifierLayout(vk, .mac), vk) + } + } + + func testWindowsLayoutSwapsAltAndSuperPerSide() { + XCTAssertEqual(InputCapture.applyModifierLayout(lWin, .windows), lAlt) // L ⌘ → L Alt + XCTAssertEqual(InputCapture.applyModifierLayout(rWin, .windows), rAlt) // R ⌘ → R Alt + XCTAssertEqual(InputCapture.applyModifierLayout(lAlt, .windows), lWin) // L ⌥ → L Win + XCTAssertEqual(InputCapture.applyModifierLayout(rAlt, .windows), rWin) // R ⌥ → R Win + } + + func testWindowsLayoutKeepsSideNeverCrossesLeftRight() { + // A left key never becomes a right VK or vice-versa. + XCTAssertNotEqual(InputCapture.applyModifierLayout(lWin, .windows), rAlt) + XCTAssertNotEqual(InputCapture.applyModifierLayout(rWin, .windows), lAlt) + } + + func testControlShiftAndRegularKeysNeverMove() { + for vk: UInt32 in [ + 0xA0, 0xA1, // L/R Shift + 0xA2, 0xA3, // L/R Control + 0x41, 0x5A, // A, Z + 0x1B, 0x0D, // Esc, Return + ] { + XCTAssertEqual(InputCapture.applyModifierLayout(vk, .windows), vk) + } + } + + func testWindowsRemapIsItsOwnInverse() { + // Down-remapped then the same key up-remapped land on the same host VK — no stuck modifier. + for vk: UInt32 in [lWin, rWin, lAlt, rAlt] { + let once = InputCapture.applyModifierLayout(vk, .windows) + XCTAssertEqual(InputCapture.applyModifierLayout(once, .windows), vk) + } + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/RenderScaleTests.swift b/clients/apple/Tests/PunktfunkKitTests/RenderScaleTests.swift new file mode 100644 index 00000000..44a1afbc --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/RenderScaleTests.swift @@ -0,0 +1,82 @@ +import XCTest + +import PunktfunkShared + +/// Pins the render-scale geometry (`RenderScale`) — the multiply → aspect-preserve → even-floor → +/// codec-clamp that turns the `renderScale` setting into a host-valid `Mode`, plus the label/clamp +/// helpers the UI and the connect share. +final class RenderScaleTests: XCTestCase { + func testSanitizeClampsToRangeAndDefaultsMissing() { + XCTAssertEqual(RenderScale.sanitize(0), 1.0) // absent / zero → Native + XCTAssertEqual(RenderScale.sanitize(-2), 1.0) // nonsense → Native + XCTAssertEqual(RenderScale.sanitize(0.1), 0.5) // below the floor + XCTAssertEqual(RenderScale.sanitize(9), 4.0) // above the ceiling + XCTAssertEqual(RenderScale.sanitize(1.5), 1.5) // in range, untouched + } + + func testMaxDimensionIsCodecAware() { + XCTAssertEqual(RenderScale.maxDimension(codec: "h264"), 4096) + XCTAssertEqual(RenderScale.maxDimension(codec: "hevc"), 8192) + XCTAssertEqual(RenderScale.maxDimension(codec: "av1"), 8192) + XCTAssertEqual(RenderScale.maxDimension(codec: "auto"), 8192) + } + + func testNativeScaleIsIdentity() { + let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 1.0, maxDimension: 8192) + XCTAssertEqual(m.width, 1920) + XCTAssertEqual(m.height, 1080) + } + + func testSupersampleDoubles() { + let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 2.0, maxDimension: 8192) + XCTAssertEqual(m.width, 3840) + XCTAssertEqual(m.height, 2160) + } + + func testUnderRenderHalves() { + let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 0.5, maxDimension: 8192) + XCTAssertEqual(m.width, 960) + XCTAssertEqual(m.height, 540) + } + + func testResultsAreAlwaysEven() { + // 1280×720 × 1.5 = 1920×1080 (already even); 1366×768 × 1.5 = 2049×1152 → 2048×1152. + let m = RenderScale.apply(baseWidth: 1366, baseHeight: 768, scale: 1.5, maxDimension: 8192) + XCTAssertEqual(m.width % 2, 0) + XCTAssertEqual(m.height % 2, 0) + XCTAssertEqual(m.width, 2048) + XCTAssertEqual(m.height, 1152) + } + + func testOverCeilingClampsUniformlyPreservingAspect() { + // 4K × 4 would be 15360×8640; both axes exceed 8192, so the LARGER (width) lands on the cap + // and the ratio is kept (16:9 → 8192×4608, even). + let m = RenderScale.apply(baseWidth: 3840, baseHeight: 2160, scale: 4.0, maxDimension: 8192) + XCTAssertLessThanOrEqual(m.width, 8192) + XCTAssertLessThanOrEqual(m.height, 8192) + XCTAssertEqual(m.width, 8192) + XCTAssertEqual(m.height, 4608) + // 16:9 preserved to within the even-floor rounding. + XCTAssertEqual(Double(m.width) / Double(m.height), 16.0 / 9.0, accuracy: 0.01) + } + + func testH264CeilingIsTighter() { + // 1080p × 4 = 7680×4320; under H.264's 4096 wall the width clamps to 4096, aspect kept. + let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 4.0, maxDimension: 4096) + XCTAssertEqual(m.width, 4096) + XCTAssertEqual(m.height, 2304) + } + + func testMinimumFloorHonoured() { + // A tiny base under a small scale can't fall below 320×200. + let m = RenderScale.apply(baseWidth: 400, baseHeight: 300, scale: 0.5, maxDimension: 8192) + XCTAssertGreaterThanOrEqual(m.width, 320) + XCTAssertGreaterThanOrEqual(m.height, 200) + } + + func testLabels() { + XCTAssertEqual(RenderScale.label(1.0), "Native (1×)") + XCTAssertEqual(RenderScale.label(2.0), "2× · supersample") + XCTAssertEqual(RenderScale.label(0.5), "0.5×") + } +}