Skip to content

Commit 6f1fec7

Browse files
alexnskcodyclaude
andcommitted
Features: shift-swipe thirds, ctrl+A lock screen, Help menu label
- GestureAction.thirdWidth (fixture-compatible decode); recognizer snapshots Shift; left/right swipes with Shift tile to thirds, combining with Cmd (TDD: 6 new tests incl. decode compatibility) - TilingCommand left/rightThird + geometry (live integration test) and lockScreen via SACLockScreenImmediate with CGSession fallback - HotkeyController: ctrl+A -> lock screen (conflict with beginning-of-line flagged to and accepted by owner) - Menu: Help label; guide cheat sheet gains ctrl+A and shift-third rows; README screenshot regenerated. 103 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1495d2b commit 6f1fec7

18 files changed

Lines changed: 270 additions & 26 deletions

File tree

Sources/Tiler/AppDelegate.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
219219
NSLog("Tiler: gesture %@ nextDisplay=%d", action.direction.rawValue, action.nextDisplay ? 1 : 0)
220220
let command: TilingCommand
221221
switch action.direction {
222-
case .left: command = .leftHalf(nextDisplay: action.nextDisplay)
223-
case .right: command = .rightHalf(nextDisplay: action.nextDisplay)
224-
case .up: command = .maximize
222+
case .left:
223+
command = action.thirdWidth
224+
? .leftThird(nextDisplay: action.nextDisplay)
225+
: .leftHalf(nextDisplay: action.nextDisplay)
226+
case .right:
227+
command = action.thirdWidth
228+
? .rightThird(nextDisplay: action.nextDisplay)
229+
: .rightHalf(nextDisplay: action.nextDisplay)
230+
case .up:
231+
command = .maximize
225232
}
226233
execute(command)
227234
}
@@ -254,7 +261,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
254261
item.button?.imagePosition = .imageLeft
255262

256263
let menu = NSMenu()
257-
menu.addItem(makeItem("Tiler", #selector(showGuideAction)))
264+
menu.addItem(makeItem("Help", #selector(showGuideAction)))
258265
let settingsItem = makeItem("Settings", #selector(showSettingsAction))
259266
settingsItem.keyEquivalent = ","
260267
menu.addItem(settingsItem)

Sources/Tiler/GuideWindow.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ enum GuideContent {
4242
let id = UUID()
4343
let direction: GestureDirection
4444
let cmd: Bool
45+
var shift: Bool = false
4546
let action: String
4647
}
4748

@@ -74,18 +75,21 @@ enum GuideContent {
7475
HotkeyRow(keys: ["", "", ""], action: "Restore the window's previous frame"),
7576
HotkeyRow(keys: ["", "", "", ""], action: "Left half on the next display"),
7677
HotkeyRow(keys: ["", "", "", ""], action: "Right half on the next display"),
78+
HotkeyRow(keys: ["", "A"], action: "Lock the screen"),
7779
]
7880

7981
static let gestures: [GestureRow] = [
8082
GestureRow(direction: .left, cmd: false, action: "Left half"),
8183
GestureRow(direction: .right, cmd: false, action: "Right half"),
8284
GestureRow(direction: .up, cmd: false, action: "Maximize"),
85+
GestureRow(direction: .left, cmd: false, shift: true, action: "Left third"),
86+
GestureRow(direction: .right, cmd: false, shift: true, action: "Right third"),
8387
GestureRow(direction: .left, cmd: true, action: "Left half on the next display"),
8488
GestureRow(direction: .right, cmd: true, action: "Right half on the next display"),
8589
]
8690

8791
static let gestureFootnote =
88-
"Exactly three fingers, one confident stroke. Two- and four-finger movements and swipe-down do nothing — by design."
92+
"Exactly three fingers, one confident stroke. ⇧ and ⌘ combine (⇧⌘ = third on the next display). Two- and four-finger movements and swipe-down do nothing — by design."
8993

9094
/// Honesty marker: the only configuration this build was actually verified on.
9195
static let verifiedOn = "verified on macOS 26.5 only"
@@ -146,7 +150,9 @@ struct GuideView: View {
146150
ForEach(GuideContent.gestures) { row in
147151
GridRow {
148152
HStack(spacing: 6) {
149-
if row.cmd { keycaps([""]) }
153+
if row.cmd || row.shift {
154+
keycaps((row.cmd ? [""] : []) + (row.shift ? [""] : []))
155+
}
150156
HoverDemo(direction: row.direction)
151157
}
152158
.gridColumnAlignment(.trailing)

Sources/TilerCore/GestureRecognizer.swift

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,10 @@ public final class GestureRecognizer {
6060
pendingTunables = nil
6161
}
6262

63-
/// Process one frame. `cmdHeld` is the Cmd-modifier snapshot at frame time.
63+
/// Process one frame. `cmdHeld`/`shiftHeld` are modifier snapshots at frame time.
6464
/// Returns an action only at the exact moment a gesture is confirmed.
65-
public func process(_ frame: TouchFrame, cmdHeld: Bool = false) -> GestureAction? {
65+
public func process(_ frame: TouchFrame, cmdHeld: Bool = false,
66+
shiftHeld: Bool = false) -> GestureAction? {
6667
let t = frame.timestamp
6768

6869
// Stream silence implies zero contacts for the whole gap: if the gap covers
@@ -96,7 +97,8 @@ public final class GestureRecognizer {
9697
processIdle(active: active, palmPresent: palmPresent, at: t)
9798
return nil
9899
case .tracking:
99-
return processTracking(active: active, palmPresent: palmPresent, at: t, cmdHeld: cmdHeld)
100+
return processTracking(active: active, palmPresent: palmPresent, at: t,
101+
cmdHeld: cmdHeld, shiftHeld: shiftHeld)
100102
}
101103
}
102104

@@ -153,7 +155,7 @@ public final class GestureRecognizer {
153155
}
154156

155157
private func processTracking(active: [Contact], palmPresent: Bool,
156-
at t: Double, cmdHeld: Bool) -> GestureAction? {
158+
at t: Double, cmdHeld: Bool, shiftHeld: Bool) -> GestureAction? {
157159
let keys = Set(active.map { FingerKey(device: $0.deviceID, finger: $0.fingerID) })
158160
guard !palmPresent, active.count == 3, keys == trackedFingers else {
159161
lockoutNow()
@@ -215,11 +217,12 @@ public final class GestureRecognizer {
215217
case .down:
216218
return nil // deliberately not implemented
217219
case .up:
220+
// ⇧ is meaningless for maximize and is ignored (spec).
218221
return cmdHeld ? nil : GestureAction(direction: .up, nextDisplay: false)
219222
case .left:
220-
return GestureAction(direction: .left, nextDisplay: cmdHeld)
223+
return GestureAction(direction: .left, nextDisplay: cmdHeld, thirdWidth: shiftHeld)
221224
case .right:
222-
return GestureAction(direction: .right, nextDisplay: cmdHeld)
225+
return GestureAction(direction: .right, nextDisplay: cmdHeld, thirdWidth: shiftHeld)
223226
}
224227
}
225228

Sources/TilerCore/Models.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,24 @@ public enum GestureDirection: String, Codable, Sendable, Equatable {
5050
}
5151

5252
/// The single output of a confirmed gesture. Swipe-down and Cmd+up produce no action.
53+
/// `thirdWidth` = ⇧ held at confirmation: left/right tile to thirds instead of halves.
5354
public struct GestureAction: Codable, Sendable, Equatable {
5455
public var direction: GestureDirection
5556
public var nextDisplay: Bool
57+
public var thirdWidth: Bool
5658

57-
public init(direction: GestureDirection, nextDisplay: Bool) {
59+
public init(direction: GestureDirection, nextDisplay: Bool, thirdWidth: Bool = false) {
5860
self.direction = direction
5961
self.nextDisplay = nextDisplay
62+
self.thirdWidth = thirdWidth
63+
}
64+
65+
// Custom decode: golden fixtures recorded before thirdWidth existed must keep
66+
// decoding (missing key defaults to false).
67+
public init(from decoder: Decoder) throws {
68+
let container = try decoder.container(keyedBy: CodingKeys.self)
69+
direction = try container.decode(GestureDirection.self, forKey: .direction)
70+
nextDisplay = try container.decode(Bool.self, forKey: .nextDisplay)
71+
thirdWidth = try container.decodeIfPresent(Bool.self, forKey: .thirdWidth) ?? false
6072
}
6173
}

Sources/TilerSystem/GestureEngine.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ public final class GestureEngine: @unchecked Sendable {
5252
tap?(frame)
5353

5454
recorder?.append(frame)
55-
let cmdHeld = CGEventSource.flagsState(.hidSystemState).contains(.maskCommand)
56-
if let action = recognizer.process(frame, cmdHeld: cmdHeld) {
55+
let flags = CGEventSource.flagsState(.hidSystemState)
56+
if let action = recognizer.process(frame,
57+
cmdHeld: flags.contains(.maskCommand),
58+
shiftHeld: flags.contains(.maskShift)) {
5759
onAction(action)
5860
}
5961
}

Sources/TilerSystem/HotkeyController.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,23 @@ public final class HotkeyController {
1111
public var handler: ((TilingCommand) -> Void)?
1212

1313
private enum Key: UInt32, CaseIterable {
14-
case left = 1, right, up, down, cmdLeft, cmdRight
14+
case left = 1, right, up, down, cmdLeft, cmdRight, lockScreen
1515

1616
var keyCode: UInt32 {
1717
switch self {
1818
case .left, .cmdLeft: UInt32(kVK_LeftArrow)
1919
case .right, .cmdRight: UInt32(kVK_RightArrow)
2020
case .up: UInt32(kVK_UpArrow)
2121
case .down: UInt32(kVK_DownArrow)
22+
case .lockScreen: UInt32(kVK_ANSI_A)
2223
}
2324
}
2425

2526
var modifiers: UInt32 {
2627
switch self {
2728
case .left, .right, .up, .down: UInt32(controlKey | shiftKey)
2829
case .cmdLeft, .cmdRight: UInt32(cmdKey | controlKey | shiftKey)
30+
case .lockScreen: UInt32(controlKey)
2931
}
3032
}
3133
}
@@ -108,6 +110,8 @@ public final class HotkeyController {
108110
emit(.leftHalf(nextDisplay: true))
109111
case .cmdRight:
110112
emit(.rightHalf(nextDisplay: true))
113+
case .lockScreen:
114+
emit(.lockScreen)
111115
case .up:
112116
let now = ProcessInfo.processInfo.systemUptime
113117
if let decision = resolver.registerPress(at: now) {

Sources/TilerSystem/WindowActions.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import TilerCore
66
public enum TilingCommand: Equatable, Sendable {
77
case leftHalf(nextDisplay: Bool)
88
case rightHalf(nextDisplay: Bool)
9+
case leftThird(nextDisplay: Bool)
10+
case rightThird(nextDisplay: Bool)
911
case maximize
1012
case centerThird
1113
case restore
14+
case lockScreen
1215
}
1316

1417
/// AX window manipulation layer (specs/window-actions/spec.md).
@@ -34,6 +37,10 @@ public final class WindowActions {
3437
/// can feed PermissionMonitor.noteActionFailed().
3538
@discardableResult
3639
public func perform(_ command: TilingCommand) -> Bool {
40+
if command == .lockScreen {
41+
lockScreen()
42+
return true
43+
}
3744
guard let (app, window) = frontmostFocusedWindow() else {
3845
NSLog("Tiler: no focused window for \(command) — ignoring")
3946
return false
@@ -45,6 +52,10 @@ public final class WindowActions {
4552
/// the window by pid instead of relying on frontmost focus).
4653
@discardableResult
4754
public func perform(_ command: TilingCommand, app: AXUIElement, window: AXUIElement) -> Bool {
55+
if command == .lockScreen {
56+
lockScreen()
57+
return true
58+
}
4859
trimStoresIfNeeded()
4960
guard let currentAX = frame(of: window) else {
5061
NSLog("Tiler: cannot read window frame — ignoring \(command)")
@@ -93,6 +104,12 @@ public final class WindowActions {
93104
width: vf.width / 2, height: vf.height)
94105
}
95106

107+
func third(of screen: NSScreen, left: Bool) -> CGRect {
108+
let vf = screen.visibleFrame
109+
return CGRect(x: left ? vf.minX : vf.minX + vf.width * 2 / 3, y: vf.minY,
110+
width: vf.width / 3, height: vf.height)
111+
}
112+
96113
switch command {
97114
case .maximize:
98115
return current.visibleFrame
@@ -103,7 +120,11 @@ public final class WindowActions {
103120
return half(of: nextDisplay ? next(after: current, in: screens) : current, left: true)
104121
case .rightHalf(let nextDisplay):
105122
return half(of: nextDisplay ? next(after: current, in: screens) : current, left: false)
106-
case .restore:
123+
case .leftThird(let nextDisplay):
124+
return third(of: nextDisplay ? next(after: current, in: screens) : current, left: true)
125+
case .rightThird(let nextDisplay):
126+
return third(of: nextDisplay ? next(after: current, in: screens) : current, left: false)
127+
case .restore, .lockScreen:
107128
return nil
108129
}
109130
}
@@ -234,6 +255,28 @@ public final class WindowActions {
234255
AXUIElementSetAttributeValue(element, attribute as CFString, value ? kCFBooleanTrue : kCFBooleanFalse)
235256
}
236257

258+
/// System screen lock (⌃A hotkey): private login framework, same effect as
259+
/// the system Lock Screen; CGSession suspend as fallback. No AX involved.
260+
private func lockScreen() {
261+
typealias LockFn = @convention(c) () -> Int32
262+
if let handle = dlopen("/System/Library/PrivateFrameworks/login.framework/login", RTLD_NOW),
263+
let sym = dlsym(handle, "SACLockScreenImmediate") {
264+
_ = unsafeBitCast(sym, to: LockFn.self)()
265+
NSLog("Tiler: screen locked (SACLockScreenImmediate)")
266+
return
267+
}
268+
let task = Process()
269+
task.executableURL = URL(fileURLWithPath:
270+
"/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession")
271+
task.arguments = ["-suspend"]
272+
do {
273+
try task.run()
274+
NSLog("Tiler: screen locked (CGSession fallback)")
275+
} catch {
276+
NSLog("Tiler: lock screen failed: \(error)")
277+
}
278+
}
279+
237280
/// Drop entries for windows that no longer answer AX queries (closed windows).
238281
private func trimStoresIfNeeded() {
239282
guard originalFrames.count > 64 else { return }

Tests/TilerCoreTests/GestureSim.swift

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,29 @@ struct Sim {
1414
t = startTime
1515
}
1616

17-
mutating func feed(_ contacts: [Contact], cmd: Bool = false) {
18-
if let a = recognizer.process(TouchFrame(timestamp: t, contacts: contacts), cmdHeld: cmd) {
17+
mutating func feed(_ contacts: [Contact], cmd: Bool = false, shift: Bool = false) {
18+
if let a = recognizer.process(TouchFrame(timestamp: t, contacts: contacts),
19+
cmdHeld: cmd, shiftHeld: shift) {
1920
actions.append(a)
2021
}
2122
t += dt
2223
}
2324

24-
mutating func hold(_ contacts: [Contact], frames: Int, cmd: Bool = false) {
25-
for _ in 0..<frames { feed(contacts, cmd: cmd) }
25+
mutating func hold(_ contacts: [Contact], frames: Int, cmd: Bool = false, shift: Bool = false) {
26+
for _ in 0..<frames { feed(contacts, cmd: cmd, shift: shift) }
2627
}
2728

2829
/// Move `n` fingers' centroid from `start` by `delta` over `frames` frames.
2930
/// `extra` contacts (palm, stale…) are appended to every frame.
3031
mutating func move(_ n: Int, from start: (x: Double, y: Double),
3132
by delta: (dx: Double, dy: Double), frames: Int,
32-
cmd: Bool = false, size: Double = 0.5,
33+
cmd: Bool = false, shift: Bool = false, size: Double = 0.5,
3334
idBase: Int32 = 1, extra: [Contact] = []) {
3435
for i in 1...frames {
3536
let f = Double(i) / Double(frames)
3637
let contacts = fingers(n, at: start.x + delta.dx * f, start.y + delta.dy * f,
3738
size: size, idBase: idBase)
38-
feed(contacts + extra, cmd: cmd)
39+
feed(contacts + extra, cmd: cmd, shift: shift)
3940
}
4041
}
4142

@@ -47,9 +48,10 @@ struct Sim {
4748

4849
/// Canonical valid 3-finger swipe: arm stationary, move, lift.
4950
/// delta of ±0.15 with 18 move frames comfortably exceeds all confirm thresholds.
50-
mutating func performValidSwipe(_ delta: (dx: Double, dy: Double), cmd: Bool = false) {
51-
hold(fingers(3, at: 0.5, 0.5), frames: 5, cmd: cmd)
52-
move(3, from: (0.5, 0.5), by: delta, frames: 18, cmd: cmd)
51+
mutating func performValidSwipe(_ delta: (dx: Double, dy: Double),
52+
cmd: Bool = false, shift: Bool = false) {
53+
hold(fingers(3, at: 0.5, 0.5), frames: 5, cmd: cmd, shift: shift)
54+
move(3, from: (0.5, 0.5), by: delta, frames: 18, cmd: cmd, shift: shift)
5355
liftAll()
5456
}
5557
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
import Testing
3+
@testable import TilerCore
4+
5+
// Shift modifier (add-thirds-lock-help): ⇧ at confirmation turns left/right into
6+
// third-width actions; combines with ⌘; ignored for up.
7+
@Suite("Shift modifier") struct ShiftModifierTests {
8+
9+
@Test func shiftLeftSwipeEmitsThirdWidth() {
10+
var sim = Sim()
11+
sim.performValidSwipe((dx: -0.15, dy: 0), shift: true)
12+
#expect(sim.actions == [GestureAction(direction: .left, nextDisplay: false, thirdWidth: true)])
13+
}
14+
15+
@Test func shiftRightSwipeEmitsThirdWidth() {
16+
var sim = Sim()
17+
sim.performValidSwipe((dx: 0.15, dy: 0), shift: true)
18+
#expect(sim.actions == [GestureAction(direction: .right, nextDisplay: false, thirdWidth: true)])
19+
}
20+
21+
@Test func shiftAndCmdCombine() {
22+
var sim = Sim()
23+
sim.performValidSwipe((dx: -0.15, dy: 0), cmd: true, shift: true)
24+
#expect(sim.actions == [GestureAction(direction: .left, nextDisplay: true, thirdWidth: true)])
25+
}
26+
27+
@Test func plainSwipesStayHalfWidth() {
28+
var sim = Sim()
29+
sim.performValidSwipe((dx: 0.15, dy: 0))
30+
#expect(sim.actions == [GestureAction(direction: .right, nextDisplay: false, thirdWidth: false)])
31+
}
32+
33+
@Test func shiftWithUpIsIgnoredPlainMaximize() {
34+
var sim = Sim()
35+
sim.performValidSwipe((dx: 0, dy: 0.15), shift: true)
36+
#expect(sim.actions == [GestureAction(direction: .up, nextDisplay: false, thirdWidth: false)])
37+
}
38+
39+
// Old golden fixtures predate thirdWidth: decoding must default it to false.
40+
@Test func decodingWithoutThirdWidthDefaultsFalse() throws {
41+
let json = Data(#"{"direction":"left","nextDisplay":false}"#.utf8)
42+
let action = try JSONDecoder().decode(GestureAction.self, from: json)
43+
#expect(action == GestureAction(direction: .left, nextDisplay: false, thirdWidth: false))
44+
}
45+
}

0 commit comments

Comments
 (0)