diff --git a/Apps/PointerLocker/Onboarding/WelcomeStep.swift b/Apps/PointerLocker/Onboarding/WelcomeStep.swift index f69bc2c..791ef9d 100644 --- a/Apps/PointerLocker/Onboarding/WelcomeStep.swift +++ b/Apps/PointerLocker/Onboarding/WelcomeStep.swift @@ -1,8 +1,20 @@ import SwiftUI +/// When Welcome's two cards stack instead of sitting side by side: only for +/// accessibility text sizes and narrow windows. Measuring whether the text +/// fits on one line (ViewThatFits) stacked them on a full iPad as soon as a +/// summary grew longer than half the width. +enum WelcomeLayout { + static func stacksCards(dynamicTypeSize: DynamicTypeSize, horizontalSizeClass: UserInterfaceSizeClass?) -> Bool { + dynamicTypeSize.isAccessibilitySize || horizontalSizeClass == .compact + } +} + struct WelcomeStep: View { let services: [ServiceProfile] let onContinue: () -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { // Scrolls when the text doesn't fit (a small window at a large text @@ -23,12 +35,13 @@ struct WelcomeStep: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 520) - // Play and Create side by side, stacked when the text is large. - // Cards as tall as their text, and as tall as each other. - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 16) { worldCards } - VStack(spacing: 16) { worldCards } - } + // Create and Play side by side, stacked when the text is large + // or the window narrow. Cards as tall as their text, and as + // tall as each other. + let layout = WelcomeLayout.stacksCards(dynamicTypeSize: dynamicTypeSize, horizontalSizeClass: horizontalSizeClass) + ? AnyLayout(VStackLayout(spacing: 16)) + : AnyLayout(HStackLayout(alignment: .top, spacing: 16)) + layout { worldCards } .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 640) } diff --git a/Apps/PointerLocker/Services/FigmaProfile.swift b/Apps/PointerLocker/Services/FigmaProfile.swift index 4836cf9..af3755c 100644 --- a/Apps/PointerLocker/Services/FigmaProfile.swift +++ b/Apps/PointerLocker/Services/FigmaProfile.swift @@ -22,11 +22,17 @@ extension ServiceProfile { colors: (UIColor(red: 0.55, green: 0.36, blue: 0.96, alpha: 1), UIColor(red: 0.13, green: 0.07, blue: 0.27, alpha: 1)) ), setupTips: [ + SetupTip( + id: "navigate", + symbol: "arrow.up.and.down.and.arrow.left.and.right", + title: "Use Figma as on a computer", + detail: "Space + drag pans, ⌘ + scroll zooms and Shift + scroll moves sideways. Right-click and keyboard shortcuts work too." + ), SetupTip( id: "scrub", symbol: "arrow.left.and.right", title: "Drag a number's label to change it", - detail: "The pointer locks while you drag, so the value keeps changing past the edge of the screen. Space + drag moves the canvas." + detail: "The pointer locks while you drag, so the value keeps changing past the edge of the screen." ), SetupTip( id: "session", @@ -36,7 +42,7 @@ extension ServiceProfile { ), ], wording: ServiceWording( - mouseNeeded: "Figma needs a mouse or trackpad to drag and scrub values. Touch alone won't work.", + mouseNeeded: "Figma needs a mouse or trackpad to pan, right-click and drag values. Touch alone won't work.", mouseTest: "Move it and watch the check mark. If it reacts, Figma can see the mouse too.", keyboardReady: "Ready for shortcuts.", lockReady: "The pointer can lock while you drag.", diff --git a/Apps/PointerLocker/Services/ServiceCategory.swift b/Apps/PointerLocker/Services/ServiceCategory.swift index a602eaf..b20d7e9 100644 --- a/Apps/PointerLocker/Services/ServiceCategory.swift +++ b/Apps/PointerLocker/Services/ServiceCategory.swift @@ -1,7 +1,7 @@ /// The two worlds Mouselook serves, shown side by side on Welcome and as -/// headings in the chooser: cloud games, and design and 3D tools. +/// headings in the chooser: design and 3D tools first, then cloud games. enum ServiceCategory: CaseIterable { - case play, create + case create, play var title: String { switch self { @@ -14,7 +14,7 @@ enum ServiceCategory: CaseIterable { var summary: String { switch self { case .play: "Aim and look around in cloud games." - case .create: "Drag, scrub and pan in design tools." + case .create: "Pan, zoom and use shortcuts in design tools." } } @@ -42,7 +42,7 @@ enum ServiceCategory: CaseIterable { services.filter { $0.category == self }.map(\.name).joined(separator: ", ") } - /// Services grouped by world, Play first; worlds without services are left out. + /// Services grouped by world, Create first; worlds without services are left out. static func groups(of services: [ServiceProfile]) -> [(category: ServiceCategory, services: [ServiceProfile])] { allCases.compactMap { category in let members = services.filter { $0.category == category } diff --git a/Apps/PointerLocker/Services/ServiceProfile.swift b/Apps/PointerLocker/Services/ServiceProfile.swift index d8a6a70..4975eab 100644 --- a/Apps/PointerLocker/Services/ServiceProfile.swift +++ b/Apps/PointerLocker/Services/ServiceProfile.swift @@ -68,7 +68,7 @@ struct ServiceProfile { static let all: [ServiceProfile] = [.geforceNow, .figma, .generic] /// What onboarding and Settings offer. `generic` stays internal. - static let selectable: [ServiceProfile] = [.geforceNow, .figma] + static let selectable: [ServiceProfile] = [.figma, .geforceNow] static func profile(id: String) -> ServiceProfile? { all.first { $0.id == id } diff --git a/README.md b/README.md index a6cd3db..0bea7fd 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ An iPad browser, built on the system WKWebView, that gives web pages a working **Pointer Lock API**, so a mouse works the way it does on a computer. Safari and WebKit on iPadOS don't implement pointer lock. Two kinds of service use it: -- **Create:** design tools such as Figma, for dragging number fields past the - screen edge. Figma sees a desktop browser, so its keyboard shortcuts work - too: Space + drag pans the canvas, ⌘ + scroll zooms. More browser-based - design tools will follow as they're tested. +- **Create:** design tools such as Figma, used the way you use them on a + computer. Figma sees a desktop browser, and Mouselook passes along the + modifier keys iPadOS drops from scrolling, so Space + drag pans, ⌘ + scroll + zooms, Shift + scroll moves sideways, right-click opens context menus, and + keyboard shortcuts and modifier clicks (Shift + click, ⌥ + drag) work. + Pointer lock lets you drag number fields past the screen edge. More + browser-based design tools will follow as they're tested. - **Play:** cloud games such as GeForce NOW (`play.geforcenow.com`), for mouse-look: aiming and looking around. Other browser-based services that use pointer lock, such as Xbox Cloud Gaming, should work through ⋯ ▸ Open diff --git a/docs/app-store/review-notes.md b/docs/app-store/review-notes.md index 610b6d8..c0bc87b 100644 --- a/docs/app-store/review-notes.md +++ b/docs/app-store/review-notes.md @@ -20,21 +20,26 @@ Apple ID emails from those messages as external testers. **Beta App Description** -Mouselook lets web apps on iPad use the mouse the way they do on a desktop: -dragging number fields in Figma keeps going past the screen edge, and games in -GeForce NOW can capture the mouse for camera control. Needs an iPad with a +Mouselook lets you use Figma on iPad the way you do on a computer: Space + drag +pans, ⌘ + scroll zooms, right-click and keyboard shortcuts work, and dragging a +number field keeps going past the screen edge. Games in GeForce NOW can capture +the mouse for camera control. Needs an iPad with a mouse or trackpad and a keyboard. **What to Test** 1. Pick Figma in onboarding, sign in, open one of your files. -2. Drag a number field label (X, Y, W, rotation) past the screen edge. The +2. Hold Space and drag to pan, hold ⌘ and scroll to zoom, and hold Shift and + scroll to move sideways. +3. Use the keyboard and mouse as on a computer: V, R, T, ⌘D, ⌘G, right-click + for the context menu, Shift + click to select several layers, ⌥ + drag to + duplicate. +4. Drag a number field label (X, Y, W, rotation) past the screen edge. The value keeps changing instead of stopping at the edge. -3. Hold ⌘ and scroll to zoom. Switch to GeForce NOW from the ⋯ menu and back; - the Figma file reopens. -4. In GeForce NOW, start a game and click into it. The mouse turns the camera +5. Switch to GeForce NOW from the ⋯ menu and back; the Figma file reopens. +6. In GeForce NOW, start a game and click into it. The mouse turns the camera until you hold Esc for a second. -5. If something doesn't work: ⋯ ▸ Settings ▸ Copy diagnostics, and send it +7. If something doesn't work: ⋯ ▸ Settings ▸ Copy diagnostics, and send it with your feedback. **Feedback email:** mouselook@icebear.sk diff --git a/project.yml b/project.yml index 95c5a22..7365787 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: # Change to your own team to build elsewhere. DEVELOPMENT_TEAM: AR9ZHABG8Y # IceBear s.r.o. MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + CURRENT_PROJECT_VERSION: "3" targets: PointerLocker: diff --git a/tests/PointerLockerTests/ServiceCategoryTests.swift b/tests/PointerLockerTests/ServiceCategoryTests.swift index 7120060..3bf4989 100644 --- a/tests/PointerLockerTests/ServiceCategoryTests.swift +++ b/tests/PointerLockerTests/ServiceCategoryTests.swift @@ -7,10 +7,11 @@ final class ServiceCategoryTests: XCTestCase { XCTAssertEqual(ServiceProfile.figma.category, .create) } - func testTheChooserGroupsPlayBeforeCreateAndHidesEmptyGroups() { - let groups = ServiceCategory.groups(of: [.figma, .geforceNow]) - XCTAssertEqual(groups.map(\.category), [.play, .create]) - XCTAssertEqual(groups.map { $0.services.map(\.id) }, [["geforcenow"], ["figma"]]) + func testCreateLeadsOnWelcomeAndInTheChooser() { + XCTAssertEqual(ServiceCategory.allCases, [.create, .play]) + let groups = ServiceCategory.groups(of: [.geforceNow, .figma]) + XCTAssertEqual(groups.map(\.category), [.create, .play]) + XCTAssertEqual(groups.map { $0.services.map(\.id) }, [["figma"], ["geforcenow"]]) XCTAssertEqual(ServiceCategory.groups(of: [.figma]).map(\.category), [.create], "no empty Play heading") } diff --git a/tests/PointerLockerTests/ServiceMenuTests.swift b/tests/PointerLockerTests/ServiceMenuTests.swift index 4d93d0f..10dac7e 100644 --- a/tests/PointerLockerTests/ServiceMenuTests.swift +++ b/tests/PointerLockerTests/ServiceMenuTests.swift @@ -45,10 +45,10 @@ final class ServiceMenuTests: XCTestCase { func testTheMenuListsTheServicesAndMarksTheCurrentOne() { let entries = ServiceSwitch.menuEntries(current: "figma") - XCTAssertEqual(entries.map(\.name), ["GeForce NOW", "Figma"]) - XCTAssertEqual(entries.map(\.subtitle), ["Play", "Create"]) - XCTAssertEqual(entries.map(\.isCurrent), [false, true]) - XCTAssertEqual(entries.map(\.symbol), [ServiceProfile.geforceNow.artwork.symbol, ServiceProfile.figma.artwork.symbol]) + XCTAssertEqual(entries.map(\.name), ["Figma", "GeForce NOW"]) + XCTAssertEqual(entries.map(\.subtitle), ["Create", "Play"]) + XCTAssertEqual(entries.map(\.isCurrent), [true, false]) + XCTAssertEqual(entries.map(\.symbol), [ServiceProfile.figma.artwork.symbol, ServiceProfile.geforceNow.artwork.symbol]) } func testNoServicesSectionWithOnlyOneService() { diff --git a/tests/PointerLockerTests/ServicePresentationTests.swift b/tests/PointerLockerTests/ServicePresentationTests.swift index a3c1f2b..3bae9eb 100644 --- a/tests/PointerLockerTests/ServicePresentationTests.swift +++ b/tests/PointerLockerTests/ServicePresentationTests.swift @@ -3,8 +3,8 @@ import XCTest @testable import PointerLocker final class ServicePresentationTests: XCTestCase { - func testOnboardingOffersGeForceNowAndFigmaButNotTheGenericProfile() { - XCTAssertEqual(ServiceProfile.selectable.map(\.id), ["geforcenow", "figma"]) + func testOnboardingOffersFigmaThenGeForceNowButNotTheGenericProfile() { + XCTAssertEqual(ServiceProfile.selectable.map(\.id), ["figma", "geforcenow"]) } func testEverySelectableServiceCanBePresented() { @@ -27,6 +27,10 @@ final class ServicePresentationTests: XCTestCase { XCTAssertEqual(tip?.title, "Set the stream to 1920×1080") } + func testFigmaLeadsWithDesktopNavigation() { + XCTAssertEqual(ServiceProfile.figma.setupTips.first?.id, "navigate") + } + func testFigmaTellsYouHowToScrub() { let tip = ServiceProfile.figma.setupTips.first { $0.id == "scrub" } XCTAssertEqual(tip?.title, "Drag a number's label to change it") diff --git a/tests/PointerLockerTests/WelcomeLayoutTests.swift b/tests/PointerLockerTests/WelcomeLayoutTests.swift new file mode 100644 index 0000000..70f8083 --- /dev/null +++ b/tests/PointerLockerTests/WelcomeLayoutTests.swift @@ -0,0 +1,18 @@ +import SwiftUI +import XCTest +@testable import PointerLocker + +final class WelcomeLayoutTests: XCTestCase { + func testCardsSitSideBySideOnAFullIPadAtLargeTextSizes() { + XCTAssertFalse(WelcomeLayout.stacksCards(dynamicTypeSize: .large, horizontalSizeClass: .regular)) + XCTAssertFalse(WelcomeLayout.stacksCards(dynamicTypeSize: .xxxLarge, horizontalSizeClass: .regular)) + } + + func testCardsStackAtAccessibilityTextSizes() { + XCTAssertTrue(WelcomeLayout.stacksCards(dynamicTypeSize: .accessibility1, horizontalSizeClass: .regular)) + } + + func testCardsStackInANarrowWindow() { + XCTAssertTrue(WelcomeLayout.stacksCards(dynamicTypeSize: .large, horizontalSizeClass: .compact)) + } +}