Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
name: swift test
runs-on: macos-15

steps:
- uses: actions/checkout@v4

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

- name: Build
run: swift build

- name: Test
run: swift test
14 changes: 14 additions & 0 deletions Sources/TouchpadInputApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
<string>TouchpadInput</string>
<key>CFBundleIdentifier</key>
<string>com.touchpad-input.app</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>TouchpadInputApp</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAccessibilityUsageDescription</key>
<string>TouchpadInput needs Accessibility access to inject keystrokes into the frontmost app when system injection mode is enabled.</string>
<key>NSHumanReadableCopyright</key>
Expand Down
22 changes: 22 additions & 0 deletions Sources/TouchpadInputApp/TouchpadInputApp.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
No sandbox: the app uses MultitouchSupport.framework (private, via dlopen)
and CGEvent injection, both incompatible with the App Sandbox.
Distribution is via notarized Developer ID DMG, not Mac App Store.
-->
<key>com.apple.security.app-sandbox</key>
<false/>

<!--
Hardened runtime exception: allows dlopen of private system frameworks
(MultitouchSupport.framework) that are not in the standard load path.
Apple-signed, so library validation would normally pass — included as
a precaution for edge cases during notarization.
-->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
94 changes: 60 additions & 34 deletions Sources/TouchpadInputApp/Views/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,46 +1,57 @@
import SwiftUI
import TouchpadInputCore

private enum AppMode: String, CaseIterable {
case keyboard = "Keyboard"
case drawing = "Drawing"
}

struct ContentView: View {
@StateObject private var session = TouchInputSession()
@StateObject private var drawSession = DrawingSession()
@StateObject private var calibSession = CalibrationSession()
@State private var appMode: AppMode = .keyboard
@State private var showSettings = false
@State private var showCalibration = false
@AppStorage("hasCompletedCalibration") private var hasCompletedCalibration = false

var body: some View {
VStack(spacing: 0) {
header
if showSettings {
Divider()
SettingsPanel(session: session, onRecalibrate: {
calibSession.reset()
showCalibration = true
})
}
if !session.completions.isEmpty {
if appMode == .keyboard {
if showSettings {
Divider()
SettingsPanel(session: session, onRecalibrate: {
calibSession.reset()
showCalibration = true
})
}
if !session.completions.isEmpty {
Divider()
AutocompleteBar(completions: session.completions) { word in
session.acceptCompletion(word)
}
}
Divider()
AutocompleteBar(completions: session.completions) { word in
session.acceptCompletion(word)
HStack(spacing: 0) {
TrackpadSurface(
fingers: session.liveFingers,
isActive: session.isActive,
zones: KeyGrid.default.applying(calibration: session.userCalibration).zones,
activeModifiers: session.activeModifiers
)
.padding(16)
Divider()
FingerTablePanel(fingers: session.liveFingers)
.frame(width: 340)
}
}
Divider()
HStack(spacing: 0) {
TrackpadSurface(
fingers: session.liveFingers,
isActive: session.isActive,
zones: KeyGrid.default.applying(calibration: session.userCalibration).zones,
activeModifiers: session.activeModifiers
)
.padding(16)
Divider()
FingerTablePanel(fingers: session.liveFingers)
.frame(width: 340)
OutputBufferPanel(text: session.outputBuffer, activeModifiers: session.activeModifiers)
Divider()
EventLogPanel(entries: session.eventLog)
} else {
DrawingCanvasView(session: drawSession)
}
Divider()
OutputBufferPanel(text: session.outputBuffer, activeModifiers: session.activeModifiers)
Divider()
EventLogPanel(entries: session.eventLog)
}
.onAppear {
MultitouchCapture.shared.setupDoubleControlToggle(for: session)
Expand Down Expand Up @@ -69,22 +80,37 @@ struct ContentView: View {
Text("Touchpad Diagnostics")
.font(.system(size: 18, weight: .semibold))
modePill
Divider().frame(height: 18)
Picker("Mode", selection: $appMode) {
ForEach(AppMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.frame(width: 160)
.onChange(of: appMode) { mode in
let activeSession: any TouchEventReceiver = mode == .keyboard ? session : drawSession
MultitouchCapture.shared.session = activeSession
}
Spacer()
Button(action: { showSettings.toggle() }) {
Image(systemName: "slider.horizontal.3")
.foregroundColor(showSettings ? .accentColor : .secondary)
if appMode == .keyboard {
Button(action: { showSettings.toggle() }) {
Image(systemName: "slider.horizontal.3")
.foregroundColor(showSettings ? .accentColor : .secondary)
}
.buttonStyle(.borderless)
.help("Toggle stability settings")
Button("Clear") { session.clearAll() }
}
.buttonStyle(.borderless)
.help("Toggle stability settings")
Button("Clear") { session.clearAll() }
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}

private var modePill: some View {
Group {
if session.isActive {
let isActive = appMode == .keyboard ? session.isActive : drawSession.isActive
return Group {
if isActive {
Text("● CAPTURING — double ctrl to stop")
.foregroundColor(.white)
.background(Color.green)
Expand Down
184 changes: 184 additions & 0 deletions Sources/TouchpadInputApp/Views/DrawingCanvasView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Sources/TouchpadInputApp/Views/DrawingCanvasView.swift
import SwiftUI
import TouchpadInputCore
import AppKit

struct DrawingCanvasView: View {
@ObservedObject var session: DrawingSession

@State private var selectedColor: Color = .black
@State private var lineWidth: CGFloat = 2.5
private let widthOptions: [CGFloat] = [1.5, 2.5, 4.0, 7.0]

var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
canvas
}
}

// MARK: - Toolbar

private var toolbar: some View {
HStack(spacing: 12) {
Text("Drawing")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.secondary)

Divider().frame(height: 18)

// Color swatches
ForEach([Color.black, .blue, .red, .green, .orange], id: \.self) { color in
colorSwatch(color)
}

Divider().frame(height: 18)

// Line width picker
Picker("Width", selection: $lineWidth) {
ForEach(widthOptions, id: \.self) { w in
Text(widthLabel(w)).tag(w)
}
}
.pickerStyle(.segmented)
.frame(width: 160)
.onChange(of: lineWidth) { w in
session.currentLineWidth = w
}

Spacer()

Button("Undo") { session.undo() }
.disabled(session.strokes.isEmpty)
.keyboardShortcut("z", modifiers: .command)

Button("Clear") { session.clear() }
.disabled(session.strokes.isEmpty)

Button("Export PNG") { exportPNG() }
.disabled(session.strokes.isEmpty)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}

private func colorSwatch(_ color: Color) -> some View {
Button(action: {
selectedColor = color
session.currentColor = NSColor(color).cgColor
}) {
Circle()
.fill(color)
.frame(width: 18, height: 18)
.overlay(
Circle()
.stroke(selectedColor == color ? Color.primary : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.borderless)
}

private func widthLabel(_ w: CGFloat) -> String {
switch w {
case 1.5: return "Fine"
case 2.5: return "Med"
case 4.0: return "Thick"
case 7.0: return "Bold"
default: return String(format: "%.0f", w)
}
}

// MARK: - Canvas

private var canvas: some View {
GeometryReader { geo in
ZStack {
// Background
RoundedRectangle(cornerRadius: 0)
.fill(Color.white)
RoundedRectangle(cornerRadius: 0)
.stroke(
session.isActive ? Color.green.opacity(0.4) : Color.secondary.opacity(0.2),
lineWidth: session.isActive ? 1.5 : 1
)

// Strokes canvas
Canvas { ctx, size in
for stroke in session.strokes {
drawStroke(stroke, in: ctx, size: size)
}
}

// Live finger dots
ForEach(session.liveFingers) { finger in
Circle()
.fill(selectedColor.opacity(0.4))
.frame(width: 14, height: 14)
.position(
x: finger.x * geo.size.width,
y: (1 - finger.y) * geo.size.height
)
}

if !session.isActive {
Text("Double-tap ctrl to start drawing")
.font(.system(size: 14))
.foregroundColor(.secondary)
.padding(8)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
}

// Stroke y is hardware-space (0=bottom, 1=top); Canvas y is screen-space (0=top).
private func drawStroke(_ stroke: DrawingStroke, in ctx: GraphicsContext, size: CGSize) {
guard stroke.points.count >= 2 else { return }

let pts = stroke.points.map {
CGPoint(x: $0.x * size.width, y: (1 - $0.y) * size.height)
}

var path = Path()
path.move(to: pts[0])
if pts.count == 2 {
path.addLine(to: pts[1])
} else {
for i in 1..<pts.count - 1 {
let mid = CGPoint(x: (pts[i].x + pts[i + 1].x) / 2,
y: (pts[i].y + pts[i + 1].y) / 2)
path.addQuadCurve(to: mid, control: pts[i])
}
path.addLine(to: pts[pts.count - 1])
}

let color = Color(cgColor: stroke.color)
ctx.stroke(path, with: .color(color), style: StrokeStyle(
lineWidth: stroke.lineWidth,
lineCap: .round,
lineJoin: .round
))
}

// MARK: - Export

private func exportPNG() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
panel.nameFieldStringValue = "signature.png"
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
let size = CGSize(width: 1200, height: 800)
let image = session.renderToImage(size: size)
guard
let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let pngData = bitmap.representation(using: .png, properties: [:])
else { return }
try? pngData.write(to: url)
}
}
}
Loading
Loading