diff --git a/Sources/TouchpadInputApp/Views/ContentView.swift b/Sources/TouchpadInputApp/Views/ContentView.swift index e3104e6..7b158d9 100644 --- a/Sources/TouchpadInputApp/Views/ContentView.swift +++ b/Sources/TouchpadInputApp/Views/ContentView.swift @@ -37,7 +37,7 @@ struct ContentView: View { TrackpadSurface( fingers: session.liveFingers, isActive: session.isActive, - zones: KeyGrid.default.applying(calibration: session.userCalibration).zones, + zones: KeyGrid.default.zones, activeModifiers: session.activeModifiers ) .padding(16) @@ -46,9 +46,19 @@ struct ContentView: View { .frame(width: 340) } Divider() - OutputBufferPanel(text: session.outputBuffer, activeModifiers: session.activeModifiers) - Divider() - EventLogPanel(entries: session.eventLog) + HStack(spacing: 0) { + TrackpadSurface( + fingers: session.liveFingers, + isActive: session.isActive, + zones: KeyGrid.default.zones, + activeModifiers: session.activeModifiers + ) + .padding(16) + Divider() + OutputBufferPanel(text: session.outputBuffer, activeModifiers: session.activeModifiers) + Divider() + EventLogPanel(entries: session.eventLog) + } } else { DrawingCanvasView(session: drawSession) } diff --git a/Sources/TouchpadInputCore/Calibration/UserCalibration.swift b/Sources/TouchpadInputCore/Calibration/UserCalibration.swift index 3ceee67..e399a36 100644 --- a/Sources/TouchpadInputCore/Calibration/UserCalibration.swift +++ b/Sources/TouchpadInputCore/Calibration/UserCalibration.swift @@ -18,6 +18,16 @@ public struct UserCalibration: Codable, Sendable { public static let empty = UserCalibration(offsets: [:]) + /// Median per-axis offset across all calibrated keys. Applied to the raw tap coordinate + /// before zone lookup so the grid boundaries stay intact (no overlap / gaps). + public var globalOffset: (dx: Float, dy: Float) { + guard !offsets.isEmpty else { return (0, 0) } + let dxs = offsets.values.map { $0.dx }.sorted() + let dys = offsets.values.map { $0.dy }.sorted() + let mid = offsets.count / 2 + return (dxs[mid], dys[mid]) + } + public init(offsets: [String: Offset]) { self.offsets = offsets } private static let defaultsKey = "userCalibration" diff --git a/Sources/TouchpadInputCore/DefaultImplementations/KeyGrid.swift b/Sources/TouchpadInputCore/DefaultImplementations/KeyGrid.swift index 8301d67..f211ed5 100644 --- a/Sources/TouchpadInputCore/DefaultImplementations/KeyGrid.swift +++ b/Sources/TouchpadInputCore/DefaultImplementations/KeyGrid.swift @@ -28,17 +28,12 @@ public struct KeyGrid: Sendable { zones.first { z in x >= z.xMin && x < z.xMax && y >= z.yMin && y < z.yMax } } - /// Returns a new grid with each zone's boundaries shifted by its calibration offset. - public func applying(calibration: UserCalibration) -> KeyGrid { - guard !calibration.offsets.isEmpty else { return self } - let shifted = zones.map { zone -> KeyZone in - let key = String(zone.character) - guard let off = calibration.offsets[key] else { return zone } - return KeyZone(character: zone.character, altCharacter: zone.altCharacter, - xMin: zone.xMin + off.dx, xMax: zone.xMax + off.dx, - yMin: zone.yMin + off.dy, yMax: zone.yMax + off.dy) - } - return KeyGrid(zones: shifted) + /// Looks up a zone after correcting for the user's global hand-position offset. + /// Adjusting the tap coordinate (rather than shifting zone boundaries) keeps the grid + /// perfectly tiled with no overlaps or gaps. + public func zone(at x: Float, y: Float, calibration: UserCalibration) -> KeyZone? { + let off = calibration.globalOffset + return zone(at: x - off.dx, y: y - off.dy) } public static let `default`: KeyGrid = { @@ -90,5 +85,10 @@ extension KeyGrid: InputZoneProvider { return String(z.character) } + public func zoneID(at x: Float, y: Float, calibration: UserCalibration) -> String? { + guard let z = zone(at: x, y: y, calibration: calibration) else { return nil } + return String(z.character) + } + public func label(forZoneID id: String) -> String? { id } } diff --git a/Sources/TouchpadInputCore/Session/TouchInputSession.swift b/Sources/TouchpadInputCore/Session/TouchInputSession.swift index ebdb921..9d3f55d 100644 --- a/Sources/TouchpadInputCore/Session/TouchInputSession.swift +++ b/Sources/TouchpadInputCore/Session/TouchInputSession.swift @@ -117,10 +117,9 @@ public final class TouchInputSession: ObservableObject, @preconcurrency TouchEve public convenience init() { let cal = UserCalibration.load() - let calibratedGrid = KeyGrid.default.applying(calibration: cal) self.init( - zoneProvider: calibratedGrid, - resolver: CharacterEmitter(grid: calibratedGrid), + zoneProvider: KeyGrid.default, + resolver: CharacterEmitter(grid: .default), modifierStrategy: CornerModifierStrategy.default, completionProvider: SpellCheckerCompletionProvider() ) @@ -131,9 +130,8 @@ public final class TouchInputSession: ObservableObject, @preconcurrency TouchEve public func applyCalibration(_ calibration: UserCalibration) { userCalibration = calibration - let calibratedGrid = KeyGrid.default.applying(calibration: calibration) - zoneProvider = calibratedGrid - resolver = CharacterEmitter(grid: calibratedGrid) + zoneProvider = KeyGrid.default + resolver = CharacterEmitter(grid: .default) calibration.save() } @@ -274,8 +272,13 @@ public final class TouchInputSession: ObservableObject, @preconcurrency TouchEve if let calSession = activeCalibrationSession { calSession.recordTap(x: fx, y: fy) } else { + // Adjust tap by global calibration offset before zone lookup. + let calOff = userCalibration.globalOffset + let calX = fx - calOff.dx + let calY = fy - calOff.dy + // Suppress "began" only for modifier zones that are NOT already held. - let modifier = modifierStrategy.modifierKind(at: fx, y: fy) + let modifier = modifierStrategy.modifierKind(at: calX, y: calY) let isInUnheldModifierZone: Bool if let mod = modifier { isInUnheldModifierZone = !heldModifiers.contains(mod) @@ -293,7 +296,7 @@ public final class TouchInputSession: ObservableObject, @preconcurrency TouchEve completions = completionProvider?.completions( forPartial: currentPartialWord, maxCount: 3 ) ?? [] - } else if let zoneID = zoneProvider.zoneID(at: fx, y: fy) { + } else if let zoneID = zoneProvider.zoneID(at: calX, y: calY) { if !emittedZoneKeys.contains(zoneID) { emittedZoneKeys.insert(zoneID) let passesSize = contact.size >= minContactSize @@ -328,9 +331,6 @@ public final class TouchInputSession: ObservableObject, @preconcurrency TouchEve in: KeyGrid.default ) userCalibration.save() - let calibratedGrid = KeyGrid.default.applying(calibration: userCalibration) - zoneProvider = calibratedGrid - resolver = CharacterEmitter(grid: calibratedGrid) } completions = completionProvider?.completions(